Compare commits

..

27 Commits

Author SHA1 Message Date
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
312 changed files with 15712 additions and 7852 deletions

View File

@@ -3,11 +3,12 @@ module.exports = {
'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
@@ -792,14 +793,12 @@ model FasilitasKesehatan {
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(fields: [tarifDanLayananId], references: [id]) tarifdanlayanan TarifDanLayanan[] @relation("Tarif")
tarifDanLayananId String
} }
model InformasiUmum { model InformasiUmum {
@@ -829,11 +828,16 @@ model DokterdanTenagaMedis {
name String name String
specialist String specialist String
jadwal String jadwal String
jadwalLibur String?
jamBukaOperasional String?
jamTutupOperasional String?
jamBukaLibur String?
jamTutupLibur 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)
FasilitasKesehatan FasilitasKesehatan[] 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,7 +71,8 @@ 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 = "") => {
// Change to arrow function
programInovasi.findMany.loading = true; // Use the full path to access the property 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;
@@ -82,7 +83,7 @@ const programInovasi = proxy({
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) {
@@ -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,7 +500,8 @@ 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 = "") => {
// Change to arrow function
mediaSosial.findMany.loading = true; // Use the full path to access the property 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;
@@ -500,9 +509,7 @@ const mediaSosial = proxy({
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,
}); });
@@ -617,12 +624,16 @@ const mediaSosial = proxy({
this.id = data.id; this.id = data.id;
this.form = { this.form = {
name: data.name || "", name: data.name || "",
imageId: data.imageId || "", imageId: data.imageId || null,
iconUrl: data.iconUrl || "", iconUrl: data.iconUrl || "",
icon: data.icon || null,
}; };
return data; return data;
} else { } else {
throw new Error(result?.message || "Gagal mengambil data media sosial"); throw new Error(
result?.message || "Gagal mengambil data media sosial"
);
} }
} catch (error) { } catch (error) {
console.error((error as Error).message); console.error((error as Error).message);
@@ -645,7 +656,9 @@ const mediaSosial = proxy({
try { try {
mediaSosial.update.loading = true; mediaSosial.update.loading = true;
const response = await fetch(`/api/landingpage/mediasosial/${this.id}`, { const response = await fetch(
`/api/landingpage/mediasosial/${this.id}`,
{
method: "PUT", method: "PUT",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -654,8 +667,10 @@ 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) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));

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,14 +7,20 @@ 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: {
@@ -21,44 +28,58 @@ const jenisInformasiDiminta = proxy({
| 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 =
await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi[
"find-many"
].get();
if (res.status === 200) { if (res.status === 200) {
jenisInformasiDiminta.findMany.data = res.data?.data ?? []; 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<{
omit: { isActive: true };
}>[],
async load() { async load() {
const res = await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi["find-many"].get(); const res =
await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi[
"find-many"
].get();
if (res.status === 200) { if (res.status === 200) {
caraMemperolehInformasi.findMany.data = res.data?.data ?? []; 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<{
omit: { isActive: true };
}>[],
async load() { async load() {
const res = await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi["find-many"].get(); const res =
await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi[
"find-many"
].get();
if (res.status === 200) { if (res.status === 200) {
caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? []; caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? [];
} }
} },
} },
}) });
console.log(caraMemperolehSalinanInformasi) console.log(caraMemperolehSalinanInformasi);
type PermohonanInformasiPublikForm = Prisma.PermohonanInformasiPublikGetPayload<{ type PermohonanInformasiPublikForm =
Prisma.PermohonanInformasiPublikGetPayload<{
select: { select: {
name: true; name: true;
nik: true; nik: true;
@@ -76,49 +97,92 @@ const statepermohonanInformasiPublik = proxy({
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(
statepermohonanInformasiPublik.create.form
);
if (!cek.success) { if (!cek.success) {
const err = `[${cek.error.issues toast.error(cek.error.issues.map((i) => i.message).join("\n"));
.map((v) => `${v.path.join(".")}`) return false; // ⬅️ tambahkan return false
.join("\n")}] required`;
return toast.error(err);
} }
try { try {
statepermohonanInformasiPublik.create.loading = true; statepermohonanInformasiPublik.create.loading = true;
const res = await ApiFetch.api.ppid.permohonaninformasipublik["create"].post(statepermohonanInformasiPublik.create.form); const res = await ApiFetch.api.ppid.permohonaninformasipublik[
if (res.status === 200) { "create"
statepermohonanInformasiPublik.findMany.load(); ].post(statepermohonanInformasiPublik.create.form);
return toast.success("Sukses menambahkan");
if (res.data?.success === false) {
toast.error(res.data?.message);
return false; // ⬅️ gagal
} }
return toast.error("failed create");
} catch (error) { toast.success("Sukses menambahkan");
console.log((error as Error).message); return true; // ⬅️ sukses
} catch {
toast.error("Terjadi kesalahan server");
return false;
} finally { } finally {
statepermohonanInformasiPublik.create.loading = false; statepermohonanInformasiPublik.create.loading = false;
} }
} },
}, },
findMany: { findMany: {
data: null as data: null as
| Prisma.PermohonanInformasiPublikGetPayload<{ include: { | Prisma.PermohonanInformasiPublikGetPayload<{
caraMemperolehSalinanInformasi: true, include: {
jenisInformasiDiminta: true, caraMemperolehSalinanInformasi: true;
caraMemperolehInformasi: true, jenisInformasiDiminta: true;
} }>[] caraMemperolehInformasi: true;
};
}>[]
| null, | null,
async load() { page: 1,
const res = await ApiFetch.api.ppid.permohonaninformasipublik["find-many"].get(); totalPages: 1,
if (res.status === 200) { total: 0,
statepermohonanInformasiPublik.findMany.data = res.data?.data ?? []; loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
// Change to arrow function
statepermohonanInformasiPublik.findMany.loading = true; // Use the full path to access the property
statepermohonanInformasiPublik.findMany.page = page;
statepermohonanInformasiPublik.findMany.search = search;
try {
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: { findUnique: {
data: null as Prisma.PermohonanInformasiPublikGetPayload<{ data: null as Prisma.PermohonanInformasiPublikGetPayload<{
include: { include: {
jenisInformasiDiminta: true, jenisInformasiDiminta: true;
caraMemperolehInformasi: true, caraMemperolehInformasi: true;
caraMemperolehSalinanInformasi: true, caraMemperolehSalinanInformasi: true;
}; };
}> | null, }> | null,
async load(id: string) { async load(id: string) {
@@ -137,14 +201,13 @@ const statepermohonanInformasiPublik = proxy({
} }
}, },
}, },
});
})
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";
@@ -7,11 +8,15 @@ 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
.string()
.min(3, "Nomor Telepon minimal 3 karakter")
.max(15, "Nomor Telepon maksimal 15 angka"),
alasan: z.string().min(3, "Alasan minimal 3 karakter"), 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;
@@ -25,23 +30,28 @@ const permohonanKeberatanInformasi = proxy({
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(
permohonanKeberatanInformasi.create.form
);
if (!cek.success) { if (!cek.success) {
const err = `[${cek.error.issues toast.error(cek.error.issues.map((i) => i.message).join("\n"));
.map((v) => `${v.path.join(".")}`) return false; // ⬅️ tambahkan return false
.join("\n")}] required`;
return toast.error(err);
} }
try { try {
permohonanKeberatanInformasi.create.loading = true; permohonanKeberatanInformasi.create.loading = true;
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["create"].post(permohonanKeberatanInformasi.create.form); const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[
if (res.status === 200) { "create"
permohonanKeberatanInformasi.findMany.load(); ].post(permohonanKeberatanInformasi.create.form);
return toast.success("Sukses menambahkan"); if (res.data?.success === false) {
toast.error(res.data?.message);
return false; // ⬅️ gagal
} }
return toast.error("failed create");
} catch (error) { toast.success("Sukses menambahkan");
console.log((error as Error).message); return true; // ⬅️ sukses
} catch {
toast.error("Terjadi kesalahan server");
return false;
} finally { } finally {
permohonanKeberatanInformasi.create.loading = false; permohonanKeberatanInformasi.create.loading = false;
} }
@@ -49,15 +59,50 @@ const permohonanKeberatanInformasi = proxy({
}, },
findMany: { findMany: {
data: null as data: null as
| Prisma.FormulirPermohonanKeberatanGetPayload<{omit: {isActive: true}}>[] | null
| null, | Prisma.FormulirPermohonanKeberatanGetPayload<{
async load() { omit: { isActive: true };
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["find-many"].get(); }>[],
if (res.status === 200) { page: 1,
permohonanKeberatanInformasi.findMany.data = res.data?.data ?? []; 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: { findUnique: {
data: null as Prisma.FormulirPermohonanKeberatanGetPayload<{ data: null as Prisma.FormulirPermohonanKeberatanGetPayload<{
omit: { omit: {
@@ -66,12 +111,17 @@ const permohonanKeberatanInformasi = proxy({
}> | null, }> | null,
async load(id: string) { async load(id: string) {
try { try {
const res = await fetch(`/api/ppid/permohonankeberataninformasipublik/${id}`); const res = await fetch(
`/api/ppid/permohonankeberataninformasipublik/${id}`
);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
permohonanKeberatanInformasi.findUnique.data = data.data ?? null; permohonanKeberatanInformasi.findUnique.data = data.data ?? null;
} else { } else {
console.error("Failed to fetch permohonan keberatan informasi:", res.statusText); console.error(
"Failed to fetch permohonan keberatan informasi:",
res.statusText
);
permohonanKeberatanInformasi.findUnique.data = null; permohonanKeberatanInformasi.findUnique.data = null;
} }
} catch (error) { } catch (error) {
@@ -79,8 +129,7 @@ const permohonanKeberatanInformasi = proxy({
permohonanKeberatanInformasi.findUnique.data = null; 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
@@ -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

@@ -31,7 +31,7 @@ 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();
@@ -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();
// ✅ Client-side redirect
setTimeout(() => {
window.location.href = '/waiting-room'; 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 }),
@@ -200,7 +207,7 @@ export default function Validasi() {
// 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

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

View File

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

View File

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

View File

@@ -1,157 +1,163 @@
"use client"; '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 { 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">
<Flex justify="space-between" align="center" wrap="wrap" gap="md">
<Title order={2} fw={700}>
Galeri Foto
</Title>
<TextInput
radius="xl"
size="md"
placeholder="Cari foto berdasarkan nama..."
leftSection={<IconSearch size={18} />}
rightSection={
<ActionIcon
variant="light"
color="gray"
radius="xl"
onClick={() => stateFileStorage.load()}
>
<IconX size={18} />
</ActionIcon>
}
onChange={(e) => {
if (timeOut) clearTimeout(timeOut);
timeOut = setTimeout(() => {
stateFileStorage.load({ search: e.target.value });
}, 300);
}}
/>
</Flex>
<Paper withBorder radius="lg" p="md" shadow="sm">
{list && list.length > 0 ? (
<SimpleGrid
cols={{ base: 2, sm: 3, md: 5, lg: 8 }}
spacing="md"
verticalSpacing="md"
>
{list.map((v, k) => (
<Card
key={k}
withBorder
radius="md"
shadow="sm"
className="hover:shadow-md transition-all duration-200"
>
<Stack gap="xs">
<motion.div
onClick={() => {
navigator.clipboard.writeText(v.url);
toast("Tautan foto berhasil disalin");
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
style={{ cursor: "pointer" }}
>
<Image
src={`${v.url}?size=200`}
alt={v.name}
radius="md"
h={120}
fit="cover"
loading="lazy"
/>
</motion.div>
<Box> <Box>
<Text size="sm" fw={500} lineClamp={2}> <HeaderSearch
{v.name} title='Foto'
</Text> placeholder='Cari judul atau deskripsi foto...'
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListFoto search={search} />
</Box> </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 {
data,
page,
totalPages,
loading,
load,
} = FotoState.findMany;
useShallowEffect(() => {
load(page, 10, search)
}, [page, search])
const filteredData = data || []
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
)
}
return (
<Box py={10}>
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Foto</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/gallery/foto/create')}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh style={{ width: '25%' }}>Judul Foto</TableTh>
<TableTh style={{ width: '20%' }}>Tanggal</TableTh>
<TableTh style={{ width: '30%' }}>Deskripsi</TableTh>
<TableTh style={{ width: '15%' }}>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd style={{ width: '25%' }}>
<Box w={200}>
<Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text>
</Box>
</TableTd>
<TableTd style={{ width: '20%' }}>
<Box w={200}>
<Text fz="sm" c="dimmed">
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
</Box>
</TableTd>
<TableTd style={{ width: '30%' }}>
<Box w={200}>
<Text fz="sm" truncate="end" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Box>
</TableTd>
<TableTd style={{ width: '15%' }}>
<Button
variant="light"
color="blue"
onClick={() => router.push(`/admin/desa/gallery/foto/${item.id}`)}
>
<IconDeviceImac size={20} />
<Text ml={5}>Detail</Text>
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text c="dimmed">Tidak ada foto yang cocok</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10)
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
</Box>
);
}
export default Foto;

View File

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

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');
} }
@@ -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>

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);

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;
} }
@@ -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');
} }
@@ -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');
} }
@@ -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

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

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');

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");
} }
}; };
@@ -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');

View File

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

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);

View File

@@ -41,7 +41,7 @@ function Page() {
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>

View File

@@ -44,20 +44,58 @@ 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 () => {
const { embedMapUrl } = polsekState.create.form;
// ✅ Validasi Google Maps Embed URL (jika diisi)
if (embedMapUrl && !isValidGoogleMapsEmbed(embedMapUrl)) {
toast.error("URL embed peta tidak valid. Harap paste iframe dari Google Maps.");
return;
}
try { try {
setIsSubmitting(true); setIsSubmitting(true);
await polsekState.create.create(); await polsekState.create.create();
resetForm(); resetForm();
router.push("/admin/keamanan/polsek-terdekat"); router.push("/admin/keamanan/polsek-terdekat");
} catch (error) { } catch (error) {
console.error(error) console.error(error);
toast.error("Gagal menambah polsek terdekat"); toast.error("Gagal menambah polsek terdekat");
} finally { } finally {
setIsSubmitting(false); 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 () => {
try { try {
const res = await fetch("/api/keamanan/layanan-polsek/find-many"); const res = await fetch("/api/keamanan/layanan-polsek/find-many");
@@ -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

@@ -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} />
}, },
{ {

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: '' },
fasilitasPendukung: { content: '' },
prosedurPendaftaran: { content: '' },
tarifDanLayanan: { layanan: '', tarif: '' },
});
// Helper untuk update nested state
const updateForm = <K extends keyof FasilitasKesehatanFormBase>(
key: K,
value: FasilitasKesehatanFormBase[K]
) => setFormData(prev => ({ ...prev, [key]: value }));
const updateNested = <
K extends keyof FasilitasKesehatanFormBase,
N extends keyof FasilitasKesehatanFormBase[K]
>(key: K, nestedKey: N, value: FasilitasKesehatanFormBase[K][N]) =>
setFormData(prev => ({
...prev,
[key]: { ...prev[key] as object, [nestedKey]: value },
}));
const deepClone = (obj: any): any => {
try {
return JSON.parse(JSON.stringify(obj));
} catch (error) {
console.warn('Gagal deep clone dengan JSON fallback:', error);
return obj; // fallback (berisiko shared reference)
}
};
// Load data
useEffect(() => {
const load = async () => {
const id = params?.id as string;
if (!id) return; if (!id) return;
try { // Load dokter & tarif (untuk opsi MultiSelect)
await Promise.all([
dokterState.findMany.load(),
tarifState.findMany.load(),
]);
// Load data fasilitas
await state.edit.load(id); await state.edit.load(id);
const loadedData = state.edit.form; const loaded = state.edit.form;
if (loaded) {
if (!loadedData) { setFormData({
toast.error('Data tidak ditemukan'); name: loaded.name,
return; informasiUmum: loaded.informasiUmum,
} layananUnggulan: loaded.layananUnggulan,
dokterdanTenagaMedis: loaded.dokterdanTenagaMedis || [],
// Gunakan JSON fallback untuk deep clone fasilitasPendukung: loaded.fasilitasPendukung,
const clonedData = deepClone(loadedData) as FasilitasKesehatanFormBase; prosedurPendaftaran: loaded.prosedurPendaftaran,
tarifDanLayanan: loaded.tarifDanLayanan || [],
setFormData(clonedData); });
setOriginalData(clonedData);
} catch (err) {
console.error(err);
toast.error('Gagal memuat data fasilitas kesehatan');
} }
}; };
load(); loadAll();
}, [params?.id]); }, [params?.id]);
const handleResetForm = () => { const updateForm = <K extends keyof EditFasilitasKesehatanForm>(
setFormData({ field: K,
name: originalData.name, value: EditFasilitasKesehatanForm[K]
informasiUmum: ) => {
{ setFormData(prev => ({ ...prev, [field]: value }));
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 handleReset = () => {
const handleSubmit = async () => { 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');
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
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: 'sm', md: 'lg' }} py="md" 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
label="Specialist"
value={formData.dokterdanTenagaMedis.specialist}
onChange={(e) =>
updateNested('dokterdanTenagaMedis', 'specialist', e.target.value)
} }
value={formData.dokterdanTenagaMedis}
onChange={(val) => updateForm('dokterdanTenagaMedis', val)}
searchable
clearable
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)} })) || []
}
value={formData.tarifDanLayanan}
onChange={(val) => updateForm('tarifDanLayanan', val)}
searchable
clearable
required
/> />
<TextInput
label="Layanan"
value={formData.tarifDanLayanan.layanan}
onChange={(e) => updateNested('tarifDanLayanan', 'layanan', e.target.value)}
/>
</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',

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';
@@ -108,21 +114,79 @@ function DetailFasilitasKesehatan() {
</Box> </Box>
<Box> <Box>
<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 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,6 +64,11 @@ 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: 'sm', md: 'lg' }} py="md" component="form" onSubmit={handleSubmit}>
{/* Header */} {/* Header */}
@@ -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,
})) || []
}
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis}
onChange={(val: string[]) => {
stateFasilitasKesehatan.create.form.dokterdanTenagaMedis = val;
}}
searchable
clearable
required required
/> />
<TextInput
label="Spesialis"
placeholder="Masukkan spesialis"
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.specialist}
onChange={(e) => (stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.specialist = e.target.value)}
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: 'sm', md: 'lg' }} py="md">
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 Page() { 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 ( return (
<div> <Stack py={10}>
Page <Skeleton height={500} radius="md" />
</div> </Stack>
); );
} }
export default Page; const data = state.findUnique.data;
return (
<Box py={10}>
{/* Tombol Back */}
<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 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 () => {
try {
setIsSubmitting(true);
await createState.create.create.create(); await createState.create.create.create();
toast.success('Data berhasil disimpan');
resetForm(); resetForm();
router.push(`/admin/kesehatan/fasilitas-kesehatan/${params?.id}/dokter-tenaga-medis`) 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: 'sm', md: 'lg' }} py="md" 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"
placeholder="Masukkan jadwal"
value={createState.create.create.form.jadwal} value={createState.create.create.form.jadwal}
onChange={(htmlContent) => { onChange={(e) => (createState.create.create.form.jadwal = e.target.value)}
createState.create.create.form.jadwal = htmlContent; required
}}
/> />
</Box>
<Button onClick={handleSubmit} bg={colors['blue-button']}> <TextInput
Simpan 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> </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> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -1,13 +1,12 @@
'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 { 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';
@@ -18,7 +17,7 @@ function DokterTenagaMedis() {
return ( return (
<Box> <Box>
<Box mb={10}> <Box mb={10}>
<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>
@@ -60,49 +59,101 @@ function ListDokterTenagaMedis({ search }: { search: string }) {
} }
return ( return (
<Box py={10}> <Box py={10}>
<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}>Daftar Dokter dan Tenaga Medis</Title>
href={`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/create`} <Button
/> leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push(
'/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/create'
)
}
>
Tambah Baru
</Button>
</Group>
{/* Tabel */}
<Box style={{ overflowX: "auto" }}> <Box style={{ overflowX: "auto" }}>
<Table striped withRowBorders withTableBorder style={{ minWidth: '700px' }}> <Table highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>Fasilitas Kesehatan</TableTh> <TableTh>Nama Dokter</TableTh>
<TableTh>Alamat</TableTh> <TableTh>Spesialis</TableTh>
<TableTh>Jam Operasional</TableTh> <TableTh>Jadwal</TableTh>
<TableTh>Detail</TableTh> <TableTh>Aksi</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.map((item) => ( {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 }} /> <Box w={150}>
<Text fw={500} truncate="end" lineClamp={1}>
{item.name}
</Text>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${item.id}`)}> <Box w={150}>
<IconDeviceImacCog size={25} /> {item.specialist || '-'}
</Box>
</TableTd>
<TableTd>
<Box w={150}>
<Text dangerouslySetInnerHTML={{ __html: item.jadwal || '-' }} />
</Box>
</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={5}>Detail</Text>
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text c="dimmed">
Tidak ada fasilitas kesehatan yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
</Stack>
</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>

View File

@@ -1,9 +1,12 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
ActionIcon,
Box, Box,
Button, Button,
Center, Center,
Grid,
GridCol,
Group, Group,
Pagination, Pagination,
Paper, Paper,
@@ -16,30 +19,52 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title TextInput,
Title,
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { 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 */} <Grid mb={10}>
<HeaderSearch <GridCol span={{ base: 12, md: 8 }}>
title='Fasilitas Kesehatan' <Title order={3}>Fasilitas Kesehatan</Title>
</GridCol>
<GridCol span={{ base: 12, md: 4 }}>
<Group gap={"xs"}>
<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>
<Paper radius="lg" bg={colors['white-1']}>
<TextInput
radius="lg"
placeholder='Cari nama, alamat, atau jam operasional...' placeholder='Cari nama, alamat, atau jam operasional...'
searchIcon={<IconSearch size={20} />} leftSection={<IconSearch size={20} />}
w="133%"
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
/> />
</Paper>
</Group>
</GridCol>
</Grid>
<ListFasilitasKesehatan search={search} /> <ListFasilitasKesehatan search={search} />
</Box> </Box>
@@ -54,6 +79,7 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
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, search);
}, [page, search]); }, [page, search]);
@@ -93,8 +119,8 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
<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>
@@ -111,13 +137,17 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={150}> <Box w={150}>
{item.dokterdantenagamedis?.name || '-'} {item.dokterdantenagamedis?.length
? `${item.dokterdantenagamedis.length} dokter`
: '-'}
</Box> </Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={150}> <Box w={150}>
<Text truncate="end" lineClamp={1}> <Text truncate="end" lineClamp={1}>
{item.tarifdanlayanan?.layanan || '-'} {item.tarifdanlayanan?.length
? `${item.tarifdanlayanan.length} layanan`
: '-'}
</Text> </Text>
</Box> </Box>
</TableTd> </TableTd>
@@ -141,7 +171,7 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={4}>
<Center py={20}> <Center py={20}>
<Text color="dimmed"> <Text c="dimmed">
Tidak ada fasilitas kesehatan yang cocok Tidak ada fasilitas kesehatan yang cocok
</Text> </Text>
</Center> </Center>

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: 'sm', md: 'lg' }} py="md">
{/* 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: 'sm', md: 'lg' }} py="md">
{/* 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,13 +1,13 @@
'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 { 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';
@@ -18,12 +18,12 @@ function TarifLayanan() {
return ( return (
<Box> <Box>
<Box mb={10}> <Box mb={10}>
<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,8 +35,11 @@ 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 { const {
data, data,
loading, loading,
@@ -49,6 +52,15 @@ function ListTarifLayanan({ search }: { search: string }) {
load(page, 10, search) load(page, 10, search)
}, [page, search]) }, [page, search])
const handleDelete = () => {
if (selectedId) {
stateFasilitasKesehatan.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
load(page, 10, search);
}
};
const filteredData = data || [] const filteredData = data || []
if (loading || !data) { if (loading || !data) {
@@ -60,51 +72,116 @@ function ListTarifLayanan({ search }: { search: string }) {
} }
return ( return (
<Box py={10}> <Box py={10}>
<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}>Daftar Tarif dan Layanan</Title>
href={`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/create`} <Button
/> leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push(
'/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan/create'
)
}
>
Tambah Baru
</Button>
</Group>
{/* Tabel */}
<Box style={{ overflowX: "auto" }}> <Box style={{ overflowX: "auto" }}>
<Table striped withRowBorders withTableBorder style={{ minWidth: '700px' }}> <Table highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>Fasilitas Kesehatan</TableTh> <TableTh>Layanan</TableTh>
<TableTh>Alamat</TableTh> <TableTh>Tarif</TableTh>
<TableTh>Jam Operasional</TableTh> <TableTh>Edit</TableTh>
<TableTh>Detail</TableTh> <TableTh>Hapus</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.map((item) => ( {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 }} /> <Box w={150}>
{item.layanan || '-'}
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${item.id}`)}> <Box w={150}>
<IconDeviceImacCog size={25} /> <Text fw={500} truncate="end" lineClamp={1}>
{item.tarif}
</Text>
</Box>
</TableTd>
<TableTd>
<Button
variant="light"
color="green"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan/${item.id}`
)
}
>
<IconEdit size={18} />
</Button>
</TableTd>
<TableTd>
<Button
variant="light"
color="red"
disabled={stateFasilitasKesehatan.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={18} />
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text c="dimmed">
Tidak ada fasilitas kesehatan yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
</Stack>
</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>
) )
} }

View File

@@ -70,8 +70,8 @@ function EditGrafikHasilKepuasan() {
}); });
} }
} catch (err) { } catch (err) {
console.error("Error loading grafik hasil kepuasan:", err); console.error("Error loading penderita penyakit:", err);
toast.error("Gagal memuat data grafik hasil kepuasan"); toast.error("Gagal memuat data penderita penyakit");
} }
}; };
@@ -99,11 +99,11 @@ function EditGrafikHasilKepuasan() {
setIsSubmitting(true); setIsSubmitting(true);
editState.update.form = { ...editState.update.form, ...formData }; editState.update.form = { ...editState.update.form, ...formData };
await editState.update.submit(); await editState.update.submit();
toast.success('Grafik hasil kepuasan berhasil diperbarui!'); toast.success('penderita penyakit berhasil diperbarui!');
router.push('/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan'); router.push('/admin/kesehatan/data-kesehatan-warga/penderita_penyakit');
} catch (err) { } catch (err) {
console.error('Error updating grafik hasil kepuasan:', err); console.error('Error updating penderita penyakit:', err);
toast.error('Terjadi kesalahan saat memperbarui grafik hasil kepuasan'); toast.error('Terjadi kesalahan saat memperbarui penderita penyakit');
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -122,7 +122,7 @@ function EditGrafikHasilKepuasan() {
<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 Grafik Hasil Kepuasan Edit Penderita Penyakit
</Title> </Title>
</Group> </Group>

View File

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

View File

@@ -40,7 +40,7 @@ function CreateGrafikHasilKepuasanMasyarakat() {
setIsSubmitting(true); setIsSubmitting(true);
await stateGrafikKepuasan.create.create(); await stateGrafikKepuasan.create.create();
resetForm(); resetForm();
router.push("/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan"); router.push("/admin/kesehatan/data-kesehatan-warga/penderita_penyakit");
} catch (error) { } catch (error) {
console.error("Error creating grafik kepuasan:", error); console.error("Error creating grafik kepuasan:", error);
toast.error("Terjadi kesalahan saat membuat grafik kepuasan"); toast.error("Terjadi kesalahan saat membuat grafik kepuasan");
@@ -62,7 +62,7 @@ function CreateGrafikHasilKepuasanMasyarakat() {
<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">
Tambah Grafik Hasil Kepuasan Masyarakat Tambah Penderita Penyakit
</Title> </Title>
</Group> </Group>

View File

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

View File

@@ -123,7 +123,7 @@ export default function EditKolaborasiInovasi() {
}; };
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

@@ -1,7 +1,7 @@
'use client' 'use client'
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';
@@ -42,7 +42,7 @@ function DetailSDGSDesa() {
const data = sdgsState.findUnique.data; const data = sdgsState.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()}
@@ -54,7 +54,7 @@ function DetailSDGSDesa() {
<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"
@@ -106,7 +106,7 @@ function DetailSDGSDesa() {
size="md" size="md"
disabled={sdgsState.delete.loading} disabled={sdgsState.delete.loading}
> >
<IconX size={20} /> <IconTrash size={20} />
</Button> </Button>
<Button <Button

View File

@@ -65,7 +65,7 @@ function CreateSDGsDesa() {
} }
} }
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

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

View File

@@ -204,7 +204,7 @@ function EditAPBDes() {
}; };
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} />
@@ -215,7 +215,7 @@ function EditAPBDes() {
</Group> </Group>
<Paper <Paper
w={{ base: '100%', md: '100%' }} w={{ base: '100%', md: '50%' }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"
@@ -368,6 +368,13 @@ function EditAPBDes() {
{ value: '2', label: 'Level 2 (Sub-kelompok)' }, { value: '2', label: 'Level 2 (Sub-kelompok)' },
{ value: '3', label: 'Level 3 (Detail)' }, { value: '3', label: 'Level 3 (Detail)' },
]} ]}
styles={{
option: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
}}
value={String(newItem.level)} value={String(newItem.level)}
onChange={(val) => setNewItem({ ...newItem, level: Number(val) || 1 })} onChange={(val) => setNewItem({ ...newItem, level: Number(val) || 1 })}
/> />
@@ -378,6 +385,13 @@ function EditAPBDes() {
{ value: 'belanja', label: 'Belanja' }, { value: 'belanja', label: 'Belanja' },
{ value: 'pembiayaan', label: 'Pembiayaan' }, { value: 'pembiayaan', label: 'Pembiayaan' },
]} ]}
styles={{
option: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
}}
value={newItem.tipe} value={newItem.tipe}
onChange={(val) => setNewItem({ ...newItem, tipe: (val as any) || 'pendapatan' })} onChange={(val) => setNewItem({ ...newItem, tipe: (val as any) || 'pendapatan' })}
/> />

View File

@@ -65,7 +65,7 @@ function DetailAPBDes() {
}); });
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()}
@@ -77,7 +77,7 @@ function DetailAPBDes() {
<Paper <Paper
withBorder withBorder
w={{ base: '100%', md: '100%' }} w={{ base: '100%', md: '70%' }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"

View File

@@ -155,7 +155,7 @@ function CreateAPBDes() {
}; };
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} />
@@ -353,6 +353,13 @@ function CreateAPBDes() {
{ value: '2', label: 'Level 2 (Sub-kelompok)' }, { value: '2', label: 'Level 2 (Sub-kelompok)' },
{ value: '3', label: 'Level 3 (Detail)' }, { value: '3', label: 'Level 3 (Detail)' },
]} ]}
styles={{
option: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
}}
value={String(newItem.level)} value={String(newItem.level)}
onChange={(val) => setNewItem({ ...newItem, level: Number(val) || 1 })} onChange={(val) => setNewItem({ ...newItem, level: Number(val) || 1 })}
/> />
@@ -361,7 +368,15 @@ function CreateAPBDes() {
data={[ data={[
{ value: 'pendapatan', label: 'Pendapatan' }, { value: 'pendapatan', label: 'Pendapatan' },
{ value: 'belanja', label: 'Belanja' }, { value: 'belanja', label: 'Belanja' },
{ value: 'pembiayaan', label: 'Pembiayaan' },
]} ]}
styles={{
option: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
}}
value={newItem.level === 1 ? null : newItem.tipe} value={newItem.level === 1 ? null : newItem.tipe}
onChange={(val) => setNewItem({ ...newItem, tipe: val as any })} onChange={(val) => setNewItem({ ...newItem, tipe: val as any })}
disabled={newItem.level === 1} disabled={newItem.level === 1}

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, IconFile, IconPlus, IconSearch } from '@tabler/icons-react'; import { IconDeviceImacCog, IconFile, 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,28 +45,33 @@ function APBDes() {
function ListAPBDes({ search }: { search: string }) { function ListAPBDes({ search }: { search: string }) {
const listState = useProxy(apbdes); const listState = useProxy(apbdes);
const router = useRouter(); const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = listState.findMany; const { data, page, totalPages, loading, load } = listState.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: 'md', md: 'lg' }}>
<Skeleton height={600} radius="md" /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
); );
} }
return ( return (
<Box py={10}> <Box py={{ base: 'md', md: 'lg' }}>
{/* Desktop Table */}
<Box visibleFrom="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 APBDes</Title> <Title order={2} size="lg" lh={1.2}>
Daftar APBDes
</Title>
<Button <Button
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
@@ -77,29 +82,39 @@ function ListAPBDes({ search }: { search: string }) {
</Button> </Button>
</Group> </Group>
<Box style={{ overflowX: 'auto' }}> <Box>
<Table highlightOnHover> <Table highlightOnHover miw={0}>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '25%' }}>APBDes</TableTh> <TableTh fz="md" fw={600} ta="left" w="25%">
<TableTh style={{ width: '25%' }}>Tahun</TableTh> APBDes
<TableTh style={{ width: '25%' }}>Dokumen</TableTh> </TableTh>
<TableTh style={{ width: '25%' }}>Aksi</TableTh> <TableTh fz="md" fw={600} ta="left" w="25%">
Tahun
</TableTh>
<TableTh fz="md" fw={600} ta="left" w="25%">
Dokumen
</TableTh>
<TableTh fz="md" fw={600} ta="left" w="25%">
Aksi
</TableTh>
</TableTr> </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>
<Text fw={500} lineClamp={1}> <Text fz="md" fw={500} lh={1.5} lineClamp={1}>
APBDes {item.tahun} APBDes {item.tahun}
</Text> </Text>
</TableTd> </TableTd>
<TableTd style={{ width: '25%' }}> <TableTd>
<Text fw={500}>{item.tahun || '-'}</Text> <Text fz="md" fw={500} lh={1.5}>
{item.tahun || '-'}
</Text>
</TableTd> </TableTd>
<TableTd style={{ width: '25%' }}> <TableTd>
{item.file?.link ? ( {item.file?.link ? (
<Button <Button
component="a" component="a"
@@ -110,17 +125,17 @@ function ListAPBDes({ search }: { search: string }) {
leftSection={<IconFile size={16} />} leftSection={<IconFile size={16} />}
size="xs" size="xs"
radius="sm" radius="sm"
fz="sm"
> >
Lihat Dokumen Lihat Dokumen
</Button> </Button>
) : ( ) : (
<Text c="dimmed" fz="sm"> <Text c="dimmed" fz="sm" lh={1.5}>
Tidak ada dokumen Tidak ada dokumen
</Text> </Text>
)} )}
</TableTd> </TableTd>
<TableTd style={{ width: '25%' }}> <TableTd>
<Box w={100}>
<Button <Button
size="xs" size="xs"
radius="md" radius="md"
@@ -128,19 +143,20 @@ function ListAPBDes({ search }: { search: string }) {
color="blue" color="blue"
leftSection={<IconDeviceImacCog size={14} />} leftSection={<IconDeviceImacCog size={14} />}
onClick={() => router.push(`/admin/landing-page/apbdes/${item.id}`)} onClick={() => router.push(`/admin/landing-page/apbdes/${item.id}`)}
fullWidth fz="sm"
> >
Detail Detail
</Button> </Button>
</Box>
</TableTd> </TableTd>
</TableTr> </TableTr>
)) ))
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={5}> <TableTd colSpan={4}>
<Center py={20}> <Center py="lg">
<Text color="dimmed">Tidak ada data APBDes yang cocok</Text> <Text c="dimmed" fz="sm" lh={1.5}>
Tidak ada data APBDes yang cocok
</Text>
</Center> </Center>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -149,8 +165,104 @@ function ListAPBDes({ search }: { search: string }) {
</Table> </Table>
</Box> </Box>
</Paper> </Paper>
</Box>
<Center mt="md"> {/* Mobile Cards */}
<Box hiddenFrom="md">
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={2} size="lg" lh={1.2}>
Daftar APBDes
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/landing-page/apbdes/create')}
>
Tambah Baru
</Button>
</Group>
<Stack gap="md">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper
key={item.id}
withBorder
bg={colors['white-1']}
p="md"
shadow="sm"
radius="md"
>
<Stack gap="xs">
<Text fz="sm" fw={600} lh={1.4}>
APBDes {item.tahun}
</Text>
<Box>
<Text fz="sm"fw={600} lh={1.4}>
Tahun
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.tahun || '-'}
</Text>
</Box>
<Box>
<Text fz="sm"fw={600} lh={1.4}>
Dokumen
</Text>
{item.file?.link ? (
<Button
component="a"
href={item.file.link}
target="_blank"
rel="noopener noreferrer"
variant="light"
leftSection={<IconFile size={14} />}
size="xs"
radius="sm"
fz="xs"
lh={1.4}
>
Lihat
</Button>
) : (
<Text fz="xs" c="dimmed" lh={1.4}>
Tidak ada
</Text>
)}
</Box>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={14} />}
onClick={() => router.push(`/admin/landing-page/apbdes/${item.id}`)}
mt="sm"
fz="xs"
lh={1.4}
>
Detail
</Button>
</Stack>
</Paper>
))
) : (
<Paper withBorder bg={colors['white-1']} p="md" radius="md">
<Center py="lg">
<Text c="dimmed" fz="xs" lh={1.4}>
Tidak ada data APBDes yang cocok
</Text>
</Center>
</Paper>
)}
</Stack>
</Paper>
</Box>
<Center mt="xl">
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => { onChange={(newPage) => {

View File

@@ -3,6 +3,7 @@
import colors from "@/con/colors"; import colors from "@/con/colors";
import { import {
Box,
ScrollArea, ScrollArea,
Stack, Stack,
Tabs, Tabs,
@@ -68,6 +69,7 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
keepMounted={false} keepMounted={false}
> >
{/* ✅ Scroll horizontal wrapper */} {/* ✅ Scroll horizontal wrapper */}
<Box visibleFrom='md' pb={10}>
<ScrollArea type="auto" offsetScrollbars> <ScrollArea type="auto" offsetScrollbars>
<TabsList <TabsList
p="sm" p="sm"
@@ -90,7 +92,7 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
fontWeight: 600, fontWeight: 600,
fontSize: "0.9rem", fontSize: "0.9rem",
transition: "all 0.2s ease", transition: "all 0.2s ease",
flexShrink: 0, // ✅ mencegah tab mengecil flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}} }}
> >
{tab.label} {tab.label}
@@ -98,7 +100,45 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
))} ))}
</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
key={i} key={i}

View File

@@ -82,7 +82,7 @@ export default function EditKategoriDesaAntiKorupsi() {
// 🧩 UI // 🧩 UI
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

@@ -43,7 +43,7 @@ export default function CreateKategoriDesaAntiKorupsi() {
}; };
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

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

View File

@@ -150,7 +150,7 @@ export default function EditDesaAntiKorupsi() {
}; };
return ( 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

@@ -42,7 +42,7 @@ export default function DetailKegiatanDesa() {
const data = detailState.findUnique.data; const data = detailState.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()}
@@ -53,7 +53,7 @@ export default function DetailKegiatanDesa() {
</Button> </Button>
<Paper <Paper
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

@@ -85,7 +85,7 @@ export default function CreateDesaAntiKorupsi() {
} }
}; };
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

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

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 { IconChartBar, IconUsers } from '@tabler/icons-react'; import { IconChartBar, IconUsers } 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';
@@ -53,7 +53,9 @@ function LayoutTabsKepuasan({ children }: { children: React.ReactNode }) {
radius="lg" radius="lg"
keepMounted={false} keepMounted={false}
> >
{/* ✅ Scroll horizontal wrapper */} {/* ✅ Scroll horizontal wrapper */}
<Box>
<ScrollArea type="auto" offsetScrollbars> <ScrollArea type="auto" offsetScrollbars>
<TabsList <TabsList
p="sm" p="sm"
@@ -67,22 +69,25 @@ function LayoutTabsKepuasan({ children }: { children: React.ReactNode }) {
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}} }}
> >
{tabs.map((e, i) => ( {tabs.map((tab, i) => (
<TabsTab <TabsTab
key={i} key={i}
value={e.value} value={tab.value}
leftSection={e.icon} leftSection={tab.icon}
style={{ style={{
fontWeight: 500, fontWeight: 600,
fontSize: "0.9rem", fontSize: "0.9rem",
transition: "all 0.2s ease", transition: "all 0.2s ease",
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}} }}
> >
{e.label} {tab.label}
</TabsTab> </TabsTab>
))} ))}
</TabsList> </TabsList>
</ScrollArea> </ScrollArea>
</Box>
{tabs.map((e, i) => ( {tabs.map((e, i) => (
<TabsPanel key={i} value={e.value}> <TabsPanel key={i} value={e.value}>
<></> <></>

View File

@@ -149,7 +149,7 @@ function EditResponden() {
); );
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

@@ -38,7 +38,7 @@ export default function DetailResponden() {
) )
} }
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()}
@@ -50,7 +50,7 @@ export default function DetailResponden() {
<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

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

View File

@@ -56,6 +56,7 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
radius="lg" radius="lg"
keepMounted={false} keepMounted={false}
> >
<ScrollArea type="auto" offsetScrollbars> <ScrollArea type="auto" offsetScrollbars>
<TabsList <TabsList
p="sm" p="sm"
@@ -63,6 +64,10 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
background: "linear-gradient(135deg, #e7ebf7, #f9faff)", background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem", borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)", 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) => ( {tabs.map((tab, i) => (
@@ -74,6 +79,7 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
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}

View File

@@ -78,7 +78,7 @@ function EditKategoriPrestasi() {
}; };
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

@@ -40,7 +40,7 @@ function CreateKategoriPrestasi() {
} }
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

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

View File

@@ -128,7 +128,7 @@ export default function EditPrestasiDesa() {
}; };
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

@@ -41,7 +41,7 @@ function DetailPrestasiDesa() {
} }
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()}
@@ -53,7 +53,7 @@ function DetailPrestasiDesa() {
<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

@@ -69,7 +69,7 @@ function CreatePrestasiDesa() {
} }
} }
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

@@ -1,7 +1,7 @@
'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 { useShallowEffect } from '@mantine/hooks'; import { useDebouncedValue, useMediaQuery, 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';
@@ -28,6 +28,8 @@ function ListPrestasiDesa() {
function ListPrestasi({ search }: { search: string }) { function ListPrestasi({ search }: { search: string }) {
const listState = useProxy(prestasiState.prestasiDesa) const listState = useProxy(prestasiState.prestasiDesa)
const router = useRouter(); const router = useRouter();
const isMobile = useMediaQuery('(max-width: 768px)');
const [debouncedSearch] = useDebouncedValue(search, 1000); // 500ms delay
const { const {
data, data,
@@ -38,63 +40,69 @@ function ListPrestasi({ search }: { search: string }) {
} = listState.findMany } = listState.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 Prestasi Desa</Title> <Title order={2} size={isMobile ? 'md' : 'lg'} lh={1.2}>
Daftar Prestasi Desa
</Title>
<Button <Button
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
variant="light" variant="light"
onClick={() => router.push('/admin/landing-page/prestasi-desa/list-prestasi-desa/create')} onClick={() => router.push('/admin/landing-page/prestasi-desa/list-prestasi-desa/create')}
size={isMobile ? 'xs' : 'sm'}
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Group> </Group>
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover> {/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table highlightOnHover striped verticalSpacing="sm" miw={800}>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '25%' }}>Nama Prestasi</TableTh> <TableTh>Nama Prestasi</TableTh>
<TableTh style={{ width: '25%' }}>Deskripsi</TableTh> <TableTh>Deskripsi</TableTh>
<TableTh style={{ width: '25%' }}>Kategori</TableTh> <TableTh>Kategori</TableTh>
<TableTh style={{ width: '25%', textAlign: 'center' }}>Aksi</TableTh> <TableTh ta="center">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 style={{ maxWidth: 250 }}>
<Box w={100}> <Text truncate="end" fz="md" lh={1.5}>
<Text truncate="end" fz={"sm"}>{item.name}</Text> {item.name}
</Box> </Text>
</TableTd> </TableTd>
<TableTd style={{ width: '25%' }}> <TableTd style={{ maxWidth: 250 }}>
<Text lineClamp={1} fz="sm" c="dimmed" dangerouslySetInnerHTML={{ __html: item.deskripsi }} /> <Text lineClamp={1} fz="md" c="dimmed" lh={1.5} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</TableTd> </TableTd>
<TableTd style={{ width: '25%' }}> <TableTd>
<Box w={150}> <Text truncate="end" fz="md" lh={1.5}>
<Text truncate="end" fz={"sm"}>{item.kategori?.name || 'Tidak ada kategori'}</Text> {item.kategori?.name || 'Tidak ada kategori'}
</Box> </Text>
</TableTd> </TableTd>
<TableTd style={{ width: '25%', textAlign: 'center' }}> <TableTd ta="center">
<Center>
<Button <Button
size="xs" size="sm"
radius="md" radius="md"
variant="light" variant="light"
color="blue" color="blue"
@@ -103,28 +111,78 @@ function ListPrestasi({ search }: { search: string }) {
> >
Detail Detail
</Button> </Button>
</Center>
</TableTd> </TableTd>
</TableTr> </TableTr>
)) ))
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={4} style={{ textAlign: 'center' }}> <TableTd colSpan={4} ta="center">
<Text c="dimmed" py="md">Tidak ada data prestasi</Text> <Text c="dimmed" py="md" fz="sm" lh={1.4}>
Tidak ada data prestasi
</Text>
</TableTd> </TableTd>
</TableTr> </TableTr>
)} )}
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Cards */}
<Stack hiddenFrom="md" gap="xs">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="sm" radius="sm">
<Stack gap={"xs"}>
<Box>
<Text fz="sm" fw={600} lh={1.4}>Nama Prestasi</Text>
<Text fz="sm" fw={500} lh={1.4} lineClamp={2}>
{item.name}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>Deskripsi</Text>
<Text fz="sm" fw={500} lh={1.5} lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>Kategori</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.kategori?.name || 'Tidak ada kategori'}
</Text>
</Box>
<Group justify="flex-end" mt="xs">
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={14} />}
onClick={() => router.push(`/admin/landing-page/prestasi-desa/list-prestasi-desa/${item.id}`)}
>
Detail
</Button>
</Group>
</Stack>
</Paper> </Paper>
))
) : (
<Center py="md">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data prestasi
</Text>
</Center>
)}
</Stack>
</Paper>
{totalPages > 1 && ( {totalPages > 1 && (
<Center mt="lg"> <Center mt={{ base: 'md', md: 'lg' }}>
<Pagination <Pagination
value={page} value={page}
onChange={load} onChange={load}
total={totalPages} total={totalPages}
withEdges withEdges
size="sm" size={isMobile ? 'xs' : 'sm'}
/> />
</Center> </Center>
)} )}

View File

@@ -2,6 +2,7 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
Box,
ScrollArea, ScrollArea,
Stack, Stack,
Tabs, Tabs,
@@ -74,6 +75,7 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
keepMounted={false} keepMounted={false}
> >
{/* ✅ Scroll horizontal wrapper */} {/* ✅ Scroll horizontal wrapper */}
<Box visibleFrom='md' pb={10}>
<ScrollArea type="auto" offsetScrollbars> <ScrollArea type="auto" offsetScrollbars>
<TabsList <TabsList
p="sm" p="sm"
@@ -104,6 +106,45 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
))} ))}
</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

@@ -0,0 +1,12 @@
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 },
};

View File

@@ -1,5 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client'; 'use client';
import SelectSocialMediaEdit from '@/app/admin/(dashboard)/_com/selectSocialMediaEdit';
import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page/profile'; import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
@@ -14,7 +16,7 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
Loader Loader,
} from '@mantine/core'; } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -23,15 +25,45 @@ import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
type SosmedKey =
| 'none'
| 'facebook'
| 'instagram'
| 'tiktok'
| 'youtube'
| 'whatsapp'
| 'gmail'
| 'telegram'
| 'x'
| 'telephone'
| 'custom';
const sosmedMap: Record<SosmedKey, { label: string; src: string | null }> = {
none: { label: "None", src: '/no-image.jpg' },
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 },
};
function EditMediaSosial() { function EditMediaSosial() {
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial); const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial);
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const [selectedSosmed, setSelectedSosmed] = useState<SosmedKey>('facebook');
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
icon: '',
iconUrl: '', iconUrl: '',
imageId: '', imageId: '',
}); });
@@ -39,13 +71,14 @@ function EditMediaSosial() {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({ const [originalData, setOriginalData] = useState({
name: "", name: '',
iconUrl: "", icon: '',
imageId: "", iconUrl: '',
imageUrl: "", imageId: '',
imageUrl: '',
}); });
// Load data by ID // Load Data by ID
useEffect(() => { useEffect(() => {
const id = params?.id as string; const id = params?.id as string;
if (!id) return; if (!id) return;
@@ -54,81 +87,97 @@ function EditMediaSosial() {
try { try {
const data = await stateMediaSosial.update.load(id); const data = await stateMediaSosial.update.load(id);
if (data) { if (!data) return;
// isi form awal
// Tentukan default/custom icon
// Tentukan default/custom icon
if (data.imageId) {
setSelectedSosmed('custom');
} else {
// ✅ Gunakan langsung data.icon jika ada dan valid
if (data.icon && sosmedMap[data.icon as SosmedKey]) {
setSelectedSosmed(data.icon as SosmedKey);
} else {
setSelectedSosmed('none'); // fallback
}
}
const newForm = { const newForm = {
name: data.name || "", name: data.name || '',
iconUrl: data.iconUrl || "", icon: data.icon || '',
imageId: data.imageId || "", iconUrl: data.iconUrl || '',
imageId: data.imageId || '',
}; };
setFormData(newForm); setFormData(newForm);
// simpan juga versi original
setOriginalData({ setOriginalData({
...newForm, ...newForm,
imageUrl: data.image?.link || "", imageUrl: data.image?.link || '',
}); });
setPreviewImage(data.image?.link || null); setPreviewImage(data.image?.link || null);
} } catch {
} catch (error) { toast.error('Gagal mengambil data media sosial');
console.error('Error loading media sosial:', error);
toast.error(
error instanceof Error ? error.message : 'Gagal mengambil data media sosial'
);
} }
}; };
loadData(); loadData();
}, [params?.id]); }, [params?.id]);
const handleChange = (field: string, value: string) => { const handleChange = (field: keyof typeof formData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// update global state hanya saat submit
stateMediaSosial.update.form = { ...stateMediaSosial.update.form, ...formData }; stateMediaSosial.update.form = { ...stateMediaSosial.update.form, ...formData };
if (file) { if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name }); const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) return toast.error('Gagal upload gambar'); if (!uploaded?.id) {
toast.error('Gagal upload gambar');
return;
}
stateMediaSosial.update.form.imageId = uploaded.id; stateMediaSosial.update.form.imageId = uploaded.id;
} }
// 🚨 Tambahkan ini untuk debugging
console.log("Data yang akan dikirim ke backend:", stateMediaSosial.update.form);
await stateMediaSosial.update.update(); await stateMediaSosial.update.update();
toast.success('Media sosial berhasil diperbarui!'); toast.success('Media sosial berhasil diperbarui!');
router.push('/admin/landing-page/profil/media-sosial'); router.push('/admin/landing-page/profil/media-sosial');
} catch (error) { } catch (error) {
console.error('Error updating media sosial:', error); console.error("Error di handleSubmit:", error); // 🚨 Tambahkan ini juga
toast.error('Terjadi kesalahan saat memperbarui media sosial'); toast.error('Terjadi kesalahan saat memperbarui media sosial');
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
// ✅ Tombol Batal → balikin ke data original
const handleResetForm = () => { const handleResetForm = () => {
setFormData({ setFormData({
name: originalData.name, name: originalData.name,
icon: originalData.icon,
iconUrl: originalData.iconUrl, iconUrl: originalData.iconUrl,
imageId: originalData.imageId, imageId: originalData.imageId,
}); });
setPreviewImage(originalData.imageUrl || null); setPreviewImage(originalData.imageUrl || null);
setFile(null); setFile(null);
toast.info("Form dikembalikan ke data awal"); toast.info('Form dikembalikan ke data awal');
}; };
return ( return (
<Box <Box px={{ base: 0, md: 'xs' }} py="xs">
px={{ base: 'sm', md: 'lg' }}
py="md"
>
<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} />
@@ -147,20 +196,50 @@ function EditMediaSosial() {
style={{ border: '1px solid #e0e0e0' }} style={{ border: '1px solid #e0e0e0' }}
> >
<Stack gap="md"> <Stack gap="md">
{/* Upload Gambar */} {/* Upload / Icon */}
<Box> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
Gambar Program Inovasi Icon / Gambar Media Sosial
</Text> </Text>
{/* Custom Upload */}
{/* PILIH ICON */}
<SelectSocialMediaEdit
value={selectedSosmed}
onChange={(key) => {
setSelectedSosmed(key);
if (key === 'custom') {
// custom → gunakan Dropzone
setFormData((prev) => ({
...prev,
icon: '',
imageId: '',
}));
return;
}
// default → pakai icon bawaan
setFormData((prev) => ({
...prev,
icon: key, // <-- simpan 'facebook', bukan path
imageId: '',
}));
}}
/>
{selectedSosmed === 'custom' ? (
<>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
const selectedFile = files[0]; const selectedFile = files[0];
if (selectedFile) { if (selectedFile) {
setFile(selectedFile); setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile)); setPreviewImage(URL.createObjectURL(selectedFile));
handleChange('imageId', '');
} }
}} }}
onReject={() => toast.error('File tidak valid, gunakan format gambar')} onReject={() => toast.error('File tidak valid')}
maxSize={5 * 1024 ** 2} maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }} accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md" radius="md"
@@ -176,33 +255,29 @@ function EditMediaSosial() {
<Dropzone.Idle> <Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} /> <IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle> </Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}> <Stack align="center" gap="xs">
Seret gambar atau klik untuk memilih file <Text fw={500}>Seret gambar atau klik untuk pilih</Text>
</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp Maksimal 5MB, format: .png, .jpg, .jpeg, .webp
</Text> </Text>
</Stack> </Stack>
</Group> </Group>
</Dropzone> </Dropzone>
{/* ✅ Preview gambar + tombol X */}
{previewImage && ( {previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}> <Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image <Image
src={previewImage} src={previewImage}
alt="Preview Gambar" alt="Preview"
radius="md" radius="md"
style={{ style={{
maxHeight: 200, maxHeight: 200,
objectFit: 'contain', objectFit: 'contain',
border: '1px solid #ddd', border: '1px solid #ddd',
}} }}
loading="lazy"
/> />
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon <ActionIcon
variant="filled" variant="filled"
color="red" color="red"
@@ -212,17 +287,30 @@ function EditMediaSosial() {
top={5} top={5}
right={5} right={5}
onClick={() => { onClick={() => {
setPreviewImage(null);
setFile(null); setFile(null);
setPreviewImage(null);
handleChange('imageId', '');
}} }}
style={{ style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
> >
<IconX size={14} /> <IconX size={14} />
</ActionIcon> </ActionIcon>
</Box> </Box>
)} )}
</>
) : (
// Default icon
<Box mt="xs">
<Image
src={sosmedMap[selectedSosmed].src || ''}
alt="Icon bawaan"
width={40}
height={40}
radius="md"
style={{ border: '1px solid #ddd', padding: 4, background: '#fff' }}
/>
</Box>
)}
</Box> </Box>
{/* Nama Media Sosial */} {/* Nama Media Sosial */}
@@ -237,25 +325,17 @@ function EditMediaSosial() {
{/* Link Media Sosial */} {/* Link Media Sosial */}
<TextInput <TextInput
label="Link Media Sosial / Nomor Telepon" label="Link Media Sosial / Nomor Telepon"
placeholder="Masukkan link media sosial atau nomor telepon" placeholder="Masukkan link atau nomor telepon"
value={formData.iconUrl} value={formData.iconUrl}
onChange={(e) => handleChange('iconUrl', e.target.value)} onChange={(e) => handleChange('iconUrl', e.target.value)}
required required
/> />
<Group justify="right"> <Group justify="right">
{/* Tombol Batal */} <Button variant="outline" color="gray" radius="md" size="md" onClick={handleResetForm}>
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal Batal
</Button> </Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client' 'use client'
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page/profile'; import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page/profile';
@@ -8,6 +9,7 @@ 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';
import { sosmedMap } from '../../_lib/sosmed';
function DetailMediaSosial() { function DetailMediaSosial() {
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial); const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial);
@@ -16,6 +18,14 @@ function DetailMediaSosial() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const getIconSource = (item: any) => {
if (item.image?.link) return item.image.link;
if (item.icon && sosmedMap[item.icon as keyof typeof sosmedMap]?.src) {
return sosmedMap[item.icon as keyof typeof sosmedMap].src;
}
return null;
};
useShallowEffect(() => { useShallowEffect(() => {
stateMediaSosial.findUnique.load(params?.id as string); stateMediaSosial.findUnique.load(params?.id as string);
}, []); }, []);
@@ -40,7 +50,7 @@ function DetailMediaSosial() {
const data = stateMediaSosial.findUnique.data; const data = stateMediaSosial.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 +62,7 @@ function DetailMediaSosial() {
<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"
@@ -77,21 +87,22 @@ function DetailMediaSosial() {
<Box> <Box>
<Text fz="lg" fw="bold">Gambar</Text> <Text fz="lg" fw="bold">Gambar</Text>
{data.image?.link ? ( {(() => {
<Image const src = getIconSource(data);
src={data.image.link}
alt={data.name || 'Gambar Media Sosial'}
w="100%"
maw={120} // max width biar tidak keluar layar
h="auto"
radius="md"
fit="cover"
loading="lazy"
/>
) : ( if (src) {
<Text fz="sm" c="dimmed">Tidak ada gambar</Text> return (
)} <Image
loading="lazy"
src={src}
alt={data.name}
fit={data.image?.link ? "cover" : "contain"}
/>
);
}
return <Box bg={colors['blue-button']} w="100%" h="100%" />;
})()}
</Box> </Box>
<Group gap="sm"> <Group gap="sm">

View File

@@ -1,5 +1,6 @@
/* 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 ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { import {
@@ -22,10 +23,40 @@ import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import profileLandingPageState from '../../../../_state/landing-page/profile'; import profileLandingPageState from '../../../../_state/landing-page/profile';
import SelectSosialMedia from '@/app/admin/(dashboard)/_com/selectSocialMedia';
// ⭐ Tambah type SosmedKey
type SosmedKey =
| 'facebook'
| 'instagram'
| 'tiktok'
| 'youtube'
| 'whatsapp'
| 'gmail'
| 'telegram'
| 'x'
| 'telephone'
| 'custom';
// ⭐ mapping icon sosmed bawaan
const sosmedMap: Record<SosmedKey, { label: string; src: string | null }> = {
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 },
};
export default function CreateMediaSosial() { export default function CreateMediaSosial() {
const router = useRouter(); const router = useRouter();
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial); const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial);
const [selectedSosmed, setSelectedSosmed] = useState<SosmedKey>('facebook');
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -39,16 +70,33 @@ export default function CreateMediaSosial() {
name: '', name: '',
imageId: '', imageId: '',
iconUrl: '', iconUrl: '',
icon: ''
}; };
setPreviewImage(null);
setFile(null); setFile(null);
setPreviewImage(null);
setSelectedSosmed('facebook');
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// ──────────────── ⭐ CASE 1: PAKAI ICON DEFAULT ────────────────
if (selectedSosmed !== 'custom') {
stateMediaSosial.create.form.imageId = null;
stateMediaSosial.create.form.icon = sosmedMap[selectedSosmed].src!;
await stateMediaSosial.create.create();
resetForm();
router.push('/admin/landing-page/profil/media-sosial');
return;
}
// ──────────────── ⭐ CASE 2: CUSTOM ICON → WAJIB UPLOAD ────────────────
if (!file) { if (!file) {
return toast.warn('Silakan pilih file gambar terlebih dahulu'); toast.warn('Silakan upload icon custom terlebih dahulu');
return;
} }
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
@@ -59,10 +107,12 @@ export default function CreateMediaSosial() {
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) { if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi'); toast.error('Gagal mengunggah icon custom');
return;
} }
stateMediaSosial.create.form.imageId = uploaded.id; stateMediaSosial.create.form.imageId = uploaded.id;
stateMediaSosial.create.form.icon = null;
await stateMediaSosial.create.create(); await stateMediaSosial.create.create();
@@ -77,12 +127,13 @@ export default function CreateMediaSosial() {
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* 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">
<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={2} ml="sm" c="dark" lh={1.2} fz={{ base: 'md', md: 'lg' }}>
Tambah Media Sosial Tambah Media Sosial
</Title> </Title>
</Group> </Group>
@@ -96,10 +147,16 @@ export default function CreateMediaSosial() {
style={{ border: '1px solid #e0e0e0' }} style={{ border: '1px solid #e0e0e0' }}
> >
<Stack gap="md"> <Stack gap="md">
{/* Select Sosmed */}
<SelectSosialMedia value={selectedSosmed} onChange={setSelectedSosmed} />
{/* Custom icon uploader */}
{selectedSosmed === 'custom' && (
<Box> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz={{ base: 'sm', md: 'md' }} lh={1.45} mb={6}>
Gambar Program Inovasi Upload Custom Icon
</Text> </Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
const selectedFile = files[0]; const selectedFile = files[0];
@@ -108,7 +165,7 @@ export default function CreateMediaSosial() {
setPreviewImage(URL.createObjectURL(selectedFile)); setPreviewImage(URL.createObjectURL(selectedFile));
} }
}} }}
onReject={() => toast.error('File tidak valid, gunakan format gambar')} onReject={() => toast.error('File tidak valid')}
maxSize={5 * 1024 ** 2} maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }} accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md" radius="md"
@@ -124,33 +181,31 @@ export default function CreateMediaSosial() {
<Dropzone.Idle> <Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} /> <IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle> </Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}> <Stack align="center" gap="xs">
Seret gambar atau klik untuk memilih file <Text fw={500} fz={{ base: 'sm', md: 'md' }} lh={1.45}>
Seret gambar atau klik untuk pilih
</Text> </Text>
<Text size="sm" c="dimmed"> <Text fz={{ base: 12, md: 'sm' }} c="dimmed" lh={1.4}>
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp Maksimal 5MB, format .png, .jpg, .jpeg, webp
</Text> </Text>
</Stack> </Stack>
</Group> </Group>
</Dropzone> </Dropzone>
{/* ✅ Preview gambar + tombol X */}
{previewImage && ( {previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}> <Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image <Image
src={previewImage} src={previewImage}
alt="Preview Gambar" alt="Preview"
radius="md" radius="md"
style={{ style={{
maxHeight: 200, maxHeight: 200,
objectFit: 'contain', objectFit: 'contain',
border: '1px solid #ddd', border: '1px solid #ddd',
}} }}
loading="lazy"
/> />
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon <ActionIcon
variant="filled" variant="filled"
color="red" color="red"
@@ -160,48 +215,52 @@ export default function CreateMediaSosial() {
top={5} top={5}
right={5} right={5}
onClick={() => { onClick={() => {
setPreviewImage(null);
setFile(null); setFile(null);
setPreviewImage(null);
}} }}
style={{ style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
> >
<IconX size={14} /> <IconX size={14} />
</ActionIcon> </ActionIcon>
</Box> </Box>
)} )}
</Box> </Box>
)}
{/* Input name */}
<TextInput <TextInput
label="Nama Media Sosial / Kontak" label={
placeholder="Masukkan nama media sosial atau kontak" <Text fw={500} fz={{ base: 'sm', md: 'md' }} lh={1.45}>
value={stateMediaSosial.create.form.name || ''} Nama Media Sosial
</Text>
}
placeholder="Masukkan nama media sosial"
value={stateMediaSosial.create.form.name ?? ''}
onChange={(e) => (stateMediaSosial.create.form.name = e.target.value)} onChange={(e) => (stateMediaSosial.create.form.name = e.target.value)}
required required
/> />
{/* Input link */}
<TextInput <TextInput
label="Link Media Sosial / Nomor Telepon" label={
placeholder="Masukkan link media sosial atau nomor telepon" <Text fw={500} fz={{ base: 'sm', md: 'md' }} lh={1.45}>
value={stateMediaSosial.create.form.iconUrl || ''} Link / Kontak
</Text>
}
placeholder="Masukkan link atau nomor"
value={stateMediaSosial.create.form.iconUrl ?? ''}
onChange={(e) => (stateMediaSosial.create.form.iconUrl = e.target.value)} onChange={(e) => (stateMediaSosial.create.form.iconUrl = e.target.value)}
required required
/> />
{/* Actions */}
<Group justify="right"> <Group justify="right">
<Button <Button variant="outline" color="gray" radius="md" onClick={resetForm}>
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset Reset
</Button> </Button>
<Button <Button
onClick={handleSubmit}
radius="md" radius="md"
size="md" onClick={handleSubmit}
style={{ style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`, background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff', color: '#fff',

View File

@@ -1,13 +1,33 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Group, Image, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core'; import {
import { useShallowEffect } from '@mantine/hooks'; Box,
Button,
Center,
Group,
Image,
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 { 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';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import profileLandingPageState from '../../../_state/landing-page/profile'; import profileLandingPageState from '../../../_state/landing-page/profile';
import { sosmedMap } from '../_lib/sosmed';
function MediaSosial() { function MediaSosial() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -26,8 +46,17 @@ function MediaSosial() {
} }
function ListMediaSosial({ search }: { search: string }) { function ListMediaSosial({ search }: { search: string }) {
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial) const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial);
const router = useRouter(); const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000); // 500ms delay
const getIconSource = (item: any) => {
if (item.image?.link) return item.image.link;
if (item.icon && sosmedMap[item.icon as keyof typeof sosmedMap]?.src) {
return sosmedMap[item.icon as keyof typeof sosmedMap].src;
}
return null;
};
const { const {
data, data,
@@ -38,57 +67,95 @@ function ListMediaSosial({ search }: { search: string }) {
} = stateMediaSosial.findMany; } = stateMediaSosial.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', sm: 'md' }}>
<Skeleton height={600} radius="md" /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
); );
} }
return ( return (
<Box py={10}> <Box py={{ base: 'sm', sm: 'md' }}>
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p={{ base: 'md', sm: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb={{ base: 'sm', sm: 'md' }}>
<Title order={4}>Daftar Media Sosial</Title> <Title order={4} lh={1.15}>
<Button leftSection={<IconPlus size={18} />} color="blue" variant="light" onClick={() => router.push('/admin/landing-page/profil/media-sosial/create')}> Daftar Media Sosial
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/landing-page/profil/media-sosial/create')}
fz={{ base: 'xs', sm: 'sm' }}
>
Tambah Baru Tambah Baru
</Button> </Button>
</Group> </Group>
<Box style={{ overflowX: "auto" }}>
<Box>
{/* Desktop: Table | Mobile: Card-based vertical layout */}
<Box visibleFrom="md">
<Table highlightOnHover> <Table highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '25%' }}>Nama Media Sosial / Kontak</TableTh> <TableTh style={{ width: '25%' }}>
<TableTh style={{ width: '20%' }}>Gambar</TableTh> <Text fw={600} fz="md" lh={1.45}>
<TableTh style={{ width: '20%' }}>Link / No. Telepon</TableTh> Nama Media Sosial / Kontak
<TableTh style={{ width: '15%' }}>Aksi</TableTh> </Text>
</TableTh>
<TableTh style={{ width: '20%' }}>
<Text fw={600} fz="md" lh={1.45}>
Gambar
</Text>
</TableTh>
<TableTh style={{ width: '20%' }}>
<Text fw={600} fz="md" lh={1.45}>
Link / No. Telepon
</Text>
</TableTh>
<TableTh style={{ width: '15%' }}>
<Text fw={600} fz="md" lh={1.45}>
Aksi
</Text>
</TableTh>
</TableTr> </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 style={{ width: '25%' }}>
<Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text> <Text fw={500} fz="md" lh={1.5} truncate="end" lineClamp={1}>
{item.name}
</Text>
</TableTd> </TableTd>
<TableTd style={{ width: '20%', }}> <TableTd style={{ width: '20%' }}>
<Box w={50} h={50} style={{ borderRadius: 8, overflow: 'hidden', }}> <Box w={50} h={50} style={{ borderRadius: 8, overflow: 'hidden' }}>
{item.image?.link ? ( {(() => {
<Image loading='lazy' src={item.image.link} alt={item.name} fit="cover" /> const src = getIconSource(item);
) : ( if (src) {
<Box bg={colors['blue-button']} w="100%" h="100%" /> return (
)} <Image
loading="lazy"
src={src}
alt={item.name}
fit={item.image?.link ? 'cover' : 'contain'}
/>
);
}
return <Box bg={colors['blue-button']} w="100%" h="100%" />;
})()}
</Box> </Box>
</TableTd> </TableTd>
<TableTd style={{ width: '20%', }}> <TableTd style={{ width: '20%' }}>
<Box w={250}> <Box w={250}>
<Text truncate fz="sm" c="dimmed" lineClamp={1}> <Text truncate fz="sm" lh={1.5} c="dimmed" lineClamp={1}>
{item.iconUrl || item.noTelp || '-'} {item.iconUrl || item.noTelp || '-'}
</Text> </Text>
</Box> </Box>
@@ -100,7 +167,9 @@ function ListMediaSosial({ search }: { search: string }) {
variant="light" variant="light"
color="blue" color="blue"
leftSection={<IconDeviceImacCog size={16} />} leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/admin/landing-page/profil/media-sosial/${item.id}`)} onClick={() =>
router.push(`/admin/landing-page/profil/media-sosial/${item.id}`)
}
> >
Detail Detail
</Button> </Button>
@@ -111,7 +180,9 @@ function ListMediaSosial({ search }: { search: string }) {
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={4}>
<Center py={20}> <Center py={20}>
<Text color="dimmed">Tidak ada data media sosial yang cocok</Text> <Text c="dimmed" fz="md" lh={1.5}>
Tidak ada data media sosial yang cocok
</Text>
</Center> </Center>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -119,7 +190,81 @@ function ListMediaSosial({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile layout */}
<Stack hiddenFrom="md" gap="xs">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="sm" radius="md">
<Box>
<Text fz="sm" fw={600} lh={1.4}>Nama Media Sosial / Kontak</Text>
<Text fw={500} fz="sm" lh={1.45}>
{item.name}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>Gambar</Text>
</Box>
<Box w={40} h={40} style={{ borderRadius: 6, overflow: 'hidden' }}>
{(() => {
const src = getIconSource(item);
if (src) {
return (
<Image
loading="lazy"
src={src}
alt={item.name}
fit={item.image?.link ? 'cover' : 'contain'}
/>
);
}
return <Box bg={colors['blue-button']} w="100%" h="100%" />;
})()}
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>Link / No. Telepon</Text>
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: 'underline' }}
>
<Text
fz="sm"
c="blue"
truncate
>
{item.iconUrl || item.noTelp || '-'}
</Text>
</a>
</Box>
<Group mt="sm" justify="flex-end">
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/landing-page/profil/media-sosial/${item.id}`)
}
>
Detail
</Button>
</Group>
</Paper> </Paper>
))
) : (
<Center py={24}>
<Text c="dimmed" fz="sm" lh={1.5}>
Tidak ada data media sosial yang cocok
</Text>
</Center>
)}
</Stack>
</Box>
</Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}

View File

@@ -178,7 +178,7 @@ function EditPejabatDesa() {
} }
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

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

View File

@@ -130,7 +130,7 @@ function EditProgramInovasi() {
}; };
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

@@ -40,13 +40,15 @@ function DetailProgramInovasi() {
const data = stateProgramInovasi.findUnique.data const data = stateProgramInovasi.findUnique.data
return ( return (
<Box px={{ base: 'md', md: 'xl' }} py="lg"> <Box px={{ base: 0, md: 'xs' }} py="xs">
<Box pb="20">
<Button variant="subtle" onClick={() => router.back()} leftSection={<IconArrowBack size={22} color={colors['blue-button']} />}> <Button variant="subtle" onClick={() => router.back()} leftSection={<IconArrowBack size={22} color={colors['blue-button']} />}>
Kembali Kembali
</Button> </Button>
</Box>
<Paper <Paper
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

@@ -76,7 +76,7 @@ function CreateProgramInovasi() {
}; };
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

@@ -1,7 +1,7 @@
'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, Tooltip } from '@mantine/core'; import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title, Tooltip } 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';
@@ -13,7 +13,7 @@ function ProgramInovasi() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
return ( return (
<Box px="md" py="lg"> <Box px={{base: 0, md: "md"}} py="lg">
<HeaderSearch <HeaderSearch
title="Program Inovasi" title="Program Inovasi"
placeholder="Cari program inovasi..." placeholder="Cari program inovasi..."
@@ -29,12 +29,13 @@ function ProgramInovasi() {
function ListProgramInovasi({ search }: { search: string }) { function ListProgramInovasi({ search }: { search: string }) {
const stateProgramInovasi = useProxy(profileLandingPageState.programInovasi); const stateProgramInovasi = useProxy(profileLandingPageState.programInovasi);
const router = useRouter(); const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000); // 500ms delay
const { data, page, totalPages, loading, load } = stateProgramInovasi.findMany; const { data, page, totalPages, loading, load } = stateProgramInovasi.findMany;
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, search); load(page, 10, debouncedSearch);
}, [page, search]); }, [page, debouncedSearch]);
const filteredData = data || []; const filteredData = data || [];
@@ -61,6 +62,7 @@ function ListProgramInovasi({ search }: { search: string }) {
Tambah Program Tambah Program
</Button> </Button>
</Group> </Group>
<Box visibleFrom='md'>
<Box style={{ overflowX: 'auto' }}> <Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover striped verticalSpacing="sm"> <Table highlightOnHover striped verticalSpacing="sm">
<TableThead> <TableThead>
@@ -121,6 +123,74 @@ function ListProgramInovasi({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
</Box>
<Box hiddenFrom="md" pt={20}>
<Stack gap="sm">
{filteredData.map((item) => (
<Paper
key={item.id}
withBorder
radius="md"
p="md"
shadow="xs"
>
<Stack gap={6}>
{/* Title */}
<Box>
<Text fz="sm" fw={600} lh={1.4}>Nama Program</Text>
<Text fw={500} lh={1.4}>{item.name}</Text>
</Box>
{/* Description */}
<Box>
<Text fz="sm" fw={600} lh={1.4}>Deskripsi</Text>
<Text fz="sm" c="gray.7" lineClamp={2}>
{item.description || '-'}
</Text>
</Box>
{/* Link */}
<Box>
<Text fz="sm" fw={600} lh={1.4}>Link</Text>
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: 'underline' }}
>
<Text
fz="sm"
c="blue"
truncate
>
{item.link}
</Text>
</a>
</Box>
{/* Action */}
<Group justify="flex-end" mt="xs">
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(
`/admin/landing-page/profil/program-inovasi/${item.id}`
)
}
>
Detail
</Button>
</Group>
</Stack>
</Paper>
))}
</Stack>
</Box>
{filteredData.length > 0 && ( {filteredData.length > 0 && (
<Center mt="md"> <Center mt="md">
<Pagination <Pagination

View File

@@ -79,7 +79,7 @@ function EditDaftarInformasiPublik() {
}; };
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

@@ -52,7 +52,7 @@ function DetailDaftarInformasiPublik() {
<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"
@@ -83,11 +83,11 @@ function DetailDaftarInformasiPublik() {
<Box> <Box>
<Text fz="lg" fw="bold" mb={4}>Deskripsi</Text> <Text fz="lg" fw="bold" mb={4}>Deskripsi</Text>
<Box <Text
px={"xs"}
fz="md" fz="md"
c="dimmed" c="dimmed"
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }} dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
className="prose max-w-none"
style={{ wordBreak: "break-word", whiteSpace: "normal" }} style={{ wordBreak: "break-word", whiteSpace: "normal" }}
/> />
</Box> </Box>

View File

@@ -41,7 +41,7 @@ export default function CreateDaftarInformasi() {
}; };
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

@@ -1,7 +1,24 @@
'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 {
import { useShallowEffect, useViewportSize } from '@mantine/hooks'; Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect, useViewportSize } 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';
@@ -10,7 +27,7 @@ import HeaderSearch from '../../_com/header';
import daftarInformasiPublik from '../../_state/ppid/daftar_informasi_publik/daftarInformasiPublik'; import daftarInformasiPublik from '../../_state/ppid/daftar_informasi_publik/daftarInformasiPublik';
function DaftarInformasiPublik() { function DaftarInformasiPublik() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState('');
return ( return (
<Box> <Box>
<HeaderSearch <HeaderSearch
@@ -26,32 +43,35 @@ function DaftarInformasiPublik() {
} }
function ListDaftarInformasi({ search }: { search: string }) { function ListDaftarInformasi({ search }: { search: string }) {
const listData = useProxy(daftarInformasiPublik) const listData = useProxy(daftarInformasiPublik);
const router = useRouter() const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = listData.findMany const { data, page, totalPages, loading, load } = listData.findMany;
const { width } = useViewportSize() const { width } = useViewportSize();
const isMobile = width < 768 const isMobile = width < 768;
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="md">
<Skeleton height={600} radius="md" /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
) );
} }
return ( return (
<Box py="md"> <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: '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 Daftar Informasi Publik</Title> <Title order={2} lh={1.2}>
List Daftar Informasi Publik
</Title>
<Button <Button
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
@@ -65,10 +85,14 @@ function ListDaftarInformasi({ search }: { search: string }) {
{filteredData.length === 0 ? ( {filteredData.length === 0 ? (
<Stack align="center" py="xl"> <Stack align="center" py="xl">
<IconDeviceImacCog size={40} stroke={1.5} color={colors['blue-button']} /> <IconDeviceImacCog size={40} stroke={1.5} color={colors['blue-button']} />
<Text fw={500} c="dimmed">Belum ada informasi publik yang tersedia</Text> <Text fz={{ base: 'sm', md: 'md' }} fw={500} c="dimmed" lh={1.5}>
Belum ada informasi publik yang tersedia
</Text>
</Stack> </Stack>
) : ( ) : (
<Box style={{ overflowX: 'auto' }}> <>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table <Table
highlightOnHover highlightOnHover
striped striped
@@ -77,39 +101,46 @@ function ListDaftarInformasi({ search }: { search: string }) {
> >
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '5%', textAlign: 'center' }}>No</TableTh> <TableTh w="25%">
<TableTh style={{ width: '25%' }}>Jenis Informasi</TableTh> <Text fw={600} lh={1.4}>
<TableTh style={{ width: '40%' }}>Deskripsi</TableTh> Jenis Informasi
<TableTh style={{ width: '20%', textAlign: 'center' }}>Aksi</TableTh> </Text>
</TableTh>
<TableTh w="40%">
<Text fw={600} lh={1.4}>
Deskripsi
</Text>
</TableTh>
<TableTh ta="center" w="20%">
<Text fw={600} lh={1.4}>
Aksi
</Text>
</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.map((item, index) => ( {filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd style={{ textAlign: 'center' }}>
<Text fz="sm">{(page - 1) * 10 + index + 1}</Text>
</TableTd>
<TableTd> <TableTd>
<Box w={200}> <Text fz="sm" fw={600} lh={1.5} lineClamp={1}>
<Text fw={500} lineClamp={1}>{item.jenisInformasi}</Text> {item.jenisInformasi}
</Box>
</TableTd>
<TableTd>
<Box w={200}>
<Text lineClamp={1} fz="sm" c="dimmed">
{item.deskripsi?.replace(/<[^>]*>?/gm, '').substring(0, 80) + '...'}
</Text> </Text>
</Box>
</TableTd> </TableTd>
<TableTd style={{ textAlign: 'center' }}> <TableTd>
<Text fz="sm" lh={1.5} c="dimmed" lineClamp={1}>
{item.deskripsi?.replace(/<[^>]*>?/gm, '').substring(0, 80)}...
</Text>
</TableTd>
<TableTd ta="center">
<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={() => router.push(`/admin/ppid/daftar-informasi-publik/${item.id}`)} onClick={() =>
router.push(`/admin/ppid/daftar-informasi-publik/${item.id}`)
}
> >
Detail Detail
</Button> </Button>
@@ -119,9 +150,51 @@ function ListDaftarInformasi({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Card List */}
<Stack hiddenFrom="md" gap="sm">
{filteredData.map((item) => (
<Paper key={item.id} withBorder p="sm" radius="md">
<Stack gap={4}>
<Box>
<Text fw={600} lh={1.4}>
Jenis Informasi
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.jenisInformasi}
</Text>
</Box>
<Box>
<Text fw={600} lh={1.4}>
Deskripsi
</Text>
<Text fz="sm" fw={500} lh={1.5} c="dimmed">
{item.deskripsi?.replace(/<[^>]*>?/gm, '').substring(0, 80)}...
</Text>
</Box>
<Box>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/ppid/daftar-informasi-publik/${item.id}`)
}
>
Detail
</Button>
</Box>
</Stack>
</Paper>
))}
</Stack>
</>
)} )}
</Paper> </Paper>
<Center mt="lg">
<Center mt={{ base: 'lg', md: 'xl' }}>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => { onChange={(newPage) => {
@@ -129,14 +202,12 @@ function ListDaftarInformasi({ search }: { search: string }) {
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}} }}
total={totalPages} total={totalPages}
mt="md"
mb="md"
color="blue" color="blue"
radius="md" radius="md"
/> />
</Center> </Center>
</Box> </Box>
) );
} }
export default DaftarInformasiPublik; export default DaftarInformasiPublik;

View File

@@ -8,11 +8,11 @@ import { useProxy } from 'valtio/utils';
import stateDasarHukumPPID from '../../_state/ppid/dasar_hukum/dasarHukum'; import stateDasarHukumPPID from '../../_state/ppid/dasar_hukum/dasarHukum';
function Page() { function Page() {
const router = useRouter() const router = useRouter();
const listDasarHukum = useProxy(stateDasarHukumPPID) const listDasarHukum = useProxy(stateDasarHukumPPID);
useShallowEffect(() => { useShallowEffect(() => {
listDasarHukum.findById.load('1') listDasarHukum.findById.load('1');
}, []) }, []);
if (listDasarHukum.findById.loading) { if (listDasarHukum.findById.loading) {
return ( return (
@@ -41,6 +41,7 @@ function Page() {
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Button <Button
w={{ base: '100%', md: "110%" }}
c="green" c="green"
variant="light" variant="light"
leftSection={<IconEdit size={18} stroke={2} />} leftSection={<IconEdit size={18} stroke={2} />}
@@ -57,33 +58,39 @@ function Page() {
<Grid> <Grid>
<GridCol span={12}> <GridCol span={12}>
<Center> <Center>
<Image loading='lazy' src="/darmasaba-icon.png" w={{ base: 100, md: 150 }} alt="Logo PPID" /> <Image loading="lazy" src="/darmasaba-icon.png" w={{ base: 100, md: 150 }} alt="Logo PPID" />
</Center> </Center>
</GridCol> </GridCol>
<GridCol span={12}> <GridCol span={12}>
<Text <Title
order={3}
ta="center" ta="center"
fz={{ base: '1.5rem', md: '2rem' }} lh={{ base: 1.15, md: 1.1 }}
fw="bold" fw="bold"
c={colors['blue-button']} c={colors['blue-button']}
dangerouslySetInnerHTML={{ __html: listDasarHukum.findById.data.judul }} dangerouslySetInnerHTML={{ __html: listDasarHukum.findById.data.judul }}
style={{ wordBreak: "break-word", whiteSpace: "normal" }} style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
/> />
</GridCol> </GridCol>
</Grid> </Grid>
<Divider my="xl" color={colors['blue-button']} /> <Divider my="xl" color={colors['blue-button']} />
<Box <Text
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: listDasarHukum.findById.data.content }} dangerouslySetInnerHTML={{ __html: listDasarHukum.findById.data.content }}
style={{ wordBreak: "break-word", whiteSpace: "normal", fontSize: '1.1rem', lineHeight: 1.7, textAlign: 'justify' }} style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
fontSize: '1rem',
lineHeight: 1.55,
textAlign: 'justify',
}}
/> />
</Box> </Box>
</Paper> </Paper>
</Stack> </Stack>
</Paper> </Paper>
) );
} }
export default Page; export default Page;

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, Tooltip } from '@mantine/core'; import { Box, ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title} from '@mantine/core';
import { IconChartBar, IconUsers } from '@tabler/icons-react'; import { IconChartBar, IconUsers } 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';
@@ -56,6 +56,8 @@ function LayoutTabsIKM({ children }: { children: React.ReactNode }) {
radius="lg" radius="lg"
keepMounted={false} keepMounted={false}
> >
{/* ✅ Scroll horizontal wrapper */}
<Box visibleFrom='md' pb={10}>
<ScrollArea type="auto" offsetScrollbars> <ScrollArea type="auto" offsetScrollbars>
<TabsList <TabsList
p="sm" p="sm"
@@ -69,29 +71,62 @@ function LayoutTabsIKM({ children }: { children: React.ReactNode }) {
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}} }}
> >
{tabs.map((e, i) => ( {tabs.map((tab, i) => (
<Tooltip
key={i}
label={e.description}
withArrow
position="bottom"
transitionProps={{ transition: 'pop', duration: 200 }}
>
<TabsTab <TabsTab
value={e.value} key={i}
leftSection={e.icon} value={tab.value}
leftSection={tab.icon}
style={{ style={{
fontWeight: 500, fontWeight: 600,
fontSize: "0.9rem", fontSize: "0.9rem",
transition: "all 0.2s ease", transition: "all 0.2s ease",
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}} }}
> >
{e.label} {tab.label}
</TabsTab> </TabsTab>
</Tooltip>
))} ))}
</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((e, i) => ( {tabs.map((e, i) => (
<TabsPanel key={i} value={e.value} mt="md"> <TabsPanel key={i} value={e.value} mt="md">
{/* Konten dummy, bisa diganti tergantung routing */} {/* Konten dummy, bisa diganti tergantung routing */}

View File

@@ -85,7 +85,7 @@ function EditResponden() {
}; };
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

@@ -38,7 +38,7 @@ export default function DetailResponden() {
) )
} }
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

@@ -51,7 +51,7 @@ function RespondenCreate() {
} }
} }
return ( return (
<Box> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Box mb={10}> <Box mb={10}>
<Button variant="subtle" onClick={() => router.back()}> <Button variant="subtle" onClick={() => router.back()}>
<IconArrowBack size={20} /> <IconArrowBack size={20} />

View File

@@ -1,5 +1,21 @@
'use client'; 'use client';
import { Box, Button, Center, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core'; import {
Box,
Button,
Center,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconSearch } from '@tabler/icons-react'; import { IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -9,11 +25,11 @@ import HeaderSearch from '../../../_com/header';
import indeksKepuasanState from '../../../_state/landing-page/indeks-kepuasan'; import indeksKepuasanState from '../../../_state/landing-page/indeks-kepuasan';
function Responden() { function Responden() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState('');
return ( return (
<Box> <Box>
<HeaderSearch <HeaderSearch
title="Data Responden" title="Responden"
placeholder="Cari nama responden..." placeholder="Cari nama responden..."
searchIcon={<IconSearch size={18} />} searchIcon={<IconSearch size={18} />}
value={search} value={search}
@@ -33,17 +49,13 @@ function ListResponden({ search }: ListRespondenProps) {
const router = useRouter(); const router = useRouter();
const { data, page, totalPages, loading, load } = state.findMany; const { data, page, totalPages, loading, load } = state.findMany;
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10) load(page, 10);
}, [page]); }, [page]);
const filteredData = (data || []).filter((item) => {
const filteredData = (data || []).filter(item => {
const keyword = search.toLowerCase(); const keyword = search.toLowerCase();
return ( return item.name.toLowerCase().includes(keyword);
item.name.toLowerCase().includes(keyword)
);
}); });
if (loading || !data) { if (loading || !data) {
@@ -56,21 +68,25 @@ function ListResponden({ search }: ListRespondenProps) {
if (data.length === 0) { if (data.length === 0) {
return ( return (
<Paper withBorder bg="white" p="lg" radius="md" shadow="sm"> <Paper withBorder bg="white" p={{ base: 'md', sm: 'lg' }} radius="md" shadow="sm">
<Stack gap="md"> <Stack gap="md">
<Title order={3}>Data Responden</Title> <Title order={2} lh={1.2}>
Data Responden
</Title>
<Box visibleFrom="md">
<Table striped withRowBorders> <Table striped withRowBorders>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ textAlign: 'center' }}>No</TableTh> <TableTh ta="center">No</TableTh>
<TableTh>Nama</TableTh> <TableTh>Nama</TableTh>
<TableTh>Tanggal</TableTh> <TableTh>Tanggal</TableTh>
<TableTh>Jenis Kelamin</TableTh> <TableTh>Jenis Kelamin</TableTh>
<TableTh style={{ textAlign: 'center' }}>Aksi</TableTh> <TableTh ta="center">Aksi</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
</Table> </Table>
<Text c="dimmed" ta="center" py="md"> </Box>
<Text c="dimmed" ta="center" py="md" fz={{ base: 'sm', md: 'md' }} lh={1.4}>
Belum ada data responden yang tersedia Belum ada data responden yang tersedia
</Text> </Text>
</Stack> </Stack>
@@ -79,24 +95,33 @@ function ListResponden({ search }: ListRespondenProps) {
} }
return ( return (
<Paper withBorder bg="white" p="lg" radius="md" shadow="sm"> <Paper withBorder bg="white" p={{ base: 'md', sm: 'lg' }} radius="md" shadow="sm">
<Stack gap="md"> <Stack gap="md">
<Title order={3}>Data Responden</Title> <Title order={2} lh={1.2}>
Data Responden
</Title>
{/* Desktop Table */}
<Box visibleFrom="md">
<Table striped withRowBorders> <Table striped withRowBorders>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '5%', textAlign: 'center' }}>No</TableTh> <TableTh w="5%" ta="center">
<TableTh style={{ width: '25%' }}>Nama</TableTh> No
<TableTh style={{ width: '25%' }}>Tanggal</TableTh> </TableTh>
<TableTh style={{ width: '20%' }}>Jenis Kelamin</TableTh> <TableTh w="25%">Nama</TableTh>
<TableTh style={{ width: '15%', textAlign: 'center' }}>Aksi</TableTh> <TableTh w="25%">Tanggal</TableTh>
<TableTh w="20%">Jenis Kelamin</TableTh>
<TableTh w="15%" ta="center">
Aksi
</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.length === 0 ? ( {filteredData.length === 0 ? (
<TableTr> <TableTr>
<TableTd colSpan={5}> <TableTd colSpan={5}>
<Text c="dimmed" ta="center" py="md"> <Text c="dimmed" ta="center" py="md" fz="sm" lh={1.4}>
Tidak ada data yang cocok dengan pencarian Tidak ada data yang cocok dengan pencarian
</Text> </Text>
</TableTd> </TableTd>
@@ -104,20 +129,22 @@ function ListResponden({ search }: ListRespondenProps) {
) : ( ) : (
filteredData.map((item, index) => ( filteredData.map((item, index) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd style={{ width: '5%', textAlign: 'center' }}>{index + 1}</TableTd> <TableTd ta="center">{index + 1}</TableTd>
<TableTd style={{ width: '25%' }}>{item.name}</TableTd> <TableTd>{item.name}</TableTd>
<TableTd style={{ width: '25%' }}> <TableTd>
{item.tanggal ? new Date(item.tanggal).toLocaleDateString('id-ID') : '-'} {item.tanggal ? new Date(item.tanggal).toLocaleDateString('id-ID') : '-'}
</TableTd> </TableTd>
<TableTd style={{ width: '20%' }}>{item.jenisKelamin.name}</TableTd> <TableTd>{item.jenisKelamin.name}</TableTd>
<TableTd style={{ width: '15%', textAlign: 'center' }}> <TableTd ta="center">
<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={() => router.push(`/admin/ppid/ikm-desa-darmasaba/responden/${item.id}`)} onClick={() =>
router.push(`/admin/ppid/ikm-desa-darmasaba/responden/${item.id}`)
}
> >
Detail Detail
</Button> </Button>
@@ -127,6 +154,74 @@ function ListResponden({ search }: ListRespondenProps) {
)} )}
</TableTbody> </TableTbody>
</Table> </Table>
</Box>
{/* Mobile Card View */}
<Box hiddenFrom="md">
{filteredData.length === 0 ? (
<Text c="dimmed" ta="center" py="md" fz="sm" lh={1.4}>
Tidak ada data yang cocok dengan pencarian
</Text>
) : (
<Stack gap="sm">
{filteredData.map((item, index) => (
<Paper key={item.id} withBorder p="sm" radius="md">
<Stack gap={4}>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
No
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{index + 1}
</Text>
</Box>
<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}>
Tanggal
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.tanggal
? new Date(item.tanggal).toLocaleDateString('id-ID')
: '-'}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Jenis Kelamin
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.jenisKelamin.name}
</Text>
</Box>
<Box ta="center">
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/ppid/ikm-desa-darmasaba/responden/${item.id}`)
}
>
Detail
</Button>
</Box>
</Stack>
</Paper>
))}
</Stack>
)}
</Box>
{filteredData.length > 0 && ( {filteredData.length > 0 && (
<Center> <Center>
<Pagination <Pagination
@@ -138,7 +233,6 @@ function ListResponden({ search }: ListRespondenProps) {
}} }}
size="md" size="md"
radius="md" radius="md"
mt="md"
/> />
</Center> </Center>
)} )}
@@ -148,5 +242,3 @@ function ListResponden({ search }: ListRespondenProps) {
} }
export default Responden; export default Responden;

View File

@@ -27,7 +27,7 @@ function DetailPermohonanInformasiPublik() {
const data = state.findUnique.data; const data = state.findUnique.data;
return ( return (
<Box py="md"> <Box px={{ base: 0, md: 'xs' }} py="xs">
<Button <Button
variant="subtle" variant="subtle"
onClick={() => router.back()} onClick={() => router.back()}
@@ -39,7 +39,7 @@ function DetailPermohonanInformasiPublik() {
<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

@@ -1,82 +1,182 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors' import colors from '@/con/colors'
import { Box, Button, Group, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core' import {
import { useShallowEffect } from '@mantine/hooks' Box,
import { IconDeviceImacCog, IconId, IconInfoCircle, IconPhone, IconUser } from '@tabler/icons-react' Button,
Center,
Grid,
GridCol,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
TextInput,
Title,
} from '@mantine/core'
import {
IconDeviceImacCog,
IconId,
IconInfoCircle,
IconPhone,
IconSearch,
IconUser,
} from '@tabler/icons-react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useProxy } from 'valtio/utils' import { useProxy } from 'valtio/utils'
import statepermohonanInformasiPublikForm from '../../_state/ppid/permohonan_informasi_publik/permohonanInformasiPublik' import statepermohonanInformasiPublikForm from '../../_state/ppid/permohonan_informasi_publik/permohonanInformasiPublik'
import { useDebouncedValue } from '@mantine/hooks'
function Page() { function Page() {
const permohonanInformasiPublikState = useProxy(statepermohonanInformasiPublikForm) const permohonanInformasiPublikState = useProxy(statepermohonanInformasiPublikForm)
const router = useRouter() const router = useRouter()
const { data, page, totalPages, loading, load } = permohonanInformasiPublikState.statepermohonanInformasiPublik.findMany
const [search, setSearch] = useState('')
const [debouncedSearch] = useDebouncedValue(search, 1000);
useShallowEffect(() => { useEffect(() => {
permohonanInformasiPublikState.statepermohonanInformasiPublik.findMany.load() load(page, 10, debouncedSearch);
}, []) }, [page, debouncedSearch]);
if (!permohonanInformasiPublikState.statepermohonanInformasiPublik.findMany.data) { if (loading) {
return ( return (
<Stack pos="relative" bg={colors.Bg} p="lg" align="center"> <Stack pos="relative" p="lg" align="center">
<Skeleton radius="md" h={40} w="60%" />
<Skeleton radius="md" h={200} w="100%" /> <Skeleton radius="md" h={200} w="100%" />
</Stack> </Stack>
) )
} }
if (!data || data.length === 0) {
return (
<Box py={{ base: 'md', md: 'lg' }}>
<Paper bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} radius="xl" shadow="sm" withBorder>
<Stack gap={'sm'}>
<Grid mb={10}>
<GridCol span={{ base: 12, md: 9 }}>
<Title order={2} lh={1.2} c="dark">
Daftar Permohonan Informasi Publik
</Title>
</GridCol>
<GridCol span={{ base: 12, md: 3 }}>
<Paper radius="lg" bg={colors['white-1']}>
<TextInput
radius="lg"
placeholder={"Cari nama..."}
leftSection={<IconSearch size={16} />}
w="100%"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Paper>
</GridCol>
</Grid>
<Stack align="center" py="xl" ta="center">
<IconInfoCircle size={40} stroke={1.5} color={colors['blue-button']} />
<Text fz={{ base: 'sm', md: 'md' }} fw={500} c="dimmed" lh={1.5}>
{search
? 'Tidak ditemukan data yang sesuai dengan pencarian'
: 'Belum ada permohonan yang tercatat'
}
</Text>
</Stack>
</Stack>
</Paper>
</Box>
)
}
const data = permohonanInformasiPublikState.statepermohonanInformasiPublik.findMany.data
return ( return (
<Box py="md"> <Box py={{ base: 'sm', md: 'md' }}>
<Paper bg={colors['white-1']} p="lg" radius="xl" shadow="sm" withBorder> <Paper bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} radius="xl" shadow="sm" withBorder>
<Stack gap="md"> <Stack gap={'sm'}>
<Group justify="space-between"> <Grid mb={10}>
<Title order={2} c="dark">Daftar Permohonan Informasi Publik</Title> <GridCol span={{ base: 12, md: 9 }}>
<IconInfoCircle size={20} stroke={1.5} /> <Title order={2} lh={1.2} c="dark">
</Group> Daftar Permohonan Informasi Publik
</Title>
</GridCol>
<GridCol span={{ base: 12, md: 3 }}>
<Paper radius="lg" bg={colors['white-1']}>
<TextInput
radius="lg"
placeholder={"Cari nama..."}
leftSection={<IconSearch size={16} />}
w="100%"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Paper>
</GridCol>
</Grid>
{data.length === 0 ? ( {data.length === 0 ? (
<Stack align="center" py="xl"> <Stack align="center" py={{ base: 'xl', md: 'xl' }}>
<IconInfoCircle size={40} stroke={1.5} color={colors['blue-button']} /> <IconInfoCircle size={40} stroke={1.5} color={colors['blue-button']} />
<Text fw={500} c="dimmed">Belum ada permohonan informasi yang tercatat</Text> <Text fz={{ base: 'sm', md: 'md' }} fw={500} c="dimmed" lh={1.5}>
Belum ada permohonan informasi yang tercatat
</Text>
</Stack> </Stack>
) : ( ) : (
<Box style={{ overflowX: 'auto' }}> <>
<Table {/* Desktop Table */}
highlightOnHover <Box visibleFrom="md">
withRowBorders <Table highlightOnHover>
withColumnBorders
withTableBorder
striped
stickyHeader
>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>No</TableTh> <TableTh fz="sm" fw={600} ta="center" w={60}>
<TableTh><Group gap={5}><IconUser size={16} /> Nama</Group></TableTh> No
<TableTh><Group gap={5}><IconId size={16} /> NIK</Group></TableTh> </TableTh>
<TableTh><Group gap={5}><IconPhone size={16} /> Telepon</Group></TableTh> <TableTh fz="sm" fw={600}>
<TableTh><Group gap={5}><IconInfoCircle size={16} /> Detail</Group></TableTh> <Group gap={5}>
<IconUser size={16} />
Nama
</Group>
</TableTh>
<TableTh fz="sm" fw={600}>
<Group gap={5}>
<IconId size={16} />
NIK
</Group>
</TableTh>
<TableTh fz="sm" fw={600}>
<Group gap={5}>
<IconPhone size={16} />
Telepon
</Group>
</TableTh>
<TableTh fz="sm" fw={600} w={140}>
<Group gap={5}>
<IconInfoCircle size={16} />
Detail
</Group>
</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{data.map((item, index) => ( {data.map((item, index) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd>{index + 1}</TableTd> <TableTd ta="center" fz="sm" lh={1.5}>
<TableTd> {index + 1}
<Box w={200}>
<Text lineClamp={1} fw={500}>{item.name}</Text>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={200}> <Text fz="sm" fw={500} lh={1.5} lineClamp={1}>
{item.nik} {item.name}
</Box> </Text>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={200}> <Text fz="sm" lh={1.5}>{item.nik}</Text>
{item.notelp} </TableTd>
</Box> <TableTd>
<Text fz="sm" lh={1.5}>{item.notelp}</Text>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button <Button
@@ -85,7 +185,9 @@ function Page() {
variant="light" variant="light"
color="blue" color="blue"
leftSection={<IconDeviceImacCog size={16} />} leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/admin/ppid/permohonan-informasi-publik/${item.id}`)} onClick={() =>
router.push(`/admin/ppid/permohonan-informasi-publik/${item.id}`)
}
> >
Detail Detail
</Button> </Button>
@@ -95,8 +197,78 @@ function Page() {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Cards */}
<Stack hiddenFrom="md" gap="xs">
{data.map((item, index) => (
<Paper key={item.id} p="sm" radius="md" withBorder bg="white">
<Stack gap={4}>
<Box>
<Text fz="xs" fw={600} lh={1.4} c="dark">
No
</Text>
<Text fz="sm" fw={500} lh={1.5} c="dark">
{index + 1}
</Text>
</Box>
<Box>
<Text fz="xs" fw={600} lh={1.4} c="dark">
Nama
</Text>
<Text fz="sm" fw={500} lh={1.5} c="dark" lineClamp={1}>
{item.name}
</Text>
</Box>
<Box>
<Text fz="xs" fw={600} lh={1.4} c="dark">
NIK
</Text>
<Text fz="sm" lh={1.5} c="dark">{item.nik}</Text>
</Box>
<Box>
<Text fz="xs" fw={600} lh={1.4} c="dark">
Telepon
</Text>
<Text fz="sm" lh={1.5} c="dark">{item.notelp}</Text>
</Box>
<Box>
<Text fz="xs" fw={600} lh={1.4} c="dark">
Detail
</Text>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/ppid/permohonan-informasi-publik/${item.id}`)
}
mt={2}
>
Lihat Detail
</Button>
</Box>
</Stack>
</Paper>
))}
</Stack>
</>
)} )}
</Stack> </Stack>
<Center mt="xl">
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10);
window.scrollTo(0, 0);
}}
total={totalPages}
withEdges
withControls
radius="md"
/>
</Center>
</Paper> </Paper>
</Box> </Box>
) )

View File

@@ -28,7 +28,7 @@ function DetailPermohonanKeberatanInformasiPublik() {
const data = state.findUnique.data; const data = state.findUnique.data;
return ( return (
<Box py="md"> <Box px={{ base: 0, md: 'xs' }} py="xs">
<Button <Button
variant="subtle" variant="subtle"
onClick={() => router.back()} onClick={() => router.back()}
@@ -40,7 +40,7 @@ function DetailPermohonanKeberatanInformasiPublik() {
<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"

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