Compare commits

..

29 Commits

Author SHA1 Message Date
bf20cd55e8 Fix QC Kak Inno 18 Des
Fix UI Admin Menu Kesehatan
Fix Search : Sudah diberi useDebounced menu Kesehatan
2025-12-19 15:43:55 +08:00
af60bcd6fc Fix QC Kak Inno Tgl 17
Fix QC Kak Ayu Tgl 17
Fix UI Admin Mobile Menu PPID
Search Admin Menu Landing Page & Menu PPID
2025-12-18 17:25:22 +08:00
dc8793e3ae Fix QC Kak Inno 16 Des
Fix QC Kak Ayu 16 Des
FIx UI Admin Mobile Menu PPID
Fix Search Admin Menu Landing Page & Menu PPID
2025-12-17 17:37:58 +08:00
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
867dce42f0 Fix Error Build Staging 2025-12-04 11:58:47 +08:00
7bb17ddf22 Menambahkan menu dokter dan tenaga medis, admin bisa create, edit, delet dokter
Menambahkan menu tarif dan layanan, admin bisa create, edit, delete tarif dan layanan
Dibagian fasilitas kesehatan admin bisa multiselect bagian dokter dan tarif layanan
Di tampilan user juga sudah disesuaikan dengan datanya bisa muncul lebih dari 1 dokter dan 1 tarif layanan
2025-12-03 17:24:03 +08:00
a4069d3cba Fix UI Sosial Media Landing Page in User 2025-12-02 16:45:55 +08:00
ffe5e6dd9f Fix menu admin landing page, submenu sosial media 2025-12-02 16:06:14 +08:00
dcf195f54f Tambahan filter data sesuai tahun, di landing page apbdes 2025-12-01 17:11:24 +08:00
c03a6b3aed Tambah Term of Service di Registrasi 2025-12-01 14:01:03 +08:00
1bb9f239db Tambah Term of Service di Registrasi 2025-12-01 13:50:25 +08:00
a213ff7d37 Tambah Term of Service di Registrasi 2025-12-01 12:10:22 +08:00
0018bdc251 Fix Ganti Role, ganti role menunya sudah menyesuaikan 2025-11-28 15:03:18 +08:00
83fb39a957 Fix Ganti Role, ganti role menunya sudah menyesuaikan 2025-11-28 15:00:09 +08:00
7238692dd0 Push WebDesaDarmasabaSatging 2025-11-28 13:56:40 +08:00
8b50139d79 Push Staging 2025-11-28 12:03:07 +08:00
066180fc0e Fix registrasi, waitong-room, & tampilan layout sesuai id 2025-11-28 11:13:20 +08:00
67f29aabef Balik ke awal 2025-11-27 18:53:33 +08:00
402 changed files with 20260 additions and 10032 deletions

View File

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

View File

@@ -136,6 +136,7 @@ model MediaSosial {
name String name String
image FileStorage? @relation(fields: [imageId], references: [id]) image FileStorage? @relation(fields: [imageId], references: [id])
imageId String? imageId String?
icon String?
iconUrl String? @db.VarChar(255) iconUrl String? @db.VarChar(255)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -782,24 +783,22 @@ model Penghargaan {
// ========================================= FASILITAS KESEHATAN ========================================= // // ========================================= FASILITAS KESEHATAN ========================================= //
model FasilitasKesehatan { model FasilitasKesehatan {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
informasiumum InformasiUmum @relation(fields: [informasiUmumId], references: [id]) informasiumum InformasiUmum @relation(fields: [informasiUmumId], references: [id])
informasiUmumId String informasiUmumId String
layananunggulan LayananUnggulan @relation(fields: [layananUnggulanId], references: [id]) layananunggulan LayananUnggulan @relation(fields: [layananUnggulanId], references: [id])
layananUnggulanId String layananUnggulanId String
dokterdantenagamedis DokterdanTenagaMedis @relation(fields: [dokterdanTenagaMedisId], references: [id]) dokterdantenagamedis DokterdanTenagaMedis[] @relation("Dokter")
dokterdanTenagaMedisId String fasilitaspendukung FasilitasPendukung @relation(fields: [fasilitasPendukungId], references: [id])
fasilitaspendukung FasilitasPendukung @relation(fields: [fasilitasPendukungId], references: [id]) fasilitasPendukungId String
fasilitasPendukungId String prosedurpendaftaran ProsedurPendaftaran @relation(fields: [prosedurPendaftaranId], references: [id])
prosedurpendaftaran ProsedurPendaftaran @relation(fields: [prosedurPendaftaranId], references: [id]) prosedurPendaftaranId String
prosedurPendaftaranId String tarifdanlayanan TarifDanLayanan[] @relation("Tarif")
tarifdanlayanan TarifDanLayanan @relation(fields: [tarifDanLayananId], references: [id])
tarifDanLayananId String
} }
model InformasiUmum { model InformasiUmum {
@@ -825,15 +824,20 @@ model LayananUnggulan {
} }
model DokterdanTenagaMedis { model DokterdanTenagaMedis {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
specialist String specialist String
jadwal String jadwal String
createdAt DateTime @default(now()) jadwalLibur String?
updatedAt DateTime @updatedAt jamBukaOperasional String?
deletedAt DateTime @default(now()) jamTutupOperasional String?
isActive Boolean @default(true) jamBukaLibur String?
FasilitasKesehatan FasilitasKesehatan[] jamTutupLibur String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
isActive Boolean @default(true)
FasilitasKesehatan FasilitasKesehatan[] @relation("Dokter")
} }
model FasilitasPendukung { model FasilitasPendukung {
@@ -864,7 +868,7 @@ model TarifDanLayanan {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
FasilitasKesehatan FasilitasKesehatan[] FasilitasKesehatan FasilitasKesehatan[] @relation("Tarif")
} }
// ========================================= JADWAL KEGIATAN ========================================= // // ========================================= JADWAL KEGIATAN ========================================= //

BIN
public/mangupuraaward.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

View File

@@ -0,0 +1,76 @@
'use client';
import { Box, Image, Select, rem } from '@mantine/core';
const sosmedMap = {
facebook: { label: 'Facebook', src: '/assets/images/sosmed/facebook.png' },
instagram: { label: 'Instagram', src: '/assets/images/sosmed/instagram.png' },
tiktok: { label: 'Tiktok', src: '/assets/images/sosmed/tiktok.png' },
youtube: { label: 'YouTube', src: '/assets/images/sosmed/youtube.png' },
whatsapp: { label: 'WhatsApp', src: '/assets/images/sosmed/whatsapp.png' },
gmail: { label: 'Gmail', src: '/assets/images/sosmed/gmail.png' },
telegram: { label: 'Telegram', src: '/assets/images/sosmed/telegram.png' },
x: { label: 'X (Twitter)', src: '/assets/images/sosmed/x-twitter.png' },
telephone: { label: 'Telephone', src: '/assets/images/sosmed/telephone-call.png' },
custom: { label: 'Custom Icon', src: null },
};
type SosmedKey = keyof typeof sosmedMap;
const sosmedList = Object.entries(sosmedMap).map(([value, item]) => ({
value,
label: item.label,
}));
export default function SelectSosialMedia({
value,
onChange,
}: {
value: SosmedKey;
onChange: (value: SosmedKey) => void;
}) {
const selected = value;
const selectedImage = sosmedMap[selected]?.src;
return (
<Box maw={300}>
<Select
placeholder="Pilih sosial media"
value={selected}
data={sosmedList}
searchable={false}
withCheckIcon={false}
onChange={(val) => val && onChange(val as SosmedKey)}
styles={{
input: {
textAlign: 'left',
fontSize: rem(16),
paddingLeft: 36,
},
section: {
left: 10,
right: 'auto',
},
}}
/>
{/* 🔥 PREVIEW DIPISAH DI LUAR SELECT */}
{selectedImage && (
<Box mt="md">
<Image
alt=""
src={selectedImage}
radius="md"
style={{
width: 120,
height: 120,
objectFit: 'contain',
border: '1px solid #eee',
padding: 8,
}}
/>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { Box, Select } from '@mantine/core';
import { useEffect, useState } from 'react';
export const sosmedMap = {
facebook: { label: 'Facebook', src: '/assets/images/sosmed/facebook.png' },
instagram: { label: 'Instagram', src: '/assets/images/sosmed/instagram.png' },
tiktok: { label: 'Tiktok', src: '/assets/images/sosmed/tiktok.png' },
youtube: { label: 'YouTube', src: '/assets/images/sosmed/youtube.png' },
whatsapp: { label: 'WhatsApp', src: '/assets/images/sosmed/whatsapp.png' },
gmail: { label: 'Gmail', src: '/assets/images/sosmed/gmail.png' },
telegram: { label: 'Telegram', src: '/assets/images/sosmed/telegram.png' },
x: { label: 'X (Twitter)', src: '/assets/images/sosmed/x-twitter.png' },
telephone: { label: 'Telephone', src: '/assets/images/sosmed/telephone-call.png' },
custom: { label: 'Custom Icon', src: null },
};
type SosmedKey = keyof typeof sosmedMap;
const sosmedList = Object.entries(sosmedMap).map(([value, item]) => ({
value,
label: item.label,
}));
export default function SelectSocialMediaEdit({
value,
onChange,
}: {
value: string;
onChange: (val: SosmedKey) => void;
}) {
const [selected, setSelected] = useState<SosmedKey>('facebook');
useEffect(() => {
if (value && sosmedMap[value as SosmedKey]) {
setSelected(value as SosmedKey);
}
}, [value]);
return (
<Box>
<Select
label="Jenis Media Sosial"
value={selected}
data={sosmedList}
searchable={false}
onChange={(val) => {
if (!val) return;
setSelected(val as SosmedKey);
onChange(val as SosmedKey);
}}
/>
</Box>
);
}

View File

@@ -9,29 +9,30 @@ import { z } from "zod";
// Validasi form // Validasi form
const templateForm = z.object({ const templateForm = z.object({
name: z.string().min(1, "Nama harus diisi"), name: z.string().min(1, "Nama harus diisi"),
informasiUmum: z.object({ informasiUmum: z.object({
fasilitas: z.string().min(1, "Fasilitas harus diisi"), fasilitas: z.string().min(1),
alamat: z.string().min(1, "Alamat harus diisi"), alamat: z.string().min(1),
jamOperasional: z.string().min(1, "Jam operasional harus diisi"), jamOperasional: z.string().min(1),
}), }),
layananUnggulan: z.object({ layananUnggulan: z.object({
content: z.string().min(1, "Layanan unggulan harus diisi"), content: z.string().min(1),
}),
dokterdanTenagaMedis: z.object({
name: z.string().min(1, "Nama dokter harus diisi"),
specialist: z.string().min(1, "Spesialis harus diisi"),
jadwal: z.string().min(1, "Jadwal harus diisi"),
}), }),
// NOW ARRAY OF STRING (ID)
dokterdanTenagaMedis: z.array(z.string()).min(1, "Minimal pilih 1 dokter"),
fasilitasPendukung: z.object({ fasilitasPendukung: z.object({
content: z.string().min(1, "Fasilitas pendukung harus diisi"), content: z.string().min(1),
}), }),
prosedurPendaftaran: z.object({ prosedurPendaftaran: z.object({
content: z.string().min(1, "Prosedur pendaftaran harus diisi"), content: z.string().min(1),
}),
tarifDanLayanan: z.object({
layanan: z.string().min(1, "Layanan harus diisi"),
tarif: z.string().min(1, "Tarif harus diisi"),
}), }),
// NOW ARRAY OF STRING (ID)
tarifDanLayanan: z.array(z.string()).min(1, "Minimal pilih 1 tarif"),
}); });
// Default form kosong // Default form kosong
@@ -45,21 +46,34 @@ const defaultForm = {
layananUnggulan: { layananUnggulan: {
content: "", content: "",
}, },
dokterdanTenagaMedis: {
name: "", dokterdanTenagaMedis: [] as string[], // ← array kosong
specialist: "", tarifDanLayanan: [] as string[], // ← array kosong
jadwal: "",
},
fasilitasPendukung: { fasilitasPendukung: {
content: "", content: "",
}, },
prosedurPendaftaran: { prosedurPendaftaran: {
content: "", content: "",
}, },
tarifDanLayanan: { };
layanan: "",
tarif: "", type DokterItem = {
}, id: string;
name: string;
specialist: string;
jadwal: string;
jadwalLibur: string;
jamBukaOperasional: string;
jamTutupOperasional: string;
jamBukaLibur: string;
jamTutupLibur: string;
};
type TarifItem = {
id: string;
layanan: string;
tarif: string;
}; };
const fasilitasKesehatan = proxy({ const fasilitasKesehatan = proxy({
@@ -186,33 +200,26 @@ const fasilitasKesehatan = proxy({
const result = await res.json(); const result = await res.json();
const data = result.data; const data = result.data;
this.id = data.id;
fasilitasKesehatan.edit.id = data.id; this.form = {
fasilitasKesehatan.edit.form = {
name: data.name, name: data.name,
informasiUmum: { informasiUmum: {
fasilitas: data.informasiumum.fasilitas, fasilitas: data.informasiumum.fasilitas,
alamat: data.informasiumum.alamat, alamat: data.informasiumum.alamat,
jamOperasional: data.informasiumum.jamOperasional, jamOperasional: data.informasiumum.jamOperasional,
}, },
layananUnggulan: {
content: data.layananunggulan.content,
},
dokterdanTenagaMedis: {
name: data.dokterdantenagamedis.name,
specialist: data.dokterdantenagamedis.specialist,
jadwal: data.dokterdantenagamedis.jadwal,
},
fasilitasPendukung: { fasilitasPendukung: {
content: data.fasilitaspendukung.content, content: data.fasilitaspendukung.content,
}, },
prosedurPendaftaran: { prosedurPendaftaran: {
content: data.prosedurpendaftaran.content, content: data.prosedurpendaftaran.content,
}, },
tarifDanLayanan: { // map relasi -> array of IDs
layanan: data.tarifdanlayanan.layanan, layananUnggulan: {
tarif: data.tarifdanlayanan.tarif, content: data.layananunggulan.content,
}, },
dokterdanTenagaMedis: data.dokterdantenagamedis?.map((v: DokterItem) => v.id) ?? [],
tarifDanLayanan: data.tarifdanlayanan?.map((v: TarifItem) => v.id) ?? [],
}; };
}, },
async submit() { async submit() {
@@ -238,22 +245,15 @@ const fasilitasKesehatan = proxy({
layananUnggulan: { layananUnggulan: {
content: fasilitasKesehatan.edit.form.layananUnggulan.content, content: fasilitasKesehatan.edit.form.layananUnggulan.content,
}, },
dokterdanTenagaMedis: { dokterdanTenagaMedis:
name: fasilitasKesehatan.edit.form.dokterdanTenagaMedis.name, fasilitasKesehatan.edit.form.dokterdanTenagaMedis,
specialist:
fasilitasKesehatan.edit.form.dokterdanTenagaMedis.specialist,
jadwal: fasilitasKesehatan.edit.form.dokterdanTenagaMedis.jadwal,
},
fasilitasPendukung: { fasilitasPendukung: {
content: fasilitasKesehatan.edit.form.fasilitasPendukung.content, content: fasilitasKesehatan.edit.form.fasilitasPendukung.content,
}, },
prosedurPendaftaran: { prosedurPendaftaran: {
content: fasilitasKesehatan.edit.form.prosedurPendaftaran.content, content: fasilitasKesehatan.edit.form.prosedurPendaftaran.content,
}, },
tarifDanLayanan: { tarifDanLayanan: fasilitasKesehatan.edit.form.tarifDanLayanan,
layanan: fasilitasKesehatan.edit.form.tarifDanLayanan.layanan,
tarif: fasilitasKesehatan.edit.form.tarifDanLayanan.tarif,
},
}; };
const res = await fetch( const res = await fetch(
@@ -320,12 +320,26 @@ const templateDokterForm = z.object({
name: z.string().min(1, "Nama tidak boleh kosong"), name: z.string().min(1, "Nama tidak boleh kosong"),
specialist: z.string().min(1, "Spesialis tidak boleh kosong"), specialist: z.string().min(1, "Spesialis tidak boleh kosong"),
jadwal: z.string().min(1, "Jadwal tidak boleh kosong"), jadwal: z.string().min(1, "Jadwal tidak boleh kosong"),
jadwalLibur: z.string().min(1, "Jadwal libur tidak boleh kosong"),
jamBukaOperasional: z
.string()
.min(1, "Jam buka operasional tidak boleh kosong"),
jamTutupOperasional: z
.string()
.min(1, "Jam tutup operasional tidak boleh kosong"),
jamBukaLibur: z.string().min(1, "Jam buka libur tidak boleh kosong"),
jamTutupLibur: z.string().min(1, "Jam tutup libur tidak boleh kosong"),
}); });
const defaultDokterForm = { const defaultDokterForm = {
name: "", name: "",
specialist: "", specialist: "",
jadwal: "", jadwal: "",
jadwalLibur: "",
jamBukaOperasional: "",
jamTutupOperasional: "",
jamBukaLibur: "",
jamTutupLibur: "",
}; };
const dokter = proxy({ const dokter = proxy({
@@ -463,6 +477,11 @@ const dokter = proxy({
name: data.name, name: data.name,
specialist: data.specialist, specialist: data.specialist,
jadwal: data.jadwal, jadwal: data.jadwal,
jadwalLibur: data.jadwalLibur,
jamBukaOperasional: data.jamBukaOperasional,
jamTutupOperasional: data.jamTutupOperasional,
jamBukaLibur: data.jamBukaLibur,
jamTutupLibur: data.jamTutupLibur,
}; };
return data; // Return the loaded data return data; // Return the loaded data
} else { } else {
@@ -487,6 +506,11 @@ const dokter = proxy({
name: this.form.name, name: this.form.name,
specialist: this.form.specialist, specialist: this.form.specialist,
jadwal: this.form.jadwal, jadwal: this.form.jadwal,
jadwalLibur: this.form.jadwalLibur,
jamBukaOperasional: this.form.jamBukaOperasional,
jamTutupOperasional: this.form.jamTutupOperasional,
jamBukaLibur: this.form.jamBukaLibur,
jamTutupLibur: this.form.jamTutupLibur,
}; };
const cek = templateDokterForm.safeParse(formData); const cek = templateDokterForm.safeParse(formData);
@@ -567,9 +591,255 @@ const dokter = proxy({
}, },
}); });
const templateTarifForm = z.object({
tarif: z.string().min(1, "Tarif tidak boleh kosong"),
layanan: z.string().min(1, "Layanan tidak boleh kosong"),
});
const defaultTarifForm = {
tarif: "",
layanan: "",
};
const tarif = proxy({
create: {
form: defaultTarifForm,
loading: false,
async create() {
const cek = templateTarifForm.safeParse(tarif.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
toast.error(err);
return null;
}
try {
tarif.create.loading = true;
const res = await ApiFetch.api.kesehatan.tarifdanlayanan["create"].post(
tarif.create.form
);
if (res.status === 200) {
const id = res.data?.data;
if (id) {
toast.success("Sukses menambahkan");
tarif.create.form = { ...defaultTarifForm };
tarif.findMany.load();
return id;
}
}
toast.error("failed create");
return null;
} catch (error) {
console.log((error as Error).message);
return null;
} finally {
tarif.create.loading = false;
}
},
},
findMany: {
data: null as
| Prisma.TarifDanLayananGetPayload<{
omit: {
isActive: true;
};
}>[]
| null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
tarif.findMany.loading = true; // ✅ Akses langsung via nama path
tarif.findMany.page = page;
tarif.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.kesehatan.tarifdanlayanan[
"findMany"
].get({ query });
if (res.status === 200 && res.data?.success) {
tarif.findMany.data = res.data.data ?? [];
tarif.findMany.totalPages = res.data.totalPages ?? 1;
} else {
tarif.findMany.data = [];
tarif.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch tarif dan layanan paginated:", err);
tarif.findMany.data = [];
tarif.findMany.totalPages = 1;
} finally {
tarif.findMany.loading = false;
}
},
},
findUnique: {
data: null as Prisma.TarifDanLayananGetPayload<{
omit: { isActive: true };
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/kesehatan/tarifdanlayanan/${id}`);
if (res.ok) {
const data = await res.json();
tarif.findUnique.data = data.data ?? null;
} else {
console.error(
"Failed to fetch tarif dan layanan",
res.statusText
);
tarif.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching tarif dan layanan", error);
tarif.findUnique.data = null;
}
},
},
update: {
id: "",
form: { ...defaultTarifForm },
loading: false,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
try {
const response = await fetch(`/api/kesehatan/tarifdanlayanan/${id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result?.success) {
const data = result.data;
this.id = data.id;
this.form = {
tarif: data.tarif,
layanan: data.layanan
};
return data; // Return the loaded data
} else {
throw new Error(result?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error loading tarif dan layanan:", error);
toast.error(
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
}
},
async submit() {
const id = this.id;
if (!id) {
toast.warn("ID tidak valid");
return null;
}
const formData = {
tarif: this.form.tarif,
layanan: this.form.layanan
};
const cek = templateTarifForm.safeParse(formData);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v: any) => `${v.path.join(".")}`)
.join("\n")}] required`;
toast.error(err);
return null;
}
try {
this.loading = true;
const res = await fetch(`/api/kesehatan/tarifdanlayanan/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
const result = await res.json();
if (!res.ok || !result?.success) {
throw new Error(result?.message || "Gagal update data");
}
toast.success("Berhasil update data!");
await tarif.findMany.load();
return result.data;
} catch (error) {
console.error("Update error:", error);
toast.error("Gagal update data tarif dan layanan");
throw error;
} finally {
this.loading = false;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) {
return toast.warn("ID tidak valid");
}
try {
tarif.delete.loading = true;
const response = await fetch(
`/api/kesehatan/tarifdanlayanan/del/${id}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(
result.message || "tarif dan layanan berhasil dihapus"
);
await tarif.findMany.load(); // refresh list
} else {
toast.error(
result?.message || "Gagal menghapus tarif dan layanan"
);
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus tarif dan layanan");
} finally {
tarif.delete.loading = false;
}
},
},
});
const fasilitasKesehatanState = proxy({ const fasilitasKesehatanState = proxy({
fasilitasKesehatan, fasilitasKesehatan,
dokter, dokter,
tarif
}); });
export default fasilitasKesehatanState; export default fasilitasKesehatanState;

View File

@@ -27,7 +27,7 @@ const programInovasi = proxy({
name: "", name: "",
description: "", description: "",
imageId: "", imageId: "",
link: "" link: "",
} as ProgramInovasiForm, } as ProgramInovasiForm,
loading: false, loading: false,
async create() { async create() {
@@ -71,20 +71,21 @@ const programInovasi = proxy({
total: 0, total: 0,
loading: false, loading: false,
search: "", search: "",
load: async (page = 1, limit = 10, search = "") => { // Change to arrow function load: async (page = 1, limit = 10, search = "") => {
programInovasi.findMany.loading = true; // Use the full path to access the property // Change to arrow function
programInovasi.findMany.loading = true; // Use the full path to access the property
programInovasi.findMany.page = page; programInovasi.findMany.page = page;
programInovasi.findMany.search = search; programInovasi.findMany.search = search;
try { try {
const query: any = { page, limit }; const query: any = { page, limit };
if (search) query.search = search; if (search) query.search = search;
const res = await ApiFetch.api.landingpage.programinovasi[ const res = await ApiFetch.api.landingpage.programinovasi[
"findMany" "findMany"
].get({ ].get({
query query,
}); });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {
programInovasi.findMany.data = res.data.data || []; programInovasi.findMany.data = res.data.data || [];
programInovasi.findMany.total = res.data.total || 0; programInovasi.findMany.total = res.data.total || 0;
@@ -389,7 +390,10 @@ const pejabatDesa = proxy({
try { try {
// Ensure ID is properly encoded in the URL // Ensure ID is properly encoded in the URL
const url = new URL(`/api/landingpage/pejabatdesa/${encodeURIComponent(this.id)}`, window.location.origin); const url = new URL(
`/api/landingpage/pejabatdesa/${encodeURIComponent(this.id)}`,
window.location.origin
);
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
method: "PUT", method: "PUT",
headers: { headers: {
@@ -438,16 +442,19 @@ const pejabatDesa = proxy({
const templateMediaSosial = z.object({ const templateMediaSosial = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"), name: z.string().min(3, "Nama minimal 3 karakter"),
imageId: z.string().min(1, "Gambar wajib dipilih"), imageId: z.string().nullable().optional(),
iconUrl: z.string().min(3, "Icon URL minimal 3 karakter"), iconUrl: z.string().min(3, "Icon URL minimal 3 karakter"),
icon: z.string().nullable().optional(),
}); });
type MediaSosialForm = { type MediaSosialForm = {
name: string; name: string;
imageId: string; imageId: string | null; // boleh null
iconUrl: string; iconUrl: string;
icon: string | null; // boleh null
}; };
const mediaSosial = proxy({ const mediaSosial = proxy({
create: { create: {
form: {} as MediaSosialForm, form: {} as MediaSosialForm,
@@ -455,9 +462,10 @@ const mediaSosial = proxy({
async create() { async create() {
// Ensure all required fields are non-null // Ensure all required fields are non-null
const formData = { const formData = {
name: mediaSosial.create.form.name || "", name: mediaSosial.create.form.name ?? "",
imageId: mediaSosial.create.form.imageId || "", imageId: mediaSosial.create.form.imageId ?? null, // FIXED
iconUrl: mediaSosial.create.form.iconUrl || "", iconUrl: mediaSosial.create.form.iconUrl ?? "",
icon: mediaSosial.create.form.icon ?? null, // FIXED
}; };
const cek = templateMediaSosial.safeParse(formData); const cek = templateMediaSosial.safeParse(formData);
@@ -492,20 +500,19 @@ const mediaSosial = proxy({
total: 0, total: 0,
loading: false, loading: false,
search: "", search: "",
load: async (page = 1, limit = 10, search = "") => { // Change to arrow function load: async (page = 1, limit = 10, search = "") => {
mediaSosial.findMany.loading = true; // Use the full path to access the property // Change to arrow function
mediaSosial.findMany.loading = true; // Use the full path to access the property
mediaSosial.findMany.page = page; mediaSosial.findMany.page = page;
mediaSosial.findMany.search = search; mediaSosial.findMany.search = search;
try { try {
const query: any = { page, limit }; const query: any = { page, limit };
if (search) query.search = search; if (search) query.search = search;
const res = await ApiFetch.api.landingpage.mediasosial[ const res = await ApiFetch.api.landingpage.mediasosial["findMany"].get({
"findMany"
].get({
query, query,
}); });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {
mediaSosial.findMany.data = res.data.data || []; mediaSosial.findMany.data = res.data.data || [];
mediaSosial.findMany.total = res.data.total || 0; mediaSosial.findMany.total = res.data.total || 0;
@@ -537,7 +544,7 @@ const mediaSosial = proxy({
toast.warn("ID tidak valid"); toast.warn("ID tidak valid");
return null; return null;
} }
mediaSosial.update.loading = true; mediaSosial.update.loading = true;
try { try {
const res = await fetch(`/api/landingpage/mediasosial/${id}`); const res = await fetch(`/api/landingpage/mediasosial/${id}`);
@@ -586,66 +593,72 @@ const mediaSosial = proxy({
}, },
}, },
update: { update: {
id: "", id: "",
form: {} as MediaSosialForm, form: {} as MediaSosialForm,
loading: false, loading: false,
async load(id: string) { async load(id: string) {
if (!id) { if (!id) {
toast.warn("ID tidak valid"); toast.warn("ID tidak valid");
return null; return null;
}
mediaSosial.update.loading = true; // ✅ Tambahkan ini di awal
try {
const response = await fetch(`/api/landingpage/mediasosial/${id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
} }
mediaSosial.update.loading = true; // ✅ Tambahkan ini di awal const result = await response.json();
try { if (result?.success) {
const response = await fetch(`/api/landingpage/mediasosial/${id}`, { const data = result.data;
method: "GET", this.id = data.id;
headers: { this.form = {
"Content-Type": "application/json", name: data.name || "",
}, imageId: data.imageId || null,
}); iconUrl: data.iconUrl || "",
icon: data.icon || null,
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); };
} return data;
} else {
const result = await response.json(); throw new Error(
result?.message || "Gagal mengambil data media sosial"
if (result?.success) { );
const data = result.data;
this.id = data.id;
this.form = {
name: data.name || "",
imageId: data.imageId || "",
iconUrl: data.iconUrl || "",
};
return data;
} else {
throw new Error(result?.message || "Gagal mengambil data media sosial");
}
} catch (error) {
console.error((error as Error).message);
toast.error("Terjadi kesalahan saat mengambil data media sosial");
} finally {
mediaSosial.update.loading = false; // ✅ Supaya berhenti loading walau error
} }
}, } catch (error) {
console.error((error as Error).message);
async update() { toast.error("Terjadi kesalahan saat mengambil data media sosial");
const cek = templateMediaSosial.safeParse(mediaSosial.update.form); } finally {
if (!cek.success) { mediaSosial.update.loading = false; // ✅ Supaya berhenti loading walau error
const err = `[${cek.error.issues }
.map((v) => `${v.path.join(".")}`) },
.join("\n")}] required`;
toast.error(err); async update() {
return false; const cek = templateMediaSosial.safeParse(mediaSosial.update.form);
} if (!cek.success) {
const err = `[${cek.error.issues
try { .map((v) => `${v.path.join(".")}`)
mediaSosial.update.loading = true; .join("\n")}] required`;
toast.error(err);
const response = await fetch(`/api/landingpage/mediasosial/${this.id}`, { return false;
}
try {
mediaSosial.update.loading = true;
const response = await fetch(
`/api/landingpage/mediasosial/${this.id}`,
{
method: "PUT", method: "PUT",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -654,38 +667,40 @@ const mediaSosial = proxy({
name: this.form.name, name: this.form.name,
imageId: this.form.imageId, imageId: this.form.imageId,
iconUrl: this.form.iconUrl, iconUrl: this.form.iconUrl,
icon: this.form.icon,
}), }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
} }
);
const result = await response.json();
if (!response.ok) {
if (result.success) { const errorData = await response.json().catch(() => ({}));
toast.success("Berhasil update media sosial"); throw new Error(
await mediaSosial.findMany.load(); // refresh list errorData.message || `HTTP error! status: ${response.status}`
return true;
} else {
throw new Error(result.message || "Gagal update media sosial");
}
} catch (error) {
console.error("Error updating media sosial:", error);
toast.error(
error instanceof Error
? error.message
: "Terjadi kesalahan saat update media sosial"
); );
return false;
} finally {
mediaSosial.update.loading = false;
} }
},
const result = await response.json();
if (result.success) {
toast.success("Berhasil update media sosial");
await mediaSosial.findMany.load(); // refresh list
return true;
} else {
throw new Error(result.message || "Gagal update media sosial");
}
} catch (error) {
console.error("Error updating media sosial:", error);
toast.error(
error instanceof Error
? error.message
: "Terjadi kesalahan saat update media sosial"
);
return false;
} finally {
mediaSosial.update.loading = false;
}
}, },
},
}); });
const profileLandingPageState = proxy({ const profileLandingPageState = proxy({

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ApiFetch from "@/lib/api-fetch"; import ApiFetch from "@/lib/api-fetch";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -6,145 +7,207 @@ import { z } from "zod";
const templateForm = z.object({ const templateForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"), name: z.string().min(3, "Nama minimal 3 karakter"),
nik: z.string().min(3, "NIK minimal 3 karakter"), nik: z
notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"), .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"), alamat: z.string().min(3, "Alamat minimal 3 karakter"),
email: z.string().min(3, "Email minimal 3 karakter"), email: z.string().min(3, "Email minimal 3 karakter"),
jenisInformasiDimintaId: z.string().nonempty(), jenisInformasiDimintaId: z.string().nonempty(),
caraMemperolehInformasiId: z.string().nonempty(), caraMemperolehInformasiId: z.string().nonempty(),
caraMemperolehSalinanInformasiId: z.string().nonempty(), caraMemperolehSalinanInformasiId: z.string().nonempty(),
}) });
const jenisInformasiDiminta = proxy({ const jenisInformasiDiminta = proxy({
findMany: { findMany: {
data: null as data: null as
| null | null
| Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[], | Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[],
async load(){ async load() {
const res = await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi["find-many"].get(); const res =
if (res.status === 200) { await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi[
jenisInformasiDiminta.findMany.data = res.data?.data ?? []; "find-many"
} ].get();
} if (res.status === 200) {
} jenisInformasiDiminta.findMany.data = res.data?.data ?? [];
}) }
},
},
});
const caraMemperolehInformasi = proxy({ const caraMemperolehInformasi = proxy({
findMany: { findMany: {
data: null as data: null as
| null | null
| Prisma.CaraMemperolehInformasiGetPayload<{ omit: { isActive: true } }>[], | Prisma.CaraMemperolehInformasiGetPayload<{
async load() { omit: { isActive: true };
const res = await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi["find-many"].get(); }>[],
if (res.status === 200) { async load() {
caraMemperolehInformasi.findMany.data = res.data?.data ?? []; const res =
} await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi[
} "find-many"
} ].get();
}) if (res.status === 200) {
caraMemperolehInformasi.findMany.data = res.data?.data ?? [];
}
},
},
});
const caraMemperolehSalinanInformasi = proxy({ const caraMemperolehSalinanInformasi = proxy({
findMany: { findMany: {
data: null as data: null as
| null | null
| Prisma.CaraMemperolehSalinanInformasiGetPayload<{ omit: { isActive: true } }>[], | Prisma.CaraMemperolehSalinanInformasiGetPayload<{
async load() { omit: { isActive: true };
const res = await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi["find-many"].get(); }>[],
if (res.status === 200) { async load() {
caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? []; const res =
} await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi[
} "find-many"
} ].get();
}) if (res.status === 200) {
console.log(caraMemperolehSalinanInformasi) caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? [];
}
},
},
});
console.log(caraMemperolehSalinanInformasi);
type PermohonanInformasiPublikForm = Prisma.PermohonanInformasiPublikGetPayload<{ type PermohonanInformasiPublikForm =
Prisma.PermohonanInformasiPublikGetPayload<{
select: { select: {
name: true; name: true;
nik: true; nik: true;
notelp: true; notelp: true;
alamat: true; alamat: true;
email: true; email: true;
jenisInformasiDimintaId: true; jenisInformasiDimintaId: true;
caraMemperolehInformasiId: true; caraMemperolehInformasiId: true;
caraMemperolehSalinanInformasiId: true; caraMemperolehSalinanInformasiId: true;
}; };
}>; }>;
const statepermohonanInformasiPublik = proxy({ const statepermohonanInformasiPublik = proxy({
create: { create: {
form: {} as PermohonanInformasiPublikForm, form: {} as PermohonanInformasiPublikForm,
loading: false, loading: false,
async create(){ async create() {
const cek = templateForm.safeParse(statepermohonanInformasiPublik.create.form); const cek = templateForm.safeParse(
if(!cek.success) { statepermohonanInformasiPublik.create.form
const err = `[${cek.error.issues );
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`; if (!cek.success) {
return toast.error(err); 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); try {
if (res.status === 200) { statepermohonanInformasiPublik.create.loading = true;
statepermohonanInformasiPublik.findMany.load(); const res = await ApiFetch.api.ppid.permohonaninformasipublik[
return toast.success("Sukses menambahkan"); "create"
} ].post(statepermohonanInformasiPublik.create.form);
return toast.error("failed create");
} catch (error) { if (res.data?.success === false) {
console.log((error as Error).message); toast.error(res.data?.message);
} finally { return false; // ⬅️ gagal
statepermohonanInformasiPublik.create.loading = false;
}
} }
toast.success("Sukses menambahkan");
return true; // ⬅️ sukses
} catch {
toast.error("Terjadi kesalahan server");
return false;
} finally {
statepermohonanInformasiPublik.create.loading = false;
}
}, },
findMany: { },
data: null as findMany: {
| Prisma.PermohonanInformasiPublikGetPayload<{ include: { data: null as
caraMemperolehSalinanInformasi: true, | Prisma.PermohonanInformasiPublikGetPayload<{
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<{
include: { include: {
jenisInformasiDiminta: true, caraMemperolehSalinanInformasi: true;
caraMemperolehInformasi: true, jenisInformasiDiminta: true;
caraMemperolehSalinanInformasi: true, caraMemperolehInformasi: true;
}; };
}> | null, }>[]
async load(id: string) { | null,
try { page: 1,
const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`); totalPages: 1,
if (res.ok) { total: 0,
const data = await res.json(); loading: false,
statepermohonanInformasiPublik.findUnique.data = data.data ?? null; search: "",
} else { load: async (page = 1, limit = 10, search = "") => {
console.error("Failed to fetch program inovasi:", res.statusText); // Change to arrow function
statepermohonanInformasiPublik.findUnique.data = null; statepermohonanInformasiPublik.findMany.loading = true; // Use the full path to access the property
} statepermohonanInformasiPublik.findMany.page = page;
} catch (error) { statepermohonanInformasiPublik.findMany.search = search;
console.error("Error fetching program inovasi:", error); try {
statepermohonanInformasiPublik.findUnique.data = null; const query: any = { page, limit };
} if (search) query.search = search;
},
}, const res = await ApiFetch.api.ppid.permohonaninformasipublik[
"find-many"
}) ].get({
query,
});
if (res.status === 200 && res.data?.success) {
statepermohonanInformasiPublik.findMany.data = res.data.data || [];
statepermohonanInformasiPublik.findMany.total = res.data.total || 0;
statepermohonanInformasiPublik.findMany.totalPages = res.data.totalPages || 1;
} else {
console.error("Failed to load permohonan keberatan informasi:", res.data?.message);
statepermohonanInformasiPublik.findMany.data = [];
statepermohonanInformasiPublik.findMany.total = 0;
statepermohonanInformasiPublik.findMany.totalPages = 1;
}
} catch (error) {
console.error("Error loading permohonan keberatan informasi:", error);
statepermohonanInformasiPublik.findMany.data = [];
statepermohonanInformasiPublik.findMany.total = 0;
statepermohonanInformasiPublik.findMany.totalPages = 1;
} finally {
statepermohonanInformasiPublik.findMany.loading = false;
}
},
},
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({ const statepermohonanInformasiPublikForm = proxy({
statepermohonanInformasiPublik, statepermohonanInformasiPublik,
jenisInformasiDiminta, jenisInformasiDiminta,
caraMemperolehInformasi, caraMemperolehInformasi,
caraMemperolehSalinanInformasi, caraMemperolehSalinanInformasi,
}) });
export default statepermohonanInformasiPublikForm; export default statepermohonanInformasiPublikForm;

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ApiFetch from "@/lib/api-fetch"; import ApiFetch from "@/lib/api-fetch";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -5,82 +6,130 @@ import { proxy } from "valtio";
import { z } from "zod"; import { z } from "zod";
const templateForm = z.object({ const templateForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"), name: z.string().min(3, "Nama minimal 3 karakter"),
email: z.string().min(3, "Email minimal 3 karakter"), email: z.string().min(3, "Email minimal 3 karakter"),
notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"), notelp: z
alasan: z.string().min(3, "Alasan minimal 3 karakter"), .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: { select: {
name: true; name: true;
email: true; email: true;
notelp: true; notelp: true;
alasan: true; alasan: true;
}; };
}>; }>;
const permohonanKeberatanInformasi = proxy({ const permohonanKeberatanInformasi = proxy({
create: { create: {
form: {} as PermohonanKeberatanInformasiForm, form: {} as PermohonanKeberatanInformasiForm,
loading: false, loading: false,
async create(){ async create() {
const cek = templateForm.safeParse(permohonanKeberatanInformasi.create.form); const cek = templateForm.safeParse(
if(!cek.success) { permohonanKeberatanInformasi.create.form
const err = `[${cek.error.issues );
.map((v) => `${v.path.join(".")}`) if (!cek.success) {
.join("\n")}] required`; toast.error(cek.error.issues.map((i) => i.message).join("\n"));
return toast.error(err); return false; // ⬅️ tambahkan return false
}
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;
}
},
} }
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
| null
| Prisma.FormulirPermohonanKeberatanGetPayload<{
omit: { isActive: true };
}>[],
page: 1,
totalPages: 1,
total: 0,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
// Change to arrow function
permohonanKeberatanInformasi.findMany.loading = true; // Use the full path to access the property
permohonanKeberatanInformasi.findMany.page = page;
permohonanKeberatanInformasi.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[
"find-many"
].get({
query,
});
if (res.status === 200 && res.data?.success) {
permohonanKeberatanInformasi.findMany.data = res.data.data || [];
permohonanKeberatanInformasi.findMany.total = res.data.total || 0;
permohonanKeberatanInformasi.findMany.totalPages = res.data.totalPages || 1;
} else {
console.error("Failed to load permohonan keberatan informasi:", res.data?.message);
permohonanKeberatanInformasi.findMany.data = [];
permohonanKeberatanInformasi.findMany.total = 0;
permohonanKeberatanInformasi.findMany.totalPages = 1;
}
} catch (error) {
console.error("Error loading permohonan keberatan informasi:", error);
permohonanKeberatanInformasi.findMany.data = [];
permohonanKeberatanInformasi.findMany.total = 0;
permohonanKeberatanInformasi.findMany.totalPages = 1;
} finally {
permohonanKeberatanInformasi.findMany.loading = false;
}
},
},
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; export default permohonanKeberatanInformasi;

View File

@@ -1,6 +1,5 @@
'use client'; 'use client';
import { apiFetchLogin } from '@/app/api/auth/_lib/api_fetch_auth';
import { apiFetchLogin } from '@/app/api/[auth]/_lib/api_fetch_auth';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Image, Paper, Stack, Title } from '@mantine/core'; import { Box, Button, Center, Image, Paper, Stack, Title } from '@mantine/core';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';

View File

@@ -1,7 +1,7 @@
// app/registrasi/page.tsx // app/registrasi/page.tsx
'use client'; 'use client';
import { apiFetchRegister } from '@/app/api/[auth]/_lib/api_fetch_auth'; import { apiFetchRegister } from '@/app/api/auth/_lib/api_fetch_auth';
import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto'; import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
@@ -18,6 +18,7 @@ export default function Registrasi() {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [phone, setPhone] = useState(''); // ✅ tambahkan state untuk phone const [phone, setPhone] = useState(''); // ✅ tambahkan state untuk phone
const [agree, setAgree] = useState(false)
// Ambil data dari localStorage (dari login) // Ambil data dari localStorage (dari login)
useEffect(() => { useEffect(() => {
@@ -46,6 +47,11 @@ export default function Registrasi() {
return; return;
} }
if (!agree) {
toast.error("Anda harus menyetujui syarat dan ketentuan!");
return;
}
try { try {
setLoading(true); setLoading(true);
// ✅ Hanya kirim username & nomor → dapat kodeId // ✅ Hanya kirim username & nomor → dapat kodeId
@@ -92,8 +98,8 @@ export default function Registrasi() {
username.length > 0 && username.length < 5 username.length > 0 && username.length < 5
? 'Minimal 5 karakter!' ? 'Minimal 5 karakter!'
: username.includes(' ') : username.includes(' ')
? 'Tidak boleh ada spasi' ? 'Tidak boleh ada spasi'
: '' : ''
} }
required required
/> />
@@ -108,9 +114,29 @@ export default function Registrasi() {
</Box> </Box>
<Box pt="md"> <Box pt="md">
<Checkbox label="Saya menyetujui syarat dan ketentuan" defaultChecked /> <Checkbox
checked={agree}
onChange={(e) => setAgree(e.currentTarget.checked)}
label={
<Text fz="sm">
Saya menyetujui{" "}
<a
href="/terms-of-service"
target="_blank"
style={{
color: colors["blue-button"],
textDecoration: "underline",
fontWeight: 500,
}}
>
syarat dan ketentuan
</a>
</Text>
}
/>
</Box> </Box>
<Box pt="xl"> <Box pt="xl">
<Button <Button
fullWidth fullWidth

View File

@@ -19,7 +19,7 @@ import { authStore } from '@/store/authStore';
export default function Validasi() { export default function Validasi() {
const router = useRouter(); const router = useRouter();
const [nomor, setNomor] = useState<string | null>(null); const [nomor, setNomor] = useState<string | null>(null);
const [otp, setOtp] = useState(''); const [otp, setOtp] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -31,11 +31,11 @@ export default function Validasi() {
useEffect(() => { useEffect(() => {
const checkFlow = async () => { const checkFlow = async () => {
try { try {
const res = await fetch('/api/get-flow', { const res = await fetch('/api/auth/get-flow', {
credentials: 'include' credentials: 'include'
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
setIsRegistrationFlow(data.flow === 'register'); setIsRegistrationFlow(data.flow === 'register');
console.log('🔍 Flow detected from cookie:', data.flow); console.log('🔍 Flow detected from cookie:', data.flow);
@@ -45,7 +45,7 @@ export default function Validasi() {
setIsRegistrationFlow(false); setIsRegistrationFlow(false);
} }
}; };
checkFlow(); checkFlow();
}, []); }, []);
@@ -60,7 +60,7 @@ export default function Validasi() {
setKodeId(storedKodeId); setKodeId(storedKodeId);
const loadOtpData = async () => { const loadOtpData = async () => {
try { try {
const res = await fetch(`/api/otp-data?kodeId=${encodeURIComponent(storedKodeId)}`); const res = await fetch(`/api/auth/otp-data?kodeId=${encodeURIComponent(storedKodeId)}`);
const result = await res.json(); const result = await res.json();
if (res.ok && result.data?.nomor) { if (res.ok && result.data?.nomor) {
@@ -110,7 +110,8 @@ export default function Validasi() {
return; return;
} }
const verifyRes = await fetch('/api/verify-otp-register', { // ✅ Verify OTP
const verifyRes = await fetch('/api/auth/verify-otp-register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor: cleanNomor, otp, kodeId }), body: JSON.stringify({ nomor: cleanNomor, otp, kodeId }),
@@ -123,26 +124,32 @@ export default function Validasi() {
return; return;
} }
const finalizeRes = await fetch('/api/finalize-registration', { // ✅ Finalize registration
const finalizeRes = await fetch('/api/auth/finalize-registration', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, username, kodeId }), body: JSON.stringify({ nomor: cleanNomor, username, kodeId }),
credentials: 'include' credentials: 'include'
}); });
const data = await finalizeRes.json(); const data = await finalizeRes.json();
if (data.success || finalizeRes.redirected) { // ✅ Check JSON response (bukan redirect)
// ✅ Cleanup setelah registrasi sukses if (data.success) {
toast.success('Registrasi berhasil! Menunggu persetujuan admin.');
await cleanupStorage(); await cleanupStorage();
window.location.href = '/waiting-room';
// ✅ Client-side redirect
setTimeout(() => {
window.location.href = '/waiting-room';
}, 1000);
} else { } else {
toast.error(data.message || 'Registrasi gagal'); toast.error(data.message || 'Registrasi gagal');
} }
}; };
const handleLoginVerification = async () => { const handleLoginVerification = async () => {
const loginRes = await fetch('/api/verify-otp-login', { const loginRes = await fetch('/api/auth/verify-otp-login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, otp, kodeId }), body: JSON.stringify({ nomor, otp, kodeId }),
@@ -197,10 +204,10 @@ export default function Validasi() {
localStorage.removeItem('auth_kodeId'); localStorage.removeItem('auth_kodeId');
localStorage.removeItem('auth_nomor'); localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_username'); localStorage.removeItem('auth_username');
// Clear cookie // Clear cookie
try { try {
await fetch('/api/clear-flow', { await fetch('/api/auth/clear-flow', {
method: 'POST', method: 'POST',
credentials: 'include' credentials: 'include'
}); });
@@ -212,7 +219,7 @@ export default function Validasi() {
const handleResend = async () => { const handleResend = async () => {
if (!nomor) return; if (!nomor) return;
try { try {
const res = await fetch('/api/resend', { const res = await fetch('/api/auth/resend', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor }), body: JSON.stringify({ nomor }),

View File

@@ -1,7 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; 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 { IconBuildingStore, IconFileText, IconSparkles, IconUsers, IconUsersPlus } from '@tabler/icons-react'; import { IconBuildingStore, IconFileText, IconSparkles, IconUsers, IconUsersPlus } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
@@ -72,35 +72,76 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
keepMounted={false} keepMounted={false}
> >
{/* ✅ Scroll horizontal wrapper */} {/* ✅ Scroll horizontal wrapper */}
<ScrollArea type="auto" offsetScrollbars> <Box visibleFrom='md' pb={10}>
<TabsList <ScrollArea type="auto" offsetScrollbars>
p="sm" <TabsList
style={{ p="sm"
background: "linear-gradient(135deg, #e7ebf7, #f9faff)", style={{
borderRadius: "1rem", background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)", borderRadius: "1rem",
display: "flex", boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
flexWrap: "nowrap", display: "flex",
gap: "0.5rem", flexWrap: "nowrap",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi gap: "0.5rem",
}} paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
> }}
{tabs.map((tab, i) => ( >
{tabs.map((tab, i) => (
<TabsTab <TabsTab
key={i} key={i}
value={tab.value} value={tab.value}
leftSection={tab.icon} leftSection={tab.icon}
style={{ style={{
fontWeight: 600, fontWeight: 600,
fontSize: "0.9rem", fontSize: "0.9rem",
transition: "all 0.2s ease", transition: "all 0.2s ease",
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}} }}
> >
{tab.label} {tab.label}
</TabsTab> </TabsTab>
))} ))}
</TabsList> </TabsList>
</ScrollArea> </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) => ( {tabs.map((tab, i) => (
<TabsPanel <TabsPanel

View File

@@ -92,7 +92,7 @@ function EditKategoriBerita() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Back Button + Title */} {/* Back Button + Title */}
<Group mb="md"> <Group mb="md">
<Button <Button
@@ -111,7 +111,7 @@ function EditKategoriBerita() {
{/* Form Wrapper */} {/* Form Wrapper */}
<Paper <Paper
w={{ base: '100%', md: '50%' }} w={{ base: '100%', md: '50%' }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"
shadow="sm" shadow="sm"

View File

@@ -43,7 +43,7 @@ function CreateKategoriBerita() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header dengan back button */} {/* Header dengan back button */}
<Group mb="md"> <Group mb="md">
<Button <Button

View File

@@ -26,6 +26,7 @@ import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import stateDashboardBerita from '../../../_state/desa/berita'; import stateDashboardBerita from '../../../_state/desa/berita';
import { useDebouncedValue } from '@mantine/hooks';
function KategoriBerita() { function KategoriBerita() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -48,6 +49,7 @@ function ListKategoriBerita({ search }: { search: string }) {
const router = useRouter(); const router = useRouter();
const [modalHapus, setModalHapus] = useState(false); const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { const {
data, data,
@@ -58,8 +60,8 @@ function ListKategoriBerita({ search }: { search: string }) {
} = listDataState.findMany; } = listDataState.findMany;
useEffect(() => { useEffect(() => {
load(page, 10, search); load(page, 10, debouncedSearch);
}, [page, search]); }, [page, debouncedSearch]);
const handleDelete = () => { const handleDelete = () => {
if (selectedId) { if (selectedId) {
@@ -81,77 +83,84 @@ function ListKategoriBerita({ search }: { search: string }) {
} }
return ( return (
<Box py={10}> <Box py={{ base: 'sm', md: 'lg' }}>
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb={{ base: 'md', md: 'lg' }}>
<Title order={4}>Daftar Kategori Berita</Title> <Title order={4} lh={1.2}>
<Button Daftar Kategori Berita
leftSection={<IconPlus size={18} />} </Title>
color="blue" <Button
variant="light" leftSection={<IconPlus size={18} />}
onClick={() => color="blue"
router.push('/admin/desa/berita/kategori-berita/create') variant="light"
} onClick={() =>
> router.push('/admin/desa/berita/kategori-berita/create')
Tambah Baru }
</Button> >
Tambah Baru
</Button>
</Group> </Group>
<Box style={{ overflowX: 'auto' }}> {/* Desktop Table */}
<Table highlightOnHover> <Box visibleFrom="md">
<Table highlightOnHover miw={0}>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '10%' }}>No</TableTh> <TableTh w="50%">
<TableTh style={{ width: '50%' }}>Nama</TableTh> <Text fz="sm" fw={600} lh={1.4}>Kategori</Text>
<TableTh style={{ width: '20%' }}>Edit</TableTh> </TableTh>
<TableTh style={{ width: '20%' }}>Hapus</TableTh> <TableTh w="20%">
<Text fz="sm" fw={600} lh={1.4} ta="center">Edit</Text>
</TableTh>
<TableTh w="20%">
<Text fz="sm" fw={600} lh={1.4} ta="center">Hapus</Text>
</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.length > 0 ? ( {filteredData.length > 0 ? (
filteredData.map((item, index) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Text fz="sm">{index + 1}</Text> <Text fz="sm" fw={500} lh={1.45} truncate="end">
</TableTd>
<TableTd>
<Text fw={500} truncate="end" lineClamp={1}>
{item.name} {item.name}
</Text> </Text>
</TableTd> </TableTd>
<TableTd> <TableTd ta="center">
<Button <Button
variant="light" variant="light"
color="green" color="green"
onClick={() => onClick={() =>
router.push( router.push(
`/admin/desa/berita/kategori-berita/${item.id}` `/admin/desa/berita/kategori-berita/${item.id}`
) )
} }
> size="compact-sm"
<IconEdit size={18} /> >
</Button> <IconEdit size={16} />
</Button>
</TableTd> </TableTd>
<TableTd> <TableTd ta="center">
<Button <Button
variant="light" variant="light"
color="red" color="red"
disabled={listDataState.delete.loading} disabled={listDataState.delete.loading}
onClick={() => { onClick={() => {
setSelectedId(item.id); setSelectedId(item.id);
setModalHapus(true); setModalHapus(true);
}} }}
> size="compact-sm"
<IconTrash size={18} /> >
</Button> <IconTrash size={16} />
</Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
)) ))
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={4}>
<Center py={20}> <Center py={24}>
<Text color="dimmed"> <Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori berita yang cocok Tidak ada data kategori berita yang cocok
</Text> </Text>
</Center> </Center>
@@ -161,22 +170,70 @@ function ListKategoriBerita({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Cards */}
<Stack hiddenFrom="md" gap="xs" mt="md">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder radius="md" p="sm" bg="white">
<Box flex={1} ml="md">
<Text fz="sm" fw={600} lh={1.4}>Kategori</Text>
<Text fz="sm" fw={500} lh={1.45} truncate>
{item.name}
</Text>
</Box>
<Group mt="sm" justify="flex-end" gap="xs">
<Button
variant="light"
color="green"
size="compact-xs"
onClick={() =>
router.push(
`/admin/desa/berita/kategori-berita/${item.id}`
)
}
>
<IconEdit size={14} />
</Button>
<Button
variant="light"
color="red"
size="compact-xs"
disabled={listDataState.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={14} />
</Button>
</Group>
</Paper>
))
) : (
<Center py={32}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori berita yang cocok
</Text>
</Center>
)}
</Stack>
</Paper> </Paper>
<Center> {totalPages > 1 && (
<Pagination <Center mt={{ base: 'lg', md: 'xl' }}>
value={page} <Pagination
onChange={(newPage) => { value={page}
load(newPage, 10, search); onChange={(newPage) => {
window.scrollTo({ top: 0, behavior: 'smooth' }); load(newPage, 10, search);
}} window.scrollTo({ top: 0, behavior: 'smooth' });
total={totalPages} }}
mt="md" total={totalPages}
mb="md" color="blue"
color="blue" radius="md"
radius="md" />
/> </Center>
</Center> )}
{/* Modal Konfirmasi Hapus */} {/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus <ModalKonfirmasiHapus
@@ -189,4 +246,4 @@ function ListKategoriBerita({ search }: { search: string }) {
); );
} }
export default KategoriBerita; export default KategoriBerita;

View File

@@ -150,7 +150,7 @@ function EditBerita() {
}; };
return ( return (
<Box px={{ base: "sm", md: "lg" }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button <Button

View File

@@ -41,7 +41,7 @@ function DetailBerita() {
const data = beritaState.berita.findUnique.data; const data = beritaState.berita.findUnique.data;
return ( return (
<Box py={10}> <Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Tombol Back */} {/* Tombol Back */}
<Button <Button
variant="subtle" variant="subtle"

View File

@@ -80,7 +80,7 @@ export default function CreateBerita() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header dengan tombol kembali */} {/* Header dengan tombol kembali */}
<Group mb="md"> <Group mb="md">
<Button <Button

View File

@@ -18,7 +18,7 @@ import {
Text, Text,
Title Title
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react'; import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
@@ -45,16 +45,17 @@ function Berita() {
function ListBerita({ search }: { search: string }) { function ListBerita({ search }: { search: string }) {
const beritaState = useProxy(stateDashboardBerita); const beritaState = useProxy(stateDashboardBerita);
const router = useRouter(); const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = beritaState.berita.findMany; const { data, page, totalPages, loading, load } = beritaState.berita.findMany;
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, search); load(page, 10, debouncedSearch);
}, [page, search]); }, [page, debouncedSearch]);
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py="md">
<Skeleton height={600} radius="md" /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
); );
@@ -63,64 +64,66 @@ function ListBerita({ search }: { search: string }) {
const filteredData = data || []; const filteredData = data || [];
return ( return (
<Box py={10}> <Box py="md">
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={4}>Daftar Berita</Title> <Title order={4}>Daftar Berita</Title>
<Button <Button
leftSection={<IconCircleDashedPlus size={18} />} leftSection={<IconCircleDashedPlus size={18} />}
color="blue" color="blue"
variant="light" variant="light"
onClick={() => router.push('/admin/desa/berita/list-berita/create')} onClick={() => router.push('/admin/desa/berita/list-berita/create')}
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Group> </Group>
<Box style={{ overflowX: 'auto' }}> {/* Desktop Table */}
<Table highlightOnHover> <Box visibleFrom="md">
<Table highlightOnHover miw={0}>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '30%' }}>Judul</TableTh> <TableTh w="50%">Judul</TableTh>
<TableTh style={{ width: '20%' }}>Kategori</TableTh> <TableTh w="30%">Kategori</TableTh>
<TableTh style={{ width: '15%' }}>Aksi</TableTh> <TableTh w="20%">Aksi</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.length > 0 ? ( {filteredData.length > 0 ? (
filteredData.map((item) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd style={{ width: '30%' }}> <TableTd>
<Box w={150}> <Text fz="md" fw={600} lh={1.45} truncate="end">
<Text fw={500} truncate="end" lineClamp={1}> {item.judul}
{item.judul} </Text>
</Text>
</Box>
</TableTd> </TableTd>
<TableTd style={{ width: '20%' }}> <TableTd>
<Text fz="sm" c="dimmed"> <Text fz="sm" c="dimmed" lh={1.45}>
{item.kategoriBerita?.name || '-'} {item.kategoriBerita?.name || '-'}
</Text> </Text>
</TableTd> </TableTd>
<TableTd style={{ width: '15%' }}> <TableTd>
<Button <Button
variant="light" variant="light"
color="blue" color="blue"
onClick={() => onClick={() =>
router.push(`/admin/desa/berita/list-berita/${item.id}`) router.push(`/admin/desa/berita/list-berita/${item.id}`)
} }
fz="sm"
px="sm"
h={36}
> >
<IconDeviceImacCog size={20} /> <IconDeviceImacCog size={18} />
<Text ml={5}>Detail</Text> <Text ml="xs">Detail</Text>
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
)) ))
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={3}>
<Center py={20}> <Center py="xl">
<Text color="dimmed"> <Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data berita yang cocok Tidak ada data berita yang cocok
</Text> </Text>
</Center> </Center>
@@ -130,6 +133,52 @@ function ListBerita({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Cards */}
<Stack hiddenFrom="md" gap="sm" mt="sm">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="md" radius="md">
<Stack gap={"xs"}>
<Text fz="sm" fw={600} lh={1.4} c="dimmed">
Judul
</Text>
<Text fz="sm" fw={500} lh={1.45}>
{item.judul}
</Text>
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">
Kategori
</Text>
<Text fz="sm" lh={1.45} fw={500}>
{item.kategoriBerita?.name || '-'}
</Text>
<Button
variant="light"
color="blue"
fullWidth
mt="sm"
onClick={() =>
router.push(`/admin/desa/berita/list-berita/${item.id}`)
}
fz="sm"
h={36}
>
<IconDeviceImacCog size={18} />
<Text ml="xs">Detail</Text>
</Button>
</Stack>
</Paper>
))
) : (
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data berita yang cocok
</Text>
</Center>
)}
</Stack>
</Paper> </Paper>
<Center> <Center>
@@ -150,4 +199,4 @@ function ListBerita({ search }: { search: string }) {
); );
} }
export default Berita; export default Berita;

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: 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">
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 px={{ base: 0, md: 'xs' }} py="xs">
<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={{ base: "100%", md: "70%" }}
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: 0, md: 'lg' }} py="xs">
{/* 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,216 @@
"use client"; 'use client'
import stateFileStorage from "@/state/state-list-image"; import colors from '@/con/colors';
import { import {
ActionIcon,
Box, Box,
Card, Button,
Flex, Center,
Group, Group,
Image,
Pagination, Pagination,
Paper, Paper,
SimpleGrid, Skeleton,
Stack, Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text, Text,
TextInput,
Title Title
} from "@mantine/core"; } from '@mantine/core';
import { useShallowEffect } from "@mantine/hooks"; import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconSearch, IconTrash, IconX } from "@tabler/icons-react"; import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
import { motion } from "framer-motion"; import { useRouter } from 'next/navigation';
import toast from "react-simple-toasts"; import { useState } from 'react';
import { useSnapshot } from "valtio"; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
export default function ListImage() { import stateGallery from '../../../_state/desa/gallery';
const { list, total } = useSnapshot(stateFileStorage);
useShallowEffect(() => {
stateFileStorage.load();
}, []);
let timeOut: NodeJS.Timer;
function Foto() {
const [search, setSearch] = useState("");
return ( return (
<Stack p="lg" gap="lg"> <Box>
<Flex justify="space-between" align="center" wrap="wrap" gap="md"> <HeaderSearch
<Title order={2} fw={700}> title='Foto'
Galeri Foto placeholder='Cari judul atau deskripsi foto...'
</Title> searchIcon={<IconSearch size={20} />}
<TextInput value={search}
radius="xl" onChange={(e) => setSearch(e.currentTarget.value)}
size="md" />
placeholder="Cari foto berdasarkan nama..." <ListFoto search={search} />
leftSection={<IconSearch size={18} />} </Box>
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>
); );
} }
function ListFoto({ search }: { search: string }) {
const FotoState = useProxy(stateGallery.foto)
const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000);
const {
data,
page,
totalPages,
loading,
load,
} = FotoState.findMany;
useShallowEffect(() => {
load(page, 10, debouncedSearch)
}, [page, debouncedSearch])
const filteredData = data || []
if (loading || !data) {
return (
<Stack py={{ base: 'md', md: 'lg' }}>
<Skeleton height={600} radius="md" />
</Stack>
)
}
return (
<Box py={{ base: 'md', 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={4} lh={1.2}>Daftar Foto</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/gallery/foto/create')}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md">
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Judul Foto</TableTh>
<TableTh>Tanggal</TableTh>
<TableTh>Deskripsi</TableTh>
<TableTh>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fz="md" fw={500} lh={1.45} truncate="end" lineClamp={1}>
{item.name}
</Text>
</TableTd>
<TableTd>
<Text fz="sm" c="dimmed" lh={1.45}>
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
</TableTd>
<TableTd>
<Text
fz="sm"
lh={1.45}
truncate="end"
lineClamp={1}
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
/>
</TableTd>
<TableTd>
<Button
variant="light"
color="blue"
size="xs"
onClick={() => router.push(`/admin/desa/gallery/foto/${item.id}`)}
>
<IconDeviceImac size={16} />
<Text ml={5} fz="sm" fw={500}>Detail</Text>
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>Tidak ada foto yang cocok</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Card View */}
<Box hiddenFrom="md">
<Stack gap="md">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder radius="sm" p="md">
<Stack gap="xs">
<Box>
<Text fz="sm" fw={600} lh={1.4}>Judul Foto</Text>
<Text fz="sm" fw={500} lh={1.45}>{item.name}</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>Tanggal</Text>
<Text fz="sm" fw={500} lh={1.45} c="dimmed">
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>Deskripsi</Text>
<Text fz="sm" fw={500} lh={1.45} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Box>
<Button
variant="light"
color="blue"
size="xs"
fullWidth
onClick={() => router.push(`/admin/desa/gallery/foto/${item.id}`)}
>
<IconDeviceImac size={16} />
<Text ml={5} fz="sm" fw={500}>Detail</Text>
</Button>
</Stack>
</Paper>
))
) : (
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>Tidak ada foto yang cocok</Text>
</Center>
)}
</Stack>
</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

@@ -118,7 +118,7 @@ function EditVideo() {
const embedLink = convertYoutubeUrlToEmbed(formData.linkVideo); const embedLink = convertYoutubeUrlToEmbed(formData.linkVideo);
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md"> <Group mb="md">
<Button <Button
variant="subtle" variant="subtle"

View File

@@ -40,7 +40,7 @@ function DetailVideo() {
const data = videoState.findUnique.data; const data = videoState.findUnique.data;
return ( return (
<Box py={10}> <Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Tombol Kembali */} {/* Tombol Kembali */}
<Button <Button
variant="subtle" variant="subtle"
@@ -54,7 +54,7 @@ function DetailVideo() {
{/* Detail Video */} {/* Detail Video */}
<Paper <Paper
withBorder withBorder
w={{ base: "100%", md: "50%" }} w={{ base: "100%", md: "70%" }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"

View File

@@ -58,7 +58,7 @@ function CreateVideo() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header Back Button + Title */} {/* Header Back Button + Title */}
<Group mb="md"> <Group mb="md">
<Button <Button

View File

@@ -18,7 +18,7 @@ import {
Text, Text,
Title Title
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react'; import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
@@ -45,6 +45,7 @@ function Video() {
function ListVideo({ search }: { search: string }) { function ListVideo({ search }: { search: string }) {
const videoState = useProxy(stateGallery.video) const videoState = useProxy(stateGallery.video)
const router = useRouter(); const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { const {
data, data,
@@ -55,75 +56,77 @@ function ListVideo({ search }: { search: string }) {
} = videoState.findMany; } = videoState.findMany;
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, search) load(page, 10, debouncedSearch)
}, [page, search]) }, [page, debouncedSearch])
const filteredData = data || [] const filteredData = data || []
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py={20}>
<Skeleton height={600} radius="md" /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
) )
} }
return ( return (
<Box py={10}> <Box py={20}>
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p={{ base: 'sm', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
<Title order={4}>Daftar Video</Title> <Title order={4} lh={1.2}>
<Button Daftar Video
leftSection={<IconPlus size={18} />} </Title>
color="blue" <Button
variant="light" leftSection={<IconPlus size={18} />}
onClick={() => router.push('/admin/desa/gallery/video/create')} color="blue"
> variant="light"
Tambah Baru onClick={() => router.push('/admin/desa/gallery/video/create')}
</Button> >
Tambah Baru
</Button>
</Group> </Group>
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover> {/* Desktop Table */}
<Box visibleFrom="md">
<Table highlightOnHover w="100%">
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '25%' }}>Judul Video</TableTh> <TableTh>Judul Video</TableTh>
<TableTh style={{ width: '20%' }}>Tanggal</TableTh> <TableTh>Tanggal</TableTh>
<TableTh style={{ width: '30%' }}>Deskripsi</TableTh> <TableTh>Deskripsi</TableTh>
<TableTh style={{ width: '15%' }}>Aksi</TableTh> <TableTh>Aksi</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.length > 0 ? ( {filteredData.length > 0 ? (
filteredData.map((item) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd style={{ width: '25%' }}> <TableTd>
<Box w={200}> <Text fz="md" fw={500} lh={1.45} truncate="end" lineClamp={1}>
<Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text> {item.name}
</Box> </Text>
</TableTd> </TableTd>
<TableTd style={{ width: '20%' }}> <TableTd>
<Box w={200}> <Text fz="sm" c="dimmed" lh={1.45}>
<Text fz="sm" c="dimmed">
{new Date(item.createdAt).toLocaleDateString('id-ID', { {new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric', day: 'numeric',
month: 'long', month: 'long',
year: 'numeric', year: 'numeric',
})} })}
</Text> </Text>
</Box>
</TableTd> </TableTd>
<TableTd style={{ width: '30%' }}> <TableTd>
<Box w={200}> <Text fz="sm" lh={1.45} truncate="end" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
<Text fz="sm" truncate="end" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Box>
</TableTd> </TableTd>
<TableTd style={{ width: '15%' }}> <TableTd>
<Button <Button
variant="light" variant="light"
color="blue" color="blue"
onClick={() => router.push(`/admin/desa/gallery/video/${item.id}`)} onClick={() => router.push(`/admin/desa/gallery/video/${item.id}`)}
fz="sm"
px="xs"
> >
<IconDeviceImac size={20} /> <IconDeviceImac size={18} />
<Text ml={5}>Detail</Text> <Text ml={5}>Detail</Text>
</Button> </Button>
</TableTd> </TableTd>
@@ -132,8 +135,10 @@ function ListVideo({ search }: { search: string }) {
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={4}>
<Center py={20}> <Center py={24}>
<Text c="dimmed">Tidak ada video yang cocok</Text> <Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada video yang cocok
</Text>
</Center> </Center>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -141,23 +146,74 @@ function ListVideo({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Cards */}
<Stack hiddenFrom="md" gap="xs" mt="sm">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} p="sm" withBorder radius="sm">
<Stack gap={"xs"}>
<Box>
<Text fz="xs" fw={600} lh={1.4}>Judul Video</Text>
<Text fz="sm" fw={500} lh={1.45}>
{item.name}
</Text>
</Box>
<Box>
<Text fz="xs" fw={600} lh={1.4}>Tanggal</Text>
<Text fz="sm" fw={500} lh={1.45}>
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
</Box>
<Box>
<Text fz="xs" fw={600} lh={1.4}>Deskripsi</Text>
<Text fz="sm" lineClamp={5} fw={500} lh={1.45} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Box>
<Box>
<Button
variant="light"
color="blue"
fullWidth
onClick={() => router.push(`/admin/desa/gallery/video/${item.id}`)}
fz="sm"
>
<IconDeviceImac size={18} />
<Text ml={5}>Detail</Text>
</Button>
</Box>
</Stack>
</Paper>
))
) : (
<Center py={24}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada video yang cocok
</Text>
</Center>
)}
</Stack>
</Paper> </Paper>
<Center>
<Pagination {totalPages > 1 && (
value={page} <Center mt="xl">
onChange={(newPage) => { <Pagination
load(newPage, 10) value={page}
window.scrollTo({ top: 0, behavior: 'smooth' }) onChange={(newPage) => {
}} load(newPage, 10)
total={totalPages} window.scrollTo({ top: 0, behavior: 'smooth' })
mt="md" }}
mb="md" total={totalPages}
color="blue" color="blue"
radius="md" radius="md"
/> />
</Center> </Center>
)}
</Box> </Box>
); );
} }
export default Video; export default Video;

View File

@@ -115,7 +115,7 @@ function EditAjukanPermohonan() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Back Button */} {/* Back Button */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">

View File

@@ -48,7 +48,7 @@ function DetailAjukanPermohonan() {
const data = ajukanPermohonanState.findUnique.data; const data = ajukanPermohonanState.findUnique.data;
return ( return (
<Box py={10}> <Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Tombol Kembali */} {/* Tombol Kembali */}
<Button <Button
variant="subtle" variant="subtle"
@@ -61,7 +61,7 @@ function DetailAjukanPermohonan() {
<Paper <Paper
withBorder withBorder
w={{ base: '100%', md: '60%' }} w={{ base: '100%', md: '70%' }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"

View File

@@ -24,6 +24,7 @@ import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import stateLayananDesa from '../../../_state/desa/layananDesa'; import stateLayananDesa from '../../../_state/desa/layananDesa';
import { useDebouncedValue } from '@mantine/hooks';
function AjukanPermohonan() { function AjukanPermohonan() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -44,6 +45,7 @@ function AjukanPermohonan() {
function ListAjukanPermohonan({ search }: { search: string }) { function ListAjukanPermohonan({ search }: { search: string }) {
const AjukanPermohonanState = useProxy(stateLayananDesa.ajukanPermohonan); const AjukanPermohonanState = useProxy(stateLayananDesa.ajukanPermohonan);
const router = useRouter(); const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000); // 500ms delay
const { const {
data, data,
@@ -54,58 +56,56 @@ function ListAjukanPermohonan({ search }: { search: string }) {
} = AjukanPermohonanState.findMany; } = AjukanPermohonanState.findMany;
useEffect(() => { useEffect(() => {
load(page, 10, search); load(page, 10, debouncedSearch);
}, [page, search]); }, [page, debouncedSearch]);
// Loading state // Loading state
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py={{ base: 'sm', md: 'md' }}>
<Skeleton height={600} radius="md" /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
); );
} }
return ( return (
<Box py={10}> <Box py={{ base: 'sm', md: 'md' }}>
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Title order={4}>List Ajukan Permohonan</Title> <Title order={2} lh={1.2} mb={{ base: 'md', md: 'lg' }}>
<Box style={{ overflowX: "auto" }}> List Ajukan Permohonan
<Table highlightOnHover> </Title>
{/* Desktop Table */}
<Box visibleFrom="md">
<Table highlightOnHover miw={0}>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '30%' }}>Nama</TableTh> <TableTh fz="sm" fw={600} lh={1.4}>Nama</TableTh>
<TableTh style={{ width: '45%' }}>Alamat</TableTh> <TableTh fz="sm" fw={600} lh={1.4}>Alamat</TableTh>
<TableTh style={{ width: '15%' }}>NIK</TableTh> <TableTh fz="sm" fw={600} lh={1.4}>NIK</TableTh>
<TableTh style={{ width: '15%' }}>Aksi</TableTh> <TableTh fz="sm" fw={600} lh={1.4}>Aksi</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{data.length > 0 ? ( {data.length > 0 ? (
data.map((item) => ( data.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd style={{ width: '30%' }}> <TableTd>
<Box w={200}> <Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}>
<Text fw={500} truncate="end" lineClamp={1}> {item.nama}
{item.nama} </Text>
</Text>
</Box>
</TableTd> </TableTd>
<TableTd style={{ width: '45%' }}> <TableTd>
<Box w={200}> <Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}>
<Text fw={500} truncate="end" lineClamp={1}> {item.alamat}
{item.alamat} </Text>
</Text>
</Box>
</TableTd> </TableTd>
<TableTd style={{ width: '45%' }}> <TableTd>
<Box w={200}> <Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}>
<Text fw={500} truncate="end" lineClamp={1}> {item.nik}
{item.nik} </Text>
</Text>
</Box>
</TableTd> </TableTd>
<TableTd style={{ width: '15%' }}> <TableTd>
<Button <Button
size="xs" size="xs"
radius="md" radius="md"
@@ -123,9 +123,11 @@ function ListAjukanPermohonan({ search }: { search: string }) {
)) ))
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={3}> <TableTd colSpan={4}>
<Center py={20}> <Center py="xl">
<Text color="dimmed">Tidak ada data ajukan permohonan yang cocok</Text> <Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data ajukan permohonan yang cocok
</Text>
</Center> </Center>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -133,23 +135,71 @@ function ListAjukanPermohonan({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Card View */}
<Box hiddenFrom="md">
<Stack gap="md">
{data.length > 0 ? (
data.map((item) => (
<Paper key={item.id} withBorder p="md" radius="md" shadow="xs">
<Stack gap={'xs'}>
<Box>
<Text fz="sm" fw={600} lh={1.4}>Nama</Text>
<Text fz="sm" fw={500} lh={1.5}>{item.nama}</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>Alamat</Text>
<Text fz="sm" fw={500} lh={1.5}>{item.alamat}</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>NIK</Text>
<Text fz="sm" fw={500} lh={1.5}>{item.nik}</Text>
</Box>
<Box>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/desa/layanan/ajukan_permohonan/${item.id}`)
}
fullWidth
>
Detail
</Button>
</Box>
</Stack>
</Paper>
))
) : (
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data ajukan permohonan yang cocok
</Text>
</Center>
)}
</Stack>
</Box>
</Paper> </Paper>
<Center>
<Pagination {totalPages > 1 && (
value={page} <Center mt="md">
onChange={(newPage) => { <Pagination
load(newPage, 10, search); value={page}
window.scrollTo({ top: 0, behavior: 'smooth' }); onChange={(newPage) => {
}} load(newPage, 10, search);
total={totalPages} window.scrollTo({ top: 0, behavior: 'smooth' });
mt="md" }}
mb="md" total={totalPages}
color="blue" color="blue"
radius="md" radius="md"
/> />
</Center> </Center>
)}
</Box> </Box>
); );
} }
export default AjukanPermohonan; export default AjukanPermohonan;

View File

@@ -108,7 +108,7 @@ function EditPelayananPendudukNonPermanent() {
}; };
return ( return (
<Box> <Box px={{ base: 0, md: 'xs' }} py="xs">
<Stack gap="xs"> <Stack gap="xs">
<Group mb="md"> <Group mb="md">
<Button <Button

View File

@@ -45,24 +45,28 @@ function PelayananPendudukNonPermanent() {
{/* Header */} {/* Header */}
<Grid align="center"> <Grid align="center">
<GridCol span={{ base: 12, md: 11 }}> <GridCol span={{ base: 12, md: 11 }}>
<Title order={3} c={colors['blue-button']}> <Title
order={3}
lh={1.2}
c={colors['blue-button']}
>
Preview Pelayanan Penduduk Non Permanen Preview Pelayanan Penduduk Non Permanen
</Title> </Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Button <Button
c="green" c="green"
variant="light" variant="light"
leftSection={<IconEdit size={18} stroke={2} />} leftSection={<IconEdit size={18} stroke={2} />}
radius="md" radius="md"
onClick={() => onClick={() =>
router.push( router.push(
`/admin/desa/layanan/pelayanan_penduduk_non_permanent/${data.id}` `/admin/desa/layanan/pelayanan_penduduk_non_permanent/${data.id}`
) )
} }
> >
Edit Edit
</Button> </Button>
</GridCol> </GridCol>
</Grid> </Grid>
@@ -70,14 +74,14 @@ function PelayananPendudukNonPermanent() {
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs"> <Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
<Box px={{ base: 0, md: 50 }} pb="xl"> <Box px={{ base: 0, md: 50 }} pb="xl">
<Center> <Center>
<Text <Title
order={2}
lh={1.2}
ta="center" ta="center"
fz={{ base: '1.2rem', md: '1.8rem' }}
fw="bold"
c={colors['blue-button']} c={colors['blue-button']}
> >
{data.name} {data.name}
</Text> </Title>
</Center> </Center>
<Divider my="md" color={colors['blue-button']} /> <Divider my="md" color={colors['blue-button']} />
@@ -86,9 +90,11 @@ function PelayananPendudukNonPermanent() {
<Text <Text
py={10} py={10}
ta="justify" ta="justify"
fz={{ base: '1rem', md: '1.2rem' }} fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.5, md: 1.55 }}
c="dark"
dangerouslySetInnerHTML={{ __html: data.deskripsi }} dangerouslySetInnerHTML={{ __html: data.deskripsi }}
style={{wordBreak: "break-word", whiteSpace: "normal"}} style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
/> />
</Box> </Box>
</Box> </Box>
@@ -98,4 +104,4 @@ function PelayananPendudukNonPermanent() {
); );
} }
export default PelayananPendudukNonPermanent; export default PelayananPendudukNonPermanent;

View File

@@ -123,7 +123,7 @@ function EditPelayananPerizinanBerusaha() {
} }
return ( return (
<Box> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Stack gap="xs"> <Stack gap="xs">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">

View File

@@ -41,8 +41,7 @@ function PerizinanBerusaha() {
const loadData = async () => { const loadData = async () => {
try { try {
setLoading(true); setLoading(true);
// You should get the ID from your router query or params const id = 'edit';
const id = 'edit'; // Replace with actual ID or get from URL params
await pelayananPerizinanBerusaha.findById.load(id); await pelayananPerizinanBerusaha.findById.load(id);
} catch (err) { } catch (err) {
setError('Gagal memuat data'); setError('Gagal memuat data');
@@ -66,7 +65,7 @@ function PerizinanBerusaha() {
if (error || !pelayananPerizinanBerusaha.findById.data) { if (error || !pelayananPerizinanBerusaha.findById.data) {
return ( return (
<Center h={200}> <Center h={200}>
<Text>{error || 'Data tidak ditemukan'}</Text> <Text c="dimmed">{error || 'Data tidak ditemukan'}</Text>
</Center> </Center>
); );
} }
@@ -79,24 +78,24 @@ function PerizinanBerusaha() {
{/* Header */} {/* Header */}
<Grid align="center"> <Grid align="center">
<GridCol span={{ base: 12, md: 11 }}> <GridCol span={{ base: 12, md: 11 }}>
<Title order={3} c={colors['blue-button']}> <Title order={3} c={colors['blue-button']} lh={1.2}>
Preview Pelayanan Perizinan Berusaha Preview Pelayanan Perizinan Berusaha
</Title> </Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Button <Button
c="green" c="green"
variant="light" variant="light"
leftSection={<IconEdit size={18} stroke={2} />} leftSection={<IconEdit size={18} stroke={2} />}
radius="md" radius="md"
onClick={() => onClick={() =>
router.push( router.push(
`/admin/desa/layanan/pelayanan_perizinan_berusaha/${data.id}` `/admin/desa/layanan/pelayanan_perizinan_berusaha/${data.id}`
) )
} }
> >
Edit Edit
</Button> </Button>
</GridCol> </GridCol>
</Grid> </Grid>
@@ -104,38 +103,40 @@ function PerizinanBerusaha() {
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs"> <Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
<Box px={{ base: 0, md: 50 }} pb="xl"> <Box px={{ base: 0, md: 50 }} pb="xl">
<Center> <Center>
<Text <Title
order={3}
ta="center" ta="center"
fz={{ base: '1.2rem', md: '1.8rem' }}
fw="bold"
c={colors['blue-button']} c={colors['blue-button']}
lh={1.15}
> >
{data.name} {data.name}
</Text> </Title>
</Center> </Center>
<Divider my="md" color={colors['blue-button']} /> <Divider my="md" color={colors['blue-button']} />
<Box mt="lg"> <Box mt="lg">
<Text <Text
py={10} py="xs"
ta="justify" ta={{ base: "left", md: "justify" }}
fz={{ base: '1rem', md: '1.2rem' }} fz={{ base: 'sm', md: 'md' }}
lh={1.55}
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
dangerouslySetInnerHTML={{ __html: data.deskripsi }} dangerouslySetInnerHTML={{ __html: data.deskripsi }}
style={{wordBreak: "break-word", whiteSpace: "normal"}}
/> />
<Text <Text
py={10} py="xs"
fz={{ base: '1rem', md: '1.2rem' }} fz={{ base: 'sm', md: 'md' }}
fw="bold" fw={700}
c={colors['blue-button']} c={colors['blue-button']}
lh={1.5}
> >
Proses pendaftaran NIB melalui OSS mencakup beberapa langkah Proses pendaftaran NIB melalui OSS mencakup beberapa langkah
umum: umum:
</Text> </Text>
<Box p="xl" w="100%"> <Box p="xl" w="100%" visibleFrom='md'>
<Stepper <Stepper
active={active} active={active}
onStepClick={setActive} onStepClick={setActive}
@@ -143,28 +144,115 @@ function PerizinanBerusaha() {
styles={{ styles={{
separator: { marginLeft: 25 }, separator: { marginLeft: 25 },
step: { padding: '12px 0' }, step: { padding: '12px 0' },
stepLabel: {
fontSize: 'var(--mantine-font-size-sm)',
fontWeight: 700,
lineHeight: 1.4,
},
stepDescription: {
fontSize: 'var(--mantine-font-size-xs)',
lineHeight: 1.4,
},
}} }}
> >
<StepperStep label="Langkah Pertama" description="Pendaftaran Akun"> <StepperStep label="Langkah Pertama" description="Pendaftaran Akun">
Pendaftaran akun pada portal OSS <Text fz="sm" lh={1.5}>
Pendaftaran akun pada portal OSS
</Text>
</StepperStep> </StepperStep>
<StepperStep label="Langkah Kedua" description="Pengisian Data Perusahaan"> <StepperStep label="Langkah Kedua" description="Pengisian Data Perusahaan">
Mengisi informasi perusahaan, termasuk data pemegang saham, alamat perusahaan, dan lainnya <Text fz="sm" lh={1.5}>
Mengisi informasi perusahaan, termasuk data pemegang saham, alamat perusahaan, dan lainnya
</Text>
</StepperStep> </StepperStep>
<StepperStep label="Langkah Ketiga" description="Pemilihan KBLI"> <StepperStep label="Langkah Ketiga" description="Pemilihan KBLI">
Memilih KBLI dengan jenis usaha yang akan didaftarkan <Text fz="sm" lh={1.5}>
Memilih KBLI dengan jenis usaha yang akan didaftarkan
</Text>
</StepperStep> </StepperStep>
<StepperStep label="Langkah Keempat" description="Pengunggahan Dokumen"> <StepperStep label="Langkah Keempat" description="Pengunggahan Dokumen">
Mengunggah dokumen-dokumen yang diperlukan, seperti akta pendirian perusahaan, surat izin usaha, dan dokumen lainnya sesuai dengan ketentuan yang berlaku <Text fz="sm" lh={1.5}>
Mengunggah dokumen-dokumen yang diperlukan, seperti akta pendirian perusahaan, surat izin usaha, dan dokumen lainnya sesuai dengan ketentuan yang berlaku
</Text>
</StepperStep> </StepperStep>
<StepperStep label="Langkah Kelima" description="Verifikasi dan Persetujuan"> <StepperStep label="Langkah Kelima" description="Verifikasi dan Persetujuan">
Proses verifikasi dan persetujuan oleh instansi terkait <Text fz="sm" lh={1.5}>
Proses verifikasi dan persetujuan oleh instansi terkait
</Text>
</StepperStep> </StepperStep>
<StepperStep label="Langkah Keenam" description="Penerimaan NIB"> <StepperStep label="Langkah Keenam" description="Penerimaan NIB">
Jika proses sebelumnya berhasil, perusahaan akan menerima NIB sebagai identitas resmi usaha anda <Text fz="sm" lh={1.5}>
Jika proses sebelumnya berhasil, perusahaan akan menerima NIB sebagai identitas resmi usaha anda
</Text>
</StepperStep> </StepperStep>
<StepperCompleted> <StepperCompleted>
Selesai, anda telah mengikuti proses pendaftaran NIB melalui OSS <Text fz="sm" lh={1.5}>
Selesai, anda telah mengikuti proses pendaftaran NIB melalui OSS
</Text>
</StepperCompleted>
</Stepper>
<Group justify="center" mt="xl">
<Button variant="default" onClick={prevStep}>
Back
</Button>
<Button onClick={nextStep}>Next step</Button>
</Group>
</Box>
<Box p="xl" w="100%" hiddenFrom='md'>
<Stepper
active={active}
onStepClick={setActive}
orientation="vertical"
styles={{
separator: { marginLeft: 25 },
step: { padding: '12px 0' },
stepLabel: {
fontSize: 'var(--mantine-font-size-sm)',
fontWeight: 700,
lineHeight: 1.4,
},
stepDescription: {
fontSize: 'var(--mantine-font-size-xs)',
lineHeight: 1.4,
},
}}
>
<StepperStep label="Langkah Pertama" description="Pendaftaran Akun">
<Text fz="sm" lh={1.5}>
</Text>
</StepperStep>
<StepperStep label="Langkah Kedua" description="Pengisian Data Perusahaan">
<Text fz="sm" lh={1.5}>
</Text>
</StepperStep>
<StepperStep label="Langkah Ketiga" description="Pemilihan KBLI">
<Text fz="sm" lh={1.5}>
</Text>
</StepperStep>
<StepperStep label="Langkah Keempat" description="Pengunggahan Dokumen">
<Text fz="sm" lh={1.5}>
</Text>
</StepperStep>
<StepperStep label="Langkah Kelima" description="Verifikasi dan Persetujuan">
<Text fz="sm" lh={1.5}>
</Text>
</StepperStep>
<StepperStep label="Langkah Keenam" description="Penerimaan NIB">
<Text fz="sm" lh={1.5}>
</Text>
</StepperStep>
<StepperCompleted>
<Text fz="sm" lh={1.5}>
</Text>
</StepperCompleted> </StepperCompleted>
</Stepper> </Stepper>
@@ -177,9 +265,10 @@ function PerizinanBerusaha() {
</Box> </Box>
<Text <Text
py={35} py="md"
ta="justify" ta={{ base: "left", md: "justify" }}
fz={{ base: '1rem', md: '1.2rem' }} fz={{ base: 'sm', md: 'md' }}
lh={1.55}
> >
Penting untuk diingat bahwa prosedur dan persyaratan dapat Penting untuk diingat bahwa prosedur dan persyaratan dapat
berubah seiring waktu. Untuk informasi yang lebih akurat dan berubah seiring waktu. Untuk informasi yang lebih akurat dan
@@ -203,5 +292,4 @@ function PerizinanBerusaha() {
); );
} }
export default PerizinanBerusaha; export default PerizinanBerusaha;

View File

@@ -64,7 +64,7 @@ const FileUploader: React.FC<FileUploaderProps> = ({
}; };
return ( return (
<Box> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
{title} {title}
</Text> </Text>

View File

@@ -49,7 +49,7 @@ function DetailSuratKeterangan() {
const data = suratKeteranganState.findUnique.data; const data = suratKeteranganState.findUnique.data;
return ( return (
<Box py={10}> <Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Tombol Kembali */} {/* Tombol Kembali */}
<Button <Button
variant="subtle" variant="subtle"
@@ -62,7 +62,7 @@ function DetailSuratKeterangan() {
<Paper <Paper
withBorder withBorder
w={{ base: '100%', md: '60%' }} w={{ base: '100%', md: '70%' }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"
@@ -75,20 +75,21 @@ function DetailSuratKeterangan() {
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs"> <Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm"> <Stack gap="sm">
<Box> <Stack gap={"xs"}>
<Text fz="lg" fw="bold"> <Text fz="lg" fw="bold">
Nama Nama
</Text> </Text>
<Text fz="md" c="dimmed"> <Text fz="md" c="dimmed">
{data?.name || '-'} {data?.name || '-'}
</Text> </Text>
</Box> </Stack>
<Box> <Stack gap={"xs"}>
<Text fz="lg" fw="bold"> <Text fz="lg" fw="bold">
Deskripsi Deskripsi
</Text> </Text>
<Text <Box pl={10}>
<Text
fz="md" fz="md"
c="dimmed" c="dimmed"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
@@ -96,9 +97,10 @@ function DetailSuratKeterangan() {
}} }}
style={{ wordBreak: "break-word", whiteSpace: "normal" }} style={{ wordBreak: "break-word", whiteSpace: "normal" }}
/> />
</Box> </Box>
</Stack>
<Box> <Stack gap={"xs"}>
<Text fz="lg" fw="bold"> <Text fz="lg" fw="bold">
Gambar Konten Pelayanan Gambar Konten Pelayanan
</Text> </Text>
@@ -117,7 +119,7 @@ function DetailSuratKeterangan() {
Tidak ada gambar Tidak ada gambar
</Text> </Text>
)} )}
</Box> </Stack>
<Box> <Box>
<Text fz="lg" fw="bold"> <Text fz="lg" fw="bold">

View File

@@ -87,7 +87,7 @@ function CreateSuratKeterangan() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">

View File

@@ -17,7 +17,7 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title Title,
} from '@mantine/core'; } from '@mantine/core';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react'; import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -25,9 +25,10 @@ import { useEffect, useMemo, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import stateLayananDesa from '../../../_state/desa/layananDesa'; import stateLayananDesa from '../../../_state/desa/layananDesa';
import { useDebouncedValue } from '@mantine/hooks';
function SuratKeterangan() { function SuratKeterangan() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState('');
return ( return (
<Box> <Box>
<HeaderSearch <HeaderSearch
@@ -45,6 +46,7 @@ function SuratKeterangan() {
function ListSuratKeterangan({ search }: { search: string }) { function ListSuratKeterangan({ search }: { search: string }) {
const suratKeteranganState = useProxy(stateLayananDesa.suratKeterangan); const suratKeteranganState = useProxy(stateLayananDesa.suratKeterangan);
const router = useRouter(); const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { const {
data, data,
@@ -55,72 +57,80 @@ function ListSuratKeterangan({ search }: { search: string }) {
} = suratKeteranganState.findMany; } = suratKeteranganState.findMany;
useEffect(() => { useEffect(() => {
load(page, 10, search); load(page, 10, debouncedSearch);
}, [page, search]); }, [page, debouncedSearch]);
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
if (!data) return []; if (!data) return [];
const keyword = search.toLowerCase(); const keyword = debouncedSearch.toLowerCase();
return data.filter(item => return data.filter(
item.name?.toLowerCase().includes(keyword) || (item) =>
item.deskripsi?.toLowerCase().includes(keyword) item.name?.toLowerCase().includes(keyword) ||
item.deskripsi?.toLowerCase().includes(keyword)
); );
}, [data, search]); }, [data, debouncedSearch]);
// Loading state
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py={{ base: 'sm', md: 'md' }}>
<Skeleton height={600} radius="md" /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
); );
} }
return ( return (
<Box py={10}> <Box py={{ base: 'sm', md: 'md' }}>
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p={{ base: 'sm', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
<Title order={4}>List Surat Keterangan</Title> <Title order={4} lh={1.2}>
<Button List Surat Keterangan
leftSection={<IconPlus size={18} />} </Title>
color="blue" <Button
variant="light" leftSection={<IconPlus size={18} />}
onClick={() => color="blue"
router.push('/admin/desa/layanan/pelayanan_surat_keterangan/create') variant="light"
} onClick={() =>
> router.push('/admin/desa/layanan/pelayanan_surat_keterangan/create')
Tambah Baru }
</Button> >
Tambah Baru
</Button>
</Group> </Group>
<Box style={{ overflowX: "auto" }}>
{/* Desktop Table */}
<Box visibleFrom="md">
<Table highlightOnHover> <Table highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '30%' }}>Nama</TableTh> <TableTh fz="sm" fw={600} ta="left">
<TableTh style={{ width: '45%' }}>Deskripsi</TableTh> Nama
<TableTh style={{ width: '15%' }}>Aksi</TableTh> </TableTh>
<TableTh fz="sm" fw={600} ta="left">
Deskripsi
</TableTh>
<TableTh fz="sm" fw={600} ta="left">
Aksi
</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.length > 0 ? ( {filteredData.length > 0 ? (
filteredData.map((item) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd style={{ width: '30%' }}> <TableTd>
<Box w={200}> <Text fz="md" fw={500} lh={1.5} truncate="end">
<Text fw={500} truncate="end" lineClamp={1}> {item.name}
{item.name} </Text>
</Text>
</Box>
</TableTd> </TableTd>
<TableTd style={{ width: '45%' }}> <TableTd>
<Box w={200}> <Text
<Text truncate="end" lineClamp={1} fz="sm" c="dimmed" fz="sm"
dangerouslySetInnerHTML={{ __html: item.deskripsi }} lh={1.5}
style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: item.deskripsi || '' }}
/> style={{ wordBreak: 'break-word' }}
</Box> />
</TableTd> </TableTd>
<TableTd style={{ width: '15%' }}> <TableTd>
<Button <Button
size="xs" size="xs"
radius="md" radius="md"
@@ -128,7 +138,9 @@ function ListSuratKeterangan({ search }: { search: string }) {
color="blue" color="blue"
leftSection={<IconDeviceImacCog size={16} />} leftSection={<IconDeviceImacCog size={16} />}
onClick={() => onClick={() =>
router.push(`/admin/desa/layanan/pelayanan_surat_keterangan/${item.id}`) router.push(
`/admin/desa/layanan/pelayanan_surat_keterangan/${item.id}`
)
} }
> >
Detail Detail
@@ -139,8 +151,10 @@ function ListSuratKeterangan({ search }: { search: string }) {
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={3}> <TableTd colSpan={3}>
<Center py={20}> <Center py="xl">
<Text color="dimmed">Tidak ada data surat keterangan yang cocok</Text> <Text c="dimmed" fz="sm" ta="center">
Tidak ada data surat keterangan yang cocok
</Text>
</Center> </Center>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -148,7 +162,67 @@ function ListSuratKeterangan({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Cards */}
<Box hiddenFrom="md">
<Stack gap="sm">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="sm" radius="md">
<Stack gap={'xs'}>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Nama
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.name}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Deskripsi
</Text>
<Box pl={8}>
<Text
fz="sm"
fw={500}
lh={1.4}
dangerouslySetInnerHTML={{ __html: item.deskripsi || '' }}
style={{ wordBreak: 'break-word' }}
/>
</Box>
</Box>
<Box>
<Button
fullWidth
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(
`/admin/desa/layanan/pelayanan_surat_keterangan/${item.id}`
)
}
>
Detail
</Button>
</Box>
</Stack>
</Paper>
))
) : (
<Center py="xl">
<Text c="dimmed" fz="sm" ta="center">
Tidak ada data surat keterangan yang cocok
</Text>
</Center>
)}
</Stack>
</Box>
</Paper> </Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
@@ -167,4 +241,4 @@ function ListSuratKeterangan({ search }: { search: string }) {
); );
} }
export default SuratKeterangan; export default SuratKeterangan;

View File

@@ -74,13 +74,13 @@ function EditPelayananTelunjukSakti() {
); );
const handleResetForm = () => { const handleResetForm = () => {
setFormData({ setFormData({
name: originalData.name, name: originalData.name,
deskripsi: originalData.deskripsi, deskripsi: originalData.deskripsi,
link: originalData.link, link: originalData.link,
}); });
toast.info("Form dikembalikan ke data awal"); toast.info("Form dikembalikan ke data awal");
}; };
// Submit: update global state hanya saat simpan // Submit: update global state hanya saat simpan
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -102,12 +102,12 @@ function EditPelayananTelunjukSakti() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Back Button + Title */} {/* Back Button + Title */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Pelayanan Telunjuk Sakti Desa Edit Pelayanan Telunjuk Sakti Desa
</Title> </Title>

View File

@@ -50,7 +50,7 @@ function DetailPelayananTelunjukSakti() {
const data = telunjukSaktiState.findUnique.data; const data = telunjukSaktiState.findUnique.data;
return ( return (
<Box py={10}> <Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Tombol Kembali */} {/* Tombol Kembali */}
<Button <Button
variant="subtle" variant="subtle"
@@ -63,7 +63,7 @@ function DetailPelayananTelunjukSakti() {
<Paper <Paper
withBorder withBorder
w={{ base: '100%', md: '60%' }} w={{ base: '100%', md: '70%' }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"

View File

@@ -47,7 +47,7 @@ function CreatePelayananTelunjukDesa() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">

View File

@@ -1,5 +1,5 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
@@ -18,7 +18,7 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title Title,
} from '@mantine/core'; } from '@mantine/core';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react'; import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -26,9 +26,10 @@ import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import stateLayananDesa from '../../../_state/desa/layananDesa'; import stateLayananDesa from '../../../_state/desa/layananDesa';
import { useDebouncedValue } from '@mantine/hooks';
function PelayananTelunjukSakti() { function PelayananTelunjukSakti() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState('');
return ( return (
<Box> <Box>
<HeaderSearch <HeaderSearch
@@ -46,46 +47,57 @@ function PelayananTelunjukSakti() {
function ListPelayananTelunjukSakti({ search }: { search: string }) { function ListPelayananTelunjukSakti({ search }: { search: string }) {
const telunjukSaktiState = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa); const telunjukSaktiState = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa);
const router = useRouter(); const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = telunjukSaktiState.findMany; const { data, page, totalPages, loading, load } = telunjukSaktiState.findMany;
useEffect(() => { useEffect(() => {
load(page, 10, search); load(page, 10, debouncedSearch);
}, [page, search]); }, [page, debouncedSearch]);
const filteredData = data || []; const filteredData = data || [];
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py={{ base: 'sm', md: 'md' }}>
<Skeleton height={400} radius="md" /> <Skeleton height={400} radius="md" />
</Stack> </Stack>
); );
} }
return ( return (
<Box py={10}> <Box py={{ base: 'sm', md: 'md' }}>
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={4}>Daftar Pelayanan Telunjuk Sakti</Title> <Title order={4} lh={1.2}>
<Button Daftar Pelayanan Telunjuk Sakti
leftSection={<IconPlus size={18} />} </Title>
color="blue" <Button
variant="light" leftSection={<IconPlus size={18} />}
onClick={() => color="blue"
router.push('/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/create') variant="light"
} onClick={() =>
> router.push('/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/create')
Tambah Baru }
</Button> >
Tambah Baru
</Button>
</Group> </Group>
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover style={{ minWidth: '700px' }}> {/* Desktop Table */}
<Box visibleFrom="md">
<Table highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '30%' }}>Nama</TableTh> <TableTh fz="sm" fw={600} ta="left" c="gray.8" w="30%">
<TableTh style={{ width: '40%' }}>Link</TableTh> Nama
<TableTh style={{ width: '30%' }}>Detail</TableTh> </TableTh>
<TableTh fz="sm" fw={600} ta="left" c="gray.8" w="40%">
Link
</TableTh>
<TableTh fz="sm" fw={600} ta="left" c="gray.8" w="30%">
Detail
</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
@@ -93,18 +105,19 @@ function ListPelayananTelunjukSakti({ search }: { search: string }) {
filteredData.map((item) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Box w={200}> <Text fz="sm" fw={500} lh={1.5} truncate="end">
<Text fw={500} truncate="end" lineClamp={1}> {item.name}
{item.name} </Text>
</Text></Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={200}> <a href={item.link} target="_blank" rel="noopener noreferrer">
<a href={item.link} target="_blank" rel="noopener noreferrer"> <Text
<Text lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} style={{wordBreak: "break-word", whiteSpace: "normal"}} truncate="end" fz={"sm"} /> fz="sm"
</a> lh={1.5}
</Box> truncate="end"
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
/>
</a>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button <Button
@@ -117,7 +130,9 @@ function ListPelayananTelunjukSakti({ search }: { search: string }) {
} }
> >
<IconDeviceImacCog size={20} /> <IconDeviceImacCog size={20} />
<Text ml={5}>Detail</Text> <Text ml="xs" fz="sm" fw={500}>
Detail
</Text>
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -125,8 +140,8 @@ function ListPelayananTelunjukSakti({ search }: { search: string }) {
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={3}> <TableTd colSpan={3}>
<Center py={20}> <Center py="lg">
<Text color="dimmed"> <Text c="dimmed" fz="sm" lh={1.5}>
Tidak ada data layanan yang cocok Tidak ada data layanan yang cocok
</Text> </Text>
</Center> </Center>
@@ -136,17 +151,68 @@ function ListPelayananTelunjukSakti({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Cards */}
<Box hiddenFrom="md">
{filteredData.length > 0 ? (
<Stack gap="md">
{filteredData.map((item) => (
<Paper key={item.id} withBorder p="md" radius="sm">
<Box mb="xs">
<Text fz='sm' fw={600} lh={1.4} c="gray.8">
Nama
</Text>
<Text fz="sm" fw={500} lh={1.5}>
{item.name}
</Text>
</Box>
<Box mb="xs">
<Text fz='sm' fw={600} lh={1.4} c="gray.8">
Link
</Text>
<Text fz="sm" fw={500} lh={1.5} component="a" href={item.link} target="_blank" rel="noopener noreferrer">
{item.deskripsi}
</Text>
</Box>
<Button
variant="light"
color="blue"
fullWidth
mt="sm"
onClick={() =>
router.push(
`/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/${item.id}`
)
}
>
<IconDeviceImacCog size={20} />
<Text ml="xs" fz="sm" fw={500}>
Detail
</Text>
</Button>
</Paper>
))}
</Stack>
) : (
<Center py="lg">
<Text c="dimmed" fz="sm" lh={1.5}>
Tidak ada data layanan yang cocok
</Text>
</Center>
)}
</Box>
</Paper> </Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => { onChange={(newPage) => {
load(newPage, 10, search); load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}} }
}
total={totalPages} total={totalPages}
mt="md" mt="md"
mb="md"
color="blue" color="blue"
radius="md" radius="md"
/> />
@@ -155,5 +221,4 @@ function ListPelayananTelunjukSakti({ search }: { search: string }) {
); );
} }
export default PelayananTelunjukSakti; export default PelayananTelunjukSakti;

View File

@@ -133,7 +133,7 @@ function EditPenghargaan() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Tombol Back + Title */} {/* Tombol Back + Title */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">

View File

@@ -49,7 +49,7 @@ function DetailPenghargaan() {
const data = statePenghargaan.findUnique.data; const data = statePenghargaan.findUnique.data;
return ( return (
<Box py={10}> <Box px={{ base: 0, md: 'xs' }} py="xs">
<Button <Button
variant="subtle" variant="subtle"
onClick={() => router.back()} onClick={() => router.back()}

View File

@@ -73,7 +73,7 @@ function CreatePenghargaan() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">

View File

@@ -25,6 +25,7 @@ import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header'; import HeaderSearch from '../../_com/header';
import { useDebouncedValue } from '@mantine/hooks';
function Penghargaan() { function Penghargaan() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -45,45 +46,48 @@ function Penghargaan() {
function ListPenghargaan({ search }: { search: string }) { function ListPenghargaan({ search }: { search: string }) {
const state = useProxy(penghargaanState); const state = useProxy(penghargaanState);
const router = useRouter(); const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = state.findMany; const { data, page, totalPages, loading, load } = state.findMany;
useEffect(() => { useEffect(() => {
load(page, 10, search); load(page, 10, debouncedSearch);
}, [page, search]); }, [page, debouncedSearch]);
const filteredData = data || [] const filteredData = data || [];
// Loading state // Loading state
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py="md">
<Skeleton height={600} radius="md" /> <Skeleton h={600} radius="md" />
</Stack> </Stack>
); );
} }
return ( return (
<Box py={10}> <Box py="md">
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="lg">
<Title order={4}>List Penghargaan</Title> <Title order={4}>List Penghargaan</Title>
<Button <Button
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
variant="light" variant="light"
onClick={() => router.push('/admin/desa/penghargaan/create')} onClick={() => router.push('/admin/desa/penghargaan/create')}
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Group> </Group>
<Box style={{ overflowX: 'auto' }}>
{/* Desktop Table */}
<Box visibleFrom="md">
<Table highlightOnHover> <Table highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '35%' }}>Nama</TableTh> <TableTh w="35%">Nama</TableTh>
<TableTh style={{ width: '35%' }}>Deskripsi</TableTh> <TableTh w="35%">Deskripsi</TableTh>
<TableTh style={{ width: '30%' }}>Aksi</TableTh> <TableTh w="30%">Aksi</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
@@ -91,31 +95,27 @@ function ListPenghargaan({ search }: { search: string }) {
filteredData.map((item) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Box w={200}> <Text fz="md" fw={500} lh={1.45} truncate="end" lineClamp={1}>
<Text fw={500} truncate="end" lineClamp={1}> {item.name}
{item.name} </Text>
</Text>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={200}> <Text
<Text fz="sm"
truncate="end" lh={1.45}
lineClamp={1} c="dimmed"
fz="sm" dangerouslySetInnerHTML={{ __html: item.deskripsi }}
c="dimmed" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
dangerouslySetInnerHTML={{ __html: item.deskripsi }} lineClamp={1}
style={{wordBreak: "break-word", whiteSpace: "normal"}} />
/>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button <Button
size="xs" size="xs"
radius="md" radius="md"
variant="light" variant="light"
color="blue" color="blue"
leftSection={<IconDeviceImacCog size={16} />} leftSection={<IconDeviceImacCog size={16} />}
onClick={() => onClick={() =>
router.push(`/admin/desa/penghargaan/${item.id}`) router.push(`/admin/desa/penghargaan/${item.id}`)
} }
@@ -127,9 +127,9 @@ function ListPenghargaan({ search }: { search: string }) {
)) ))
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={3}>
<Center py={20}> <Center py="xl">
<Text color="dimmed"> <Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data penghargaan yang cocok Tidak ada data penghargaan yang cocok
</Text> </Text>
</Center> </Center>
@@ -139,7 +139,54 @@ function ListPenghargaan({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Cards */}
<Stack gap="sm" hiddenFrom="md">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="md" radius="md">
<Box>
<Text fz="xs" fw={600} lh={1.4}>
Nama
</Text>
<Text fz="sm" fw={500} lh={1.45}>
{item.name}
</Text>
</Box>
<Box mt="xs">
<Text fz="xs" fw={600} lh={1.4}>
Deskripsi
</Text>
<Text lineClamp={3} fz="sm" fw={500} lh={1.45} c="dimmed">
<span dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Text>
</Box>
<Group mt="md">
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/desa/penghargaan/${item.id}`)
}
>
Detail
</Button>
</Group>
</Paper>
))
) : (
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data penghargaan yang cocok
</Text>
</Center>
)}
</Stack>
</Paper> </Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
@@ -148,7 +195,7 @@ function ListPenghargaan({ search }: { search: string }) {
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}} }}
total={totalPages} total={totalPages}
mt="md" mt="lg"
mb="md" mb="md"
color="blue" color="blue"
radius="md" radius="md"
@@ -158,4 +205,4 @@ function ListPenghargaan({ search }: { search: string }) {
); );
} }
export default Penghargaan; export default Penghargaan;

View File

@@ -1,7 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; 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 { IconCategory, IconListDetails } from '@tabler/icons-react'; import { IconCategory, IconListDetails } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
@@ -54,35 +54,76 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
keepMounted={false} keepMounted={false}
> >
{/* ✅ Scroll horizontal wrapper */} {/* ✅ Scroll horizontal wrapper */}
<ScrollArea type="auto" offsetScrollbars> <Box visibleFrom='md' pb={10}>
<TabsList <ScrollArea type="auto" offsetScrollbars>
p="sm" <TabsList
style={{ p="sm"
background: "linear-gradient(135deg, #e7ebf7, #f9faff)", style={{
borderRadius: "1rem", background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)", borderRadius: "1rem",
display: "flex", boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
flexWrap: "nowrap", display: "flex",
gap: "0.5rem", flexWrap: "nowrap",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi 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 <TabsList
key={i} p="xs" // lebih kecil
value={tab.value} style={{
leftSection={tab.icon} background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
style={{ borderRadius: "1rem",
fontWeight: 600, display: "flex",
fontSize: "0.9rem", flexWrap: "nowrap",
transition: "all 0.2s ease", gap: "0.5rem",
}} width: "max-content", // ⬅️ kunci
> maxWidth: "100%", // ⬅️ penting
{tab.label} }}
</TabsTab> >
))} {tabs.map((tab, i) => (
</TabsList> <TabsTab
</ScrollArea> 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) => ( {tabs.map((tab, i) => (
<TabsPanel <TabsPanel

View File

@@ -84,7 +84,7 @@ function EditKategoriPengumuman() {
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button <Button

View File

@@ -42,7 +42,7 @@ function CreateKategoriPengumuman() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header dengan back button */} {/* Header dengan back button */}
<Group mb="md"> <Group mb="md">
<Button <Button

View File

@@ -2,11 +2,21 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
Box, Button, Center, Box,
Button,
Center,
Pagination, Pagination,
Paper, Skeleton, Stack, Paper,
Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Skeleton,
Text, Title Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
} from '@mantine/core'; } from '@mantine/core';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react'; import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -15,6 +25,7 @@ import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import stateDesaPengumuman from '../../../_state/desa/pengumuman'; import stateDesaPengumuman from '../../../_state/desa/pengumuman';
import { useDebouncedValue } from '@mantine/hooks';
function KategoriPengumuman() { function KategoriPengumuman() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -33,90 +44,134 @@ function KategoriPengumuman() {
} }
function ListKategoriPengumuman({ search }: { search: string }) { function ListKategoriPengumuman({ search }: { search: string }) {
const listDataState = useProxy(stateDesaPengumuman.category) const listDataState = useProxy(stateDesaPengumuman.category);
const router = useRouter(); const router = useRouter();
const [modalHapus, setModalHapus] = useState(false) const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null);
const [debouncedSearch] = useDebouncedValue(search, 500);
const { data, page, totalPages, loading, load } = listDataState.findMany; const { data, page, totalPages, loading, load } = listDataState.findMany;
useEffect(() => { useEffect(() => {
load(1, 10, search) load(page, 10, debouncedSearch);
}, [search]) }, [page, debouncedSearch]);
const handleDelete = () => { const handleDelete = () => {
if (selectedId) { if (selectedId) {
listDataState.delete.delete(selectedId) listDataState.delete.delete(selectedId);
setModalHapus(false) setModalHapus(false);
setSelectedId(null) setSelectedId(null);
load(page, 10, search) load(page, 10, search);
} }
} };
const filteredData = data || [] const filteredData = data || [];
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py={{ base: 'md', md: 'lg' }}>
<Skeleton height={500} radius="md" /> <Skeleton height={500} radius="md" />
</Stack> </Stack>
) );
} }
return ( return (
<Box py={10}> <Box py={{ base: 'md', md: 'lg' }}>
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Stack> <Stack gap={'lg'}>
<Box style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 15 }}> <Box
<Title order={4}>List Kategori Pengumuman</Title> visibleFrom="md"
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
>
<Title order={4} lh={1.1}>
List Kategori Pengumuman
</Title>
<Button <Button
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
variant="light" variant="light"
onClick={() => router.push('/admin/desa/pengumuman/kategori-pengumuman/create')} onClick={() =>
router.push('/admin/desa/pengumuman/kategori-pengumuman/create')
}
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Box> </Box>
<Box style={{ overflowX: 'auto' }}> <Box hiddenFrom="md">
<Table highlightOnHover striped withRowBorders style={{ minWidth: '700px' }}> <Stack gap="xs">
<Title order={2} size="md" lh={1.1} ta="left">
List Kategori Pengumuman
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push('/admin/desa/pengumuman/kategori-pengumuman/create')
}
fullWidth
>
Tambah Baru
</Button>
</Stack>
</Box>
<Box visibleFrom="md">
<Table highlightOnHover striped withRowBorders>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '10%' }}>No</TableTh> <TableTh w="60%">
<TableTh style={{ width: '60%' }}>Nama</TableTh> <Text fz="sm" fw={600} lh={1.4}>
<TableTh style={{ width: '15%' }}>Edit</TableTh> Nama
<TableTh style={{ width: '15%' }}>Hapus</TableTh> </Text>
</TableTh>
<TableTh w="15%">
<Text fz="sm" fw={600} lh={1.4}>
Edit
</Text>
</TableTh>
<TableTh w="15%">
<Text fz="sm" fw={600} lh={1.4}>
Hapus
</Text>
</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.length > 0 ? ( {filteredData.length > 0 ? (
filteredData.map((item, index) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Text fz="sm">{(page - 1) * 10 + index + 1}</Text> <Text fz="md" fw={500} lh={1.5} truncate>
</TableTd> {item.name}
<TableTd> </Text>
<Text truncate lineClamp={1}>{item.name}</Text>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button <Button
variant='light' variant="light"
color='green' color="green"
onClick={() => router.push(`/admin/desa/pengumuman/kategori-pengumuman/${item.id}`)} onClick={() =>
router.push(
`/admin/desa/pengumuman/kategori-pengumuman/${item.id}`
)
}
size="compact-sm"
> >
<IconEdit size={20} /> <IconEdit size={16} />
</Button> </Button>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button <Button
variant='light' variant="light"
color='red' color="red"
disabled={listDataState.delete.loading} disabled={listDataState.delete.loading}
onClick={() => { onClick={() => {
setSelectedId(item.id) setSelectedId(item.id);
setModalHapus(true) setModalHapus(true);
}}> }}
<IconTrash size={20} /> size="compact-sm"
>
<IconTrash size={16} />
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -124,8 +179,10 @@ function ListKategoriPengumuman({ search }: { search: string }) {
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={4}>
<Center py={20}> <Center py={24}>
<Text c="dimmed">Tidak ada data kategori pengumuman yang cocok</Text> <Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori pengumuman yang cocok
</Text>
</Center> </Center>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -133,6 +190,71 @@ function ListKategoriPengumuman({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
<Stack hiddenFrom="md" gap="sm">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper
key={item.id}
withBorder
p="md"
radius="md"
bg={colors['white-1']}
>
<Stack gap="xs">
<Box>
<Text fz="xs" fw={600} lh={1.4}>
Nama
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.name}
</Text>
</Box>
<Box
style={{
display: 'flex',
gap: 8,
justifyContent: 'flex-end',
}}
>
<Button
variant="light"
color="green"
onClick={() =>
router.push(
`/admin/desa/pengumuman/kategori-pengumuman/${item.id}`
)
}
size="compact-xs"
>
<IconEdit size={14} />
</Button>
<Button
variant="light"
color="red"
disabled={listDataState.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
size="compact-xs"
>
<IconTrash size={14} />
</Button>
</Box>
</Stack>
</Paper>
))
) : (
<Paper withBorder p="xl" radius="md" bg={colors['white-1']}>
<Center>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori pengumuman yang cocok
</Text>
</Center>
</Paper>
)}
</Stack>
</Stack> </Stack>
</Paper> </Paper>
@@ -153,7 +275,7 @@ function ListKategoriPengumuman({ search }: { search: string }) {
text='Apakah anda yakin ingin menghapus kategori Pengumuman ini?' text='Apakah anda yakin ingin menghapus kategori Pengumuman ini?'
/> />
</Box> </Box>
) );
} }
export default KategoriPengumuman; export default KategoriPengumuman;

View File

@@ -111,7 +111,7 @@ function EditPengumuman() {
}; };
return ( return (
<Box px={{ base: "sm", md: "lg" }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md"> <Group mb="md">
<Button <Button
variant="subtle" variant="subtle"

View File

@@ -49,7 +49,7 @@ export default function DetailPengumuman() {
const data = pengumumanState.pengumuman.findUnique.data; const data = pengumumanState.pengumuman.findUnique.data;
return ( return (
<Box py={10}> <Box px={{ base: 0, md: 'xs' }} py="xs">
<Button <Button
variant="subtle" variant="subtle"
onClick={() => router.back()} onClick={() => router.back()}
@@ -61,7 +61,7 @@ export default function DetailPengumuman() {
<Paper <Paper
withBorder withBorder
w={{ base: '100%', md: '60%' }} w={{ base: '100%', md: '70%' }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"
@@ -74,14 +74,6 @@ export default function DetailPengumuman() {
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs"> <Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm"> <Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">
Kategori
</Text>
<Text fz="md" c="dimmed">
{data?.CategoryPengumuman?.name || '-'}
</Text>
</Box>
<Box> <Box>
<Text fz="lg" fw="bold"> <Text fz="lg" fw="bold">
@@ -92,6 +84,15 @@ export default function DetailPengumuman() {
</Text> </Text>
</Box> </Box>
<Box>
<Text fz="lg" fw="bold">
Kategori
</Text>
<Text fz="md" c="dimmed">
{data?.CategoryPengumuman?.name || '-'}
</Text>
</Box>
<Box> <Box>
<Text fz="lg" fw="bold"> <Text fz="lg" fw="bold">
Deskripsi Deskripsi

View File

@@ -55,7 +55,7 @@ function CreatePengumuman() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">

View File

@@ -19,7 +19,7 @@ import {
Text, Text,
Title Title
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react'; import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
@@ -46,44 +46,56 @@ function Pengumuman() {
function ListPengumuman({ search }: { search: string }) { function ListPengumuman({ search }: { search: string }) {
const pengumumanState = useProxy(stateDesaPengumuman); const pengumumanState = useProxy(stateDesaPengumuman);
const router = useRouter(); const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = pengumumanState.pengumuman.findMany; const { data, page, totalPages, loading, load } = pengumumanState.pengumuman.findMany;
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, search); load(page, 10, debouncedSearch);
}, [page, search]); }, [page, debouncedSearch]);
const filteredData = data || []; const filteredData = data || [];
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py={{ base: 'sm', md: 'md' }}>
<Skeleton height={600} radius="md" /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
); );
} }
return ( return (
<Box py={10}> <Box py={{ base: 'sm', md: 'md' }}>
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
<Title order={4}>Daftar Pengumuman</Title> <Title order={4} lh={1.2}>
Daftar Pengumuman
</Title>
<Button <Button
leftSection={<IconCircleDashedPlus size={18} />} leftSection={<IconCircleDashedPlus size={18} />}
color="blue" color="blue"
variant="light" variant="light"
onClick={() => router.push('/admin/desa/pengumuman/list-pengumuman/create')} onClick={() => router.push('/admin/desa/pengumuman/list-pengumuman/create')}
fz={{ base: 'sm', md: 'md' }}
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Group> </Group>
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover style={{ minWidth: '700px' }}> {/* Desktop Table */}
<Box visibleFrom="md">
<Table highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '40%' }}>Judul</TableTh> <TableTh fz="sm" fw={600} ta="left">
<TableTh style={{ width: '30%' }}>Kategori</TableTh> Judul
<TableTh style={{ width: '20%' }}>Detail</TableTh> </TableTh>
<TableTh fz="sm" fw={600} ta="left">
Kategori
</TableTh>
<TableTh fz="sm" fw={600} ta="left">
Detail
</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
@@ -91,14 +103,12 @@ function ListPengumuman({ search }: { search: string }) {
filteredData.map((item) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Box w={150}> <Text fz="md" fw={500} lh={1.45} truncate="end" lineClamp={1}>
<Text fw={500} truncate="end" lineClamp={1}> {item.judul}
{item.judul} </Text>
</Text>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Text fz="sm" c="dimmed"> <Text fz="sm" fw={500} lh={1.45} c="dimmed">
{item.CategoryPengumuman?.name || '-'} {item.CategoryPengumuman?.name || '-'}
</Text> </Text>
</TableTd> </TableTd>
@@ -109,9 +119,12 @@ function ListPengumuman({ search }: { search: string }) {
onClick={() => onClick={() =>
router.push(`/admin/desa/pengumuman/list-pengumuman/${item.id}`) router.push(`/admin/desa/pengumuman/list-pengumuman/${item.id}`)
} }
fz="sm"
px="sm"
py="xs"
> >
<IconDeviceImacCog size={20} /> <IconDeviceImacCog size={18} />
<Text ml={5}>Detail</Text> <Text ml="xs">Detail</Text>
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -119,8 +132,10 @@ function ListPengumuman({ search }: { search: string }) {
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={3}> <TableTd colSpan={3}>
<Center py={20}> <Center py={{ base: 'sm', md: 'md' }}>
<Text color="dimmed">Tidak ada pengumuman yang cocok</Text> <Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada pengumuman yang cocok
</Text>
</Center> </Center>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -128,7 +143,59 @@ function ListPengumuman({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Card List */}
<Box hiddenFrom="md">
<Stack gap="xs">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="md" radius="sm">
<Stack gap="xs">
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Judul
</Text>
<Text fz="sm" fw={500} lh={1.45}>
{item.judul}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Kategori
</Text>
<Text fz="sm" fw={500} lh={1.45} c="dimmed">
{item.CategoryPengumuman?.name || '-'}
</Text>
</Box>
<Box>
<Button
variant="light"
color="blue"
onClick={() =>
router.push(`/admin/desa/pengumuman/list-pengumuman/${item.id}`)
}
fullWidth
fz="sm"
mt="xs"
>
<IconDeviceImacCog size={18} />
<Text ml="xs">Detail</Text>
</Button>
</Box>
</Stack>
</Paper>
))
) : (
<Center py="sm">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada pengumuman yang cocok
</Text>
</Center>
)}
</Stack>
</Box>
</Paper> </Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
@@ -147,4 +214,4 @@ function ListPengumuman({ search }: { search: string }) {
); );
} }
export default Pengumuman; export default Pengumuman;

View File

@@ -93,7 +93,7 @@ function EditKategoriPotensi() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md"> <Group mb="md">
<Button <Button
variant="subtle" variant="subtle"

View File

@@ -41,7 +41,7 @@ function CreateKategoriPotensi() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header dengan back button */} {/* Header dengan back button */}
<Group mb="md"> <Group mb="md">
<Button <Button

View File

@@ -1,7 +1,24 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client';
import colors from '@/con/colors'; 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 { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react'; import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -9,6 +26,7 @@ import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import potensiDesaState from '../../../_state/desa/potensi'; import potensiDesaState from '../../../_state/desa/potensi';
import { useDebouncedValue } from '@mantine/hooks';
function KategoriPotensi() { function KategoriPotensi() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -27,85 +45,110 @@ function KategoriPotensi() {
} }
function ListKategoriPotensi({ search }: { search: string }) { function ListKategoriPotensi({ search }: { search: string }) {
const listDataState = useProxy(potensiDesaState.kategoriPotensi) const listDataState = useProxy(potensiDesaState.kategoriPotensi);
const router = useRouter(); const router = useRouter();
const [modalHapus, setModalHapus] = useState(false) const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null);
const { data, page, totalPages, loading, load } = listDataState.findMany; const { data, page, totalPages, loading, load } = listDataState.findMany;
const [debouncedSearch] = useDebouncedValue(search, 1000);
useEffect(() => { useEffect(() => {
load(1, 10, search) load(1, 10, debouncedSearch);
}, [search]) }, [debouncedSearch]);
const handleDelete = () => { const handleDelete = () => {
if (selectedId) { if (selectedId) {
listDataState.delete.delete(selectedId) listDataState.delete.delete(selectedId);
setModalHapus(false) setModalHapus(false);
setSelectedId(null) setSelectedId(null);
load(page, 10, search) load(page, 10, search);
} }
} };
const filteredData = data || [] const filteredData = data || [];
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py="md">
<Skeleton height={500} radius="md" /> <Skeleton height={500} radius="md" />
</Stack> </Stack>
) );
} }
return ( return (
<Box py={10}> <Box py="md">
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Stack> <Stack gap="xl">
<Group justify="space-between"> <Group justify="space-between" align="center">
<Title order={4}>List Kategori Potensi</Title> <Title order={4} lh={1.2}>
<Button List Kategori Potensi
leftSection={<IconPlus size={18} />} </Title>
color="blue" <Button
variant="light" leftSection={<IconPlus size={18} />}
onClick={() => router.push('/admin/desa/potensi/kategori-potensi/create')} color="blue"
> variant="light"
Tambah Baru onClick={() =>
</Button> router.push('/admin/desa/potensi/kategori-potensi/create')
}
>
Tambah Baru
</Button>
</Group> </Group>
<Box style={{ overflowX: 'auto' }}> {/* Desktop Table */}
<Table highlightOnHover striped withRowBorders style={{ minWidth: '700px' }}> <Box visibleFrom="md">
<Table highlightOnHover striped withRowBorders miw={700}>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '10%' }}>No</TableTh> <TableTh w="60%">
<TableTh style={{ width: '60%' }}>Nama</TableTh> <Text fz="xs" fw={600} lh={1.4} c="black">
<TableTh style={{ width: '15%' }}>Edit</TableTh> Nama
<TableTh style={{ width: '15%' }}>Hapus</TableTh> </Text>
</TableTh>
<TableTh w="15%">
<Text fz="xs" fw={600} lh={1.4} c="black">
Edit
</Text>
</TableTh>
<TableTh w="15%">
<Text fz="xs" fw={600} lh={1.4} c="black">
Hapus
</Text>
</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.length > 0 ? ( {filteredData.length > 0 ? (
filteredData.map((item, index) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Text fz="sm">{(page - 1) * 10 + index + 1}</Text> <Text fz="sm" lh={1.5} truncate>
{item.nama}
</Text>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Text truncate lineClamp={1}>{item.nama}</Text> <Button
</TableTd> variant="light"
<TableTd> color="green"
<Button variant='light' color='green' onClick={() => router.push(`/admin/desa/potensi/kategori-potensi/${item.id}`)}> onClick={() =>
router.push(
`/admin/desa/potensi/kategori-potensi/${item.id}`
)
}
>
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button <Button
variant='light' variant="light"
color='red' color="red"
disabled={listDataState.delete.loading} disabled={listDataState.delete.loading}
onClick={() => { onClick={() => {
setSelectedId(item.id) setSelectedId(item.id);
setModalHapus(true) setModalHapus(true);
}}> }}
>
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
</TableTd> </TableTd>
@@ -114,8 +157,10 @@ function ListKategoriPotensi({ search }: { search: string }) {
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={4}>
<Center py={20}> <Center py="lg">
<Text color="dimmed">Tidak ada data kategori potensi yang cocok</Text> <Text c="dimmed" fz="sm" lh={1.5}>
Tidak ada data kategori potensi yang cocok
</Text>
</Center> </Center>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -123,10 +168,70 @@ function ListKategoriPotensi({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Cards */}
<Stack gap="sm" hiddenFrom="md">
{filteredData.length > 0 ? (
filteredData.map((item, index) => (
<Paper key={item.id} withBorder p="sm" radius="md">
<Stack gap={'xs'}>
<Box>
<Text fz="xs" fw={600} lh={1.4}>
No
</Text>
<Text fz="sm" lh={1.5}>
{(page - 1) * 10 + index + 1}
</Text>
</Box>
<Box>
<Text fz="xs" fw={600} lh={1.4}>
Nama
</Text>
<Text fz="sm" lh={1.5}>
{item.nama}
</Text>
</Box>
<Group justify="flex-end" mt="xs">
<Button
variant="light"
color="green"
size="xs"
onClick={() =>
router.push(
`/admin/desa/potensi/kategori-potensi/${item.id}`
)
}
>
<IconEdit size={16} />
</Button>
<Button
variant="light"
color="red"
size="xs"
disabled={listDataState.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={16} />
</Button>
</Group>
</Stack>
</Paper>
))
) : (
<Center py="lg">
<Text c="dimmed" fz="sm" lh={1.5}>
Tidak ada data kategori potensi yang cocok
</Text>
</Center>
)}
</Stack>
</Stack> </Stack>
</Paper> </Paper>
<Center mt="md"> <Center mt="xl">
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => load(newPage, 10, search)} onChange={(newPage) => load(newPage, 10, search)}
@@ -143,7 +248,7 @@ function ListKategoriPotensi({ search }: { search: string }) {
text='Apakah anda yakin ingin menghapus kategori Potensi ini?' text='Apakah anda yakin ingin menghapus kategori Potensi ini?'
/> />
</Box> </Box>
) );
} }
export default KategoriPotensi; export default KategoriPotensi;

View File

@@ -143,7 +143,7 @@ function EditPotensi() {
}; };
return ( return (
<Box px={{ base: "sm", md: "lg" }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md"> <Group mb="md">
<Button <Button
variant="subtle" variant="subtle"

View File

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

View File

@@ -79,7 +79,7 @@ function CreatePotensi() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">

View File

@@ -26,6 +26,7 @@ import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import potensiDesaState from '../../../_state/desa/potensi'; import potensiDesaState from '../../../_state/desa/potensi';
import { useDebouncedValue } from '@mantine/hooks';
function Potensi() { function Potensi() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -46,6 +47,7 @@ function Potensi() {
function ListPotensi({ search }: { search: string }) { function ListPotensi({ search }: { search: string }) {
const potensiState = useProxy(potensiDesaState); const potensiState = useProxy(potensiDesaState);
const router = useRouter(); const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { const {
data, data,
@@ -57,41 +59,61 @@ function ListPotensi({ search }: { search: string }) {
useEffect(() => { useEffect(() => {
potensiState.kategoriPotensi.findMany.load(); potensiState.kategoriPotensi.findMany.load();
load(page, 10, search); load(page, 10, debouncedSearch);
}, [page, search]); }, [page, debouncedSearch]);
const filteredData = data || [] const filteredData = data || [];
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py="lg">
<Skeleton height={600} radius="md" /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
); );
} }
return ( return (
<Box py={10}> <Box py="lg">
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
<Title order={4}>Daftar Potensi Desa</Title> <Title order={4} lh={1.2}>
<Button Daftar Potensi Desa
leftSection={<IconPlus size={18} />} </Title>
color="blue" <Button
variant="light" leftSection={<IconPlus size={18} />}
onClick={() => router.push('/admin/desa/potensi/list-potensi/create')} color="blue"
> variant="light"
Tambah Baru onClick={() => router.push('/admin/desa/potensi/list-potensi/create')}
</Button> >
Tambah Baru
</Button>
</Group> </Group>
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover style={{ minWidth: '700px' }}> {/* Desktop Table */}
<Box visibleFrom="md">
<Table highlightOnHover miw={700}>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '20%' }}>Judul</TableTh> <TableTh w="20%">
<TableTh style={{ width: '20%' }}>Kategori</TableTh> <Text fz="sm" fw={600} lh={1.4}>
<TableTh style={{ width: '35%' }}>Deskripsi</TableTh> Judul
<TableTh style={{ width: '15%' }}>Detail</TableTh> </Text>
</TableTh>
<TableTh w="20%">
<Text fz="sm" fw={600} lh={1.4}>
Kategori
</Text>
</TableTh>
<TableTh w="35%">
<Text fz="sm" fw={600} lh={1.4}>
Deskripsi
</Text>
</TableTh>
<TableTh w="15%">
<Text fz="sm" fw={600} lh={1.4}>
Detail
</Text>
</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
@@ -99,27 +121,23 @@ function ListPotensi({ search }: { search: string }) {
filteredData.map((item) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Box w={200}> <Text fz="md" fw={500} lh={1.5} lineClamp={1}>
<Text fw={500} truncate="end" lineClamp={1}> {item.name}
{item.name} </Text>
</Text>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={200}> <Text fz="sm" c="gray.7" lh={1.5}>
<Text fz="sm" c="dimmed">{item.kategori?.nama || '-'}</Text> {item.kategori?.nama || '-'}
</Box> </Text>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={300}> <Text
<Text fz="sm"
lineClamp={1} lh={1.5}
truncate lineClamp={2}
fz="sm" dangerouslySetInnerHTML={{ __html: item.deskripsi }}
dangerouslySetInnerHTML={{ __html: item.deskripsi }} style={{ wordBreak: 'break-word' }}
style={{ wordBreak: "break-word", whiteSpace: "normal" }} />
/>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button <Button
@@ -138,8 +156,10 @@ function ListPotensi({ search }: { search: string }) {
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={4}>
<Center py={20}> <Center py="xl">
<Text color="dimmed">Tidak ada data potensi yang cocok</Text> <Text c="gray.6" fz="sm" ta="center" lh={1.5}>
Tidak ada data potensi yang cocok
</Text>
</Center> </Center>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -147,7 +167,64 @@ function ListPotensi({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Cards */}
<Box hiddenFrom="md">
{filteredData.length > 0 ? (
<Stack gap="sm">
{filteredData.map((item) => (
<Paper key={item.id} withBorder p="sm" radius="md">
<Box mb="xs">
<Text fz="xs" fw={600} lh={1.4}>
Judul
</Text>
<Text fz="sm" fw={500} lh={1.5}>
{item.name}
</Text>
</Box>
<Box mb="xs">
<Text fz="xs" fw={600} lh={1.4}>
Kategori
</Text>
<Text fz="sm" c="gray.7" lh={1.5}>
{item.kategori?.nama || '-'}
</Text>
</Box>
<Box mb="xs">
<Text fz="xs" fw={600} lh={1.4}>
Deskripsi
</Text>
<Text
fz="sm"
lh={1.5}
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
style={{ wordBreak: 'break-word' }}
/>
</Box>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/admin/desa/potensi/list-potensi/${item.id}`)}
w="100%"
>
Detail
</Button>
</Paper>
))}
</Stack>
) : (
<Center py="xl">
<Text c="gray.6" fz="sm" ta="center" lh={1.5}>
Tidak ada data potensi yang cocok
</Text>
</Center>
)}
</Box>
</Paper> </Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
@@ -166,4 +243,4 @@ function ListPotensi({ search }: { search: string }) {
); );
} }
export default Potensi; export default Potensi;

View File

@@ -0,0 +1,154 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import { Box, ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconCalendar, IconUser, IconUsers } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
function LayoutTabsDetail({ children }: { children: React.ReactNode }) {
const router = useRouter()
const pathname = usePathname()
const tabs = [
{
label: "Profil Desa",
value: "profildesa",
href: "/admin/desa/profil/profil-desa",
icon: <IconUser size={18} stroke={1.8} />
},
{
label: "Profil Perbekel",
value: "profilperbekel",
href: "/admin/desa/profil/profil-perbekel",
icon: <IconUsers size={18} stroke={1.8} />
},
{
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} />
}
];
const currentTab = tabs.find(tab => tab.href === pathname)
const [activeTab, setActiveTab] = useState<string | null>(currentTab?.value || tabs[0].value);
const handleTabChange = (value: string | null) => {
const tab = tabs.find(t => t.value === value)
if (tab) {
router.push(tab.href)
}
setActiveTab(value)
}
useEffect(() => {
const match = tabs.find(tab => tab.href === pathname)
if (match) {
setActiveTab(match.value)
}
}, [pathname])
return (
<Stack gap="lg">
<Title order={3} fw={700} style={{ color: "#1A1B1E" }}>Profile Desa</Title>
<Tabs
color={colors["blue-button"]}
variant="pills"
value={activeTab}
onChange={handleTabChange}
radius="lg"
keepMounted={false}
>
{/* ✅ Scroll horizontal wrapper */}
<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}
value={tab.value}
style={{
padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}}
>
{/* Konten dummy, bisa diganti sesuai routing */}
<>{children}</>
</TabsPanel>
))}
</Tabs>
</Stack>
);
}
export default LayoutTabsDetail;

View File

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

View File

@@ -43,7 +43,7 @@ function Page() {
const id = params?.id as string; const id = params?.id as string;
if (!id) { if (!id) {
toast.error('ID tidak valid'); toast.error('ID tidak valid');
router.push('/admin/desa/profile/profile-desa'); router.push('/admin/desa/profil/profil-desa');
return; return;
} }
@@ -106,7 +106,7 @@ function Page() {
if (success) { if (success) {
toast.success('Data berhasil disimpan'); toast.success('Data berhasil disimpan');
router.push('/admin/desa/profile/profile-desa'); router.push('/admin/desa/profil/profil-desa');
} else { } else {
toast.error('Gagal menyimpan data'); toast.error('Gagal menyimpan data');
} }
@@ -148,7 +148,7 @@ function Page() {
// ❌ Error // ❌ Error
if (loadError) { if (loadError) {
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Stack gap="md"> <Stack gap="md">
<Button variant="subtle" onClick={handleBack} p="xs" radius="md"> <Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
@@ -156,7 +156,7 @@ function Page() {
<Alert icon={<IconAlertCircle size={20} />} color="red" title="Terjadi Kesalahan" radius="md"> <Alert icon={<IconAlertCircle size={20} />} color="red" title="Terjadi Kesalahan" radius="md">
{loadError} {loadError}
</Alert> </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 Kembali ke Halaman Utama
</Button> </Button>
</Stack> </Stack>
@@ -166,7 +166,7 @@ function Page() {
// 🧱 UI utama // 🧱 UI utama
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Stack gap="md"> <Stack gap="md">
{/* Header */} {/* Header */}
<Group mb="sm"> <Group mb="sm">

View File

@@ -40,7 +40,7 @@ function Page() {
const id = params?.id as string; const id = params?.id as string;
if (!id) { if (!id) {
toast.error("ID tidak valid"); toast.error("ID tidak valid");
router.push("/admin/desa/profile/profile-desa"); router.push("/admin/desa/profil/profil-desa");
return; return;
} }
@@ -157,7 +157,7 @@ function Page() {
if (success) { if (success) {
toast.success("Maskot berhasil diperbarui!"); toast.success("Maskot berhasil diperbarui!");
router.push("/admin/desa/profile/profile-desa"); router.push("/admin/desa/profil/profil-desa");
} }
} catch (error) { } catch (error) {
console.error("Error update maskot:", error); console.error("Error update maskot:", error);
@@ -170,7 +170,7 @@ function Page() {
// Loading state // Loading state
if (maskotState.findUnique.loading || maskotState.update.loading) { if (maskotState.findUnique.loading || maskotState.update.loading) {
return ( return (
<Box> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Center h={400}> <Center h={400}>
<Text>Memuat data...</Text> <Text>Memuat data...</Text>
</Center> </Center>
@@ -181,7 +181,7 @@ function Page() {
// Error state // Error state
if (maskotState.findUnique.error) { if (maskotState.findUnique.error) {
return ( return (
<Box> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Stack gap="md"> <Stack gap="md">
<Button variant="subtle" onClick={handleBack}> <Button variant="subtle" onClick={handleBack}>
<IconArrowBack color={colors['blue-button']} size={20} /> <IconArrowBack color={colors['blue-button']} size={20} />
@@ -196,7 +196,7 @@ function Page() {
} }
return ( return (
<Box> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Stack gap="xs"> <Stack gap="xs">
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={handleBack} p="xs" radius="md"> <Button variant="subtle" onClick={handleBack} p="xs" radius="md">

View File

@@ -50,7 +50,7 @@ function Page() {
const id = params?.id as string; const id = params?.id as string;
if (!id) { if (!id) {
toast.error('ID tidak valid'); toast.error('ID tidak valid');
router.push('/admin/desa/profile/profile-desa'); router.push('/admin/desa/profil/profil-desa');
return; return;
} }
@@ -91,7 +91,7 @@ function Page() {
}, [params?.id, router]); }, [params?.id, router]);
// 🔄 Check if form has changes // 🔄 Check if form has changes
// 🔁 Reset Form to Original Data // 🔁 Reset Form to Original Data
const handleResetForm = () => { const handleResetForm = () => {
@@ -122,7 +122,7 @@ function Page() {
if (success) { if (success) {
toast.success('Data berhasil disimpan'); toast.success('Data berhasil disimpan');
router.push('/admin/desa/profile/profile-desa'); router.push('/admin/desa/profil/profil-desa');
} else { } else {
toast.error('Gagal menyimpan data'); toast.error('Gagal menyimpan data');
} }
@@ -149,7 +149,7 @@ function Page() {
// 🔄 Loading State // 🔄 Loading State
if (isLoading) { if (isLoading) {
return ( return (
<Box> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Center h={400}> <Center h={400}>
<Stack align="center" gap="md"> <Stack align="center" gap="md">
<Loader size="lg" color={colors['blue-button']} /> <Loader size="lg" color={colors['blue-button']} />
@@ -165,7 +165,7 @@ function Page() {
// ❌ Error State // ❌ Error State
if (loadError) { if (loadError) {
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Stack gap="md"> <Stack gap="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
@@ -179,7 +179,7 @@ function Page() {
{loadError} {loadError}
</Alert> </Alert>
<Button <Button
onClick={() => router.push('/admin/desa/profile/profile-desa')} onClick={() => router.push('/admin/desa/profil/profil-desa')}
variant="outline" variant="outline"
> >
Kembali ke Halaman Utama Kembali ke Halaman Utama

View File

@@ -42,7 +42,7 @@ function Page() {
const id = params?.id as string; const id = params?.id as string;
if (!id) { if (!id) {
toast.error('ID tidak valid'); toast.error('ID tidak valid');
router.push('/admin/desa/profile/profile-desa'); router.push('/admin/desa/profil/profil-desa');
return; return;
} }
@@ -106,7 +106,7 @@ function Page() {
if (success) { if (success) {
toast.success('Data berhasil disimpan'); toast.success('Data berhasil disimpan');
router.push('/admin/desa/profile/profile-desa'); router.push('/admin/desa/profil/profil-desa');
} else { } else {
toast.error('Gagal menyimpan data'); toast.error('Gagal menyimpan data');
} }
@@ -126,7 +126,7 @@ function Page() {
// ⏳ Loading // ⏳ Loading
if (isLoading) { if (isLoading) {
return ( return (
<Box> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Center h={400}> <Center h={400}>
<Stack align="center" gap="md"> <Stack align="center" gap="md">
<Loader size="lg" color={colors['blue-button']} /> <Loader size="lg" color={colors['blue-button']} />
@@ -142,7 +142,7 @@ function Page() {
// ❌ Error // ❌ Error
if (loadError) { if (loadError) {
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Stack gap="md"> <Stack gap="md">
<Button variant="subtle" onClick={handleBack} p="xs" radius="md"> <Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
@@ -156,7 +156,7 @@ function Page() {
{loadError} {loadError}
</Alert> </Alert>
<Button <Button
onClick={() => router.push('/admin/desa/profile/profile-desa')} onClick={() => router.push('/admin/desa/profil/profil-desa')}
variant="outline" variant="outline"
> >
Kembali ke Halaman Utama Kembali ke Halaman Utama

View File

@@ -0,0 +1,590 @@
'use client'
import colors from '@/con/colors';
import { Box, Button, Card, Center, Divider, Group, Image, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core';
import { IconEdit } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useSnapshot } from 'valtio';
import stateProfileDesa from '../../../_state/desa/profile';
function Page() {
const router = useRouter();
const snap = useSnapshot(stateProfileDesa);
useEffect(() => {
stateProfileDesa.sejarahDesa.findUnique.load("edit");
stateProfileDesa.visiMisiDesa.findUnique.load("edit");
stateProfileDesa.lambangDesa.findUnique.load("edit");
stateProfileDesa.maskotDesa.findUnique.load("edit");
}, []);
const sejarah = snap.sejarahDesa.findUnique.data;
const visiMisi = snap.visiMisiDesa.findUnique.data;
const lambang = snap.lambangDesa.findUnique.data;
const maskot = snap.maskotDesa.findUnique.data;
return (
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
<Stack gap="lg">
<Title order={2} c={colors['blue-button']}>Preview Profil Desa</Title>
{/* Sejarah Desa */}
<Box visibleFrom='md'>
{sejarah && (
<Paper p={{ base: "lg", md: "xl" }} bg={'white'} withBorder radius="md" shadow="xs">
<Group justify='space-between'>
<Title order={3} c={colors['blue-button']}>Preview Sejarah Desa</Title>
<Button
c="green"
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profil/profil-desa/${sejarah.id}/sejarah_desa`)}
>
Edit
</Button>
</Group>
<Box py="xl">
<Paper bg={colors['white-1']} withBorder radius="md" shadow="xs" p="lg">
<Stack gap={0}>
<Center>
<Image loading='lazy' src="/darmasaba-icon.png" w={{ base: 150, md: 250 }} alt="Logo Desa" />
</Center>
<Paper
bg={colors['blue-button']}
py="md"
px="sm"
radius="md"
style={{ mt: -30, boxShadow: '0 4px 20px rgba(0,0,0,0.15)' }}
>
<Text ta="center" c={colors['white-1']} fw="bolder" fz={{ base: "1.2rem", md: "1.6rem" }}>
{sejarah.judul}
</Text>
</Paper>
</Stack>
<Divider my="md" color={colors['blue-button']} />
<Box px={20}>
<Text fz={{ base: "md", md: "h3" }} style={{ wordBreak: "break-word", whiteSpace: "normal" }} ta="justify" dangerouslySetInnerHTML={{ __html: sejarah.deskripsi }} />
</Box>
</Paper>
</Box>
</Paper>
)}
</Box>
<Box hiddenFrom='md'>
{sejarah && (
<Paper p={{ base: "md", md: "xl" }} bg="white" withBorder radius="md" shadow="xs">
{/* Header */}
<Group justify="space-between" align="center" mb="md">
<Title order={3} c={colors['blue-button']}>
Preview Sejarah Desa
</Title>
<Button
variant="light"
color="green"
radius="md"
leftSection={<IconEdit size={18} />}
onClick={() =>
router.push(`/admin/desa/profil/profil-desa/${sejarah.id}/sejarah_desa`)
}
>
Edit
</Button>
</Group>
{/* Content Wrapper */}
<Box
mx="auto"
w="100%"
maw={720} // batas nyaman baca
>
<Paper
bg={colors['white-1']}
withBorder
radius="md"
p={{ base: "md", md: "lg" }}
>
{/* Logo + Title */}
<Stack align="center" gap="xs">
<Image
src="/darmasaba-icon.png"
alt="Logo Desa"
w={{ base: 120, md: 200 }}
loading="lazy"
/>
<Paper
bg={colors['blue-button']}
px="md"
py="sm"
radius="md"
mt={{ base: "sm", md: -24 }} // aman di mobile
>
<Text
ta="center"
c="white"
fw={700}
fz={{ base: "lg", md: "xl" }}
>
{sejarah.judul}
</Text>
</Paper>
</Stack>
<Divider my="md" color={colors['blue-button']} />
{/* Deskripsi */}
<Box pl={10}>
<Text
fz="sm"
lh="1.6"
ta="left"
style={{ wordBreak: "break-word" }}
dangerouslySetInnerHTML={{ __html: sejarah.deskripsi }}
/>
</Box>
</Paper>
</Box>
</Paper>
)}
</Box>
{/* Visi Misi Desa */}
<Box visibleFrom='md'>
{visiMisi && (
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
<Group justify='space-between'>
<Title order={3} c={colors['blue-button']}>Preview Visi Misi Desa</Title>
<Button
c="green"
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profil/profil-desa/${visiMisi.id}/visi_misi_desa`)}
>
Edit
</Button>
</Group>
<Box px={{ base: 0, md: 50 }} py="xl">
<Paper bg={colors['white-1']} withBorder radius="md" shadow="xs" p="lg">
<Stack gap={0}>
<Center>
<Image loading='lazy' src="/darmasaba-icon.png" w={{ base: 150, md: 250 }} alt="Logo Desa" />
</Center>
<Paper
bg={colors['blue-button']}
py="md"
px="sm"
radius="md"
style={{ mt: -30, boxShadow: '0 4px 20px rgba(0,0,0,0.15)' }}
>
<Text ta="center" c={colors['white-1']} fw="bolder" fz={{ base: "1.2rem", md: "1.6rem" }}>
Visi Misi Desa
</Text>
</Paper>
</Stack>
<Divider my="md" color={colors['blue-button']} />
<Box px={20}>
<Text fw="bold" fz={{ base: "lg", md: "h2" }}>Visi Desa</Text>
<Text fz={{ base: "md", md: "h3" }} ta="justify" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: visiMisi.visi }} />
</Box>
<Box px={20}>
<Text fw="bold" fz={{ base: "lg", md: "h2" }}>Misi Desa</Text>
<Text fz={{ base: "md", md: "h3" }} ta="justify" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: visiMisi.misi }} />
</Box>
</Paper>
</Box>
</Paper>
)}
</Box>
<Box hiddenFrom='md'>
{visiMisi && (
<Paper p={{ base: "md", md: "xl" }} bg="white" withBorder radius="md" shadow="xs">
{/* Header */}
<Group justify="space-between" align="center" mb="md">
<Title order={3} c={colors['blue-button']}>
Preview Visi Misi Desa
</Title>
<Button
variant="light"
color="green"
radius="md"
leftSection={<IconEdit size={18} />}
onClick={() => router.push(`/admin/desa/profil/profil-desa/${visiMisi.id}/visi_misi_desa`)}
>
Edit
</Button>
</Group>
{/* Content Wrapper */}
<Box
mx="auto"
w="100%"
maw={720} // batas nyaman baca
>
<Paper
bg={colors['white-1']}
withBorder
radius="md"
p={{ base: "md", md: "lg" }}
>
{/* Logo + Title */}
<Stack align="center" gap="xs">
<Image
src="/darmasaba-icon.png"
alt="Logo Desa"
w={{ base: 120, md: 200 }}
loading="lazy"
/>
<Paper
bg={colors['blue-button']}
px="md"
py="sm"
radius="md"
mt={{ base: "sm", md: -24 }} // aman di mobile
>
<Text
ta="center"
c="white"
fw={700}
fz={{ base: "lg", md: "xl" }}
>
Visi Misi Desa
</Text>
</Paper>
</Stack>
<Divider my="md" color={colors['blue-button']} />
<Stack pl={10}>
<Text fw="bold" fz={{ base: "lg", md: "h2" }}>Visi Desa</Text>
<Text
fz="sm"
lh="1.6"
ta="left"
style={{ wordBreak: "break-word" }}
dangerouslySetInnerHTML={{ __html: visiMisi.visi }}
/>
</Stack>
<Stack pl={10}>
<Text fw="bold" fz={{ base: "lg", md: "h2" }}>Misi Desa</Text>
<Text
fz="sm"
lh="1.6"
ta="left"
style={{ wordBreak: "break-word" }}
dangerouslySetInnerHTML={{ __html: visiMisi.misi }}
/>
</Stack>
</Paper>
</Box>
</Paper>
)}
</Box>
{/* Lambang Desa */}
<Box visibleFrom='md'>
{lambang && (
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
<Group justify='space-between'>
<Title order={3} c={colors['blue-button']}>Preview Lambang Desa</Title>
<Button
c="green"
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profil/profil-desa/${lambang.id}/lambang_desa`)}
>
Edit
</Button>
</Group>
<Box px={{ base: 0, md: 50 }} py="xl">
<Paper bg={colors['white-1']} withBorder radius="md" shadow="xs" p="lg">
<Stack gap={0}>
<Center>
<Image loading='lazy' src="/darmasaba-icon.png" w={{ base: 150, md: 250 }} alt="Logo Desa" />
</Center>
<Paper
bg={colors['blue-button']}
py="md"
px="sm"
radius="md"
style={{ mt: -30, boxShadow: '0 4px 20px rgba(0,0,0,0.15)' }}
>
<Text ta="center" c={colors['white-1']} fw="bolder" fz={{ base: "1.2rem", md: "1.6rem" }}>
{lambang.judul}
</Text>
</Paper>
</Stack>
<Divider my="md" color={colors['blue-button']} />
<Box px={20}>
<Text fz={{ base: "md", md: "h3" }} ta="justify" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: lambang.deskripsi }} />
</Box>
</Paper>
</Box>
</Paper>
)}
</Box>
<Box hiddenFrom='md'>
{lambang && (
<Paper p={{ base: "md", md: "xl" }} bg="white" withBorder radius="md" shadow="xs">
{/* Header */}
<Group justify="space-between" align="center" mb="md">
<Title order={3} c={colors['blue-button']}>
Preview Lambang Desa
</Title>
<Button
variant="light"
color="green"
radius="md"
leftSection={<IconEdit size={18} />}
onClick={() =>
router.push(`/admin/desa/profil/profil-desa/${lambang.id}/lambang_desa`)
}
>
Edit
</Button>
</Group>
{/* Content Wrapper */}
<Box
mx="auto"
w="100%"
maw={720} // batas nyaman baca
>
<Paper
bg={colors['white-1']}
withBorder
radius="md"
p={{ base: "md", md: "lg" }}
>
{/* Logo + Title */}
<Stack align="center" gap="xs">
<Image
src="/darmasaba-icon.png"
alt="Logo Desa"
w={{ base: 120, md: 200 }}
loading="lazy"
/>
<Paper
bg={colors['blue-button']}
px="md"
py="sm"
radius="md"
mt={{ base: "sm", md: -24 }} // aman di mobile
>
<Text
ta="center"
c="white"
fw={700}
fz={{ base: "lg", md: "xl" }}
>
{lambang.judul}
</Text>
</Paper>
</Stack>
<Divider my="md" color={colors['blue-button']} />
{/* Deskripsi */}
<Box pl={10}>
<Text
fz="sm"
lh="1.6"
ta="left"
style={{ wordBreak: "break-word" }}
dangerouslySetInnerHTML={{ __html: lambang.deskripsi }}
/>
</Box>
</Paper>
</Box>
</Paper>
)}
</Box>
{/* Maskot Desa */}
<Box visibleFrom='md'>
{maskot && (
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
<Group justify='space-between'>
<Title order={3} c={colors['blue-button']}>Preview Maskot Desa</Title>
<Button
c="green"
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profil/profil-desa/${maskot.id}/maskot_desa`)}
>
Edit
</Button>
</Group>
<Box px={{ base: 0, md: 50 }} py="xl">
<Paper bg={colors['white-1']} withBorder radius="md" shadow="xs" p="lg">
<Stack gap={0}>
<Center>
<Image loading='lazy' src="/pudak-icon.png" w={{ base: 150, md: 250 }} alt="Maskot Desa" />
</Center>
<Paper
bg={colors['blue-button']}
py="md"
px="sm"
radius="md"
style={{ mt: -30, boxShadow: '0 4px 20px rgba(0,0,0,0.15)' }}
>
<Text ta="center" c={colors['white-1']} fw="bolder" fz={{ base: "1.2rem", md: "1.6rem" }}>
Maskot Desa
</Text>
</Paper>
</Stack>
<Divider my="md" color={colors['blue-button']} />
<Box px={20}>
<Text fz={{ base: "md", md: "h3" }} ta="justify" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: maskot.deskripsi }} />
</Box>
<Stack mt="md" gap="sm">
<SimpleGrid cols={{ base: 1, md: 4 }} spacing="md">
{maskot.images.map((img, idx) => (
<Card withBorder key={idx} p="xs" w={{ base: '100%', md: 180 }}>
<Center>
<Image
src={img.image.link}
alt={img.label}
w={150}
h={150}
fit="cover"
radius="md"
style={{ border: '1px solid #ccc' }}
loading='lazy'
/>
</Center>
<Text ta="center" mt="xs" fw="bold">{img.label}</Text>
</Card>
))}
</SimpleGrid>
</Stack>
</Paper>
</Box>
</Paper>
)}
</Box>
<Box hiddenFrom='md'>
{maskot && (
<Paper p={{ base: "md", md: "xl" }} bg="white" withBorder radius="md" shadow="xs">
{/* Header */}
<Group justify="space-between" align="center" mb="md">
<Title order={3} c={colors['blue-button']}>
Preview Maskot Desa
</Title>
<Button
variant="light"
color="green"
radius="md"
leftSection={<IconEdit size={18} />}
onClick={() =>
router.push(`/admin/desa/profil/profil-desa/${maskot.id}/maskot_desa`)
}
>
Edit
</Button>
</Group>
{/* Content Wrapper */}
<Box
mx="auto"
w="100%"
maw={720} // batas nyaman baca
>
<Paper
bg={colors['white-1']}
withBorder
radius="md"
p={{ base: "md", md: "lg" }}
>
{/* Logo + Title */}
<Stack align="center" gap="xs">
<Image
src="/pudak-icon.png"
alt="Logo Desa"
w={{ base: 120, md: 200 }}
loading="lazy"
/>
<Paper
bg={colors['blue-button']}
px="md"
py="sm"
radius="md"
mt={{ base: "sm", md: -24 }} // aman di mobile
>
<Text
ta="center"
c="white"
fw={700}
fz={{ base: "lg", md: "xl" }}
>
Maskot Desa
</Text>
</Paper>
</Stack>
<Divider my="md" color={colors['blue-button']} />
{/* Deskripsi */}
<Box pl={10}>
<Text
fz="sm"
lh="1.6"
ta="left"
style={{ wordBreak: "break-word" }}
dangerouslySetInnerHTML={{ __html: maskot.deskripsi }}
/>
</Box>
<Stack mt="md" gap="sm">
<SimpleGrid cols={{ base: 1, md: 4 }} spacing="md">
{maskot.images.map((img, idx) => (
<Card withBorder key={idx} p="xs" w={{ base: '100%', md: 180 }}>
<Center>
<Image
src={img.image.link}
alt={img.label}
w={150}
h={150}
fit="cover"
radius="md"
style={{ border: '1px solid #ccc' }}
loading='lazy'
/>
</Center>
<Text ta="center" mt="xs" fw="bold">{img.label}</Text>
</Card>
))}
</SimpleGrid>
</Stack>
</Paper>
</Box>
</Paper>
)}
</Box>
</Stack>
</Paper>
);
}
export default Page;

View File

@@ -117,7 +117,7 @@ function EditPerbekelDariMasaKeMasa() {
await state.update.update(); await state.update.update();
toast.success('Perbekel dari masa ke masa berhasil diperbarui!'); 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) { } catch (error) {
console.error('Error updating perbekel dari masa ke masa:', error); console.error('Error updating perbekel dari masa ke masa:', error);
toast.error('Terjadi kesalahan saat memperbarui perbekel dari masa ke masa'); toast.error('Terjadi kesalahan saat memperbarui perbekel dari masa ke masa');
@@ -127,7 +127,7 @@ function EditPerbekelDariMasaKeMasa() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />

View File

@@ -4,7 +4,7 @@ import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core'; import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
@@ -25,7 +25,7 @@ function DetailPerbekelDariMasa() {
state.delete.byId(selectedId); state.delete.byId(selectedId);
setModalHapus(false); setModalHapus(false);
setSelectedId(null); setSelectedId(null);
router.push("/admin/desa/profile/profile-perbekel-dari-masa-ke-masa"); router.push("/admin/desa/profil/profil-perbekel-dari-masa-ke-masa");
} }
}; };
@@ -40,7 +40,7 @@ function DetailPerbekelDariMasa() {
const data = state.findUnique.data; const data = state.findUnique.data;
return ( return (
<Box py={10}> <Box px={{ base: 0, md: 'xs' }} py="xs">
<Button <Button
variant="subtle" variant="subtle"
onClick={() => router.back()} onClick={() => router.back()}
@@ -52,7 +52,7 @@ function DetailPerbekelDariMasa() {
<Paper <Paper
withBorder withBorder
w={{ base: "100%", md: "60%" }} w={{ base: "100%", md: "70%" }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"
@@ -108,12 +108,12 @@ function DetailPerbekelDariMasa() {
radius="md" radius="md"
size="md" size="md"
> >
<IconX size={20} /> <IconTrash size={20} />
</Button> </Button>
<Button <Button
color="green" 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" variant="light"
radius="md" radius="md"
size="md" size="md"

View File

@@ -46,7 +46,7 @@ function CreatePerbekelDariMasaKeMasa() {
state.create.form.imageId = uploaded.id; state.create.form.imageId = uploaded.id;
await state.create.create(); await state.create.create();
resetForm(); 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) { } catch (error) {
console.error(error); console.error(error);
toast.error('Gagal menambahkan perbekel dari masa ke masa'); toast.error('Gagal menambahkan perbekel dari masa ke masa');
@@ -56,7 +56,7 @@ function CreatePerbekelDariMasaKeMasa() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Back button + Title */} {/* Back button + Title */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">

View File

@@ -0,0 +1,191 @@
'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 { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, 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 stateProfileDesa from '../../../_state/desa/profile';
function PerbekelDariMasaKeMasa() {
const [search, setSearch] = useState("");
return (
<Box>
<HeaderSearch
title='Perbekel Dari Masa Ke Masa'
placeholder='Cari nama perbekel...'
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListPerbekelDariMasaKeMasa search={search} />
</Box>
);
}
function ListPerbekelDariMasaKeMasa({ search }: { search: string }) {
const state = useProxy(stateProfileDesa.mantanPerbekel)
const router = useRouter();
const { data, page, totalPages, loading, load } = state.findMany;
const [debouncedSearch] = useDebouncedValue(search, 1000); // 500ms delay
useShallowEffect(() => {
load(page, 10, debouncedSearch)
}, [page, debouncedSearch]);
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py={{ base: 'sm', md: 'md' }}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
return (
<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={4} lh={1.2}>
List Perbekel Dari Masa Ke Masa
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/profil/profil-perbekel-dari-masa-ke-masa/create')}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md">
<Table highlightOnHover miw={0}>
<TableThead>
<TableTr>
<TableTh fz="sm" fw={600} ta="left" c="dark.9">Nama Perbekel</TableTh>
<TableTh fz="sm" fw={600} ta="left" c="dark.9">Periode</TableTh>
<TableTh fz="sm" fw={600} ta="left" c="dark.9">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}>{item.nama}</Text>
</TableTd>
<TableTd>
<Text fz="md" lh={1.5} lineClamp={1}>{item.periode}</Text>
</TableTd>
<TableTd>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/admin/desa/profil/profil-perbekel-dari-masa-ke-masa/${item.id}`)}
>
Detail
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={3}>
<Center py={{ base: 'md', md: 'lg' }}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data perbekel yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Card View */}
<Box hiddenFrom="md">
<Stack gap="xs">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="sm" radius="md">
<Stack gap={'xs'}>
<Box>
<Text fz="xs" fw={600} lh={1.4} c="dark.9">Nama Perbekel</Text>
<Text fz="sm" fw={500} lh={1.5}>{item.nama}</Text>
</Box>
<Box>
<Text fz="xs" fw={600} lh={1.4} c="dark.9">Periode</Text>
<Text fz="sm" lh={1.5}>{item.periode}</Text>
</Box>
<Box>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/admin/desa/profil/profil-perbekel-dari-masa-ke-masa/${item.id}`)}
fullWidth
>
Detail
</Button>
</Box>
</Stack>
</Paper>
))
) : (
<Center py="md">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data perbekel yang cocok
</Text>
</Center>
)}
</Stack>
</Box>
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt={{ base: 'sm', md: 'md' }}
mb={{ base: 'sm', md: 'md' }}
color="blue"
radius="md"
/>
</Center>
</Box>
);
}
export default PerbekelDariMasaKeMasa;

View File

@@ -25,7 +25,7 @@ function ProfilePerbekel() {
const id = params?.id as string; const id = params?.id as string;
if (!id) { if (!id) {
toast.error("ID tidak valid"); toast.error("ID tidak valid");
router.push("/admin/desa/profile/profile-perbekel"); router.push("/admin/desa/profil/profil-perbekel");
return; return;
} }
@@ -74,7 +74,7 @@ function ProfilePerbekel() {
const success = await perbekelState.edit.submit() const success = await perbekelState.edit.submit()
if (success) { if (success) {
toast.success("Data berhasil disimpan"); toast.success("Data berhasil disimpan");
router.push("/admin/desa/profile/profile-perbekel"); router.push("/admin/desa/profil/profil-perbekel");
} }
} catch (error) { } catch (error) {
console.error("Error update sejarah desa:", error); console.error("Error update sejarah desa:", error);
@@ -97,7 +97,7 @@ function ProfilePerbekel() {
} }
return ( return (
<Box py={10}> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Stack gap="xs"> <Stack gap="xs">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">

View File

@@ -33,18 +33,18 @@ function Page() {
{/* Header + tombol edit */} {/* Header + tombol edit */}
<Grid align="center"> <Grid align="center">
<GridCol span={{ base: 12, md: 11 }}> <GridCol span={{ base: 12, md: 11 }}>
<Title order={3} c={colors['blue-button']}>Preview Profil PPID</Title> <Title order={2} c={colors['blue-button']} lh={1.2} />
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Button <Button
c="green" c="green"
variant="light" variant="light"
leftSection={<IconEdit size={18} stroke={2} />} leftSection={<IconEdit size={18} stroke={2} />}
radius="md" radius="md"
onClick={() => router.push(`/admin/desa/profile/profile-perbekel/${perbekel.id}`)} onClick={() => router.push(`/admin/desa/profil/profil-perbekel/${perbekel.id}`)}
> >
Edit Edit
</Button> </Button>
</GridCol> </GridCol>
</Grid> </Grid>
@@ -58,7 +58,13 @@ function Page() {
</Center> </Center>
</GridCol> </GridCol>
<GridCol span={12}> <GridCol span={12}>
<Text ta="center" fz={{ base: "1.2rem", md: "1.8rem" }} fw="bold" c={colors['blue-button']}> <Text
ta="center"
fz={{ base: 'sm', md: 'md' }}
fw="bold"
c={colors['blue-button']}
lh={{ base: 1.45, md: 1.45 }}
>
Profil Pimpinan Badan Publik Desa Darmasaba Profil Pimpinan Badan Publik Desa Darmasaba
</Text> </Text>
</GridCol> </GridCol>
@@ -86,25 +92,55 @@ function Page() {
className="glass3" className="glass3"
style={{ mt: -30, boxShadow: '0 4px 20px rgba(0,0,0,0.15)' }} style={{ mt: -30, boxShadow: '0 4px 20px rgba(0,0,0,0.15)' }}
> >
<Text ta="center" c={colors['white-1']} fw="bolder" fz={{ base: "1.2rem", md: "1.6rem" }}> <Text
I.B. Surya Prabhawa Manuaba, S.H., M.H. ta="center"
c={colors['white-1']}
fw="bolder"
fz={{ base: 'md', md: 'lg' }}
lh={{ base: 1.4, md: 1.4 }}
>
I.B. Surya Prabhawa Manuaba, S.H., M.H.
</Text> </Text>
</Paper> </Paper>
</Stack> </Stack>
{/* Biodata & Info */} {/* Biodata & Info */}
<Box mt="lg"> <Box mt="lg">
<Text fz={{ base: "1.125rem", md: "1.5rem" }} fw="bold" mb={4}>Biodata</Text> <Title order={3} lh={1.2} mb={4} />
<Text fz={{ base: "1rem", md: "1.4rem" }} ta="justify" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: perbekel.biodata }} /> <Text
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.6, md: 1.6 }}
ta="justify"
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: perbekel.biodata }}
/>
<Text fz={{ base: "1.125rem", md: "1.5rem" }} fw="bold" mt="md" mb={4}>Pengalaman</Text> <Title order={3} lh={1.2} mt="md" mb={4} />
<Text fz={{ base: "1rem", md: "1.4rem" }} ta="justify" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: perbekel.pengalaman }} /> <Text
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.6, md: 1.6 }}
ta="justify"
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: perbekel.pengalaman }}
/>
<Text fz={{ base: "1.125rem", md: "1.5rem" }} fw="bold" mt="md" mb={4}>Pengalaman Organisasi</Text> <Title order={3} lh={1.2} mt="md" mb={4} />
<Text fz={{ base: "1rem", md: "1.4rem" }} ta="justify" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: perbekel.pengalamanOrganisasi }} /> <Text
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.6, md: 1.6 }}
ta="justify"
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: perbekel.pengalamanOrganisasi }}
/>
<Text fz={{ base: "1.125rem", md: "1.5rem" }} fw="bold" mt="md" mb={4}>Program Kerja Unggulan</Text> <Title order={3} lh={1.2} mt="md" mb={4} />
<Text fz={{ base: "1rem", md: "1.4rem" }} ta="justify" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: perbekel.programUnggulan }} /> <Text
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.6, md: 1.6 }}
ta="justify"
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: perbekel.programUnggulan }}
/>
</Box> </Box>
</Paper> </Paper>
</Stack> </Stack>
@@ -112,4 +148,4 @@ function Page() {
); );
} }
export default Page; export default Page;

View File

@@ -1,113 +0,0 @@
/* 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 { IconCalendar, IconUser, IconUsers } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
function LayoutTabsDetail({ children }: { children: React.ReactNode }) {
const router = useRouter()
const pathname = usePathname()
const tabs = [
{
label: "Profile Desa",
value: "profiledesa",
href: "/admin/desa/profile/profile-desa",
icon: <IconUser size={18} stroke={1.8} />
},
{
label: "Profile Perbekel",
value: "profileperbekel",
href: "/admin/desa/profile/profile-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",
icon: <IconCalendar size={18} stroke={1.8} />
}
];
const currentTab = tabs.find(tab => tab.href === pathname)
const [activeTab, setActiveTab] = useState<string | null>(currentTab?.value || tabs[0].value);
const handleTabChange = (value: string | null) => {
const tab = tabs.find(t => t.value === value)
if (tab) {
router.push(tab.href)
}
setActiveTab(value)
}
useEffect(() => {
const match = tabs.find(tab => tab.href === pathname)
if (match) {
setActiveTab(match.value)
}
}, [pathname])
return (
<Stack gap="lg">
<Title order={3} fw={700} style={{ color: "#1A1B1E" }}>Profile Desa</Title>
<Tabs
color={colors["blue-button"]}
variant="pills"
value={activeTab}
onChange={handleTabChange}
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((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
{tabs.map((tab, i) => (
<TabsPanel
key={i}
value={tab.value}
style={{
padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}}
>
{/* Konten dummy, bisa diganti sesuai routing */}
<>{children}</>
</TabsPanel>
))}
</Tabs>
</Stack>
);
}
export default LayoutTabsDetail;

View File

@@ -1,240 +0,0 @@
'use client'
import colors from '@/con/colors';
import { Box, Button, Card, Center, Divider, Grid, GridCol, Image, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core';
import { IconEdit } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useSnapshot } from 'valtio';
import stateProfileDesa from '../../../_state/desa/profile';
function Page() {
const router = useRouter();
const snap = useSnapshot(stateProfileDesa);
useEffect(() => {
stateProfileDesa.sejarahDesa.findUnique.load("edit");
stateProfileDesa.visiMisiDesa.findUnique.load("edit");
stateProfileDesa.lambangDesa.findUnique.load("edit");
stateProfileDesa.maskotDesa.findUnique.load("edit");
}, []);
const sejarah = snap.sejarahDesa.findUnique.data;
const visiMisi = snap.visiMisiDesa.findUnique.data;
const lambang = snap.lambangDesa.findUnique.data;
const maskot = snap.maskotDesa.findUnique.data;
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>
{/* Sejarah Desa */}
{sejarah && (
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
<Grid align="center">
<GridCol span={{ base: 12, md: 11 }}>
<Title order={3} c={colors['blue-button']}>Preview Sejarah 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/desa/profile/profile-desa/${sejarah.id}/sejarah_desa`)}
>
Edit
</Button>
</GridCol>
</Grid>
<Box px={{ base: 0, md: 50 }} py="xl">
<Paper bg={colors['white-1']} withBorder radius="md" shadow="xs" p="lg">
<Stack gap={0}>
<Center>
<Image loading='lazy' src="/darmasaba-icon.png" w={{ base: 150, md: 250 }} alt="Logo Desa" />
</Center>
<Paper
bg={colors['blue-button']}
py="md"
px="sm"
radius="md"
style={{ mt: -30, boxShadow: '0 4px 20px rgba(0,0,0,0.15)' }}
>
<Text ta="center" c={colors['white-1']} fw="bolder" fz={{ base: "1.2rem", md: "1.6rem" }}>
{sejarah.judul}
</Text>
</Paper>
</Stack>
<Divider my="md" color={colors['blue-button']} />
<Text fz={{ base: "md", md: "h3" }} style={{wordBreak: "break-word", whiteSpace: "normal"}} ta="justify" dangerouslySetInnerHTML={{ __html: sejarah.deskripsi }} />
</Paper>
</Box>
</Paper>
)}
{/* Visi Misi Desa */}
{visiMisi && (
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
<Grid align="center">
<GridCol span={{ base: 12, md: 11 }}>
<Title order={3} c={colors['blue-button']}>Preview Visi Misi 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/desa/profile/profile-desa/${visiMisi.id}/visi_misi_desa`)}
>
Edit
</Button>
</GridCol>
</Grid>
<Box px={{ base: 0, md: 50 }} py="xl">
<Paper bg={colors['white-1']} withBorder radius="md" shadow="xs" p="lg">
<Stack gap={0}>
<Center>
<Image loading='lazy' src="/darmasaba-icon.png" w={{ base: 150, md: 250 }} alt="Logo Desa" />
</Center>
<Paper
bg={colors['blue-button']}
py="md"
px="sm"
radius="md"
style={{ mt: -30, boxShadow: '0 4px 20px rgba(0,0,0,0.15)' }}
>
<Text ta="center" c={colors['white-1']} fw="bolder" fz={{ base: "1.2rem", md: "1.6rem" }}>
Visi Misi Desa
</Text>
</Paper>
</Stack>
<Divider my="md" color={colors['blue-button']} />
<Text fw="bold" fz={{ base: "lg", md: "h2" }}>Visi Desa</Text>
<Text fz={{ base: "md", md: "h3" }} ta="justify" style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: visiMisi.visi }} />
<Text fw="bold" fz={{ base: "lg", md: "h2" }}>Misi Desa</Text>
<Text fz={{ base: "md", md: "h3" }} ta="justify" style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: visiMisi.misi }} />
</Paper>
</Box>
</Paper>
)}
{/* Lambang Desa */}
{lambang && (
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
<Grid align="center">
<GridCol span={{ base: 12, md: 11 }}>
<Title order={3} c={colors['blue-button']}>Preview Lambang 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/desa/profile/profile-desa/${lambang.id}/lambang_desa`)}
>
Edit
</Button>
</GridCol>
</Grid>
<Box px={{ base: 0, md: 50 }} py="xl">
<Paper bg={colors['white-1']} withBorder radius="md" shadow="xs" p="lg">
<Stack gap={0}>
<Center>
<Image loading='lazy' src="/darmasaba-icon.png" w={{ base: 150, md: 250 }} alt="Logo Desa" />
</Center>
<Paper
bg={colors['blue-button']}
py="md"
px="sm"
radius="md"
style={{ mt: -30, boxShadow: '0 4px 20px rgba(0,0,0,0.15)' }}
>
<Text ta="center" c={colors['white-1']} fw="bolder" fz={{ base: "1.2rem", md: "1.6rem" }}>
{lambang.judul}
</Text>
</Paper>
</Stack>
<Divider my="md" color={colors['blue-button']} />
<Text fz={{ base: "md", md: "h3" }} ta="justify" style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: lambang.deskripsi }} />
</Paper>
</Box>
</Paper>
)}
{/* Maskot Desa */}
{maskot && (
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
<Grid align="center">
<GridCol span={{ base: 12, md: 11 }}>
<Title order={3} c={colors['blue-button']}>Preview Maskot 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/desa/profile/profile-desa/${maskot.id}/maskot_desa`)}
>
Edit
</Button>
</GridCol>
</Grid>
<Box px={{ base: 0, md: 50 }} py="xl">
<Paper bg={colors['white-1']} withBorder radius="md" shadow="xs" p="lg">
<Stack gap={0}>
<Center>
<Image loading='lazy' src="/pudak-icon.png" w={{ base: 150, md: 250 }} alt="Maskot Desa" />
</Center>
<Paper
bg={colors['blue-button']}
py="md"
px="sm"
radius="md"
style={{ mt: -30, boxShadow: '0 4px 20px rgba(0,0,0,0.15)' }}
>
<Text ta="center" c={colors['white-1']} fw="bolder" fz={{ base: "1.2rem", md: "1.6rem" }}>
Maskot Desa
</Text>
</Paper>
</Stack>
<Divider my="md" color={colors['blue-button']} />
<Text fz={{ base: "md", md: "h3" }} ta="justify" style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: maskot.deskripsi }} />
<Stack mt="md" gap="sm">
<SimpleGrid cols={{ base: 1, md: 4 }} spacing="md">
{maskot.images.map((img, idx) => (
<Card withBorder key={idx} p="xs" w={{ base: '100%', md: 180 }}>
<Center>
<Image
src={img.image.link}
alt={img.label}
w={150}
h={150}
fit="cover"
radius="md"
style={{ border: '1px solid #ccc' }}
loading='lazy'
/>
</Center>
<Text ta="center" mt="xs" fw="bold">{img.label}</Text>
</Card>
))}
</SimpleGrid>
</Stack>
</Paper>
</Box>
</Paper>
)}
</Stack>
</Paper>
);
}
export default Page;

View File

@@ -1,133 +0,0 @@
'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 { IconDeviceImacCog, 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 stateProfileDesa from '../../../_state/desa/profile';
function PerbekelDariMasaKeMasa() {
const [search, setSearch] = useState("");
return (
<Box>
<HeaderSearch
title='Perbekel Dari Masa Ke Masa'
placeholder='Cari nama perbekel...'
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListPerbekelDariMasaKeMasa search={search} />
</Box>
);
}
function ListPerbekelDariMasaKeMasa({ search }: { search: string }) {
const state = useProxy(stateProfileDesa.mantanPerbekel)
const router = useRouter();
const { data, page, totalPages, loading, load } = state.findMany;
useShallowEffect(() => {
load(page, 10, search)
}, [page, search]);
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={500} 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}>List Perbekel Dari Masa Ke Masa</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/create')}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh style={{ width: '35%' }}>Nama Perbekel</TableTh>
<TableTh style={{ width: '35%' }}>Periode</TableTh>
<TableTh style={{ width: '20%' }}>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Box w={200}>
<Text fw={500} lineClamp={1}>{item.nama}</Text>
</Box>
</TableTd>
<TableTd>
<Box w={200}>
<Text lineClamp={1}>{item.periode}</Text>
</Box>
</TableTd>
<TableTd>
<Box w={200}>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/${item.id}`)}
>
Detail
</Button>
</Box>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={3}>
<Center py={20}>
<Text color="dimmed">Tidak ada data perbekel 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 PerbekelDariMasaKeMasa;

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 () => { const handleSubmit = async () => {
try { const { embedMapUrl } = polsekState.create.form;
setIsSubmitting(true);
await polsekState.create.create(); // ✅ Validasi Google Maps Embed URL (jika diisi)
resetForm(); if (embedMapUrl && !isValidGoogleMapsEmbed(embedMapUrl)) {
router.push("/admin/keamanan/polsek-terdekat"); toast.error("URL embed peta tidak valid. Harap paste iframe dari Google Maps.");
} catch (error) { return;
console.error(error) }
toast.error("Gagal menambah polsek terdekat");
} finally { try {
setIsSubmitting(false); 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 () => { const fetchLayanan = async () => {
@@ -190,9 +228,14 @@ function CreatePolsekTerdekat() {
/> />
<TextInput <TextInput
value={polsekState.create.form.embedMapUrl} 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>} 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 <TextInput
value={polsekState.create.form.namaTempatMaps} value={polsekState.create.form.namaTempatMaps}

View File

@@ -1,7 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; 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 { IconActivity, IconBuildingHospital, IconCalendarEvent, IconGauge, IconNotes } from '@tabler/icons-react'; import { IconActivity, IconBuildingHospital, IconCalendarEvent, IconGauge, IconNotes } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
@@ -20,9 +20,9 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
icon: <IconActivity size={18} stroke={1.8} /> icon: <IconActivity size={18} stroke={1.8} />
}, },
{ {
label: "Grafik Hasil Kepuasan Masyarakat", label: "Penderita Penyakit",
value: "grafikhasilkepuasan", value: "penderitapenyakit",
href: "/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan", href: "/admin/kesehatan/data-kesehatan-warga/penderita_penyakit",
icon: <IconGauge size={18} stroke={1.8} /> icon: <IconGauge size={18} stroke={1.8} />
}, },
{ {
@@ -81,52 +81,93 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
keepMounted={false} keepMounted={false}
> >
{/* ✅ Scroll horizontal wrapper */} {/* ✅ Scroll horizontal wrapper */}
<ScrollArea type="auto" offsetScrollbars> <Box visibleFrom='md' pb={10}>
<TabsList <ScrollArea type="auto" offsetScrollbars>
p="sm" <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}
value={tab.value}
style={{ style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)", padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem", borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)", boxShadow: "0 4px 16px 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) => ( {children}
<TabsTab </TabsPanel>
key={i} ))}
value={tab.value} </Tabs>
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
{tabs.map((tab, i) => (
<TabsPanel
key={i}
value={tab.value}
style={{
padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}}
>
{children}
</TabsPanel>
))}
</Tabs>
</Stack > </Stack >
); );
} }

View File

@@ -147,7 +147,7 @@ function EditArtikelKesehatan() {
); );
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">

View File

@@ -49,7 +49,7 @@ function DetailArtikelKesehatan() {
const data = state.findUnique.data; const data = state.findUnique.data;
return ( return (
<Box py={10}> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Tombol Back */} {/* Tombol Back */}
<Button <Button
variant="subtle" variant="subtle"
@@ -63,7 +63,7 @@ function DetailArtikelKesehatan() {
{/* Wrapper Detail */} {/* Wrapper Detail */}
<Paper <Paper
withBorder withBorder
w={{ base: '100%', md: '50%' }} w={{ base: '100%', md: '70%' }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"

View File

@@ -94,7 +94,7 @@ function CreateArtikelKesehatan() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md" component="form" onSubmit={handleSubmit}> <Box px={{ base: 0, md: 'lg' }} py="xs" component="form" onSubmit={handleSubmit}>
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button <Button

View File

@@ -18,7 +18,7 @@ import {
Text, Text,
Title Title
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react'; import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
@@ -50,10 +50,11 @@ function ListArtikelKesehatan({ search }: { search: string }) {
const router = useRouter(); const router = useRouter();
const { data, page, totalPages, loading, load } = stateArtikel.findMany; const { data, page, totalPages, loading, load } = stateArtikel.findMany;
const [debouncedSearch] = useDebouncedValue(search, 1000);
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, search); load(page, 10, debouncedSearch);
}, [page, search]); }, [page, debouncedSearch]);
const filteredData = data || []; const filteredData = data || [];

View File

@@ -1,5 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
'use client'; 'use client';
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor'; import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
@@ -10,19 +8,22 @@ import {
Button, Button,
Group, Group,
Loader, Loader,
MultiSelect,
Paper, Paper,
Stack, Stack,
Text, Text,
TextInput, TextInput,
Title Title,
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
interface FasilitasKesehatanFormBase { // Tipe form yang SESUAI dengan logika relasi (array ID)
interface EditFasilitasKesehatanForm {
name: string; name: string;
informasiUmum: { informasiUmum: {
fasilitas: string; fasilitas: string;
@@ -30,128 +31,92 @@ interface FasilitasKesehatanFormBase {
jamOperasional: string; jamOperasional: string;
}; };
layananUnggulan: { content: string }; layananUnggulan: { content: string };
dokterdanTenagaMedis: { dokterdanTenagaMedis: string[]; // ← ARRAY ID
name: string;
specialist: string;
jadwal: string;
};
fasilitasPendukung: { content: string }; fasilitasPendukung: { content: string };
prosedurPendaftaran: { content: string }; prosedurPendaftaran: { content: string };
tarifDanLayanan: { tarifDanLayanan: string[]; // ← ARRAY ID
layanan: string;
tarif: string;
};
} }
function EditFasilitasKesehatan() { function EditFasilitasKesehatan() {
const state = useProxy(fasilitasKesehatanState.fasilitasKesehatan); const state = useProxy(fasilitasKesehatanState.fasilitasKesehatan);
const dokterState = useProxy(fasilitasKesehatanState.dokter);
const tarifState = useProxy(fasilitasKesehatanState.tarif);
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams<{ id: string }>();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<FasilitasKesehatanFormBase>({ const [formData, setFormData] = useState<EditFasilitasKesehatanForm>({
name: '', name: '',
informasiUmum: { fasilitas: '', alamat: '', jamOperasional: '' }, informasiUmum: { fasilitas: '', alamat: '', jamOperasional: '' },
layananUnggulan: { content: '' }, layananUnggulan: { content: '' },
dokterdanTenagaMedis: { name: '', specialist: '', jadwal: '' }, dokterdanTenagaMedis: [],
fasilitasPendukung: { content: '' }, fasilitasPendukung: { content: '' },
prosedurPendaftaran: { content: '' }, prosedurPendaftaran: { content: '' },
tarifDanLayanan: { layanan: '', tarif: '' }, tarifDanLayanan: [],
}); });
const [originalData, setOriginalData] = useState<FasilitasKesehatanFormBase>({ // Load data fasilitas & daftar dokter/tarif
name: '', useShallowEffect(() => {
informasiUmum: { fasilitas: '', alamat: '', jamOperasional: '' }, const loadAll = async () => {
layananUnggulan: { content: '' }, const id = params?.id;
dokterdanTenagaMedis: { name: '', specialist: '', jadwal: '' }, if (!id) return;
fasilitasPendukung: { content: '' },
prosedurPendaftaran: { content: '' },
tarifDanLayanan: { layanan: '', tarif: '' },
});
// Helper untuk update nested state // Load dokter & tarif (untuk opsi MultiSelect)
const updateForm = <K extends keyof FasilitasKesehatanFormBase>( await Promise.all([
key: K, dokterState.findMany.load(),
value: FasilitasKesehatanFormBase[K] tarifState.findMany.load(),
) => setFormData(prev => ({ ...prev, [key]: value })); ]);
const updateNested = < // Load data fasilitas
K extends keyof FasilitasKesehatanFormBase, await state.edit.load(id);
N extends keyof FasilitasKesehatanFormBase[K] const loaded = state.edit.form;
>(key: K, nestedKey: N, value: FasilitasKesehatanFormBase[K][N]) => if (loaded) {
setFormData(prev => ({ setFormData({
...prev, name: loaded.name,
[key]: { ...prev[key] as object, [nestedKey]: value }, informasiUmum: loaded.informasiUmum,
})); layananUnggulan: loaded.layananUnggulan,
dokterdanTenagaMedis: loaded.dokterdanTenagaMedis || [],
fasilitasPendukung: loaded.fasilitasPendukung,
prosedurPendaftaran: loaded.prosedurPendaftaran,
tarifDanLayanan: loaded.tarifDanLayanan || [],
});
}
};
const deepClone = (obj: any): any => { loadAll();
try { }, [params?.id]);
return JSON.parse(JSON.stringify(obj));
} catch (error) { const updateForm = <K extends keyof EditFasilitasKesehatanForm>(
console.warn('Gagal deep clone dengan JSON fallback:', error); field: K,
return obj; // fallback (berisiko shared reference) value: EditFasilitasKesehatanForm[K]
) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleReset = () => {
const loaded = state.edit.form;
if (loaded) {
setFormData({
name: loaded.name,
informasiUmum: loaded.informasiUmum,
layananUnggulan: loaded.layananUnggulan,
dokterdanTenagaMedis: loaded.dokterdanTenagaMedis || [],
fasilitasPendukung: loaded.fasilitasPendukung,
prosedurPendaftaran: loaded.prosedurPendaftaran,
tarifDanLayanan: loaded.tarifDanLayanan || [],
});
toast.info('Form dikembalikan ke data awal');
} }
}; };
// Load data const handleSubmit = async (e: React.FormEvent) => {
useEffect(() => { e.preventDefault();
const load = async () => {
const id = params?.id as string;
if (!id) return;
try {
await state.edit.load(id);
const loadedData = state.edit.form;
if (!loadedData) {
toast.error('Data tidak ditemukan');
return;
}
// Gunakan JSON fallback untuk deep clone
const clonedData = deepClone(loadedData) as FasilitasKesehatanFormBase;
setFormData(clonedData);
setOriginalData(clonedData);
} catch (err) {
console.error(err);
toast.error('Gagal memuat data fasilitas kesehatan');
}
};
load();
}, [params?.id]);
const handleResetForm = () => {
setFormData({
name: originalData.name,
informasiUmum:
{
fasilitas: originalData.informasiUmum.fasilitas,
alamat: originalData.informasiUmum.alamat,
jamOperasional: originalData.informasiUmum.jamOperasional
},
layananUnggulan: { content: originalData.layananUnggulan.content },
dokterdanTenagaMedis: {
name: originalData.dokterdanTenagaMedis.name,
specialist: originalData.dokterdanTenagaMedis.specialist,
jadwal: originalData.dokterdanTenagaMedis.jadwal
},
fasilitasPendukung: { content: originalData.fasilitasPendukung.content },
prosedurPendaftaran: { content: originalData.prosedurPendaftaran.content },
tarifDanLayanan: {
layanan: originalData.tarifDanLayanan.layanan,
tarif: originalData.tarifDanLayanan.tarif
},
});
toast.info("Form dikembalikan ke data awal");
};
// Submit
const handleSubmit = async () => {
try { try {
setIsSubmitting(true); setIsSubmitting(true);
state.edit.form = { ...state.edit.form, ...formData };
// Update state Valtio
state.edit.form = { ...formData };
const success = await state.edit.submit(); const success = await state.edit.submit();
if (success) { if (success) {
toast.success('Fasilitas kesehatan berhasil diperbarui!'); toast.success('Fasilitas kesehatan berhasil diperbarui!');
@@ -159,14 +124,14 @@ function EditFasilitasKesehatan() {
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
toast.error('Terjadi kesalahan saat memperbarui data fasilitas kesehatan'); toast.error('Gagal memperbarui data');
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'xs' }} py="xs" component="form" onSubmit={handleSubmit}>
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
@@ -189,7 +154,7 @@ function EditFasilitasKesehatan() {
<Stack gap="md"> <Stack gap="md">
<TextInput <TextInput
label="Nama Fasilitas Kesehatan" label="Nama Fasilitas Kesehatan"
placeholder="Masukkan nama fasilitas kesehatan" placeholder="Masukkan nama"
value={formData.name} value={formData.name}
onChange={(e) => updateForm('name', e.target.value)} onChange={(e) => updateForm('name', e.target.value)}
required required
@@ -197,118 +162,108 @@ function EditFasilitasKesehatan() {
{/* Informasi Umum */} {/* Informasi Umum */}
<Box> <Box>
<Text fw="bold" mb={5}> <Text fw="bold" mb={5}>Informasi Umum</Text>
Informasi Umum
</Text>
<TextInput <TextInput
label="Fasilitas" label="Fasilitas"
value={formData.informasiUmum.fasilitas} value={formData.informasiUmum.fasilitas}
onChange={(e) => updateNested('informasiUmum', 'fasilitas', e.target.value)} onChange={(e) =>
updateForm('informasiUmum', {
...formData.informasiUmum,
fasilitas: e.target.value,
})
}
/> />
<TextInput <TextInput
label="Alamat" label="Alamat"
value={formData.informasiUmum.alamat} value={formData.informasiUmum.alamat}
onChange={(e) => updateNested('informasiUmum', 'alamat', e.target.value)} onChange={(e) =>
updateForm('informasiUmum', {
...formData.informasiUmum,
alamat: e.target.value,
})
}
/> />
<TextInput <TextInput
label="Jam Operasional" label="Jam Operasional"
value={formData.informasiUmum.jamOperasional} value={formData.informasiUmum.jamOperasional}
onChange={(e) => updateNested('informasiUmum', 'jamOperasional', e.target.value)} onChange={(e) =>
updateForm('informasiUmum', {
...formData.informasiUmum,
jamOperasional: e.target.value,
})
}
/> />
</Box> </Box>
{/* Layanan Unggulan */} {/* Layanan Unggulan */}
<Box> <Box>
<Text fw="bold" mb={5}> <Text fw="bold" mb={5}>Layanan Unggulan</Text>
Layanan Unggulan
</Text>
<EditEditor <EditEditor
value={formData.layananUnggulan.content} value={formData.layananUnggulan.content}
onChange={(v) => updateNested('layananUnggulan', 'content', v)} onChange={(v) => updateForm('layananUnggulan', { content: v })}
/> />
</Box> </Box>
{/* Dokter dan Tenaga Medis */} {/* Dokter & Tenaga Medis — MultiSelect */}
<Box> <MultiSelect
<Text fw="bold" mb={5}> label="Dokter & Tenaga Medis"
Dokter dan Tenaga Medis placeholder="Pilih dokter/tenaga medis"
</Text> data={
<TextInput dokterState.findMany.data?.map((d) => ({
label="Nama Dokter" value: d.id,
value={formData.dokterdanTenagaMedis.name} label: `${d.name} (${d.specialist})`,
onChange={(e) => updateNested('dokterdanTenagaMedis', 'name', e.target.value)} })) || []
/> }
<TextInput value={formData.dokterdanTenagaMedis}
label="Specialist" onChange={(val) => updateForm('dokterdanTenagaMedis', val)}
value={formData.dokterdanTenagaMedis.specialist} searchable
onChange={(e) => clearable
updateNested('dokterdanTenagaMedis', 'specialist', e.target.value) required
} />
/>
<TextInput
label="Jadwal"
value={formData.dokterdanTenagaMedis.jadwal}
onChange={(e) => updateNested('dokterdanTenagaMedis', 'jadwal', e.target.value)}
/>
</Box>
{/* Fasilitas Pendukung */} {/* Fasilitas Pendukung */}
<Box> <Box>
<Text fw="bold" mb={5}> <Text fw="bold" mb={5}>Fasilitas Pendukung</Text>
Fasilitas Pendukung
</Text>
<EditEditor <EditEditor
value={formData.fasilitasPendukung.content} value={formData.fasilitasPendukung.content}
onChange={(v) => updateNested('fasilitasPendukung', 'content', v)} onChange={(v) => updateForm('fasilitasPendukung', { content: v })}
/> />
</Box> </Box>
{/* Prosedur Pendaftaran */} {/* Prosedur Pendaftaran */}
<Box> <Box>
<Text fw="bold" mb={5}> <Text fw="bold" mb={5}>Prosedur Pendaftaran</Text>
Prosedur Pendaftaran
</Text>
<EditEditor <EditEditor
value={formData.prosedurPendaftaran.content} value={formData.prosedurPendaftaran.content}
onChange={(v) => updateNested('prosedurPendaftaran', 'content', v)} onChange={(v) => updateForm('prosedurPendaftaran', { content: v })}
/> />
</Box> </Box>
{/* Tarif dan Layanan */} {/* Tarif & Layanan — MultiSelect */}
<Box> <MultiSelect
<Text fw="bold" mb={5}> label="Tarif & Layanan"
Tarif dan Layanan placeholder="Pilih layanan"
</Text> data={
<TextInput tarifState.findMany.data?.map((t) => ({
label="Tarif" value: t.id,
value={formData.tarifDanLayanan.tarif} label: `${t.layanan} - ${t.tarif}`,
onChange={(e) => updateNested('tarifDanLayanan', 'tarif', e.target.value)} })) || []
/> }
<TextInput value={formData.tarifDanLayanan}
label="Layanan" onChange={(val) => updateForm('tarifDanLayanan', val)}
value={formData.tarifDanLayanan.layanan} searchable
onChange={(e) => updateNested('tarifDanLayanan', 'layanan', e.target.value)} clearable
/> required
</Box> />
{/* Tombol Simpan */} {/* Aksi */}
<Group justify="right"> <Group justify="right">
{/* Tombol Batal */} <Button variant="outline" color="gray" radius="md" onClick={handleReset}>
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal Batal
</Button> </Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} type="submit"
radius="md" radius="md"
size="md"
style={{ style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`, background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff', color: '#fff',
@@ -324,4 +279,4 @@ function EditFasilitasKesehatan() {
); );
} }
export default EditFasilitasKesehatan; export default EditFasilitasKesehatan;

View File

@@ -9,6 +9,12 @@ import {
Paper, Paper,
Skeleton, Skeleton,
Stack, Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text Text
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
@@ -50,7 +56,7 @@ function DetailFasilitasKesehatan() {
const data = state.findUnique.data; const data = state.findUnique.data;
return ( return (
<Box py={10}> <Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Tombol Back */} {/* Tombol Back */}
<Button <Button
variant="subtle" variant="subtle"
@@ -77,12 +83,12 @@ function DetailFasilitasKesehatan() {
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs"> <Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm"> <Stack gap="sm">
<Box> <Box pl={10}>
<Text fz="lg" fw="bold">Nama Fasilitas</Text> <Text fz="lg" fw="bold">Nama Fasilitas</Text>
<Text fz="md" c="dimmed">{data.name || '-'}</Text> <Text fz="md" c="dimmed">{data.name || '-'}</Text>
</Box> </Box>
<Box> <Box pl={10}>
<Text fz="lg" fw="bold">Informasi Umum</Text> <Text fz="lg" fw="bold">Informasi Umum</Text>
<Text fz="md" fw="bold">Fasilitas</Text> <Text fz="md" fw="bold">Fasilitas</Text>
<Text fz="md" c="dimmed">{data.informasiumum?.fasilitas || '-'}</Text> <Text fz="md" c="dimmed">{data.informasiumum?.fasilitas || '-'}</Text>
@@ -92,37 +98,95 @@ function DetailFasilitasKesehatan() {
<Text fz="md" c="dimmed">{data.informasiumum?.jamOperasional || '-'}</Text> <Text fz="md" c="dimmed">{data.informasiumum?.jamOperasional || '-'}</Text>
</Box> </Box>
<Box> <Box pl={10}>
<Text fz="lg" fw="bold">Layanan Unggulan</Text> <Text fz="lg" fw="bold">Layanan Unggulan</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.layananunggulan?.content || '-' }} /> <Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.layananunggulan?.content || '-' }} />
</Box> </Box>
<Box> <Box pl={10}>
<Text fz="lg" fw="bold">Fasilitas Pendukung</Text> <Text fz="lg" fw="bold">Fasilitas Pendukung</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.fasilitaspendukung?.content || '-' }} /> <Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.fasilitaspendukung?.content || '-' }} />
</Box> </Box>
<Box> <Box pl={10}>
<Text fz="lg" fw="bold">Prosedur Pendaftaran</Text> <Text fz="lg" fw="bold">Prosedur Pendaftaran</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.prosedurpendaftaran?.content || '-' }} /> <Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.prosedurpendaftaran?.content || '-' }} />
</Box> </Box>
<Box> <Box pl={10}>
<Text fz="lg" fw="bold">Dokter & Tenaga Medis</Text> <Text fz="lg" fw="bold" mb="sm">Dokter & Tenaga Medis</Text>
<Text fz="md" fw="bold">Nama</Text> {Array.isArray(data.dokterdantenagamedis) && data.dokterdantenagamedis.length > 0 ? (
<Text fz="md" c="dimmed">{data.dokterdantenagamedis?.name || '-'}</Text> <Box style={{ overflowX: 'auto', width: '100%' }}>
<Text fz="md" fw="bold">Spesialis</Text> <Table striped highlightOnHover withTableBorder>
<Text fz="md" c="dimmed">{data.dokterdantenagamedis?.specialist || '-'}</Text> <TableThead>
<Text fz="md" fw="bold">Jadwal</Text> <TableTr>
<Text fz="md" c="dimmed">{data.dokterdantenagamedis?.jadwal || '-'}</Text> <TableTh style={{ whiteSpace: 'nowrap' }}>Nama</TableTh>
<TableTh style={{ whiteSpace: 'nowrap' }}>Spesialis</TableTh>
<TableTh style={{ whiteSpace: 'nowrap' }}>Jadwal</TableTh>
<TableTh style={{ whiteSpace: 'nowrap' }}>Jam Operasional</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{data.dokterdantenagamedis.map((dokter) => (
<TableTr key={dokter.id}>
<TableTd style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{dokter.name || '-'}
</TableTd>
<TableTd style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{dokter.specialist || '-'}
</TableTd>
<TableTd style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{dokter.jadwal || '-'}
</TableTd>
<TableTd style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{dokter.jamBukaOperasional} {dokter.jamTutupOperasional}
{dokter.jadwalLibur && (
<>
<br />
<Text span c="dimmed" fz="xs">
Libur: {dokter.jadwalLibur} ({dokter.jamBukaLibur}{dokter.jamTutupLibur})
</Text>
</>
)}
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</Box>
) : (
<Text c="dimmed">Tidak ada dokter atau tenaga medis terdaftar.</Text>
)}
</Box> </Box>
<Box> <Box pl={10} mt="xl">
<Text fz="lg" fw="bold">Tarif & Layanan</Text> <Text fz="lg" fw="bold" mb="sm">Tarif & Layanan</Text>
<Text fz="md" fw="bold">Layanan</Text> {Array.isArray(data.tarifdanlayanan) && data.tarifdanlayanan.length > 0 ? (
<Text fz="md" c="dimmed">{data.tarifdanlayanan?.layanan || '-'}</Text> <Box style={{ overflowX: 'auto', width: '100%' }}>
<Text fz="md" fw="bold">Tarif</Text> <Table striped highlightOnHover withTableBorder>
<Text fz="md" c="dimmed">{data.tarifdanlayanan?.tarif || '-'}</Text> <TableThead>
<TableTr>
<TableTh style={{ whiteSpace: 'nowrap' }}>Layanan</TableTh>
<TableTh style={{ whiteSpace: 'nowrap' }}>Tarif</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{data.tarifdanlayanan.map((tarif) => (
<TableTr key={tarif.id}>
<TableTd style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{tarif.layanan || '-'}
</TableTd>
<TableTd style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{tarif.tarif || '-'}
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</Box>
) : (
<Text c="dimmed">Tidak ada tarif atau layanan terdaftar.</Text>
)}
</Box> </Box>
{/* Aksi */} {/* Aksi */}

View File

@@ -7,19 +7,20 @@ import {
Button, Button,
Group, Group,
Loader, Loader,
MultiSelect,
Paper, Paper,
Stack, Stack,
Text, Text,
TextInput, TextInput,
Title Title
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreateFasilitasKesehatan() { function CreateFasilitasKesehatan() {
const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.fasilitasKesehatan); const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.fasilitasKesehatan);
const router = useRouter(); const router = useRouter();
@@ -34,23 +35,16 @@ function CreateFasilitasKesehatan() {
jamOperasional: '', jamOperasional: '',
}, },
layananUnggulan: { layananUnggulan: {
content: '', content: ''
},
dokterdanTenagaMedis: {
name: '',
specialist: '',
jadwal: '',
}, },
dokterdanTenagaMedis: [] as string[],
fasilitasPendukung: { fasilitasPendukung: {
content: '', content: '',
}, },
prosedurPendaftaran: { prosedurPendaftaran: {
content: '', content: '',
}, },
tarifDanLayanan: { tarifDanLayanan: [] as string[],
layanan: '',
tarif: '',
},
}; };
}; };
@@ -70,8 +64,13 @@ function CreateFasilitasKesehatan() {
} }
}; };
useShallowEffect(() => {
fasilitasKesehatanState.dokter.findMany.load();
fasilitasKesehatanState.tarif.findMany.load();
}, []);
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md" component="form" onSubmit={handleSubmit}> <Box px={{ base: 0, md: 'xs' }} py="xs" component="form" onSubmit={handleSubmit}>
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button <Button
@@ -140,31 +139,25 @@ function CreateFasilitasKesehatan() {
/> />
</Box> </Box>
{/* Dokter dan Tenaga Medis */} {/* Dokter dan Tenaga Medis */}
<Box> <MultiSelect
<Text fz="md" fw="bold" mb={5}>Dokter dan Tenaga Medis</Text> label="Dokter & Tenaga Medis"
<TextInput placeholder="Pilih dokter / tenaga medis"
label="Nama Dokter" data={
placeholder="Masukkan nama dokter" fasilitasKesehatanState.dokter.findMany.data?.map((item) => ({
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.name} label: item.name,
onChange={(e) => (stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.name = e.target.value)} value: item.id,
required })) || []
/> }
<TextInput value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis}
label="Spesialis" onChange={(val: string[]) => {
placeholder="Masukkan spesialis" stateFasilitasKesehatan.create.form.dokterdanTenagaMedis = val;
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.specialist} }}
onChange={(e) => (stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.specialist = e.target.value)} searchable
required clearable
/> required
<TextInput />
label="Jadwal"
placeholder="Masukkan jadwal"
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.jadwal}
onChange={(e) => (stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.jadwal = e.target.value)}
required
/>
</Box>
{/* Fasilitas Pendukung */} {/* Fasilitas Pendukung */}
<Box> <Box>
@@ -175,6 +168,24 @@ function CreateFasilitasKesehatan() {
/> />
</Box> </Box>
<MultiSelect
label="Layanan"
placeholder="Pilih layanan"
data={
fasilitasKesehatanState.tarif.findMany.data?.map((item) => ({
label: item.layanan,
value: item.id,
})) || []
}
value={stateFasilitasKesehatan.create.form.tarifDanLayanan} // string[]
onChange={(val: string[]) => {
stateFasilitasKesehatan.create.form.tarifDanLayanan = val;
}}
searchable
clearable
required
/>
{/* Prosedur Pendaftaran */} {/* Prosedur Pendaftaran */}
<Box> <Box>
<Text fz="md" fw="bold" mb={5}>Prosedur Pendaftaran</Text> <Text fz="md" fw="bold" mb={5}>Prosedur Pendaftaran</Text>
@@ -184,24 +195,6 @@ function CreateFasilitasKesehatan() {
/> />
</Box> </Box>
{/* Tarif dan Layanan */}
<Box>
<Text fz="md" fw="bold" mb={5}>Tarif dan Layanan</Text>
<TextInput
label="Tarif"
placeholder="Masukkan tarif"
value={stateFasilitasKesehatan.create.form.tarifDanLayanan.tarif}
onChange={(e) => (stateFasilitasKesehatan.create.form.tarifDanLayanan.tarif = e.target.value)}
required
/>
<TextInput
label="Layanan"
placeholder="Masukkan layanan"
value={stateFasilitasKesehatan.create.form.tarifDanLayanan.layanan}
onChange={(e) => (stateFasilitasKesehatan.create.form.tarifDanLayanan.layanan = e.target.value)}
required
/>
</Box>
{/* Submit */} {/* Submit */}
<Group justify="right"> <Group justify="right">

View File

@@ -1,11 +1,241 @@
import React from 'react'; /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors';
import {
ActionIcon,
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title
} from '@mantine/core';
import { TimeInput } from '@mantine/dates';
import { IconArrowBack, IconClock } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function EditDokterTenagaMedis() {
const state = useProxy(fasilitasKesehatanState.dokter);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
name: '',
specialist: '',
jadwal: '',
jadwalLibur: '',
jamBukaOperasional: '',
jamTutupOperasional: '',
jamBukaLibur: '',
jamTutupLibur: '',
});
const [originalData, setOriginalData] = useState({
name: '',
specialist: '',
jadwal: '',
jadwalLibur: '',
jamBukaOperasional: '',
jamTutupOperasional: '',
jamBukaLibur: '',
jamTutupLibur: '',
});
// Load data
useEffect(() => {
const load = async () => {
const id = params?.id as string;
if (!id) return;
try {
await state.update.load(id);
const loadedData = state.update.form;
if (!loadedData) {
toast.error('Data tidak ditemukan');
return;
}
setFormData(loadedData);
setOriginalData(loadedData);
} catch (err) {
console.error(err);
toast.error('Gagal memuat data fasilitas kesehatan');
}
};
load();
}, [params?.id]);
const handleResetForm = () => {
setFormData({
name: originalData.name,
specialist: originalData.specialist,
jadwal: originalData.jadwal,
jadwalLibur: originalData.jadwalLibur,
jamBukaOperasional: originalData.jamBukaOperasional,
jamTutupOperasional: originalData.jamTutupOperasional,
jamBukaLibur: originalData.jamBukaLibur,
jamTutupLibur: originalData.jamTutupLibur,
});
toast.info("Form dikembalikan ke data awal");
};
const refBuka = useRef<HTMLInputElement>(null);
const refTutup = useRef<HTMLInputElement>(null);
const refBukaLibur = useRef<HTMLInputElement>(null);
const refTutupLibur = useRef<HTMLInputElement>(null);
const picker = (ref: any) => (
<ActionIcon variant="subtle" color="gray" onClick={() => ref.current?.showPicker()}>
<IconClock size={16} stroke={1.5} />
</ActionIcon>
);
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// Submit
const handleSubmit = async () => {
try {
setIsSubmitting(true);
state.update.form = { ...state.update.form, ...formData };
const success = await state.update.submit();
if (success) {
toast.success('Data berhasil diperbarui!');
router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis');
}
} catch (err) {
console.error(err);
toast.error('Terjadi kesalahan saat memperbarui data');
} finally {
setIsSubmitting(false);
}
};
function Page() {
return ( return (
<div> <Box px={{ base: 0, md: 'lg' }} py="xs">
Page {/* Header */}
</div> <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 Fasilitas Kesehatan
</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="Nama Dokter"
placeholder="Masukkan nama dokter"
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
required
/>
<TextInput
label="Jadwal"
placeholder="Masukkan jadwal"
value={formData.jadwal}
onChange={(e) => handleChange("jadwal", e.target.value)}
required
/>
<TextInput
label="Jadwal Libur"
placeholder="Masukkan jadwal libur"
value={formData.jadwalLibur}
onChange={(e) => handleChange("jadwalLibur", e.target.value)}
required
/>
<TimeInput
label="Jam Buka Operasional"
ref={refBuka}
rightSection={picker(refBuka)}
value={formData.jamBukaOperasional}
onChange={(e) => handleChange("jamBukaOperasional", e.target.value)}
required
/>
<TimeInput
label="Jam Tutup Operasional"
ref={refTutup}
rightSection={picker(refTutup)}
value={formData.jamTutupOperasional}
onChange={(e) => handleChange("jamTutupOperasional", e.target.value)}
required
/>
<TimeInput
label="Jam Buka Hari Libur"
ref={refBukaLibur}
rightSection={picker(refBukaLibur)}
value={formData.jamBukaLibur}
onChange={(e) => handleChange("jamBukaLibur", e.target.value)}
required
/>
<TimeInput
label="Jam Tutup Hari Libur"
ref={refTutupLibur}
rightSection={picker(refTutupLibur)}
value={formData.jamTutupLibur}
onChange={(e) => handleChange("jamTutupLibur", e.target.value)}
required
/>
{/* Tombol Simpan */}
<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 Page; export default EditDokterTenagaMedis;

View File

@@ -1,11 +1,165 @@
import React from 'react'; 'use client'
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Paper,
Skeleton,
Stack,
Text
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
function DetailDokterTenagaMedis() {
const params = useParams();
const router = useRouter();
const state = useProxy(fasilitasKesehatanState.dokter);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
useShallowEffect(() => {
state.findUnique.load(params?.id as string);
}, []);
const handleHapus = () => {
if (selectedId) {
state.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push(
'/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis'
);
}
};
if (!state.findUnique.data) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
const data = state.findUnique.data;
function Page() {
return ( return (
<div> <Box px={{ base: 0, md: 'lg' }} py="xs">
Page {/* Tombol Back */}
</div> <Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
mb={15}
>
Kembali
</Button>
{/* Wrapper Detail */}
<Paper
withBorder
w={{ base: '100%', md: '70%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Dokter & Tenaga Medis
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">Nama Dokter</Text>
<Text fz="md" c="dimmed">{data.name || '-'}</Text>
</Box>
<Box>
<Text fz="md" fw="bold">Specialist</Text>
<Text fz="md" c="dimmed">{data.specialist || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Jadwal</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.jadwal || '-' }} />
</Box>
<Box>
<Text fz="lg" fw="bold">Jadwal Libur</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.jadwalLibur || '-' }} />
</Box>
<Box>
<Text fz="lg" fw="bold">Jam Buka Operasional</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.jamBukaOperasional || '-' }} />
</Box>
<Box>
<Text fz="lg" fw="bold">Jam Tutup Operasional</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.jamTutupOperasional || '-' }} />
</Box>
<Box>
<Text fz="lg" fw="bold">Jam Buka Libur</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.jamBukaLibur || '-' }} />
</Box>
<Box>
<Text fz="lg" fw="bold">Jam Tutup Libur</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.jamTutupLibur || '-' }} />
</Box>
{/* Aksi */}
<Group gap="sm">
<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/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/${data.id}/edit`
)
}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Paper>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah anda yakin ingin menghapus dokter & tenaga medis ini?"
/>
</Box>
); );
} }
export default Page; export default DetailDokterTenagaMedis;

View File

@@ -1,71 +1,184 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client' 'use client'
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan'; import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import { ActionIcon, Box, Button, Group, Loader, Paper, Stack, TextInput, Title } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { TimeInput } from '@mantine/dates';
import { useParams, useRouter } from 'next/navigation'; import { IconArrowBack, IconClock } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreateDokter() { function CreateDokter() {
const params = useParams()
const createState = useProxy(fasilitasKesehatanState.dokter) const createState = useProxy(fasilitasKesehatanState.dokter)
const router = useRouter(); const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => { const resetForm = () => {
createState.create.create.form = { createState.create.create.form = {
name: "", name: "",
specialist: "", specialist: "",
jadwal: "", jadwal: "",
jadwalLibur: "",
jamBukaOperasional: "",
jamTutupOperasional: "",
jamBukaLibur: "",
jamTutupLibur: "",
}; };
}; };
const refBuka = useRef<HTMLInputElement>(null);
const refTutup = useRef<HTMLInputElement>(null);
const refBukaLibur = useRef<HTMLInputElement>(null);
const refTutupLibur = useRef<HTMLInputElement>(null);
const picker = (ref: any) => (
<ActionIcon variant="subtle" color="gray" onClick={() => ref.current?.showPicker()}>
<IconClock size={16} stroke={1.5} />
</ActionIcon>
);
const handleSubmit = async () => { const handleSubmit = async () => {
await createState.create.create.create(); try {
resetForm(); setIsSubmitting(true);
router.push(`/admin/kesehatan/fasilitas-kesehatan/${params?.id}/dokter-tenaga-medis`) await createState.create.create.create();
toast.success('Data berhasil disimpan');
resetForm();
router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis`)
} catch (error) {
console.error(error);
toast.error('Gagal menyimpan data');
} finally {
setIsSubmitting(false);
}
}; };
return ( return (
<Box component="form" onSubmit={handleSubmit}> <Box px={{ base: 0, md: 'lg' }} py="xs" component="form" onSubmit={handleSubmit}>
<Box mb={10}> {/* Header */}
<Button variant="subtle" onClick={() => router.back()}> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={25} /> <Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> <Title order={4} ml="sm" c="dark">
Tambah Data Dokter & Tenaga Medis
</Title>
</Group>
<Paper bg={colors['white-1']} p="md" w={{ base: '100%', md: '50%' }}> {/* Form */}
<Stack gap="xs"> <Paper
<Title order={3}>Create Dokter</Title> w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Nama Dokter</Text>} label={"Nama Dokter"}
placeholder="masukkan nama dokter" placeholder="Masukkan nama dokter"
value={createState.create.create.form.name} value={createState.create.create.form.name}
onChange={(e) => { onChange={(e) => (createState.create.create.form.name = e.target.value)}
createState.create.create.form.name = e.target.value; required
}}
/> />
<Text fz="md" fw="bold">Specialist</Text>
{/* Informasi Umum */}
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Specialist</Text>} label="Specialist"
placeholder="masukkan specialist" placeholder="Masukkan specialist"
value={createState.create.create.form.specialist} value={createState.create.create.form.specialist}
onChange={(e) => { onChange={(e) => (createState.create.create.form.specialist = e.target.value)}
createState.create.create.form.specialist = e.target.value; required
}}
/> />
<Box>
<Text fz="md" fw="bold">Jadwal</Text> <TextInput
<CreateEditor label="Jadwal"
value={createState.create.create.form.jadwal} placeholder="Masukkan jadwal"
onChange={(htmlContent) => { value={createState.create.create.form.jadwal}
createState.create.create.form.jadwal = htmlContent; onChange={(e) => (createState.create.create.form.jadwal = e.target.value)}
required
/>
<TextInput
label="Jadwal Libur"
placeholder="Masukkan jadwal libur"
value={createState.create.create.form.jadwalLibur}
onChange={(e) => (createState.create.create.form.jadwalLibur = e.target.value)}
required
/>
<TimeInput
label="Jam Buka Operasional"
ref={refBuka}
rightSection={picker(refBuka)}
value={createState.create.create.form.jamBukaOperasional}
onChange={(e) => (createState.create.create.form.jamBukaOperasional = e.target.value)}
required
/>
<TimeInput
label="Jam Tutup Operasional"
ref={refTutup}
rightSection={picker(refTutup)}
value={createState.create.create.form.jamTutupOperasional}
onChange={(e) => (createState.create.create.form.jamTutupOperasional = e.target.value)}
required
/>
<TimeInput
label="Jam Buka Hari Libur"
ref={refBukaLibur}
rightSection={picker(refBukaLibur)}
value={createState.create.create.form.jamBukaLibur}
onChange={(e) => (createState.create.create.form.jamBukaLibur = e.target.value)}
required
/>
<TimeInput
label="Jam Tutup Hari Libur"
ref={refTutupLibur}
rightSection={picker(refTutupLibur)}
value={createState.create.create.form.jamTutupLibur}
onChange={(e) => (createState.create.create.form.jamTutupLibur = e.target.value)}
required
/>
{/* Submit */}
<Group justify="right">
{/* Tombol Batal */}
<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)',
}} }}
/> >
</Box> {isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
<Button onClick={handleSubmit} bg={colors['blue-button']}> </Button>
Simpan </Group>
</Button>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -1,24 +1,22 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconDeviceImacCog, IconSearch } from '@tabler/icons-react'; import { IconArrowBack, IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '@/app/admin/(dashboard)/_com/header'; import HeaderSearch from '@/app/admin/(dashboard)/_com/header';
import JudulList from '@/app/admin/(dashboard)/_com/judulList';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan'; import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import { useState } from 'react'; import { useState } from 'react';
function DokterTenagaMedis() { function DokterTenagaMedis() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const router = useRouter(); const router = useRouter();
return ( return (
<Box> <Box>
<Box mb={10}> <Box mb="sm">
<Button variant="subtle" onClick={() => router.back()}> <Button variant="subtle" onClick={() => router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan')}>
<IconArrowBack color={colors['blue-button']} size={25} /> <IconArrowBack color={colors['blue-button']} size={25} />
</Button> </Button>
</Box> </Box>
@@ -45,68 +43,161 @@ function ListDokterTenagaMedis({ search }: { search: string }) {
totalPages totalPages
} = stateFasilitasKesehatan.findMany } = stateFasilitasKesehatan.findMany
const [debouncedSearch] = useDebouncedValue(search, 1000);
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, search) load(page, 10, debouncedSearch)
}, [page, search]) }, [page, debouncedSearch])
const filteredData = data || [] const filteredData = data || []
if (loading || !data) { if (loading || !data) {
return ( return (
<Box py={10}> <Box py="md">
<Skeleton h={500} /> <Skeleton h={500} />
</Box> </Box>
) )
} }
return ( return (
<Box py={10}> <Box py="md">
<Paper bg={colors['white-1']} p={'md'}> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Stack> {/* Judul + Tombol Tambah */}
<JudulList <Group justify="space-between" mb="md">
title='List Fasilitas Kesehatan' <Title order={3} visibleFrom="md">Daftar Dokter dan Tenaga Medis</Title>
href={`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/create`} <Title order={4} hiddenFrom="md">Daftar Dokter dan Tenaga Medis</Title>
/> <Button
<Box style={{ overflowX: "auto" }}> leftSection={<IconPlus size={18} />}
<Table striped withRowBorders withTableBorder style={{ minWidth: '700px' }}> color="blue"
<TableThead> variant="light"
<TableTr> onClick={() =>
<TableTh>Fasilitas Kesehatan</TableTh> router.push(
<TableTh>Alamat</TableTh> '/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/create'
<TableTh>Jam Operasional</TableTh> )
<TableTh>Detail</TableTh> }
</TableTr> >
</TableThead> Tambah Baru
<TableTbody> </Button>
{filteredData.map((item) => ( </Group>
{/* Desktop Table */}
<Box visibleFrom="md">
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh><Text fz="sm" fw={600} lh={1.4}>Nama Dokter</Text></TableTh>
<TableTh><Text fz="sm" fw={600} lh={1.4}>Spesialis</Text></TableTh>
<TableTh><Text fz="sm" fw={600} lh={1.4}>Jadwal</Text></TableTh>
<TableTh><Text fz="sm" fw={600} lh={1.4}>Aksi</Text></TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd>{item.name}</TableTd>
<TableTd>{item.specialist}</TableTd>
<TableTd> <TableTd>
<Text dangerouslySetInnerHTML={{ __html: item.jadwal }} /> <Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}>
{item.name}
</Text>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${item.id}`)}> <Text fz="md" fw={500} lh={1.5}>
<IconDeviceImacCog size={25} /> {item.specialist || '-'}
</Text>
</TableTd>
<TableTd>
<Text fz="md" fw={500} lh={1.5} dangerouslySetInnerHTML={{ __html: item.jadwal || '-' }} />
</TableTd>
<TableTd>
<Button
variant="light"
color="blue"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/${item.id}`
)
}
>
<IconDeviceImacCog size={20} />
<Text ml="xs" fz="sm" fw={500}>Detail</Text>
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
</TableTbody> ) : (
</Table> <TableTr>
</Box> <TableTd colSpan={4}>
</Stack> <Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada fasilitas kesehatan yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Cards */}
<Box hiddenFrom="md">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="md" mb="xs" radius="sm">
<Box mb="xs">
<Text fz="sm" fw={600} lh={1.4}>Nama Dokter</Text>
<Text fz="sm" fw={500} lh={1.5}>{item.name}</Text>
</Box>
<Box mb="xs">
<Text fz="sm" fw={600} lh={1.4}>Spesialis</Text>
<Text fz="sm" fw={500} lh={1.5}>{item.specialist || '-'}</Text>
</Box>
<Box mb="md">
<Text fz="sm" fw={600} lh={1.4}>Jadwal</Text>
<Text fz="sm" fw={500} lh={1.5} dangerouslySetInnerHTML={{ __html: item.jadwal || '-' }} />
</Box>
<Button
fullWidth
variant="light"
color="blue"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/${item.id}`
)
}
>
<IconDeviceImacCog size={20} />
<Text ml="xs" fz="sm" fw={500}>Detail</Text>
</Button>
</Paper>
))
) : (
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada fasilitas kesehatan yang cocok
</Text>
</Center>
)}
</Box>
</Paper> </Paper>
{/* Pagination */}
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => load(newPage)} // ini penting! onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages} total={totalPages}
mt="md" mt="md"
mb="md" mb="md"
color="blue"
radius="md"
/> />
</Center> </Center>
</Box> </Box>
) )
} }
export default DokterTenagaMedis; export default DokterTenagaMedis;

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
ActionIcon,
Box, Box,
Button, Button,
Center, Center,
@@ -16,63 +17,142 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title TextInput,
Title,
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react'; import { IconCoin, IconDeviceImacCog, IconPlus, IconReportMedical, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import fasilitasKesehatanState from '../../../_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan'; import fasilitasKesehatanState from '../../../_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
function FasilitasKesehatan() { function FasilitasKesehatan() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const router = useRouter();
return ( return (
<Box> <Box>
{/* Header Search */} <Paper p="lg" radius="md" mb="lg" bg={colors['white-1']} shadow="sm">
<HeaderSearch <Group justify='space-between' visibleFrom='md'>
title='Fasilitas Kesehatan' <Title order={2} visibleFrom="md" size="lg" lh={1.2}>
placeholder='Cari nama, alamat, atau jam operasional...' Fasilitas Kesehatan
searchIcon={<IconSearch size={20} />} </Title>
value={search} <Title order={2} hiddenFrom="md" size="md" lh={1.2}>
onChange={(e) => setSearch(e.currentTarget.value)} Fasilitas Kesehatan
/> </Title>
<Group gap="xs" justify="flex-end">
<Tooltip label="List Dokter" withArrow>
<ActionIcon
onClick={() => router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis')}
size="lg"
radius="xl"
color="green.6"
>
<IconReportMedical size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label="List Tarif Layanan" withArrow>
<ActionIcon
onClick={() => router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan')}
size="lg"
radius="xl"
color="blue.6"
>
<IconCoin size={20} />
</ActionIcon>
</Tooltip>
<TextInput
radius="lg"
placeholder='Cari nama, alamat, atau jam operasional...'
leftSection={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
fz={{ base: 'xs', md: 'sm' }}
px="sm"
py="xs"
/>
</Group>
</Group>
<Group justify='space-between' hiddenFrom='md'>
<Title order={2} visibleFrom="md" size="lg" lh={1.2}>
Fasilitas Kesehatan
</Title>
<Title order={2} hiddenFrom="md" size="md" lh={1.2}>
Fasilitas Kesehatan
</Title>
<Group gap="xs" justify="flex-start">
<Tooltip label="List Dokter" withArrow>
<ActionIcon
onClick={() => router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis')}
size="lg"
radius="xl"
color="green.6"
>
<IconReportMedical size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label="List Tarif Layanan" withArrow>
<ActionIcon
onClick={() => router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan')}
size="lg"
radius="xl"
color="blue.6"
>
<IconCoin size={20} />
</ActionIcon>
</Tooltip>
<TextInput
radius="lg"
placeholder='Cari nama, alamat, atau jam operasional...'
leftSection={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
fz={{ base: 'xs', md: 'sm' }}
px="sm"
py="xs"
/>
</Group>
</Group>
</Paper>
<ListFasilitasKesehatan search={search} /> <ListFasilitasKesehatan search={search} />
</Box> </Box>
); );
} }
function ListFasilitasKesehatan({ search }: { search: string }) { function ListFasilitasKesehatan({ search }: { search: string }) {
const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.fasilitasKesehatan) const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.fasilitasKesehatan);
const router = useRouter(); const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = stateFasilitasKesehatan.findMany; const { data, page, totalPages, loading, load } = stateFasilitasKesehatan.findMany;
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, search); load(page, 10, debouncedSearch);
}, [page, search]); }, [page, debouncedSearch]);
const filteredData = data || []; const filteredData = data || [];
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py="lg">
<Skeleton height={600} radius="md" /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
) );
} }
return ( return (
<Box py={10}> <Box py="lg">
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
{/* Judul + Tombol Tambah */}
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={4}>Daftar Fasilitas Kesehatan</Title> <Title order={3} visibleFrom="md" size="md" lh={1.2}>
Daftar Fasilitas Kesehatan
</Title>
<Title order={3} hiddenFrom="md" size="sm" lh={1.2}>
Daftar Fasilitas Kesehatan
</Title>
<Button <Button
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
@@ -82,19 +162,21 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
'/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/create' '/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/create'
) )
} }
fz={{ base: 'sm', md: 'md' }}
px="sm"
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Group> </Group>
{/* Tabel */} {/* Desktop Table */}
<Box style={{ overflowX: "auto" }}> <Box visibleFrom="md">
<Table highlightOnHover> <Table highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>Fasilitas Kesehatan</TableTh> <TableTh>Fasilitas Kesehatan</TableTh>
<TableTh>Dokter</TableTh> <TableTh>Jumlah Dokter</TableTh>
<TableTh>Layanan</TableTh> <TableTh>Jumlah Layanan</TableTh>
<TableTh>Aksi</TableTh> <TableTh>Aksi</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
@@ -103,23 +185,23 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
filteredData.map((item) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Box w={150}> <Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}>
<Text fw={500} truncate="end" lineClamp={1}> {item.name}
{item.name} </Text>
</Text>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={150}> <Text fz="md" lh={1.5}>
{item.dokterdantenagamedis?.name || '-'} {item.dokterdantenagamedis?.length
</Box> ? `${item.dokterdantenagamedis.length} dokter`
: '-'}
</Text>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={150}> <Text fz="md" lh={1.5}>
<Text truncate="end" lineClamp={1}> {item.tarifdanlayanan?.length
{item.tarifdanlayanan?.layanan || '-'} ? `${item.tarifdanlayanan.length} layanan`
</Text> : '-'}
</Box> </Text>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button <Button
@@ -130,6 +212,9 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${item.id}` `/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${item.id}`
) )
} }
fz="sm"
px="sm"
h={36}
> >
<IconDeviceImacCog size={20} /> <IconDeviceImacCog size={20} />
<Text ml={5}>Detail</Text> <Text ml={5}>Detail</Text>
@@ -140,8 +225,8 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={4}>
<Center py={20}> <Center py="xl">
<Text color="dimmed"> <Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada fasilitas kesehatan yang cocok Tidak ada fasilitas kesehatan yang cocok
</Text> </Text>
</Center> </Center>
@@ -151,6 +236,65 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Card View */}
<Stack gap="sm" hiddenFrom="md">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="md" radius="md">
<Box mb="xs">
<Text fz="xs" fw={600} lh={1.4}>
Fasilitas Kesehatan
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.name}
</Text>
</Box>
<Box mb="xs">
<Text fz="xs" fw={600} lh={1.4}>
Jumlah Dokter
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.dokterdantenagamedis?.length
? `${item.dokterdantenagamedis.length} dokter`
: '-'}
</Text>
</Box>
<Box mb="xs">
<Text fz="xs" fw={600} lh={1.4}>
Jumlah Layanan
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.tarifdanlayanan?.length
? `${item.tarifdanlayanan.length} layanan`
: '-'}
</Text>
</Box>
<Button
variant="light"
color="blue"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${item.id}`
)
}
fullWidth
fz="sm"
mt="md"
>
<IconDeviceImacCog size={20} />
<Text ml={5}>Detail</Text>
</Button>
</Paper>
))
) : (
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada fasilitas kesehatan yang cocok
</Text>
</Center>
)}
</Stack>
</Paper> </Paper>
{/* Pagination */} {/* Pagination */}
@@ -169,7 +313,7 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
/> />
</Center> </Center>
</Box> </Box>
) );
} }
export default FasilitasKesehatan; export default FasilitasKesehatan;

View File

@@ -0,0 +1,173 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title
} from '@mantine/core';
import { IconArrowBack } 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 EditTarifLayanan() {
const editState = useProxy(fasilitasKesehatanState.tarif);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
tarif: '',
layanan: ''
});
const [formData, setFormData] = useState({
tarif: '',
layanan: ''
});
useEffect(() => {
const loadTarifLayanan = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await editState.update.load(id);
if (data) {
setFormData({
tarif: data.tarif || '',
layanan: data.layanan || '',
});
setOriginalData({
tarif: data.tarif || '',
layanan: data.layanan || '',
});
}
} catch (error) {
console.error('Error loading tarif layanan:', error);
toast.error('Gagal memuat data tarif layanan');
}
};
loadTarifLayanan();
}, [params?.id]);
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleResetForm = () => {
setFormData({
tarif: originalData.tarif,
layanan: originalData.layanan,
});
toast.info('Form dikembalikan ke data awal');
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// update global state hanya saat submit
editState.update.form = {
...editState.update.form,
tarif: formData.tarif,
layanan: formData.layanan,
};
await editState.update.submit();
toast.success('Tarif Layanan berhasil diperbarui!');
router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan');
} catch (error) {
console.error('Error updating tarif layanan:', error);
toast.error('Terjadi kesalahan saat memperbarui tarif layanan');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
{/* 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">
Edit Tarif Layanan
</Title>
</Group>
{/* Form Wrapper */}
<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
name="layanan"
label="Layanan"
placeholder="Masukkan nama layanan"
value={formData.layanan}
onChange={(e) => handleChange('layanan', e.target.value)}
required
/>
<TextInput
name="tarif"
label="Tarif"
placeholder="Masukkan tarif layanan"
value={formData.tarif}
onChange={(e) => handleChange('tarif', e.target.value)}
required
/>
<Group justify="right">
<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 EditTarifLayanan;

View File

@@ -0,0 +1,119 @@
'use client';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function CreateTarifLayanan() {
const createState = useProxy(fasilitasKesehatanState.tarif);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
createState.create.form = {
tarif: '',
layanan: '',
};
};
const handleSubmit = async () => {
setIsSubmitting(true);
try {
await createState.create.create();
resetForm();
router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan');
} catch (error) {
console.error('Error creating tarif layanan:', error);
toast.error('Gagal menambahkan tarif layanan');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header dengan back button */}
<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 Tarif Layanan
</Title>
</Group>
{/* Form utama */}
<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="Layanan"
placeholder="Masukkan nama layanan"
value={createState.create.form.layanan || ''}
onChange={(e) => (createState.create.form.layanan = e.target.value)}
required
/>
<TextInput
label="Tarif"
placeholder="Masukkan tarif"
value={createState.create.form.tarif || ''}
onChange={(e) => (createState.create.form.tarif = e.target.value)}
required
/>
<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 CreateTarifLayanan;

View File

@@ -1,29 +1,28 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconDeviceImacCog, IconSearch } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '@/app/admin/(dashboard)/_com/header'; import HeaderSearch from '@/app/admin/(dashboard)/_com/header';
import JudulList from '@/app/admin/(dashboard)/_com/judulList'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan'; import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import { useState } from 'react'; import { useState } from 'react';
function TarifLayanan() { function TarifLayanan() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const router = useRouter(); const router = useRouter();
return ( return (
<Box> <Box>
<Box mb={10}> <Box mb="sm">
<Button variant="subtle" onClick={() => router.back()}> <Button variant="subtle" onClick={() => router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan')}>
<IconArrowBack color={colors['blue-button']} size={25} /> <IconArrowBack color={colors['blue-button']} size={25} />
</Button> </Button>
</Box> </Box>
<HeaderSearch <HeaderSearch
title='Dokter dan Tenaga Medis' title='Tarif dan Layanan'
placeholder='pencarian' placeholder='pencarian'
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
@@ -35,78 +34,234 @@ function TarifLayanan() {
} }
function ListTarifLayanan({ search }: { search: string }) { function ListTarifLayanan({ search }: { search: string }) {
const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.dokter) const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.tarif);
const router = useRouter(); const router = useRouter();
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [debouncedSearch] = useDebouncedValue(search, 10000);
const { const {
data, data,
loading, loading,
load, load,
page, page,
totalPages totalPages
} = stateFasilitasKesehatan.findMany } = stateFasilitasKesehatan.findMany;
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, search) load(page, 10, debouncedSearch);
}, [page, search]) }, [page, debouncedSearch]);
const filteredData = data || [] const handleDelete = () => {
if (selectedId) {
stateFasilitasKesehatan.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
load(page, 10, debouncedSearch);
}
};
const filteredData = data || [];
if (loading || !data) { if (loading || !data) {
return ( return (
<Box py={10}> <Box py="lg">
<Skeleton h={500} /> <Skeleton h={500} />
</Box> </Box>
) );
} }
return ( return (
<Box py={10}> <Box py="lg">
<Paper bg={colors['white-1']} p={'md'}> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Stack> {/* Judul + Tombol Tambah */}
<JudulList <Group justify="space-between" mb="md">
title='List Fasilitas Kesehatan' <Title order={4} lh={1.2}>
href={`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/create`} Daftar Tarif dan Layanan
/> </Title>
<Box style={{ overflowX: "auto" }}> <Button
<Table striped withRowBorders withTableBorder style={{ minWidth: '700px' }}> leftSection={<IconPlus size={18} />}
<TableThead> color="blue"
<TableTr> variant="light"
<TableTh>Fasilitas Kesehatan</TableTh> onClick={() =>
<TableTh>Alamat</TableTh> router.push(
<TableTh>Jam Operasional</TableTh> '/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan/create'
<TableTh>Detail</TableTh> )
</TableTr> }
</TableThead> >
<TableTbody> Tambah Baru
{filteredData.map((item) => ( </Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md">
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh>
<Text fz="sm" fw={600} lh={1.4} ta="left">
Layanan
</Text>
</TableTh>
<TableTh>
<Text fz="sm" fw={600} lh={1.4} ta="left">
Tarif
</Text>
</TableTh>
<TableTh>
<Text fz="sm" fw={600} lh={1.4} ta="center">
Edit
</Text>
</TableTh>
<TableTh>
<Text fz="sm" fw={600} lh={1.4} ta="center">
Hapus
</Text>
</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd>{item.name}</TableTd>
<TableTd>{item.specialist}</TableTd>
<TableTd> <TableTd>
<Text dangerouslySetInnerHTML={{ __html: item.jadwal }} /> <Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}>
{item.layanan || '-'}
</Text>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${item.id}`)}> <Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}>
<IconDeviceImacCog size={25} /> {item.tarif}
</Text>
</TableTd>
<TableTd ta="center">
<Button
variant="light"
color="green"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan/${item.id}`
)
}
size="compact-sm"
>
<IconEdit size={18} />
</Button>
</TableTd>
<TableTd ta="center">
<Button
variant="light"
color="red"
disabled={stateFasilitasKesehatan.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
size="compact-sm"
>
<IconTrash size={18} />
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
</TableTbody> ) : (
</Table> <TableTr>
</Box> <TableTd colSpan={4}>
</Stack> <Center py="lg">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada fasilitas kesehatan yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Cards */}
<Box hiddenFrom="md">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="md" mb="xs" radius="sm">
<Box mb="xs">
<Text fz="sm" fw={600} lh={1.4}>
Layanan
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.layanan || '-'}
</Text>
</Box>
<Box mb="md">
<Text fz="sm" fw={600} lh={1.4}>
Tarif
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.tarif}
</Text>
</Box>
<Group justify="center" gap="xs">
<Button
variant="light"
color="green"
size="compact-xs"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan/${item.id}`
)
}
>
<IconEdit size={16} />
</Button>
<Button
variant="light"
color="red"
size="compact-xs"
disabled={stateFasilitasKesehatan.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={16} />
</Button>
</Group>
</Paper>
))
) : (
<Center py="lg">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada fasilitas kesehatan yang cocok
</Text>
</Center>
)}
</Box>
</Paper> </Paper>
{/* Pagination */}
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => load(newPage)} // ini penting! onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages} total={totalPages}
mt="md" mt="md"
mb="md" mb="md"
color="blue"
radius="md"
/> />
</Center> </Center>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleDelete}
text="Apakah anda yakin ingin menghapus tarif layanan ini?"
/>
</Box> </Box>
) );
} }
export default TarifLayanan; export default TarifLayanan;

View File

@@ -1,255 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable react-hooks/exhaustive-deps */
'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 { useMediaQuery, useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Bar, BarChart, Tooltip as ChartTooltip, Legend, XAxis, YAxis } from 'recharts';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import grafikkepuasan from '../../../_state/kesehatan/data_kesehatan_warga/grafikKepuasan';
function GrafikHasilKepuasanMasyarakat() {
const [search, setSearch] = useState("");
return (
<Box>
{/* Header Search */}
<HeaderSearch
title='Grafik Hasil Kepuasan Masyarakat'
placeholder='Cari nama atau alamat...'
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListGrafikHasilKepuasanMasyarakat search={search} />
</Box>
);
}
function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
type PDKMGrafik = {
id: string;
nama: string;
tanggal: string | Date;
jenisKelamin: string;
alamat: string;
penyakit: string;
};
const stateGrafikKepuasan = useProxy(grafikkepuasan);
const [chartData, setChartData] = useState<PDKMGrafik[]>([]);
const [mounted, setMounted] = useState(false);
const isTablet = useMediaQuery('(max-width: 1024px)');
const isMobile = useMediaQuery('(max-width: 768px)');
const router = useRouter();
const { data, page, totalPages, loading, load } = stateGrafikKepuasan.findMany;
useShallowEffect(() => {
setMounted(true);
load(page, 10, search);
}, [page, search]);
useEffect(() => {
if (data) {
setChartData(data.map((item) => ({
...item,
tanggal: item.tanggal instanceof Date ? item.tanggal.toISOString() : item.tanggal
})));
}
}, [data]);
const processDiseaseData = (data: PDKMGrafik[]) => {
const diseaseCount: Record<string, number> = {};
data.forEach(item => {
const penyakit = item.penyakit.trim();
if (penyakit) {
diseaseCount[penyakit] = (diseaseCount[penyakit] || 0) + 1;
}
});
return Object.entries(diseaseCount).map(([name, count]) => ({ name, count }));
};
const [diseaseChartData, setDiseaseChartData] = useState<{ name: string, count: number }[]>([]);
useEffect(() => {
if (data && data.length > 0) {
setDiseaseChartData(processDiseaseData(data));
}
}, [data]);
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">
{/* Judul + Tombol Tambah */}
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Grafik Hasil Kepuasan Masyarakat</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push(
'/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan/create'
)
}
>
Tambah Baru
</Button>
</Group>
{/* Tabel */}
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Nama</TableTh>
<TableTh>Tanggal</TableTh>
<TableTh>Jenis Kelamin</TableTh>
<TableTh>Penyakit</TableTh>
<TableTh>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Box w={150}>
{item.nama}
</Box>
</TableTd>
<TableTd>
<Box w={150}>
{new Date(item.tanggal).toLocaleDateString('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric',
})}
</Box>
</TableTd>
<TableTd>
<Box w={150}>
{item.jenisKelamin}
</Box>
</TableTd>
<TableTd>
<Box w={150}>
{item.penyakit}
</Box>
</TableTd>
<TableTd>
<Button
variant="light"
color="blue"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan/${item.id}`
)
}
>
<IconDeviceImacCog size={20} />
<Text ml={5}>Detail</Text>
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={5}>
<Center py={20}>
<Text color="dimmed">
Tidak ada data kepuasan masyarakat yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
</Paper>
{/* Pagination */}
<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>
{/* 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>
{mounted && diseaseChartData.length > 0 ? (
<Center>
<BarChart
width={isMobile ? 320 : isTablet ? 600 : 800} // kecilin biar muat
height={350}
data={diseaseChartData}
>
<XAxis
dataKey="name"
tick={{ fontSize: 12 }}
interval={0}
angle={-45}
textAnchor="end"
height={70}
/>
<YAxis />
<ChartTooltip />
<Legend />
<Bar dataKey="count" fill={colors['blue-button']} name="Jumlah Kasus" />
</BarChart>
</Center>
) : (
<Text c="dimmed">Belum ada data untuk ditampilkan dalam grafik</Text>
)}
</Paper>
</Box>
</Box>
);
}
export default GrafikHasilKepuasanMasyarakat;

View File

@@ -145,7 +145,7 @@ function EditJadwalKegiatan() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">

View File

@@ -40,7 +40,7 @@ function DetailJadwalKegiatan() {
const data = stateJadwalKegiatan.findUnique.data const data = stateJadwalKegiatan.findUnique.data
return ( return (
<Box py={10}> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Tombol Back */} {/* Tombol Back */}
<Button <Button
variant="subtle" variant="subtle"
@@ -54,7 +54,7 @@ function DetailJadwalKegiatan() {
{/* Wrapper Detail */} {/* Wrapper Detail */}
<Paper <Paper
withBorder withBorder
w={{ base: "100%", md: "50%" }} w={{ base: "100%", md: "70%" }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"

View File

@@ -65,7 +65,7 @@ function CreateJadwalKegiatan() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md" component="form" onSubmit={handleSubmit}> <Box px={{ base: 0, md: 'lg' }} py="xs" component="form" onSubmit={handleSubmit}>
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button <Button

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