Compare commits

...

35 Commits

Author SHA1 Message Date
503da91ce6 Tambah seeder di bagian landing page 2026-01-06 17:54:21 +08:00
daaed8089b Fix All Search Admin 2026-01-05 17:11:30 +08:00
f436aa2ef0 Fix QC Kak Inno Mobile Done
FIx QC Kak Ayu Mobile Admin Done
Fix Tampilan Admin Mobile Device All Menu Done
2026-01-02 16:33:15 +08:00
50bc54ceca Fix QC Kak Inno 22 Des
Fix QC Kak Ayu 22 Des
Fix Tampilan Admin Menu Inovasi
2025-12-24 14:36:51 +08:00
f0f201c853 Fix QC Kak Inno 22 Des
Fix QC Kak Ayu 22 Des
Fix Tampilan Admin Mobile Device Menu Ekonomi
Fix Search -> useDebounced Menu Ekonomi
2025-12-23 17:18:36 +08:00
29065cb3e2 Fix QC Kak Inno 19 Des
Fix QC Kak Ayu 19 Des
Fix Tampilan Admin Mobile Menu Keamanan
Fix Search Debounce Menu Keamanan
2025-12-22 15:10:25 +08:00
bf20cd55e8 Fix QC Kak Inno 18 Des
Fix UI Admin Menu Kesehatan
Fix Search : Sudah diberi useDebounced menu Kesehatan
2025-12-19 15:43:55 +08:00
af60bcd6fc Fix QC Kak Inno Tgl 17
Fix QC Kak Ayu Tgl 17
Fix UI Admin Mobile Menu PPID
Search Admin Menu Landing Page & Menu PPID
2025-12-18 17:25:22 +08:00
dc8793e3ae Fix QC Kak Inno 16 Des
Fix QC Kak Ayu 16 Des
FIx UI Admin Mobile Menu PPID
Fix Search Admin Menu Landing Page & Menu PPID
2025-12-17 17:37:58 +08:00
c8484357cb Fix QC Kak Ayu 15 Des
Fix QC Kak Inno 15 Des
Fix UI User Font Size, Font Weight, Line Height
Fix UI Admin Font Size, Font Weight, Line Height & UI Mobile
2025-12-16 16:37:17 +08:00
342e9bbc65 Fix QC Kak Ayu Tgl 12
Fix QC Kak Ino Tgl 12
Fix UI Mobile Menu Keamanan
Fix UI Mobile Admin Menu Landing Page
2025-12-16 10:19:15 +08:00
f6f77d9e35 Fix QC Kak Inno Tgl 11 Des
Fix QC Kak Ayu Tgl 11 Des
Fix font style {font size, color, line height} menu kesehatan
2025-12-12 17:06:33 +08:00
a00481152c Fix Konsisten teks di tampilan mobile dan desktop
Fix QC Kak Inno tgl 10 Des
Fix QC Kak Ayu tgl 10 Des
2025-12-11 17:58:03 +08:00
242ea86f77 Fix konsisten font, menu landing page & PPID 2025-12-10 17:44:31 +08:00
99c2c9c6d7 Fix semua tulisan profile jadi profil, mulai dari navbar, dan route 2025-12-10 14:16:15 +08:00
ac2fc1a705 Fix QC Kak Inno 8 Des
Fix QC Kak Ayu 8 Des
Fix QC Pak Jun 8 Des
2025-12-09 17:27:23 +08:00
9dbe172165 Fix QC Kak Inno Tgl 4 & 5 Desember
Fix QC Kak Ayu Tgl 4 & 5 Desember
Fix QC Pak Jun Tgl 5 Desember
2025-12-09 12:00:27 +08:00
cc318d4d54 Fix QC Kak Inno Tgl 4 & 5 Desember
Fix QC Kak Ayu Tgl 4 & 5 Desember
Fix QC Pak Jun Tgl 5 Desember
2025-12-09 10:28:17 +08:00
dcb8017594 Fix undefined ke detail berita terbaru 2025-12-05 17:42:04 +08:00
ec3ad12531 Fix Notifikasi saat ada berita atau pengumuman baru, notifikasi baru muncul. Ga setiap masuk landing page ada notifikasi 2025-12-05 14:30:53 +08:00
dad44c0537 Fix Menu Gallery : Gallery Foto
Fix detail berita
2025-12-05 10:56:03 +08:00
867dce42f0 Fix Error Build Staging 2025-12-04 11:58:47 +08:00
7bb17ddf22 Menambahkan menu dokter dan tenaga medis, admin bisa create, edit, delet dokter
Menambahkan menu tarif dan layanan, admin bisa create, edit, delete tarif dan layanan
Dibagian fasilitas kesehatan admin bisa multiselect bagian dokter dan tarif layanan
Di tampilan user juga sudah disesuaikan dengan datanya bisa muncul lebih dari 1 dokter dan 1 tarif layanan
2025-12-03 17:24:03 +08:00
a4069d3cba Fix UI Sosial Media Landing Page in User 2025-12-02 16:45:55 +08:00
ffe5e6dd9f Fix menu admin landing page, submenu sosial media 2025-12-02 16:06:14 +08:00
dcf195f54f Tambahan filter data sesuai tahun, di landing page apbdes 2025-12-01 17:11:24 +08:00
c03a6b3aed Tambah Term of Service di Registrasi 2025-12-01 14:01:03 +08:00
1bb9f239db Tambah Term of Service di Registrasi 2025-12-01 13:50:25 +08:00
a213ff7d37 Tambah Term of Service di Registrasi 2025-12-01 12:10:22 +08:00
0018bdc251 Fix Ganti Role, ganti role menunya sudah menyesuaikan 2025-11-28 15:03:18 +08:00
83fb39a957 Fix Ganti Role, ganti role menunya sudah menyesuaikan 2025-11-28 15:00:09 +08:00
7238692dd0 Push WebDesaDarmasabaSatging 2025-11-28 13:56:40 +08:00
8b50139d79 Push Staging 2025-11-28 12:03:07 +08:00
066180fc0e Fix registrasi, waitong-room, & tampilan layout sesuai id 2025-11-28 11:13:20 +08:00
67f29aabef Balik ke awal 2025-11-27 18:53:33 +08:00
660 changed files with 31055 additions and 14127 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

@@ -0,0 +1,30 @@
export default async function fetchWithRetry(
url: string,
retries = 3,
timeoutMs = 20000
) {
for (let attempt = 1; attempt <= retries; attempt++) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
return res;
} catch (err) {
console.warn(`⚠️ Download attempt ${attempt} failed`);
if (attempt === retries) {
throw err;
}
} finally {
clearTimeout(timeout);
}
}
throw new Error("Unreachable");
}

View File

@@ -1,137 +1,120 @@
[ [
{ {
"id": "cmff0rr4z0002vn0twp333m2", "id": "cmk27746i0000vnso2aspwf9g",
"name": "S6RIjFaPvdQm3oq4rM4X9-desktop.webp", "name": "Eqlrr1W-pK8ShMGqgPGL3-desktop.webp",
"realName": "bares.png",
"path": "uploads/images",
"mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/S6RIjFaPvdQm3oq4rM4X9-desktop.webp",
"category": "image"
},
{
"id": "cmff0tnf00003vn0t3kgzi0u0",
"name": "_pVNEmThU5ICGa8gv3gh_-desktop.webp",
"realName": "bicara-darma.png",
"path": "uploads/images",
"mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/_pVNEmThU5ICGa8gv3gh_-desktop.webp",
"category": "image"
},
{
"id": "cmff0uykf0004vn0trmmxpgfh",
"name": "bv6rdKvjxkkjUSGLQ0lvB-desktop.webp",
"realName": "daves.png",
"path": "uploads/images",
"mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/bv6rdKvjxkkjUSGLQ0lvB-desktop.webp",
"category": "image"
},
{
"id": "cmff0z34f0005vn0tjtvq519p",
"name": "Z4hWaV04CvoE20MjccQsV-desktop.webp",
"realName": "mangan.png",
"path": "uploads/images",
"mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/Z4hWaV04CvoE20MjccQsV-desktop.webp",
"category": "image"
},
{
"id": "cmff38cyq000bvn0t9f01cz3f",
"name": "LvLAtOqWojx4sn6NjJWB9-desktop.webp",
"realName": "gelah-melah.png",
"path": "uploads/images",
"mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/LvLAtOqWojx4sn6NjJWB9-desktop.webp",
"category": "image"
},
{
"id": "cmff0zqvd0007vn0tv6o5hjcq",
"name": "gR2mcvAQVgJ2-rM5coYJj-desktop.webp",
"realName": "inovasi-desa-darmasaba.png",
"path": "uploads/images",
"mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/gR2mcvAQVgJ2-rM5coYJj-desktop.webp",
"category": "image"
},
{
"id": "cmff1013m0008vn0th7t0d64d",
"name": "JpL-9F8-IGztMn8E2ce02-desktop.webp",
"realName": "pdkt.png",
"path": "uploads/images",
"mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/JpL-9F8-IGztMn8E2ce02-desktop.webp",
"category": "image"
},
{
"id": "cmff10cwq0009vn0tse8dzu3j",
"name": "bxAk4AsGbJTC705_IVdes-desktop.webp",
"realName": "sajjiana-dharma-raksaka.png",
"path": "uploads/images",
"mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/bxAk4AsGbJTC705_IVdes-desktop.webp",
"category": "image"
},
{
"id": "cmff2w5ly000avn0telhct71k",
"name": "Vbj_osnMJUkGEQGDTLwV--desktop.webp",
"realName": "perbekel.png", "realName": "perbekel.png",
"path": "uploads/images", "path": "uploads/images",
"mimeType": "image/webp", "mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/Vbj_osnMJUkGEQGDTLwV--desktop.webp", "link": "/api/fileStorage/findUnique/Eqlrr1W-pK8ShMGqgPGL3-desktop.webp",
"category": "image"
}
,
{
"id": "cmk20mg320000vnevxy0k73fr",
"name": "thpgPSJkBxUIRajZt3AVo-desktop.webp",
"realName": "bares.png",
"path": "uploads/images",
"mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/thpgPSJkBxUIRajZt3AVo-desktop.webp",
"category": "image" "category": "image"
}, },
{ {
"id": "cmff3joae0000vn6h8sgs0ilg", "id": "cmk20nqmu0001vnevfte29rk0",
"name": "7hox9spUxj56hY_EBYLnj-desktop.webp", "name": "ubna9N6r7RgVWN5plO5mq-desktop.webp",
"realName": "bicara-darma.png",
"path": "uploads/images",
"mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/ubna9N6r7RgVWN5plO5mq-desktop.webp",
"category": "image"
},
{
"id": "cmk228urs0007vnevi5b66bqn",
"name": "Z4i2RRnnlHq2iWj94ldyo-desktop.webp",
"realName": "daves.png",
"path": "uploads/images",
"mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/Z4i2RRnnlHq2iWj94ldyo-desktop.webp",
"category": "image"
},
{
"id": "cmk20nyen0002vnevd0hfr3u8",
"name": "y4yaE4XdUP1TSUGhWPW9h-desktop.webp",
"realName": "mangan.png",
"path": "uploads/images",
"mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/y4yaE4XdUP1TSUGhWPW9h-desktop.webp",
"category": "image"
},
{
"id": "cmk20o7mf0003vnevohrksm1d",
"name": "Vr7CoaYDpk2dIkHx9PxRj-desktop.webp",
"realName": "gelah-melah.png",
"path": "uploads/images",
"mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/Vr7CoaYDpk2dIkHx9PxRj-desktop.webp",
"category": "image"
},
{
"id": "cmk20of8m0004vnev9ujy5o0l",
"name": "ceoB_sg-HOzljN8j_2nZA-desktop.webp",
"realName": "inovasi-desa-darmasaba.png",
"path": "uploads/images",
"mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/ceoB_sg-HOzljN8j_2nZA-desktop.webp",
"category": "image"
},
{
"id": "cmk20omzq0005vnevgi6f4edu",
"name": "vOy5YVUXfHXfiFOHylIN7-desktop.webp",
"realName": "pdkt.png",
"path": "uploads/images",
"mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/vOy5YVUXfHXfiFOHylIN7-desktop.webp",
"category": "image"
},
{
"id": "cmk20pf3d0006vnev3mkoqpyy",
"name": "gE_qcqIbY0mqI6FV9V4CL-desktop.webp",
"realName": "sajjiana-dharma-raksaka.png",
"path": "uploads/images",
"mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/gE_qcqIbY0mqI6FV9V4CL-desktop.webp",
"category": "image"
},
{
"id": "cmk2cgqgm0003vn96jun52pik",
"name": "q1G995W7cLkC_qquLTlKN-desktop.webp",
"realName": "youtube.png", "realName": "youtube.png",
"path": "uploads/images", "path": "uploads/images",
"mimeType": "image/webp", "mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/7hox9spUxj56hY_EBYLnj-desktop.webp", "link": "/api/fileStorage/findUnique/q1G995W7cLkC_qquLTlKN-desktop.webp",
"category": "image" "category": "image"
}, },
{ {
"id": "cmff3ll130001vn6hkhls3f5y", "id": "cmk2cmr000006vn96qepq6gvl",
"name": "ChihV7_1eS-AGtSg9UwMv-desktop.webp", "name": "I6mlQ4nRmPX26gm79C_rM-desktop.webp",
"realName": "gmail.png",
"path": "uploads/images",
"mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/ChihV7_1eS-AGtSg9UwMv-desktop.webp",
"category": "image"
},
{
"id": "cmff3mtat0002vn6hs8vyyhdd",
"name": "z8v9ZREwOJHKGIRYauROt-desktop.webp",
"realName": "facebook.png", "realName": "facebook.png",
"path": "uploads/images", "path": "uploads/images",
"mimeType": "image/webp", "mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/z8v9ZREwOJHKGIRYauROt-desktop.webp", "link": "/api/fileStorage/findUnique/I6mlQ4nRmPX26gm79C_rM-desktop.webp",
"category": "image" "category": "image"
}, },
{ {
"id": "cmff3nv180003vn6h5jvedidq", "id": "cmk2cpeba0009vn966jcrpf3u",
"name": "BLjMxTKoCNE31uOURR3IU-desktop.webp", "name": "WArLC_yvU33MjoqEnQeQ1-desktop.webp",
"realName": "telephone-call.png",
"path": "uploads/images",
"mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/BLjMxTKoCNE31uOURR3IU-desktop.webp",
"category": "image"
},
{
"id": "cmff3oouh0004vn6hd94brzv9",
"name": "hkJYAeTNWK_vYaYS20w3I-desktop.webp",
"realName": "instagram.png", "realName": "instagram.png",
"path": "uploads/images", "path": "uploads/images",
"mimeType": "image/webp", "mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/hkJYAeTNWK_vYaYS20w3I-desktop.webp", "link": "/api/fileStorage/findUnique/WArLC_yvU33MjoqEnQeQ1-desktop.webp",
"category": "image" "category": "image"
}, },
{ {
"id": "cmff3q12g0005vn6h5ojov2qa", "id": "cmk2crcl1000cvn96j8pmgmo5",
"name": "6XEoZ9SFu59COpil03Gya-desktop.webp", "name": "D3RPbNiaNSCjacLjeR_qO-desktop.webp",
"realName": "tiktok.png", "realName": "tiktok.png",
"path": "uploads/images", "path": "uploads/images",
"mimeType": "image/webp", "mimeType": "image/webp",
"link": "/api/fileStorage/findUnique/6XEoZ9SFu59COpil03Gya-desktop.webp", "link": "/api/fileStorage/findUnique/D3RPbNiaNSCjacLjeR_qO-desktop.webp",
"category": "image" "category": "image"
} }
] ]

View File

@@ -3,24 +3,24 @@
"id": "cmds9023u0008vnbe3oxmhwyf", "id": "cmds9023u0008vnbe3oxmhwyf",
"name": "Desa Darmasaba", "name": "Desa Darmasaba",
"iconUrl": "https://www.youtube.com/channel/UCtPw9WOQO7d2HIKzKgel4Xg", "iconUrl": "https://www.youtube.com/channel/UCtPw9WOQO7d2HIKzKgel4Xg",
"imageId": "cmff3joae0000vn6h8sgs0ilg" "imageId": "cmk2cgqgm0003vn96jun52pik"
}, },
{ {
"id": "cmds90oul000bvnbe2bqkptoi", "id": "cmds90oul000bvnbe2bqkptoi",
"name": "Pemerintah Desa Darmasaba", "name": "Pemerintah Desa Darmasaba",
"iconUrl": "https://www.facebook.com/DarmasabaDesaku", "iconUrl": "https://www.facebook.com/DarmasabaDesaku",
"imageId": "cmff3mtat0002vn6hs8vyyhdd" "imageId": "cmk2cmr000006vn96qepq6gvl"
}, },
{ {
"id": "cmds91i4e000evnbe8gtf1gub", "id": "cmds91i4e000evnbe8gtf1gub",
"name": "ddarmasaba", "name": "ddarmasaba",
"iconUrl": "https://www.instagram.com/ddarmasaba/", "iconUrl": "https://www.instagram.com/ddarmasaba/",
"imageId": "cmff3oouh0004vn6hd94brzv9" "imageId": "cmk2cpeba0009vn966jcrpf3u"
}, },
{ {
"id": "cmds92de5000hvnbemlu6sq5x", "id": "cmds92de5000hvnbemlu6sq5x",
"name": "desa.darmasaba", "name": "desa.darmasaba",
"iconUrl": "https://www.tiktok.com/@desa.darmasaba?is_from_webapp=1&sender_device=pc", "iconUrl": "https://www.tiktok.com/@desa.darmasaba?is_from_webapp=1&sender_device=pc",
"imageId": "cmff3q12g0005vn6h5ojov2qa" "imageId": "cmk2crcl1000cvn96j8pmgmo5"
} }
] ]

View File

@@ -3,6 +3,6 @@
"id": "edit", "id": "edit",
"name": "I.B Surya Prabhawa Manuaba, S.H., M.H.", "name": "I.B Surya Prabhawa Manuaba, S.H., M.H.",
"position": "Perbekel Darmasaba periode 2021-2027", "position": "Perbekel Darmasaba periode 2021-2027",
"imageId": "cmff2w5ly000avn0telhct71k" "imageId": "cmk2a2dl6001nvngck1n0k8qc"
} }
] ]

View File

@@ -4,48 +4,55 @@
"name": "Dmangan", "name": "Dmangan",
"description": "Darmasaba Aman Pangan", "description": "Darmasaba Aman Pangan",
"link": "https://darmasaba.desa.id/berita/61452-kader-d-mangan-berhasil-meraih-prestasi-dalam-ajang-lomba-banjar-bali-quis-bbq-tahun-2024", "link": "https://darmasaba.desa.id/berita/61452-kader-d-mangan-berhasil-meraih-prestasi-dalam-ajang-lomba-banjar-bali-quis-bbq-tahun-2024",
"imageId" : "cmff0z34f0005vn0tjtvq519p" "imageId" : "cmk20nyen0002vnevd0hfr3u8"
}, },
{ {
"id": "cmdr76nqk0008vn5rdddvcxnr", "id": "cmdr76nqk0008vn5rdddvcxnr",
"name": "Bicara Darmasaba", "name": "Bicara Darmasaba",
"description": "Bicara Darmasaba", "description": "Bicara Darmasaba",
"link": "https://darmasaba.desa.id/berita/42506-bicara-darmasaba", "link": "https://darmasaba.desa.id/berita/42506-bicara-darmasaba",
"imageId" : "cmff0tnf00003vn0t3kgzi0u0" "imageId" : "cmk20nqmu0001vnevfte29rk0"
}, },
{ {
"id": "cmdr77vbw000bvn5rvpmoq31s", "id": "cmdr77vbw000bvn5rvpmoq31s",
"name": "Bares", "name": "Bares",
"description": "Darmasaba Recycling Stock/Exchange", "description": "Darmasaba Recycling Stock/Exchange",
"link": "http://darmasaba.desa.id/berita/56722-bares", "link": "http://darmasaba.desa.id/berita/56722-bares",
"imageId" : "cmff0rr4z0002vn0twp333m2" "imageId" : "cmk20mg320000vnevxy0k73fr"
}, },
{ {
"id": "cmdr7bxtp000evn5rmy85wihx", "id": "cmdr7bxtp000evn5rmy85wihx",
"name": "Sajjana Dharma Raksaka", "name": "Sajjana Dharma Raksaka",
"description": "Sajjana Dharma Raksaka", "description": "Sajjana Dharma Raksaka",
"link": "https://ppid.badungkab.go.id/storage/dokumen/5RS9dldGkrgzMQq6bKdZsqsVRHI8gffWv4PGfb3r.pdf", "link": "https://ppid.badungkab.go.id/storage/dokumen/5RS9dldGkrgzMQq6bKdZsqsVRHI8gffWv4PGfb3r.pdf",
"imageId" : "cmff10cwq0009vn0tse8dzu3j" "imageId" : "cmk20pf3d0006vnev3mkoqpyy"
}, },
{ {
"id": "cmdr7dlnk000hvn5r9lur3z35", "id": "cmdr7dlnk000hvn5r9lur3z35",
"name": "PDKT", "name": "PDKT",
"description": "Perangkat Desa Kuat Teknologi", "description": "Perangkat Desa Kuat Teknologi",
"link": "https://darmasaba.desa.id/berita/53752-p-d-k-t", "link": "https://darmasaba.desa.id/berita/53752-p-d-k-t",
"imageId" : "cmff1013m0008vn0th7t0d64d" "imageId" : "cmk20omzq0005vnevgi6f4edu"
}, },
{ {
"id": "cmdr7ftob000mvn5rfhgdtg8v", "id": "cmdr7ftob000mvn5rfhgdtg8v",
"name": "GM", "name": "GM",
"description": "Galah Melah", "description": "Galah Melah",
"link": "https://darmasaba.desa.id/berita/52880-galah-melah", "link": "https://darmasaba.desa.id/berita/52880-galah-melah",
"imageId" : "cmff38cyq000bvn0t9f01cz3f" "imageId" : "cmk20o7mf0003vnevohrksm1d"
}, },
{ {
"id": "cmdr7glue000pvn5r6onzslju", "id": "cmdr7glue000pvn5r6onzslju",
"name": "Inovasi Desa Darmasaba", "name": "Inovasi Desa Darmasaba",
"description": "Inovasi Desa Darmasaba", "description": "Inovasi Desa Darmasaba",
"link": "https://darmasaba.desa.id/produk-lokal-desa", "link": "https://darmasaba.desa.id/produk-lokal-desa",
"imageId" : "cmff0zqvd0007vn0tv6o5hjcq" "imageId" : "cmk20of8m0004vnev9ujy5o0l"
},
{
"id": "cmk228ust0009vnev5p8i377o",
"name": "Davest",
"description": "<p>DAVEST (Darmasaba Investment) merupakan program inovasi Desa Darmasaba yang bertujuan mempromosikan potensi investasi desa secara terintegrasi melalui media digital dan pendampingan langsung. Program ini menjadi sarana penghubung antara pemerintah desa, pelaku usaha, dan investor dalam rangka mendorong pertumbuhan ekonomi desa yang berkelanjutan.</p><p>DAVEST menyajikan informasi potensi unggulan desa seperti sektor UMKM, pariwisata, ekonomi kreatif, serta peluang investasi berbasis sumber daya lokal dengan prinsip transparansi dan kemudahan akses informasi.</p><p>Di tahun 2024 ini Davest (Darmasaba Village Festival) akan diadakan lagi, dengan berbagai kegiatan pemerdayaan, edukasi dan hiburan yang tentunya lebih waahhhh dari dua tahun lalu. Untuk memantapkan hal tersebut, Pemdes Darmasaba melakukan rapat koordinasi (rakor) Davest 2024 yang dipimpin langsung oleh Perbekel Darmasaba I. B. Surya Prabhawa Manuaba, S.H.,M.H. pada hari Senin (22/1/2024) bertempat di Ruang Shanti Gosana Kantor Perbekel Darmasaba.</p><hr><h3>Tujuan Program</h3><ul><li><p>Meningkatkan daya tarik investasi di Desa Darmasaba</p></li><li><p>Mempromosikan potensi unggulan desa secara profesional</p></li><li><p>Mendorong pertumbuhan ekonomi dan penciptaan lapangan kerja</p></li><li><p>Mendukung visi Desa Darmasaba sebagai desa inovatif dan berdaya saing</p></li></ul><hr><h3>Sasaran Program</h3><ul><li><p>Calon investor lokal dan regional</p></li><li><p>Pelaku UMKM dan kelompok usaha desa</p></li><li><p>Masyarakat Desa Darmasaba</p></li></ul><hr><h3>Bentuk Inovasi</h3><ul><li><p>Inovasi ekonomi desa</p></li><li><p>Inovasi digital</p></li><li><p>Inovasi tata kelola pelayanan investasi</p></li></ul><hr><h3>Ruang Lingkup Kegiatan</h3><ul><li><p>Penyusunan profil potensi investasi desa</p></li><li><p>Digitalisasi informasi investasi desa</p></li><li><p>Promosi peluang investasi melalui media online</p></li><li><p>Fasilitasi komunikasi antara investor dan desa</p></li><li><p>Pendampingan awal investasi berbasis desa</p></li></ul>",
"link": "https://darmasaba.desa.id/berita/55862-rakor-davest-2024",
"imageId" : "cmk228urs0007vnevi5b66bqn"
} }
] ]

View File

@@ -0,0 +1,11 @@
import safeImageId from "./safeImageId";
export default async function resolveImageIdForSeed(
existingImageId: string | null | undefined,
seedImageId: string | null | undefined
) {
if (existingImageId) return existingImageId;
// ✅ Skip validasi saat seed
return await safeImageId(seedImageId, true);
}

View File

@@ -0,0 +1,24 @@
import prisma from "@/lib/prisma";
export default async function safeImageId(
imageId?: string | null,
skipValidation = false // ✅ tambah param
) {
if (!imageId) return null;
if (skipValidation) {
console.log(`⚠️ Skipping validation for ${imageId} (seed mode)`);
return imageId; // langsung return tanpa cek DB
}
const exists = await prisma.fileStorage.findUnique({
where: { id: imageId },
});
if (!exists) {
console.warn(`⚠️ imageId ${imageId} not found in FileStorage`);
return null;
}
return imageId;
}

View File

@@ -0,0 +1,142 @@
/*
Warnings:
- You are about to drop the column `dokterdanTenagaMedisId` on the `FasilitasKesehatan` table. All the data in the column will be lost.
- You are about to drop the column `tarifDanLayananId` on the `FasilitasKesehatan` table. All the data in the column will be lost.
- You are about to drop the `User` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `UserSession` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `permissions` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "FasilitasKesehatan" DROP CONSTRAINT "FasilitasKesehatan_dokterdanTenagaMedisId_fkey";
-- DropForeignKey
ALTER TABLE "FasilitasKesehatan" DROP CONSTRAINT "FasilitasKesehatan_tarifDanLayananId_fkey";
-- DropForeignKey
ALTER TABLE "User" DROP CONSTRAINT "User_roleId_fkey";
-- DropForeignKey
ALTER TABLE "UserSession" DROP CONSTRAINT "UserSession_userId_fkey";
-- AlterTable
ALTER TABLE "DokterdanTenagaMedis" ADD COLUMN "jadwalLibur" TEXT,
ADD COLUMN "jamBukaLibur" TEXT,
ADD COLUMN "jamBukaOperasional" TEXT,
ADD COLUMN "jamTutupLibur" TEXT,
ADD COLUMN "jamTutupOperasional" TEXT;
-- AlterTable
ALTER TABLE "FasilitasKesehatan" DROP COLUMN "dokterdanTenagaMedisId",
DROP COLUMN "tarifDanLayananId";
-- AlterTable
ALTER TABLE "MediaSosial" ADD COLUMN "icon" TEXT;
-- AlterTable
ALTER TABLE "roles" ALTER COLUMN "permissions" DROP NOT NULL;
-- DropTable
DROP TABLE "User";
-- DropTable
DROP TABLE "UserSession";
-- DropTable
DROP TABLE "permissions";
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"nomor" TEXT NOT NULL,
"roleId" TEXT NOT NULL DEFAULT '2',
"isActive" BOOLEAN NOT NULL DEFAULT false,
"sessionInvalid" BOOLEAN NOT NULL DEFAULT false,
"lastLogin" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"permissions" JSONB,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_sessions" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
CONSTRAINT "user_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserMenuAccess" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"menuId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserMenuAccess_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_Tarif" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_Tarif_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateTable
CREATE TABLE "_Dokter" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_Dokter_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_nomor_key" ON "users"("nomor");
-- CreateIndex
CREATE INDEX "user_sessions_userId_idx" ON "user_sessions"("userId");
-- CreateIndex
CREATE INDEX "user_sessions_token_idx" ON "user_sessions"("token");
-- CreateIndex
CREATE UNIQUE INDEX "UserMenuAccess_userId_menuId_key" ON "UserMenuAccess"("userId", "menuId");
-- CreateIndex
CREATE INDEX "_Tarif_B_index" ON "_Tarif"("B");
-- CreateIndex
CREATE INDEX "_Dokter_B_index" ON "_Dokter"("B");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "roles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserMenuAccess" ADD CONSTRAINT "UserMenuAccess_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_Tarif" ADD CONSTRAINT "_Tarif_A_fkey" FOREIGN KEY ("A") REFERENCES "FasilitasKesehatan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_Tarif" ADD CONSTRAINT "_Tarif_B_fkey" FOREIGN KEY ("B") REFERENCES "TarifDanLayanan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_Dokter" ADD CONSTRAINT "_Dokter_A_fkey" FOREIGN KEY ("A") REFERENCES "DokterdanTenagaMedis"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_Dokter" ADD CONSTRAINT "_Dokter_B_fkey" FOREIGN KEY ("B") REFERENCES "FasilitasKesehatan"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,30 +1,63 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
// helpers/safeSeedUnique.ts import prisma from "@/lib/prisma";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient(); type SafeSeedOptions = {
skipUpdate?: boolean;
};
/** // prisma/safeseedUnique.ts
* Helper generic buat seed dengan upsert aman
*/
export async function safeSeedUnique<T extends keyof PrismaClient>( export async function safeSeedUnique<T extends keyof PrismaClient>(
model: T, model: T,
where: Record<string, any>, where: Record<string, any>,
data: Record<string, any> data: Record<string, any>,
options: SafeSeedOptions = {}
) { ) {
const m = prisma[model]; const m = prisma[model] as any;
if (!m) throw new Error(`Model ${String(model)} tidak ditemukan`);
if (!m) throw new Error(`Model ${String(model)} tidak ditemukan di PrismaClient`);
try { try {
// @ts-expect-error upsert dynamic // Pastikan `where` berisi field yang benar-benar unique (misal: `id`)
await m.upsert({ const result = await m.upsert({
where, where,
update: data, update: options.skipUpdate ? {} : data,
create: { ...where, ...data }, create: data, // ✅ Jangan duplikasi `where` ke `create`
}); });
console.log(`✅ Seeded ${String(model)} -> ${JSON.stringify(where)}`); console.log(`✅ Seed ${String(model)}:`, where);
return result;
} catch (err) { } catch (err) {
console.error(`❌ Gagal seed ${String(model)} -> ${JSON.stringify(where)}`, err); console.error(`❌ Gagal seed ${String(model)}:`, where, err);
throw err; // ✅ Rethrow agar seeding berhenti jika kritis
} }
} }
// /* eslint-disable @typescript-eslint/no-explicit-any */
// import { PrismaClient } from "@prisma/client";
// const prisma = new PrismaClient();
// type SafeSeedOptions = {
// skipUpdate?: boolean;
// };
// export async function safeSeedUnique<T extends keyof PrismaClient>(
// model: T,
// where: Record<string, any>,
// data: Record<string, any>,
// options: SafeSeedOptions = {}
// ) {
// const m = prisma[model] as any;
// if (!m) throw new Error(`Model ${String(model)} tidak ditemukan`);
// try {
// await m.upsert({
// where,
// update: options.skipUpdate ? {} : data,
// create: { ...where, ...data },
// });
// console.log(`✅ Seed ${String(model)}:`, where);
// } catch (err) {
// console.error(`❌ Gagal seed ${String(model)}:`, where, err);
// }
// }

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 ========================================= //

View File

@@ -60,8 +60,37 @@ import jenjangPendidikan from "./data/pendidikan/info-sekolah/jenjang-pendidikan
import seedAssets from "./seed_assets"; import seedAssets from "./seed_assets";
import users from "./data/user/users.json"; import users from "./data/user/users.json";
import { safeSeedUnique } from "./safeseedUnique"; import { safeSeedUnique } from "./safeseedUnique";
import safeImageId from "./data/safeImageId";
import resolveImageIdForSeed from "./data/resolveImageId";
(async () => { (async () => {
// seed assets
await prisma.fileStorage.deleteMany();
console.log("🗑️ Cleared existing fileStorage records");
await seedAssets();
// // =========== FILE STORAGE ===========
console.log("🔄 Seeding file storage...");
for (const f of fileStorage) {
await safeSeedUnique(
"fileStorage",
{ name: f.name },
{
id: f.id,
name: f.name,
realName: f.realName,
path: f.path,
mimeType: f.mimeType,
link: f.link,
category: f.category,
deletedAt: null,
isActive: true,
}
);
}
console.log("✅ File storage seeded");
console.log("🔄 Seeding roles..."); console.log("🔄 Seeding roles...");
for (const r of roles) { for (const r of roles) {
@@ -131,112 +160,119 @@ import { safeSeedUnique } from "./safeseedUnique";
} }
} }
console.log("✅ Users seeding completed"); console.log("✅ Users seeding completed");
// =========== FILE STORAGE ===========
console.log("🔄 Seeding file storage...");
for (const f of fileStorage) {
try {
await prisma.fileStorage.upsert({
where: { id: f.id },
update: {
name: f.name,
realName: f.realName,
path: f.path,
mimeType: f.mimeType,
link: f.link,
category: f.category,
},
create: {
id: f.id,
name: f.name,
realName: f.realName,
path: f.path,
mimeType: f.mimeType,
link: f.link,
category: f.category,
},
});
} catch (error: any) {
console.error(`❌ Failed to seed file storage ${f.name}:`, error.message);
}
}
console.log("✅ File storage seeded");
// =========== LANDING PAGE =========== // =========== LANDING PAGE ===========
// =========== SUBMENU PROFILE =========== // =========== SUBMENU PROFILE ===========
// =========== PROFILE PEJABAT DESA =========== // =========== PROFILE PEJABAT DESA ===========
// In your seed.ts file, update the PejabatDesa seeding section to:
console.log("🔄 Seeding Pejabat Desa...");
for (const p of profilePejabatDesa) { for (const p of profilePejabatDesa) {
await prisma.pejabatDesa.upsert({ try {
where: { id: p.id }, // First, verify the image exists
update: {
name: p.name,
position: p.position,
imageId: p.imageId,
},
create: {
id: p.id,
name: p.name,
position: p.position,
imageId: p.imageId,
},
});
}
console.log(
"✅ profilePejabatDesa seeded without imageId (editable later via UI)"
);
// =========== PROGRAM INOVASI ===========
for (const p of programInovasi) {
let imageId: string | null = null;
if (p.imageId) { if (p.imageId) {
const imageExists = await prisma.fileStorage.findUnique({ const imageExists = await prisma.fileStorage.findUnique({
where: { id: p.imageId }, where: { id: p.imageId },
}); });
if (imageExists) { if (!imageExists) {
imageId = p.imageId;
} else {
console.warn( console.warn(
`⚠️ imageId ${p.imageId} tidak ditemukan untuk ProgramInovasi ${p.name}` `⚠️ Image not found for PejabatDesa ${p.name}, skipping...`
); );
continue;
} }
} }
await safeSeedUnique(
"pejabatDesa",
{ id: p.id },
{
id: p.id,
name: p.name,
position: p.position,
imageId: p.imageId,
}
);
console.log(`✅ Seeded Pejabat Desa -> ${p.name}`);
} catch (error: any) {
console.error(`❌ Failed to seed Pejabat Desa ${p.name}:`, error.message);
}
}
console.log("✅ Pejabat Desa seeding completed");
// =========== PROGRAM INOVASI ===========
// Add this section after the other seed operations in seed.ts
console.log("🔄 Seeding Program Inovasi...");
for (const p of programInovasi) {
const existing = await prisma.programInovasi.findUnique({
where: { id: p.id },
select: { imageId: true },
});
let imageId = existing?.imageId; // Pertahankan existing
// Kalau belum ada imageId, cari berdasarkan name/realName
if (!imageId && p.imageId) {
// ✅ Cari langsung berdasarkan ID yang ada di p.imageId
const fileRecord = await prisma.fileStorage.findUnique({
where: { id: p.imageId },
select: { id: true, name: true },
});
if (fileRecord) {
imageId = fileRecord.id;
console.log(
`✅ Found file by ID: ${fileRecord.name} (${fileRecord.id})`
);
} else {
console.warn(`⚠️ File with ID ${p.imageId} not found for ${p.name}`);
imageId = null;
}
}
await prisma.programInovasi.upsert({ await prisma.programInovasi.upsert({
where: { id: p.id }, where: { id: p.id },
update: { update: {
name: p.name, name: p.name,
description: p.description, description: p.description,
link: p.link, link: p.link,
imageId: p.imageId, imageId,
}, },
create: { create: {
id: p.id, id: p.id,
name: p.name, name: p.name,
description: p.description, description: p.description,
link: p.link, link: p.link,
imageId: p.imageId, imageId,
}, },
}); });
} }
console.log("program inovasi success ...");
// =========== MEDIA SOSIAL =========== // =========== MEDIA SOSIAL ===========
for (const p of mediaSosial) { for (const m of mediaSosial) {
const existing = await prisma.mediaSosial.findUnique({
where: { id: m.id },
select: { imageId: true },
});
const imageId = await resolveImageIdForSeed(existing?.imageId, m.imageId);
await prisma.mediaSosial.upsert({ await prisma.mediaSosial.upsert({
where: { id: p.id }, where: { id: m.id },
update: { update: {
name: p.name, name: m.name,
iconUrl: p.iconUrl, iconUrl: m.iconUrl,
imageId: p.imageId, // ⛔ JANGAN overwrite imageId sembarangan
imageId,
}, },
create: { create: {
id: p.id, id: m.id,
name: p.name, name: m.name,
iconUrl: p.iconUrl, iconUrl: m.iconUrl,
imageId: p.imageId, imageId,
}, },
}); });
} }
console.log("media sosial success ..."); console.log("media sosial success ...");
// =========== SUBMENU DESA ANTI KORUPSI =========== // =========== SUBMENU DESA ANTI KORUPSI ===========
@@ -1245,9 +1281,6 @@ import { safeSeedUnique } from "./safeseedUnique";
} }
console.log("✅ Jenjang Pendidikan seeded successfully"); console.log("✅ Jenjang Pendidikan seeded successfully");
// seed assets
await seedAssets();
})() })()
.then(() => prisma.$disconnect()) .then(() => prisma.$disconnect())
.catch((e) => { .catch((e) => {

View File

@@ -1,10 +1,11 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// prisma/seedAssets.ts // prisma/seedAssets.ts
import prisma from "@/lib/prisma";
import AdmZip from "adm-zip";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import sharp from "sharp"; import sharp from "sharp";
import fetch from "node-fetch"; import fetchWithRetry from "./data/fetchWithRetry";
import AdmZip from "adm-zip";
import prisma from "@/lib/prisma";
const UPLOADS_DIR = const UPLOADS_DIR =
process.env.WIBU_UPLOAD_DIR || path.join(process.cwd(), "uploads"); process.env.WIBU_UPLOAD_DIR || path.join(process.cwd(), "uploads");
@@ -18,7 +19,10 @@ function detectCategory(filename: string): "image" | "document" | "other" {
} }
// --- Helper: recursive walk dir --- // --- Helper: recursive walk dir ---
async function walkDir(dir: string, fileList: string[] = []): Promise<string[]> { async function walkDir(
dir: string,
fileList: string[] = []
): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true }); const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
@@ -41,18 +45,45 @@ export default async function seedAssets() {
// 1. Download zip // 1. Download zip
const url = const url =
"https://cld-dkr-makuro-seafile.wibudev.com/f/ffd5a548a04f47939474/?dl=1"; "https://cld-dkr-makuro-seafile.wibudev.com/f/90dd12c9713e42379fcd/?dl=1";
const res = await fetch(url); const res = await fetchWithRetry(url, 3, 20000);
if (!res.ok) throw new Error(`Gagal download assets: ${res.statusText}`);
// Validasi content-type
const contentType = res.headers.get("content-type");
if (!contentType?.includes("zip")) {
throw new Error(`Invalid content-type (${contentType}). Expected ZIP file`);
}
const buffer = Buffer.from(await res.arrayBuffer()); const buffer = Buffer.from(await res.arrayBuffer());
// Validasi ukuran file
if (buffer.length < 100) {
throw new Error("Downloaded ZIP is empty or corrupted");
}
// Validasi signature ZIP ("PK")
if (buffer.toString("utf8", 0, 2) !== "PK") {
throw new Error("Invalid ZIP signature (PK not found)");
}
// 2. Extract zip ke folder tmp // 2. Extract zip ke folder tmp
const extractDir = path.join(process.cwd(), "tmp_assets"); const extractDir = path.join(process.cwd(), "tmp_assets");
await fs.rm(extractDir, { recursive: true, force: true }); await fs.rm(extractDir, { recursive: true, force: true });
await fs.mkdir(extractDir, { recursive: true }); await fs.mkdir(extractDir, { recursive: true });
const zip = new AdmZip(buffer); let zip: AdmZip;
try {
zip = new AdmZip(buffer);
} catch (err) {
throw new Error("Failed to parse ZIP file (corrupted or invalid)");
}
try {
zip.extractAllTo(extractDir, true); zip.extractAllTo(extractDir, true);
} catch (err) {
throw new Error("Failed to extract ZIP contents");
}
// 3. Cari semua file valid (recursive) // 3. Cari semua file valid (recursive)
const files = await walkDir(extractDir); const files = await walkDir(extractDir);
@@ -84,7 +115,27 @@ export default async function seedAssets() {
await fs.copyFile(filePath, targetPath); await fs.copyFile(filePath, targetPath);
} }
// 5. Simpan ke DB const existing = await prisma.fileStorage.findUnique({
where: { name: finalName },
});
if (existing) {
// Restore kalau soft deleted
await prisma.fileStorage.update({
where: { name: finalName },
data: {
path: targetPath,
realName: entryName,
mimeType,
link: `/uploads/${category}/${finalName}`,
category,
deletedAt: null,
isActive: true,
},
});
console.log(`♻️ restored: ${category}/${finalName}`);
} else {
await prisma.fileStorage.create({ await prisma.fileStorage.create({
data: { data: {
name: finalName, name: finalName,
@@ -96,6 +147,9 @@ export default async function seedAssets() {
}, },
}); });
console.log(`📂 created: ${category}/${finalName}`);
}
console.log(`📂 saved: ${category}/${finalName}`); console.log(`📂 saved: ${category}/${finalName}`);
} }
@@ -103,6 +157,8 @@ export default async function seedAssets() {
await fs.rm(extractDir, { recursive: true, force: true }); await fs.rm(extractDir, { recursive: true, force: true });
console.log("✅ Selesai seed assets!"); console.log("✅ Selesai seed assets!");
console.log("DB URL (asset):", process.env.DATABASE_URL);
} }
// --- Auto run kalau dipanggil langsung --- // --- Auto run kalau dipanggil langsung ---

BIN
public/mangupuraaward.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

View File

@@ -0,0 +1,36 @@
// components/modal/ModalKonfirmasiHapus.tsx
import colors from "@/con/colors"
import { Modal, Text, Button, Flex } from "@mantine/core"
interface ModalKonfirmasiNonAktifProps {
opened: boolean
loading?: boolean
onClose: () => void
onConfirm: () => void
text: string
}
export function ModalKonfirmasiNonAktif({
opened,
loading = false,
onClose,
onConfirm,
text,
}: ModalKonfirmasiNonAktifProps) {
return (
<Modal
opened={opened}
onClose={onClose}
title={<Text fw={"bold"} fz={"xl"}>Konfirmasi Non Aktif</Text>}
centered
>
<Text mb="md">{text}</Text>
<Flex justify="flex-end" gap="sm">
<Button style={{color: "white"}} bg={colors['blue-button']} variant="default" onClick={onClose}>Batal</Button>
<Button color="red" onClick={onConfirm} loading={loading}>
Yakin Non Aktif
</Button>
</Flex>
</Modal>
)
}

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

@@ -68,7 +68,7 @@ const category = proxy({
const res = await ApiFetch.api.desa.kategoripengumuman[ const res = await ApiFetch.api.desa.kategoripengumuman[
"findMany" "findMany"
].get({ ].get({
query: { page, limit }, query: { page, limit, search },
}); });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {

View File

@@ -65,7 +65,7 @@ const potensiDesa = proxy({
const res = await ApiFetch.api.desa.potensi[ const res = await ApiFetch.api.desa.potensi[
"find-many" "find-many"
].get({ ].get({
query: { page, limit }, query: { page, limit, search },
}); });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {

View File

@@ -312,15 +312,15 @@ const kategoriProduk = proxy({
page: 1, page: 1,
totalPages: 1, totalPages: 1,
loading: false, loading: false,
search2: "", search: "",
load: async (page = 1, limit = 10, search2 = "") => { load: async (page = 1, limit = 10, search = "") => {
kategoriProduk.findMany.loading = true; // ✅ Akses langsung via nama path kategoriProduk.findMany.loading = true; // ✅ Akses langsung via nama path
kategoriProduk.findMany.page = page; kategoriProduk.findMany.page = page;
kategoriProduk.findMany.search2 = search2; kategoriProduk.findMany.search = search;
try { try {
const query: any = { page, limit }; const query: any = { page, limit };
if (search2) query.search2 = search2; if (search) query.search = search;
const res = await ApiFetch.api.ekonomi.kategoriproduk["find-many"].get({ query }); const res = await ApiFetch.api.ekonomi.kategoriproduk["find-many"].get({ query });

View File

@@ -194,7 +194,7 @@ const posisiOrganisasi = proxy({
try { try {
this.loading = true; this.loading = true;
const res = await ApiFetch.api.ekonomi['struktur-organisasi']['posisi-organisasi']['create'].post(this.form); const res = await ApiFetch.api.ekonomi["struktur-organisasi"]["posisi-organisasi"]["create"].post(this.form);
if (res.status === 200) { if (res.status === 200) {
toast.success("Berhasil menambahkan posisi organisasi"); toast.success("Berhasil menambahkan posisi organisasi");
posisiOrganisasi.findMany.load(); posisiOrganisasi.findMany.load();

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

@@ -60,13 +60,18 @@ const responden = proxy({
totalPages: 1, totalPages: 1,
total: 0, total: 0,
loading: false, loading: false,
load: async (page = 1, limit = 10) => { search: "",
load: async (page = 1, limit = 10, search = "") => {
// Change to arrow function // Change to arrow function
responden.findMany.loading = true; // Use the full path to access the property responden.findMany.loading = true; // Use the full path to access the property
responden.findMany.page = page; responden.findMany.page = page;
responden.findMany.search = search;
try { try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.landingpage.responden["findMany"].get({ const res = await ApiFetch.api.landingpage.responden["findMany"].get({
query: { page, limit }, query,
}); });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {

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";
@@ -65,13 +66,46 @@ const dataPendidikan = proxy({
select: { id: true; name: true; jumlah: true }; select: { id: true; name: true; jumlah: true };
}>[] }>[]
| null, | null,
page: 1,
totalPages: 1,
total: 0,
loading: false, loading: false,
async load() { search: "",
load: async (page = 1, limit = 10, search = "") => {
// Change to arrow function
dataPendidikan.findMany.loading = true; // Use the full path to access the property
dataPendidikan.findMany.page = page;
dataPendidikan.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.pendidikan.datapendidikan[ const res = await ApiFetch.api.pendidikan.datapendidikan[
"findMany" "findMany"
].get(); ].get({
if (res.status === 200) { query,
dataPendidikan.findMany.data = res.data?.data ?? []; });
if (res.status === 200 && res.data?.success) {
dataPendidikan.findMany.data = res.data.data || [];
dataPendidikan.findMany.total = res.data.total || 0;
dataPendidikan.findMany.totalPages = res.data.totalPages || 1;
} else {
console.error(
"Failed to load data pendidikan:",
res.data?.message
);
dataPendidikan.findMany.data = [];
dataPendidikan.findMany.total = 0;
dataPendidikan.findMany.totalPages = 1;
}
} catch (error) {
console.error("Error loading data pendidikan:", error);
dataPendidikan.findMany.data = [];
dataPendidikan.findMany.total = 0;
dataPendidikan.findMany.totalPages = 1;
} finally {
dataPendidikan.findMany.loading = false;
} }
}, },
}, },

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

@@ -220,11 +220,34 @@ const roleState = proxy({
isActive: true; isActive: true;
}; };
}>[], }>[],
page: 1,
totalPages: 1,
loading: false, loading: false,
async load() { search: "",
const res = await ApiFetch.api.role["findMany"].get(); load: async (page = 1, limit = 10, search = "") => {
if (res.status === 200) { roleState.findMany.loading = true; // ✅ Akses langsung via nama path
roleState.findMany.data = res.data?.data ?? []; roleState.findMany.page = page;
roleState.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.role["findMany"].get({ query });
if (res.status === 200 && res.data?.success) {
roleState.findMany.data = res.data.data ?? [];
roleState.findMany.totalPages = res.data.totalPages ?? 1;
} else {
roleState.findMany.data = [];
roleState.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch role paginated:", err);
roleState.findMany.data = [];
roleState.findMany.totalPages = 1;
} finally {
roleState.findMany.loading = false;
} }
}, },
}, },

View File

@@ -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

@@ -110,6 +110,7 @@ export default function Validasi() {
return; return;
} }
// ✅ Verify OTP
const verifyRes = await fetch('/api/auth/verify-otp-register', { const verifyRes = await fetch('/api/auth/verify-otp-register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -123,19 +124,25 @@ export default function Validasi() {
return; return;
} }
// ✅ Finalize registration
const finalizeRes = await fetch('/api/auth/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');
} }

View File

@@ -1,7 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core'; import { Box, ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconBuildingStore, IconFileText, IconSparkles, IconUsers, IconUsersPlus } from '@tabler/icons-react'; import { IconBuildingStore, IconFileText, IconSparkles, IconUsers, IconUsersPlus } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
@@ -72,17 +72,18 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
keepMounted={false} keepMounted={false}
> >
{/* ✅ Scroll horizontal wrapper */} {/* ✅ Scroll horizontal wrapper */}
<ScrollArea type="auto" offsetScrollbars> <Box visibleFrom='md' pb={10}>
<ScrollArea type="auto" offsetScrollbars w="100%">
<TabsList <TabsList
p="sm" p="sm"
style={{ style={{
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)",
display: "flex", display: "flex",
flexWrap: "nowrap", flexWrap: "nowrap",
gap: "0.5rem", gap: "0.5rem",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi width: "max-content", // ⬅️ kunci
maxWidth: "100%",
}} }}
> >
{tabs.map((tab, i) => ( {tabs.map((tab, i) => (
@@ -94,6 +95,7 @@ function LayoutTabsLayanan({ 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}
@@ -101,6 +103,45 @@ function LayoutTabsLayanan({ 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

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

View File

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

View File

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

View File

@@ -1,8 +1,30 @@
'use client' 'use client'
import React from 'react'; import React from 'react';
import LayoutTabsBerita from './_com/layoutTabs'; import LayoutTabsBerita from './_com/layoutTabs';
import { usePathname } from 'next/navigation';
import { Box } from '@mantine/core';
function Layout({ children }: { children: React.ReactNode }) { function Layout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/berita/semua → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length >= 5;
if (isDetailPage) {
// Tampilkan tanpa tab menu
return (
<Box>
{children}
</Box>
);
}
return ( return (
<LayoutTabsBerita> <LayoutTabsBerita>
{children} {children}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,29 @@
'use client' 'use client'
import { usePathname } from "next/navigation";
import LayoutTabsGallery from "./lib/layoutTabs" import LayoutTabsGallery from "./lib/layoutTabs"
import { Box } from "@mantine/core";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/berita/semua → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length >= 5;
if (isDetailPage) {
// Tampilkan tanpa tab menu
return (
<Box>
{children}
</Box>
);
}
return ( return (
<LayoutTabsGallery> <LayoutTabsGallery>
{children} {children}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,31 @@
'use client' 'use client'
import { usePathname } from "next/navigation";
import LayoutTabsLayanan from "../_com/layoutTabLayanan"; import LayoutTabsLayanan from "../_com/layoutTabLayanan";
import { Box } from "@mantine/core";
export default function Layout({children} : {children: React.ReactNode}) { export default function Layout({children} : {children: React.ReactNode}) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/layanan/semua → panjang 5 → list
// - /darmasaba/desa/layanan/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/layanan/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length >= 5;
if (isDetailPage) {
// Tampilkan tanpa tab menu
return (
<Box>
{children}
</Box>
);
}
return ( return (
<LayoutTabsLayanan> <LayoutTabsLayanan>
{children} {children}
</LayoutTabsLayanan> </LayoutTabsLayanan>
) );
} }

View File

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

View File

@@ -5,8 +5,7 @@ import {
Button, Button,
Center, Center,
Divider, Divider,
Grid, Group,
GridCol,
Paper, Paper,
Skeleton, Skeleton,
Stack, Stack,
@@ -43,13 +42,15 @@ function PelayananPendudukNonPermanent() {
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm"> <Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
<Stack gap="md"> <Stack gap="md">
{/* Header */} {/* Header */}
<Grid align="center"> <Group justify='space-between' align="center">
<GridCol span={{ base: 12, md: 11 }}>
<Title order={3} c={colors['blue-button']}> <Title
order={3}
lh={1.2}
c={colors['blue-button']}
>
Preview Pelayanan Penduduk Non Permanen Preview Pelayanan Penduduk Non Permanen
</Title> </Title>
</GridCol>
<GridCol span={{ base: 12, md: 1 }}>
<Button <Button
c="green" c="green"
variant="light" variant="light"
@@ -63,21 +64,20 @@ function PelayananPendudukNonPermanent() {
> >
Edit Edit
</Button> </Button>
</GridCol> </Group>
</Grid>
{/* Content */} {/* Content */}
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs"> <Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
<Box px={{ base: 0, md: 50 }} pb="xl"> <Box px={{ base: 0, md: 50 }} pb="xl">
<Center> <Center>
<Text <Title
order={2}
lh={1.2}
ta="center" ta="center"
fz={{ base: '1.2rem', md: '1.8rem' }}
fw="bold"
c={colors['blue-button']} c={colors['blue-button']}
> >
{data.name} {data.name}
</Text> </Title>
</Center> </Center>
<Divider my="md" color={colors['blue-button']} /> <Divider my="md" color={colors['blue-button']} />
@@ -86,9 +86,11 @@ function PelayananPendudukNonPermanent() {
<Text <Text
py={10} py={10}
ta="justify" ta="justify"
fz={{ base: '1rem', md: '1.2rem' }} fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.5, md: 1.55 }}
c="dark"
dangerouslySetInnerHTML={{ __html: data.deskripsi }} dangerouslySetInnerHTML={{ __html: data.deskripsi }}
style={{wordBreak: "break-word", whiteSpace: "normal"}} style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
/> />
</Box> </Box>
</Box> </Box>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core'; import { Box, ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconCategory, IconListDetails } from '@tabler/icons-react'; import { IconCategory, IconListDetails } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
@@ -54,6 +54,7 @@ function LayoutTabsLayanan({ 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"
@@ -76,6 +77,7 @@ function LayoutTabsLayanan({ 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}
@@ -83,6 +85,45 @@ function LayoutTabsLayanan({ 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

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

View File

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

View File

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

View File

@@ -1,7 +1,29 @@
'use client'
import React from 'react'; import React from 'react';
import LayoutTabs from './_com/layoutTabs'; import LayoutTabs from './_com/layoutTabs';
import { usePathname } from 'next/navigation';
import { Box } from '@mantine/core';
function Layout({ children }: { children: React.ReactNode }) { function Layout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/pengumuman/semua → panjang 5 → list
// - /darmasaba/desa/pengumuman/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/pengumuman/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length >= 5;
if (isDetailPage) {
// Tampilkan tanpa tab menu
return (
<Box>
{children}
</Box>
);
}
return ( return (
<LayoutTabs> <LayoutTabs>
{children} {children}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,29 @@
'use client' 'use client'
import React from 'react'; import React from 'react';
import LayoutTabsPotensi from './_lib/layoutTabs'; import LayoutTabsPotensi from './_lib/layoutTabs';
import { usePathname } from 'next/navigation';
import { Box } from '@mantine/core';
function Layout({ children }: { children: React.ReactNode }) { function Layout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/berita/semua → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length >= 5;
if (isDetailPage) {
// Tampilkan tanpa tab menu
return (
<Box>
{children}
</Box>
);
}
return ( return (
<LayoutTabsPotensi> <LayoutTabsPotensi>
{children} {children}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,154 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import { Box, ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconCalendar, IconUser, IconUsers } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
function LayoutTabsDetail({ children }: { children: React.ReactNode }) {
const router = useRouter()
const pathname = usePathname()
const tabs = [
{
label: "Profil Desa",
value: "profildesa",
href: "/admin/desa/profil/profil-desa",
icon: <IconUser size={18} stroke={1.8} />
},
{
label: "Profil Perbekel",
value: "profilperbekel",
href: "/admin/desa/profil/profil-perbekel",
icon: <IconUsers size={18} stroke={1.8} />
},
{
label: "Profil Perbekel Dari Masa Ke Masa",
value: "profilperbekeldarimasakemasa",
href: "/admin/desa/profil/profil-perbekel-dari-masa-ke-masa",
icon: <IconCalendar size={18} stroke={1.8} />
}
];
const currentTab = tabs.find(tab => tab.href === pathname)
const [activeTab, setActiveTab] = useState<string | null>(currentTab?.value || tabs[0].value);
const handleTabChange = (value: string | null) => {
const tab = tabs.find(t => t.value === value)
if (tab) {
router.push(tab.href)
}
setActiveTab(value)
}
useEffect(() => {
const match = tabs.find(tab => tab.href === pathname)
if (match) {
setActiveTab(match.value)
}
}, [pathname])
return (
<Stack gap="lg">
<Title order={3} fw={700} style={{ color: "#1A1B1E" }}>Profile Desa</Title>
<Tabs
color={colors["blue-button"]}
variant="pills"
value={activeTab}
onChange={handleTabChange}
radius="lg"
keepMounted={false}
>
{/* ✅ Scroll horizontal wrapper */}
<Box visibleFrom='md' pb={10}>
<ScrollArea type="auto" offsetScrollbars>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
</Box>
<Box hiddenFrom='md' pb={10}>
<ScrollArea
type="auto"
offsetScrollbars={false}
w="100%"
>
<TabsList
p="xs" // lebih kecil
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
width: "max-content", // ⬅️ kunci
maxWidth: "100%", // ⬅️ penting
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
paddingInline: "0.75rem", // ⬅️ lebih ramping
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
</Box>
{tabs.map((tab, i) => (
<TabsPanel
key={i}
value={tab.value}
style={{
padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}}
>
{/* Konten dummy, bisa diganti sesuai routing */}
<>{children}</>
</TabsPanel>
))}
</Tabs>
</Stack>
);
}
export default LayoutTabsDetail;

View File

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

View File

@@ -0,0 +1,33 @@
'use client'
import { usePathname } from "next/navigation";
import LayoutTabsDetail from "./_lib/layoutTabsDetail"
import { Box } from "@mantine/core";
export default function Layout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/berita/semua → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length >= 5;
if (isDetailPage) {
// Tampilkan tanpa tab menu
return (
<Box>
{children}
</Box>
);
}
return (
<LayoutTabsDetail>
{children}
</LayoutTabsDetail>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,147 @@
'use client'
import colors from '@/con/colors';
import { Box, Button, Center, Divider, Grid, GridCol, Group, Image, Paper, Skeleton, Stack, Text, Title } from '@mantine/core';
import { IconEdit } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useSnapshot } from 'valtio';
import stateProfileDesa from '../../../_state/desa/profile';
function Page() {
const router = useRouter();
const snap = useSnapshot(stateProfileDesa);
// Load data saat mount
useEffect(() => {
stateProfileDesa.profilPerbekel.findUnique.load("edit");
}, []);
const perbekel = snap.profilPerbekel.findUnique.data;
if (!perbekel) {
return (
<Stack align="center" justify="center" py="xl">
<Skeleton radius="md" height={800} />
</Stack>
);
}
return (
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
<Stack gap="md">
{/* Header + tombol edit */}
<Group justify="space-between">
<Title order={2} c={colors['blue-button']} lh={1.2}>Profil Perbekel</Title>
<Button
c="green"
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profil/profil-perbekel/${perbekel.id}`)}
>
Edit
</Button>
</Group>
{/* Card Profil */}
<Paper p="xl" bg={colors['white-1']} withBorder radius="md" shadow="xs">
<Box px={{ base: "sm", md: 100 }}>
<Grid>
<GridCol span={12}>
<Center>
<Image loading='lazy' src="/darmasaba-icon.png" w={{ base: 100, md: 150 }} alt="Logo Desa" />
</Center>
</GridCol>
<GridCol span={12}>
<Text
ta="center"
fz={{ base: 'sm', md: 'xl' }}
fw="bold"
c={colors['blue-button']}
lh={{ base: 1.45, md: 1.45 }}
>
Profil Pimpinan Badan Publik Desa Darmasaba
</Text>
</GridCol>
</Grid>
</Box>
<Divider my="md" color={colors['blue-button']} />
<Stack gap={0} px={{ base: 0, md: 50 }} pb="xl">
<Center>
<Image
pt={{ base: 0, md: 60 }}
src={perbekel.image?.link || "/perbekel.png"}
w={{ base: 250, md: 350 }}
alt="Foto Profil Perbekel"
radius="md"
onError={(e) => { e.currentTarget.src = "/perbekel.png"; }}
loading='lazy'
/>
</Center>
<Paper
bg={colors['blue-button']}
py="md"
px="sm"
radius="md"
className="glass3"
style={{ mt: -30, boxShadow: '0 4px 20px rgba(0,0,0,0.15)' }}
>
<Text
ta="center"
c={colors['white-1']}
fw="bolder"
fz={{ base: 'md', md: 'lg' }}
lh={{ base: 1.4, md: 1.4 }}
>
I.B. Surya Prabhawa Manuaba, S.H., M.H.
</Text>
</Paper>
</Stack>
{/* Biodata & Info */}
<Box mt="lg">
<Title order={3} lh={1.2} mb={4} />
<Text
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.6, md: 1.6 }}
ta="justify"
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: perbekel.biodata }}
/>
<Title order={3} lh={1.2} mt="md" mb={4} />
<Text
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.6, md: 1.6 }}
ta="justify"
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: perbekel.pengalaman }}
/>
<Title order={3} lh={1.2} mt="md" mb={4} />
<Text
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.6, md: 1.6 }}
ta="justify"
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: perbekel.pengalamanOrganisasi }}
/>
<Title order={3} lh={1.2} mt="md" mb={4} />
<Text
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.6, md: 1.6 }}
ta="justify"
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: perbekel.programUnggulan }}
/>
</Box>
</Paper>
</Stack>
</Paper>
);
}
export default Page;

View File

@@ -1,113 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconCalendar, IconUser, IconUsers } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
function LayoutTabsDetail({ children }: { children: React.ReactNode }) {
const router = useRouter()
const pathname = usePathname()
const tabs = [
{
label: "Profile Desa",
value: "profiledesa",
href: "/admin/desa/profile/profile-desa",
icon: <IconUser size={18} stroke={1.8} />
},
{
label: "Profile Perbekel",
value: "profileperbekel",
href: "/admin/desa/profile/profile-perbekel",
icon: <IconUsers size={18} stroke={1.8} />
},
{
label: "Profile Perbekel Dari Masa Ke Masa",
value: "profile-perbekel-dari-masa-ke-masa",
href: "/admin/desa/profile/profile-perbekel-dari-masa-ke-masa",
icon: <IconCalendar size={18} stroke={1.8} />
}
];
const currentTab = tabs.find(tab => tab.href === pathname)
const [activeTab, setActiveTab] = useState<string | null>(currentTab?.value || tabs[0].value);
const handleTabChange = (value: string | null) => {
const tab = tabs.find(t => t.value === value)
if (tab) {
router.push(tab.href)
}
setActiveTab(value)
}
useEffect(() => {
const match = tabs.find(tab => tab.href === pathname)
if (match) {
setActiveTab(match.value)
}
}, [pathname])
return (
<Stack gap="lg">
<Title order={3} fw={700} style={{ color: "#1A1B1E" }}>Profile Desa</Title>
<Tabs
color={colors["blue-button"]}
variant="pills"
value={activeTab}
onChange={handleTabChange}
radius="lg"
keepMounted={false}
>
{/* ✅ Scroll horizontal wrapper */}
<ScrollArea type="auto" offsetScrollbars>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
{tabs.map((tab, i) => (
<TabsPanel
key={i}
value={tab.value}
style={{
padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}}
>
{/* Konten dummy, bisa diganti sesuai routing */}
<>{children}</>
</TabsPanel>
))}
</Tabs>
</Stack>
);
}
export default LayoutTabsDetail;

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