Compare commits

..

12 Commits

Author SHA1 Message Date
c8484357cb Fix QC Kak Ayu 15 Des
Fix QC Kak Inno 15 Des
Fix UI User Font Size, Font Weight, Line Height
Fix UI Admin Font Size, Font Weight, Line Height & UI Mobile
2025-12-16 16:37:17 +08:00
342e9bbc65 Fix QC Kak Ayu Tgl 12
Fix QC Kak Ino Tgl 12
Fix UI Mobile Menu Keamanan
Fix UI Mobile Admin Menu Landing Page
2025-12-16 10:19:15 +08:00
f6f77d9e35 Fix QC Kak Inno Tgl 11 Des
Fix QC Kak Ayu Tgl 11 Des
Fix font style {font size, color, line height} menu kesehatan
2025-12-12 17:06:33 +08:00
a00481152c Fix Konsisten teks di tampilan mobile dan desktop
Fix QC Kak Inno tgl 10 Des
Fix QC Kak Ayu tgl 10 Des
2025-12-11 17:58:03 +08:00
242ea86f77 Fix konsisten font, menu landing page & PPID 2025-12-10 17:44:31 +08:00
99c2c9c6d7 Fix semua tulisan profile jadi profil, mulai dari navbar, dan route 2025-12-10 14:16:15 +08:00
ac2fc1a705 Fix QC Kak Inno 8 Des
Fix QC Kak Ayu 8 Des
Fix QC Pak Jun 8 Des
2025-12-09 17:27:23 +08:00
9dbe172165 Fix QC Kak Inno Tgl 4 & 5 Desember
Fix QC Kak Ayu Tgl 4 & 5 Desember
Fix QC Pak Jun Tgl 5 Desember
2025-12-09 12:00:27 +08:00
cc318d4d54 Fix QC Kak Inno Tgl 4 & 5 Desember
Fix QC Kak Ayu Tgl 4 & 5 Desember
Fix QC Pak Jun Tgl 5 Desember
2025-12-09 10:28:17 +08:00
dcb8017594 Fix undefined ke detail berita terbaru 2025-12-05 17:42:04 +08:00
ec3ad12531 Fix Notifikasi saat ada berita atau pengumuman baru, notifikasi baru muncul. Ga setiap masuk landing page ada notifikasi 2025-12-05 14:30:53 +08:00
dad44c0537 Fix Menu Gallery : Gallery Foto
Fix detail berita
2025-12-05 10:56:03 +08:00
213 changed files with 9967 additions and 5955 deletions

View File

@@ -1,14 +1,15 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
/* Mobile first */
'mantine-breakpoint-xs': '30em', // 480px → mobile kecilnormal
'mantine-breakpoint-sm': '48em', // 768px → tablet / mobile landscape
'mantine-breakpoint-md': '64em', // 1024px → laptop & desktop kecil
'mantine-breakpoint-lg': '80em', // 1280px → desktop standar
'mantine-breakpoint-xl': '90em', // 1440px+ → desktop besar
},
},
};
},
};

View File

@@ -828,11 +828,11 @@ model DokterdanTenagaMedis {
name String
specialist String
jadwal String
jadwalLibur String
jamBukaOperasional String
jamTutupOperasional String
jamBukaLibur String
jamTutupLibur String
jadwalLibur String?
jamBukaOperasional String?
jamTutupOperasional String?
jamBukaLibur String?
jamTutupLibur String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())

BIN
public/mangupuraaward.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

View File

@@ -6,145 +6,176 @@ import { z } from "zod";
const templateForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"),
nik: z.string().min(3, "NIK minimal 3 karakter"),
notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"),
nik: z
.string()
.min(3, "NIK minimal 3 karakter")
.max(16, "NIK maksimal 16 angka"),
notelp: z
.string()
.min(3, "Nomor Telepon minimal 3 karakter")
.max(15, "Nomor Telepon maksimal 15 angka"),
alamat: z.string().min(3, "Alamat minimal 3 karakter"),
email: z.string().min(3, "Email minimal 3 karakter"),
jenisInformasiDimintaId: z.string().nonempty(),
caraMemperolehInformasiId: z.string().nonempty(),
caraMemperolehSalinanInformasiId: z.string().nonempty(),
})
});
const jenisInformasiDiminta = proxy({
findMany: {
data: null as
| null
| Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[],
async load(){
const res = await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi["find-many"].get();
if (res.status === 200) {
jenisInformasiDiminta.findMany.data = res.data?.data ?? [];
}
}
}
})
findMany: {
data: null as
| null
| Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[],
async load() {
const res =
await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi[
"find-many"
].get();
if (res.status === 200) {
jenisInformasiDiminta.findMany.data = res.data?.data ?? [];
}
},
},
});
const caraMemperolehInformasi = proxy({
findMany: {
data: null as
| null
| Prisma.CaraMemperolehInformasiGetPayload<{ omit: { isActive: true } }>[],
async load() {
const res = await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi["find-many"].get();
if (res.status === 200) {
caraMemperolehInformasi.findMany.data = res.data?.data ?? [];
}
}
}
})
findMany: {
data: null as
| null
| Prisma.CaraMemperolehInformasiGetPayload<{
omit: { isActive: true };
}>[],
async load() {
const res =
await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi[
"find-many"
].get();
if (res.status === 200) {
caraMemperolehInformasi.findMany.data = res.data?.data ?? [];
}
},
},
});
const caraMemperolehSalinanInformasi = proxy({
findMany: {
data: null as
| null
| Prisma.CaraMemperolehSalinanInformasiGetPayload<{ omit: { isActive: true } }>[],
async load() {
const res = await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi["find-many"].get();
if (res.status === 200) {
caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? [];
}
}
}
})
console.log(caraMemperolehSalinanInformasi)
findMany: {
data: null as
| null
| Prisma.CaraMemperolehSalinanInformasiGetPayload<{
omit: { isActive: true };
}>[],
async load() {
const res =
await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi[
"find-many"
].get();
if (res.status === 200) {
caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? [];
}
},
},
});
console.log(caraMemperolehSalinanInformasi);
type PermohonanInformasiPublikForm = Prisma.PermohonanInformasiPublikGetPayload<{
type PermohonanInformasiPublikForm =
Prisma.PermohonanInformasiPublikGetPayload<{
select: {
name: true;
nik: true;
notelp: true;
alamat: true;
email: true;
jenisInformasiDimintaId: true;
caraMemperolehInformasiId: true;
caraMemperolehSalinanInformasiId: true;
name: true;
nik: true;
notelp: true;
alamat: true;
email: true;
jenisInformasiDimintaId: true;
caraMemperolehInformasiId: true;
caraMemperolehSalinanInformasiId: true;
};
}>;
}>;
const statepermohonanInformasiPublik = proxy({
create: {
form: {} as PermohonanInformasiPublikForm,
loading: false,
async create(){
const cek = templateForm.safeParse(statepermohonanInformasiPublik.create.form);
if(!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
statepermohonanInformasiPublik.create.loading = true;
const res = await ApiFetch.api.ppid.permohonaninformasipublik["create"].post(statepermohonanInformasiPublik.create.form);
if (res.status === 200) {
statepermohonanInformasiPublik.findMany.load();
return toast.success("Sukses menambahkan");
}
return toast.error("failed create");
} catch (error) {
console.log((error as Error).message);
} finally {
statepermohonanInformasiPublik.create.loading = false;
}
create: {
form: {} as PermohonanInformasiPublikForm,
loading: false,
async create() {
const cek = templateForm.safeParse(
statepermohonanInformasiPublik.create.form
);
if (!cek.success) {
toast.error(cek.error.issues.map((i) => i.message).join("\n"));
return false; // ⬅️ tambahkan return false
}
try {
statepermohonanInformasiPublik.create.loading = true;
const res = await ApiFetch.api.ppid.permohonaninformasipublik[
"create"
].post(statepermohonanInformasiPublik.create.form);
if (res.data?.success === false) {
toast.error(res.data?.message);
return false; // ⬅️ gagal
}
toast.success("Sukses menambahkan");
return true; // ⬅️ sukses
} catch {
toast.error("Terjadi kesalahan server");
return false;
} finally {
statepermohonanInformasiPublik.create.loading = false;
}
},
findMany: {
data: null as
| Prisma.PermohonanInformasiPublikGetPayload<{ include: {
caraMemperolehSalinanInformasi: true,
jenisInformasiDiminta: true,
caraMemperolehInformasi: true,
} }>[]
| null,
async load() {
const res = await ApiFetch.api.ppid.permohonaninformasipublik["find-many"].get();
if (res.status === 200) {
statepermohonanInformasiPublik.findMany.data = res.data?.data ?? [];
}
}
},
findUnique: {
data: null as Prisma.PermohonanInformasiPublikGetPayload<{
},
findMany: {
data: null as
| Prisma.PermohonanInformasiPublikGetPayload<{
include: {
jenisInformasiDiminta: true,
caraMemperolehInformasi: true,
caraMemperolehSalinanInformasi: true,
caraMemperolehSalinanInformasi: true;
jenisInformasiDiminta: true;
caraMemperolehInformasi: true;
};
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`);
if (res.ok) {
const data = await res.json();
statepermohonanInformasiPublik.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch program inovasi:", res.statusText);
statepermohonanInformasiPublik.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching program inovasi:", error);
statepermohonanInformasiPublik.findUnique.data = null;
}
},
},
})
}>[]
| null,
async load() {
const res = await ApiFetch.api.ppid.permohonaninformasipublik[
"find-many"
].get();
if (res.status === 200) {
statepermohonanInformasiPublik.findMany.data = res.data?.data ?? [];
}
},
},
findUnique: {
data: null as Prisma.PermohonanInformasiPublikGetPayload<{
include: {
jenisInformasiDiminta: true;
caraMemperolehInformasi: true;
caraMemperolehSalinanInformasi: true;
};
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`);
if (res.ok) {
const data = await res.json();
statepermohonanInformasiPublik.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch program inovasi:", res.statusText);
statepermohonanInformasiPublik.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching program inovasi:", error);
statepermohonanInformasiPublik.findUnique.data = null;
}
},
},
});
const statepermohonanInformasiPublikForm = proxy({
statepermohonanInformasiPublik,
jenisInformasiDiminta,
caraMemperolehInformasi,
caraMemperolehSalinanInformasi,
})
statepermohonanInformasiPublik,
jenisInformasiDiminta,
caraMemperolehInformasi,
caraMemperolehSalinanInformasi,
});
export default statepermohonanInformasiPublikForm;

View File

@@ -5,82 +5,99 @@ import { proxy } from "valtio";
import { z } from "zod";
const templateForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"),
email: z.string().min(3, "Email minimal 3 karakter"),
notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"),
alasan: z.string().min(3, "Alasan minimal 3 karakter"),
})
name: z.string().min(3, "Nama minimal 3 karakter"),
email: z.string().min(3, "Email minimal 3 karakter"),
notelp: z
.string()
.min(3, "Nomor Telepon minimal 3 karakter")
.max(15, "Nomor Telepon maksimal 15 angka"),
alasan: z.string().min(3, "Alasan minimal 3 karakter"),
});
type PermohonanKeberatanInformasiForm = Prisma.FormulirPermohonanKeberatanGetPayload<{
type PermohonanKeberatanInformasiForm =
Prisma.FormulirPermohonanKeberatanGetPayload<{
select: {
name: true;
email: true;
notelp: true;
alasan: true;
name: true;
email: true;
notelp: true;
alasan: true;
};
}>;
}>;
const permohonanKeberatanInformasi = proxy({
create: {
form: {} as PermohonanKeberatanInformasiForm,
loading: false,
async create(){
const cek = templateForm.safeParse(permohonanKeberatanInformasi.create.form);
if(!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
permohonanKeberatanInformasi.create.loading = true;
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["create"].post(permohonanKeberatanInformasi.create.form);
if (res.status === 200) {
permohonanKeberatanInformasi.findMany.load();
return toast.success("Sukses menambahkan");
}
return toast.error("failed create");
} catch (error) {
console.log((error as Error).message);
} finally {
permohonanKeberatanInformasi.create.loading = false;
}
},
},
findMany: {
data: null as
| Prisma.FormulirPermohonanKeberatanGetPayload<{omit: {isActive: true}}>[]
| null,
async load() {
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["find-many"].get();
if (res.status === 200) {
permohonanKeberatanInformasi.findMany.data = res.data?.data ?? [];
}
}
},
findUnique: {
data: null as Prisma.FormulirPermohonanKeberatanGetPayload<{
omit: {
isActive: true;
};
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/ppid/permohonankeberataninformasipublik/${id}`);
if (res.ok) {
const data = await res.json();
permohonanKeberatanInformasi.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch permohonan keberatan informasi:", res.statusText);
permohonanKeberatanInformasi.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching permohonan keberatan informasi:", error);
permohonanKeberatanInformasi.findUnique.data = null;
}
},
create: {
form: {} as PermohonanKeberatanInformasiForm,
loading: false,
async create() {
const cek = templateForm.safeParse(
permohonanKeberatanInformasi.create.form
);
if (!cek.success) {
toast.error(cek.error.issues.map((i) => i.message).join("\n"));
return false; // ⬅️ tambahkan return false
}
try {
permohonanKeberatanInformasi.create.loading = true;
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[
"create"
].post(permohonanKeberatanInformasi.create.form);
if (res.data?.success === false) {
toast.error(res.data?.message);
return false; // ⬅️ gagal
}
toast.success("Sukses menambahkan");
return true; // ⬅️ sukses
} catch {
toast.error("Terjadi kesalahan server");
return false;
} finally {
permohonanKeberatanInformasi.create.loading = false;
}
},
},
findMany: {
data: null as
| Prisma.FormulirPermohonanKeberatanGetPayload<{
omit: { isActive: true };
}>[]
| null,
async load() {
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[
"find-many"
].get();
if (res.status === 200) {
permohonanKeberatanInformasi.findMany.data = res.data?.data ?? [];
}
},
},
findUnique: {
data: null as Prisma.FormulirPermohonanKeberatanGetPayload<{
omit: {
isActive: true;
};
}> | null,
async load(id: string) {
try {
const res = await fetch(
`/api/ppid/permohonankeberataninformasipublik/${id}`
);
if (res.ok) {
const data = await res.json();
permohonanKeberatanInformasi.findUnique.data = data.data ?? null;
} else {
console.error(
"Failed to fetch permohonan keberatan informasi:",
res.statusText
);
permohonanKeberatanInformasi.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching permohonan keberatan informasi:", error);
permohonanKeberatanInformasi.findUnique.data = null;
}
},
},
});
export default permohonanKeberatanInformasi;

View File

@@ -0,0 +1,303 @@
/* eslint-disable react-hooks/exhaustive-deps */
"use client";
import EditEditor from "@/app/admin/(dashboard)/_com/editEditor";
import stateGallery from "@/app/admin/(dashboard)/_state/desa/gallery";
import colors from "@/con/colors";
import ApiFetch from "@/lib/api-fetch";
import {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
Paper,
Stack,
Text,
TextInput,
Title
} from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import {
IconArrowBack,
IconPhoto,
IconUpload,
IconX,
} from "@tabler/icons-react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { useProxy } from "valtio/utils";
function EditFoto() {
const FotoState = useProxy(stateGallery.foto);
const router = useRouter();
const params = useParams();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [formData, setFormData] = useState({
name: "",
deskripsi: "",
imagesId: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
deskripsi: "",
imagesId: "",
imageUrl: "",
});
// Load kategori + Foto
useEffect(() => {
FotoState.findMany.load();
const loadFoto = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await FotoState.update.load(id);
if (data) {
setFormData({
name: data.name || "",
deskripsi: data.deskripsi || "",
imagesId: data.imagesId || "",
});
setOriginalData({
name: data.name || "",
deskripsi: data.deskripsi || "",
imagesId: data.imagesId || "",
imageUrl: data.imageGalleryFoto?.link || ""
});
if (data?.imageGalleryFoto?.link) {
setPreviewImage(data.imageGalleryFoto.link);
}
}
} catch (error) {
console.error("Error loading Foto:", error);
toast.error("Gagal memuat data Foto");
}
};
loadFoto();
}, [params?.id]);
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// Update global state hanya sekali di sini
FotoState.update.form = {
...FotoState.update.form,
...formData,
};
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error("Gagal upload gambar");
}
FotoState.update.form.imagesId = uploaded.id;
}
await FotoState.update.update();
toast.success("Foto berhasil diperbarui!");
router.push("/admin/desa/gallery/foto");
} catch (error) {
console.error("Error updating foto:", error);
toast.error("Terjadi kesalahan saat memperbarui foto");
} finally {
setIsSubmitting(false);
}
};
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
imagesId: originalData.imagesId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
return (
<Box px={{ base: "sm", md: "lg" }} py="md">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors["blue-button"]} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Foto
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: "100%", md: "50%" }}
bg={colors["white-1"]}
p="lg"
radius="md"
shadow="sm"
style={{ border: "1px solid #e0e0e0" }}
>
<Stack gap="md">
<TextInput
label="Judul Foto"
placeholder="Masukkan judul foto"
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
required
/>
{/* Upload Gambar */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Foto
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() =>
toast.error("File tidak valid, gunakan format gambar")
}
maxSize={5 * 1024 ** 2}
accept={{ "image/*": [] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload
size={48}
color={colors["blue-button"]}
stroke={1.5}
/>
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 220,
objectFit: "contain",
border: `1px solid ${colors["blue-button"]}`,
}}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Deskripsi */}
<Box>
<Text fz="sm" fw="bold">
Deskripsi Foto
</Text>
<EditEditor
value={formData.deskripsi}
onChange={(htmlContent) =>
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }))
}
/>
</Box>
{/* Action */}
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default EditFoto;

View File

@@ -0,0 +1,175 @@
'use client';
import { Box, Button, Group, Paper, Skeleton, Stack, Text, Alert } from '@mantine/core';
import Image from 'next/image';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash, IconPhoto } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import colors from '@/con/colors';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
function DetailFoto() {
const FotoState = useProxy(stateGallery.foto);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [imageError, setImageError] = useState(false);
const params = useParams();
const router = useRouter();
useShallowEffect(() => {
FotoState.findUnique.load(params?.id as string);
}, []);
const handleHapus = () => {
if (selectedId) {
FotoState.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push("/admin/desa/gallery/foto");
}
};
if (!FotoState.findUnique.data) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
const data = FotoState.findUnique.data;
const imageUrl = data.imageGalleryFoto?.link;
return (
<Box py={10}>
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
mb={15}
>
Kembali
</Button>
<Paper
withBorder
// Gunakan max-width agar tidak terlalu lebar di desktop
maw={800}
w="100%"
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz={{ base: 'xl', md: '2xl' }} fw="bold" c={colors['blue-button']}>
Detail Foto
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">Judul Foto</Text>
<Text fz="md" c="dimmed">{data.name || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Deskripsi</Text>
<Text
fz="md"
c="dimmed"
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
/>
</Box>
<Box>
<Text fz="lg" fw="bold">Gambar</Text>
{imageUrl ? (
<Box
pos="relative"
style={{
width: '100%',
maxWidth: '600px', // Set a maximum width
margin: '0 auto', // Center the container
aspectRatio: '16/9', // Use 16:9 aspect ratio
borderRadius: 8,
overflow: 'hidden',
position: 'relative'
}}
>
<Image
src={imageUrl}
alt={data.name || 'Gambar Foto'}
fill
style={{
objectFit: 'contain', // Changed from 'cover' to 'contain' to show full image
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0
}}
loading="lazy"
onError={() => setImageError(true)}
/>
</Box>
) : imageError ? (
<Alert
color="orange"
icon={<IconPhoto size={16} />}
title="Gagal memuat gambar"
radius="md"
>
Gambar tidak dapat ditampilkan.
</Alert>
) : (
<Text fz="sm" c="dimmed">Tidak ada gambar</Text>
)}
</Box>
{/* Action Buttons */}
<Group gap="sm" justify="flex-start">
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
variant="light"
radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
<Button
color="green"
onClick={() => router.push(`/admin/desa/gallery/foto/${data.id}/edit`)}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Paper>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah Anda yakin ingin menghapus foto ini?"
/>
</Box>
);
}
export default DetailFoto;

View File

@@ -0,0 +1,228 @@
'use client';
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Title,
Loader,
Image
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function CreateFoto() {
const FotoState = useProxy(stateGallery.foto);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const resetForm = () => {
FotoState.create.form = {
name: '',
deskripsi: '',
imagesId: '',
};
setPreviewImage(null);
setFile(null);
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
if (!file) {
return toast.warn('Silakan pilih file gambar terlebih dahulu');
}
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
}
FotoState.create.form.imagesId = uploaded.id;
await FotoState.create.create();
resetForm();
router.push('/admin/desa/gallery/foto');
} catch (error) {
console.error('Error creating foto:', error);
toast.error('Terjadi kesalahan saat membuat foto');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header Back Button + Title */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Foto
</Title>
</Group>
{/* Card Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
{/* Judul */}
<TextInput
label="Judul Foto"
placeholder="Masukkan judul Foto"
value={FotoState.create.form.name}
onChange={(e) => {
FotoState.create.form.name = e.currentTarget.value;
}}
required
/>
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Berita
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB)
</Text>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Deskripsi */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Deskripsi Foto
</Text>
<CreateEditor
value={FotoState.create.form.deskripsi}
onChange={(val) => {
FotoState.create.form.deskripsi = val;
}}
/>
</Box>
{/* Button Submit */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateFoto;

View File

@@ -1,157 +1,163 @@
"use client";
import stateFileStorage from "@/state/state-list-image";
'use client'
import colors from '@/con/colors';
import {
ActionIcon,
Box,
Card,
Flex,
Button,
Center,
Group,
Image,
Pagination,
Paper,
SimpleGrid,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
TextInput,
Title
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { IconSearch, IconTrash, IconX } from "@tabler/icons-react";
import { motion } from "framer-motion";
import toast from "react-simple-toasts";
import { useSnapshot } from "valtio";
export default function ListImage() {
const { list, total } = useSnapshot(stateFileStorage);
useShallowEffect(() => {
stateFileStorage.load();
}, []);
let timeOut: NodeJS.Timer;
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import stateGallery from '../../../_state/desa/gallery';
function Foto() {
const [search, setSearch] = useState("");
return (
<Stack p="lg" gap="lg">
<Flex justify="space-between" align="center" wrap="wrap" gap="md">
<Title order={2} fw={700}>
Galeri Foto
</Title>
<TextInput
radius="xl"
size="md"
placeholder="Cari foto berdasarkan nama..."
leftSection={<IconSearch size={18} />}
rightSection={
<ActionIcon
variant="light"
color="gray"
radius="xl"
onClick={() => stateFileStorage.load()}
>
<IconX size={18} />
</ActionIcon>
}
onChange={(e) => {
if (timeOut) clearTimeout(timeOut);
timeOut = setTimeout(() => {
stateFileStorage.load({ search: e.target.value });
}, 300);
}}
/>
</Flex>
<Paper withBorder radius="lg" p="md" shadow="sm">
{list && list.length > 0 ? (
<SimpleGrid
cols={{ base: 2, sm: 3, md: 5, lg: 8 }}
spacing="md"
verticalSpacing="md"
>
{list.map((v, k) => (
<Card
key={k}
withBorder
radius="md"
shadow="sm"
className="hover:shadow-md transition-all duration-200"
>
<Stack gap="xs">
<motion.div
onClick={() => {
navigator.clipboard.writeText(v.url);
toast("Tautan foto berhasil disalin");
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
style={{ cursor: "pointer" }}
>
<Image
src={`${v.url}?size=200`}
alt={v.name}
radius="md"
h={120}
fit="cover"
loading="lazy"
/>
</motion.div>
<Box>
<Text size="sm" fw={500} lineClamp={2}>
{v.name}
</Text>
</Box>
<Group justify="space-between" align="center" pt="xs">
<ActionIcon
variant="subtle"
color="red"
radius="md"
onClick={() => {
stateFileStorage
.del({ id: v.id })
.finally(() => toast("Foto berhasil dihapus"));
}}
>
<IconTrash size={18} />
</ActionIcon>
</Group>
</Stack>
</Card>
))}
</SimpleGrid>
) : (
<Stack align="center" justify="center" py="xl" gap="sm">
<Image
src="https://cdn-icons-png.flaticon.com/512/4076/4076549.png"
alt="Kosong"
w={120}
h={120}
fit="contain"
opacity={0.7}
loading="lazy"
/>
<Text c="dimmed" ta="center">
Belum ada foto yang tersedia
</Text>
</Stack>
)}
</Paper>
{total && total > 1 && (
<Flex justify="center">
<Pagination
total={total}
value={stateFileStorage.page} // Changed from page to value
size="md"
radius="md"
withEdges
onChange={(page) => {
stateFileStorage.load({ page });
}}
/>
</Flex>
)}
</Stack>
<Box>
<HeaderSearch
title='Foto'
placeholder='Cari judul atau deskripsi foto...'
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListFoto search={search} />
</Box>
);
}
function ListFoto({ search }: { search: string }) {
const FotoState = useProxy(stateGallery.foto)
const router = useRouter();
const {
data,
page,
totalPages,
loading,
load,
} = FotoState.findMany;
useShallowEffect(() => {
load(page, 10, search)
}, [page, search])
const filteredData = data || []
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
)
}
return (
<Box py={10}>
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Foto</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/gallery/foto/create')}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh style={{ width: '25%' }}>Judul Foto</TableTh>
<TableTh style={{ width: '20%' }}>Tanggal</TableTh>
<TableTh style={{ width: '30%' }}>Deskripsi</TableTh>
<TableTh style={{ width: '15%' }}>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd style={{ width: '25%' }}>
<Box w={200}>
<Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text>
</Box>
</TableTd>
<TableTd style={{ width: '20%' }}>
<Box w={200}>
<Text fz="sm" c="dimmed">
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
</Box>
</TableTd>
<TableTd style={{ width: '30%' }}>
<Box w={200}>
<Text fz="sm" truncate="end" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Box>
</TableTd>
<TableTd style={{ width: '15%' }}>
<Button
variant="light"
color="blue"
onClick={() => router.push(`/admin/desa/gallery/foto/${item.id}`)}
>
<IconDeviceImac size={20} />
<Text ml={5}>Detail</Text>
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text c="dimmed">Tidak ada foto yang cocok</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10)
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
</Box>
);
}
export default Foto;

View File

@@ -11,21 +11,21 @@ function LayoutTabsDetail({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const tabs = [
{
label: "Profile Desa",
value: "profiledesa",
href: "/admin/desa/profile/profile-desa",
label: "Profil Desa",
value: "profildesa",
href: "/admin/desa/profil/profil-desa",
icon: <IconUser size={18} stroke={1.8} />
},
{
label: "Profile Perbekel",
value: "profileperbekel",
href: "/admin/desa/profile/profile-perbekel",
label: "Profil Perbekel",
value: "profilperbekel",
href: "/admin/desa/profil/profil-perbekel",
icon: <IconUsers size={18} stroke={1.8} />
},
{
label: "Profile Perbekel Dari Masa Ke Masa",
value: "profile-perbekel-dari-masa-ke-masa",
href: "/admin/desa/profile/profile-perbekel-dari-masa-ke-masa",
label: "Profil Perbekel Dari Masa Ke Masa",
value: "profilperbekeldarimasakemasa",
href: "/admin/desa/profil/profil-perbekel-dari-masa-ke-masa",
icon: <IconCalendar size={18} stroke={1.8} />
}
];

View File

@@ -12,22 +12,22 @@ function LayoutTabsEdit({ children }: { children: React.ReactNode }) {
{
label: "Sejarah Desa",
value: "sejarahdesa",
href: "/admin/desa/profile/edit/sejarah_desa"
href: "/admin/desa/profil/edit/sejarah_desa"
},
{
label: "Visi Misi Desa",
value: "visimisidesa",
href: "/admin/desa/profile/edit/visi_misi_desa"
href: "/admin/desa/profil/edit/visi_misi_desa"
},
{
label: "Lambang Desa",
value: "lambangdesa",
href: "/admin/desa/profile/edit/lambang_desa"
href: "/admin/desa/profil/edit/lambang_desa"
},
{
label: "Maskot Desa",
value: "maskotdesa",
href: "/admin/desa/profile/edit/maskot_desa"
href: "/admin/desa/profil/edit/maskot_desa"
},
];
const curentTab = tabs.find(tab => tab.href === pathname)

View File

@@ -43,7 +43,7 @@ function Page() {
const id = params?.id as string;
if (!id) {
toast.error('ID tidak valid');
router.push('/admin/desa/profile/profile-desa');
router.push('/admin/desa/profil/profil-desa');
return;
}
@@ -106,7 +106,7 @@ function Page() {
if (success) {
toast.success('Data berhasil disimpan');
router.push('/admin/desa/profile/profile-desa');
router.push('/admin/desa/profil/profil-desa');
} else {
toast.error('Gagal menyimpan data');
}
@@ -156,7 +156,7 @@ function Page() {
<Alert icon={<IconAlertCircle size={20} />} color="red" title="Terjadi Kesalahan" radius="md">
{loadError}
</Alert>
<Button onClick={() => router.push('/admin/desa/profile/profile-desa')} variant="outline">
<Button onClick={() => router.push('/admin/desa/profil/profil-desa')} variant="outline">
Kembali ke Halaman Utama
</Button>
</Stack>

View File

@@ -40,7 +40,7 @@ function Page() {
const id = params?.id as string;
if (!id) {
toast.error("ID tidak valid");
router.push("/admin/desa/profile/profile-desa");
router.push("/admin/desa/profil/profil-desa");
return;
}
@@ -157,7 +157,7 @@ function Page() {
if (success) {
toast.success("Maskot berhasil diperbarui!");
router.push("/admin/desa/profile/profile-desa");
router.push("/admin/desa/profil/profil-desa");
}
} catch (error) {
console.error("Error update maskot:", error);

View File

@@ -50,7 +50,7 @@ function Page() {
const id = params?.id as string;
if (!id) {
toast.error('ID tidak valid');
router.push('/admin/desa/profile/profile-desa');
router.push('/admin/desa/profil/profil-desa');
return;
}
@@ -122,7 +122,7 @@ function Page() {
if (success) {
toast.success('Data berhasil disimpan');
router.push('/admin/desa/profile/profile-desa');
router.push('/admin/desa/profil/profil-desa');
} else {
toast.error('Gagal menyimpan data');
}
@@ -179,7 +179,7 @@ function Page() {
{loadError}
</Alert>
<Button
onClick={() => router.push('/admin/desa/profile/profile-desa')}
onClick={() => router.push('/admin/desa/profil/profil-desa')}
variant="outline"
>
Kembali ke Halaman Utama

View File

@@ -42,7 +42,7 @@ function Page() {
const id = params?.id as string;
if (!id) {
toast.error('ID tidak valid');
router.push('/admin/desa/profile/profile-desa');
router.push('/admin/desa/profil/profil-desa');
return;
}
@@ -106,7 +106,7 @@ function Page() {
if (success) {
toast.success('Data berhasil disimpan');
router.push('/admin/desa/profile/profile-desa');
router.push('/admin/desa/profil/profil-desa');
} else {
toast.error('Gagal menyimpan data');
}
@@ -156,7 +156,7 @@ function Page() {
{loadError}
</Alert>
<Button
onClick={() => router.push('/admin/desa/profile/profile-desa')}
onClick={() => router.push('/admin/desa/profil/profil-desa')}
variant="outline"
>
Kembali ke Halaman Utama

View File

@@ -27,7 +27,7 @@ function Page() {
return (
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
<Stack gap="lg">
<Title order={2} c={colors['blue-button']}>Preview Profile Desa</Title>
<Title order={2} c={colors['blue-button']}>Preview Profil Desa</Title>
{/* Sejarah Desa */}
{sejarah && (
@@ -42,7 +42,7 @@ function Page() {
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profile/profile-desa/${sejarah.id}/sejarah_desa`)}
onClick={() => router.push(`/admin/desa/profil/profil-desa/${sejarah.id}/sejarah_desa`)}
>
Edit
</Button>
@@ -87,7 +87,7 @@ function Page() {
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profile/profile-desa/${visiMisi.id}/visi_misi_desa`)}
onClick={() => router.push(`/admin/desa/profil/profil-desa/${visiMisi.id}/visi_misi_desa`)}
>
Edit
</Button>
@@ -135,7 +135,7 @@ function Page() {
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profile/profile-desa/${lambang.id}/lambang_desa`)}
onClick={() => router.push(`/admin/desa/profil/profil-desa/${lambang.id}/lambang_desa`)}
>
Edit
</Button>
@@ -180,7 +180,7 @@ function Page() {
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profile/profile-desa/${maskot.id}/maskot_desa`)}
onClick={() => router.push(`/admin/desa/profil/profil-desa/${maskot.id}/maskot_desa`)}
>
Edit
</Button>

View File

@@ -117,7 +117,7 @@ function EditPerbekelDariMasaKeMasa() {
await state.update.update();
toast.success('Perbekel dari masa ke masa berhasil diperbarui!');
router.push('/admin/desa/profile/profile-perbekel-dari-masa-ke-masa');
router.push('/admin/desa/profil/profil-perbekel-dari-masa-ke-masa');
} catch (error) {
console.error('Error updating perbekel dari masa ke masa:', error);
toast.error('Terjadi kesalahan saat memperbarui perbekel dari masa ke masa');

View File

@@ -25,7 +25,7 @@ function DetailPerbekelDariMasa() {
state.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push("/admin/desa/profile/profile-perbekel-dari-masa-ke-masa");
router.push("/admin/desa/profil/profil-perbekel-dari-masa-ke-masa");
}
};
@@ -113,7 +113,7 @@ function DetailPerbekelDariMasa() {
<Button
color="green"
onClick={() => router.push(`/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/${data.id}/edit`)}
onClick={() => router.push(`/admin/desa/profil/profil-perbekel-dari-masa-ke-masa/${data.id}/edit`)}
variant="light"
radius="md"
size="md"

View File

@@ -46,7 +46,7 @@ function CreatePerbekelDariMasaKeMasa() {
state.create.form.imageId = uploaded.id;
await state.create.create();
resetForm();
router.push('/admin/desa/profile/profile-perbekel-dari-masa-ke-masa');
router.push('/admin/desa/profil/profil-perbekel-dari-masa-ke-masa');
} catch (error) {
console.error(error);
toast.error('Gagal menambahkan perbekel dari masa ke masa');

View File

@@ -53,7 +53,7 @@ function ListPerbekelDariMasaKeMasa({ search }: { search: string }) {
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/create')}
onClick={() => router.push('/admin/desa/profil/profil-perbekel-dari-masa-ke-masa/create')}
>
Tambah Baru
</Button>
@@ -90,7 +90,7 @@ function ListPerbekelDariMasaKeMasa({ search }: { search: string }) {
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/${item.id}`)}
onClick={() => router.push(`/admin/desa/profil/profil-perbekel-dari-masa-ke-masa/${item.id}`)}
>
Detail
</Button>

View File

@@ -25,7 +25,7 @@ function ProfilePerbekel() {
const id = params?.id as string;
if (!id) {
toast.error("ID tidak valid");
router.push("/admin/desa/profile/profile-perbekel");
router.push("/admin/desa/profil/profil-perbekel");
return;
}
@@ -74,7 +74,7 @@ function ProfilePerbekel() {
const success = await perbekelState.edit.submit()
if (success) {
toast.success("Data berhasil disimpan");
router.push("/admin/desa/profile/profile-perbekel");
router.push("/admin/desa/profil/profil-perbekel");
}
} catch (error) {
console.error("Error update sejarah desa:", error);

View File

@@ -41,7 +41,7 @@ function Page() {
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profile/profile-perbekel/${perbekel.id}`)}
onClick={() => router.push(`/admin/desa/profil/profil-perbekel/${perbekel.id}`)}
>
Edit
</Button>

View File

@@ -44,18 +44,56 @@ function CreatePolsekTerdekat() {
};
};
const isValidGoogleMapsEmbed = (url: string): boolean => {
try {
const u = new URL(url);
return (
u.hostname === 'www.google.com' &&
u.pathname === '/maps/embed' &&
u.searchParams.has('pb')
);
} catch {
return false;
}
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
await polsekState.create.create();
resetForm();
router.push("/admin/keamanan/polsek-terdekat");
} catch (error) {
console.error(error)
toast.error("Gagal menambah polsek terdekat");
} finally {
setIsSubmitting(false);
const { embedMapUrl } = polsekState.create.form;
// ✅ Validasi Google Maps Embed URL (jika diisi)
if (embedMapUrl && !isValidGoogleMapsEmbed(embedMapUrl)) {
toast.error("URL embed peta tidak valid. Harap paste iframe dari Google Maps.");
return;
}
try {
setIsSubmitting(true);
await polsekState.create.create();
resetForm();
router.push("/admin/keamanan/polsek-terdekat");
} catch (error) {
console.error(error);
toast.error("Gagal menambah polsek terdekat");
} finally {
setIsSubmitting(false);
}
};
const extractEmbedUrl = (input: string): string => {
// Jika sudah berupa URL embed yang valid
if (input.startsWith('https://www.google.com/maps/embed?')) {
return input.trim();
}
// Coba parse sebagai HTML string (iframe)
const iframeRegex = /<iframe[^>]*src=["']([^"']*)["'][^>]*>/i;
const match = input.match(iframeRegex);
if (match && match[1]?.startsWith('https://www.google.com/maps/embed?')) {
return match[1].trim();
}
// Jika tidak cocok, kembalikan input asli (atau string kosong)
return input.trim();
};
const fetchLayanan = async () => {
@@ -190,9 +228,14 @@ function CreatePolsekTerdekat() {
/>
<TextInput
value={polsekState.create.form.embedMapUrl}
onChange={(val) => (polsekState.create.form.embedMapUrl = val.target.value)}
onChange={(e) => {
const rawValue = e.currentTarget.value;
const cleanUrl = extractEmbedUrl(rawValue);
polsekState.create.form.embedMapUrl = cleanUrl;
}}
description="Contoh: https://www.google.com/maps/embed?pb=..."
label={<Text fw="bold" fz="sm">Embed Map URL</Text>}
placeholder="Masukkan embed map url"
placeholder="Paste iframe dari Google Maps atau URL embed langsung"
/>
<TextInput
value={polsekState.create.form.namaTempatMaps}

View File

@@ -20,9 +20,9 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
icon: <IconActivity size={18} stroke={1.8} />
},
{
label: "Grafik Hasil Kepuasan Masyarakat",
value: "grafikhasilkepuasan",
href: "/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan",
label: "Penderita Penyakit",
value: "penderitapenyakit",
href: "/admin/kesehatan/data-kesehatan-warga/penderita_penyakit",
icon: <IconGauge size={18} stroke={1.8} />
},
{

View File

@@ -70,8 +70,8 @@ function EditGrafikHasilKepuasan() {
});
}
} catch (err) {
console.error("Error loading grafik hasil kepuasan:", err);
toast.error("Gagal memuat data grafik hasil kepuasan");
console.error("Error loading penderita penyakit:", err);
toast.error("Gagal memuat data penderita penyakit");
}
};
@@ -99,11 +99,11 @@ function EditGrafikHasilKepuasan() {
setIsSubmitting(true);
editState.update.form = { ...editState.update.form, ...formData };
await editState.update.submit();
toast.success('Grafik hasil kepuasan berhasil diperbarui!');
router.push('/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan');
toast.success('penderita penyakit berhasil diperbarui!');
router.push('/admin/kesehatan/data-kesehatan-warga/penderita_penyakit');
} catch (err) {
console.error('Error updating grafik hasil kepuasan:', err);
toast.error('Terjadi kesalahan saat memperbarui grafik hasil kepuasan');
console.error('Error updating penderita penyakit:', err);
toast.error('Terjadi kesalahan saat memperbarui penderita penyakit');
} finally {
setIsSubmitting(false);
}
@@ -122,7 +122,7 @@ function EditGrafikHasilKepuasan() {
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Grafik Hasil Kepuasan
Edit Penderita Penyakit
</Title>
</Group>

View File

@@ -26,7 +26,7 @@ function DetailGrafikHasilKepuasan() {
state.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push("/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan");
router.push("/admin/kesehatan/data-kesehatan-warga/penderita_penyakit");
}
};
@@ -63,7 +63,7 @@ function DetailGrafikHasilKepuasan() {
>
<Stack gap="md">
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Data Grafik Hasil Kepuasan
Detail Data Penderita Penyakit
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
@@ -118,7 +118,7 @@ function DetailGrafikHasilKepuasan() {
color="green"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan/${data.id}/edit`
`/admin/kesehatan/data-kesehatan-warga/penderita_penyakit/${data.id}/edit`
)
}
variant="light"

View File

@@ -40,7 +40,7 @@ function CreateGrafikHasilKepuasanMasyarakat() {
setIsSubmitting(true);
await stateGrafikKepuasan.create.create();
resetForm();
router.push("/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan");
router.push("/admin/kesehatan/data-kesehatan-warga/penderita_penyakit");
} catch (error) {
console.error("Error creating grafik kepuasan:", error);
toast.error("Terjadi kesalahan saat membuat grafik kepuasan");
@@ -62,7 +62,7 @@ function CreateGrafikHasilKepuasanMasyarakat() {
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Grafik Hasil Kepuasan Masyarakat
Tambah Penderita Penyakit
</Title>
</Group>

View File

@@ -36,7 +36,7 @@ function GrafikHasilKepuasanMasyarakat() {
<Box>
{/* Header Search */}
<HeaderSearch
title='Grafik Hasil Kepuasan Masyarakat'
title='Penderita Penyakit'
placeholder='Cari nama atau alamat...'
searchIcon={<IconSearch size={20} />}
value={search}
@@ -115,14 +115,14 @@ function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
{/* Judul + Tombol Tambah */}
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Grafik Hasil Kepuasan Masyarakat</Title>
<Title order={4}>Daftar Penderita Penyakit</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push(
'/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan/create'
'/admin/kesehatan/data-kesehatan-warga/penderita_penyakit/create'
)
}
>
@@ -176,7 +176,7 @@ function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
color="blue"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan/${item.id}`
`/admin/kesehatan/data-kesehatan-warga/penderita_penyakit/${item.id}`
)
}
>
@@ -221,7 +221,7 @@ function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
{/* Chart */}
<Box mt="lg" style={{ width: '100%', minWidth: 300, height: 420, minHeight: 300 }}>
<Paper withBorder bg={colors['white-1']} p={'md'}>
<Title pb={10} order={4}>Grafik Hasil Kepuasan Masyarakat</Title>
<Title pb={10} order={4}>Penderita Penyakit</Title>
{mounted && diseaseChartData.length > 0 ? (
<Center>
<BarChart

View File

@@ -123,7 +123,7 @@ export default function EditKolaborasiInovasi() {
};
return (
<Box px={{ base: "sm", md: "lg" }} py="md">
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors["blue-button"]} size={24} />

View File

@@ -42,7 +42,7 @@ function DetailSDGSDesa() {
const data = sdgsState.findUnique.data;
return (
<Box py={10}>
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Button
variant="subtle"
onClick={() => router.back()}
@@ -54,7 +54,7 @@ function DetailSDGSDesa() {
<Paper
withBorder
w={{ base: '100%', md: '60%' }}
w={{ base: '100%', md: '70%' }}
bg={colors['white-1']}
p="lg"
radius="md"

View File

@@ -65,7 +65,7 @@ function CreateSDGsDesa() {
}
}
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />

View File

@@ -9,7 +9,6 @@ import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
import sdgsDesa from '../../_state/landing-page/sdgs-desa';
function SdgsDesa() {
const [search, setSearch] = useState('');
return (
@@ -27,7 +26,7 @@ function SdgsDesa() {
}
function ListSdgsDesa({ search }: { search: string }) {
const listState = useProxy(sdgsDesa)
const listState = useProxy(sdgsDesa);
const router = useRouter();
const {
@@ -39,10 +38,10 @@ function ListSdgsDesa({ search }: { search: string }) {
} = listState.findMany;
useShallowEffect(() => {
load(page, 10, search)
}, [page, search])
load(page, 10, search);
}, [page, search]);
const filteredData = data || []
const filteredData = data || [];
// Handle loading state
if (loading || !data) {
@@ -53,79 +52,71 @@ function ListSdgsDesa({ search }: { search: string }) {
);
}
if (data.length === 0) {
return (
<Box py={10}>
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Sdgs Desa</Title>
<Button
leftSection={<IconPlus size={18} />}
color={colors['blue-button']}
variant="light"
onClick={() => router.push('/admin/landing-page/SDGs/create')}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover striped verticalSpacing="sm">
<TableThead>
<TableTr>
<TableTh style={{ width: '60%' }}>Nama Sdgs Desa</TableTh>
<TableTh style={{ width: '20%' }}>Jumlah</TableTh>
<TableTh style={{ width: '20%', textAlign: 'center' }}>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
<TableTr>
<TableTd colSpan={3} style={{ textAlign: 'center', padding: '2rem' }}>
<Text c="dimmed">Tidak ada data Sdgs Desa</Text>
</TableTd>
</TableTr>
</TableTbody>
</Table>
</Box>
</Paper>
</Box>
);
}
const isEmpty = data.length === 0;
return (
<Box py={10}>
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Sdgs Desa</Title>
<Button leftSection={<IconPlus size={18} />} color="blue" variant="light"
onClick={() => router.push('/admin/landing-page/SDGs/create')}
>
Tambah Baru
</Button>
<Box py={{ base: 'sm', md: 'lg' }}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
<Title order={2} lh={1.2}>
Daftar Sdgs Desa
</Title>
<Button
leftSection={<IconPlus size={18} />}
color={colors['blue-button']}
variant="light"
onClick={() => router.push('/admin/landing-page/SDGs/create')}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover>
{/* Desktop Table */}
<Box visibleFrom="md">
<Table highlightOnHover striped verticalSpacing="sm">
<TableThead>
<TableTr>
<TableTh style={{ width: '60%' }}>Nama Sdgs Desa</TableTh>
<TableTh style={{ width: '20%' }}>Jumlah</TableTh>
<TableTh style={{ width: '20%', textAlign: 'center' }}>Aksi</TableTh>
<TableTh style={{ width: '60%' }}>
<Text fz="sm" fw={600} c="dark.7" ta="left">
Nama Sdgs Desa
</Text>
</TableTh>
<TableTh style={{ width: '20%' }}>
<Text fz="sm" fw={600} c="dark.7" ta="left">
Jumlah
</Text>
</TableTh>
<TableTh style={{ width: '20%' }}>
<Text fz="sm" fw={600} c="dark.7" ta="center">
Aksi
</Text>
</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd style={{ width: '60%' }}>
<Text fw={500} truncate="end" lineClamp={1}>
{item.name}
{isEmpty ? (
<TableTr>
<TableTd colSpan={3} ta="center" py="xl">
<Text c="dimmed" fz="sm" lh={1.5}>
Tidak ada data Sdgs Desa
</Text>
</TableTd>
<TableTd style={{ width: '20%' }}>
<Text fz="sm" c="dimmed">
{item.jumlah || '0'}
</Text>
</TableTd>
<TableTd style={{ width: '20%', textAlign: 'center' }}>
<Button
</TableTr>
) : (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd style={{ width: '60%' }}>
<Text fz="md" fw={500} truncate="end" lineClamp={1} lh={1.5}>
{item.name}
</Text>
</TableTd>
<TableTd style={{ width: '20%' }}>
<Text fz="sm" c="dark.6" lh={1.5}>
{item.jumlah || '0'}
</Text>
</TableTd>
<TableTd style={{ width: '20%' }} ta="center">
<Button
size="xs"
radius="md"
variant="light"
@@ -135,27 +126,69 @@ function ListSdgsDesa({ search }: { search: string }) {
>
Detail
</Button>
</TableTd>
</TableTr>
))}
</TableTd>
</TableTr>
))
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Cards */}
<Box hiddenFrom="md">
{isEmpty ? (
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.5} ta="center">
Tidak ada data Sdgs Desa
</Text>
</Center>
) : (
<Stack gap="sm">
{filteredData.map((item) => (
<Paper key={item.id} withBorder p="md" radius="md">
<Stack gap={4}>
<Text fz="sm" fw={600} lh={1.4}>
{item.name}
</Text>
<Text fz="xs" c="dark.6" lh={1.4}>
Jumlah: {item.jumlah || '0'}
</Text>
<Group justify="flex-end" mt="xs">
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/admin/landing-page/SDGs/${item.id}`)}
>
Detail
</Button>
</Group>
</Stack>
</Paper>
))}
</Stack>
)}
</Box>
</Paper>
<Center mt="lg">
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10);
window.scrollTo(0, 0);
}}
total={Math.max(1, totalPages)}
withEdges
radius="md"
/>
</Center>
{!isEmpty && (
<Center mt={{ base: 'md', md: 'lg' }}>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10);
window.scrollTo(0, 0);
}}
total={Math.max(1, totalPages)}
withEdges
radius="md"
/>
</Center>
)}
</Box>
)
);
}
export default SdgsDesa;
export default SdgsDesa;

View File

@@ -204,7 +204,7 @@ function EditAPBDes() {
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
@@ -215,7 +215,7 @@ function EditAPBDes() {
</Group>
<Paper
w={{ base: '100%', md: '100%' }}
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"

View File

@@ -65,7 +65,7 @@ function DetailAPBDes() {
});
return (
<Box py={10}>
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Button
variant="subtle"
onClick={() => router.back()}
@@ -77,7 +77,7 @@ function DetailAPBDes() {
<Paper
withBorder
w={{ base: '100%', md: '100%' }}
w={{ base: '100%', md: '70%' }}
bg={colors['white-1']}
p="lg"
radius="md"

View File

@@ -155,7 +155,7 @@ function CreateAPBDes() {
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />

View File

@@ -56,71 +56,85 @@ function ListAPBDes({ search }: { search: string }) {
if (loading || !data) {
return (
<Stack py={10}>
<Stack py={{ base: 'md', md: 'lg' }}>
<Skeleton height={600} radius="md" />
</Stack>
);
}
return (
<Box py={10}>
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar APBDes</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/landing-page/apbdes/create')}
>
Tambah Baru
</Button>
</Group>
<Box py={{ base: 'md', md: 'lg' }}>
{/* Desktop Table */}
<Box visibleFrom="md">
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={2} size="lg" lh={1.2}>
Daftar APBDes
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/landing-page/apbdes/create')}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh style={{ width: '25%' }}>APBDes</TableTh>
<TableTh style={{ width: '25%' }}>Tahun</TableTh>
<TableTh style={{ width: '25%' }}>Dokumen</TableTh>
<TableTh style={{ width: '25%' }}>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd style={{ width: '25%' }}>
<Text fw={500} lineClamp={1}>
APBDes {item.tahun}
</Text>
</TableTd>
<TableTd style={{ width: '25%' }}>
<Text fw={500}>{item.tahun || '-'}</Text>
</TableTd>
<TableTd style={{ width: '25%' }}>
{item.file?.link ? (
<Button
component="a"
href={item.file.link}
target="_blank"
rel="noopener noreferrer"
variant="light"
leftSection={<IconFile size={16} />}
size="xs"
radius="sm"
>
Lihat Dokumen
</Button>
) : (
<Text c="dimmed" fz="sm">
Tidak ada dokumen
<Box>
<Table highlightOnHover miw={0}>
<TableThead>
<TableTr>
<TableTh fz="md" fw={600} ta="left" w="25%">
APBDes
</TableTh>
<TableTh fz="md" fw={600} ta="left" w="25%">
Tahun
</TableTh>
<TableTh fz="md" fw={600} ta="left" w="25%">
Dokumen
</TableTh>
<TableTh fz="md" fw={600} ta="left" w="25%">
Aksi
</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fz="md" fw={500} lh={1.5} lineClamp={1}>
APBDes {item.tahun}
</Text>
)}
</TableTd>
<TableTd style={{ width: '25%' }}>
<Box w={100}>
</TableTd>
<TableTd>
<Text fz="md" fw={500} lh={1.5}>
{item.tahun || '-'}
</Text>
</TableTd>
<TableTd>
{item.file?.link ? (
<Button
component="a"
href={item.file.link}
target="_blank"
rel="noopener noreferrer"
variant="light"
leftSection={<IconFile size={16} />}
size="xs"
radius="sm"
fz="sm"
>
Lihat Dokumen
</Button>
) : (
<Text c="dimmed" fz="sm" lh={1.5}>
Tidak ada dokumen
</Text>
)}
</TableTd>
<TableTd>
<Button
size="xs"
radius="md"
@@ -128,29 +142,126 @@ function ListAPBDes({ search }: { search: string }) {
color="blue"
leftSection={<IconDeviceImacCog size={14} />}
onClick={() => router.push(`/admin/landing-page/apbdes/${item.id}`)}
fullWidth
fz="sm"
>
Detail
</Button>
</Box>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py="lg">
<Text c="dimmed" fz="sm" lh={1.5}>
Tidak ada data APBDes yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={5}>
<Center py={20}>
<Text color="dimmed">Tidak ada data APBDes yang cocok</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
</Paper>
)}
</TableTbody>
</Table>
</Box>
</Paper>
</Box>
<Center mt="md">
{/* Mobile Cards */}
<Box hiddenFrom="md">
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={2} size="lg" lh={1.2}>
Daftar APBDes
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/landing-page/apbdes/create')}
>
Tambah Baru
</Button>
</Group>
<Stack gap="md">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper
key={item.id}
withBorder
bg={colors['white-1']}
p="md"
shadow="sm"
radius="md"
>
<Stack gap="xs">
<Text fz="sm" fw={600} lh={1.4}>
APBDes {item.tahun}
</Text>
<Group justify="space-between" wrap="nowrap">
<Text fz="sm" c="dimmed" lh={1.4}>
Tahun
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.tahun || '-'}
</Text>
</Group>
<Group justify="space-between" wrap="nowrap">
<Text fz="sm" c="dimmed" lh={1.4}>
Dokumen
</Text>
{item.file?.link ? (
<Button
component="a"
href={item.file.link}
target="_blank"
rel="noopener noreferrer"
variant="light"
leftSection={<IconFile size={14} />}
size="xs"
radius="sm"
fz="xs"
lh={1.4}
>
Lihat
</Button>
) : (
<Text fz="xs" c="dimmed" lh={1.4}>
Tidak ada
</Text>
)}
</Group>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={14} />}
onClick={() => router.push(`/admin/landing-page/apbdes/${item.id}`)}
mt="sm"
fz="xs"
lh={1.4}
>
Detail
</Button>
</Stack>
</Paper>
))
) : (
<Paper withBorder bg={colors['white-1']} p="md" radius="md">
<Center py="lg">
<Text c="dimmed" fz="xs" lh={1.4}>
Tidak ada data APBDes yang cocok
</Text>
</Center>
</Paper>
)}
</Stack>
</Paper>
</Box>
<Center mt="xl">
<Pagination
value={page}
onChange={(newPage) => {

View File

@@ -3,6 +3,7 @@
import colors from "@/con/colors";
import {
Box,
ScrollArea,
Stack,
Tabs,
@@ -68,37 +69,76 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
keepMounted={false}
>
{/* ✅ Scroll horizontal wrapper */}
<ScrollArea type="auto" offsetScrollbars>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
flexShrink: 0, // ✅ mencegah tab mengecil
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
<Box visibleFrom='md' pb={10}>
<ScrollArea type="auto" offsetScrollbars>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
</Box>
<Box hiddenFrom='md' pb={10}>
<ScrollArea
type="auto"
offsetScrollbars={false}
w="100%"
>
<TabsList
p="xs" // lebih kecil
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
width: "max-content", // ⬅️ kunci
maxWidth: "100%", // ⬅️ penting
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
paddingInline: "0.75rem", // ⬅️ lebih ramping
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
</Box>
{tabs.map((tab, i) => (
<TabsPanel
key={i}

View File

@@ -82,7 +82,7 @@ export default function EditKategoriDesaAntiKorupsi() {
// 🧩 UI
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />

View File

@@ -43,7 +43,7 @@ export default function CreateKategoriDesaAntiKorupsi() {
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />

View File

@@ -1,6 +1,23 @@
'use client'
import colors from '@/con/colors';
import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
@@ -10,9 +27,8 @@ import HeaderSearch from '../../../_com/header';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import korupsiState from '../../../_state/landing-page/desa-anti-korupsi';
function KategoriDesaAntiKorupsi() {
const [search, setSearch] = useState("")
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
@@ -28,126 +44,188 @@ function KategoriDesaAntiKorupsi() {
}
function ListKategoriKegiatan({ search }: { search: string }) {
const stateKategori = useProxy(korupsiState.kategoriDesaAntiKorupsi)
const [modalHapus, setModalHapus] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const router = useRouter()
const stateKategori = useProxy(korupsiState.kategoriDesaAntiKorupsi);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const router = useRouter();
const {
data,
page,
totalPages,
loading,
load,
} = stateKategori.findMany;
const { data, page, totalPages, loading, load } = stateKategori.findMany;
const handleHapus = () => {
if (selectedId) {
stateKategori.delete.byId(selectedId)
setModalHapus(false)
setSelectedId(null)
stateKategori.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
}
}
};
useShallowEffect(() => {
load(page, 10, search)
}, [page, search])
load(page, 10, search);
}, [page, search]);
const filteredData = data || []
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py={10}>
<Stack py="xl">
<Skeleton height={600} radius="md" />
</Stack>
);
}
return (
<Box py={10}>
<Paper bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Kategori Kegiatan</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/landing-page/desa-anti-korupsi/kategori-desa-anti-korupsi/create')}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover striped verticalSpacing="sm">
<TableThead>
<TableTr>
<TableTh>Nama Kategori</TableTh>
<TableTh>Edit</TableTh>
<TableTh>Hapus</TableTh>
// Mobile cards
const renderMobileCards = () => (
<Stack gap="md">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} p="md" withBorder>
<Group justify="space-between" align="flex-start">
<Box flex={1}>
<Text fw={500} fz={{ base: 'sm', md: 'md' }} lh={1.45} lineClamp={2}>
{item.name}
</Text>
</Box>
<Group gap="xs" wrap="nowrap">
<Button
variant="light"
color="green"
size="xs"
onClick={() => router.push(`/admin/landing-page/desa-anti-korupsi/kategori-desa-anti-korupsi/${item.id}`)}
>
<IconEdit size={16} />
</Button>
<Button
variant="light"
color="red"
size="xs"
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={16} />
</Button>
</Group>
</Group>
</Paper>
))
) : (
<Paper p="xl" ta="center">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori yang ditemukan
</Text>
</Paper>
)}
</Stack>
);
// Desktop table
const renderDesktopTable = () => (
<Box>
<Table highlightOnHover striped verticalSpacing="sm" miw={300}>
<TableThead>
<TableTr>
<TableTh>
<Text fw={600} fz="sm" c="dimmed">
Nama Kategori
</Text>
</TableTh>
<TableTh>
<Text fw={600} fz="sm" c="dimmed">
Edit
</Text>
</TableTh>
<TableTh>
<Text fw={600} fz="sm" c="dimmed">
Hapus
</Text>
</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fw={500} fz="md" lh={1.45} lineClamp={1}>
{item.name}
</Text>
</TableTd>
<TableTd w={60}>
<Button
variant="light"
color="green"
size="sm"
onClick={() => router.push(`/admin/landing-page/desa-anti-korupsi/kategori-desa-anti-korupsi/${item.id}`)}
>
<IconEdit size={18} />
</Button>
</TableTd>
<TableTd w={60}>
<Button
variant="light"
color="red"
size="sm"
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={18} />
</Button>
</TableTd>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Box w={200}>
<Text fw={500} lineClamp={1}>{item.name}</Text>
</Box>
</TableTd>
<TableTd>
<Button
variant="light"
color="green"
size="sm"
onClick={() => router.push(`/admin/landing-page/desa-anti-korupsi/kategori-desa-anti-korupsi/${item.id}`)}
>
<IconEdit size={18} />
</Button>
</TableTd>
<TableTd>
<Button
variant="light"
color="red"
size="sm"
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={18} />
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={2}>
<Center py={20}>
<Text c="dimmed">Tidak ada data kategori yang ditemukan</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
))
) : (
<TableTr>
<TableTd colSpan={3} ta="center" py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori yang ditemukan
</Text>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
);
return (
<Box py={{ base: 'xl', md: 'xl' }}>
<Paper bg={colors['white-1']} p={{ base: 'md', md: 'xl' }} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'md', md: 'lg' }}>
<Title order={2} lh={1.2}>
Daftar Kategori Kegiatan
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/landing-page/desa-anti-korupsi/kategori-desa-anti-korupsi/create')}
>
Tambah Baru
</Button>
</Group>
<Box visibleFrom="md">{renderDesktopTable()}</Box>
<Box hiddenFrom="md">{renderMobileCards()}</Box>
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
{/* Modal Konfirmasi Hapus */}
{totalPages > 1 && (
<Center mt="xl">
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
color="blue"
radius="md"
/>
</Center>
)}
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
@@ -158,4 +236,4 @@ function ListKategoriKegiatan({ search }: { search: string }) {
);
}
export default KategoriDesaAntiKorupsi
export default KategoriDesaAntiKorupsi;

View File

@@ -150,7 +150,7 @@ export default function EditDesaAntiKorupsi() {
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />

View File

@@ -42,7 +42,7 @@ export default function DetailKegiatanDesa() {
const data = detailState.findUnique.data;
return (
<Box py={10}>
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Button
variant="subtle"
onClick={() => router.back()}
@@ -53,7 +53,7 @@ export default function DetailKegiatanDesa() {
</Button>
<Paper
w={{ base: "100%", md: "50%" }}
w={{ base: "100%", md: "70%" }}
bg={colors['white-1']}
p="lg"
radius="md"

View File

@@ -85,7 +85,7 @@ export default function CreateDesaAntiKorupsi() {
}
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />

View File

@@ -38,7 +38,7 @@ function ListDesaAntiKorupsi({ search }: { search: string }) {
if (loading || !data) {
return (
<Stack py="md">
<Stack py={{ base: 'sm', md: 'md' }}>
<Skeleton height={650} radius="lg" />
</Stack>
);
@@ -46,11 +46,13 @@ function ListDesaAntiKorupsi({ search }: { search: string }) {
if (data.length === 0) {
return (
<Box py="md">
<Paper p="lg" radius="lg" shadow="md" withBorder>
<Box py={{ base: 'sm', md: 'md' }}>
<Paper p={{ base: 'md', md: 'lg' }} radius="lg" shadow="md" withBorder>
<Stack align="center" gap="sm">
<Title order={4}>Data Program Desa Anti Korupsi</Title>
<Text c="dimmed" ta="center">
<Title order={2} lh={1.2}>
Data Program Desa Anti Korupsi
</Title>
<Text c="dimmed" ta="center" fz={{ base: 'xs', md: 'sm' }} lh={1.5}>
Belum ada data program yang tersedia
</Text>
</Stack>
@@ -61,48 +63,56 @@ function ListDesaAntiKorupsi({ search }: { search: string }) {
return (
<Box>
<Stack gap="md">
<Paper p="lg" radius="lg" shadow="md" withBorder>
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Program Desa Anti Korupsi</Title>
<Button leftSection={<IconPlus size={18} />} color="blue" variant="light"
onClick={() => router.push('/admin/landing-page/desa-anti-korupsi/list-desa-anti-korupsi/create')}
>
Tambah Baru
</Button>
<Stack gap={'md'}>
<Paper p={{ base: 'md', md: 'lg' }} radius="lg" shadow="md" withBorder>
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
<Title order={2} lh={1.2}>
Daftar Program Desa Anti Korupsi
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push('/admin/landing-page/desa-anti-korupsi/list-desa-anti-korupsi/create')
}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: 'auto' }}>
{/* Desktop Table */}
<Box visibleFrom="md">
<Table
striped
highlightOnHover
withRowBorders
verticalSpacing="sm"
>
<TableThead>
<TableTr>
<TableTh style={{ width: '50%' }}>Nama Program</TableTh>
<TableTh style={{ width: '30%' }}>Kategori</TableTh>
<TableTh style={{ width: '20%', textAlign: 'center' }}>Aksi</TableTh>
<TableTh w="50%">Nama Program</TableTh>
<TableTh w="30%">Kategori</TableTh>
<TableTh w="20%" ta="center">
Aksi
</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd style={{ width: '50%' }}>
<Text fw={500} lineClamp={1}>
<TableTd w="50%">
<Text fw={500} lineClamp={1} fz="md" lh={1.5}>
{item.name || '-'}
</Text>
</TableTd>
<TableTd style={{ width: '30%' }}>
<Box w={200}>
<Text fz="sm" c="dimmed" lineClamp={1}>
<TableTd w="30%">
<Text fz="sm" c="dimmed" lineClamp={1} lh={1.5}>
{item.kategori?.name || '-'}
</Text>
</Box>
</TableTd>
<TableTd style={{ width: '20%', textAlign: 'center' }}>
<TableTd w="20%" ta="center">
<Button
size="xs"
radius="md"
@@ -123,7 +133,7 @@ function ListDesaAntiKorupsi({ search }: { search: string }) {
) : (
<TableTr>
<TableTd colSpan={3}>
<Text ta="center" c="dimmed">
<Text ta="center" c="dimmed" fz="sm" lh={1.5}>
Tidak ditemukan data dengan kata kunci pencarian
</Text>
</TableTd>
@@ -132,6 +142,48 @@ function ListDesaAntiKorupsi({ search }: { search: string }) {
</TableTbody>
</Table>
</Box>
{/* Mobile Cards */}
<Box hiddenFrom="md">
<Stack gap="xs">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} p="sm" radius="md" withBorder shadow="xs">
<Stack gap="xs">
<Text fw={500} fz="sm" lh={1.5} lineClamp={1}>
{item.name || '-'}
</Text>
<Text fz="xs" c="dimmed" lh={1.5} lineClamp={1}>
Kategori: {item.kategori?.name || '-'}
</Text>
<Group justify="flex-end">
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(
`/admin/landing-page/desa-anti-korupsi/list-desa-anti-korupsi/${item.id}`
)
}
>
Detail
</Button>
</Group>
</Stack>
</Paper>
))
) : (
<Paper p="sm" radius="md" withBorder>
<Text ta="center" c="dimmed" fz="xs" lh={1.5}>
Tidak ditemukan data dengan kata kunci pencarian
</Text>
</Paper>
)}
</Stack>
</Box>
</Paper>
<Center>
@@ -144,7 +196,6 @@ function ListDesaAntiKorupsi({ search }: { search: string }) {
}}
size="md"
radius="md"
mt="md"
/>
</Center>
</Stack>
@@ -152,4 +203,4 @@ function ListDesaAntiKorupsi({ search }: { search: string }) {
);
}
export default DesaAntiKorupsi;
export default DesaAntiKorupsi;

View File

@@ -1,7 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { Box, ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconChartBar, IconUsers } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
@@ -53,36 +53,41 @@ function LayoutTabsKepuasan({ children }: { children: React.ReactNode }) {
radius="lg"
keepMounted={false}
>
{/* ✅ Scroll horizontal wrapper */}
<ScrollArea type="auto" offsetScrollbars>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}}
>
{tabs.map((e, i) => (
<TabsTab
key={i}
value={e.value}
leftSection={e.icon}
style={{
fontWeight: 500,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{e.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
<Box>
<ScrollArea type="auto" offsetScrollbars>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
</Box>
{tabs.map((e, i) => (
<TabsPanel key={i} value={e.value}>
<></>

View File

@@ -149,7 +149,7 @@ function EditResponden() {
);
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />

View File

@@ -38,7 +38,7 @@ export default function DetailResponden() {
)
}
return (
<Box py={10}>
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Button
variant="subtle"
onClick={() => router.back()}
@@ -50,7 +50,7 @@ export default function DetailResponden() {
<Paper
withBorder
w={{ base: "100%", md: "60%" }}
w={{ base: "100%", md: "70%" }}
bg={colors['white-1']}
p="lg"
radius="md"

View File

@@ -60,7 +60,7 @@ function ListResponden({ search }: ListRespondenProps) {
if (loading || !data) {
return (
<Stack py="md">
<Stack py={{ base: 'md', md: 'lg' }}>
<Skeleton height={650} radius="lg" />
</Stack>
);
@@ -68,11 +68,13 @@ function ListResponden({ search }: ListRespondenProps) {
if (data.length === 0) {
return (
<Box py="md">
<Paper p="lg" radius="lg" shadow="md" withBorder>
<Box py={{ base: 'md', md: 'lg' }}>
<Paper p={{ base: 'md', md: 'lg' }} radius="lg" shadow="md" withBorder>
<Stack align="center" gap="sm">
<Title order={4}>Data Responden</Title>
<Text c="dimmed" ta="center">
<Title order={2} lh={1.2}>
Data Responden
</Title>
<Text c="dimmed" ta="center" fz={{ base: 'sm', md: 'md' }} lh={1.5}>
Belum ada data responden yang tersedia
</Text>
</Stack>
@@ -83,12 +85,13 @@ function ListResponden({ search }: ListRespondenProps) {
return (
<Box>
<Stack gap="md">
<Paper p="lg" radius="lg" shadow="md" withBorder>
<Title order={4} mb="sm">
Daftar Responden
</Title>
<Box style={{ overflowX: 'auto' }}>
<Stack gap={'lg'}>
{/* Desktop Table */}
<Box visibleFrom="md">
<Paper p="lg" radius="lg" shadow="md" withBorder>
<Title order={2} size="lg" mb="md" lh={1.2}>
Daftar Responden
</Title>
<Table
striped
highlightOnHover
@@ -97,18 +100,18 @@ function ListResponden({ search }: ListRespondenProps) {
>
<TableThead>
<TableTr>
<TableTh style={{ width: '5%' }}>No</TableTh>
<TableTh style={{ width: '25%' }}>Nama</TableTh>
<TableTh style={{ width: '20%' }}>Tanggal</TableTh>
<TableTh style={{ width: '20%' }}>Jenis Kelamin</TableTh>
<TableTh style={{ width: '15%' }}>Aksi</TableTh>
<TableTh fz="sm" fw={600} w={60}>No</TableTh>
<TableTh fz="sm" fw={600}>Nama</TableTh>
<TableTh fz="sm" fw={600}>Tanggal</TableTh>
<TableTh fz="sm" fw={600}>Jenis Kelamin</TableTh>
<TableTh fz="sm" fw={600} w={120}>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length === 0 ? (
<TableTr>
<TableTd colSpan={5}>
<Text ta="center" c="dimmed">
<Text ta="center" c="dimmed" fz="sm" lh={1.5}>
Tidak ditemukan data dengan kata kunci pencarian
</Text>
</TableTd>
@@ -116,24 +119,18 @@ function ListResponden({ search }: ListRespondenProps) {
) : (
filteredData.map((item, index) => (
<TableTr key={item.id}>
<TableTd>{index + 1}</TableTd>
<TableTd>{item.name}</TableTd>
<TableTd>
<Box w={150}>
<TableTd fz="md" lh={1.5}>{index + 1}</TableTd>
<TableTd fz="md" lh={1.5}>{item.name}</TableTd>
<TableTd fz="md" lh={1.5}>
{item.tanggal
? new Date(item.tanggal).toLocaleDateString('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric',
})
day: '2-digit',
month: 'long',
year: 'numeric',
})
: '-'}
</Box>
</TableTd>
<TableTd>
<Box w={100}>
{item.jenisKelamin.name}
</Box>
</TableTd>
<TableTd fz="md" lh={1.5}>{item.jenisKelamin.name}</TableTd>
<TableTd>
<Button
size="xs"
@@ -155,8 +152,64 @@ function ListResponden({ search }: ListRespondenProps) {
)}
</TableTbody>
</Table>
</Box>
</Paper>
</Paper>
</Box>
{/* Mobile Cards */}
<Box hiddenFrom="md">
<Stack gap="sm">
<Title order={2} size="md" lh={1.2} px="md">
Daftar Responden
</Title>
{filteredData.length === 0 ? (
<Paper p="md" radius="lg" shadow="sm" mx="md">
<Text ta="center" c="dimmed" fz="sm" lh={1.5}>
Tidak ditemukan data dengan kata kunci pencarian
</Text>
</Paper>
) : (
filteredData.map((item) => (
<Paper key={item.id} p="md" radius="lg" shadow="sm" mx="md">
<Stack gap={4}>
<Text fz="sm" c="dimmed" lh={1.4}>Nama</Text>
<Text fz="md" lh={1.5}>{item.name}</Text>
<Text fz="sm" c="dimmed" lh={1.4}>Tanggal</Text>
<Text fz="md" lh={1.5}>
{item.tanggal
? new Date(item.tanggal).toLocaleDateString('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric',
})
: '-'}
</Text>
<Text fz="sm" c="dimmed" lh={1.4}>Jenis Kelamin</Text>
<Text fz="md" lh={1.5}>{item.jenisKelamin.name}</Text>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImac size={16} />}
onClick={() =>
router.push(
`/admin/landing-page/indeks-kepuasan-masyarakat/responden/${item.id}`
)
}
mt="xs"
>
Detail
</Button>
</Stack>
</Paper>
))
)}
</Stack>
</Box>
<Center>
<Pagination
value={page}
@@ -167,7 +220,7 @@ function ListResponden({ search }: ListRespondenProps) {
}}
size="md"
radius="md"
mt="md"
mt={{ base: 'md', md: 'lg' }}
/>
</Center>
</Stack>
@@ -175,4 +228,4 @@ function ListResponden({ search }: ListRespondenProps) {
);
}
export default Responden;
export default Responden;

View File

@@ -56,6 +56,7 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
radius="lg"
keepMounted={false}
>
<ScrollArea type="auto" offsetScrollbars>
<TabsList
p="sm"
@@ -63,6 +64,10 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}}
>
{tabs.map((tab, i) => (
@@ -74,6 +79,7 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}}
>
{tab.label}

View File

@@ -78,7 +78,7 @@ function EditKategoriPrestasi() {
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />

View File

@@ -40,7 +40,7 @@ function CreateKategoriPrestasi() {
}
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />

View File

@@ -57,7 +57,7 @@ function ListKategoriPrestasi({ search }: { search: string }) {
if (loading || !data) {
return (
<Stack py={10}>
<Stack py="md">
<Skeleton h={500} />
</Stack>
)
@@ -65,28 +65,33 @@ function ListKategoriPrestasi({ search }: { search: string }) {
return (
<Box>
{/* DESKTOP: Table */}
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm" withBorder>
<Group justify="space-between" mb="md">
<Title order={4} c="dark">List Kategori Prestasi</Title>
<Button leftSection={<IconPlus size={18} />} color="blue" variant="light" onClick={() => router.push('/admin/landing-page/prestasi-desa/kategori-prestasi-desa/create')}>
Tambah Baru
</Button>
<Group justify="space-between" mb="xl">
<Title order={2} size="lg" lh={1.2}>List Kategori Prestasi</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/landing-page/prestasi-desa/kategori-prestasi-desa/create')}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: 'auto' }}>
<Box visibleFrom="md">
<Table verticalSpacing="sm" highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Nama Kategori</TableTh>
<TableTh style={{ width: '120px' }} ta={'center'}>Edit</TableTh>
<TableTh ta={'center'} style={{ width: '120px' }}>Delete</TableTh>
<TableTh><Text fz="sm" fw={600} c="dark">Nama Kategori</Text></TableTh>
<TableTh w={120} ta="center"><Text fz="sm" fw={600} c="dark">Edit</Text></TableTh>
<TableTh w={120} ta="center"><Text fz="sm" fw={600} c="dark">Delete</Text></TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length === 0 ? (
<TableTr>
<TableTd colSpan={2} style={{ textAlign: 'center' }}>
<Text py="md" c="dimmed">
<TableTd colSpan={3} ta="center">
<Text py="md" c="dimmed" fz="sm" lh={1.5}>
{search ? 'Tidak ada hasil yang cocok' : 'Belum ada data kategori'}
</Text>
</TableTd>
@@ -95,68 +100,130 @@ function ListKategoriPrestasi({ search }: { search: string }) {
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Box w={200}>
<Text truncate="end" fz={"sm"}>{item.name}</Text>
</Box>
<Text truncate="end" fz="md" lh={1.5} c="dark">
{item.name}
</Text>
</TableTd>
<TableTd style={{ textAlign: 'center', width: '120px' }}>
<Button
variant="light"
color="green"
size="sm"
onClick={() => router.push(`/admin/landing-page/prestasi-desa/kategori-prestasi-desa/${item.id}`)}
>
<IconEdit size={18} />
</Button>
<TableTd ta="center" w={120}>
<Button
variant="light"
color="green"
size="sm"
onClick={() => router.push(`/admin/landing-page/prestasi-desa/kategori-prestasi-desa/${item.id}`)}
>
<IconEdit size={16} />
</Button>
</TableTd>
<TableTd style={{ textAlign: 'center', width: '120px' }}>
<Button
variant="light"
color="red"
size="sm"
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={18} />
</Button>
<TableTd ta="center" w={120}>
<Button
variant="light"
color="red"
size="sm"
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={16} />
</Button>
</TableTd>
</TableTr>
))
)}
</TableTbody>
</Table>
{totalPages > 1 && (
<Center mt="xl">
<Pagination
value={page}
onChange={(newPage) => load(newPage)}
total={totalPages}
withEdges
size="sm"
styles={{
control: {
'&[data-active]': {
background: `${colors['blue-button']} !important`,
},
},
}}
/>
</Center>
)}
</Box>
{totalPages > 1 && (
<Center mt="lg">
<Pagination
value={page}
onChange={(newPage) => load(newPage)}
total={totalPages}
withEdges
size="sm"
styles={{
control: {
'&[data-active]': {
background: `${colors['blue-button']} !important`,
},
},
}}
/>
</Center>
)}
{/* MOBILE: Card */}
<Box hiddenFrom="md">
<Stack gap="md">
{filteredData.length === 0 ? (
<Paper p="lg" ta="center">
<Text c="dimmed" fz="sm" lh={1.5}>
{search ? 'Tidak ada hasil yang cocok' : 'Belum ada data kategori'}
</Text>
</Paper>
) : (
filteredData.map((item) => (
<Paper key={item.id} p="md" withBorder bg={colors['white-1']}>
<Stack gap="xs">
<Text fz="sm" lh={1.5} fw={600} c="dark">{item.name}</Text>
<Group justify="flex-end" gap="xs">
<Button
variant="light"
color="green"
size="xs"
onClick={() => router.push(`/admin/landing-page/prestasi-desa/kategori-prestasi-desa/${item.id}`)}
>
<IconEdit size={14} />
</Button>
<Button
variant="light"
color="red"
size="xs"
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={14} />
</Button>
</Group>
</Stack>
</Paper>
))
)}
{totalPages > 1 && (
<Center py="lg">
<Pagination
value={page}
onChange={(newPage) => load(newPage)}
total={totalPages}
withEdges
size="xs"
styles={{
control: {
'&[data-active]': {
background: `${colors['blue-button']} !important`,
},
},
}}
/>
</Center>
)}
</Stack>
</Box>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text='Apakah anda yakin ingin menghapus kategori prestasi ini?'
/>
</Paper>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text='Apakah anda yakin ingin menghapus kategori prestasi ini?'
/>
</Box >
</Box>
);
}
export default KategoriPrestasiDesa
export default KategoriPrestasiDesa

View File

@@ -128,7 +128,7 @@ export default function EditPrestasiDesa() {
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />

View File

@@ -41,7 +41,7 @@ function DetailPrestasiDesa() {
}
return (
<Box py={10}>
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Button
variant="subtle"
onClick={() => router.back()}
@@ -53,7 +53,7 @@ function DetailPrestasiDesa() {
<Paper
withBorder
w={{ base: "100%", md: "60%" }}
w={{ base: "100%", md: "70%" }}
bg={colors['white-1']}
p="lg"
radius="md"

View File

@@ -69,7 +69,7 @@ function CreatePrestasiDesa() {
}
}
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />

View File

@@ -1,7 +1,7 @@
'use client'
import colors from '@/con/colors';
import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { useMediaQuery, useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
@@ -28,6 +28,7 @@ function ListPrestasiDesa() {
function ListPrestasi({ search }: { search: string }) {
const listState = useProxy(prestasiState.prestasiDesa)
const router = useRouter();
const isMobile = useMediaQuery('(max-width: 768px)');
const {
data,
@@ -39,60 +40,65 @@ function ListPrestasi({ search }: { search: string }) {
useShallowEffect(() => {
load(page, 10, search);
}, [page, search]);
}, []);
const filteredData = data || []
if (loading || !data) {
return (
<Stack py={10}>
<Stack py={{ base: 'sm', md: 'md' }}>
<Skeleton height={600} radius="md" />
</Stack>
);
}
return (
<Box py={10}>
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Prestasi Desa</Title>
<Box py={{ base: 'sm', md: 'md' }}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
<Title order={2} size={isMobile ? 'md' : 'lg'} lh={1.2}>
Daftar Prestasi Desa
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/landing-page/prestasi-desa/list-prestasi-desa/create')}
size={isMobile ? 'xs' : 'sm'}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover>
{/* Desktop Table */}
<Box visibleFrom="md">
<Table highlightOnHover miw={0}>
<TableThead>
<TableTr>
<TableTh style={{ width: '25%' }}>Nama Prestasi</TableTh>
<TableTh style={{ width: '25%' }}>Deskripsi</TableTh>
<TableTh style={{ width: '25%' }}>Kategori</TableTh>
<TableTh style={{ width: '25%', textAlign: 'center' }}>Aksi</TableTh>
<TableTh w="25%">Nama Prestasi</TableTh>
<TableTh w="25%">Deskripsi</TableTh>
<TableTh w="25%">Kategori</TableTh>
<TableTh w="25%" ta="center">Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd style={{ width: '25%' }}>
<Box w={100}>
<Text truncate="end" fz={"sm"}>{item.name}</Text>
</Box>
<TableTd w="25%">
<Text truncate="end" fz="md" lh={1.5}>
{item.name}
</Text>
</TableTd>
<TableTd style={{ width: '25%' }}>
<Text lineClamp={1} fz="sm" c="dimmed" dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
<TableTd w="25%">
<Text lineClamp={1} fz="md" c="dimmed" lh={1.5} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</TableTd>
<TableTd style={{ width: '25%' }}>
<Box w={150}>
<Text truncate="end" fz={"sm"}>{item.kategori?.name || 'Tidak ada kategori'}</Text>
</Box>
<TableTd w="25%">
<Text truncate="end" fz="md" lh={1.5}>
{item.kategori?.name || 'Tidak ada kategori'}
</Text>
</TableTd>
<TableTd style={{ width: '25%', textAlign: 'center' }}>
<TableTd w="25%" ta="center">
<Button
size="xs"
radius="md"
@@ -108,23 +114,63 @@ function ListPrestasi({ search }: { search: string }) {
))
) : (
<TableTr>
<TableTd colSpan={4} style={{ textAlign: 'center' }}>
<Text c="dimmed" py="md">Tidak ada data prestasi</Text>
<TableTd colSpan={4} ta="center">
<Text c="dimmed" py="md" fz="sm" lh={1.4}>
Tidak ada data prestasi
</Text>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Cards */}
<Stack hiddenFrom="md" gap="xs">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="sm" radius="sm">
<Stack gap={4}>
<Text fz="sm" fw={600} lh={1.4}>
{item.name}
</Text>
<Text fz="xs" c="dimmed" lh={1.5} lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
<Text fz="xs" c="dimmed" lh={1.4}>
Kategori: {item.kategori?.name || 'Tidak ada kategori'}
</Text>
<Group justify="flex-end" mt="xs">
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={14} />}
onClick={() => router.push(`/admin/landing-page/prestasi-desa/list-prestasi-desa/${item.id}`)}
>
Detail
</Button>
</Group>
</Stack>
</Paper>
))
) : (
<Center py="md">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data prestasi
</Text>
</Center>
)}
</Stack>
</Paper>
{totalPages > 1 && (
<Center mt="lg">
<Center mt={{ base: 'md', md: 'lg' }}>
<Pagination
value={page}
onChange={load}
total={totalPages}
withEdges
size="sm"
size={isMobile ? 'xs' : 'sm'}
/>
</Center>
)}
@@ -132,4 +178,4 @@ function ListPrestasi({ search }: { search: string }) {
)
}
export default ListPrestasiDesa;
export default ListPrestasiDesa;

View File

@@ -2,6 +2,7 @@
'use client'
import colors from '@/con/colors';
import {
Box,
ScrollArea,
Stack,
Tabs,
@@ -74,36 +75,76 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
keepMounted={false}
>
{/* ✅ Scroll horizontal wrapper */}
<ScrollArea type="auto" offsetScrollbars>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}}
<Box visibleFrom='md' pb={10}>
<ScrollArea type="auto" offsetScrollbars>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
</Box>
<Box hiddenFrom='md' pb={10}>
<ScrollArea
type="auto"
offsetScrollbars={false}
w="100%"
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
<TabsList
p="xs" // lebih kecil
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
width: "max-content", // ⬅️ kunci
maxWidth: "100%", // ⬅️ penting
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
paddingInline: "0.75rem", // ⬅️ lebih ramping
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
</Box>
{tabs.map((tab, i) => (
<TabsPanel

View File

@@ -177,7 +177,7 @@ function EditMediaSosial() {
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />

View File

@@ -50,7 +50,7 @@ function DetailMediaSosial() {
const data = stateMediaSosial.findUnique.data;
return (
<Box py={10}>
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Button
variant="subtle"
onClick={() => router.back()}
@@ -62,7 +62,7 @@ function DetailMediaSosial() {
<Paper
withBorder
w={{ base: "100%", md: "50%" }}
w={{ base: "100%", md: "70%" }}
bg={colors['white-1']}
p="lg"
radius="md"

View File

@@ -25,7 +25,6 @@ import { useProxy } from 'valtio/utils';
import profileLandingPageState from '../../../../_state/landing-page/profile';
import SelectSosialMedia from '@/app/admin/(dashboard)/_com/selectSocialMedia';
// ⭐ Tambah type SosmedKey
type SosmedKey =
| 'facebook'
@@ -88,7 +87,6 @@ export default function CreateMediaSosial() {
stateMediaSosial.create.form.imageId = null;
stateMediaSosial.create.form.icon = sosmedMap[selectedSosmed].src!;
await stateMediaSosial.create.create();
resetForm();
router.push('/admin/landing-page/profil/media-sosial');
@@ -129,13 +127,13 @@ export default function CreateMediaSosial() {
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header */}
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
<Title order={2} ml="sm" c="dark" lh={1.2} fz={{ base: 'md', md: 'lg' }}>
Tambah Media Sosial
</Title>
</Group>
@@ -155,7 +153,7 @@ export default function CreateMediaSosial() {
{/* Custom icon uploader */}
{selectedSosmed === 'custom' && (
<Box>
<Text fw="bold" fz="sm" mb={6}>
<Text fw="bold" fz={{ base: 'sm', md: 'md' }} lh={1.45} mb={6}>
Upload Custom Icon
</Text>
@@ -185,8 +183,10 @@ export default function CreateMediaSosial() {
</Dropzone.Idle>
<Stack align="center" gap="xs">
<Text fw={500}>Seret gambar atau klik untuk pilih</Text>
<Text size="sm" c="dimmed">
<Text fw={500} fz={{ base: 'sm', md: 'md' }} lh={1.45}>
Seret gambar atau klik untuk pilih
</Text>
<Text fz={{ base: 12, md: 'sm' }} c="dimmed" lh={1.4}>
Maksimal 5MB, format .png, .jpg, .jpeg, webp
</Text>
</Stack>
@@ -229,7 +229,11 @@ export default function CreateMediaSosial() {
{/* Input name */}
<TextInput
label="Nama Media Sosial"
label={
<Text fw={500} fz={{ base: 'sm', md: 'md' }} lh={1.45}>
Nama Media Sosial
</Text>
}
placeholder="Masukkan nama media sosial"
value={stateMediaSosial.create.form.name ?? ''}
onChange={(e) => (stateMediaSosial.create.form.name = e.target.value)}
@@ -238,7 +242,11 @@ export default function CreateMediaSosial() {
{/* Input link */}
<TextInput
label="Link / Kontak"
label={
<Text fw={500} fz={{ base: 'sm', md: 'md' }} lh={1.45}>
Link / Kontak
</Text>
}
placeholder="Masukkan link atau nomor"
value={stateMediaSosial.create.form.iconUrl ?? ''}
onChange={(e) => (stateMediaSosial.create.form.iconUrl = e.target.value)}
@@ -266,4 +274,4 @@ export default function CreateMediaSosial() {
</Paper>
</Box>
);
}
}

View File

@@ -1,7 +1,25 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import colors from '@/con/colors';
import { Box, Button, Center, Group, Image, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core';
import {
Box,
Button,
Center,
Group,
Image,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
@@ -28,11 +46,11 @@ function MediaSosial() {
}
function ListMediaSosial({ search }: { search: string }) {
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial)
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial);
const router = useRouter();
const getIconSource = (item: any) => {
if (item.image?.link) return item.image.link;
if (item.image?.link) return item.image.link;
if (item.icon && sosmedMap[item.icon as keyof typeof sosmedMap]?.src) {
return sosmedMap[item.icon as keyof typeof sosmedMap].src;
}
@@ -48,101 +66,201 @@ function ListMediaSosial({ search }: { search: string }) {
} = stateMediaSosial.findMany;
useShallowEffect(() => {
load(page, 10, search)
}, [page, search])
load(page, 10, search);
}, [page, search]);
const filteredData = data || []
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py={10}>
<Stack py={{ base: 'sm', sm: 'md' }}>
<Skeleton height={600} radius="md" />
</Stack>
);
}
return (
<Box py={10}>
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Media Sosial</Title>
<Button leftSection={<IconPlus size={18} />} color="blue" variant="light" onClick={() => router.push('/admin/landing-page/profil/media-sosial/create')}>
<Box py={{ base: 'sm', sm: 'md' }}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', sm: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'sm', sm: 'md' }}>
<Title order={4} lh={1.15}>
Daftar Media Sosial
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/landing-page/profil/media-sosial/create')}
fz={{ base: 'xs', sm: 'sm' }}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh style={{ width: '25%' }}>Nama Media Sosial / Kontak</TableTh>
<TableTh style={{ width: '20%' }}>Gambar</TableTh>
<TableTh style={{ width: '20%' }}>Link / No. Telepon</TableTh>
<TableTh style={{ width: '15%' }}>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd style={{ width: '25%', }}>
<Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text>
</TableTd>
<TableTd style={{ width: '20%' }}>
<Box w={50} h={50} style={{ borderRadius: 8, overflow: 'hidden' }}>
{(() => {
const src = getIconSource(item);
if (src) {
return (
<Image
loading="lazy"
src={src}
alt={item.name}
fit={item.image?.link ? "cover" : "contain"}
/>
);
}
return <Box bg={colors['blue-button']} w="100%" h="100%" />;
})()}
</Box>
</TableTd>
<TableTd style={{ width: '20%', }}>
<Box w={250}>
<Text truncate fz="sm" c="dimmed" lineClamp={1}>
{item.iconUrl || item.noTelp || '-'}
<Box>
{/* Desktop: Table | Mobile: Card-based vertical layout */}
<Box visibleFrom="md">
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh style={{ width: '25%' }}>
<Text fw={600} fz="md" lh={1.45}>
Nama Media Sosial / Kontak
</Text>
</TableTh>
<TableTh style={{ width: '20%' }}>
<Text fw={600} fz="md" lh={1.45}>
Gambar
</Text>
</TableTh>
<TableTh style={{ width: '20%' }}>
<Text fw={600} fz="md" lh={1.45}>
Link / No. Telepon
</Text>
</TableTh>
<TableTh style={{ width: '15%' }}>
<Text fw={600} fz="md" lh={1.45}>
Aksi
</Text>
</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd style={{ width: '25%' }}>
<Text fw={500} fz="md" lh={1.5} truncate="end" lineClamp={1}>
{item.name}
</Text>
</Box>
</TableTd>
<TableTd style={{ width: '15%' }}>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/admin/landing-page/profil/media-sosial/${item.id}`)}
>
Detail
</Button>
</TableTd>
<TableTd style={{ width: '20%' }}>
<Box w={50} h={50} style={{ borderRadius: 8, overflow: 'hidden' }}>
{(() => {
const src = getIconSource(item);
if (src) {
return (
<Image
loading="lazy"
src={src}
alt={item.name}
fit={item.image?.link ? 'cover' : 'contain'}
/>
);
}
return <Box bg={colors['blue-button']} w="100%" h="100%" />;
})()}
</Box>
</TableTd>
<TableTd style={{ width: '20%' }}>
<Box w={250}>
<Text truncate fz="sm" lh={1.5} c="dimmed" lineClamp={1}>
{item.iconUrl || item.noTelp || '-'}
</Text>
</Box>
</TableTd>
<TableTd style={{ width: '15%' }}>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/landing-page/profil/media-sosial/${item.id}`)
}
>
Detail
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text c="dimmed" fz="md" lh={1.5}>
Tidak ada data media sosial yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text color="dimmed">Tidak ada data media sosial yang cocok</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile layout */}
<Stack hiddenFrom="md" gap="xs">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="sm" radius="md">
<Group justify="space-between" wrap="nowrap" align='center'>
<Box>
<Text fw={600} fz="sm" lh={1.45}>
{item.name}
</Text>
</Box>
<Box w={40} h={40} style={{ borderRadius: 6, overflow: 'hidden' }}>
{(() => {
const src = getIconSource(item);
if (src) {
return (
<Image
loading="lazy"
src={src}
alt={item.name}
fit={item.image?.link ? 'cover' : 'contain'}
/>
);
}
return <Box bg={colors['blue-button']} w="100%" h="100%" />;
})()}
</Box>
</Group>
<Box>
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: 'underline' }}
>
<Text
fz="sm"
c="blue"
truncate
>
{item.iconUrl || item.noTelp || '-'}
</Text>
</a>
</Box>
<Group mt="sm" justify="flex-end">
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/landing-page/profil/media-sosial/${item.id}`)
}
>
Detail
</Button>
</Group>
</Paper>
))
) : (
<Center py={24}>
<Text c="dimmed" fz="sm" lh={1.5}>
Tidak ada data media sosial yang cocok
</Text>
</Center>
)}
</Stack>
</Box>
</Paper>
<Center>
<Pagination
value={page}
@@ -161,4 +279,4 @@ function ListMediaSosial({ search }: { search: string }) {
);
}
export default MediaSosial;
export default MediaSosial;

View File

@@ -178,7 +178,7 @@ function EditPejabatDesa() {
}
return (
<Box>
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Stack gap="xs">
<Group mb="md">
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">

View File

@@ -3,7 +3,6 @@ import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page
import colors from '@/con/colors';
import { Box, Button, Center, Divider, Grid, GridCol, Image, Paper, Skeleton, Stack, Text, Title } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconEdit } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils';
@@ -35,15 +34,15 @@ function Page() {
<Title order={3} c={colors['blue-button']}>Preview Pejabat Desa</Title>
</GridCol>
<GridCol span={{ base: 12, md: 1 }}>
<Button
c="green"
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/landing-page/profil/pejabat-desa/${allList.findUnique.data?.id}`)}
>
Edit
</Button>
<Button
style={{fontSize: 15, fontWeight: "bold"}}
c="green"
variant="light"
radius="md"
onClick={() => router.push(`/admin/landing-page/profil/pejabat-desa/${allList.findUnique.data?.id}`)}
>
Edit
</Button>
</GridCol>
</Grid>
{dataArray.map((item) => (
@@ -52,7 +51,7 @@ function Page() {
<Grid>
<GridCol span={12}>
<Center>
<Image src="/darmasaba-icon.png" w={{ base: 100, md: 150 }} alt="Logo Desa" loading="lazy"/>
<Image src="/darmasaba-icon.png" w={{ base: 100, md: 150 }} alt="Logo Desa" loading="lazy" />
</Center>
</GridCol>
<GridCol span={12}>
@@ -93,7 +92,7 @@ function Page() {
</Paper>
<Box mt="lg">
<Text fz={{ base: "1.125rem", md: "1.5rem" }} fw="bold" mb={4}>Jabatan</Text>
<Text fz={{ base: "1rem", md: "1.4rem" }} ta="justify" c={colors['blue-button']}>
<Text fz={{ base: "1rem", md: "1.4rem" }} ta="left" c={colors['blue-button']}>
{item.position}
</Text>
</Box>

View File

@@ -130,7 +130,7 @@ function EditProgramInovasi() {
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />

View File

@@ -40,13 +40,15 @@ function DetailProgramInovasi() {
const data = stateProgramInovasi.findUnique.data
return (
<Box px={{ base: 'md', md: 'xl' }} py="lg">
<Button variant="subtle" onClick={() => router.back()} leftSection={<IconArrowBack size={22} color={colors['blue-button']} />}>
Kembali
</Button>
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Box pb="20">
<Button variant="subtle" onClick={() => router.back()} leftSection={<IconArrowBack size={22} color={colors['blue-button']} />}>
Kembali
</Button>
</Box>
<Paper
w={{ base: "100%", md: "60%" }}
w={{ base: "100%", md: "70%" }}
bg={colors['white-1']}
p="lg"
radius="md"
@@ -68,9 +70,9 @@ function DetailProgramInovasi() {
<Box>
<Text fz="lg" fw="bold">Gambar</Text>
{data.image?.link ? (
<Image
src={data.image.link}
alt="Gambar Program"
<Image
src={data.image.link}
alt="Gambar Program"
radius="md"
style={{ maxWidth: '100%', maxHeight: 300, objectFit: 'contain' }}
loading="lazy"
@@ -106,28 +108,28 @@ function DetailProgramInovasi() {
</Box>
<Group gap="sm">
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
variant="light"
radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
variant="light"
radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
<Button
color="green"
onClick={() => router.push(`/admin/landing-page/profil/program-inovasi/${data.id}/edit`)}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
<Button
color="green"
onClick={() => router.push(`/admin/landing-page/profil/program-inovasi/${data.id}/edit`)}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
</Group>
</Stack>
</Paper>

View File

@@ -76,7 +76,7 @@ function CreateProgramInovasi() {
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />

View File

@@ -13,7 +13,7 @@ function ProgramInovasi() {
const [search, setSearch] = useState("");
return (
<Box px="md" py="lg">
<Box px={{base: 0, md: "md"}} py="lg">
<HeaderSearch
title="Program Inovasi"
placeholder="Cari program inovasi..."
@@ -52,75 +52,137 @@ function ListProgramInovasi({ search }: { search: string }) {
<Group justify='space-between'>
<Title order={4}>Daftar Program Inovasi</Title>
<Button
color="blue"
leftSection={<IconPlus size={18} />}
variant="light"
radius="md"
onClick={() => router.push('/admin/landing-page/profil/program-inovasi/create')}
>
Tambah Program
</Button>
color="blue"
leftSection={<IconPlus size={18} />}
variant="light"
radius="md"
onClick={() => router.push('/admin/landing-page/profil/program-inovasi/create')}
>
Tambah Program
</Button>
</Group>
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover striped verticalSpacing="sm">
<TableThead>
<TableTr>
<TableTh>Nama Program</TableTh>
<TableTh>Deskripsi</TableTh>
<TableTh>Link</TableTh>
<TableTh>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length === 0 ? (
<Box visibleFrom='md'>
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover striped verticalSpacing="sm">
<TableThead>
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text color="dimmed">Belum ada data program inovasi</Text>
</Center>
</TableTd>
<TableTh>Nama Program</TableTh>
<TableTh>Deskripsi</TableTh>
<TableTh>Link</TableTh>
<TableTh>Aksi</TableTh>
</TableTr>
) : (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fw={500}>{item.name}</Text>
</TableTd>
<TableTd style={{ maxWidth: 250 }}>
<Text fz="sm" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.description || '-' }}></Text>
</TableTd>
<TableTd style={{ maxWidth: 250 }}>
<Tooltip label="Buka tautan program" position="top" withArrow>
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
style={{ color: colors['blue-button'], textDecoration: 'underline' }}
>
<Text truncate fz="sm">{item.link}</Text>
</a>
</Tooltip>
</TableTd>
<TableTd>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/landing-page/profil/program-inovasi/${item.id}`)
}
>
Detail
</Button>
</TableThead>
<TableTbody>
{filteredData.length === 0 ? (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text color="dimmed">Belum ada data program inovasi</Text>
</Center>
</TableTd>
</TableTr>
))
)}
</TableTbody>
</Table>
) : (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fw={500}>{item.name}</Text>
</TableTd>
<TableTd style={{ maxWidth: 250 }}>
<Text fz="sm" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.description || '-' }}></Text>
</TableTd>
<TableTd style={{ maxWidth: 250 }}>
<Tooltip label="Buka tautan program" position="top" withArrow>
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
style={{ color: colors['blue-button'], textDecoration: 'underline' }}
>
<Text truncate fz="sm">{item.link}</Text>
</a>
</Tooltip>
</TableTd>
<TableTd>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/landing-page/profil/program-inovasi/${item.id}`)
}
>
Detail
</Button>
</TableTd>
</TableTr>
))
)}
</TableTbody>
</Table>
</Box>
</Box>
<Box hiddenFrom="md" pt={20}>
<Stack gap="sm">
{filteredData.map((item) => (
<Paper
key={item.id}
withBorder
radius="md"
p="md"
shadow="xs"
>
<Stack gap={6}>
{/* Title */}
<Text fw={600}>{item.name}</Text>
{/* Description */}
<Text fz="sm" c="gray.7" lineClamp={2}>
{item.description || '-'}
</Text>
{/* Link */}
<Box>
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: 'underline' }}
>
<Text
fz="sm"
c="blue"
truncate
>
{item.link}
</Text>
</a>
</Box>
{/* Action */}
<Group justify="flex-end" mt="xs">
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(
`/admin/landing-page/profil/program-inovasi/${item.id}`
)
}
>
Detail
</Button>
</Group>
</Stack>
</Paper>
))}
</Stack>
</Box>
{filteredData.length > 0 && (
<Center mt="md">
<Pagination

View File

@@ -30,12 +30,13 @@ function Page() {
return (
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
<Stack gap="md">
<Grid align="center">
<Grid>
<GridCol span={{ base: 12, md: 11 }}>
<Title order={3} c={colors['blue-button']}>Preview Profil PPID</Title>
</GridCol>
<GridCol span={{ base: 12, md: 1 }}>
<Button
w={{base: '100%', md: "110%"}}
c="green"
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}

View File

@@ -91,8 +91,8 @@ export const devBar = [
children: [
{
id: "Desa_1",
name: "Profile",
path: "/admin/desa/profile/profile-desa"
name: "Profil",
path: "/admin/desa/profil/profil-desa"
},
{
id: "Desa_2",
@@ -495,8 +495,8 @@ export const navBar = [
children: [
{
id: "Desa_1",
name: "Profile",
path: "/admin/desa/profile/profile-desa"
name: "Profil",
path: "/admin/desa/profil/profil-desa"
},
{
id: "Desa_2",
@@ -899,8 +899,8 @@ export const role1 = [
children: [
{
id: "Desa_1",
name: "Profile",
path: "/admin/desa/profile/profile-desa"
name: "Profil",
path: "/admin/desa/profil/profil-desa"
},
{
id: "Desa_2",

View File

@@ -1,399 +1,3 @@
// 'use client'
// import colors from "@/con/colors";
// import { authStore } from "@/store/authStore";
// import {
// ActionIcon,
// AppShell,
// AppShellHeader,
// AppShellMain,
// AppShellNavbar,
// Burger,
// Center,
// Flex,
// Group,
// Image,
// Loader,
// NavLink,
// ScrollArea,
// Text,
// Tooltip,
// rem
// } from "@mantine/core";
// import { useDisclosure } from "@mantine/hooks";
// import {
// IconChevronLeft,
// IconChevronRight,
// IconLogout2
// } from "@tabler/icons-react";
// import _ from "lodash";
// import Link from "next/link";
// import { useRouter, useSelectedLayoutSegments } from "next/navigation";
// import { useEffect, useState } from "react";
// // import { useSnapshot } from "valtio";
// import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
// export default function Layout({ children }: { children: React.ReactNode }) {
// const [opened, { toggle }] = useDisclosure();
// const [loading, setLoading] = useState(true);
// const [isLoggingOut, setIsLoggingOut] = useState(false);
// const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
// const router = useRouter();
// const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
// // const { user } = useSnapshot(authStore);
// // console.log("Current user in store:", user);
// // ✅ FIX: Selalu fetch user data setiap kali komponen mount
// useEffect(() => {
// const fetchUser = async () => {
// try {
// const res = await fetch('/api/auth/me');
// const data = await res.json();
// if (data.user) {
// // ✅ Check if user is NOT active → redirect to waiting room
// if (!data.user.isActive) {
// authStore.setUser(null);
// router.replace('/waiting-room');
// return;
// }
// // ✅ Fetch menuIds
// const menuRes = await fetch(`/api/admin/user-menu-access?userId=${data.user.id}`);
// const menuData = await menuRes.json();
// const menuIds = menuData.success && Array.isArray(menuData.menuIds)
// ? [...menuData.menuIds]
// : null;
// // ✅ Set user dengan menuIds yang fresh
// authStore.setUser({
// id: data.user.id,
// name: data.user.name,
// roleId: Number(data.user.roleId),
// menuIds,
// isActive: data.user.isActive
// });
// // ✅ TAMBAHKAN INI: Redirect ke dashboard sesuai roleId
// const currentPath = window.location.pathname;
// const expectedPath = getRedirectPath(Number(data.user.roleId));
// // Jika user di halaman /admin tapi bukan di path yang sesuai roleId
// if (currentPath === '/admin' || !currentPath.startsWith(expectedPath)) {
// router.replace(expectedPath);
// }
// } else {
// authStore.setUser(null);
// router.replace('/login');
// }
// } catch (error) {
// console.error('Gagal memuat data pengguna:', error);
// authStore.setUser(null);
// router.replace('/login');
// } finally {
// setLoading(false);
// }
// };
// fetchUser();
// }, [router]);
// // ✅ Fungsi helper untuk get redirect path
// const getRedirectPath = (roleId: number): string => {
// switch (roleId) {
// case 0: // DEVELOPER
// case 1: // SUPERADMIN
// case 2: // ADMIN_DESA
// return '/admin/landing-page/profil/program-inovasi';
// case 3: // ADMIN_KESEHATAN
// return '/admin/kesehatan/posyandu';
// case 4: // ADMIN_PENDIDIKAN
// return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
// default:
// return '/admin';
// }
// };
// if (loading) {
// return (
// <AppShell>
// <AppShellMain>
// <Center h="100vh">
// <Loader />
// </Center>
// </AppShellMain>
// </AppShell>
// );
// }
// // ✅ Ambil menu berdasarkan roleId dan menuIds
// const currentNav = authStore.user
// ? getNavbar({ roleId: authStore.user.roleId, menuIds: authStore.user.menuIds })
// : [];
// const handleLogout = async () => {
// try {
// setIsLoggingOut(true);
// // ✅ Panggil API logout untuk clear session di server
// const response = await fetch('/api/auth/logout', { method: 'POST' });
// const result = await response.json();
// if (result.success) {
// // Clear user data dari store
// authStore.setUser(null);
// // Clear localStorage
// localStorage.removeItem('auth_nomor');
// localStorage.removeItem('auth_kodeId');
// // Force reload untuk reset semua state
// window.location.href = '/login';
// } else {
// console.error('Logout failed:', result.message);
// // Tetap redirect meskipun gagal
// authStore.setUser(null);
// window.location.href = '/login';
// }
// } catch (error) {
// console.error('Error during logout:', error);
// // Tetap clear store dan redirect jika error
// authStore.setUser(null);
// window.location.href = '/login';
// } finally {
// setIsLoggingOut(false);
// }
// };
// return (
// <AppShell
// suppressHydrationWarning
// header={{ height: 64 }}
// navbar={{
// width: { base: 260, sm: 280, lg: 300 },
// breakpoint: 'sm',
// collapsed: {
// mobile: !opened,
// desktop: !desktopOpened,
// },
// }}
// padding="md"
// >
// <AppShellHeader
// style={{
// background: "linear-gradient(90deg, #ffffff, #f9fbff)",
// borderBottom: `1px solid ${colors["blue-button"]}20`,
// padding: '0 16px',
// }}
// px={{ base: 'sm', sm: 'md' }}
// py={{ base: 'xs', sm: 'sm' }}
// >
// <Group w="100%" h="100%" justify="space-between" wrap="nowrap">
// <Flex align="center" gap="sm">
// <Image
// src="/assets/images/darmasaba-icon.png"
// alt="Logo Darmasaba"
// w={{ base: 32, sm: 40 }}
// h={{ base: 32, sm: 40 }}
// radius="md"
// loading="lazy"
// style={{
// minWidth: '32px',
// height: 'auto',
// }}
// />
// <Text
// fw={700}
// c={colors["blue-button"]}
// fz={{ base: 'md', sm: 'xl' }}
// >
// Admin Darmasaba
// </Text>
// </Flex>
// <Group gap="xs">
// {!desktopOpened && (
// <Tooltip label="Buka Navigasi" position="bottom" withArrow>
// <ActionIcon
// variant="light"
// radius="xl"
// size="lg"
// onClick={toggleDesktop}
// color={colors["blue-button"]}
// >
// <IconChevronRight />
// </ActionIcon>
// </Tooltip>
// )}
// <Burger
// opened={opened}
// onClick={toggle}
// hiddenFrom="sm"
// size="md"
// color={colors["blue-button"]}
// mr="xs"
// />
// <Tooltip label="Kembali ke Website Desa" position="bottom" withArrow>
// <ActionIcon
// onClick={() => {
// router.push("/darmasaba");
// }}
// color={colors["blue-button"]}
// radius="xl"
// size="lg"
// variant="gradient"
// gradient={{ from: colors["blue-button"], to: "#228be6" }}
// >
// <Image
// src="/assets/images/darmasaba-icon.png"
// alt="Logo Darmasaba"
// w={20}
// h={20}
// radius="md"
// loading="lazy"
// style={{
// minWidth: '20px',
// height: 'auto',
// }}
// />
// </ActionIcon>
// </Tooltip>
// <Tooltip label="Keluar" position="bottom" withArrow>
// <ActionIcon
// onClick={handleLogout}
// color={colors["blue-button"]}
// radius="xl"
// size="lg"
// variant="gradient"
// gradient={{ from: colors["blue-button"], to: "#228be6" }}
// loading={isLoggingOut}
// disabled={isLoggingOut}
// >
// <IconLogout2 size={22} />
// </ActionIcon>
// </Tooltip>
// </Group>
// </Group>
// </AppShellHeader>
// <AppShellNavbar
// component={ScrollArea}
// style={{
// background: "#ffffff",
// borderRight: `1px solid ${colors["blue-button"]}20`,
// }}
// p={{ base: 'xs', sm: 'sm' }}
// >
// <AppShell.Section p="sm">
// {currentNav.map((v, k) => {
// const isParentActive = segments.includes(_.lowerCase(v.name));
// return (
// <NavLink
// key={k}
// defaultOpened={isParentActive}
// c={isParentActive ? colors["blue-button"] : "gray"}
// label={
// <Text fw={isParentActive ? 600 : 400} fz="sm">
// {v.name}
// </Text>
// }
// style={{
// borderRadius: rem(10),
// marginBottom: rem(4),
// transition: "background 150ms ease",
// }}
// styles={{
// root: {
// '&:hover': {
// backgroundColor: 'rgba(25, 113, 194, 0.05)',
// },
// },
// }}
// variant="light"
// active={isParentActive}
// >
// {v.children.map((child, key) => {
// const isChildActive = segments.includes(
// _.lowerCase(child.name)
// );
// return (
// <NavLink
// key={key}
// href={child.path}
// c={isChildActive ? colors["blue-button"] : "gray"}
// label={
// <Text fw={isChildActive ? 600 : 400} fz="sm">
// {child.name}
// </Text>
// }
// styles={{
// root: {
// borderRadius: rem(8),
// marginBottom: rem(2),
// transition: 'background 150ms ease',
// padding: '6px 12px',
// '&:hover': {
// backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)',
// },
// ...(isChildActive && {
// backgroundColor: 'rgba(25, 113, 194, 0.1)',
// }),
// },
// }}
// active={isChildActive}
// component={Link}
// />
// );
// })}
// </NavLink>
// );
// })}
// </AppShell.Section>
// <AppShell.Section py="md">
// <Group justify="end" pr="sm">
// <Tooltip
// label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"}
// position="top"
// withArrow
// >
// <ActionIcon
// variant="light"
// radius="xl"
// size="lg"
// onClick={toggleDesktop}
// color={colors["blue-button"]}
// >
// <IconChevronLeft />
// </ActionIcon>
// </Tooltip>
// </Group>
// </AppShell.Section>
// </AppShellNavbar>
// <AppShellMain
// style={{
// background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)",
// minHeight: "100vh",
// }}
// >
// {children}
// </AppShellMain>
// </AppShell>
// );
// }
// app/admin/layout.tsx
'use client'
import colors from "@/con/colors";
@@ -429,7 +33,7 @@ import { useEffect, useState } from "react";
import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
export default function Layout({ children }: { children: React.ReactNode }) {
const [opened, { toggle }] = useDisclosure();
const [opened, { toggle, close }] = useDisclosure(); // ✅ Tambahkan 'close'
const [loading, setLoading] = useState(true);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
@@ -441,21 +45,19 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const fetchUser = async () => {
try {
const res = await fetch('/api/auth/me', {
credentials: 'include' // ✅ ADD credentials
credentials: 'include'
});
const data = await res.json();
if (data.user) {
// ✅ Check if user is NOT active → redirect to waiting room
if (!data.user.isActive) {
authStore.setUser(null);
router.replace('/waiting-room');
return;
}
// ✅ Fetch menuIds
const menuRes = await fetch(`/api/admin/user-menu-access?userId=${data.user.id}`, {
credentials: 'include' // ✅ ADD credentials
credentials: 'include'
});
const menuData = await menuRes.json();
@@ -463,7 +65,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
? [...menuData.menuIds]
: null;
// ✅ Set user dengan menuIds yang fresh
authStore.setUser({
id: data.user.id,
name: data.user.name,
@@ -472,7 +73,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
isActive: data.user.isActive
});
// ✅ IMPROVED: Redirect ONLY if di root /admin
const currentPath = window.location.pathname;
if (currentPath === '/admin') {
@@ -480,7 +80,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
console.log('🔄 Redirecting from /admin to:', expectedPath);
router.replace(expectedPath);
}
// ✅ Jangan redirect jika user sudah di path yang valid
} else {
authStore.setUser(null);
@@ -496,17 +95,17 @@ export default function Layout({ children }: { children: React.ReactNode }) {
};
fetchUser();
}, [router]); // ✅ Only depend on router
}, [router]);
const getRedirectPath = (roleId: number): string => {
switch (roleId) {
case 0: // DEVELOPER
case 1: // SUPERADMIN
case 2: // ADMIN_DESA
case 0:
case 1:
case 2:
return '/admin/landing-page/profil/program-inovasi';
case 3: // ADMIN_KESEHATAN
case 3:
return '/admin/kesehatan/posyandu';
case 4: // ADMIN_PENDIDIKAN
case 4:
return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
default:
return '/admin';
@@ -535,7 +134,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const response = await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include' // ✅ ADD credentials
credentials: 'include'
});
const result = await response.json();
@@ -559,6 +158,12 @@ export default function Layout({ children }: { children: React.ReactNode }) {
}
};
// ✅ Handler untuk menutup mobile menu saat navigasi
const handleNavClick = (path: string) => {
router.push(path);
close(); // Tutup mobile menu
};
return (
<AppShell
suppressHydrationWarning
@@ -573,7 +178,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
}}
padding="md"
>
{/* ... rest of your JSX (Header, Navbar, Main) sama seperti sebelumnya ... */}
<AppShellHeader
style={{
background: "linear-gradient(90deg, #ffffff, #f9fbff)",
@@ -626,16 +230,48 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</AppShellHeader>
<AppShellNavbar component={ScrollArea} style={{ background: "#ffffff", borderRight: `1px solid ${colors["blue-button"]}20` }} p={{ base: 'xs', sm: 'sm' }}>
{/* ... Navbar content sama seperti sebelumnya ... */}
<AppShell.Section p="sm">
{currentNav.map((v, k) => {
const isParentActive = segments.includes(_.lowerCase(v.name));
return (
<NavLink key={k} defaultOpened={isParentActive} c={isParentActive ? colors["blue-button"] : "gray"} label={<Text fw={isParentActive ? 600 : 400} fz="sm">{v.name}</Text>} style={{ borderRadius: rem(10), marginBottom: rem(4), transition: "background 150ms ease" }} styles={{ root: { '&:hover': { backgroundColor: 'rgba(25, 113, 194, 0.05)' } } }} variant="light" active={isParentActive}>
<NavLink
key={k}
defaultOpened={isParentActive}
c={isParentActive ? colors["blue-button"] : "gray"}
label={<Text fw={isParentActive ? 600 : 400} fz="sm">{v.name}</Text>}
style={{ borderRadius: rem(10), marginBottom: rem(4), transition: "background 150ms ease" }}
styles={{ root: { '&:hover': { backgroundColor: 'rgba(25, 113, 194, 0.05)' } } }}
variant="light"
active={isParentActive}
>
{v.children.map((child, key) => {
const isChildActive = segments.includes(_.lowerCase(child.name));
return (
<NavLink key={key} href={child.path} c={isChildActive ? colors["blue-button"] : "gray"} label={<Text fw={isChildActive ? 600 : 400} fz="sm">{child.name}</Text>} styles={{ root: { borderRadius: rem(8), marginBottom: rem(2), transition: 'background 150ms ease', padding: '6px 12px', '&:hover': { backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)' }, ...(isChildActive && { backgroundColor: 'rgba(25, 113, 194, 0.1)' }) } }} active={isChildActive} component={Link} />
<NavLink
key={key}
// ✅ PERBAIKAN: Gunakan onClick untuk handle navigasi dan close menu
onClick={(e) => {
e.preventDefault();
handleNavClick(child.path);
}}
href={child.path}
c={isChildActive ? colors["blue-button"] : "gray"}
label={<Text fw={isChildActive ? 600 : 400} fz="sm">{child.name}</Text>}
styles={{
root: {
borderRadius: rem(8),
marginBottom: rem(2),
transition: 'background 150ms ease',
padding: '6px 12px',
'&:hover': {
backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)'
},
...(isChildActive && { backgroundColor: 'rgba(25, 113, 194, 0.1)' })
}
}}
active={isChildActive}
component={Link}
/>
);
})}
</NavLink>

View File

@@ -6,33 +6,24 @@ import path from "path";
const beritaDelete = async (context: Context) => {
const id = context.params?.id as string;
if (!id) {
return {
status: 400,
body: "ID tidak diberikan",
};
}
if (!id) return { status: 400, body: "ID tidak diberikan" };
const berita = await prisma.berita.findUnique({
where: { id },
include: {
image: true,
kategoriBerita: true, // pastikan relasi image sudah ada di prisma schema
},
include: { image: true, kategoriBerita: true },
});
if (!berita) {
return {
status: 404,
body: "Berita tidak ditemukan",
};
}
if (!berita) return { status: 404, body: "Berita tidak ditemukan" };
// Hapus file gambar dari filesystem jika ada
// 1. HAPUS BERITA DULU
await prisma.berita.delete({ where: { id } });
// 2. BARU HAPUS FILE
if (berita.image) {
try {
const filePath = path.join(berita.image.path, berita.image.name);
await fs.unlink(filePath);
await prisma.fileStorage.delete({
where: { id: berita.image.id },
});
@@ -41,15 +32,11 @@ const beritaDelete = async (context: Context) => {
}
}
// Hapus berita dari DB
await prisma.berita.delete({
where: { id },
});
return {
success: true,
message: "Berita dan file terkait berhasil dihapus",
};
};
export default beritaDelete;

View File

@@ -28,7 +28,7 @@ export default async function grafikJumlahPendudukMiskinFindMany(
where,
skip,
take: limit,
orderBy: { createdAt: "desc" },
orderBy: { year: "asc" },
}),
prisma.grafikJumlahPendudukMiskin.count({
where,

View File

@@ -1,6 +1,5 @@
import Elysia from "elysia";
import DaftarInformasiPublik from "./daftar_informasi_publik";
import GrafikHasilKepuasanMasyarakat from "./ikm/grafik_hasil_kepuasan_masyarakat";
import GrafikBerdasarkanJenisKelamin from "./ikm/grafik_berdasarkan_jenis_kelamin";
import GrafikBerdasarkanResponden from "./ikm/grafik_responden";
import GrafikBerdasarkanUmur from "./ikm/grafik_berdasarkan_umur";
@@ -10,6 +9,7 @@ import ProfilePPID from "./profile_ppid";
import VisiMisiPPID from "./visi_misi_ppid/visi_misi_ppid";
import DasarHukumPPID from "./dasar_hukum";
import StrukturPPID from "./struktur_ppid";
import GrafikHasilKepuasanMasyarakat from "./ikm/grafik_hasil_kepuasan_masyarakat";

View File

@@ -3,39 +3,55 @@ import { Prisma } from "@prisma/client";
import { Context } from "elysia";
type FormCreate = Prisma.PermohonanInformasiPublikGetPayload<{
select: {
name: true;
nik: true;
email: true;
notelp: true;
alamat: true;
jenisInformasiDimintaId: true;
caraMemperolehInformasiId: true;
caraMemperolehSalinanInformasiId: true;
}
}>
export default async function permohonanInformasiPublikCreate(context: Context) {
const body = context.body as FormCreate;
await prisma.permohonanInformasiPublik.create({
data: {
name: body.name,
nik: body.nik,
email: body.email,
notelp: body.notelp,
alamat: body.alamat,
jenisInformasiDimintaId: body.jenisInformasiDimintaId,
caraMemperolehInformasiId: body.caraMemperolehInformasiId,
caraMemperolehSalinanInformasiId: body.caraMemperolehSalinanInformasiId,
}
})
select: {
name: true;
nik: true;
email: true;
notelp: true;
alamat: true;
jenisInformasiDimintaId: true;
caraMemperolehInformasiId: true;
caraMemperolehSalinanInformasiId: true;
};
}>;
export default async function permohonanInformasiPublikCreate(context: Context) {
const body = context.body as FormCreate;
// ========== VALIDASI NIK ==========
if (body.nik && body.nik.length > 16) {
return {
success: true,
message: "Permohonan Informasi Publik Berhasil Dibuat",
data: {
...body,
}
}
success: false,
status: 400,
message: "Maksimal NIK adalah 16 angka",
};
}
// ========== VALIDASI NOMOR TELEPON ==========
if (body.notelp && body.notelp.length > 15) {
return {
success: false,
status: 400,
message: "Maksimal nomor telepon adalah 15 angka",
};
}
await prisma.permohonanInformasiPublik.create({
data: {
name: body.name,
nik: body.nik,
email: body.email,
notelp: body.notelp,
alamat: body.alamat,
jenisInformasiDimintaId: body.jenisInformasiDimintaId,
caraMemperolehInformasiId: body.caraMemperolehInformasiId,
caraMemperolehSalinanInformasiId: body.caraMemperolehSalinanInformasiId,
},
});
return {
success: true,
message: "Permohonan Informasi Publik Berhasil Dibuat",
data: { ...body },
};
}

View File

@@ -3,31 +3,42 @@ import { Prisma } from "@prisma/client";
import { Context } from "elysia";
type FormCreate = Prisma.FormulirPermohonanKeberatanGetPayload<{
select: {
name: true;
email: true;
notelp: true;
alasan: true;
}
}>
select: {
name: true;
email: true;
notelp: true;
alasan: true;
};
}>;
export default async function permohonanKeberatanInformasiPublikCreate(context: Context) {
const body = context.body as FormCreate;
await prisma.formulirPermohonanKeberatan.create({
data: {
name: body.name,
email: body.email,
notelp: body.notelp,
alasan: body.alasan,
}
})
export default async function permohonanKeberatanInformasiPublikCreate(
context: Context
) {
const body = context.body as FormCreate;
// ========== VALIDASI NOMOR TELEPON ==========
if (body.notelp && body.notelp.length > 15) {
return {
success: true,
message: "Permohonan Keberatan Informasi Publik Berhasil Dibuat",
data: {
...body,
}
}
}
success: false,
status: 400,
message: "Maksimal nomor telepon adalah 15 angka",
};
}
await prisma.formulirPermohonanKeberatan.create({
data: {
name: body.name,
email: body.email,
notelp: body.notelp,
alasan: body.alasan,
},
});
return {
success: true,
message: "Permohonan Keberatan Informasi Publik Berhasil Dibuat",
data: {
...body,
},
};
}

View File

@@ -0,0 +1,43 @@
// app/api/news/latest/route.ts
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function GET() {
try {
const berita = await prisma.berita.findMany({
take: 3,
orderBy: { createdAt: "desc" },
include: { kategoriBerita: true },
});
const pengumuman = await prisma.pengumuman.findMany({
take: 3,
orderBy: { createdAt: "desc" },
include: { CategoryPengumuman: true },
});
const news = [
...berita.map((b) => ({
id: b.id,
type: "berita" as const,
title: b.judul,
content: b.content,
timestamp: b.createdAt,
kategoriBerita: b.kategoriBerita || undefined,
})),
...pengumuman.map((p) => ({
id: p.id,
type: "pengumuman" as const,
title: p.judul,
content: p.content,
timestamp: p.createdAt,
kategoriPengumuman: p.CategoryPengumuman || undefined,
})),
];
return NextResponse.json({ success: true, news }); // ✅ ganti 'data' jadi 'news'
} catch (error) {
console.error("API Error:", error);
return NextResponse.json({ success: false, error: "Gagal memuat data" }, { status: 500 });
}
}

View File

@@ -1,5 +1,5 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
'use client';
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
import {
Badge,
@@ -51,10 +51,14 @@ export default function Content({ kategori }: { kategori: string }) {
<Container size="xl" px={{ base: 'md', md: 'xl' }}>
{/* === Berita Utama === */}
{featuredState.loading ? (
<Center><Skeleton h={400} /></Center>
<Center>
<Skeleton h={400} />
</Center>
) : featured ? (
<Box mb={50}>
<Text fz="h2" fw={700} mb="md">Berita Utama</Text>
<Title order={2} mb="md">
Berita Utama
</Title>
<Paper shadow="md" radius="md" withBorder>
<Grid gutter={0}>
<GridCol span={{ base: 12, md: 6 }}>
@@ -74,13 +78,29 @@ export default function Content({ kategori }: { kategori: string }) {
<Badge color="blue" variant="light" mb="md">
{featured.kategoriBerita?.name || kategori}
</Badge>
<Title order={2} mb="md">{featured.judul}</Title>
<Text c="dimmed" lineClamp={3} mb="md" dangerouslySetInnerHTML={{ __html: featured.deskripsi }} />
<Title order={3} mb="md">
{featured.judul}
</Title>
<Text
c="dimmed"
lineClamp={3}
mb="md"
style={{ lineHeight: 1.6 }}
dangerouslySetInnerHTML={{ __html: featured.deskripsi }}
/>
</div>
<Group justify="apart" mt="auto">
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">
<Text
fz={{ base: 'xs', md: 'sm' }}
c="dimmed"
lh={1.5}
style={{
fontSize: '0.875rem',
lineHeight: '1.5rem',
}}
>
{new Date(featured.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
@@ -91,7 +111,9 @@ export default function Content({ kategori }: { kategori: string }) {
<Button
variant="light"
rightSection={<IconArrowRight size={16} />}
onClick={() => router.push(`/darmasaba/desa/berita/${kategori}/${featured.id}`)}
onClick={() =>
router.push(`/darmasaba/desa/berita/${kategori}/${featured.id}`)
}
>
Baca Selengkapnya
</Button>
@@ -105,19 +127,29 @@ export default function Content({ kategori }: { kategori: string }) {
{/* === Daftar Berita === */}
<Box mt={50}>
<Title order={2} mb="md">Daftar Berita</Title>
<Title order={2} mb="md">
Daftar Berita
</Title>
<Divider mb="xl" />
{state.findMany.loading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl">
{Array(3).fill(0).map((_, i) => (
<Skeleton key={i} h={300} radius="md" />
))}
{Array(3)
.fill(0)
.map((_, i) => (
<Skeleton key={i} h={300} radius="md" />
))}
</SimpleGrid>
) : paginatedNews.length === 0 ? (
<Text c="dimmed" ta="center">Belum ada berita di kategori &quot;{kategori}&quot;.</Text>
<Text c="dimmed" ta="center" fz={{ base: 'sm', md: 'md' }} lh={1.5}>
Belum ada berita di kategori &quot;{kategori}&quot;.
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl" verticalSpacing="xl">
<SimpleGrid
cols={{ base: 1, sm: 2, lg: 3 }}
spacing="xl"
verticalSpacing="xl"
>
{paginatedNews.map((item) => (
<Card
key={item.id}
@@ -125,19 +157,51 @@ export default function Content({ kategori }: { kategori: string }) {
p="lg"
radius="md"
withBorder
onClick={() => router.push(`/darmasaba/desa/berita/${kategori}/${item.id}`)}
onClick={() =>
router.push(`/darmasaba/desa/berita/${kategori}/${item.id}`)
}
style={{ cursor: 'pointer' }}
>
<Card.Section>
<Image src={item.image?.link} height={200} alt={item.judul} fit="cover" loading="lazy"/>
<Image
src={item.image?.link}
height={200}
alt={item.judul}
fit="cover"
loading="lazy"
/>
</Card.Section>
<Badge color="blue" variant="light" mt="md">
{item.kategoriBerita?.name || kategori}
</Badge>
<Text fw={600} size="lg" mt="sm" lineClamp={2}>{item.judul}</Text>
<Text size="sm" c="dimmed" lineClamp={3} style={{wordBreak: "break-word", whiteSpace: "normal"}} mt="xs" dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
<Title
order={4}
mt="sm"
fz={{ base: 'sm', md: 'md' }}
style={{ lineHeight: 1.4 }}
lineClamp={2}
>
{item.judul}
</Title>
<Text
fz={{ base: 'xs', md: 'sm' }}
c="dimmed"
lineClamp={3}
style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
lineHeight: 1.5,
}}
mt="xs"
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
/>
<Group justify="apart" mt="md" gap="xs">
<Text size="xs" c="dimmed">
<Text
fz={{ base: 'xs', md: 'xs' }}
c="dimmed"
lh={1.4}
style={{ fontSize: '0.75rem', lineHeight: '1.125rem' }}
>
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',

View File

@@ -3,18 +3,16 @@
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
import NewsReader from '@/app/darmasaba/_com/NewsReader';
import colors from '@/con/colors';
import { Box, Center, Container, Group, Image, Skeleton, Stack, Text } from '@mantine/core';
import { Box, Center, Container, Group, Image, Skeleton, Stack, Text, Title } from '@mantine/core';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
function Page() {
const params = useParams<{ id: string }>();
const id = Array.isArray(params.id) ? params.id[0] : params.id;
const state = useProxy(stateDashboardBerita.berita)
const [loading, setLoading] = useState(true)
const state = useProxy(stateDashboardBerita.berita);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadData = async () => {
@@ -27,9 +25,9 @@ function Page() {
} finally {
setLoading(false);
}
}
loadData()
}, [id])
};
loadData();
}, [id]);
if (loading) {
return (
@@ -47,41 +45,49 @@ function Page() {
);
}
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"} px={{ base: "md", md: 0 }}>
<Group px={{ base: "md", md: 100 }}>
<Stack pos="relative" bg={colors.Bg} pb="xl" gap="xs" px={{ base: 'md', md: 0 }}>
<Group px={{ base: 'md', md: 100 }}>
<NewsReader />
</Group>
<Container w={{ base: "100%", md: "50%" }} >
<Container w={{ base: '100%', md: '50%' }}>
<Box pb={20}>
<Text id='news-title' ta={"center"} fz={"2.4rem"} c={colors["blue-button"]} fw={"bold"}>
{state.findUnique.data?.judul}
</Text>
<Text
ta={"center"}
fw={"bold"}
fz={"1.5rem"}
<Title
id="news-title"
order={1}
ta="center"
c={colors['blue-button']}
fw="bold"
lh={{ base: 1.2, md: 1.25 }}
>
{state.findUnique.data.judul}
</Title>
<Title
order={2}
ta="center"
fw="bold"
fz={{ base: 'md', md: 'lg' }}
lh={{ base: 1.3, md: 1.35 }}
>
Informasi dan Pelayanan Administrasi Digital
</Text>
</Title>
</Box>
<Image src={state.findUnique.data?.image?.link || ''} alt='' w={"100%"} loading="lazy" />
<Image src={state.findUnique.data.image?.link || ''} alt="" w="100%" loading="lazy" />
</Container>
<Box px={{ base: "md", md: 100 }}>
<Stack gap={"xs"}>
<Box px={{ base: 'md', md: 100 }}>
<Stack gap="xs">
<Text
id='news-content'
id="news-content"
py={20}
fz={{ base: "sm", md: "lg" }}
lh={{ base: 1.6, md: 1.8 }} // ✅ line-height lebih rapat dan responsif
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.6, md: 1.8 }}
ta="justify"
style={{
wordBreak: "break-word",
whiteSpace: "normal",
wordBreak: 'break-word',
whiteSpace: 'normal',
}}
dangerouslySetInnerHTML={{
__html: state.findUnique.data?.content || "",
__html: state.findUnique.data.content || '',
}}
/>
</Stack>
@@ -90,4 +96,4 @@ function Page() {
);
}
export default Page;
export default Page;

View File

@@ -1,12 +1,44 @@
// app/desa/berita/BeritaLayoutClient.tsx
'use client'
import dynamic from 'next/dynamic';
// app/darmasaba/(pages)/desa/berita/layout.tsx
'use client';
import { usePathname } from 'next/navigation';
import { ReactNode } from 'react';
import dynamic from 'next/dynamic';
import { Box } from '@mantine/core';
import BackButton from '../layanan/_com/BackButto';
import colors from '@/con/colors';
const LayoutTabsBerita = dynamic(
() => import('./_lib/layoutTabs'),
{ ssr: false }
);
export default function BeritaLayoutClient({ children }: { children: React.ReactNode }) {
export default function BeritaLayoutClient({
children,
}: {
children: ReactNode;
}) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/berita/semua → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length === 5; // [darmasaba, desa, berita, kategori, id]
if (isDetailPage) {
// Tampilkan tanpa tab menu
return (
<Box bg={colors.Bg}>
<Box pt={33} px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
{children}
</Box>
);
}
// Tampilkan dengan tab menu (untuk /semua atau /kategori)
return <LayoutTabsBerita>{children}</LayoutTabsBerita>;
}

View File

@@ -16,35 +16,30 @@ function Semua() {
const searchParams = useSearchParams();
const router = useTransitionRouter();
// Ambil parameter langsung dari URL
const search = searchParams.get('search') || '';
const page = parseInt(searchParams.get('page') || '1');
// Gunakan proxy untuk state global
const state = useProxy(stateDashboardBerita.berita);
const featured = useProxy(stateDashboardBerita.berita.findFirst);
const loadingGrid = state.findMany.loading;
const loadingFeatured = featured.loading;
// Load berita utama sekali saja
useEffect(() => {
if (!featured.data && !loadingFeatured) {
stateDashboardBerita.berita.findFirst.load();
}
}, [featured.data, loadingFeatured]);
// Load berita terbaru tiap page / search berubah
useEffect(() => {
const limit = 3;
state.findMany.load(page, limit, search);
}, [page, search]);
// Handler pagination → langsung update URL
const handlePageChange = (newPage: number) => {
const url = new URLSearchParams(searchParams.toString());
if (search) url.set('search', search);
if (newPage > 1) url.set('page', newPage.toString());
else url.delete('page'); // biar page=1 ga muncul di URL
else url.delete('page');
router.replace(`?${url.toString()}`);
};
@@ -61,7 +56,7 @@ function Semua() {
<Center><Skeleton h={400} /></Center>
) : featuredData ? (
<Box mb={50}>
<Text fz="h2" fw={700} mb="md">Berita Utama</Text>
<Title order={2} mb="md">Berita Utama</Title>
<Paper shadow="md" radius="md" withBorder>
<Grid gutter={0}>
<GridCol span={{ base: 12, md: 6 }}>
@@ -81,13 +76,24 @@ function Semua() {
<Badge color="blue" variant="light" mb="md">
{featuredData.kategoriBerita?.name || 'Berita'}
</Badge>
<Title order={2} mb="md">{featuredData.judul}</Title>
<Text c="dimmed" lineClamp={3} mb="md" dangerouslySetInnerHTML={{ __html: featuredData.deskripsi }} />
<Title order={3} mb="md">{featuredData.judul}</Title>
<Text
c="dimmed"
lineClamp={3}
mb="md"
dangerouslySetInnerHTML={{ __html: featuredData.deskripsi }}
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.5, md: 1.6 }}
/>
</div>
<Group justify="apart" mt="auto">
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">
<Text
c="dimmed"
fz={{ base: 'xs', md: 'sm' }}
lh={{ base: 1.4, md: 1.5 }}
>
{new Date(featuredData.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
@@ -124,7 +130,9 @@ function Semua() {
))}
</SimpleGrid>
) : paginatedNews.length === 0 ? (
<Text c="dimmed" ta="center">Tidak ada berita ditemukan.</Text>
<Text c="dimmed" ta="center" fz={{ base: 'sm', md: 'md' }} lh={{ base: 1.5, md: 1.6 }}>
Tidak ada berita ditemukan.
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl" verticalSpacing="xl">
{paginatedNews.map((item) => (
@@ -143,11 +151,24 @@ function Semua() {
{item.kategoriBerita?.name || 'Berita'}
</Badge>
<Text fw={600} size="lg" mt="sm" lineClamp={2}>{item.judul}</Text>
<Text size="sm" c="dimmed" lineClamp={3} mt="xs" dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
<Title order={4} mt="sm" lineClamp={2}>
{item.judul}
</Title>
<Text
c="dimmed"
lineClamp={3}
mt="xs"
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
fz={{ base: 'xs', md: 'sm' }}
lh={{ base: 1.5, md: 1.6 }}
/>
<Flex align="center" justify="apart" mt="md" gap="xs">
<Text size="xs" c="dimmed">
<Text
c="dimmed"
fz={{ base: 'xs', md: 'xs' }}
lh={{ base: 1.4, md: 1.4 }}
>
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
@@ -187,4 +208,4 @@ function Semua() {
);
}
export default Semua;
export default Semua;

View File

@@ -1,150 +0,0 @@
'use client';
import colors from '@/con/colors';
import { Box, Center, Image, Pagination, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
import { useCallback, useEffect, useState } from 'react';
import ApiFetch from '@/lib/api-fetch';
interface FileItem {
id: string;
name: string;
link: string;
realName: string;
createdAt: string | Date;
category: string;
path: string;
mimeType: string;
}
export default function FotoContent() {
const [files, setFiles] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const limit = 9; // ✅ ambil 12 data per page
const loadData = useCallback(async (pageNum: number, searchTerm: string) => {
setLoading(true);
try {
const query: Record<string, string> = {
category: 'image',
page: pageNum.toString(),
limit: limit.toString(),
};
if (searchTerm) query.search = searchTerm;
const response = await ApiFetch.api.fileStorage.findMany.get({ query });
if (response.status === 200 && response.data) {
setFiles(response.data.data || []);
setTotalPages(response.data.meta?.totalPages || 1);
} else {
setFiles([]);
}
} catch (err) {
console.error('Load error:', err);
setFiles([]);
} finally {
setLoading(false);
}
}, []);
// ✅ Initial load + update when URL/search changes
useEffect(() => {
const handleRouteChange = () => {
const urlParams = new URLSearchParams(window.location.search);
const urlSearch = urlParams.get('search') || '';
const urlPage = parseInt(urlParams.get('page') || '1');
setSearch(urlSearch);
setPage(urlPage);
loadData(urlPage, urlSearch);
};
const handleSearchUpdate = (e: Event) => {
const { search } = (e as CustomEvent).detail;
setSearch(search);
setPage(1);
loadData(1, search);
};
handleRouteChange();
window.addEventListener('popstate', handleRouteChange);
window.addEventListener('searchUpdate', handleSearchUpdate as EventListener);
return () => {
window.removeEventListener('popstate', handleRouteChange);
window.removeEventListener('searchUpdate', handleSearchUpdate as EventListener);
};
}, [loadData]);
// ✅ Update when page/search changes
useEffect(() => {
loadData(page, search);
}, [page, search, loadData]);
const updateURL = (newSearch: string, newPage: number) => {
const url = new URL(window.location.href);
if (newSearch) url.searchParams.set('search', newSearch);
else url.searchParams.delete('search');
if (newPage > 1) url.searchParams.set('page', newPage.toString());
else url.searchParams.delete('page');
window.history.pushState({}, '', url);
};
const handlePageChange = (newPage: number) => {
setPage(newPage);
updateURL(search, newPage);
};
if (loading && files.length === 0) {
return <Center>Memuat data...</Center>;
}
if (files.length === 0) {
return <Center>Tidak ada foto ditemukan</Center>;
}
return (
<Box pt={20} px={{ base: 'md', md: 100 }}>
<SimpleGrid cols={{ base: 1, md: 3 }}>
{files.map((file) => (
<Paper
key={file.id}
mb={50}
p="md"
radius={26}
bg={colors['white-trans-1']}
style={{ height: '100%' }}
>
<Box style={{ height: '250px', overflow: 'hidden', borderRadius: '12px' }}>
<Image
src={file.link}
alt={file.realName || file.name}
height={250}
width="100%"
style={{ objectFit: 'cover', height: '100%', width: '100%' }}
loading="lazy"
/>
</Box>
<Stack gap="sm" py={10}>
<Text fw="bold" fz={{ base: 'h4', md: 'h3' }}>
{file.realName || file.name}
</Text>
<Text fz="sm" c="dimmed">
{new Date(file.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
</Stack>
</Paper>
))}
</SimpleGrid>
<Center mt="xl">
<Pagination total={totalPages} value={page} onChange={handlePageChange} />
</Center>
</Box>
);
}

View File

@@ -1,25 +1,173 @@
'use client'
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors';
import {
Box,
Center,
Grid,
Image,
Pagination,
Paper,
Skeleton,
Stack,
Text,
Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconPhoto } from '@tabler/icons-react';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
// ✅ Load komponen tanpa SSR
const FotoContent = dynamic(
() => import('./Content'),
{
ssr: false,
loading: () => <div>Memuat konten...</div>
}
);
// Komponen kartu foto
function FotoCard({ item }: { item: any }) {
function PageContent() {
return (
<Suspense fallback={<div>Memuat...</div>}>
<FotoContent />
</Suspense>
<Grid.Col span={{ base: 12, xs: 6, md: 4 }}>
<Paper
shadow="sm"
radius="md"
p={0}
style={{ transition: 'transform 0.2s' }}
onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.02)')}
onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1)')}
>
{item.imageGalleryFoto?.link ? (
<Box
pos="relative"
style={{
paddingBottom: '100%',
overflow: 'hidden',
borderRadius: '4px 4px 0 0',
backgroundColor: '#f9f9f9',
}}
>
<Image
radius="lg"
src={item.imageGalleryFoto.link}
alt={item.name || 'Foto Galeri'}
p={10}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'contain',
objectPosition: 'center',
}}
loading="lazy"
/>
</Box>
) : (
<Center h={180} bg="gray.1">
<IconPhoto size={40} color="gray" />
</Center>
)}
<Stack p="md" gap={4}>
<Text fw={600} lineClamp={1} fz={{ base: 'sm', md: 'md' }} lh={{ base: '1.4', md: '1.5' }}>
{item.name || 'Tanpa Judul'}
</Text>
{item.deskripsi && (
<Text
fz={{ base: 'xs', md: 'sm' }}
c="dimmed"
lineClamp={2}
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
lh={{ base: '1.4', md: '1.5' }}
/>
)}
<Text
fz={{ base: 11, md: 'xs' }}
c="dimmed"
lh={{ base: '1.3', md: '1.4' }}
>
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</Text>
</Stack>
</Paper>
</Grid.Col>
);
}
export default function Page() {
return <PageContent />;
// Komponen utama
export default function GaleriFotoUser() {
const [search] = useState('');
return (
<Box py="xl" px={{ base: 'md', md: 'lg' }}>
{/* Header */}
<Title order={2} c={colors['blue-button']} mb="lg" ta="center">
Galeri Foto Desa Darmasaba
</Title>
{/* Daftar Foto */}
<FotoList search={search} />
</Box>
);
}
function FotoList({ search }: { search: string }) {
const FotoState = useProxy(stateGallery.foto);
const { data, page, totalPages, loading, load } = FotoState.findMany;
useShallowEffect(() => {
load(page, 3, search);
}, [page, search]);
if (loading) {
return (
<Grid mt="md">
{Array.from({ length: 3 }).map((_, i) => (
<Grid.Col key={i} span={{ base: 12, xs: 6, md: 4 }}>
<Skeleton height={280} radius="md" />
</Grid.Col>
))}
</Grid>
);
}
if (!data || data.length === 0) {
return (
<Center py="xl">
<Stack align="center" c="dimmed">
<IconPhoto size={48} />
<Text fz={{ base: 'sm', md: 'md' }} lh={{ base: '1.4', md: '1.5' }}>
Tidak ada foto ditemukan
</Text>
</Stack>
</Center>
);
}
return (
<Stack mt="md" gap="xl">
<Grid>
{data.map((item) => (
<FotoCard key={item.id} item={item} />
))}
</Grid>
{/* Pagination */}
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 3, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
color="blue"
radius="md"
/>
</Center>
</Stack>
);
}

View File

@@ -1,9 +1,36 @@
'use client'
import { usePathname } from "next/navigation";
import { ReactNode } from "react";
import LayoutTabsGalery from "./_lib/layoutTabs";
export default function LayoutGalery({ children }: { children: React.ReactNode }) {
return (
<LayoutTabsGalery>
{children}
</LayoutTabsGalery>
)
// export default function LayoutGalery({ children }: { children: React.ReactNode }) {
// return (
// <LayoutTabsGalery>
// {children}
// </LayoutTabsGalery>
// )
// }
export default function BeritaLayoutClient({
children,
}: {
children: ReactNode;
}) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/berita/semua → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length === 5; // [darmasaba, desa, berita, kategori, id]
if (isDetailPage) {
// Tampilkan tanpa tab menu
return <>{children}</>
}
// Tampilkan dengan tab menu (untuk /semua atau /kategori)
return <LayoutTabsGalery>{children}</LayoutTabsGalery>;
}

View File

@@ -4,29 +4,29 @@ import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
SimpleGrid,
Spoiler,
Stack,
Text,
Title
} from '@mantine/core';
import { useCallback, useEffect, useState } from 'react';
import { useTransitionRouter } from 'next-view-transitions';
import { useCallback, useEffect } from 'react';
import { useSnapshot } from 'valtio';
export default function VideoContent() {
// ✅ expanded state per index
const [expandedMap, setExpandedMap] = useState<Record<number, boolean>>({});
const videoState = useSnapshot(stateGallery.video);
const router = useTransitionRouter();
const { data, page, totalPages, loading } = videoState.findMany;
// Handle search and pagination changes
const loadData = useCallback((pageNum: number, searchTerm: string) => {
stateGallery.video.findMany.load(pageNum, 10, searchTerm.trim());
stateGallery.video.findMany.load(pageNum, 3, searchTerm.trim());
}, []);
// Initial load and URL change handler
useEffect(() => {
const handleRouteChange = () => {
const urlParams = new URLSearchParams(window.location.search);
@@ -56,19 +56,14 @@ export default function VideoContent() {
loadData(newPage, search);
};
const toggleExpanded = (index: number, value: boolean) => {
setExpandedMap((prev) => ({
...prev,
[index]: value,
}));
};
const dataVideo = data || [];
if (loading && !data) {
return (
<Box py={10}>
<Text>Memuat Video...</Text>
<Text fz={{ base: 'sm', md: 'md' }} c="dimmed" ta="center">
Memuat Video...
</Text>
</Box>
);
}
@@ -83,60 +78,71 @@ export default function VideoContent() {
p="md"
radius={26}
bg={colors['white-trans-1']}
w={{ base: '100%', md: '100%' }}
w="100%"
>
<Box>
<Center>
<Box
component="iframe"
src={convertToEmbedUrl(v.linkVideo)}
width="100%"
height={300}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{ borderRadius: 8 }}
/>
</Center>
</Box>
<Box>
<Stack gap="sm" py={10}>
<Text fz="sm" c="dimmed">
{new Date(v.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
<Text fw="bold" fz="sm" lineClamp={1}>
{v.name}
</Text>
<Spoiler
showLabel={
<Text fw="bold" fz="sm" c={colors['blue-button']}>
Show more
</Text>
}
hideLabel={
<Text fw="bold" fz="sm" c={colors['blue-button']}>
Hide details
</Text>
}
expanded={expandedMap[k] || false}
onExpandedChange={(val) => toggleExpanded(k, val)}
<Center>
<Box
component="iframe"
src={convertToEmbedUrl(v.linkVideo)}
width="100%"
height={300}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{ borderRadius: 8 }}
/>
</Center>
<Stack gap="sm" py={10}>
{/* Tanggal: Caption */}
<Text
fz={{ base: 12, md: 14 }}
c="dimmed"
ta="left"
>
{new Date(v.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
{/* Judul Video: Subsection (H3) */}
<Title
order={3}
c="dark"
ta="left"
lh={1.3}
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{v.name}
</Title>
{/* Deskripsi: Body kecil */}
<Text
ta="justify"
fz={{ base: 13, md: 14 }}
c="dimmed"
style={{ wordBreak: 'break-word' }}
lineClamp={3}
>
<span dangerouslySetInnerHTML={{ __html: v.deskripsi }} />
</Text>
<Group justify="right">
<Button
onClick={() => router.push(`/darmasaba/desa/galery/video/${v.id}`)}
bg={colors['blue-button']}
fz={{ base: 'sm', md: 'md' }}
>
<Text
ta="justify"
fz="sm"
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
style={{wordBreak: "break-word", whiteSpace: "normal"}}
/>
</Spoiler>
</Stack>
</Box>
Detail
</Button>
</Group>
</Stack>
</Paper>
</Box>
))}
</SimpleGrid>
<Center>
<Pagination
value={page}
@@ -150,7 +156,6 @@ export default function VideoContent() {
);
}
// ✅ Fix: convert YouTube URL ke embed
function convertToEmbedUrl(youtubeUrl: string): string {
try {
const url = new URL(youtubeUrl);
@@ -161,4 +166,4 @@ function convertToEmbedUrl(youtubeUrl: string): string {
console.error('Error converting YouTube URL to embed:', err);
return youtubeUrl;
}
}
}

View File

@@ -0,0 +1,209 @@
'use client';
import colors from '@/con/colors';
import {
Alert,
Box,
Button,
Card,
Group,
Paper,
Skeleton,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconInfoCircle, IconVideo } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import BackButton from '../../../layanan/_com/BackButto';
function convertToEmbedUrl(youtubeUrl: string): string {
try {
const url = new URL(youtubeUrl);
let videoId = '';
if (url.hostname === 'youtu.be') {
videoId = url.pathname.slice(1);
} else if (url.hostname.includes('youtube.com')) {
videoId = url.searchParams.get('v') || '';
}
return videoId ? `https://www.youtube.com/embed/${videoId}` : youtubeUrl;
} catch {
return youtubeUrl;
}
}
export default function DetailVideoUser() {
const params = useParams<{ id: string }>();
const router = useRouter();
const videoState = useProxy(stateGallery.video);
const [videoError, setVideoError] = useState(false);
const id = Array.isArray(params.id) ? params.id[0] : params.id;
useShallowEffect(() => {
if (id) {
videoState.findUnique.load(id);
}
}, [id]);
const data = videoState.findUnique.data;
if (!videoState.findUnique && !id) {
return (
<Box py="xl" px={{ base: 'md', md: 'lg' }}>
<Skeleton height={400} radius="md" />
</Box>
);
}
if (!data) {
return (
<Box py="xl" px={{ base: 'md', md: 'lg' }}>
<Alert
icon={<IconInfoCircle size={20} />}
title="Video tidak ditemukan"
color="red"
radius="md"
>
<Text fz={{ base: 'sm', md: 'md' }} c="red.9">
Video yang Anda cari tidak tersedia.
</Text>
</Alert>
<Button
leftSection={<IconArrowBack size={16} />}
mt="md"
onClick={() => router.push('/darmasaba/galeri/video')}
variant="outline"
>
Kembali ke Galeri
</Button>
</Box>
);
}
const embedUrl = data.linkVideo ? convertToEmbedUrl(data.linkVideo) : null;
return (
<Box py="xl" px={{ base: 'md', md: 100 }}>
{/* Tombol Kembali */}
<Box>
<BackButton />
</Box>
{/* Header - Dijadikan Title */}
<Title
order={1}
ta="center"
c={colors['blue-button']}
mb="lg"
lh={{ base: 1.2, md: 1.25 }}
>
{data.name || 'Video Galeri Desa'}
</Title>
{/* Konten Utama */}
<Card
shadow="sm"
radius="md"
p={{ base: 'md', md: 'xl' }}
bg={colors['white-1']}
>
<Stack gap="lg">
{/* Video */}
{embedUrl ? (
<Box
pos="relative"
style={{ paddingBottom: '56.25%', height: 0, overflow: 'hidden' }}
>
<iframe
src={embedUrl}
title={data.name}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
borderRadius: 8,
border: 'none',
}}
onError={() => setVideoError(true)}
/>
</Box>
) : videoError ? (
<Alert
color="orange"
icon={<IconVideo size={20} />}
title="Gagal memuat video"
radius="md"
>
<Text fz={{ base: 'xs', md: 'sm' }} c="orange.9">
Mohon maaf, video tidak dapat diputar.
</Text>
</Alert>
) : (
<Alert
color="gray"
icon={<IconInfoCircle size={20} />}
title="Tidak ada video"
radius="md"
>
<Text fz={{ base: 'xs', md: 'sm' }} c="dimmed">
Konten video belum tersedia.
</Text>
</Alert>
)}
{/* Informasi Tambahan */}
{data.createdAt && (
<Group gap="xs" justify="center" wrap="nowrap">
<ThemeIcon variant="light" size="sm" radius="xl">
<IconInfoCircle size={14} />
</ThemeIcon>
<Text
fz={{ base: 'xs', md: 'sm' }}
c="dimmed"
lh={{ base: 1.4, md: 1.5 }}
>
Diunggah pada{' '}
{new Date(data.createdAt).toLocaleDateString('id-ID', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</Text>
</Group>
)}
{/* Deskripsi */}
{data.deskripsi && (
<Paper p="md" bg="gray.0" radius="md">
<Text
fz={{ base: 'sm', md: 'md' }}
c="dark"
ta={"justify"}
dangerouslySetInnerHTML={{ __html: data.deskripsi }}
style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
lineHeight: 1.6,
}}
/>
</Paper>
)}
</Stack>
</Card>
</Box>
);
}

View File

@@ -100,7 +100,7 @@ function Page() {
{data.name}
</Text>
</Container>
<Box px={{ base: "md", md: 100 }}>
<Box px={{ base: "35", md: 100 }}>
<Stack gap="md">
<Text
dangerouslySetInnerHTML={{ __html: data.deskripsi }}

View File

@@ -3,7 +3,7 @@
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors';
import { ActionIcon, Box, Divider, Flex, Group, Skeleton, Stack, Text, Tooltip } from '@mantine/core';
import { ActionIcon, Box, Divider, Flex, Group, Skeleton, Stack, Text, Title, Tooltip } from '@mantine/core';
import { IconBrandFacebook, IconBrandInstagram, IconBrandTwitter, IconBrandWhatsapp } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
@@ -39,30 +39,38 @@ function PelayananPendudukNonPermanent() {
) : (
<Stack gap="xl">
<Box>
<Text fz={{ base: "xl", md: "2xl" }} fw={700} lh={1.3} c="dark">
<Title
order={1}
fz={{ base: 'lg', md: 'xl' }}
fw={700}
lh={{ base: 1.3, md: 1.3 }}
c="dark"
>
{data?.name || "Judul belum tersedia"}
</Text>
</Title>
</Box>
<Box>
{data?.deskripsi ? (
<Text
fz={{ base: "sm", md: "md" }}
lh={1.7}
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.6, md: 1.7 }}
ta="justify"
c="dimmed"
c="black"
dangerouslySetInnerHTML={{ __html: data?.deskripsi }}
style={{wordBreak: "break-word", whiteSpace: "normal"}}
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
/>
) : (
<Text fz="sm" c="gray">Deskripsi belum tersedia.</Text>
<Text fz="xs" c="gray">
Deskripsi belum tersedia.
</Text>
)}
</Box>
<Divider color={colors["blue-button"]} size="sm" />
<Flex justify="space-between" align="center" wrap="wrap" gap="md">
<Text fz={{ base: "xs", md: "sm" }} c="dimmed">
<Text fz={{ base: 'xs', md: 'sm' }} lh={{ base: 1.4, md: 1.5 }} c="black">
25 Mei 2021 Darmasaba
</Text>
<Group gap="md">
@@ -96,4 +104,4 @@ function PelayananPendudukNonPermanent() {
);
}
export default PelayananPendudukNonPermanent;
export default PelayananPendudukNonPermanent;

View File

@@ -47,7 +47,7 @@ function PelayananPerizinanBerusaha() {
return (
<Center mih={300}>
<Stack align="center" gap="sm">
<Text fz="lg" fw={500} c="dimmed">
<Text fz={{ base: 'md', md: 'lg' }} fw={500} c="dimmed" lh="sm">
Belum ada informasi layanan yang tersedia
</Text>
<Button component="a" href="https://oss.go.id" target="_blank" radius="xl">
@@ -67,10 +67,10 @@ function PelayananPerizinanBerusaha() {
) : (
<Stack gap="lg">
<Box>
<Title order={2} fw={700} fz={{ base: 22, md: 32 }} mb="sm">
<Title order={2} fw={700} mb="sm">
Perizinan Berusaha Berbasis Risiko melalui OSS
</Title>
<Text fz={{ base: 'sm', md: 'md' }} c="dimmed">
<Text fz={{ base: 'sm', md: 'md' }} c="black" lh="sm">
Sistem Online Single Submission (OSS) untuk pendaftaran NIB
</Text>
</Box>
@@ -83,13 +83,13 @@ function PelayananPerizinanBerusaha() {
/>
<Box>
<Text fw={600} mb="sm" fz={{ base: 'sm', md: 'lg' }}>
<Title order={3} fw={600} mb="sm">
Alur pendaftaran NIB:
</Text>
</Title>
<Stepper
active={active}
onStepClick={(step) => {
if (step <= active) { // Only allow clicking on previous or current steps
if (step <= active) {
setActive(step);
}
}}
@@ -102,28 +102,42 @@ function PelayananPerizinanBerusaha() {
}}
>
<StepperStep label="Langkah 1" description="Daftar Akun">
<Text fz="sm">Membuat akun di portal OSS</Text>
<Text fz={{ base: 'xs', md: 'sm' }} lh="sm">
Membuat akun di portal OSS
</Text>
</StepperStep>
<StepperStep label="Langkah 2" description="Isi Data Perusahaan">
<Text fz="sm">Lengkapi informasi perusahaan, data pemegang saham, dan alamat</Text>
<Text fz={{ base: 'xs', md: 'sm' }} lh="sm">
Lengkapi informasi perusahaan, data pemegang saham, dan alamat
</Text>
</StepperStep>
<StepperStep label="Langkah 3" description="Pilih KBLI">
<Text fz="sm">Menentukan kode KBLI sesuai jenis usaha</Text>
<Text fz={{ base: 'xs', md: 'sm' }} lh="sm">
Menentukan kode KBLI sesuai jenis usaha
</Text>
</StepperStep>
<StepperStep label="Langkah 4" description="Unggah Dokumen">
<Text fz="sm">Unggah akta pendirian, surat izin, dan dokumen wajib lainnya</Text>
<Text fz={{ base: 'xs', md: 'sm' }} lh="sm">
Unggah akta pendirian, surat izin, dan dokumen wajib lainnya
</Text>
</StepperStep>
<StepperStep label="Langkah 5" description="Verifikasi Instansi">
<Text fz="sm">Menunggu verifikasi dan persetujuan dari pihak berwenang</Text>
<Text fz={{ base: 'xs', md: 'sm' }} lh="sm">
Menunggu verifikasi dan persetujuan dari pihak berwenang
</Text>
</StepperStep>
<StepperStep label="Langkah 6" description="Terbit NIB">
<Text fz="sm">Menerima NIB sebagai identitas resmi usaha</Text>
<Text fz={{ base: 'xs', md: 'sm' }} lh="sm">
Menerima NIB sebagai identitas resmi usaha
</Text>
</StepperStep>
<StepperCompleted>
<Center>
<Stack align="center" gap="xs">
<IconCheck size={40} color="green" />
<Text fz="sm" fw={500}>Proses pendaftaran selesai</Text>
<Text fz={{ base: 'xs', md: 'sm' }} fw={500} lh="sm">
Proses pendaftaran selesai
</Text>
</Stack>
</Center>
</StepperCompleted>
@@ -159,7 +173,7 @@ function PelayananPerizinanBerusaha() {
)}
</Box>
<Text fz="sm" ta="justify" c="dimmed" mt="md">
<Text fz={{ base: 'xs', md: 'sm' }} ta="justify" c="black" lh="sm" mt="md">
Catatan: Persyaratan dan prosedur dapat berubah sewaktu-waktu. Untuk informasi resmi terbaru, silakan kunjungi situs{' '}
<a href="https://oss.go.id/" target="_blank" rel="noopener noreferrer">
oss.go.id

View File

@@ -2,7 +2,7 @@
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors';
import { BackgroundImage, Box, Button, Center, Group, Pagination, SimpleGrid, Skeleton, Stack, Text, Tooltip } from '@mantine/core';
import { BackgroundImage, Box, Button, Center, Group, Pagination, SimpleGrid, Skeleton, Stack, Text, Title, Tooltip } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconFileDescription, IconInfoCircle } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
@@ -35,7 +35,7 @@ function PelayananSuratKeterangan({ search }: { search: string }) {
<Center py="xl">
<Stack align="center" gap="xs">
<IconFileDescription size={40} stroke={1.5} color={colors["blue-button"]} />
<Text c="dimmed" ta="center">
<Text c="dimmed" ta="center" fz={{ base: 'sm', md: 'md' }} lh="sm">
Tidak ada layanan surat keterangan yang ditemukan
</Text>
</Stack>
@@ -48,9 +48,9 @@ function PelayananSuratKeterangan({ search }: { search: string }) {
<Group justify="space-between" align="center" mb="md">
<Group gap="xs">
<IconFileDescription size={28} stroke={1.8} />
<Text fz={{ base: "h4", md: "h2" }} fw={700}>
<Title order={2} c="black">
Layanan Surat Keterangan
</Text>
</Title>
</Group>
<Tooltip label="Pilih layanan surat keterangan sesuai kebutuhan Anda" withArrow>
<IconInfoCircle size={22} stroke={1.8} />
@@ -82,15 +82,15 @@ function PelayananSuratKeterangan({ search }: { search: string }) {
style={{ borderRadius: 16 }}
/>
<Stack justify="space-between" h="100%" gap="md" p="lg" pos="relative">
<Text
<Title
order={3}
c="white"
fw={600}
fz="lg"
ta="center"
lineClamp={2}
lh="sm"
>
{v.name}
</Text>
</Title>
<Group justify="center">
<Button
size="md"
@@ -128,4 +128,4 @@ function PelayananSuratKeterangan({ search }: { search: string }) {
);
}
export default PelayananSuratKeterangan;
export default PelayananSuratKeterangan;

View File

@@ -42,9 +42,10 @@ function PelayananTelunjukSaktiDesa() {
return (
<Box>
<Title order={2} mb="lg" fz={{ base: 22, md: 28 }} fw={700} style={{ lineHeight: 1.4 }}>
Layanan Telunjuk Sakti Desa <br />
<Text span c="dimmed" fz="lg" fw={400}>
<Title order={2} mb="lg" fw={700} style={{ lineHeight: 1.3 }} ta="left">
Layanan Telunjuk Sakti Desa
<Text span c="black" fz={{ base: 'sm', md: 'md' }} fw={400} style={{ lineHeight: 1.5 }}>
{' '}
Terwujudnya sistem administrasi kependudukan terintegrasi berbasis elektronik, cerdas, dan aman
</Text>
</Title>
@@ -53,7 +54,7 @@ function PelayananTelunjukSaktiDesa() {
<Skeleton h={400} radius="lg" />
) : data.length === 0 ? (
<Card shadow="sm" radius="lg" withBorder>
<Text c="dimmed" ta="center" py="xl">
<Text c="black" ta="center" py="xl" fz={{ base: 'sm', md: 'md' }} lh={1.5}>
Belum ada layanan tersedia untuk saat ini
</Text>
</Card>
@@ -72,9 +73,9 @@ function PelayananTelunjukSaktiDesa() {
}}
>
<Stack gap="sm">
<Text fw={700} fz="lg" lh={1.4}>
<Title order={3} fw={700} lh={1.3}>
{v.name}
</Text>
</Title>
<Flex gap="xs" align="center">
<IconExternalLink size={18} stroke={1.5} />
<Text
@@ -82,7 +83,7 @@ function PelayananTelunjukSaktiDesa() {
href={v.link}
target="_blank"
rel="noopener noreferrer"
fz="sm"
fz={{ base: 'xs', md: 'sm' }}
c="blue"
td="underline"
style={{ cursor: 'pointer' }}
@@ -100,4 +101,4 @@ function PelayananTelunjukSaktiDesa() {
);
}
export default PelayananTelunjukSaktiDesa;
export default PelayananTelunjukSaktiDesa;

View File

@@ -1,96 +0,0 @@
import colors from '@/con/colors';
import { Stack, Box, Container, Text, Paper, Group } from '@mantine/core';
import React from 'react';
import BackButton from '../../../layanan/_com/BackButto';
import { IconCalendar, IconClock, IconMapPin } from '@tabler/icons-react';
const dataPengumuman = [
{
id: 1,
judul: 'Sosialisasi Perarem Konservasi Adat di Darmasaba',
tanggal: 'Jumat, 26 April 2025',
jam: '16:00 WITA',
lokasi: 'Wantilan Adat Desa',
deskripsi: 'Para krama diundang hadir dalam sosialisasi perarem (aturan adat) baru yang berkaitan dengan konservasi lingkungan berbasis budaya, termasuk larangan alih fungsi lahan pekarangan suci.'
},
{
id: 2,
judul: 'Sosialisasi Perarem Konservasi Adat di Darmasaba',
tanggal: 'Jumat, 26 April 2025',
jam: '16:00 WITA',
lokasi: 'Wantilan Adat Desa',
deskripsi: 'Para krama diundang hadir dalam sosialisasi perarem (aturan adat) baru yang berkaitan dengan konservasi lingkungan berbasis budaya, termasuk larangan alih fungsi lahan pekarangan suci.'
},
{
id: 3,
judul: 'Sosialisasi Perarem Konservasi Adat di Darmasaba',
tanggal: 'Jumat, 26 April 2025',
jam: '16:00 WITA',
lokasi: 'Wantilan Adat Desa',
deskripsi: 'Para krama diundang hadir dalam sosialisasi perarem (aturan adat) baru yang berkaitan dengan konservasi lingkungan berbasis budaya, termasuk larangan alih fungsi lahan pekarangan suci.'
},
{
id: 4,
judul: 'Sosialisasi Perarem Konservasi Adat di Darmasaba',
tanggal: 'Jumat, 26 April 2025',
jam: '16:00 WITA',
lokasi: 'Wantilan Adat Desa',
deskripsi: 'Para krama diundang hadir dalam sosialisasi perarem (aturan adat) baru yang berkaitan dengan konservasi lingkungan berbasis budaya, termasuk larangan alih fungsi lahan pekarangan suci.'
},
{
id: 5,
judul: 'Sosialisasi Perarem Konservasi Adat di Darmasaba',
tanggal: 'Jumat, 26 April 2025',
jam: '16:00 WITA',
lokasi: 'Wantilan Adat Desa',
deskripsi: 'Para krama diundang hadir dalam sosialisasi perarem (aturan adat) baru yang berkaitan dengan konservasi lingkungan berbasis budaya, termasuk larangan alih fungsi lahan pekarangan suci.'
},
]
function Page() {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
{/* Header */}
<Box px={{ base: "md", md: 100 }}>
<BackButton />
</Box>
<Container size="lg" px="md" >
<Stack align="center" gap="0" >
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
Pengumuman Adat & Budaya
</Text>
<Text ta="center" px="md" pb={10}>
Informasi dan pengumuman resmi terkait pengumuman adat & budaya di Desa Darmasaba.
</Text>
</Stack>
</Container>
<Box px={{ base: 'md', md: 100 }}>
{dataPengumuman.map((v, k) => {
return (
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
<Text fz={'h3'}>{v.judul}</Text>
<Group style={{ color: 'black' }} pb={20}>
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">{v.tanggal}</Text>
</Group>
<Group gap="xs">
<IconClock size={18} />
<Text size="sm">{v.jam}</Text>
</Group>
<Group gap="xs">
<IconMapPin size={18} />
<Text size="sm">{v.lokasi}</Text>
</Group>
</Group>
<Text ta={'justify'}>
{v.deskripsi}
</Text>
</Paper>
)
})}
</Box>
</Stack>
);
}
export default Page;

View File

@@ -1,96 +0,0 @@
import colors from '@/con/colors';
import { Stack, Box, Container, Text, Paper, Group } from '@mantine/core';
import React from 'react';
import BackButton from '../../../layanan/_com/BackButto';
import { IconCalendar, IconClock, IconMapPin } from '@tabler/icons-react';
const dataPengumuman = [
{
id: 1,
judul: 'Pelatihan Dasar Komputer untuk Lansia dan Ibu Rumah Tangga',
tanggal: 'Selasa, 30 April 2025',
jam: '09:00 WITA',
lokasi: 'Perpustakaan Desa',
deskripsi: 'Belajar dari nol: cara menyalakan komputer, menulis di Word, membuat email, dan dasar penggunaan HP Android untuk komunikasi dan akses layanan desa online. Terbuka untuk 20 peserta.'
},
{
id: 2,
judul: 'Pelatihan Dasar Komputer untuk Lansia dan Ibu Rumah Tangga',
tanggal: 'Selasa, 30 April 2025',
jam: '09:00 WITA',
lokasi: 'Perpustakaan Desa',
deskripsi: 'Belajar dari nol: cara menyalakan komputer, menulis di Word, membuat email, dan dasar penggunaan HP Android untuk komunikasi dan akses layanan desa online. Terbuka untuk 20 peserta.'
},
{
id: 3,
judul: 'Pelatihan Dasar Komputer untuk Lansia dan Ibu Rumah Tangga',
tanggal: 'Selasa, 30 April 2025',
jam: '09:00 WITA',
lokasi: 'Perpustakaan Desa',
deskripsi: 'Belajar dari nol: cara menyalakan komputer, menulis di Word, membuat email, dan dasar penggunaan HP Android untuk komunikasi dan akses layanan desa online. Terbuka untuk 20 peserta.'
},
{
id: 4,
judul: 'Pelatihan Dasar Komputer untuk Lansia dan Ibu Rumah Tangga',
tanggal: 'Selasa, 30 April 2025',
jam: '09:00 WITA',
lokasi: 'Perpustakaan Desa',
deskripsi: 'Belajar dari nol: cara menyalakan komputer, menulis di Word, membuat email, dan dasar penggunaan HP Android untuk komunikasi dan akses layanan desa online. Terbuka untuk 20 peserta.'
},
{
id: 5,
judul: 'Pelatihan Dasar Komputer untuk Lansia dan Ibu Rumah Tangga',
tanggal: 'Selasa, 30 April 2025',
jam: '09:00 WITA',
lokasi: 'Perpustakaan Desa',
deskripsi: 'Belajar dari nol: cara menyalakan komputer, menulis di Word, membuat email, dan dasar penggunaan HP Android untuk komunikasi dan akses layanan desa online. Terbuka untuk 20 peserta.'
},
]
function Page() {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
{/* Header */}
<Box px={{ base: "md", md: 100 }}>
<BackButton />
</Box>
<Container size="lg" px="md" >
<Stack align="center" gap="0" >
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
Pengumuman Digitalisasi Desa
</Text>
<Text ta="center" px="md" pb={10}>
Informasi dan pengumuman resmi terkait pengumuman digitalisasi desa
</Text>
</Stack>
</Container>
<Box px={{ base: 'md', md: 100 }}>
{dataPengumuman.map((v, k) => {
return (
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
<Text fz={'h3'}>{v.judul}</Text>
<Group style={{ color: 'black' }} pb={20}>
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">{v.tanggal}</Text>
</Group>
<Group gap="xs">
<IconClock size={18} />
<Text size="sm">{v.jam}</Text>
</Group>
<Group gap="xs">
<IconMapPin size={18} />
<Text size="sm">{v.lokasi}</Text>
</Group>
</Group>
<Text ta={'justify'}>
{v.deskripsi}
</Text>
</Paper>
)
})}
</Box>
</Stack>
);
}
export default Page;

View File

@@ -1,96 +0,0 @@
import colors from '@/con/colors';
import { Stack, Box, Container, Text, Paper, Group } from '@mantine/core';
import React from 'react';
import BackButton from '../../../layanan/_com/BackButto';
import { IconCalendar, IconClock, IconMapPin } from '@tabler/icons-react';
const dataPengumuman = [
{
id: 1,
judul: 'Pelatihan Digital Marketing untuk UMKM Desa',
tanggal: 'Rabu, 23 April 2025',
jam: '13:00 WITA',
lokasi: 'Aula Kantor Desa',
deskripsi: 'Para pelaku UMKM diundang untuk mengikuti pelatihan dasar digital marketing: mulai dari membuat akun bisnis, copywriting produk, hingga promosi melalui media sosial. Peserta akan mendapat sertifikat dan materi pelatihan.'
},
{
id: 2,
judul: 'Pelatihan Digital Marketing untuk UMKM Desa',
tanggal: 'Rabu, 23 April 2025',
jam: '13:00 WITA',
lokasi: 'Aula Kantor Desa',
deskripsi: 'Para pelaku UMKM diundang untuk mengikuti pelatihan dasar digital marketing: mulai dari membuat akun bisnis, copywriting produk, hingga promosi melalui media sosial. Peserta akan mendapat sertifikat dan materi pelatihan.'
},
{
id: 3,
judul: 'Pelatihan Digital Marketing untuk UMKM Desa',
tanggal: 'Rabu, 23 April 2025',
jam: '13:00 WITA',
lokasi: 'Aula Kantor Desa',
deskripsi: 'Para pelaku UMKM diundang untuk mengikuti pelatihan dasar digital marketing: mulai dari membuat akun bisnis, copywriting produk, hingga promosi melalui media sosial. Peserta akan mendapat sertifikat dan materi pelatihan.'
},
{
id: 4,
judul: 'Pelatihan Digital Marketing untuk UMKM Desa',
tanggal: 'Rabu, 23 April 2025',
jam: '13:00 WITA',
lokasi: 'Aula Kantor Desa',
deskripsi: 'Para pelaku UMKM diundang untuk mengikuti pelatihan dasar digital marketing: mulai dari membuat akun bisnis, copywriting produk, hingga promosi melalui media sosial. Peserta akan mendapat sertifikat dan materi pelatihan.'
},
{
id: 5,
judul: 'Pelatihan Digital Marketing untuk UMKM Desa',
tanggal: 'Rabu, 23 April 2025',
jam: '13:00 WITA',
lokasi: 'Aula Kantor Desa',
deskripsi: 'Para pelaku UMKM diundang untuk mengikuti pelatihan dasar digital marketing: mulai dari membuat akun bisnis, copywriting produk, hingga promosi melalui media sosial. Peserta akan mendapat sertifikat dan materi pelatihan.'
},
]
function Page() {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
{/* Header */}
<Box px={{ base: "md", md: 100 }}>
<BackButton />
</Box>
<Container size="lg" px="md" >
<Stack align="center" gap="0" >
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
Pengumuman Ekonomi & UMKM
</Text>
<Text ta="center" px="md" pb={10}>
Informasi dan pengumuman resmi terkait pengumuman ekonomi & umkm
</Text>
</Stack>
</Container>
<Box px={{ base: 'md', md: 100 }}>
{dataPengumuman.map((v, k) => {
return (
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
<Text fz={'h3'}>{v.judul}</Text>
<Group style={{ color: 'black' }} pb={20}>
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">{v.tanggal}</Text>
</Group>
<Group gap="xs">
<IconClock size={18} />
<Text size="sm">{v.jam}</Text>
</Group>
<Group gap="xs">
<IconMapPin size={18} />
<Text size="sm">{v.lokasi}</Text>
</Group>
</Group>
<Text ta={'justify'}>
{v.deskripsi}
</Text>
</Paper>
)
})}
</Box>
</Stack>
);
}
export default Page;

View File

@@ -1,96 +0,0 @@
import colors from '@/con/colors';
import { Stack, Box, Container, Text, Paper, Group } from '@mantine/core';
import React from 'react';
import BackButton from '../../../layanan/_com/BackButto';
import { IconCalendar, IconClock, IconMapPin } from '@tabler/icons-react';
const dataPengumuman = [
{
id: 1,
judul: 'Gotong Royong Bersih Sungai dan Drainase',
tanggal: 'Minggu, 21 April 2025',
jam: '06:30 WITA',
lokasi: 'Titik Kumpul: Poskamling RW 02',
deskripsi: 'Seluruh warga RW 02 diimbau ikut serta dalam kegiatan bersih-bersih aliran sungai dan saluran air untuk mencegah banjir saat musim hujan. Disediakan alat kebersihan dan konsumsi ringan.'
},
{
id: 2,
judul: 'Gotong Royong Bersih Sungai dan Drainase',
tanggal: 'Minggu, 21 April 2025',
jam: '06:30 WITA',
lokasi: 'Titik Kumpul: Poskamling RW 02',
deskripsi: 'Seluruh warga RW 02 diimbau ikut serta dalam kegiatan bersih-bersih aliran sungai dan saluran air untuk mencegah banjir saat musim hujan. Disediakan alat kebersihan dan konsumsi ringan.'
},
{
id: 3,
judul: 'Gotong Royong Bersih Sungai dan Drainase',
tanggal: 'Minggu, 21 April 2025',
jam: '06:30 WITA',
lokasi: 'Titik Kumpul: Poskamling RW 02',
deskripsi: 'Seluruh warga RW 02 diimbau ikut serta dalam kegiatan bersih-bersih aliran sungai dan saluran air untuk mencegah banjir saat musim hujan. Disediakan alat kebersihan dan konsumsi ringan.'
},
{
id: 4,
judul: 'Gotong Royong Bersih Sungai dan Drainase',
tanggal: 'Minggu, 21 April 2025',
jam: '06:30 WITA',
lokasi: 'Titik Kumpul: Poskamling RW 02',
deskripsi: 'Seluruh warga RW 02 diimbau ikut serta dalam kegiatan bersih-bersih aliran sungai dan saluran air untuk mencegah banjir saat musim hujan. Disediakan alat kebersihan dan konsumsi ringan.'
},
{
id: 5,
judul: 'Gotong Royong Bersih Sungai dan Drainase',
tanggal: 'Minggu, 21 April 2025',
jam: '06:30 WITA',
lokasi: 'Titik Kumpul: Poskamling RW 02',
deskripsi: 'Seluruh warga RW 02 diimbau ikut serta dalam kegiatan bersih-bersih aliran sungai dan saluran air untuk mencegah banjir saat musim hujan. Disediakan alat kebersihan dan konsumsi ringan.'
},
]
function Page() {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
{/* Header */}
<Box px={{ base: "md", md: 100 }}>
<BackButton />
</Box>
<Container size="lg" px="md" >
<Stack align="center" gap="0" >
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
Pengumuman Lingkungan & Bencana
</Text>
<Text ta="center" px="md" pb={10}>
Informasi dan pengumuman resmi terkait pengumuman lingkungan & bencana
</Text>
</Stack>
</Container>
<Box px={{ base: 'md', md: 100 }}>
{dataPengumuman.map((v, k) => {
return (
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
<Text fz={'h3'}>{v.judul}</Text>
<Group style={{ color: 'black' }} pb={20}>
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">{v.tanggal}</Text>
</Group>
<Group gap="xs">
<IconClock size={18} />
<Text size="sm">{v.jam}</Text>
</Group>
<Group gap="xs">
<IconMapPin size={18} />
<Text size="sm">{v.lokasi}</Text>
</Group>
</Group>
<Text ta={'justify'}>
{v.deskripsi}
</Text>
</Paper>
)
})}
</Box>
</Stack>
);
}
export default Page;

View File

@@ -1,96 +0,0 @@
import colors from '@/con/colors';
import { Stack, Box, Container, Text, Paper, Group } from '@mantine/core';
import React from 'react';
import BackButton from '../../../layanan/_com/BackButto';
import { IconCalendar, IconClock, IconMapPin } from '@tabler/icons-react';
const dataPengumuman = [
{
id: 1,
judul: 'Pendaftaran Bimbingan Belajar Gratis untuk Pelajar',
tanggal: 'Sabtu, 20 April 2025',
jam: '08:00 WITA',
lokasi: 'Balai Banjar Desa Darmasaba',
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
},
{
id: 2,
judul: 'Lomba Video Pendek Hari Lingkungan',
tanggal: 'Deadline: 28 April 2025',
jam: '08:00 WITA',
lokasi: 'Online Submission',
deskripsi: 'Karang Taruna Desa mengadakan lomba video pendek bertema "Lingkunganku, Tanggung Jawabku". Pemenang akan diumumkan saat acara Hari Desa Hijau. Total hadiah Rp1.000.000.'
},
{
id: 3,
judul: 'Pendaftaran Bimbingan Belajar Gratis untuk Pelajar',
tanggal: 'Sabtu, 20 April 2025',
jam: '08:00 WITA',
lokasi: 'Balai Banjar Desa Darmasaba',
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
},
{
id: 4,
judul: 'Lomba Video Pendek Hari Lingkungan',
tanggal: 'Deadline: 28 April 2025',
jam: '08:00 WITA',
lokasi: 'Online Submission',
deskripsi: 'Karang Taruna Desa mengadakan lomba video pendek bertema "Lingkunganku, Tanggung Jawabku". Pemenang akan diumumkan saat acara Hari Desa Hijau. Total hadiah Rp1.000.000.'
},
{
id: 5,
judul: 'Pendaftaran Bimbingan Belajar Gratis untuk Pelajar',
tanggal: 'Sabtu, 20 April 2025',
jam: '08:00 WITA',
lokasi: 'Balai Banjar Desa Darmasaba',
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
},
]
function Page() {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
{/* Header */}
<Box px={{ base: "md", md: 100 }}>
<BackButton />
</Box>
<Container size="lg" px="md" >
<Stack align="center" gap="0" >
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
Pengumuman Pendidikan & Kepemudaan
</Text>
<Text ta="center" px="md" pb={10}>
Informasi dan pengumuman resmi terkait pengumuman pendidikan & kepemudaan
</Text>
</Stack>
</Container>
<Box px={{ base: 'md', md: 100 }}>
{dataPengumuman.map((v, k) => {
return (
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
<Text fz={'h3'}>{v.judul}</Text>
<Group style={{ color: 'black' }} pb={20}>
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">{v.tanggal}</Text>
</Group>
<Group gap="xs">
<IconClock size={18} />
<Text size="sm">{v.jam}</Text>
</Group>
<Group gap="xs">
<IconMapPin size={18} />
<Text size="sm">{v.lokasi}</Text>
</Group>
</Group>
<Text ta={'justify'}>
{v.deskripsi}
</Text>
</Paper>
)
})}
</Box>
</Stack>
);
}
export default Page;

View File

@@ -1,96 +0,0 @@
import colors from '@/con/colors';
import { Stack, Box, Container, Text, Paper, Group } from '@mantine/core';
import React from 'react';
import BackButton from '../../../layanan/_com/BackButto';
import { IconCalendar, IconClock, IconMapPin } from '@tabler/icons-react';
const dataPengumuman = [
{
id: 1,
judul: 'Pemeriksaan Kesehatan Gratis Untuk Warga Desa',
tanggal: 'Sabtu, 20 April 2025',
jam: '08:00 WITA',
lokasi: 'Balai Banjar Desa Darmasaba',
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
},
{
id: 2,
judul: 'Pemeriksaan Kesehatan Gratis Untuk Warga Desa',
tanggal: 'Sabtu, 20 April 2025',
jam: '08:00 WITA',
lokasi: 'Balai Banjar Desa Darmasaba',
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
},
{
id: 3,
judul: 'Pemeriksaan Kesehatan Gratis Untuk Warga Desa',
tanggal: 'Sabtu, 20 April 2025',
jam: '08:00 WITA',
lokasi: 'Balai Banjar Desa Darmasaba',
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
},
{
id: 4,
judul: 'Pemeriksaan Kesehatan Gratis Untuk Warga Desa',
tanggal: 'Sabtu, 20 April 2025',
jam: '08:00 WITA',
lokasi: 'Balai Banjar Desa Darmasaba',
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
},
{
id: 5,
judul: 'Pemeriksaan Kesehatan Gratis Untuk Warga Desa',
tanggal: 'Sabtu, 20 April 2025',
jam: '08:00 WITA',
lokasi: 'Balai Banjar Desa Darmasaba',
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
},
]
function Page() {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
{/* Header */}
<Box px={{ base: "md", md: 100 }}>
<BackButton />
</Box>
<Container size="lg" px="md" >
<Stack align="center" gap="0" >
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
Pengumuman Sosial & Kesehatan
</Text>
<Text ta="center" px="md" pb={10}>
Informasi dan pengumuman resmi terkait pengumuman sosial & kesehatan
</Text>
</Stack>
</Container>
<Box px={{ base: 'md', md: 100 }}>
{dataPengumuman.map((v, k) => {
return (
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
<Text fz={'h3'}>{v.judul}</Text>
<Group style={{ color: 'black' }} pb={20}>
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">{v.tanggal}</Text>
</Group>
<Group gap="xs">
<IconClock size={18} />
<Text size="sm">{v.jam}</Text>
</Group>
<Group gap="xs">
<IconMapPin size={18} />
<Text size="sm">{v.lokasi}</Text>
</Group>
</Group>
<Text ta={'justify'}>
{v.deskripsi}
</Text>
</Paper>
)
})}
</Box>
</Stack>
);
}
export default Page;

View File

@@ -1,58 +1,94 @@
'use client'
import stateDesaPengumuman from '@/app/admin/(dashboard)/_state/desa/pengumuman';
import colors from '@/con/colors';
import { Box, Container, Group, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { Box, Container, Group, Paper, Skeleton, Stack, Text, Title } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { useParams } from 'next/navigation';
import { useProxy } from 'valtio/utils';
import BackButton from '../../../layanan/_com/BackButto';
import NewsReader from '@/app/darmasaba/_com/NewsReader';
import BackButton from '../../../layanan/_com/BackButto';
function Page() {
const detail = useProxy(stateDesaPengumuman.pengumuman.findUnique)
const params = useParams()
const detail = useProxy(stateDesaPengumuman.pengumuman.findUnique);
const params = useParams();
useShallowEffect(() => {
stateDesaPengumuman.pengumuman.findUnique.load(params?.id as string)
}, [])
stateDesaPengumuman.pengumuman.findUnique.load(params?.id as string);
}, []);
if (!detail.data) {
return (
<Box>
<Skeleton h={400} />
</Box>
)
);
}
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
{/* Header */}
<Box px={{ base: "md", md: 100 }}>
<Box px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
<Container size="lg" px="md">
<Group>
<NewsReader />
</Group>
<Stack gap="xs" >
<Group justify={"space-between"} align={"center"}>
<Text fz={{ base: "2rem", md: "2rem" }} c={colors["blue-button"]} fw="bold" >
{detail.data?.judul}
<Stack gap="xs">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Title
order={1}
c={colors['blue-button']}
fz={{ base: 28, md: 36 }}
style={{
wordBreak: 'break-word',
flex: '1 1 auto',
minWidth: 0
}}
>
{detail.data?.judul}
</Title>
<Paper bg={colors['blue-button']} p={8} style={{ flexShrink: 0 }}>
<Text c={colors['white-1']} fz={{ base: 'xs', md: 'sm' }} lh={1.2}>
{detail.data?.CategoryPengumuman?.name}
</Text>
<Group justify='end'>
<Paper bg={colors['blue-button']} p={5}>
<Text c={colors['white-1']}>{detail.data?.CategoryPengumuman?.name}</Text>
</Paper>
</Group>
</Group>
<Paper bg={colors["white-1"]} p="md">
<Text id='news-content' fz={"md"} style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: detail.data?.content }} />
<Text fz={"md"} c={colors["blue-button"]} fw="bold" >
</Paper>
</Group>
<Paper
bg={colors['white-1']}
p="md"
w="100%"
mih={{ base: 200, md: 300 }}
>
<Text
px="lg"
id="news-content"
fz={{ base: 14, md: 16 }}
lh={{ base: 1.6, md: 1.6 }}
style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
width: '100%'
}}
dangerouslySetInnerHTML={{ __html: detail.data?.content }}
/>
<Text
px="lg"
fz={{ base: 12, md: 14 }}
c={colors['blue-button']}
fw="bold"
lh={{ base: 1.4, md: 1.4 }}
mt="md"
>
{new Date(detail.data?.createdAt).toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
year: 'numeric',
})}
</Text>
</Paper>
@@ -62,4 +98,4 @@ function Page() {
);
}
export default Page;
export default Page;

View File

@@ -2,14 +2,13 @@
/* eslint-disable react-hooks/exhaustive-deps */
import stateDesaPengumuman from '@/app/admin/(dashboard)/_state/desa/pengumuman';
import colors from '@/con/colors';
import { Box, Container, Group, Paper, Stack, Text } from '@mantine/core';
import { Box, Container, Group, Paper, Stack, Text, Title } from '@mantine/core';
import { IconCalendar } from '@tabler/icons-react';
import { useProxy } from 'valtio/utils';
import BackButton from '../../layanan/_com/BackButto';
import { useEffect } from 'react';
import { useParams } from 'next/navigation';
function Page() {
const unwrappedParams = useParams();
const kategoriState = useProxy(stateDesaPengumuman);
@@ -26,48 +25,85 @@ function Page() {
<Box px={{ base: "md", md: 100 }}>
<BackButton />
</Box>
<Container size="lg" px="md" >
<Stack align="center" gap="0" >
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
{categoryName.split('-').map(word =>
<Container size="lg" px="md">
<Stack align="center" gap="xs">
<Title
order={1}
c={colors["blue-button"]}
ta="center"
style={{ fontWeight: 'bold' }}
>
{categoryName.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')}
</Text>
<Text ta="center" px="md" pb={10}>
</Title>
<Text
ta="center"
px="md"
pb="sm"
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.5, md: 1.6 }}
c="dimmed"
>
Informasi dan pengumuman resmi terkait {categoryName.split('-').join(' ')}
</Text>
</Stack>
</Container>
<Box px={{ base: 'md', md: 100 }}>
{!kategoriState.pengumuman.findMany.data?.length ? (
<Paper p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
Tidak ada pengumuman yang ditemukan
<Text
fz={{ base: 'sm', md: 'md' }}
ta="center"
c="dimmed"
>
Tidak ada pengumuman yang ditemukan
</Text>
</Paper>
) : kategoriState.pengumuman.findMany.data?.map((v, k) => {
return (
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
<Text fz={'h3'}>{v.judul}</Text>
<Group style={{ color: 'black' }} pb={20}>
) : (
kategoriState.pengumuman.findMany.data?.map((v, k) => (
<Paper
mb="md"
key={k}
withBorder
p="lg"
radius="md"
shadow="md"
bg={colors["white-1"]}
>
<Title order={3}>{v.judul}</Title>
<Group style={{ color: 'black' }} pb="sm">
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">
{v.createdAt ? new Date(v.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
}) : 'No date available'}
<Text
fz={{ base: 'xs', md: 'sm' }}
lh={{ base: 1.4, md: 1.5 }}
>
{v.createdAt
? new Date(v.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
: 'No date available'}
</Text>
</Group>
</Group>
<Text ta={'justify'}>
<Text
ta="justify"
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.6, md: 1.7 }}
>
{v.deskripsi}
</Text>
</Paper>
)
})}
))
)}
</Box>
</Stack>
);
}
export default Page;
export default Page;

View File

@@ -9,7 +9,6 @@ import {
Center,
Container,
Divider,
Flex,
Grid,
GridCol,
Group,
@@ -22,7 +21,7 @@ import {
Text,
TextInput,
Title,
UnstyledButton,
UnstyledButton
} from '@mantine/core';
import { IconCalendar, IconClock, IconSearch } from '@tabler/icons-react';
import { useTransitionRouter } from 'next-view-transitions';
@@ -98,10 +97,14 @@ function Page() {
<Container size="lg" px="md">
<Stack align="center" gap="0">
<Text fz={{ base: '2rem', md: '3.4rem' }} c={colors['blue-button']} fw="bold" ta="center">
<Title
order={1}
c={colors['blue-button']}
ta="center"
>
Pengumuman Desa Darmasaba
</Text>
<Text ta="center" px="md" pb={10}>
</Title>
<Text ta="center" px="md" pb={10} fz={{ base: 'sm', md: 'md' }} lh="sm">
Informasi dan pengumuman resmi terkait kegiatan dan kebijakan Desa Darmasaba
</Text>
</Stack>
@@ -126,17 +129,17 @@ function Page() {
withCloseButton={false}
title={item.CategoryPengumuman?.name || 'Pengumuman'}
>
<Stack gap={"xs"}>
<Text fz="sm" fw="bold" c="black" style={{ textTransform: 'uppercase' }}>
<Stack gap="xs">
<Text fz={{ base: 'sm', md: 'sm' }} fw="bold" c="black" style={{ textTransform: 'uppercase' }}>
{item.judul}
</Text>
<Text ta="justify" fz="sm" c="black" lineClamp={3} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
<Text ta="justify" fz={{ base: 'xs', md: 'sm' }} c="black" lineClamp={3} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Stack>
<Flex pt={20} gap="md" justify="space-between">
<Group pt={20} gap="md" justify="space-between">
<Group style={{ color: 'black' }}>
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">
<Text fz={{ base: 'xs', md: 'sm' }}>
{new Date(item.createdAt).toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
@@ -147,7 +150,7 @@ function Page() {
</Group>
<Group gap="xs">
<IconClock size={18} />
<Text size="sm">
<Text fz={{ base: 'xs', md: 'sm' }}>
{new Date(item.createdAt).toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
@@ -157,11 +160,11 @@ function Page() {
</Group>
</Group>
<Anchor variant="transparent" href={`/darmasaba/desa/pengumuman/${item.CategoryPengumuman?.name}/${item.id}`}>
<Text fs="unset" c={colors['blue-button']} fz="sm">
<Text fs="unset" c={colors['blue-button']} fz={{ base: 'xs', md: 'sm' }}>
Baca Selengkapnya
</Text>
</Anchor>
</Flex>
</Group>
</Notification>
))
)}
@@ -169,19 +172,19 @@ function Page() {
<Paper p="md">
<Stack gap="xs">
<Text fw="bold" fz="lg" c={colors['blue-button']}>
<Title order={3} c={colors['blue-button']}>
Kategori
</Text>
</Title>
{stateDesaPengumuman.category.findMany.data?.map((v: any, k) => {
const count = v._count?.pengumumans || 0;
return (
<UnstyledButton component={Link} href={`/darmasaba/desa/pengumuman/${v.name}`} key={k}>
<Paper bg={colors['BG-trans']} p={5}>
<Group px={3} justify="space-between">
<Text fz="md" c="black">
<Text fz={{ base: 'sm', md: 'md' }} c="black">
{v.name}
</Text>
<Text fz="md" c="black">
<Text fz={{ base: 'sm', md: 'md' }} c="black">
{count}
</Text>
</Group>
@@ -200,7 +203,7 @@ function Page() {
<Divider mb={10} color={colors['blue-button']} />
<Grid>
<GridCol span={{ base: 12, md: 8 }}>
<Title order={3}>Daftar Pengumuman</Title>
<Title order={2}>Daftar Pengumuman</Title>
</GridCol>
<GridCol span={{ base: 12, md: 4 }}>
<TextInput
@@ -210,6 +213,7 @@ function Page() {
w="100%"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
fz={{ base: 'sm', md: 'md' }}
/>
</GridCol>
</Grid>
@@ -223,7 +227,9 @@ function Page() {
</SimpleGrid>
) : !state.findMany.data?.length ? (
<Notification withCloseButton={false} h={100}>
Tidak ada pengumuman yang ditemukan
<Text fz={{ base: 'sm', md: 'md' }} ta="center">
Tidak ada pengumuman yang ditemukan
</Text>
</Notification>
) : (
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="lg" verticalSpacing="lg">
@@ -231,26 +237,26 @@ function Page() {
<Paper key={item.id} p="md" withBorder radius="md" h="100%">
<Stack h="100%" justify="space-between">
<div>
<Text fw={600} c={colors['blue-button']} mb={5}>
<Text fw={600} c={colors['blue-button']} mb={5} fz={{ base: 'sm', md: 'md' }}>
{item.CategoryPengumuman?.name || 'Pengumuman'}
</Text>
<Text fz="lg" fw={700} mb="sm" lineClamp={2} style={{ textTransform: 'uppercase' }}>
<Text fw={700} mb="sm" lineClamp={2} style={{ textTransform: 'uppercase' }} fz={{ base: 'sm', md: 'lg' }}>
{item.judul}
</Text>
<Text
fz="sm"
c="dimmed"
lineClamp={4}
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
mb="md"
style={{wordBreak: "break-word", whiteSpace: "normal"}}
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
fz={{ base: 'xs', md: 'sm' }}
/>
</div>
<div>
<Group mb="sm" c="dimmed">
<Group gap={5}>
<IconCalendar size={16} />
<Text size="xs">
<Text fz={{ base: 'xs', md: 'xs' }}>
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
@@ -260,19 +266,19 @@ function Page() {
</Group>
<Group gap={5}>
<IconClock size={16} />
<Text size="xs">
<Text fz={{ base: 'xs', md: 'xs' }}>
{new Date(item.createdAt).toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</Group>
<Anchor variant="transparent" href={`/darmasaba/desa/pengumuman/${item.CategoryPengumuman?.name}/${item.id}`}>
<Text fw={600} c={colors['blue-button']} fz={{ base: 'sm', md: 'sm' }}>
Baca Selengkapnya
</Text>
</Anchor>
</Group>
<Anchor variant="transparent" href={`/darmasaba/desa/pengumuman/${item.CategoryPengumuman?.name}/${item.id}`}>
<Text fw={600} c={colors['blue-button']} size="sm">
Baca Selengkapnya
</Text>
</Anchor>
</div>
</Stack>
</Paper>
@@ -289,6 +295,7 @@ function Page() {
siblings={1}
boundaries={1}
withEdges
fz={{ base: 'xs', md: 'sm' }}
/>
</Center>
</Stack>

Some files were not shown because too many files have changed in this diff Show More