Compare commits
68 Commits
nico/22-ok
...
nico/6-jan
| Author | SHA1 | Date | |
|---|---|---|---|
| 503da91ce6 | |||
| daaed8089b | |||
| f436aa2ef0 | |||
| 50bc54ceca | |||
| f0f201c853 | |||
| 29065cb3e2 | |||
| bf20cd55e8 | |||
| af60bcd6fc | |||
| dc8793e3ae | |||
| c8484357cb | |||
| 342e9bbc65 | |||
| f6f77d9e35 | |||
| a00481152c | |||
| 242ea86f77 | |||
| 99c2c9c6d7 | |||
| ac2fc1a705 | |||
| 9dbe172165 | |||
| cc318d4d54 | |||
| dcb8017594 | |||
| ec3ad12531 | |||
| dad44c0537 | |||
| 867dce42f0 | |||
| 7bb17ddf22 | |||
| a4069d3cba | |||
| ffe5e6dd9f | |||
| dcf195f54f | |||
| c03a6b3aed | |||
| 1bb9f239db | |||
| a213ff7d37 | |||
| 0018bdc251 | |||
| 83fb39a957 | |||
| 7238692dd0 | |||
| 8b50139d79 | |||
| 066180fc0e | |||
| 67f29aabef | |||
| dbf7c34228 | |||
| 036fc86fed | |||
| 2cecec733e | |||
| c64a2e5457 | |||
| 757911d7dd | |||
| 54232e4465 | |||
| 29a9a59bca | |||
| 2fb3666e57 | |||
| e30b27f7a4 | |||
| e941ed3893 | |||
| ace5aff1b6 | |||
| 716db0adca | |||
| a291bdfb51 | |||
| 0dff8f3254 | |||
| 78b8aa74cd | |||
| a0537810e8 | |||
| b3c169a2d4 | |||
| 2608a5ffdd | |||
| 6c32f3ebdb | |||
| 0feeb4de93 | |||
| 9622eb5a9a | |||
| 417a8937f5 | |||
| db8909b9ed | |||
| f66a46f645 | |||
| fb57698dc9 | |||
| d128313e71 | |||
| 7b4bb1e58e | |||
| 0befe6a3f2 | |||
| a6663bbcee | |||
| ed371bd0d9 | |||
| f82c7b86e0 | |||
| b5d6585cd5 | |||
| aa98359ef7 |
18
package.json
18
package.json
@@ -3,9 +3,9 @@
|
||||
"version": "0.1.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun --bun next dev --hostname 0.0.0.0",
|
||||
"build": "bun --bun next build",
|
||||
"start": "bun --bun next start"
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "bun run prisma/seed.ts"
|
||||
@@ -19,6 +19,7 @@
|
||||
"@elysiajs/static": "^1.3.0",
|
||||
"@elysiajs/stream": "^1.1.0",
|
||||
"@elysiajs/swagger": "^1.2.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@mantine/carousel": "^7.16.2",
|
||||
"@mantine/charts": "^7.17.1",
|
||||
"@mantine/core": "^7.17.4",
|
||||
@@ -26,6 +27,7 @@
|
||||
"@mantine/dropzone": "^8.1.1",
|
||||
"@mantine/form": "^8.1.0",
|
||||
"@mantine/hooks": "^7.17.4",
|
||||
"@mantine/modals": "^8.3.6",
|
||||
"@mantine/tiptap": "^7.17.4",
|
||||
"@paljs/types": "^8.1.0",
|
||||
"@prisma/client": "^6.3.1",
|
||||
@@ -43,6 +45,7 @@
|
||||
"@types/bun": "^1.2.2",
|
||||
"@types/leaflet": "^1.9.20",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/nodemailer": "^7.0.2",
|
||||
"add": "^2.0.6",
|
||||
"adm-zip": "^0.5.16",
|
||||
"animate.css": "^4.1.1",
|
||||
@@ -51,10 +54,13 @@
|
||||
"chart.js": "^4.4.8",
|
||||
"classnames": "^2.5.1",
|
||||
"colors": "^1.4.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^17.2.3",
|
||||
"elysia": "^1.3.5",
|
||||
"embla-carousel-autoplay": "^8.5.2",
|
||||
"embla-carousel-react": "^7.1.0",
|
||||
"embla-carousel": "^8.6.0",
|
||||
"embla-carousel-autoplay": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"form-data": "^4.0.2",
|
||||
"framer-motion": "^12.23.5",
|
||||
@@ -71,12 +77,14 @@
|
||||
"next": "^15.5.2",
|
||||
"next-view-transitions": "^0.3.4",
|
||||
"node-fetch": "^3.3.2",
|
||||
"nodemailer": "^7.0.10",
|
||||
"p-limit": "^6.2.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.6",
|
||||
"prisma": "^6.3.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-exif-orientation-img": "^0.1.5",
|
||||
"react-international-phone": "^4.6.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-simple-toasts": "^6.1.0",
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-preset-mantine': {},
|
||||
'postcss-simple-vars': {
|
||||
variables: {
|
||||
'mantine-breakpoint-xs': '36em',
|
||||
'mantine-breakpoint-sm': '48em',
|
||||
'mantine-breakpoint-md': '62em',
|
||||
'mantine-breakpoint-lg': '75em',
|
||||
'mantine-breakpoint-xl': '88em',
|
||||
},
|
||||
plugins: {
|
||||
'postcss-preset-mantine': {},
|
||||
'postcss-simple-vars': {
|
||||
variables: {
|
||||
/* Mobile first */
|
||||
'mantine-breakpoint-xs': '30em', // 480px → mobile kecil–normal
|
||||
'mantine-breakpoint-sm': '48em', // 768px → tablet / mobile landscape
|
||||
'mantine-breakpoint-md': '64em', // 1024px → laptop & desktop kecil
|
||||
'mantine-breakpoint-lg': '80em', // 1280px → desktop standar
|
||||
'mantine-breakpoint-xl': '90em', // 1440px+ → desktop besar
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
30
prisma/data/fetchWithRetry.ts
Normal file
30
prisma/data/fetchWithRetry.ts
Normal 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");
|
||||
}
|
||||
@@ -1,137 +1,120 @@
|
||||
[
|
||||
{
|
||||
"id": "cmff0rr4z0002vn0twp333m2",
|
||||
"name": "S6RIjFaPvdQm3oq4rM4X9-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",
|
||||
"id": "cmk27746i0000vnso2aspwf9g",
|
||||
"name": "Eqlrr1W-pK8ShMGqgPGL3-desktop.webp",
|
||||
"realName": "perbekel.png",
|
||||
"path": "uploads/images",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"id": "cmff3joae0000vn6h8sgs0ilg",
|
||||
"name": "7hox9spUxj56hY_EBYLnj-desktop.webp",
|
||||
"id": "cmk20nqmu0001vnevfte29rk0",
|
||||
"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",
|
||||
"path": "uploads/images",
|
||||
"mimeType": "image/webp",
|
||||
"link": "/api/fileStorage/findUnique/7hox9spUxj56hY_EBYLnj-desktop.webp",
|
||||
"link": "/api/fileStorage/findUnique/q1G995W7cLkC_qquLTlKN-desktop.webp",
|
||||
"category": "image"
|
||||
},
|
||||
{
|
||||
"id": "cmff3ll130001vn6hkhls3f5y",
|
||||
"name": "ChihV7_1eS-AGtSg9UwMv-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",
|
||||
"id": "cmk2cmr000006vn96qepq6gvl",
|
||||
"name": "I6mlQ4nRmPX26gm79C_rM-desktop.webp",
|
||||
"realName": "facebook.png",
|
||||
"path": "uploads/images",
|
||||
"mimeType": "image/webp",
|
||||
"link": "/api/fileStorage/findUnique/z8v9ZREwOJHKGIRYauROt-desktop.webp",
|
||||
"link": "/api/fileStorage/findUnique/I6mlQ4nRmPX26gm79C_rM-desktop.webp",
|
||||
"category": "image"
|
||||
},
|
||||
{
|
||||
"id": "cmff3nv180003vn6h5jvedidq",
|
||||
"name": "BLjMxTKoCNE31uOURR3IU-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",
|
||||
"id": "cmk2cpeba0009vn966jcrpf3u",
|
||||
"name": "WArLC_yvU33MjoqEnQeQ1-desktop.webp",
|
||||
"realName": "instagram.png",
|
||||
"path": "uploads/images",
|
||||
"mimeType": "image/webp",
|
||||
"link": "/api/fileStorage/findUnique/hkJYAeTNWK_vYaYS20w3I-desktop.webp",
|
||||
"link": "/api/fileStorage/findUnique/WArLC_yvU33MjoqEnQeQ1-desktop.webp",
|
||||
"category": "image"
|
||||
},
|
||||
{
|
||||
"id": "cmff3q12g0005vn6h5ojov2qa",
|
||||
"name": "6XEoZ9SFu59COpil03Gya-desktop.webp",
|
||||
"id": "cmk2crcl1000cvn96j8pmgmo5",
|
||||
"name": "D3RPbNiaNSCjacLjeR_qO-desktop.webp",
|
||||
"realName": "tiktok.png",
|
||||
"path": "uploads/images",
|
||||
"mimeType": "image/webp",
|
||||
"link": "/api/fileStorage/findUnique/6XEoZ9SFu59COpil03Gya-desktop.webp",
|
||||
"link": "/api/fileStorage/findUnique/D3RPbNiaNSCjacLjeR_qO-desktop.webp",
|
||||
"category": "image"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -3,24 +3,24 @@
|
||||
"id": "cmds9023u0008vnbe3oxmhwyf",
|
||||
"name": "Desa Darmasaba",
|
||||
"iconUrl": "https://www.youtube.com/channel/UCtPw9WOQO7d2HIKzKgel4Xg",
|
||||
"imageId": "cmff3joae0000vn6h8sgs0ilg"
|
||||
"imageId": "cmk2cgqgm0003vn96jun52pik"
|
||||
},
|
||||
{
|
||||
"id": "cmds90oul000bvnbe2bqkptoi",
|
||||
"name": "Pemerintah Desa Darmasaba",
|
||||
"iconUrl": "https://www.facebook.com/DarmasabaDesaku",
|
||||
"imageId": "cmff3mtat0002vn6hs8vyyhdd"
|
||||
"imageId": "cmk2cmr000006vn96qepq6gvl"
|
||||
},
|
||||
{
|
||||
"id": "cmds91i4e000evnbe8gtf1gub",
|
||||
"name": "ddarmasaba",
|
||||
"iconUrl": "https://www.instagram.com/ddarmasaba/",
|
||||
"imageId": "cmff3oouh0004vn6hd94brzv9"
|
||||
"imageId": "cmk2cpeba0009vn966jcrpf3u"
|
||||
},
|
||||
{
|
||||
"id": "cmds92de5000hvnbemlu6sq5x",
|
||||
"name": "desa.darmasaba",
|
||||
"iconUrl": "https://www.tiktok.com/@desa.darmasaba?is_from_webapp=1&sender_device=pc",
|
||||
"imageId": "cmff3q12g0005vn6h5ojov2qa"
|
||||
"imageId": "cmk2crcl1000cvn96j8pmgmo5"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"id": "edit",
|
||||
"name": "I.B Surya Prabhawa Manuaba, S.H., M.H.",
|
||||
"position": "Perbekel Darmasaba periode 2021-2027",
|
||||
"imageId": "cmff2w5ly000avn0telhct71k"
|
||||
"imageId": "cmk2a2dl6001nvngck1n0k8qc"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,48 +4,55 @@
|
||||
"name": "Dmangan",
|
||||
"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",
|
||||
"imageId" : "cmff0z34f0005vn0tjtvq519p"
|
||||
"imageId" : "cmk20nyen0002vnevd0hfr3u8"
|
||||
},
|
||||
{
|
||||
"id": "cmdr76nqk0008vn5rdddvcxnr",
|
||||
"name": "Bicara Darmasaba",
|
||||
"description": "Bicara Darmasaba",
|
||||
"link": "https://darmasaba.desa.id/berita/42506-bicara-darmasaba",
|
||||
"imageId" : "cmff0tnf00003vn0t3kgzi0u0"
|
||||
"imageId" : "cmk20nqmu0001vnevfte29rk0"
|
||||
},
|
||||
{
|
||||
"id": "cmdr77vbw000bvn5rvpmoq31s",
|
||||
"name": "Bares",
|
||||
"description": "Darmasaba Recycling Stock/Exchange",
|
||||
"link": "http://darmasaba.desa.id/berita/56722-bares",
|
||||
"imageId" : "cmff0rr4z0002vn0twp333m2"
|
||||
"imageId" : "cmk20mg320000vnevxy0k73fr"
|
||||
},
|
||||
{
|
||||
"id": "cmdr7bxtp000evn5rmy85wihx",
|
||||
"name": "Sajjana Dharma Raksaka",
|
||||
"description": "Sajjana Dharma Raksaka",
|
||||
"link": "https://ppid.badungkab.go.id/storage/dokumen/5RS9dldGkrgzMQq6bKdZsqsVRHI8gffWv4PGfb3r.pdf",
|
||||
"imageId" : "cmff10cwq0009vn0tse8dzu3j"
|
||||
"imageId" : "cmk20pf3d0006vnev3mkoqpyy"
|
||||
},
|
||||
{
|
||||
"id": "cmdr7dlnk000hvn5r9lur3z35",
|
||||
"name": "PDKT",
|
||||
"description": "Perangkat Desa Kuat Teknologi",
|
||||
"link": "https://darmasaba.desa.id/berita/53752-p-d-k-t",
|
||||
"imageId" : "cmff1013m0008vn0th7t0d64d"
|
||||
"imageId" : "cmk20omzq0005vnevgi6f4edu"
|
||||
},
|
||||
{
|
||||
"id": "cmdr7ftob000mvn5rfhgdtg8v",
|
||||
"name": "GM",
|
||||
"description": "Galah Melah",
|
||||
"link": "https://darmasaba.desa.id/berita/52880-galah-melah",
|
||||
"imageId" : "cmff38cyq000bvn0t9f01cz3f"
|
||||
"imageId" : "cmk20o7mf0003vnevohrksm1d"
|
||||
},
|
||||
{
|
||||
"id": "cmdr7glue000pvn5r6onzslju",
|
||||
"name": "Inovasi Desa Darmasaba",
|
||||
"description": "Inovasi Desa Darmasaba",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
|
||||
11
prisma/data/resolveImageId.ts
Normal file
11
prisma/data/resolveImageId.ts
Normal 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);
|
||||
}
|
||||
24
prisma/data/safeImageId.ts
Normal file
24
prisma/data/safeImageId.ts
Normal 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;
|
||||
}
|
||||
@@ -1,23 +1,32 @@
|
||||
[
|
||||
{
|
||||
"id": "role-1",
|
||||
"name": "ADMIN DESA",
|
||||
"description": "Administrator Desa",
|
||||
"permissions": ["manage_users", "manage_content", "view_reports"],
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "role-2",
|
||||
"name": "ADMIN KESEHATAN",
|
||||
"description": "Administrator Bidang Kesehatan",
|
||||
"permissions": ["manage_health_data", "view_reports"],
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "role-3",
|
||||
"name": "ADMIN SEKOLAH",
|
||||
"description": "Administrator Sekolah",
|
||||
"permissions": ["manage_school_data", "view_reports"],
|
||||
"isActive": true
|
||||
}
|
||||
]
|
||||
{
|
||||
"id": "0",
|
||||
"name": "DEVELOPER",
|
||||
"description": "Developer",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"name": "SUPER ADMIN",
|
||||
"description": "Administrator",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "ADMIN DESA",
|
||||
"description": "Administrator Desa",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"name": "ADMIN KESEHATAN",
|
||||
"description": "Administrator Bidang Kesehatan",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"name": "ADMIN PENDIDIKAN",
|
||||
"description": "Administrator Bidang Pendidikan",
|
||||
"isActive": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
[
|
||||
{
|
||||
"id": "user-1",
|
||||
"nama": "Admin Desa",
|
||||
"nomor": "089647037426",
|
||||
"roleId": "role-1",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "user-2",
|
||||
"nama": "Admin Kesehatan",
|
||||
"nomor": "082339004198",
|
||||
"roleId": "role-2",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "user-3",
|
||||
"nama": "Admin Sekolah",
|
||||
"nomor": "085237157222",
|
||||
"roleId": "role-3",
|
||||
"isActive": true
|
||||
"id": "cmie1o0zh0002vn132vtzg7hh",
|
||||
"username": "SuperAdmin-Nico",
|
||||
"nomor": "6289647037426",
|
||||
"roleId": 0,
|
||||
"isActive": true,
|
||||
"sessionInvalid": false
|
||||
}
|
||||
]
|
||||
|
||||
1127
prisma/migrations/20251119062255_add_unique_username/migration.sql
Normal file
1127
prisma/migrations/20251119062255_add_unique_username/migration.sql
Normal file
File diff suppressed because it is too large
Load Diff
142
prisma/migrations/20260106072549_nico_6_jan2025/migration.sql
Normal file
142
prisma/migrations/20260106072549_nico_6_jan2025/migration.sql
Normal 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;
|
||||
@@ -1,30 +1,63 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// helpers/safeSeedUnique.ts
|
||||
import prisma from "@/lib/prisma";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
type SafeSeedOptions = {
|
||||
skipUpdate?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper generic buat seed dengan upsert aman
|
||||
*/
|
||||
// prisma/safeseedUnique.ts
|
||||
export async function safeSeedUnique<T extends keyof PrismaClient>(
|
||||
model: T,
|
||||
where: Record<string, any>,
|
||||
data: Record<string, any>
|
||||
data: Record<string, any>,
|
||||
options: SafeSeedOptions = {}
|
||||
) {
|
||||
const m = prisma[model];
|
||||
|
||||
if (!m) throw new Error(`Model ${String(model)} tidak ditemukan di PrismaClient`);
|
||||
const m = prisma[model] as any;
|
||||
if (!m) throw new Error(`Model ${String(model)} tidak ditemukan`);
|
||||
|
||||
try {
|
||||
// @ts-expect-error upsert dynamic
|
||||
await m.upsert({
|
||||
// Pastikan `where` berisi field yang benar-benar unique (misal: `id`)
|
||||
const result = await m.upsert({
|
||||
where,
|
||||
update: data,
|
||||
create: { ...where, ...data },
|
||||
update: options.skipUpdate ? {} : 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) {
|
||||
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);
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -136,6 +136,7 @@ model MediaSosial {
|
||||
name String
|
||||
image FileStorage? @relation(fields: [imageId], references: [id])
|
||||
imageId String?
|
||||
icon String?
|
||||
iconUrl String? @db.VarChar(255)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -184,18 +185,46 @@ model SdgsDesa {
|
||||
//========================================= APBDes ========================================= //
|
||||
model APBDes {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
jumlah String
|
||||
tahun Int?
|
||||
name String? // misalnya: "APBDes Tahun 2025"
|
||||
deskripsi String?
|
||||
jumlah String? // total keseluruhan (opsional, bisa juga dihitung dari items)
|
||||
items APBDesItem[]
|
||||
image FileStorage? @relation("APBDesImage", fields: [imageId], references: [id])
|
||||
imageId String?
|
||||
file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id])
|
||||
fileId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now())
|
||||
deletedAt DateTime? // opsional, tidak perlu default now()
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model APBDesItem {
|
||||
id String @id @default(cuid())
|
||||
kode String // contoh: "4", "4.1", "4.1.2"
|
||||
uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha"
|
||||
anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS)
|
||||
realisasi Float
|
||||
selisih Float // realisasi - anggaran
|
||||
persentase Float
|
||||
tipe String? // (realisasi / anggaran) * 100
|
||||
level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail
|
||||
parentId String? // untuk relasi hierarki
|
||||
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
|
||||
children APBDesItem[] @relation("APBDesItemParent")
|
||||
apbdesId String
|
||||
apbdes APBDes @relation(fields: [apbdesId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
@@index([kode])
|
||||
@@index([level])
|
||||
@@index([apbdesId])
|
||||
}
|
||||
|
||||
//========================================= PRESTASI DESA ========================================= //
|
||||
model PrestasiDesa {
|
||||
id String @id @default(cuid())
|
||||
@@ -754,24 +783,22 @@ model Penghargaan {
|
||||
|
||||
// ========================================= FASILITAS KESEHATAN ========================================= //
|
||||
model FasilitasKesehatan {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now())
|
||||
isActive Boolean @default(true)
|
||||
informasiumum InformasiUmum @relation(fields: [informasiUmumId], references: [id])
|
||||
informasiUmumId String
|
||||
layananunggulan LayananUnggulan @relation(fields: [layananUnggulanId], references: [id])
|
||||
layananUnggulanId String
|
||||
dokterdantenagamedis DokterdanTenagaMedis @relation(fields: [dokterdanTenagaMedisId], references: [id])
|
||||
dokterdanTenagaMedisId String
|
||||
fasilitaspendukung FasilitasPendukung @relation(fields: [fasilitasPendukungId], references: [id])
|
||||
fasilitasPendukungId String
|
||||
prosedurpendaftaran ProsedurPendaftaran @relation(fields: [prosedurPendaftaranId], references: [id])
|
||||
prosedurPendaftaranId String
|
||||
tarifdanlayanan TarifDanLayanan @relation(fields: [tarifDanLayananId], references: [id])
|
||||
tarifDanLayananId String
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now())
|
||||
isActive Boolean @default(true)
|
||||
informasiumum InformasiUmum @relation(fields: [informasiUmumId], references: [id])
|
||||
informasiUmumId String
|
||||
layananunggulan LayananUnggulan @relation(fields: [layananUnggulanId], references: [id])
|
||||
layananUnggulanId String
|
||||
dokterdantenagamedis DokterdanTenagaMedis[] @relation("Dokter")
|
||||
fasilitaspendukung FasilitasPendukung @relation(fields: [fasilitasPendukungId], references: [id])
|
||||
fasilitasPendukungId String
|
||||
prosedurpendaftaran ProsedurPendaftaran @relation(fields: [prosedurPendaftaranId], references: [id])
|
||||
prosedurPendaftaranId String
|
||||
tarifdanlayanan TarifDanLayanan[] @relation("Tarif")
|
||||
}
|
||||
|
||||
model InformasiUmum {
|
||||
@@ -797,15 +824,20 @@ model LayananUnggulan {
|
||||
}
|
||||
|
||||
model DokterdanTenagaMedis {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
specialist String
|
||||
jadwal String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now())
|
||||
isActive Boolean @default(true)
|
||||
FasilitasKesehatan FasilitasKesehatan[]
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
specialist String
|
||||
jadwal String
|
||||
jadwalLibur String?
|
||||
jamBukaOperasional String?
|
||||
jamTutupOperasional String?
|
||||
jamBukaLibur String?
|
||||
jamTutupLibur String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now())
|
||||
isActive Boolean @default(true)
|
||||
FasilitasKesehatan FasilitasKesehatan[] @relation("Dokter")
|
||||
}
|
||||
|
||||
model FasilitasPendukung {
|
||||
@@ -836,7 +868,7 @@ model TarifDanLayanan {
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now())
|
||||
isActive Boolean @default(true)
|
||||
FasilitasKesehatan FasilitasKesehatan[]
|
||||
FasilitasKesehatan FasilitasKesehatan[] @relation("Tarif")
|
||||
}
|
||||
|
||||
// ========================================= JADWAL KEGIATAN ========================================= //
|
||||
@@ -1942,23 +1974,28 @@ model KeunggulanProgram {
|
||||
}
|
||||
|
||||
model BeasiswaPendaftar {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
namaLengkap String
|
||||
nik String @unique
|
||||
nis String?
|
||||
kelas String?
|
||||
jenisKelamin JenisKelamin
|
||||
alamatDomisili String?
|
||||
tempatLahir String
|
||||
tanggalLahir DateTime
|
||||
jenisKelamin JenisKelamin
|
||||
kewarganegaraan String
|
||||
agama Agama
|
||||
alamatKTP String
|
||||
alamatDomisili String?
|
||||
namaOrtu String?
|
||||
nik String @unique
|
||||
pekerjaanOrtu String?
|
||||
penghasilan String?
|
||||
noHp String
|
||||
email String @unique
|
||||
statusPernikahan StatusPernikahan
|
||||
kewarganegaraan String?
|
||||
agama Agama?
|
||||
alamatKTP String?
|
||||
email String? @unique
|
||||
statusPernikahan StatusPernikahan?
|
||||
ukuranBaju UkuranBaju?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
enum JenisKelamin {
|
||||
@@ -2130,25 +2167,28 @@ enum StatusPeminjaman {
|
||||
// ========================================= USER ========================================= //
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
username String
|
||||
nomor String @unique
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
roleId String @default("1")
|
||||
instansi String?
|
||||
UserSession UserSession? // Nama instansi (Puskesmas, Sekolah, dll)
|
||||
isActive Boolean @default(true)
|
||||
lastLogin DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
id String @id @default(cuid())
|
||||
username String
|
||||
nomor String @unique
|
||||
roleId String @default("2")
|
||||
isActive Boolean @default(false)
|
||||
sessionInvalid Boolean @default(false)
|
||||
lastLogin DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
permissions Json?
|
||||
sessions UserSession[] // ✅ Relasi one-to-many
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
menuAccesses UserMenuAccess[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Role {
|
||||
id String @id @default(cuid())
|
||||
name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH
|
||||
description String?
|
||||
permissions Json // Menyimpan permission dalam format JSON
|
||||
permissions Json?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -2167,26 +2207,32 @@ model KodeOtp {
|
||||
otp Int
|
||||
}
|
||||
|
||||
// Tabel untuk menyimpan permission
|
||||
model Permission {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
model UserSession {
|
||||
id String @id @default(cuid())
|
||||
token String @db.Text // ✅ JWT bisa panjang
|
||||
expiresAt DateTime // ✅ Ubah jadi expiresAt (konsisten)
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@map("permissions")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String // ✅ HAPUS @unique - user bisa punya multiple sessions
|
||||
|
||||
@@index([userId]) // ✅ Index untuk query cepat
|
||||
@@index([token]) // ✅ Index untuk verify cepat
|
||||
@@map("user_sessions")
|
||||
}
|
||||
|
||||
model UserSession {
|
||||
id String @id @default(cuid())
|
||||
token String
|
||||
expires DateTime?
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
User User @relation(fields: [userId], references: [id])
|
||||
userId String @unique
|
||||
model UserMenuAccess {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
menuId String // ID menu (misal: "Landing Page", "Kesehatan")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali
|
||||
}
|
||||
|
||||
// ========================================= DATA PENDIDIKAN ========================================= //
|
||||
|
||||
299
prisma/seed.ts
299
prisma/seed.ts
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import prisma from "@/lib/prisma";
|
||||
import profilePejabatDesa from "./data/landing-page/profile/profile.json";
|
||||
@@ -54,64 +55,27 @@ import tujuanProgram2 from "./data/pendidikan/pendidikan-non-formal/tujuan-progr
|
||||
import programUnggulan from "./data/pendidikan/program-pendidikan-anak/program-unggulan.json";
|
||||
import tujuanProgram from "./data/pendidikan/program-pendidikan-anak/tujuan-program.json";
|
||||
import roles from "./data/user/roles.json";
|
||||
import users from "./data/user/users.json";
|
||||
import fileStorage from "./data/file-storage.json";
|
||||
import jenjangPendidikan from "./data/pendidikan/info-sekolah/jenjang-pendidikan.json";
|
||||
import seedAssets from "./seed_assets";
|
||||
import users from "./data/user/users.json";
|
||||
import { safeSeedUnique } from "./safeseedUnique";
|
||||
import safeImageId from "./data/safeImageId";
|
||||
import resolveImageIdForSeed from "./data/resolveImageId";
|
||||
|
||||
(async () => {
|
||||
// =========== USER & ROLE ===========
|
||||
// In your seed.ts
|
||||
// =========== ROLES ===========
|
||||
console.log("🔄 Seeding roles...");
|
||||
for (const r of roles) {
|
||||
await safeSeedUnique("role", { id: r.id }, {
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
permissions: r.permissions,
|
||||
isActive: r.isActive,
|
||||
});
|
||||
}
|
||||
// seed assets
|
||||
await prisma.fileStorage.deleteMany();
|
||||
console.log("🗑️ Cleared existing fileStorage records");
|
||||
await seedAssets();
|
||||
|
||||
console.log("✅ Roles seeded");
|
||||
|
||||
// =========== USERS ===========
|
||||
console.log("🔄 Seeding users...");
|
||||
for (const u of users) {
|
||||
// First verify the role exists
|
||||
const roleExists = await prisma.role.findUnique({
|
||||
where: { id: u.roleId },
|
||||
});
|
||||
|
||||
if (!roleExists) {
|
||||
console.error(`❌ Role with id ${u.roleId} not found for user ${u.nama}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await safeSeedUnique("user", { id: u.id }, {
|
||||
username: u.nama,
|
||||
nomor: u.nomor,
|
||||
roleId: u.roleId,
|
||||
isActive: u.isActive,
|
||||
});
|
||||
}
|
||||
console.log("✅ Users seeded");
|
||||
|
||||
// =========== FILE STORAGE ===========
|
||||
// // =========== FILE STORAGE ===========
|
||||
console.log("🔄 Seeding file storage...");
|
||||
for (const f of fileStorage) {
|
||||
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: {
|
||||
await safeSeedUnique(
|
||||
"fileStorage",
|
||||
{ name: f.name },
|
||||
{
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
realName: f.realName,
|
||||
@@ -119,86 +83,196 @@ import { safeSeedUnique } from "./safeseedUnique";
|
||||
mimeType: f.mimeType,
|
||||
link: f.link,
|
||||
category: f.category,
|
||||
},
|
||||
});
|
||||
deletedAt: null,
|
||||
isActive: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
console.log("✅ File storage seeded");
|
||||
|
||||
console.log("🔄 Seeding roles...");
|
||||
|
||||
for (const r of roles) {
|
||||
try {
|
||||
// ✅ Destructure to remove permissions if exists
|
||||
const { permissions, ...roleData } = r as any;
|
||||
|
||||
await safeSeedUnique(
|
||||
"role",
|
||||
{ name: roleData.name },
|
||||
{
|
||||
id: roleData.id,
|
||||
name: roleData.name,
|
||||
description: roleData.description,
|
||||
permissions: roleData.permissions || {}, // ✅ Include permissions
|
||||
isActive: roleData.isActive,
|
||||
}
|
||||
);
|
||||
console.log(`✅ Seeded role -> ${roleData.name}`);
|
||||
} catch (error: any) {
|
||||
if (error.code === "P2002") {
|
||||
console.warn(`⚠️ Role already exists (skipping): ${r.name}`);
|
||||
} else {
|
||||
console.error(`❌ Failed to seed role ${r.name}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("✅ Roles seeding completed");
|
||||
|
||||
// =========== USER ===========
|
||||
console.log("🔄 Seeding users...");
|
||||
for (const u of users) {
|
||||
try {
|
||||
// Verify role exists first
|
||||
const roleExists = await prisma.role.findUnique({
|
||||
where: { id: u.roleId.toString() },
|
||||
select: { id: true }, // Only select id to minimize query
|
||||
});
|
||||
|
||||
if (!roleExists) {
|
||||
console.error(
|
||||
`❌ Role with id ${u.roleId} not found for user ${u.username}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await safeSeedUnique(
|
||||
"user",
|
||||
{ id: u.id },
|
||||
{
|
||||
username: u.username,
|
||||
nomor: u.nomor,
|
||||
roleId: u.roleId.toString(),
|
||||
isActive: u.isActive,
|
||||
sessionInvalid: false,
|
||||
}
|
||||
);
|
||||
console.log(`✅ Seeded user -> ${u.username}`);
|
||||
} catch (error: any) {
|
||||
if (error.code === "P2003") {
|
||||
console.error(
|
||||
`❌ Foreign key constraint failed for user ${u.username}: Role ${u.roleId} does not exist`
|
||||
);
|
||||
} else {
|
||||
console.error(`❌ Failed to seed user ${u.username}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("✅ Users seeding completed");
|
||||
// =========== LANDING PAGE ===========
|
||||
// =========== SUBMENU PROFILE ===========
|
||||
// =========== PROFILE PEJABAT DESA ===========
|
||||
// In your seed.ts file, update the PejabatDesa seeding section to:
|
||||
console.log("🔄 Seeding Pejabat Desa...");
|
||||
for (const p of profilePejabatDesa) {
|
||||
await prisma.pejabatDesa.upsert({
|
||||
where: { id: p.id },
|
||||
update: {
|
||||
name: p.name,
|
||||
position: p.position,
|
||||
imageId: p.imageId,
|
||||
},
|
||||
create: {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
position: p.position,
|
||||
imageId: p.imageId,
|
||||
},
|
||||
});
|
||||
try {
|
||||
// First, verify the image exists
|
||||
if (p.imageId) {
|
||||
const imageExists = await prisma.fileStorage.findUnique({
|
||||
where: { id: p.imageId },
|
||||
});
|
||||
|
||||
if (!imageExists) {
|
||||
console.warn(
|
||||
`⚠️ 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(
|
||||
"✅ profilePejabatDesa seeded without imageId (editable later via UI)"
|
||||
);
|
||||
console.log("✅ Pejabat Desa seeding completed");
|
||||
|
||||
// =========== PROGRAM INOVASI ===========
|
||||
for (const p of programInovasi) {
|
||||
let imageId: string | null = null;
|
||||
// Add this section after the other seed operations in seed.ts
|
||||
console.log("🔄 Seeding Program Inovasi...");
|
||||
|
||||
if (p.imageId) {
|
||||
const imageExists = await prisma.fileStorage.findUnique({
|
||||
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 (imageExists) {
|
||||
imageId = p.imageId;
|
||||
} else {
|
||||
console.warn(
|
||||
`⚠️ imageId ${p.imageId} tidak ditemukan untuk ProgramInovasi ${p.name}`
|
||||
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({
|
||||
where: { id: p.id },
|
||||
update: {
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
link: p.link,
|
||||
imageId: p.imageId,
|
||||
imageId,
|
||||
},
|
||||
create: {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
link: p.link,
|
||||
imageId: p.imageId,
|
||||
imageId,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log("program inovasi success ...");
|
||||
|
||||
// =========== 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({
|
||||
where: { id: p.id },
|
||||
where: { id: m.id },
|
||||
update: {
|
||||
name: p.name,
|
||||
iconUrl: p.iconUrl,
|
||||
imageId: p.imageId,
|
||||
name: m.name,
|
||||
iconUrl: m.iconUrl,
|
||||
// ⛔ JANGAN overwrite imageId sembarangan
|
||||
imageId,
|
||||
},
|
||||
create: {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
iconUrl: p.iconUrl,
|
||||
imageId: p.imageId,
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
iconUrl: m.iconUrl,
|
||||
imageId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log("media sosial success ...");
|
||||
|
||||
// =========== SUBMENU DESA ANTI KORUPSI ===========
|
||||
@@ -539,15 +613,40 @@ import { safeSeedUnique } from "./safeseedUnique";
|
||||
console.log("posisi organisasi berhasil");
|
||||
|
||||
// =========== PEGAWAI PPID ===========
|
||||
console.log("🔄 Seeding pegawai PPID...");
|
||||
const flattenedPegawai = pegawaiPPID.flat();
|
||||
|
||||
// Check for duplicate emails
|
||||
const emails = new Set();
|
||||
for (const p of flattenedPegawai) {
|
||||
await prisma.pegawaiPPID.upsert({
|
||||
where: { id: p.id },
|
||||
update: p,
|
||||
create: p,
|
||||
});
|
||||
if (emails.has(p.email)) {
|
||||
console.warn(`⚠️ Duplicate email found in pegawaiPPID: ${p.email}`);
|
||||
}
|
||||
emails.add(p.email);
|
||||
}
|
||||
console.log("pegawai berhasil");
|
||||
|
||||
for (const p of flattenedPegawai) {
|
||||
try {
|
||||
await prisma.pegawaiPPID.upsert({
|
||||
where: { id: p.id },
|
||||
update: p,
|
||||
create: p,
|
||||
});
|
||||
console.log(`✅ Seeded pegawai PPID -> ${p.namaLengkap}`);
|
||||
} catch (error: any) {
|
||||
if (error.code === "P2002") {
|
||||
console.warn(
|
||||
`⚠️ Pegawai PPID with duplicate email (skipping): ${p.email}`
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
`❌ Failed to seed pegawai PPID ${p.namaLengkap}:`,
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("✅ pegawai PPID seeding completed");
|
||||
|
||||
// =========== SUBMENU VISI MISI PPID ===========
|
||||
|
||||
@@ -811,7 +910,9 @@ import { safeSeedUnique } from "./safeseedUnique";
|
||||
const flattenedPosisiBumdes = posisiOrganisasi.flat();
|
||||
|
||||
// ✅ Urutkan berdasarkan hierarki
|
||||
const sortedPosisiBumdes = flattenedPosisiBumdes.sort((a, b) => a.hierarki - b.hierarki);
|
||||
const sortedPosisiBumdes = flattenedPosisiBumdes.sort(
|
||||
(a, b) => a.hierarki - b.hierarki
|
||||
);
|
||||
|
||||
for (const p of sortedPosisiBumdes) {
|
||||
console.log(`Seeding: ${p.nama} (id: ${p.id}, parent: ${p.parentId})`);
|
||||
@@ -891,7 +992,7 @@ import { safeSeedUnique } from "./safeseedUnique";
|
||||
// Add IDs to the kategoriKegiatan data
|
||||
const kategoriKegiatan = kategoriKegiatanData.map((k, index) => ({
|
||||
...k,
|
||||
id: `kategori-${index + 1}`
|
||||
id: `kategori-${index + 1}`,
|
||||
}));
|
||||
|
||||
for (const k of kategoriKegiatan) {
|
||||
@@ -1180,10 +1281,6 @@ import { safeSeedUnique } from "./safeseedUnique";
|
||||
}
|
||||
|
||||
console.log("✅ Jenjang Pendidikan seeded successfully");
|
||||
|
||||
// seed assets
|
||||
await seedAssets();
|
||||
|
||||
})()
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch((e) => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
// prisma/seedAssets.ts
|
||||
import prisma from "@/lib/prisma";
|
||||
import AdmZip from "adm-zip";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import sharp from "sharp";
|
||||
import fetch from "node-fetch";
|
||||
import AdmZip from "adm-zip";
|
||||
import prisma from "@/lib/prisma";
|
||||
import fetchWithRetry from "./data/fetchWithRetry";
|
||||
|
||||
const UPLOADS_DIR =
|
||||
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 ---
|
||||
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 });
|
||||
|
||||
for (const entry of entries) {
|
||||
@@ -41,18 +45,45 @@ export default async function seedAssets() {
|
||||
|
||||
// 1. Download zip
|
||||
const url =
|
||||
"https://cld-dkr-makuro-seafile.wibudev.com/f/ffd5a548a04f47939474/?dl=1";
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Gagal download assets: ${res.statusText}`);
|
||||
"https://cld-dkr-makuro-seafile.wibudev.com/f/90dd12c9713e42379fcd/?dl=1";
|
||||
const res = await fetchWithRetry(url, 3, 20000);
|
||||
|
||||
// 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());
|
||||
|
||||
// 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
|
||||
const extractDir = path.join(process.cwd(), "tmp_assets");
|
||||
await fs.rm(extractDir, { recursive: true, force: true });
|
||||
await fs.mkdir(extractDir, { recursive: true });
|
||||
|
||||
const zip = new AdmZip(buffer);
|
||||
zip.extractAllTo(extractDir, true);
|
||||
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);
|
||||
} catch (err) {
|
||||
throw new Error("Failed to extract ZIP contents");
|
||||
}
|
||||
|
||||
// 3. Cari semua file valid (recursive)
|
||||
const files = await walkDir(extractDir);
|
||||
@@ -84,18 +115,41 @@ export default async function seedAssets() {
|
||||
await fs.copyFile(filePath, targetPath);
|
||||
}
|
||||
|
||||
// 5. Simpan ke DB
|
||||
await prisma.fileStorage.create({
|
||||
data: {
|
||||
name: finalName,
|
||||
realName: entryName,
|
||||
path: targetPath,
|
||||
mimeType,
|
||||
link: `/uploads/${category}/${finalName}`,
|
||||
category,
|
||||
},
|
||||
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({
|
||||
data: {
|
||||
name: finalName,
|
||||
realName: entryName,
|
||||
path: targetPath,
|
||||
mimeType,
|
||||
link: `/uploads/${category}/${finalName}`,
|
||||
category,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`📂 created: ${category}/${finalName}`);
|
||||
}
|
||||
|
||||
console.log(`📂 saved: ${category}/${finalName}`);
|
||||
}
|
||||
|
||||
@@ -103,6 +157,8 @@ export default async function seedAssets() {
|
||||
await fs.rm(extractDir, { recursive: true, force: true });
|
||||
|
||||
console.log("✅ Selesai seed assets!");
|
||||
console.log("DB URL (asset):", process.env.DATABASE_URL);
|
||||
|
||||
}
|
||||
|
||||
// --- Auto run kalau dipanggil langsung ---
|
||||
|
||||
BIN
public/mangupuraaward.jpeg
Normal file
BIN
public/mangupuraaward.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
@@ -7,6 +7,7 @@ import Underline from '@tiptap/extension-underline';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import Superscript from '@tiptap/extension-superscript';
|
||||
import SubScript from '@tiptap/extension-subscript';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
type CreateEditorProps = {
|
||||
value: string;
|
||||
@@ -32,6 +33,13 @@ export default function CreateEditor({ value, onChange }: CreateEditorProps) {
|
||||
},
|
||||
});
|
||||
|
||||
// 👇 Tambahkan efek untuk sinkronisasi value dari luar (resetForm)
|
||||
useEffect(() => {
|
||||
if (editor && value !== editor.getHTML()) {
|
||||
editor.commands.setContent(value || '');
|
||||
}
|
||||
}, [value, editor]);
|
||||
|
||||
return (
|
||||
<RichTextEditor editor={editor}>
|
||||
<RichTextEditor.Toolbar sticky stickyOffset="var(--docs-header-height)">
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function EditEditor({ value, onChange }: EditEditorProps) {
|
||||
editor.off('update', updateHandler);
|
||||
};
|
||||
}, [editor, onChange]);
|
||||
|
||||
|
||||
return (
|
||||
<RichTextEditor editor={editor}>
|
||||
|
||||
36
src/app/admin/(dashboard)/_com/modalNonaktif.tsx
Normal file
36
src/app/admin/(dashboard)/_com/modalNonaktif.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import { Box, rem, Select } from '@mantine/core';
|
||||
import { Box, Group, rem, Select, SelectProps } from '@mantine/core';
|
||||
import {
|
||||
IconAmbulance,
|
||||
IconCash,
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
IconTrophy,
|
||||
IconTruckFilled,
|
||||
IconBuilding,
|
||||
IconAlertTriangle
|
||||
IconAlertTriangle,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
const iconMap = {
|
||||
@@ -38,26 +38,26 @@ const iconMap = {
|
||||
scale: { label: 'Scale', icon: IconScale },
|
||||
clipboard: { label: 'Clipboard', icon: IconClipboardTextFilled },
|
||||
trash: { label: 'Trash', icon: IconTrashFilled },
|
||||
lingkunganSehat: {label: 'Lingkungan Sehat', icon: IconHomeEco},
|
||||
sumberOksigen: {label: 'Sumber Oksigen', icon: IconChristmasTreeFilled},
|
||||
ekonomiBerkelanjutan: {label: 'Ekonomi Berkelanjutan', icon: IconTrendingUp},
|
||||
mencegahBencana: {label: 'Mencegah Bencana', icon: IconShieldFilled},
|
||||
rumah: {label: 'Rumah', icon: IconHome},
|
||||
pohon: {label: 'Pohon', icon: IconTree},
|
||||
air: {label: 'Air', icon: IconDroplet},
|
||||
bantuan: {label: 'Bantuan', icon: IconCash},
|
||||
pelatihan: {label: 'Pelatihan', icon: IconSchool},
|
||||
subsidi: {label: 'Subsidi', icon: IconShoppingCart},
|
||||
layananKesehatan: {label: 'Layanan Kesehatan', icon: IconHospital},
|
||||
polisi: {label: 'Polisi', icon: IconShieldFilled},
|
||||
ambulans: {label: 'Ambulans', icon: IconAmbulance},
|
||||
pemadam: {label: 'Pemadam', icon: IconFiretruck},
|
||||
rumahSakit: {label: 'Rumah Sakit', icon: IconHospital},
|
||||
bangunan: {label: 'Bangunan', icon: IconBuilding},
|
||||
darurat: {label: 'Darurat', icon: IconAlertTriangle},
|
||||
lingkunganSehat: { label: 'Lingkungan Sehat', icon: IconHomeEco },
|
||||
sumberOksigen: { label: 'Sumber Oksigen', icon: IconChristmasTreeFilled },
|
||||
ekonomiBerkelanjutan: { label: 'Ekonomi Berkelanjutan', icon: IconTrendingUp },
|
||||
mencegahBencana: { label: 'Mencegah Bencana', icon: IconShieldFilled },
|
||||
rumah: { label: 'Rumah', icon: IconHome },
|
||||
pohon: { label: 'Pohon', icon: IconTree },
|
||||
air: { label: 'Air', icon: IconDroplet },
|
||||
bantuan: { label: 'Bantuan', icon: IconCash },
|
||||
pelatihan: { label: 'Pelatihan', icon: IconSchool },
|
||||
subsidi: { label: 'Subsidi', icon: IconShoppingCart },
|
||||
layananKesehatan: { label: 'Layanan Kesehatan', icon: IconHospital },
|
||||
polisi: { label: 'Polisi', icon: IconShieldFilled },
|
||||
ambulans: { label: 'Ambulans', icon: IconAmbulance },
|
||||
pemadam: { label: 'Pemadam', icon: IconFiretruck },
|
||||
rumahSakit: { label: 'Rumah Sakit', icon: IconHospital },
|
||||
bangunan: { label: 'Bangunan', icon: IconBuilding },
|
||||
darurat: { label: 'Darurat', icon: IconAlertTriangle },
|
||||
};
|
||||
|
||||
type IconKey = keyof typeof iconMap;
|
||||
export type IconKey = keyof typeof iconMap;
|
||||
|
||||
const iconList = Object.entries(iconMap).map(([value, data]) => ({
|
||||
value,
|
||||
@@ -67,44 +67,52 @@ const iconList = Object.entries(iconMap).map(([value, data]) => ({
|
||||
export default function SelectIconProgramEdit({
|
||||
onChange,
|
||||
value,
|
||||
...props
|
||||
}: {
|
||||
onChange: (value: IconKey) => void;
|
||||
value: IconKey;
|
||||
}) {
|
||||
const IconComponent = iconMap[value]?.icon || null;
|
||||
|
||||
onChange: (value: IconKey | '') => void;
|
||||
value: IconKey | '';
|
||||
} & Omit<SelectProps, 'onChange' | 'value' | 'data'>) {
|
||||
return (
|
||||
<Box maw={300}>
|
||||
<Select
|
||||
placeholder="Pilih ikon"
|
||||
value={value}
|
||||
onChange={(value) => {
|
||||
if (value) onChange(value as IconKey);
|
||||
value={value || ''}
|
||||
onChange={(val: string | null) => {
|
||||
if (val) {
|
||||
onChange(val as IconKey);
|
||||
} else {
|
||||
onChange('');
|
||||
}
|
||||
}}
|
||||
data={iconList}
|
||||
renderOption={({ option }) => {
|
||||
const Icon = iconMap[option.value as IconKey]?.icon;
|
||||
return (
|
||||
<Group gap="sm">
|
||||
{Icon && <Icon size={18} stroke={1.5} />}
|
||||
{option.label}
|
||||
</Group>
|
||||
);
|
||||
}}
|
||||
leftSection={
|
||||
IconComponent && (
|
||||
<Box>
|
||||
<IconComponent size={24} stroke={1.5} />
|
||||
value && iconMap[value as IconKey] ? (
|
||||
<Box ml={-4}>
|
||||
{(() => {
|
||||
const Icon = iconMap[value as IconKey].icon;
|
||||
return <Icon size={20} stroke={1.5} />;
|
||||
})()}
|
||||
</Box>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
withCheckIcon={false}
|
||||
searchable={false}
|
||||
rightSectionWidth={0}
|
||||
searchable
|
||||
styles={{
|
||||
input: {
|
||||
textAlign: 'left',
|
||||
fontSize: rem(16),
|
||||
paddingLeft: 40,
|
||||
},
|
||||
section: {
|
||||
left: 10,
|
||||
right: 'auto',
|
||||
fontSize: rem(16),
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
76
src/app/admin/(dashboard)/_com/selectSocialMedia.tsx
Normal file
76
src/app/admin/(dashboard)/_com/selectSocialMedia.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/app/admin/(dashboard)/_com/selectSocialMediaEdit.tsx
Normal file
56
src/app/admin/(dashboard)/_com/selectSocialMediaEdit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,7 @@ const penghargaanState = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
penghargaanState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -68,7 +68,7 @@ const category = proxy({
|
||||
const res = await ApiFetch.api.desa.kategoripengumuman[
|
||||
"findMany"
|
||||
].get({
|
||||
query: { page, limit },
|
||||
query: { page, limit, search },
|
||||
});
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
@@ -287,7 +287,7 @@ const pengumuman = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
pengumuman.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -65,7 +65,7 @@ const potensiDesa = proxy({
|
||||
const res = await ApiFetch.api.desa.potensi[
|
||||
"find-many"
|
||||
].get({
|
||||
query: { page, limit },
|
||||
query: { page, limit, search },
|
||||
});
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
|
||||
@@ -101,6 +101,38 @@ const ApbDesa = proxy({
|
||||
}
|
||||
},
|
||||
},
|
||||
findFirst: {
|
||||
data: null as Prisma.ApbDesaGetPayload<{
|
||||
include: { pendapatan: true; belanja: true; pembiayaan: true };
|
||||
}> | null,
|
||||
loading: false,
|
||||
async load(params?: Record<string, any>) {
|
||||
try {
|
||||
this.loading = true;
|
||||
|
||||
// ✅ request ke endpoint find-first
|
||||
const res = await ApiFetch.api.ekonomi.pendapatanaslidesa.apbdesa[
|
||||
"find-first"
|
||||
].get({ query: params || {} });
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
this.data = res.data.data ?? null;
|
||||
} else {
|
||||
this.data = null;
|
||||
toast.error(res.data?.message || "Gagal memuat data pertama APB Desa");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error findFirst APB Desa:", error);
|
||||
toast.error("Gagal memuat data APB Desa pertama");
|
||||
this.data = null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
reset() {
|
||||
this.data = null;
|
||||
},
|
||||
},
|
||||
update: {
|
||||
id: "",
|
||||
form: { ...ApbDesaDefaultForm },
|
||||
|
||||
@@ -49,7 +49,7 @@ const demografiPekerjaan = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data?.id;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
demografiPekerjaan.create.form = { ...defaultForm };
|
||||
demografiPekerjaan.findMany.load();
|
||||
return id;
|
||||
|
||||
@@ -47,7 +47,7 @@ const jumlahPendudukMiskin = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data?.id;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
jumlahPendudukMiskin.create.form = {
|
||||
year: 0,
|
||||
totalPoorPopulation: 0,
|
||||
|
||||
@@ -89,7 +89,7 @@ const jumlahPengangguran = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.id;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
jumlahPengangguran.create.form = { ...jumlahPengangguranForm };
|
||||
jumlahPengangguran.findMany.load();
|
||||
return id;
|
||||
|
||||
@@ -47,7 +47,7 @@ const lowonganKerjaState = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
lowonganKerjaState.create.loading = false;
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -312,15 +312,15 @@ const kategoriProduk = proxy({
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
loading: false,
|
||||
search2: "",
|
||||
load: async (page = 1, limit = 10, search2 = "") => {
|
||||
search: "",
|
||||
load: async (page = 1, limit = 10, search = "") => {
|
||||
kategoriProduk.findMany.loading = true; // ✅ Akses langsung via nama path
|
||||
kategoriProduk.findMany.page = page;
|
||||
kategoriProduk.findMany.search2 = search2;
|
||||
kategoriProduk.findMany.search = search;
|
||||
|
||||
try {
|
||||
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 });
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const programKemiskinanState = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
programKemiskinanState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -46,7 +46,7 @@ const grafikSektorUnggulan = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data?.id;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
grafikSektorUnggulan.create.form = {
|
||||
name: "",
|
||||
description: "",
|
||||
|
||||
@@ -194,7 +194,7 @@ const posisiOrganisasi = proxy({
|
||||
|
||||
try {
|
||||
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) {
|
||||
toast.success("Berhasil menambahkan posisi organisasi");
|
||||
posisiOrganisasi.findMany.load();
|
||||
|
||||
@@ -51,7 +51,7 @@ const grafikBerdasarkanUsiaKerjaNganggur = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data?.id;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
grafikBerdasarkanUsiaKerjaNganggur.create.form = {
|
||||
usia18_25: "",
|
||||
usia26_35: "",
|
||||
@@ -255,7 +255,7 @@ const grafikBerdasarkanPendidikan = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data?.id;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
grafikBerdasarkanPendidikan.create.form = {
|
||||
SD: "",
|
||||
SMP: "",
|
||||
|
||||
@@ -37,7 +37,7 @@ const desaDigitalState = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
desaDigitalState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -37,7 +37,7 @@ const infoTeknoState = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
infoTeknoState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -6,9 +6,9 @@ import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
|
||||
const templateForm = z.object({
|
||||
name: z.string().min(1, "Nama minimal 1 karakter"),
|
||||
deskripsi: z.string().min(1, "Deskripsi minimal 1 karakter"),
|
||||
slug: z.string().min(1, "Deskripsi singkat minimal 1 karakter"),
|
||||
name: z.string().min(5, "Nama minimal 5 karakter"),
|
||||
deskripsi: z.string().min(5, "Deskripsi minimal 5 karakter"),
|
||||
slug: z.string().min(5, "Deskripsi singkat minimal 5 karakter"),
|
||||
icon: z.string().min(1, "Icon minimal 1 karakter"),
|
||||
});
|
||||
|
||||
@@ -29,26 +29,33 @@ const programKreatifState = proxy({
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
return toast.error(err);
|
||||
toast.error(err);
|
||||
return false; // ⬅️ ini penting
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
programKreatifState.create.loading = true;
|
||||
const res = await ApiFetch.api.inovasi.programkreatif["create"].post(
|
||||
programKreatifState.create.form
|
||||
);
|
||||
|
||||
if (res.status === 200) {
|
||||
programKreatifState.findMany.load();
|
||||
return toast.success("success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
return true;
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
toast.error("failed create");
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.log((error as Error).message);
|
||||
console.error((error as Error).message);
|
||||
toast.error("Terjadi kesalahan saat create");
|
||||
return false;
|
||||
} finally {
|
||||
programKreatifState.create.loading = false;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
},
|
||||
findMany: {
|
||||
data: null as any[] | null,
|
||||
|
||||
@@ -37,7 +37,7 @@ const keamananLingkunganState = proxy({
|
||||
].post(keamananLingkunganState.create.form);
|
||||
if (res.status === 200) {
|
||||
keamananLingkunganState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -38,7 +38,7 @@ const kontakDaruratKeamananState = proxy({
|
||||
].post(kontakDaruratKeamananState.create.form);
|
||||
if (res.status === 200) {
|
||||
kontakDaruratKeamananState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
@@ -294,7 +294,7 @@ const kontakDaruratItem = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
kontakDaruratItem.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -88,7 +88,7 @@ const laporanPublikState = proxy({
|
||||
|
||||
if (res.status === 200) {
|
||||
laporanPublikState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
|
||||
console.log(res);
|
||||
|
||||
@@ -40,7 +40,7 @@ const pencegahanKriminalitasState = proxy({
|
||||
].post(pencegahanKriminalitasState.create.form);
|
||||
if (res.status === 200) {
|
||||
pencegahanKriminalitasState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -37,7 +37,7 @@ const tipsKeamananState = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
tipsKeamananState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -9,29 +9,30 @@ import { z } from "zod";
|
||||
// Validasi form
|
||||
const templateForm = z.object({
|
||||
name: z.string().min(1, "Nama harus diisi"),
|
||||
|
||||
informasiUmum: z.object({
|
||||
fasilitas: z.string().min(1, "Fasilitas harus diisi"),
|
||||
alamat: z.string().min(1, "Alamat harus diisi"),
|
||||
jamOperasional: z.string().min(1, "Jam operasional harus diisi"),
|
||||
fasilitas: z.string().min(1),
|
||||
alamat: z.string().min(1),
|
||||
jamOperasional: z.string().min(1),
|
||||
}),
|
||||
|
||||
layananUnggulan: z.object({
|
||||
content: z.string().min(1, "Layanan unggulan harus diisi"),
|
||||
}),
|
||||
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"),
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
|
||||
// NOW ARRAY OF STRING (ID)
|
||||
dokterdanTenagaMedis: z.array(z.string()).min(1, "Minimal pilih 1 dokter"),
|
||||
|
||||
fasilitasPendukung: z.object({
|
||||
content: z.string().min(1, "Fasilitas pendukung harus diisi"),
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
|
||||
prosedurPendaftaran: z.object({
|
||||
content: z.string().min(1, "Prosedur pendaftaran harus diisi"),
|
||||
}),
|
||||
tarifDanLayanan: z.object({
|
||||
layanan: z.string().min(1, "Layanan harus diisi"),
|
||||
tarif: z.string().min(1, "Tarif harus diisi"),
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
|
||||
// NOW ARRAY OF STRING (ID)
|
||||
tarifDanLayanan: z.array(z.string()).min(1, "Minimal pilih 1 tarif"),
|
||||
});
|
||||
|
||||
// Default form kosong
|
||||
@@ -45,21 +46,34 @@ const defaultForm = {
|
||||
layananUnggulan: {
|
||||
content: "",
|
||||
},
|
||||
dokterdanTenagaMedis: {
|
||||
name: "",
|
||||
specialist: "",
|
||||
jadwal: "",
|
||||
},
|
||||
|
||||
dokterdanTenagaMedis: [] as string[], // ← array kosong
|
||||
tarifDanLayanan: [] as string[], // ← array kosong
|
||||
|
||||
fasilitasPendukung: {
|
||||
content: "",
|
||||
},
|
||||
prosedurPendaftaran: {
|
||||
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({
|
||||
@@ -186,33 +200,26 @@ const fasilitasKesehatan = proxy({
|
||||
|
||||
const result = await res.json();
|
||||
const data = result.data;
|
||||
|
||||
fasilitasKesehatan.edit.id = data.id;
|
||||
fasilitasKesehatan.edit.form = {
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
name: data.name,
|
||||
informasiUmum: {
|
||||
fasilitas: data.informasiumum.fasilitas,
|
||||
alamat: data.informasiumum.alamat,
|
||||
jamOperasional: data.informasiumum.jamOperasional,
|
||||
},
|
||||
layananUnggulan: {
|
||||
content: data.layananunggulan.content,
|
||||
},
|
||||
dokterdanTenagaMedis: {
|
||||
name: data.dokterdantenagamedis.name,
|
||||
specialist: data.dokterdantenagamedis.specialist,
|
||||
jadwal: data.dokterdantenagamedis.jadwal,
|
||||
},
|
||||
fasilitasPendukung: {
|
||||
content: data.fasilitaspendukung.content,
|
||||
},
|
||||
prosedurPendaftaran: {
|
||||
content: data.prosedurpendaftaran.content,
|
||||
},
|
||||
tarifDanLayanan: {
|
||||
layanan: data.tarifdanlayanan.layanan,
|
||||
tarif: data.tarifdanlayanan.tarif,
|
||||
// map relasi -> array of IDs
|
||||
layananUnggulan: {
|
||||
content: data.layananunggulan.content,
|
||||
},
|
||||
dokterdanTenagaMedis: data.dokterdantenagamedis?.map((v: DokterItem) => v.id) ?? [],
|
||||
tarifDanLayanan: data.tarifdanlayanan?.map((v: TarifItem) => v.id) ?? [],
|
||||
};
|
||||
},
|
||||
async submit() {
|
||||
@@ -238,22 +245,15 @@ const fasilitasKesehatan = proxy({
|
||||
layananUnggulan: {
|
||||
content: fasilitasKesehatan.edit.form.layananUnggulan.content,
|
||||
},
|
||||
dokterdanTenagaMedis: {
|
||||
name: fasilitasKesehatan.edit.form.dokterdanTenagaMedis.name,
|
||||
specialist:
|
||||
fasilitasKesehatan.edit.form.dokterdanTenagaMedis.specialist,
|
||||
jadwal: fasilitasKesehatan.edit.form.dokterdanTenagaMedis.jadwal,
|
||||
},
|
||||
dokterdanTenagaMedis:
|
||||
fasilitasKesehatan.edit.form.dokterdanTenagaMedis,
|
||||
fasilitasPendukung: {
|
||||
content: fasilitasKesehatan.edit.form.fasilitasPendukung.content,
|
||||
},
|
||||
prosedurPendaftaran: {
|
||||
content: fasilitasKesehatan.edit.form.prosedurPendaftaran.content,
|
||||
},
|
||||
tarifDanLayanan: {
|
||||
layanan: fasilitasKesehatan.edit.form.tarifDanLayanan.layanan,
|
||||
tarif: fasilitasKesehatan.edit.form.tarifDanLayanan.tarif,
|
||||
},
|
||||
tarifDanLayanan: fasilitasKesehatan.edit.form.tarifDanLayanan,
|
||||
};
|
||||
|
||||
const res = await fetch(
|
||||
@@ -320,12 +320,26 @@ const templateDokterForm = z.object({
|
||||
name: z.string().min(1, "Nama tidak boleh kosong"),
|
||||
specialist: z.string().min(1, "Spesialis 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 = {
|
||||
name: "",
|
||||
specialist: "",
|
||||
jadwal: "",
|
||||
jadwalLibur: "",
|
||||
jamBukaOperasional: "",
|
||||
jamTutupOperasional: "",
|
||||
jamBukaLibur: "",
|
||||
jamTutupLibur: "",
|
||||
};
|
||||
|
||||
const dokter = proxy({
|
||||
@@ -351,7 +365,7 @@ const dokter = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
dokter.create.create.form = { ...defaultDokterForm };
|
||||
dokter.findMany.load();
|
||||
return id;
|
||||
@@ -463,6 +477,11 @@ const dokter = proxy({
|
||||
name: data.name,
|
||||
specialist: data.specialist,
|
||||
jadwal: data.jadwal,
|
||||
jadwalLibur: data.jadwalLibur,
|
||||
jamBukaOperasional: data.jamBukaOperasional,
|
||||
jamTutupOperasional: data.jamTutupOperasional,
|
||||
jamBukaLibur: data.jamBukaLibur,
|
||||
jamTutupLibur: data.jamTutupLibur,
|
||||
};
|
||||
return data; // Return the loaded data
|
||||
} else {
|
||||
@@ -487,6 +506,11 @@ const dokter = proxy({
|
||||
name: this.form.name,
|
||||
specialist: this.form.specialist,
|
||||
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);
|
||||
@@ -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({
|
||||
fasilitasKesehatan,
|
||||
dokter,
|
||||
tarif
|
||||
});
|
||||
|
||||
export default fasilitasKesehatanState;
|
||||
|
||||
@@ -43,7 +43,7 @@ const grafikkepuasan = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
grafikkepuasan.create.form = { ...defaultForm };
|
||||
grafikkepuasan.findMany.load();
|
||||
return id;
|
||||
|
||||
@@ -50,7 +50,7 @@ const persentasekelahiran = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
persentasekelahiran.create.form = { ...defaultForm };
|
||||
persentasekelahiran.findMany.load();
|
||||
return id;
|
||||
|
||||
@@ -5,58 +5,117 @@ import { toast } from "react-toastify";
|
||||
import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
|
||||
const templateapbDesaForm = z.object({
|
||||
name: z.string().min(1, "Judul minimal 1 karakter"),
|
||||
jumlah: z.string().min(1, "Deskripsi minimal 1 karakter"),
|
||||
imageId: z.string().min(1, "File minimal 1"),
|
||||
fileId: z.string().min(1, "File minimal 1"),
|
||||
// --- Zod Schema ---
|
||||
const ApbdesItemSchema = z.object({
|
||||
kode: z.string().min(1, "Kode wajib diisi"),
|
||||
uraian: z.string().min(1, "Uraian wajib diisi"),
|
||||
anggaran: z.number().min(0),
|
||||
realisasi: z.number().min(0),
|
||||
selisih: z.number(),
|
||||
persentase: z.number(),
|
||||
level: z.number().int().min(1).max(3),
|
||||
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
|
||||
});
|
||||
|
||||
const defaultapbdesForm = {
|
||||
name: "",
|
||||
jumlah: "",
|
||||
const ApbdesFormSchema = z.object({
|
||||
tahun: z.number().int().min(2000, "Tahun tidak valid"),
|
||||
imageId: z.string().min(1, "Gambar wajib diunggah"),
|
||||
fileId: z.string().min(1, "File wajib diunggah"),
|
||||
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
|
||||
});
|
||||
|
||||
// --- Default Form ---
|
||||
const defaultApbdesForm = {
|
||||
tahun: new Date().getFullYear(),
|
||||
imageId: "",
|
||||
fileId: "",
|
||||
items: [] as z.infer<typeof ApbdesItemSchema>[],
|
||||
};
|
||||
|
||||
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
|
||||
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
|
||||
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
|
||||
const anggaran = item.anggaran ?? 0;
|
||||
const realisasi = item.realisasi ?? 0;
|
||||
|
||||
|
||||
|
||||
|
||||
// ✅ Formula yang benar
|
||||
const selisih = anggaran - realisasi; // positif = sisa anggaran, negatif = over budget
|
||||
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran
|
||||
|
||||
return {
|
||||
kode: item.kode || "",
|
||||
uraian: item.uraian || "",
|
||||
anggaran,
|
||||
realisasi,
|
||||
selisih,
|
||||
persentase,
|
||||
level: item.level || 1,
|
||||
tipe: item.tipe, // biarkan null jika memang null
|
||||
};
|
||||
}
|
||||
|
||||
// --- State Utama ---
|
||||
const apbdes = proxy({
|
||||
create: {
|
||||
form: { ...defaultapbdesForm },
|
||||
form: { ...defaultApbdesForm },
|
||||
loading: false,
|
||||
async create() {
|
||||
const cek = templateapbDesaForm.safeParse(apbdes.create.form);
|
||||
if (!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
return toast.error(err);
|
||||
}
|
||||
try {
|
||||
apbdes.create.loading = true;
|
||||
const res = await ApiFetch.api.landingpage.apbdes["create"].post({
|
||||
...apbdes.create.form,
|
||||
});
|
||||
|
||||
if (res.status === 200) {
|
||||
addItem(item: Partial<z.infer<typeof ApbdesItemSchema>>) {
|
||||
const normalized = normalizeItem(item);
|
||||
this.form.items.push(normalized);
|
||||
},
|
||||
|
||||
removeItem(index: number) {
|
||||
this.form.items.splice(index, 1);
|
||||
},
|
||||
|
||||
updateItem(index: number, updates: Partial<z.infer<typeof ApbdesItemSchema>>) {
|
||||
const current = this.form.items[index];
|
||||
if (current) {
|
||||
const updated = normalizeItem({ ...current, ...updates });
|
||||
this.form.items[index] = updated;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.form = { ...defaultApbdesForm };
|
||||
},
|
||||
|
||||
async create() {
|
||||
const parsed = ApbdesFormSchema.safeParse(this.form);
|
||||
if (!parsed.success) {
|
||||
const errors = parsed.error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`);
|
||||
toast.error(`Validasi gagal:\n${errors.join("\n")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.loading = true;
|
||||
const res = await ApiFetch.api.landingpage.apbdes["create"].post(parsed.data);
|
||||
|
||||
if (res.data?.success) {
|
||||
toast.success("APBDes berhasil dibuat");
|
||||
apbdes.findMany.load();
|
||||
return toast.success("Data berhasil ditambahkan");
|
||||
this.reset();
|
||||
} else {
|
||||
toast.error(res.data?.message || "Gagal membuat APBDes");
|
||||
}
|
||||
return toast.error("Gagal menambahkan data");
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error("Gagal menambahkan data");
|
||||
} catch (error: any) {
|
||||
console.error("Create APBDes error:", error);
|
||||
toast.error(error?.message || "Terjadi kesalahan saat membuat APBDes");
|
||||
} finally {
|
||||
apbdes.create.loading = false;
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
findMany: {
|
||||
data: null as
|
||||
| Prisma.APBDesGetPayload<{
|
||||
include: {
|
||||
image: true;
|
||||
file: true;
|
||||
};
|
||||
include: { image: true; file: true; items: true };
|
||||
}>[]
|
||||
| null,
|
||||
page: 1,
|
||||
@@ -64,194 +123,202 @@ const apbdes = proxy({
|
||||
total: 0,
|
||||
loading: false,
|
||||
search: "",
|
||||
load: async (page = 1, limit = 10, search = "") => { // Change to arrow function
|
||||
apbdes.findMany.loading = true; // Use the full path to access the property
|
||||
|
||||
load: async (page = 1, limit = 10, search = "") => {
|
||||
apbdes.findMany.loading = true;
|
||||
apbdes.findMany.page = page;
|
||||
apbdes.findMany.search = search;
|
||||
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
const query: Record<string, string> = { page: String(page), limit: String(limit) };
|
||||
if (search) query.search = search;
|
||||
|
||||
const res = await ApiFetch.api.landingpage.apbdes[
|
||||
"findMany"
|
||||
].get({
|
||||
query
|
||||
});
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
|
||||
const res = await ApiFetch.api.landingpage.apbdes["findMany"].get({ query });
|
||||
|
||||
if (res.data?.success) {
|
||||
apbdes.findMany.data = res.data.data || [];
|
||||
apbdes.findMany.total = res.data.total || 0;
|
||||
apbdes.findMany.totalPages = res.data.totalPages || 1;
|
||||
apbdes.findMany.total = res.data.meta?.total || 0;
|
||||
apbdes.findMany.totalPages = res.data.meta?.totalPages || 1;
|
||||
} else {
|
||||
console.error("Failed to load pegawai:", res.data?.message);
|
||||
apbdes.findMany.data = [];
|
||||
apbdes.findMany.total = 0;
|
||||
apbdes.findMany.totalPages = 1;
|
||||
toast.error(res.data?.message || "Gagal memuat data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading pegawai:", error);
|
||||
console.error("FindMany error:", error);
|
||||
apbdes.findMany.data = [];
|
||||
apbdes.findMany.total = 0;
|
||||
apbdes.findMany.totalPages = 1;
|
||||
toast.error("Gagal memuat daftar APBDes");
|
||||
} finally {
|
||||
apbdes.findMany.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
findUnique: {
|
||||
data: null as Prisma.APBDesGetPayload<{
|
||||
include: {
|
||||
image: true;
|
||||
file: true;
|
||||
};
|
||||
}> | null,
|
||||
data: null as
|
||||
| Prisma.APBDesGetPayload<{
|
||||
include: { image: true; file: true; items: true };
|
||||
}>
|
||||
| null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
|
||||
async load(id: string) {
|
||||
if (!id || id.trim() === '') {
|
||||
this.data = null;
|
||||
this.error = "ID tidak valid";
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/landingpage/apbdes/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
apbdes.findUnique.data = data.data ?? null;
|
||||
// Pastikan URL-nya benar
|
||||
const url = `/api/landingpage/apbdes/${id}`;
|
||||
console.log("🌐 Fetching:", url);
|
||||
|
||||
// Gunakan fetch biasa atau ApiFetch dengan cara yang benar
|
||||
const response = await fetch(url);
|
||||
const res = await response.json();
|
||||
|
||||
console.log("📦 Response:", res);
|
||||
|
||||
if (res.success && res.data) {
|
||||
this.data = res.data;
|
||||
} else {
|
||||
console.error("Failed to fetch data", res.status, res.statusText);
|
||||
apbdes.findUnique.data = null;
|
||||
this.data = null;
|
||||
this.error = res.message || "Gagal memuat detail APBDes";
|
||||
toast.error(this.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
apbdes.findUnique.data = null;
|
||||
console.error("❌ FindUnique error:", error);
|
||||
this.data = null;
|
||||
this.error = "Gagal memuat detail APBDes";
|
||||
toast.error(this.error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
delete: {
|
||||
loading: false,
|
||||
async byId(id: string) {
|
||||
if (!id) return toast.warn("ID tidak valid");
|
||||
|
||||
try {
|
||||
apbdes.delete.loading = true;
|
||||
this.loading = true;
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any)["del"][id].delete();
|
||||
|
||||
const response = await fetch(`/api/landingpage/apbdes/del/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result?.success) {
|
||||
toast.success(result.message || "apbdes berhasil dihapus");
|
||||
await apbdes.findMany.load(); // refresh list
|
||||
if (res.data?.success) {
|
||||
toast.success("APBDes berhasil dihapus");
|
||||
apbdes.findMany.load();
|
||||
} else {
|
||||
toast.error(result?.message || "Gagal menghapus apbdes");
|
||||
toast.error(res.data?.message || "Gagal menghapus APBDes");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Gagal delete:", error);
|
||||
toast.error("Terjadi kesalahan saat menghapus apbdes");
|
||||
} catch (error: any) {
|
||||
console.error("Delete error:", error);
|
||||
toast.error(error?.message || "Terjadi kesalahan saat menghapus");
|
||||
} finally {
|
||||
apbdes.delete.loading = false;
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
edit: {
|
||||
id: "",
|
||||
form: { ...defaultapbdesForm },
|
||||
form: { ...defaultApbdesForm },
|
||||
loading: false,
|
||||
|
||||
async load(id: string) {
|
||||
if (!id) {
|
||||
toast.warn("ID tidak valid");
|
||||
return null;
|
||||
}
|
||||
if (!id) return toast.warn("ID tidak valid");
|
||||
|
||||
try {
|
||||
apbdes.edit.loading = true;
|
||||
this.loading = true;
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any)[id].get();
|
||||
|
||||
const response = await fetch(`/api/landingpage/apbdes/${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;
|
||||
if (res.data?.success) {
|
||||
const data = res.data.data;
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
name: data.name,
|
||||
jumlah: data.jumlah,
|
||||
imageId: data.imageId,
|
||||
fileId: data.fileId,
|
||||
tahun: data.tahun || new Date().getFullYear(),
|
||||
imageId: data.imageId || "",
|
||||
fileId: data.fileId || "",
|
||||
items: (data.items || []).map((item: any) => ({
|
||||
kode: item.kode,
|
||||
uraian: item.uraian,
|
||||
anggaran: item.anggaran,
|
||||
realisasi: item.realisasi,
|
||||
selisih: item.selisih,
|
||||
persentase: item.persentase,
|
||||
level: item.level,
|
||||
tipe: item.tipe || 'pendapatan',
|
||||
})),
|
||||
};
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(result?.message || "Gagal memuat data");
|
||||
throw new Error(res.data?.message || "Gagal memuat data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading apbdes:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Gagal memuat data"
|
||||
);
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
console.error("Edit load error:", error);
|
||||
toast.error(error.message || "Gagal memuat data untuk diedit");
|
||||
} finally {
|
||||
apbdes.edit.loading = false;
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async update() {
|
||||
const cek = templateapbDesaForm.safeParse(apbdes.edit.form);
|
||||
if (!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
return toast.error(err);
|
||||
const parsed = ApbdesFormSchema.safeParse(this.form);
|
||||
if (!parsed.success) {
|
||||
const errors = parsed.error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`);
|
||||
toast.error(`Validasi gagal:\n${errors.join("\n")}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
apbdes.edit.loading = true;
|
||||
const response = await fetch(`/api/landingpage/apbdes/${this.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: this.form.name,
|
||||
jumlah: this.form.jumlah,
|
||||
imageId: this.form.imageId,
|
||||
fileId: this.form.fileId,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.message || `HTTP error! status: ${response.status}`
|
||||
);
|
||||
}
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
toast.success("Berhasil update apbdes");
|
||||
await apbdes.findMany.load(); // refresh list
|
||||
this.loading = true;
|
||||
// Include the ID in the request body
|
||||
const requestData = {
|
||||
...parsed.data,
|
||||
id: this.id, // Add the ID to the request body
|
||||
};
|
||||
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
|
||||
|
||||
if (res.data?.success) {
|
||||
toast.success("APBDes berhasil diperbarui");
|
||||
apbdes.findMany.load();
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.message || "Gagal mengupdate apbdes");
|
||||
throw new Error(res.data?.message || "Gagal memperbarui APBDes");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating apbdes:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Gagal mengupdate apbdes"
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("Update error:", error);
|
||||
toast.error(error.message || "Gagal memperbarui APBDes");
|
||||
return false;
|
||||
} finally {
|
||||
apbdes.edit.loading = false;
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
addItem(item: Partial<z.infer<typeof ApbdesItemSchema>>) {
|
||||
const normalized = normalizeItem(item);
|
||||
this.form.items.push(normalized);
|
||||
},
|
||||
|
||||
removeItem(index: number) {
|
||||
this.form.items.splice(index, 1);
|
||||
},
|
||||
|
||||
reset() {
|
||||
apbdes.edit.id = "";
|
||||
apbdes.edit.form = { ...defaultapbdesForm };
|
||||
this.id = "";
|
||||
this.form = { ...defaultApbdesForm };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default apbdes;
|
||||
export default apbdes;
|
||||
@@ -60,13 +60,18 @@ const responden = proxy({
|
||||
totalPages: 1,
|
||||
total: 0,
|
||||
loading: false,
|
||||
load: async (page = 1, limit = 10) => {
|
||||
search: "",
|
||||
load: async (page = 1, limit = 10, search = "") => {
|
||||
// Change to arrow function
|
||||
responden.findMany.loading = true; // Use the full path to access the property
|
||||
responden.findMany.page = page;
|
||||
responden.findMany.search = search;
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
if (search) query.search = search;
|
||||
|
||||
const res = await ApiFetch.api.landingpage.responden["findMany"].get({
|
||||
query: { page, limit },
|
||||
query,
|
||||
});
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
|
||||
@@ -27,7 +27,7 @@ const programInovasi = proxy({
|
||||
name: "",
|
||||
description: "",
|
||||
imageId: "",
|
||||
link: ""
|
||||
link: "",
|
||||
} as ProgramInovasiForm,
|
||||
loading: false,
|
||||
async create() {
|
||||
@@ -53,7 +53,7 @@ const programInovasi = proxy({
|
||||
].post(formData);
|
||||
if (res.status === 200) {
|
||||
programInovasi.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
@@ -71,20 +71,21 @@ const programInovasi = proxy({
|
||||
total: 0,
|
||||
loading: false,
|
||||
search: "",
|
||||
load: async (page = 1, limit = 10, search = "") => { // Change to arrow function
|
||||
programInovasi.findMany.loading = true; // Use the full path to access the property
|
||||
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.page = page;
|
||||
programInovasi.findMany.search = search;
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
if (search) query.search = search;
|
||||
|
||||
|
||||
const res = await ApiFetch.api.landingpage.programinovasi[
|
||||
"findMany"
|
||||
].get({
|
||||
query
|
||||
query,
|
||||
});
|
||||
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
programInovasi.findMany.data = res.data.data || [];
|
||||
programInovasi.findMany.total = res.data.total || 0;
|
||||
@@ -389,7 +390,10 @@ const pejabatDesa = proxy({
|
||||
|
||||
try {
|
||||
// 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(), {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
@@ -438,16 +442,19 @@ const pejabatDesa = proxy({
|
||||
|
||||
const templateMediaSosial = z.object({
|
||||
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"),
|
||||
icon: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
type MediaSosialForm = {
|
||||
name: string;
|
||||
imageId: string;
|
||||
imageId: string | null; // boleh null
|
||||
iconUrl: string;
|
||||
icon: string | null; // boleh null
|
||||
};
|
||||
|
||||
|
||||
const mediaSosial = proxy({
|
||||
create: {
|
||||
form: {} as MediaSosialForm,
|
||||
@@ -455,9 +462,10 @@ const mediaSosial = proxy({
|
||||
async create() {
|
||||
// Ensure all required fields are non-null
|
||||
const formData = {
|
||||
name: mediaSosial.create.form.name || "",
|
||||
imageId: mediaSosial.create.form.imageId || "",
|
||||
iconUrl: mediaSosial.create.form.iconUrl || "",
|
||||
name: mediaSosial.create.form.name ?? "",
|
||||
imageId: mediaSosial.create.form.imageId ?? null, // FIXED
|
||||
iconUrl: mediaSosial.create.form.iconUrl ?? "",
|
||||
icon: mediaSosial.create.form.icon ?? null, // FIXED
|
||||
};
|
||||
|
||||
const cek = templateMediaSosial.safeParse(formData);
|
||||
@@ -474,7 +482,7 @@ const mediaSosial = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
mediaSosial.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
@@ -492,20 +500,19 @@ const mediaSosial = proxy({
|
||||
total: 0,
|
||||
loading: false,
|
||||
search: "",
|
||||
load: async (page = 1, limit = 10, search = "") => { // Change to arrow function
|
||||
mediaSosial.findMany.loading = true; // Use the full path to access the property
|
||||
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.page = page;
|
||||
mediaSosial.findMany.search = search;
|
||||
try {
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
if (search) query.search = search;
|
||||
|
||||
const res = await ApiFetch.api.landingpage.mediasosial[
|
||||
"findMany"
|
||||
].get({
|
||||
|
||||
const res = await ApiFetch.api.landingpage.mediasosial["findMany"].get({
|
||||
query,
|
||||
});
|
||||
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
mediaSosial.findMany.data = res.data.data || [];
|
||||
mediaSosial.findMany.total = res.data.total || 0;
|
||||
@@ -537,7 +544,7 @@ const mediaSosial = proxy({
|
||||
toast.warn("ID tidak valid");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
mediaSosial.update.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/landingpage/mediasosial/${id}`);
|
||||
@@ -586,66 +593,72 @@ const mediaSosial = proxy({
|
||||
},
|
||||
},
|
||||
update: {
|
||||
id: "",
|
||||
form: {} as MediaSosialForm,
|
||||
loading: false,
|
||||
|
||||
async load(id: string) {
|
||||
if (!id) {
|
||||
toast.warn("ID tidak valid");
|
||||
return null;
|
||||
id: "",
|
||||
form: {} as MediaSosialForm,
|
||||
loading: false,
|
||||
|
||||
async load(id: string) {
|
||||
if (!id) {
|
||||
toast.warn("ID tidak valid");
|
||||
return null;
|
||||
}
|
||||
|
||||
mediaSosial.update.loading = true; // ✅ Tambahkan ini di awal
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/landingpage/mediasosial/${id}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
mediaSosial.update.loading = true; // ✅ Tambahkan ini di awal
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/landingpage/mediasosial/${id}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result?.success) {
|
||||
const data = result.data;
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
name: data.name || "",
|
||||
imageId: data.imageId || "",
|
||||
iconUrl: data.iconUrl || "",
|
||||
};
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(result?.message || "Gagal mengambil data media sosial");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error((error as Error).message);
|
||||
toast.error("Terjadi kesalahan saat mengambil data media sosial");
|
||||
} finally {
|
||||
mediaSosial.update.loading = false; // ✅ Supaya berhenti loading walau error
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result?.success) {
|
||||
const data = result.data;
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
name: data.name || "",
|
||||
imageId: data.imageId || null,
|
||||
iconUrl: data.iconUrl || "",
|
||||
icon: data.icon || null,
|
||||
|
||||
};
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(
|
||||
result?.message || "Gagal mengambil data media sosial"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async update() {
|
||||
const cek = templateMediaSosial.safeParse(mediaSosial.update.form);
|
||||
if (!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
toast.error(err);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
mediaSosial.update.loading = true;
|
||||
|
||||
const response = await fetch(`/api/landingpage/mediasosial/${this.id}`, {
|
||||
} catch (error) {
|
||||
console.error((error as Error).message);
|
||||
toast.error("Terjadi kesalahan saat mengambil data media sosial");
|
||||
} finally {
|
||||
mediaSosial.update.loading = false; // ✅ Supaya berhenti loading walau error
|
||||
}
|
||||
},
|
||||
|
||||
async update() {
|
||||
const cek = templateMediaSosial.safeParse(mediaSosial.update.form);
|
||||
if (!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
toast.error(err);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
mediaSosial.update.loading = true;
|
||||
|
||||
const response = await fetch(
|
||||
`/api/landingpage/mediasosial/${this.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -654,38 +667,40 @@ const mediaSosial = proxy({
|
||||
name: this.form.name,
|
||||
imageId: this.form.imageId,
|
||||
iconUrl: this.form.iconUrl,
|
||||
icon: this.form.icon,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.message || `HTTP error! status: ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Berhasil update media sosial");
|
||||
await mediaSosial.findMany.load(); // refresh list
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.message || "Gagal update media sosial");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating media sosial:", error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Terjadi kesalahan saat update media sosial"
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.message || `HTTP error! status: ${response.status}`
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
mediaSosial.update.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Berhasil update media sosial");
|
||||
await mediaSosial.findMany.load(); // refresh list
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.message || "Gagal update media sosial");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating media sosial:", error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Terjadi kesalahan saat update media sosial"
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
mediaSosial.update.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const profileLandingPageState = proxy({
|
||||
|
||||
@@ -93,6 +93,34 @@ const sdgsDesa = proxy({
|
||||
}
|
||||
},
|
||||
},
|
||||
findManyAll: {
|
||||
data: null as any[] | null,
|
||||
loading: false,
|
||||
load: async () => { // Change to arrow function
|
||||
sdgsDesa.findManyAll.loading = true; // Use the full path to access the property
|
||||
try {
|
||||
const query: any = {};
|
||||
|
||||
const res = await ApiFetch.api.landingpage.sdgsdesa[
|
||||
"findManyAll"
|
||||
].get({
|
||||
query,
|
||||
});
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
sdgsDesa.findManyAll.data = res.data.data || [];
|
||||
} else {
|
||||
console.error("Failed to load media sosial:", res.data?.message);
|
||||
sdgsDesa.findManyAll.data = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading media sosial:", error);
|
||||
sdgsDesa.findManyAll.data = [];
|
||||
} finally {
|
||||
sdgsDesa.findManyAll.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
findUnique: {
|
||||
data: null as Prisma.SdgsDesaGetPayload<{
|
||||
include: {
|
||||
|
||||
@@ -39,7 +39,7 @@ const dataLingkunganDesaState = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
dataLingkunganDesaState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -35,7 +35,7 @@ const pengelolaanSampah = proxy({
|
||||
].post(pengelolaanSampah.create.form);
|
||||
if (res.status === 200) {
|
||||
pengelolaanSampah.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -39,7 +39,7 @@ const programPenghijauanState = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
programPenghijauanState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -9,34 +9,32 @@ import { z } from "zod";
|
||||
|
||||
const templateBeasiswaPendaftar = z.object({
|
||||
namaLengkap: z.string().min(1, "Nama harus diisi"),
|
||||
nik: z.string().min(1, "NIK harus diisi"),
|
||||
nis: z.string().min(1, "NIS harus diisi"),
|
||||
kelas: z.string().min(1, "Kelas harus diisi"),
|
||||
jenisKelamin: z.string().min(1, "Jenis kelamin harus diisi"),
|
||||
alamatDomisili: z.string().min(1, "Alamat domisili harus diisi"),
|
||||
tempatLahir: z.string().min(1, "Tempat lahir harus diisi"),
|
||||
tanggalLahir: z.string().min(1, "Tanggal lahir harus diisi"),
|
||||
jenisKelamin: z.string().min(1, "Jenis kelamin harus diisi"),
|
||||
kewarganegaraan: z.string().min(1, "Kewarganegaraan harus diisi"),
|
||||
agama: z.string().min(1, "Agama harus diisi"),
|
||||
alamatKTP: z.string().min(1, "Alamat KTP harus diisi"),
|
||||
alamatDomisili: z.string().min(1, "Alamat domisili harus diisi"),
|
||||
namaOrtu: z.string().min(1, "Nama ortu harus diisi"),
|
||||
nik: z.string().min(1, "NIK harus diisi"),
|
||||
pekerjaanOrtu: z.string().min(1, "Pekerjaan ortu harus diisi"),
|
||||
penghasilan: z.string().min(1, "Penghasilan ortu harus diisi"),
|
||||
noHp: z.string().min(1, "No HP harus diisi"),
|
||||
email: z.string().min(1, "Email harus diisi"),
|
||||
statusPernikahan: z.string().min(1, "Status pernikahan harus diisi"),
|
||||
ukuranBaju: z.string().min(1, "Ukuran baju harus diisi"),
|
||||
});
|
||||
|
||||
const defaultBeasiswaPendaftar = {
|
||||
namaLengkap: "",
|
||||
nik: "",
|
||||
nis: "",
|
||||
kelas: "",
|
||||
jenisKelamin: "",
|
||||
alamatDomisili: "",
|
||||
tempatLahir: "",
|
||||
tanggalLahir: "",
|
||||
jenisKelamin: "",
|
||||
kewarganegaraan: "",
|
||||
agama: "",
|
||||
alamatKTP: "",
|
||||
alamatDomisili: "",
|
||||
namaOrtu: "",
|
||||
nik: "",
|
||||
pekerjaanOrtu: "",
|
||||
penghasilan: "",
|
||||
noHp: "",
|
||||
email: "",
|
||||
statusPernikahan: "",
|
||||
ukuranBaju: "",
|
||||
};
|
||||
|
||||
const beasiswaPendaftar = proxy({
|
||||
@@ -200,18 +198,17 @@ const beasiswaPendaftar = proxy({
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
namaLengkap: data.namaLengkap,
|
||||
nik: data.nik,
|
||||
nis: data.nis,
|
||||
kelas: data.kelas,
|
||||
jenisKelamin: data.jenisKelamin,
|
||||
alamatDomisili: data.alamatDomisili,
|
||||
tempatLahir: data.tempatLahir,
|
||||
tanggalLahir: data.tanggalLahir,
|
||||
jenisKelamin: data.jenisKelamin,
|
||||
kewarganegaraan: data.kewarganegaraan,
|
||||
agama: data.agama,
|
||||
alamatKTP: data.alamatKTP,
|
||||
alamatDomisili: data.alamatDomisili,
|
||||
namaOrtu: data.namaOrtu,
|
||||
nik: data.nik,
|
||||
pekerjaanOrtu: data.pekerjaanOrtu,
|
||||
penghasilan: data.penghasilan,
|
||||
noHp: data.noHp,
|
||||
email: data.email,
|
||||
statusPernikahan: data.statusPernikahan,
|
||||
ukuranBaju: data.ukuranBaju,
|
||||
};
|
||||
return data; // Return the loaded data
|
||||
} else {
|
||||
@@ -249,17 +246,17 @@ const beasiswaPendaftar = proxy({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
namaLengkap: this.form.namaLengkap,
|
||||
nik: this.form.nik,
|
||||
tanggalLahir: this.form.tanggalLahir,
|
||||
nis: this.form.nis,
|
||||
kelas: this.form.kelas,
|
||||
jenisKelamin: this.form.jenisKelamin,
|
||||
kewarganegaraan: this.form.kewarganegaraan,
|
||||
agama: this.form.agama,
|
||||
alamatKTP: this.form.alamatKTP,
|
||||
alamatDomisili: this.form.alamatDomisili,
|
||||
tempatLahir: this.form.tempatLahir,
|
||||
tanggalLahir: this.form.tanggalLahir,
|
||||
namaOrtu: this.form.namaOrtu,
|
||||
nik: this.form.nik,
|
||||
pekerjaanOrtu: this.form.pekerjaanOrtu,
|
||||
penghasilan: this.form.penghasilan,
|
||||
noHp: this.form.noHp,
|
||||
email: this.form.email,
|
||||
statusPernikahan: this.form.statusPernikahan,
|
||||
ukuranBaju: this.form.ukuranBaju,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import ApiFetch from "@/lib/api-fetch";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { toast } from "react-toastify";
|
||||
@@ -42,7 +43,7 @@ const dataPendidikan = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data?.id;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
dataPendidikan.create.form = {
|
||||
name: "",
|
||||
jumlah: "",
|
||||
@@ -65,13 +66,46 @@ const dataPendidikan = proxy({
|
||||
select: { id: true; name: true; jumlah: true };
|
||||
}>[]
|
||||
| null,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
total: 0,
|
||||
loading: false,
|
||||
async load() {
|
||||
const res = await ApiFetch.api.pendidikan.datapendidikan[
|
||||
"findMany"
|
||||
].get();
|
||||
if (res.status === 200) {
|
||||
dataPendidikan.findMany.data = res.data?.data ?? [];
|
||||
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[
|
||||
"findMany"
|
||||
].get({
|
||||
query,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ const daftarInformasiPublik = proxy({
|
||||
].post(daftarInformasiPublik.create.form);
|
||||
if (res.status === 200) {
|
||||
daftarInformasiPublik.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
return toast.error("failed create");
|
||||
} catch (error) {
|
||||
|
||||
@@ -41,7 +41,7 @@ const grafikBerdasarkanUmur = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data?.id;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
grafikBerdasarkanUmur.create.form = {
|
||||
remaja: "",
|
||||
dewasa: "",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import ApiFetch from "@/lib/api-fetch";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { toast } from "react-toastify";
|
||||
@@ -6,145 +7,207 @@ import { z } from "zod";
|
||||
|
||||
const templateForm = z.object({
|
||||
name: z.string().min(3, "Nama minimal 3 karakter"),
|
||||
nik: z.string().min(3, "NIK minimal 3 karakter"),
|
||||
notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"),
|
||||
nik: z
|
||||
.string()
|
||||
.min(3, "NIK minimal 3 karakter")
|
||||
.max(16, "NIK maksimal 16 angka"),
|
||||
notelp: z
|
||||
.string()
|
||||
.min(3, "Nomor Telepon minimal 3 karakter")
|
||||
.max(15, "Nomor Telepon maksimal 15 angka"),
|
||||
alamat: z.string().min(3, "Alamat minimal 3 karakter"),
|
||||
email: z.string().min(3, "Email minimal 3 karakter"),
|
||||
jenisInformasiDimintaId: z.string().nonempty(),
|
||||
caraMemperolehInformasiId: z.string().nonempty(),
|
||||
caraMemperolehSalinanInformasiId: z.string().nonempty(),
|
||||
})
|
||||
});
|
||||
|
||||
const jenisInformasiDiminta = proxy({
|
||||
findMany: {
|
||||
data: null as
|
||||
| null
|
||||
| Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[],
|
||||
async load(){
|
||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi["find-many"].get();
|
||||
if (res.status === 200) {
|
||||
jenisInformasiDiminta.findMany.data = res.data?.data ?? [];
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
findMany: {
|
||||
data: null as
|
||||
| null
|
||||
| Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[],
|
||||
async load() {
|
||||
const res =
|
||||
await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi[
|
||||
"find-many"
|
||||
].get();
|
||||
if (res.status === 200) {
|
||||
jenisInformasiDiminta.findMany.data = res.data?.data ?? [];
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const caraMemperolehInformasi = proxy({
|
||||
findMany: {
|
||||
data: null as
|
||||
| null
|
||||
| Prisma.CaraMemperolehInformasiGetPayload<{ omit: { isActive: true } }>[],
|
||||
async load() {
|
||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi["find-many"].get();
|
||||
if (res.status === 200) {
|
||||
caraMemperolehInformasi.findMany.data = res.data?.data ?? [];
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
findMany: {
|
||||
data: null as
|
||||
| null
|
||||
| Prisma.CaraMemperolehInformasiGetPayload<{
|
||||
omit: { isActive: true };
|
||||
}>[],
|
||||
async load() {
|
||||
const res =
|
||||
await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi[
|
||||
"find-many"
|
||||
].get();
|
||||
if (res.status === 200) {
|
||||
caraMemperolehInformasi.findMany.data = res.data?.data ?? [];
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const caraMemperolehSalinanInformasi = proxy({
|
||||
findMany: {
|
||||
data: null as
|
||||
| null
|
||||
| Prisma.CaraMemperolehSalinanInformasiGetPayload<{ omit: { isActive: true } }>[],
|
||||
async load() {
|
||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi["find-many"].get();
|
||||
if (res.status === 200) {
|
||||
caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? [];
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log(caraMemperolehSalinanInformasi)
|
||||
findMany: {
|
||||
data: null as
|
||||
| null
|
||||
| Prisma.CaraMemperolehSalinanInformasiGetPayload<{
|
||||
omit: { isActive: true };
|
||||
}>[],
|
||||
async load() {
|
||||
const res =
|
||||
await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi[
|
||||
"find-many"
|
||||
].get();
|
||||
if (res.status === 200) {
|
||||
caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? [];
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(caraMemperolehSalinanInformasi);
|
||||
|
||||
type PermohonanInformasiPublikForm = Prisma.PermohonanInformasiPublikGetPayload<{
|
||||
type PermohonanInformasiPublikForm =
|
||||
Prisma.PermohonanInformasiPublikGetPayload<{
|
||||
select: {
|
||||
name: true;
|
||||
nik: true;
|
||||
notelp: true;
|
||||
alamat: true;
|
||||
email: true;
|
||||
jenisInformasiDimintaId: true;
|
||||
caraMemperolehInformasiId: true;
|
||||
caraMemperolehSalinanInformasiId: true;
|
||||
name: true;
|
||||
nik: true;
|
||||
notelp: true;
|
||||
alamat: true;
|
||||
email: true;
|
||||
jenisInformasiDimintaId: true;
|
||||
caraMemperolehInformasiId: true;
|
||||
caraMemperolehSalinanInformasiId: true;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
|
||||
const statepermohonanInformasiPublik = proxy({
|
||||
create: {
|
||||
form: {} as PermohonanInformasiPublikForm,
|
||||
loading: false,
|
||||
async create(){
|
||||
const cek = templateForm.safeParse(statepermohonanInformasiPublik.create.form);
|
||||
if(!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
return toast.error(err);
|
||||
}
|
||||
try {
|
||||
statepermohonanInformasiPublik.create.loading = true;
|
||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik["create"].post(statepermohonanInformasiPublik.create.form);
|
||||
if (res.status === 200) {
|
||||
statepermohonanInformasiPublik.findMany.load();
|
||||
return toast.success("success create");
|
||||
}
|
||||
return toast.error("failed create");
|
||||
} catch (error) {
|
||||
console.log((error as Error).message);
|
||||
} finally {
|
||||
statepermohonanInformasiPublik.create.loading = false;
|
||||
}
|
||||
create: {
|
||||
form: {} as PermohonanInformasiPublikForm,
|
||||
loading: false,
|
||||
async create() {
|
||||
const cek = templateForm.safeParse(
|
||||
statepermohonanInformasiPublik.create.form
|
||||
);
|
||||
|
||||
if (!cek.success) {
|
||||
toast.error(cek.error.issues.map((i) => i.message).join("\n"));
|
||||
return false; // ⬅️ tambahkan return false
|
||||
}
|
||||
|
||||
try {
|
||||
statepermohonanInformasiPublik.create.loading = true;
|
||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik[
|
||||
"create"
|
||||
].post(statepermohonanInformasiPublik.create.form);
|
||||
|
||||
if (res.data?.success === false) {
|
||||
toast.error(res.data?.message);
|
||||
return false; // ⬅️ gagal
|
||||
}
|
||||
|
||||
toast.success("Sukses menambahkan");
|
||||
return true; // ⬅️ sukses
|
||||
} catch {
|
||||
toast.error("Terjadi kesalahan server");
|
||||
return false;
|
||||
} finally {
|
||||
statepermohonanInformasiPublik.create.loading = false;
|
||||
}
|
||||
},
|
||||
findMany: {
|
||||
data: null as
|
||||
| Prisma.PermohonanInformasiPublikGetPayload<{ include: {
|
||||
caraMemperolehSalinanInformasi: true,
|
||||
jenisInformasiDiminta: true,
|
||||
caraMemperolehInformasi: true,
|
||||
} }>[]
|
||||
| null,
|
||||
async load() {
|
||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik["find-many"].get();
|
||||
if (res.status === 200) {
|
||||
statepermohonanInformasiPublik.findMany.data = res.data?.data ?? [];
|
||||
}
|
||||
}
|
||||
},
|
||||
findUnique: {
|
||||
data: null as Prisma.PermohonanInformasiPublikGetPayload<{
|
||||
},
|
||||
findMany: {
|
||||
data: null as
|
||||
| Prisma.PermohonanInformasiPublikGetPayload<{
|
||||
include: {
|
||||
jenisInformasiDiminta: true,
|
||||
caraMemperolehInformasi: true,
|
||||
caraMemperolehSalinanInformasi: true,
|
||||
caraMemperolehSalinanInformasi: true;
|
||||
jenisInformasiDiminta: true;
|
||||
caraMemperolehInformasi: true;
|
||||
};
|
||||
}> | null,
|
||||
async load(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
statepermohonanInformasiPublik.findUnique.data = data.data ?? null;
|
||||
} else {
|
||||
console.error("Failed to fetch program inovasi:", res.statusText);
|
||||
statepermohonanInformasiPublik.findUnique.data = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching program inovasi:", error);
|
||||
statepermohonanInformasiPublik.findUnique.data = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
})
|
||||
}>[]
|
||||
| null,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
total: 0,
|
||||
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: {
|
||||
data: null as Prisma.PermohonanInformasiPublikGetPayload<{
|
||||
include: {
|
||||
jenisInformasiDiminta: true;
|
||||
caraMemperolehInformasi: true;
|
||||
caraMemperolehSalinanInformasi: true;
|
||||
};
|
||||
}> | null,
|
||||
async load(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
statepermohonanInformasiPublik.findUnique.data = data.data ?? null;
|
||||
} else {
|
||||
console.error("Failed to fetch program inovasi:", res.statusText);
|
||||
statepermohonanInformasiPublik.findUnique.data = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching program inovasi:", error);
|
||||
statepermohonanInformasiPublik.findUnique.data = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const statepermohonanInformasiPublikForm = proxy({
|
||||
statepermohonanInformasiPublik,
|
||||
jenisInformasiDiminta,
|
||||
caraMemperolehInformasi,
|
||||
caraMemperolehSalinanInformasi,
|
||||
})
|
||||
statepermohonanInformasiPublik,
|
||||
jenisInformasiDiminta,
|
||||
caraMemperolehInformasi,
|
||||
caraMemperolehSalinanInformasi,
|
||||
});
|
||||
|
||||
export default statepermohonanInformasiPublikForm;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import ApiFetch from "@/lib/api-fetch";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { toast } from "react-toastify";
|
||||
@@ -5,82 +6,130 @@ import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
|
||||
const templateForm = z.object({
|
||||
name: z.string().min(3, "Nama minimal 3 karakter"),
|
||||
email: z.string().min(3, "Email minimal 3 karakter"),
|
||||
notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"),
|
||||
alasan: z.string().min(3, "Alasan minimal 3 karakter"),
|
||||
})
|
||||
name: z.string().min(3, "Nama minimal 3 karakter"),
|
||||
email: z.string().min(3, "Email minimal 3 karakter"),
|
||||
notelp: z
|
||||
.string()
|
||||
.min(3, "Nomor Telepon minimal 3 karakter")
|
||||
.max(15, "Nomor Telepon maksimal 15 angka"),
|
||||
alasan: z.string().min(3, "Alasan minimal 3 karakter"),
|
||||
});
|
||||
|
||||
type PermohonanKeberatanInformasiForm = Prisma.FormulirPermohonanKeberatanGetPayload<{
|
||||
type PermohonanKeberatanInformasiForm =
|
||||
Prisma.FormulirPermohonanKeberatanGetPayload<{
|
||||
select: {
|
||||
name: true;
|
||||
email: true;
|
||||
notelp: true;
|
||||
alasan: true;
|
||||
name: true;
|
||||
email: true;
|
||||
notelp: true;
|
||||
alasan: true;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
|
||||
const permohonanKeberatanInformasi = proxy({
|
||||
create: {
|
||||
form: {} as PermohonanKeberatanInformasiForm,
|
||||
loading: false,
|
||||
async create(){
|
||||
const cek = templateForm.safeParse(permohonanKeberatanInformasi.create.form);
|
||||
if(!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
return toast.error(err);
|
||||
}
|
||||
try {
|
||||
permohonanKeberatanInformasi.create.loading = true;
|
||||
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["create"].post(permohonanKeberatanInformasi.create.form);
|
||||
if (res.status === 200) {
|
||||
permohonanKeberatanInformasi.findMany.load();
|
||||
return toast.success("success create");
|
||||
}
|
||||
return toast.error("failed create");
|
||||
} catch (error) {
|
||||
console.log((error as Error).message);
|
||||
} finally {
|
||||
permohonanKeberatanInformasi.create.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
findMany: {
|
||||
data: null as
|
||||
| Prisma.FormulirPermohonanKeberatanGetPayload<{omit: {isActive: true}}>[]
|
||||
| null,
|
||||
async load() {
|
||||
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["find-many"].get();
|
||||
if (res.status === 200) {
|
||||
permohonanKeberatanInformasi.findMany.data = res.data?.data ?? [];
|
||||
}
|
||||
}
|
||||
},
|
||||
findUnique: {
|
||||
data: null as Prisma.FormulirPermohonanKeberatanGetPayload<{
|
||||
omit: {
|
||||
isActive: true;
|
||||
};
|
||||
}> | null,
|
||||
async load(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/ppid/permohonankeberataninformasipublik/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
permohonanKeberatanInformasi.findUnique.data = data.data ?? null;
|
||||
} else {
|
||||
console.error("Failed to fetch permohonan keberatan informasi:", res.statusText);
|
||||
permohonanKeberatanInformasi.findUnique.data = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching permohonan keberatan informasi:", error);
|
||||
permohonanKeberatanInformasi.findUnique.data = null;
|
||||
}
|
||||
},
|
||||
create: {
|
||||
form: {} as PermohonanKeberatanInformasiForm,
|
||||
loading: false,
|
||||
async create() {
|
||||
const cek = templateForm.safeParse(
|
||||
permohonanKeberatanInformasi.create.form
|
||||
);
|
||||
if (!cek.success) {
|
||||
toast.error(cek.error.issues.map((i) => i.message).join("\n"));
|
||||
return false; // ⬅️ tambahkan return false
|
||||
}
|
||||
try {
|
||||
permohonanKeberatanInformasi.create.loading = true;
|
||||
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[
|
||||
"create"
|
||||
].post(permohonanKeberatanInformasi.create.form);
|
||||
if (res.data?.success === false) {
|
||||
toast.error(res.data?.message);
|
||||
return false; // ⬅️ gagal
|
||||
}
|
||||
|
||||
toast.success("Sukses menambahkan");
|
||||
return true; // ⬅️ sukses
|
||||
} catch {
|
||||
toast.error("Terjadi kesalahan server");
|
||||
return false;
|
||||
} finally {
|
||||
permohonanKeberatanInformasi.create.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
findMany: {
|
||||
data: null as
|
||||
| null
|
||||
| Prisma.FormulirPermohonanKeberatanGetPayload<{
|
||||
omit: { isActive: true };
|
||||
}>[],
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
total: 0,
|
||||
loading: false,
|
||||
search: "",
|
||||
load: async (page = 1, limit = 10, search = "") => {
|
||||
// Change to arrow function
|
||||
permohonanKeberatanInformasi.findMany.loading = true; // Use the full path to access the property
|
||||
permohonanKeberatanInformasi.findMany.page = page;
|
||||
permohonanKeberatanInformasi.findMany.search = search;
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
if (search) query.search = search;
|
||||
|
||||
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[
|
||||
"find-many"
|
||||
].get({
|
||||
query,
|
||||
});
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
permohonanKeberatanInformasi.findMany.data = res.data.data || [];
|
||||
permohonanKeberatanInformasi.findMany.total = res.data.total || 0;
|
||||
permohonanKeberatanInformasi.findMany.totalPages = res.data.totalPages || 1;
|
||||
} else {
|
||||
console.error("Failed to load permohonan keberatan informasi:", res.data?.message);
|
||||
permohonanKeberatanInformasi.findMany.data = [];
|
||||
permohonanKeberatanInformasi.findMany.total = 0;
|
||||
permohonanKeberatanInformasi.findMany.totalPages = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading permohonan keberatan informasi:", error);
|
||||
permohonanKeberatanInformasi.findMany.data = [];
|
||||
permohonanKeberatanInformasi.findMany.total = 0;
|
||||
permohonanKeberatanInformasi.findMany.totalPages = 1;
|
||||
} finally {
|
||||
permohonanKeberatanInformasi.findMany.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
findUnique: {
|
||||
data: null as Prisma.FormulirPermohonanKeberatanGetPayload<{
|
||||
omit: {
|
||||
isActive: true;
|
||||
};
|
||||
}> | null,
|
||||
async load(id: string) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/ppid/permohonankeberataninformasipublik/${id}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
permohonanKeberatanInformasi.findUnique.data = data.data ?? null;
|
||||
} else {
|
||||
console.error(
|
||||
"Failed to fetch permohonan keberatan informasi:",
|
||||
res.statusText
|
||||
);
|
||||
permohonanKeberatanInformasi.findUnique.data = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching permohonan keberatan informasi:", error);
|
||||
permohonanKeberatanInformasi.findUnique.data = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default permohonanKeberatanInformasi;
|
||||
|
||||
|
||||
@@ -3,9 +3,6 @@ import { toast } from "react-toastify";
|
||||
import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Schema validasi form ProfilePPID menggunakan Zod.
|
||||
*/
|
||||
const templateForm = z.object({
|
||||
name: z.string().min(3, "Nama minimal 3 karakter"),
|
||||
biodata: z.string().min(3, "Biodata minimal 3 karakter"),
|
||||
@@ -33,25 +30,16 @@ type ProfilePPIDForm = Prisma.ProfilePPIDGetPayload<{
|
||||
pengalaman: true;
|
||||
unggulan: true;
|
||||
imageId: true;
|
||||
image?: {
|
||||
select: {
|
||||
link: true;
|
||||
};
|
||||
};
|
||||
image?: { select: { link: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Improved State Management - Consolidated and more robust
|
||||
*/
|
||||
const stateProfilePPID = proxy({
|
||||
// Consolidated data management
|
||||
profile: {
|
||||
data: null as ProfilePPIDForm | null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
|
||||
// Single method to load profile data
|
||||
async load(id: string) {
|
||||
if (!id) {
|
||||
toast.warn("ID tidak valid");
|
||||
@@ -62,52 +50,42 @@ const stateProfilePPID = proxy({
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/ppid/profileppid/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const res = await fetch(`/api/ppid/profileppid/${id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
this.data = result.data;
|
||||
return result.data;
|
||||
} else {
|
||||
throw new Error(result.message || "Gagal mengambil data profile");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message;
|
||||
this.error = errorMessage;
|
||||
console.error("Load profile error:", errorMessage);
|
||||
toast.error("Terjadi kesalahan saat mengambil data profile");
|
||||
} else throw new Error(result.message || "Gagal memuat data profile");
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
this.error = msg;
|
||||
console.error("Load profile error:", msg);
|
||||
toast.error("Gagal memuat data profile");
|
||||
return null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Reset profile data
|
||||
reset() {
|
||||
this.data = null;
|
||||
this.error = null;
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Edit form management
|
||||
editForm: {
|
||||
id: "",
|
||||
form: { ...defaultForm },
|
||||
originalForm: { ...defaultForm }, // ✅ Tambah field originalForm
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
isReadOnly: false, // Flag untuk data yang tidak bisa diedit
|
||||
|
||||
// Initialize form with profile data
|
||||
initialize(profileData: ProfilePPIDForm) {
|
||||
this.id = profileData.id;
|
||||
this.isReadOnly = false; // Semua data bisa diedit
|
||||
this.form = {
|
||||
const data = {
|
||||
name: profileData.name || "",
|
||||
biodata: profileData.biodata || "",
|
||||
riwayat: profileData.riwayat || "",
|
||||
@@ -115,23 +93,20 @@ const stateProfilePPID = proxy({
|
||||
unggulan: profileData.unggulan || "",
|
||||
imageId: profileData.imageId || "",
|
||||
};
|
||||
this.form = { ...data };
|
||||
this.originalForm = { ...data }; // ✅ Simpan versi original
|
||||
},
|
||||
|
||||
// Update form field
|
||||
updateField(field: keyof typeof defaultForm, value: string) {
|
||||
this.form[field] = value;
|
||||
},
|
||||
|
||||
// Submit form
|
||||
async submit() {
|
||||
// Validate form
|
||||
const validation = templateForm.safeParse(this.form);
|
||||
|
||||
if (!validation.success) {
|
||||
const errors = validation.error.issues
|
||||
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
||||
.join(", ");
|
||||
toast.error(`Form tidak valid: ${errors}`);
|
||||
const check = templateForm.safeParse(this.form);
|
||||
if (!check.success) {
|
||||
toast.error(
|
||||
check.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -139,63 +114,54 @@ const stateProfilePPID = proxy({
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/ppid/profileppid/${this.id}`, {
|
||||
const res = await fetch(`/api/ppid/profileppid/${this.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(this.form),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Berhasil update profile");
|
||||
// Refresh profile data
|
||||
await stateProfilePPID.profile.load(this.id);
|
||||
this.originalForm = { ...this.form }; // ✅ Update original setelah sukses
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.message || "Gagal update profile");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message;
|
||||
this.error = errorMessage;
|
||||
console.error("Update profile error:", errorMessage);
|
||||
toast.error("Terjadi kesalahan saat update profile");
|
||||
} else throw new Error(result.message || "Gagal update profile");
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
this.error = msg;
|
||||
toast.error(msg);
|
||||
return false;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Reset form
|
||||
// ✅ Tambahan reset ke original data
|
||||
resetToOriginal() {
|
||||
this.form = { ...this.originalForm };
|
||||
toast.info("Data dikembalikan ke kondisi awal");
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.id = "";
|
||||
this.form = { ...defaultForm };
|
||||
this.originalForm = { ...defaultForm };
|
||||
this.error = null;
|
||||
this.loading = false;
|
||||
this.isReadOnly = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Helper methods
|
||||
async loadForEdit(id: string) {
|
||||
const profileData = await this.profile.load(id);
|
||||
if (profileData) {
|
||||
this.editForm.initialize(profileData);
|
||||
}
|
||||
return profileData;
|
||||
const data = await this.profile.load(id);
|
||||
if (data) this.editForm.initialize(data);
|
||||
return data;
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.profile.reset();
|
||||
this.editForm.reset();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default stateProfilePPID;
|
||||
export default stateProfilePPID;
|
||||
|
||||
@@ -90,42 +90,96 @@ const userState = proxy({
|
||||
}
|
||||
},
|
||||
},
|
||||
updateActive: {
|
||||
deleteUser: {
|
||||
loading: false,
|
||||
async submit(id: string, isActive: boolean) {
|
||||
this.loading = true;
|
||||
|
||||
async delete(id: string) {
|
||||
if (!id) return toast.warn("ID tidak valid");
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/user/updt`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, isActive }),
|
||||
userState.deleteUser.loading = true;
|
||||
|
||||
const response = await fetch(`/api/user/delUser/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.status === 200 && data.success) {
|
||||
toast.success(data.message);
|
||||
userState.findMany.load(userState.findMany.page, 10, userState.findMany.search);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result?.success) {
|
||||
toast.success(result.message || "User berhasil dihapus permanen");
|
||||
await userState.findMany.load(); // refresh list user setelah delete
|
||||
} else {
|
||||
toast.error(data.message || "Gagal update status user");
|
||||
toast.error(result?.message || "Gagal menghapus user");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.error("Gagal update status user");
|
||||
} catch (error) {
|
||||
console.error("Gagal delete user:", error);
|
||||
toast.error("Terjadi kesalahan saat menghapus user");
|
||||
} finally {
|
||||
this.loading = false;
|
||||
userState.deleteUser.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
// Di file userState.ts atau dimana state user berada
|
||||
|
||||
update: {
|
||||
loading: false,
|
||||
|
||||
async submit(payload: { id: string; isActive?: boolean; roleId?: string }) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/user/updt`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.status === 200 && data.success) {
|
||||
// ✅ Tampilkan pesan yang berbeda jika role berubah
|
||||
if (data.roleChanged) {
|
||||
toast.success(
|
||||
`${data.message}\n\nUser akan logout otomatis dalam beberapa detik.`,
|
||||
{
|
||||
autoClose: 5000,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.success(data.message);
|
||||
}
|
||||
|
||||
// Refresh list
|
||||
await userState.findMany.load(
|
||||
userState.findMany.page,
|
||||
10,
|
||||
userState.findMany.search
|
||||
);
|
||||
|
||||
return true; // ✅ Return success untuk handling di component
|
||||
} else {
|
||||
toast.error(data.message || "Gagal update user");
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("❌ Error update user:", e);
|
||||
toast.error("Gagal update user");
|
||||
return false;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const templateRole = z.object({
|
||||
name: z.string().min(1, "Nama harus diisi"),
|
||||
permissions: z.array(z.string()).min(1, "Permission harus diisi"),
|
||||
});
|
||||
|
||||
const defaultRole = {
|
||||
name: "",
|
||||
permissions: [] as string[],
|
||||
};
|
||||
|
||||
const roleState = proxy({
|
||||
@@ -166,11 +220,34 @@ const roleState = proxy({
|
||||
isActive: true;
|
||||
};
|
||||
}>[],
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
loading: false,
|
||||
async load() {
|
||||
const res = await ApiFetch.api.role["findMany"].get();
|
||||
if (res.status === 200) {
|
||||
roleState.findMany.data = res.data?.data ?? [];
|
||||
search: "",
|
||||
load: async (page = 1, limit = 10, search = "") => {
|
||||
roleState.findMany.loading = true; // ✅ Akses langsung via nama path
|
||||
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;
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -237,7 +314,7 @@ const roleState = proxy({
|
||||
toast.warn("ID tidak valid");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/role/${id}`, {
|
||||
method: "GET",
|
||||
@@ -245,31 +322,25 @@ const roleState = proxy({
|
||||
"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 = {
|
||||
|
||||
// langsung set melalui root state, bukan this
|
||||
roleState.update.id = data.id;
|
||||
roleState.update.form = {
|
||||
name: data.name,
|
||||
permissions: data.permissions,
|
||||
};
|
||||
return data; // Return the loaded data
|
||||
} else {
|
||||
throw new Error(result?.message || "Gagal memuat data");
|
||||
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading role:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Gagal memuat data"
|
||||
);
|
||||
return null;
|
||||
toast.error("Gagal memuat data");
|
||||
}
|
||||
},
|
||||
},
|
||||
async update() {
|
||||
const cek = templateRole.safeParse(roleState.update.form);
|
||||
if (!cek.success) {
|
||||
@@ -290,7 +361,6 @@ const roleState = proxy({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: this.form.name,
|
||||
permissions: this.form.permissions,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,104 +1,103 @@
|
||||
'use client'
|
||||
import { apiFetchLogin } from '@/app/admin/auth/_lib/api_fetch_auth';
|
||||
'use client';
|
||||
import { apiFetchLogin } from '@/app/api/auth/_lib/api_fetch_auth';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Button, Center, Flex, Image, Paper, Stack, Text, Title } from '@mantine/core';
|
||||
import Link from 'next/link';
|
||||
import { Box, Button, Center, Image, Paper, Stack, Title } from '@mantine/core';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { PhoneInput } from "react-international-phone";
|
||||
import "react-international-phone/style.css";
|
||||
import { PhoneInput } from 'react-international-phone';
|
||||
import 'react-international-phone/style.css';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
|
||||
|
||||
function Login() {
|
||||
const router = useRouter()
|
||||
const [phone, setPhone] = useState("")
|
||||
const [isError, setError] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Login.tsx
|
||||
async function onLogin() {
|
||||
const nomor = phone.substring(1);
|
||||
if (nomor.length <= 4) return setError(true)
|
||||
|
||||
const cleanPhone = phone.replace(/\D/g, '');
|
||||
console.log(cleanPhone);
|
||||
if (cleanPhone.length < 10) {
|
||||
toast.error('Nomor telepon tidak valid');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiFetchLogin({ nomor: nomor })
|
||||
if (response && response.success) {
|
||||
localStorage.setItem("hipmi_auth_code_id", response.kodeId);
|
||||
toast.success(response.message);
|
||||
router.push("/validasi", { scroll: false });
|
||||
const response = await apiFetchLogin({ nomor: cleanPhone });
|
||||
|
||||
console.log(response);
|
||||
|
||||
if (!response.success) {
|
||||
toast.error(response.message || 'Gagal memproses login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Simpan nomor untuk register
|
||||
localStorage.setItem('auth_nomor', cleanPhone);
|
||||
if (response.isRegistered) {
|
||||
// ✅ User lama: simpan kodeId
|
||||
localStorage.setItem('auth_kodeId', response.kodeId);
|
||||
|
||||
// ✅ Cookie sudah di-set oleh API, langsung redirect
|
||||
router.push('/validasi'); // Clean URL
|
||||
} else {
|
||||
setLoading(false);
|
||||
toast.error(response?.message);
|
||||
// ❌ User baru: langsung ke registrasi (tanpa kodeId)
|
||||
router.push('/registrasi');
|
||||
}
|
||||
} catch (error) {
|
||||
setLoading(false)
|
||||
console.log("Error Login", error)
|
||||
toast.error("Terjadi kesalahan saat login")
|
||||
console.error('Error Login:', error);
|
||||
toast.error('Terjadi kesalahan saat login');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack pos={"relative"} bg={colors.Bg}>
|
||||
<Stack pos="relative" bg={colors.Bg}>
|
||||
<Box px={{ base: 'md', md: 100 }} pb={50}>
|
||||
<Stack align='center' justify='center' h={"100vh"}>
|
||||
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
|
||||
<Stack align='center' gap={"lg"}>
|
||||
<Stack align="center" justify="center" h="100vh">
|
||||
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
|
||||
<Stack align="center" gap="lg">
|
||||
<Box>
|
||||
<Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}>
|
||||
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
|
||||
Login
|
||||
</Title>
|
||||
<Center>
|
||||
<Image loading="lazy" src={"/darmasaba-icon.png"} alt="" w={80} />
|
||||
<Image
|
||||
loading="lazy"
|
||||
src="/darmasaba-icon.png"
|
||||
alt="Logo"
|
||||
w={80}
|
||||
h={80}
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
<Box>
|
||||
{/* <Box mb={10}>
|
||||
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masuk Untuk Akses Admin</Text>
|
||||
<TextInput
|
||||
label='Username'
|
||||
placeholder='Username'
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Box> */}
|
||||
<Box w="100%">
|
||||
<PhoneInput
|
||||
countrySelectorStyleProps={{
|
||||
buttonStyle: {
|
||||
backgroundColor: colors['blue-button'],
|
||||
},
|
||||
}}
|
||||
inputStyle={{ width: "100%"}}
|
||||
inputStyle={{ width: '100%' }}
|
||||
defaultCountry="id"
|
||||
onChange={(val) => {
|
||||
setPhone(val);
|
||||
}}
|
||||
value={phone}
|
||||
onChange={(val) => setPhone(val)}
|
||||
/>
|
||||
|
||||
{isError ? (
|
||||
toast.error("Masukan nomor telepon anda")
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<Box py={20} >
|
||||
<Box py={20}>
|
||||
<Button
|
||||
fullWidth
|
||||
bg={colors['blue-button']}
|
||||
radius={'xl'}
|
||||
radius="xl"
|
||||
onClick={onLogin}
|
||||
loading={loading ? true : false}
|
||||
>Masuk
|
||||
loading={loading}
|
||||
>
|
||||
Masuk
|
||||
</Button>
|
||||
</Box>
|
||||
<Flex justify={'center'} align={'center'}>
|
||||
<Text>Belum punya akun? </Text>
|
||||
<Button variant='transparent' component={Link} href={'/registrasi'}>
|
||||
<Text c={colors['blue-button']} fw={'bold'}>Registrasi</Text>
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -108,4 +107,4 @@ function Login() {
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
export default Login;
|
||||
@@ -1,113 +1,153 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-expressions */
|
||||
'use client'
|
||||
import { apiFetchRegister } from '@/app/admin/auth/_lib/api_fetch_auth';
|
||||
// app/registrasi/page.tsx
|
||||
'use client';
|
||||
|
||||
import { apiFetchRegister } from '@/app/api/auth/_lib/api_fetch_auth';
|
||||
import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||
import {
|
||||
Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title,
|
||||
} from '@mantine/core';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { PhoneInput } from "react-international-phone";
|
||||
import "react-international-phone/style.css";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PhoneInput } from 'react-international-phone';
|
||||
import 'react-international-phone/style.css';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
function Registrasi() {
|
||||
const [phone, setPhone] = useState("")
|
||||
const router = useRouter()
|
||||
const [value, setValue] = useState("")
|
||||
const [isValue, setIsValue] = useState(false);
|
||||
export default function Registrasi() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [phone, setPhone] = useState(''); // ✅ tambahkan state untuk phone
|
||||
const [agree, setAgree] = useState(false)
|
||||
|
||||
async function onRegistarsi() {
|
||||
if (value.length < 5) {
|
||||
toast.error("Username minimal 5 karakter!");
|
||||
// Ambil data dari localStorage (dari login)
|
||||
useEffect(() => {
|
||||
const storedNomor = localStorage.getItem('auth_nomor');
|
||||
if (!storedNomor) {
|
||||
toast.error('Akses tidak valid');
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.includes(" ")) {
|
||||
toast.error("Username tidak boleh ada spasi!");
|
||||
setPhone(storedNomor);
|
||||
}, [router]);
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!username || username.trim().length < 5) {
|
||||
toast.error('Username minimal 5 karakter!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!phone) {
|
||||
toast.error("Nomor telepon wajib diisi!");
|
||||
if (username.includes(' ')) {
|
||||
toast.error('Username tidak boleh ada spasi!');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const cleanPhone = phone.replace(/\D/g, '');
|
||||
if (cleanPhone.length < 10) {
|
||||
toast.error('Nomor tidak valid!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agree) {
|
||||
toast.error("Anda harus menyetujui syarat dan ketentuan!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const respone = await apiFetchRegister({ nomor: phone, username: value });
|
||||
// ✅ Hanya kirim username & nomor → dapat kodeId
|
||||
const response = await apiFetchRegister({ username, nomor: cleanPhone });
|
||||
|
||||
if (respone.success) {
|
||||
router.push("/login", { scroll: false });
|
||||
toast.success(respone.message);
|
||||
if (response.success) {
|
||||
// Simpan sementara
|
||||
localStorage.setItem('auth_kodeId', response.kodeId);
|
||||
localStorage.setItem('auth_username', username); // simpan username
|
||||
|
||||
} else {
|
||||
setLoading(false);
|
||||
toast.error(respone.message);
|
||||
toast.success('Kode verifikasi dikirim!');
|
||||
router.push('/validasi'); // ✅ ke halaman validasi
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error Registrasi:', error);
|
||||
toast.error('Gagal mengirim OTP');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
console.log("Error Registrasi", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack pos={"relative"} bg={colors.Bg} gap={"22"} py={"xl"} h={"100vh"}>
|
||||
<Stack pos="relative" bg={colors.Bg} gap="22" py="xl" h="100vh">
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
<BackButton />
|
||||
</Box>
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
<Stack justify='center' align='center' h={"80vh"}>
|
||||
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
|
||||
<Stack align='center'>
|
||||
<Title order={2} fw={'bold'} c={colors['blue-button']}>
|
||||
<Stack justify="center" align="center" h="80vh">
|
||||
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
|
||||
<Stack align="center">
|
||||
<Title order={2} fw="bold" c={colors['blue-button']}>
|
||||
Registrasi
|
||||
</Title>
|
||||
<Center>
|
||||
<Image loading="lazy" src={"/darmasaba-icon.png"} alt="" w={80} />
|
||||
<Image loading="lazy" src="/darmasaba-icon.png" alt="" w={80} />
|
||||
</Center>
|
||||
<Box>
|
||||
<TextInput placeholder='Username'
|
||||
label='Username'
|
||||
maxLength={50}
|
||||
|
||||
<Box w="100%">
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.currentTarget.value)}
|
||||
error={
|
||||
value.length > 0 && value.length < 5
|
||||
? "Minimal 5 karakter !"
|
||||
: value.includes(" ")
|
||||
? "Tidak boleh ada spasi"
|
||||
: isValue
|
||||
? "Masukan username anda"
|
||||
: ""
|
||||
username.length > 0 && username.length < 5
|
||||
? 'Minimal 5 karakter!'
|
||||
: username.includes(' ')
|
||||
? 'Tidak boleh ada spasi'
|
||||
: ''
|
||||
}
|
||||
onChange={(val) => {
|
||||
val.currentTarget.value.length > 0 ? setIsValue(false) : "";
|
||||
setValue(val.currentTarget.value);
|
||||
}}
|
||||
required
|
||||
|
||||
/>
|
||||
<Box py={10}>
|
||||
<Text fz={"sm"} >Nomor Telepon</Text>
|
||||
|
||||
<Box pt="md">
|
||||
<Text fz="sm">Nomor Telepon</Text>
|
||||
<PhoneInput
|
||||
countrySelectorStyleProps={{
|
||||
buttonStyle: {
|
||||
backgroundColor: colors['blue-button'],
|
||||
},
|
||||
}}
|
||||
inputStyle={{ width: "100%" }}
|
||||
defaultCountry="id"
|
||||
onChange={(val) => {
|
||||
setPhone(val);
|
||||
}}
|
||||
value={phone}
|
||||
disabled
|
||||
/>
|
||||
</Box>
|
||||
<Box pb={10}>
|
||||
|
||||
<Box pt="md">
|
||||
<Checkbox
|
||||
label="Saya menyetujui syarat dan ketentuan yang berlaku"
|
||||
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 pb={20} >
|
||||
<Button fullWidth bg={colors['blue-button']} radius={'xl'} onClick={onRegistarsi} loading={loading ? true : false}>Daftar</Button>
|
||||
|
||||
|
||||
<Box pt="xl">
|
||||
<Button
|
||||
fullWidth
|
||||
bg={colors['blue-button']}
|
||||
radius="xl"
|
||||
onClick={handleRegister}
|
||||
loading={loading}
|
||||
disabled={username.length < 5}
|
||||
>
|
||||
Kirim Kode Verifikasi
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
@@ -116,6 +156,4 @@ function Registrasi() {
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default Registrasi;
|
||||
}
|
||||
@@ -1,31 +1,306 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Button, Paper, PinInput, Stack, Text, Title } from '@mantine/core';
|
||||
import { useRouter } from 'next/navigation';
|
||||
'use client';
|
||||
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Loader,
|
||||
Paper,
|
||||
PinInput,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { authStore } from '@/store/authStore';
|
||||
|
||||
export default function Validasi() {
|
||||
const router = useRouter();
|
||||
|
||||
const [nomor, setNomor] = useState<string | null>(null);
|
||||
const [otp, setOtp] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [kodeId, setKodeId] = useState<string | null>(null);
|
||||
const [isRegistrationFlow, setIsRegistrationFlow] = useState(false);
|
||||
|
||||
// ✅ Deteksi flow dari cookie via API
|
||||
useEffect(() => {
|
||||
const checkFlow = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/get-flow', {
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
setIsRegistrationFlow(data.flow === 'register');
|
||||
console.log('🔍 Flow detected from cookie:', data.flow);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error getting flow:', error);
|
||||
setIsRegistrationFlow(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkFlow();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const storedKodeId = localStorage.getItem('auth_kodeId');
|
||||
if (!storedKodeId) {
|
||||
toast.error('Akses tidak valid');
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
setKodeId(storedKodeId);
|
||||
const loadOtpData = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/auth/otp-data?kodeId=${encodeURIComponent(storedKodeId)}`);
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.data?.nomor) {
|
||||
setNomor(result.data.nomor);
|
||||
} else {
|
||||
throw new Error('Data OTP tidak valid');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Gagal memuat data OTP:', error);
|
||||
toast.error('Kode verifikasi tidak valid');
|
||||
router.replace('/login');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadOtpData();
|
||||
}, [router]);
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!kodeId || !nomor || otp.length < 4) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isRegistrationFlow) {
|
||||
await handleRegistrationVerification();
|
||||
} else {
|
||||
await handleLoginVerification();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saat verifikasi:', error);
|
||||
toast.error('Terjadi kesalahan sistem');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegistrationVerification = async () => {
|
||||
const username = localStorage.getItem('auth_username');
|
||||
if (!username) {
|
||||
toast.error('Data registrasi tidak ditemukan.');
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanNomor = nomor?.replace(/\D/g, '') ?? '';
|
||||
if (cleanNomor.length < 10 || username.trim().length < 5) {
|
||||
toast.error('Data tidak valid');
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ Verify OTP
|
||||
const verifyRes = await fetch('/api/auth/verify-otp-register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nomor: cleanNomor, otp, kodeId }),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const verifyData = await verifyRes.json();
|
||||
if (!verifyRes.ok) {
|
||||
toast.error(verifyData.message || 'Verifikasi OTP gagal');
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ Finalize registration
|
||||
const finalizeRes = await fetch('/api/auth/finalize-registration', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nomor: cleanNomor, username, kodeId }),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const data = await finalizeRes.json();
|
||||
|
||||
// ✅ Check JSON response (bukan redirect)
|
||||
if (data.success) {
|
||||
toast.success('Registrasi berhasil! Menunggu persetujuan admin.');
|
||||
await cleanupStorage();
|
||||
|
||||
// ✅ Client-side redirect
|
||||
setTimeout(() => {
|
||||
window.location.href = '/waiting-room';
|
||||
}, 1000);
|
||||
} else {
|
||||
toast.error(data.message || 'Registrasi gagal');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginVerification = async () => {
|
||||
const loginRes = await fetch('/api/auth/verify-otp-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nomor, otp, kodeId }),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const loginData = await loginRes.json();
|
||||
|
||||
if (!loginRes.ok) {
|
||||
toast.error(loginData.message || 'Verifikasi gagal');
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, name, roleId, isActive } = loginData.user;
|
||||
|
||||
authStore.setUser({
|
||||
id,
|
||||
name: name || 'User',
|
||||
roleId: Number(roleId),
|
||||
});
|
||||
|
||||
// ✅ Cleanup setelah login sukses
|
||||
await cleanupStorage();
|
||||
|
||||
if (!isActive) {
|
||||
window.location.href = '/waiting-room';
|
||||
return;
|
||||
}
|
||||
|
||||
const redirectPath = getRedirectPath(Number(roleId));
|
||||
router.replace(redirectPath);
|
||||
};
|
||||
|
||||
const getRedirectPath = (roleId: number): string => {
|
||||
switch (roleId) {
|
||||
case 0:
|
||||
case 1:
|
||||
case 2:
|
||||
return '/admin/landing-page/profil/program-inovasi';
|
||||
case 3:
|
||||
return '/admin/kesehatan/posyandu';
|
||||
case 4:
|
||||
return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
|
||||
default:
|
||||
return '/admin';
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ CLEANUP FUNCTION - Hapus localStorage + Cookie
|
||||
const cleanupStorage = async () => {
|
||||
// Clear localStorage
|
||||
localStorage.removeItem('auth_kodeId');
|
||||
localStorage.removeItem('auth_nomor');
|
||||
localStorage.removeItem('auth_username');
|
||||
|
||||
// Clear cookie
|
||||
try {
|
||||
await fetch('/api/auth/clear-flow', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error clearing flow cookie:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
if (!nomor) return;
|
||||
try {
|
||||
const res = await fetch('/api/auth/resend', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nomor }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
localStorage.setItem('auth_kodeId', data.kodeId);
|
||||
toast.success('OTP baru dikirim');
|
||||
} else {
|
||||
toast.error(data.message || 'Gagal mengirim ulang OTP');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Gagal menghubungi server');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack pos="relative" bg={colors.Bg} align="center" justify="center" h="100vh">
|
||||
<Loader size="md" color={colors['blue-button']} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!nomor) return null;
|
||||
|
||||
function Validasi() {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<Stack pos={"relative"} bg={colors.Bg}>
|
||||
<Stack pos="relative" bg={colors.Bg}>
|
||||
<Box px={{ base: 'md', md: 100 }} pb={50}>
|
||||
<Stack align='center' justify='center' h={"100vh"}>
|
||||
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
|
||||
<Stack align='center' gap={"lg"}>
|
||||
<Stack align="center" justify="center" h="100vh">
|
||||
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
|
||||
<Stack align="center" gap="lg">
|
||||
<Box>
|
||||
<Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}>
|
||||
Kode Verifikasi
|
||||
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
|
||||
{isRegistrationFlow ? 'Verifikasi Registrasi' : 'Verifikasi Login'}
|
||||
</Title>
|
||||
<Text ta="center" size="sm" c="dimmed" mt="xs">
|
||||
Kami telah mengirim kode ke nomor <strong>{nomor}</strong>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box mb={10}>
|
||||
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masukkan Kode Verifikasi</Text>
|
||||
<PinInput type={/^[0-9]*$/} inputType="tel" inputMode="numeric" />
|
||||
<Box w="100%">
|
||||
<Box mb={20}>
|
||||
<Text c={colors['blue-button']} ta="center" fz="sm" fw="bold">
|
||||
Masukkan Kode Verifikasi
|
||||
</Text>
|
||||
<Center>
|
||||
<PinInput
|
||||
length={4}
|
||||
value={otp}
|
||||
onChange={setOtp}
|
||||
onComplete={handleVerify}
|
||||
inputMode="numeric"
|
||||
size="lg"
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
<Box py={20} >
|
||||
<Button onClick={() => router.push("/admin/landing-page/profile/program-inovasi")}>
|
||||
Page
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={handleVerify}
|
||||
loading={loading}
|
||||
disabled={otp.length < 4}
|
||||
bg={colors['blue-button']}
|
||||
radius="xl"
|
||||
>
|
||||
Verifikasi
|
||||
</Button>
|
||||
|
||||
<Text ta="center" size="sm" mt="md">
|
||||
Tidak menerima kode?{' '}
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={handleResend}
|
||||
size="xs"
|
||||
p={0}
|
||||
h="auto"
|
||||
color={colors['blue-button']}
|
||||
>
|
||||
Kirim Ulang
|
||||
</Button>
|
||||
</Box>
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -33,6 +308,4 @@ function Validasi() {
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default Validasi;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
|
||||
import { Box, ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
|
||||
import { IconBuildingStore, IconFileText, IconSparkles, IconUsers, IconUsersPlus } from '@tabler/icons-react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { IconFileText, IconBuildingStore, IconSparkles, IconUsers, IconUsersPlus } from '@tabler/icons-react';
|
||||
|
||||
function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
@@ -14,36 +14,31 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
|
||||
label: "Pelayanan Surat Keterangan",
|
||||
value: "pelayanansuratketerangan",
|
||||
href: "/admin/desa/layanan/pelayanan_surat_keterangan",
|
||||
icon: <IconFileText size={18} stroke={1.8} />,
|
||||
tooltip: "Layanan terkait surat keterangan resmi desa"
|
||||
icon: <IconFileText size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Pelayanan Perizinan Berusaha",
|
||||
value: "pelayananperizinanusaha",
|
||||
href: "/admin/desa/layanan/pelayanan_perizinan_berusaha",
|
||||
icon: <IconBuildingStore size={18} stroke={1.8} />,
|
||||
tooltip: "Layanan untuk izin usaha masyarakat"
|
||||
icon: <IconBuildingStore size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Pelayanan Telunjuk Sakti Desa",
|
||||
value: "pelayanantelunjuksaktidesa",
|
||||
href: "/admin/desa/layanan/pelayanan_telunjuk_sakti_desa",
|
||||
icon: <IconSparkles size={18} stroke={1.8} />,
|
||||
tooltip: "Layanan inovasi khusus desa"
|
||||
icon: <IconSparkles size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Pelayanan Penduduk Non-Permanent",
|
||||
value: "pelayanannonpermanent",
|
||||
href: "/admin/desa/layanan/pelayanan_penduduk_non_permanent",
|
||||
icon: <IconUsers size={18} stroke={1.8} />,
|
||||
tooltip: "Pendataan penduduk non-permanent"
|
||||
icon: <IconUsers size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Ajukan Permohonan",
|
||||
value: "ajukanpermohonan",
|
||||
href: "/admin/desa/layanan/ajukan_permohonan",
|
||||
icon: <IconUsersPlus size={18} stroke={1.8} />,
|
||||
tooltip: "Ajukan permohonan"
|
||||
icon: <IconUsersPlus size={18} stroke={1.8} />
|
||||
}
|
||||
];
|
||||
|
||||
@@ -77,42 +72,76 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
|
||||
keepMounted={false}
|
||||
>
|
||||
{/* ✅ Scroll horizontal wrapper */}
|
||||
<ScrollArea type="auto" offsetScrollbars>
|
||||
<TabsList
|
||||
p="sm"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
|
||||
borderRadius: "1rem",
|
||||
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
|
||||
display: "flex",
|
||||
flexWrap: "nowrap",
|
||||
gap: "0.5rem",
|
||||
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, i) => (
|
||||
<Tooltip
|
||||
key={i}
|
||||
label={tab.tooltip}
|
||||
position="bottom"
|
||||
withArrow
|
||||
transitionProps={{ transition: 'pop', duration: 200 }}
|
||||
>
|
||||
<Box visibleFrom='md' pb={10}>
|
||||
<ScrollArea type="auto" offsetScrollbars w="100%">
|
||||
<TabsList
|
||||
p="sm"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
|
||||
borderRadius: "1rem",
|
||||
display: "flex",
|
||||
flexWrap: "nowrap",
|
||||
gap: "0.5rem",
|
||||
width: "max-content", // ⬅️ kunci
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, i) => (
|
||||
<TabsTab
|
||||
key={i}
|
||||
value={tab.value}
|
||||
leftSection={tab.icon}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
transition: "all 0.2s ease",
|
||||
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TabsList>
|
||||
</ScrollArea>
|
||||
))}
|
||||
</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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
|
||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
|
||||
import { IconCategory, IconNews } from '@tabler/icons-react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { IconNews, IconCategory } from '@tabler/icons-react';
|
||||
|
||||
function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
@@ -15,15 +15,13 @@ function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
|
||||
label: "List Berita",
|
||||
value: "list_berita",
|
||||
href: "/admin/desa/berita/list-berita",
|
||||
icon: <IconNews size={18} stroke={1.8} />,
|
||||
tooltip: "Lihat dan kelola semua berita desa"
|
||||
icon: <IconNews size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Kategori Berita",
|
||||
value: "kategori_berita",
|
||||
href: "/admin/desa/berita/kategori-berita",
|
||||
icon: <IconCategory size={18} stroke={1.8} />,
|
||||
tooltip: "Kelola kategori berita desa"
|
||||
icon: <IconCategory size={18} stroke={1.8} />
|
||||
},
|
||||
];
|
||||
|
||||
@@ -71,46 +69,39 @@ function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, i) => (
|
||||
<Tooltip
|
||||
<TabsTab
|
||||
key={i}
|
||||
label={tab.tooltip}
|
||||
position="bottom"
|
||||
withArrow
|
||||
transitionProps={{ transition: 'pop', duration: 200 }}
|
||||
value={tab.value}
|
||||
leftSection={tab.icon}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
<TabsTab
|
||||
value={tab.value}
|
||||
leftSection={tab.icon}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
</Tooltip>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
))}
|
||||
</TabsList>
|
||||
</ScrollArea>
|
||||
</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>
|
||||
{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 >
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Loader
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
@@ -23,6 +23,11 @@ function EditKategoriBerita() {
|
||||
const editState = useProxy(stateDashboardBerita.kategoriBerita);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
name: '',
|
||||
});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
@@ -39,6 +44,9 @@ function EditKategoriBerita() {
|
||||
setFormData({
|
||||
name: data.name || '',
|
||||
});
|
||||
setOriginalData({
|
||||
name: data.name || '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading kategori Berita:', error);
|
||||
@@ -56,8 +64,16 @@ function EditKategoriBerita() {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
name: originalData.name,
|
||||
});
|
||||
toast.info('Form dikembalikan ke data awal');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
// update global state hanya saat submit
|
||||
editState.update.form = {
|
||||
...editState.update.form,
|
||||
@@ -70,14 +86,15 @@ function EditKategoriBerita() {
|
||||
} catch (error) {
|
||||
console.error('Error updating kategori Berita:', error);
|
||||
toast.error('Terjadi kesalahan saat memperbarui kategori Berita');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
{/* Back Button + Title */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
@@ -86,7 +103,6 @@ function EditKategoriBerita() {
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Title order={4} ml="sm" c="dark">
|
||||
Edit Kategori Berita
|
||||
</Title>
|
||||
@@ -95,7 +111,7 @@ function EditKategoriBerita() {
|
||||
{/* Form Wrapper */}
|
||||
<Paper
|
||||
w={{ base: '100%', md: '50%' }}
|
||||
bg={colors['white-1']}
|
||||
bg={colors['white-1']}
|
||||
p="lg"
|
||||
radius="md"
|
||||
shadow="sm"
|
||||
@@ -112,6 +128,17 @@ function EditKategoriBerita() {
|
||||
/>
|
||||
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={handleResetForm}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
@@ -122,7 +149,7 @@ function EditKategoriBerita() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -9,15 +9,18 @@ import {
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip
|
||||
Loader
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
|
||||
function CreateKategoriBerita() {
|
||||
const createState = useProxy(stateDashboardBerita.kategoriBerita);
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
createState.create.form = {
|
||||
@@ -26,16 +29,23 @@ function CreateKategoriBerita() {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await createState.create.create();
|
||||
resetForm();
|
||||
router.push('/admin/desa/berita/kategori-berita');
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createState.create.create();
|
||||
resetForm();
|
||||
router.push('/admin/desa/berita/kategori-berita');
|
||||
} catch (error) {
|
||||
console.error('Error creating kategori berita:', error);
|
||||
toast.error('Gagal menambahkan kategori berita');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
{/* Header dengan back button */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
@@ -44,7 +54,6 @@ function CreateKategoriBerita() {
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Title order={4} ml="sm" c="dark">
|
||||
Tambah Kategori Berita
|
||||
</Title>
|
||||
@@ -63,12 +72,23 @@ function CreateKategoriBerita() {
|
||||
<TextInput
|
||||
label="Nama Kategori Berita"
|
||||
placeholder="Masukkan nama kategori berita"
|
||||
defaultValue={createState.create.form.name || ''}
|
||||
value={createState.create.form.name || ''}
|
||||
onChange={(e) => (createState.create.form.name = e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={resetForm}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
@@ -79,7 +99,7 @@ function CreateKategoriBerita() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -17,8 +17,7 @@ import {
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@@ -27,6 +26,7 @@ import { useProxy } from 'valtio/utils';
|
||||
import HeaderSearch from '../../../_com/header';
|
||||
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
|
||||
import stateDashboardBerita from '../../../_state/desa/berita';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
|
||||
function KategoriBerita() {
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -49,6 +49,7 @@ function ListKategoriBerita({ search }: { search: string }) {
|
||||
const router = useRouter();
|
||||
const [modalHapus, setModalHapus] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -59,8 +60,8 @@ function ListKategoriBerita({ search }: { search: string }) {
|
||||
} = listDataState.findMany;
|
||||
|
||||
useEffect(() => {
|
||||
load(page, 10, search);
|
||||
}, [page, search]);
|
||||
load(page, 10, debouncedSearch);
|
||||
}, [page, debouncedSearch]);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedId) {
|
||||
@@ -82,83 +83,84 @@ function ListKategoriBerita({ search }: { search: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box py={10}>
|
||||
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>Daftar Kategori Berita</Title>
|
||||
<Tooltip label="Tambah Kategori Berita" withArrow>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
router.push('/admin/desa/berita/kategori-berita/create')
|
||||
}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Box py={{ base: 'sm', md: 'lg' }}>
|
||||
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
|
||||
<Group justify="space-between" mb={{ base: 'md', md: 'lg' }}>
|
||||
<Title order={4} lh={1.2}>
|
||||
Daftar Kategori Berita
|
||||
</Title>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
router.push('/admin/desa/berita/kategori-berita/create')
|
||||
}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table highlightOnHover>
|
||||
{/* Desktop Table */}
|
||||
<Box visibleFrom="md">
|
||||
<Table highlightOnHover miw={0}>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh style={{ width: '10%' }}>No</TableTh>
|
||||
<TableTh style={{ width: '50%' }}>Nama</TableTh>
|
||||
<TableTh style={{ width: '20%' }}>Edit</TableTh>
|
||||
<TableTh style={{ width: '20%' }}>Hapus</TableTh>
|
||||
<TableTh w="50%">
|
||||
<Text fz="sm" fw={600} lh={1.4}>Kategori</Text>
|
||||
</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>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((item, index) => (
|
||||
filteredData.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd>
|
||||
<Text fz="sm">{index + 1}</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Text fw={500} truncate="end" lineClamp={1}>
|
||||
<Text fz="sm" fw={500} lh={1.45} truncate="end">
|
||||
{item.name}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Tooltip label="Edit Kategori Berita" withArrow>
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/admin/desa/berita/kategori-berita/${item.id}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconEdit size={18} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TableTd ta="center">
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/admin/desa/berita/kategori-berita/${item.id}`
|
||||
)
|
||||
}
|
||||
size="compact-sm"
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</Button>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Tooltip label="Hapus Kategori Berita" withArrow>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
disabled={listDataState.delete.loading}
|
||||
onClick={() => {
|
||||
setSelectedId(item.id);
|
||||
setModalHapus(true);
|
||||
}}
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TableTd ta="center">
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
disabled={listDataState.delete.loading}
|
||||
onClick={() => {
|
||||
setSelectedId(item.id);
|
||||
setModalHapus(true);
|
||||
}}
|
||||
size="compact-sm"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</Button>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))
|
||||
) : (
|
||||
<TableTr>
|
||||
<TableTd colSpan={4}>
|
||||
<Center py={20}>
|
||||
<Text color="dimmed">
|
||||
<Center py={24}>
|
||||
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||
Tidak ada data kategori berita yang cocok
|
||||
</Text>
|
||||
</Center>
|
||||
@@ -168,22 +170,70 @@ function ListKategoriBerita({ search }: { search: string }) {
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</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>
|
||||
|
||||
<Center>
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10, search);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
total={totalPages}
|
||||
mt="md"
|
||||
mb="md"
|
||||
color="blue"
|
||||
radius="md"
|
||||
/>
|
||||
</Center>
|
||||
{totalPages > 1 && (
|
||||
<Center mt={{ base: 'lg', md: 'xl' }}>
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10, search);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
total={totalPages}
|
||||
color="blue"
|
||||
radius="md"
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* Modal Konfirmasi Hapus */}
|
||||
<ModalKonfirmasiHapus
|
||||
@@ -196,4 +246,4 @@ function ListKategoriBerita({ search }: { search: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default KategoriBerita;
|
||||
export default KategoriBerita;
|
||||
@@ -1,8 +1,30 @@
|
||||
'use client'
|
||||
import React from 'react';
|
||||
import LayoutTabsBerita from './_com/layoutTabs';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Box } from '@mantine/core';
|
||||
|
||||
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 (
|
||||
<LayoutTabsBerita>
|
||||
{children}
|
||||
|
||||
@@ -6,6 +6,7 @@ import stateDashboardBerita from "@/app/admin/(dashboard)/_state/desa/berita";
|
||||
import colors from "@/con/colors";
|
||||
import ApiFetch from "@/lib/api-fetch";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Loader
|
||||
} from "@mantine/core";
|
||||
import { Dropzone } from "@mantine/dropzone";
|
||||
import {
|
||||
@@ -45,6 +46,17 @@ function EditBerita() {
|
||||
imageId: "",
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
judul: "",
|
||||
deskripsi: "",
|
||||
kategoriBeritaId: "",
|
||||
content: "",
|
||||
imageId: "",
|
||||
imageUrl: ""
|
||||
});
|
||||
|
||||
// Load kategori + berita
|
||||
useEffect(() => {
|
||||
beritaState.kategoriBerita.findMany.load();
|
||||
@@ -64,6 +76,15 @@ function EditBerita() {
|
||||
imageId: data.imageId || "",
|
||||
});
|
||||
|
||||
setOriginalData({
|
||||
judul: data.judul || "",
|
||||
deskripsi: data.deskripsi || "",
|
||||
kategoriBeritaId: data.kategoriBeritaId || "",
|
||||
content: data.content || "",
|
||||
imageId: data.imageId || "",
|
||||
imageUrl: data.image?.link || ""
|
||||
});
|
||||
|
||||
if (data?.image?.link) {
|
||||
setPreviewImage(data.image.link);
|
||||
}
|
||||
@@ -83,6 +104,7 @@ function EditBerita() {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
// Update global state hanya sekali di sini
|
||||
beritaState.berita.edit.form = {
|
||||
...beritaState.berita.edit.form,
|
||||
@@ -109,23 +131,36 @@ function EditBerita() {
|
||||
} catch (error) {
|
||||
console.error("Error updating berita:", error);
|
||||
toast.error("Terjadi kesalahan saat memperbarui berita");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
judul: originalData.judul,
|
||||
deskripsi: originalData.deskripsi,
|
||||
kategoriBeritaId: originalData.kategoriBeritaId,
|
||||
content: originalData.content,
|
||||
imageId: originalData.imageId,
|
||||
});
|
||||
setPreviewImage(originalData.imageUrl || null);
|
||||
setFile(null);
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: "sm", md: "lg" }} py="md">
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<IconArrowBack color={colors["blue-button"]} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Berita
|
||||
</Title>
|
||||
@@ -219,14 +254,14 @@ function EditBerita() {
|
||||
Seret gambar atau klik untuk memilih file
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Maksimal 5MB, format gambar wajib
|
||||
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
|
||||
{previewImage && (
|
||||
<Box mt="sm" style={{ display: "flex", justifyContent: "center" }}>
|
||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview Gambar"
|
||||
@@ -238,6 +273,24 @@ function EditBerita() {
|
||||
}}
|
||||
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>
|
||||
@@ -257,17 +310,29 @@ function EditBerita() {
|
||||
|
||||
{/* 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)",
|
||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client'
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
|
||||
import colors from '@/con/colors';
|
||||
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
|
||||
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
|
||||
import colors from '@/con/colors';
|
||||
|
||||
function DetailBerita() {
|
||||
const beritaState = useProxy(stateDashboardBerita);
|
||||
@@ -41,7 +41,7 @@ function DetailBerita() {
|
||||
const data = beritaState.berita.findUnique.data;
|
||||
|
||||
return (
|
||||
<Box py={10}>
|
||||
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||
{/* Tombol Back */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
@@ -111,7 +111,6 @@ function DetailBerita() {
|
||||
|
||||
{/* Action Button */}
|
||||
<Group gap="sm">
|
||||
<Tooltip label="Hapus Berita" withArrow position="top">
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
@@ -124,9 +123,7 @@ function DetailBerita() {
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Edit Berita" withArrow position="top">
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
|
||||
@@ -136,7 +133,6 @@ function DetailBerita() {
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Loader,
|
||||
ActionIcon
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
@@ -29,6 +30,7 @@ export default function CreateBerita() {
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useShallowEffect(() => {
|
||||
beritaState.kategoriBerita.findMany.load();
|
||||
@@ -47,42 +49,48 @@ export default function CreateBerita() {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) {
|
||||
return toast.warn('Silakan pilih file gambar terlebih dahulu');
|
||||
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');
|
||||
}
|
||||
|
||||
beritaState.berita.create.form.imageId = uploaded.id;
|
||||
|
||||
await beritaState.berita.create.create();
|
||||
|
||||
resetForm();
|
||||
router.push('/admin/desa/berita/list-berita');
|
||||
} catch (error) {
|
||||
console.error('Error creating berita:', error);
|
||||
toast.error('Terjadi kesalahan saat membuat berita');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
beritaState.berita.create.form.imageId = uploaded.id;
|
||||
|
||||
await beritaState.berita.create.create();
|
||||
|
||||
resetForm();
|
||||
router.push('/admin/desa/berita/list-berita');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
{/* Header dengan tombol kembali */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Berita
|
||||
</Title>
|
||||
@@ -100,7 +108,7 @@ export default function CreateBerita() {
|
||||
<TextInput
|
||||
label="Judul"
|
||||
placeholder="Masukkan judul berita"
|
||||
defaultValue={beritaState.berita.create.form.judul}
|
||||
value={beritaState.berita.create.form.judul}
|
||||
onChange={(e) => (beritaState.berita.create.form.judul = e.target.value)}
|
||||
required
|
||||
/>
|
||||
@@ -112,7 +120,7 @@ export default function CreateBerita() {
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}))}
|
||||
defaultValue={beritaState.berita.create.form.kategoriBeritaId || null}
|
||||
value={beritaState.berita.create.form.kategoriBeritaId || null}
|
||||
onChange={(val: string | null) => {
|
||||
if (val) {
|
||||
const selected = beritaState.kategoriBerita.findMany.data?.find(
|
||||
@@ -157,7 +165,7 @@ export default function CreateBerita() {
|
||||
}}
|
||||
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||
maxSize={5 * 1024 ** 2}
|
||||
accept={{ 'image/*': [] }}
|
||||
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||
radius="md"
|
||||
p="xl"
|
||||
>
|
||||
@@ -178,7 +186,7 @@ export default function CreateBerita() {
|
||||
</Dropzone>
|
||||
|
||||
{previewImage && (
|
||||
<Box mt="sm" style={{ textAlign: 'center' }}>
|
||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview Gambar"
|
||||
@@ -190,6 +198,26 @@ export default function CreateBerita() {
|
||||
}}
|
||||
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>
|
||||
@@ -207,6 +235,17 @@ export default function CreateBerita() {
|
||||
</Box>
|
||||
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={resetForm}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
@@ -217,7 +256,7 @@ export default function CreateBerita() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -16,10 +16,9 @@ import {
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||
import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
@@ -46,16 +45,17 @@ function Berita() {
|
||||
function ListBerita({ search }: { search: string }) {
|
||||
const beritaState = useProxy(stateDashboardBerita);
|
||||
const router = useRouter();
|
||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||
|
||||
const { data, page, totalPages, loading, load } = beritaState.berita.findMany;
|
||||
|
||||
useShallowEffect(() => {
|
||||
load(page, 10, search);
|
||||
}, [page, search]);
|
||||
load(page, 10, debouncedSearch);
|
||||
}, [page, debouncedSearch]);
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<Stack py={10}>
|
||||
<Stack py="md">
|
||||
<Skeleton height={600} radius="md" />
|
||||
</Stack>
|
||||
);
|
||||
@@ -64,66 +64,66 @@ function ListBerita({ search }: { search: string }) {
|
||||
const filteredData = data || [];
|
||||
|
||||
return (
|
||||
<Box py={10}>
|
||||
<Box py="md">
|
||||
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>Daftar Berita</Title>
|
||||
<Tooltip label="Tambah Berita" withArrow>
|
||||
<Button
|
||||
leftSection={<IconCircleDashedPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() => router.push('/admin/desa/berita/list-berita/create')}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
leftSection={<IconCircleDashedPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() => router.push('/admin/desa/berita/list-berita/create')}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table highlightOnHover>
|
||||
{/* Desktop Table */}
|
||||
<Box visibleFrom="md">
|
||||
<Table highlightOnHover miw={0}>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh style={{ width: '30%' }}>Judul</TableTh>
|
||||
<TableTh style={{ width: '20%' }}>Kategori</TableTh>
|
||||
<TableTh style={{ width: '15%' }}>Aksi</TableTh>
|
||||
<TableTh w="50%">Judul</TableTh>
|
||||
<TableTh w="30%">Kategori</TableTh>
|
||||
<TableTh w="20%">Aksi</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd style={{ width: '30%' }}>
|
||||
<Box w={150}>
|
||||
<Text fw={500} truncate="end" lineClamp={1}>
|
||||
{item.judul}
|
||||
</Text>
|
||||
</Box>
|
||||
<TableTd>
|
||||
<Text fz="md" fw={600} lh={1.45} truncate="end">
|
||||
{item.judul}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd style={{ width: '20%' }}>
|
||||
<Text fz="sm" c="dimmed">
|
||||
<TableTd>
|
||||
<Text fz="sm" c="dimmed" lh={1.45}>
|
||||
{item.kategoriBerita?.name || '-'}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd style={{ width: '15%' }}>
|
||||
<TableTd>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={() =>
|
||||
router.push(`/admin/desa/berita/list-berita/${item.id}`)
|
||||
}
|
||||
fz="sm"
|
||||
px="sm"
|
||||
h={36}
|
||||
>
|
||||
<IconDeviceImacCog size={20} />
|
||||
<Text ml={5}>Detail</Text>
|
||||
<IconDeviceImacCog size={18} />
|
||||
<Text ml="xs">Detail</Text>
|
||||
</Button>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))
|
||||
) : (
|
||||
<TableTr>
|
||||
<TableTd colSpan={4}>
|
||||
<Center py={20}>
|
||||
<Text color="dimmed">
|
||||
<TableTd colSpan={3}>
|
||||
<Center py="xl">
|
||||
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||
Tidak ada data berita yang cocok
|
||||
</Text>
|
||||
</Center>
|
||||
@@ -133,6 +133,52 @@ function ListBerita({ search }: { search: string }) {
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</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>
|
||||
|
||||
<Center>
|
||||
@@ -153,4 +199,4 @@ function ListBerita({ search }: { search: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default Berita;
|
||||
export default Berita;
|
||||
303
src/app/admin/(dashboard)/desa/gallery/foto/[id]/edit/page.tsx
Normal file
303
src/app/admin/(dashboard)/desa/gallery/foto/[id]/edit/page.tsx
Normal 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;
|
||||
175
src/app/admin/(dashboard)/desa/gallery/foto/[id]/page.tsx
Normal file
175
src/app/admin/(dashboard)/desa/gallery/foto/[id]/page.tsx
Normal 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;
|
||||
228
src/app/admin/(dashboard)/desa/gallery/foto/create/page.tsx
Normal file
228
src/app/admin/(dashboard)/desa/gallery/foto/create/page.tsx
Normal 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;
|
||||
@@ -1,160 +1,216 @@
|
||||
"use client";
|
||||
import stateFileStorage from "@/state/state-list-image";
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Card,
|
||||
Flex,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
Pagination,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useShallowEffect } from "@mantine/hooks";
|
||||
import { IconSearch, IconTrash, IconX } from "@tabler/icons-react";
|
||||
import { motion } from "framer-motion";
|
||||
import toast from "react-simple-toasts";
|
||||
import { useSnapshot } from "valtio";
|
||||
|
||||
export default function ListImage() {
|
||||
const { list, total } = useSnapshot(stateFileStorage);
|
||||
|
||||
useShallowEffect(() => {
|
||||
stateFileStorage.load();
|
||||
}, []);
|
||||
|
||||
let timeOut: NodeJS.Timer;
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||
import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import HeaderSearch from '../../../_com/header';
|
||||
import stateGallery from '../../../_state/desa/gallery';
|
||||
|
||||
function Foto() {
|
||||
const [search, setSearch] = useState("");
|
||||
return (
|
||||
<Stack p="lg" gap="lg">
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap="md">
|
||||
<Title order={2} fw={700}>
|
||||
Galeri Foto
|
||||
</Title>
|
||||
<TextInput
|
||||
radius="xl"
|
||||
size="md"
|
||||
placeholder="Cari foto berdasarkan nama..."
|
||||
leftSection={<IconSearch size={18} />}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="gray"
|
||||
radius="xl"
|
||||
onClick={() => stateFileStorage.load()}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (timeOut) clearTimeout(timeOut);
|
||||
timeOut = setTimeout(() => {
|
||||
stateFileStorage.load({ search: e.target.value });
|
||||
}, 300);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Paper withBorder radius="lg" p="md" shadow="sm">
|
||||
{list && list.length > 0 ? (
|
||||
<SimpleGrid
|
||||
cols={{ base: 2, sm: 3, md: 5, lg: 8 }}
|
||||
spacing="md"
|
||||
verticalSpacing="md"
|
||||
>
|
||||
{list.map((v, k) => (
|
||||
<Card
|
||||
key={k}
|
||||
withBorder
|
||||
radius="md"
|
||||
shadow="sm"
|
||||
className="hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<motion.div
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(v.url);
|
||||
toast("Tautan foto berhasil disalin");
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<Image
|
||||
src={`${v.url}?size=200`}
|
||||
alt={v.name}
|
||||
radius="md"
|
||||
h={120}
|
||||
fit="cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<Box>
|
||||
<Text size="sm" fw={500} lineClamp={2}>
|
||||
{v.name}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Group justify="space-between" align="center" pt="xs">
|
||||
<Tooltip label="Hapus foto" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
radius="md"
|
||||
onClick={() => {
|
||||
stateFileStorage
|
||||
.del({ id: v.id })
|
||||
.finally(() => toast("Foto berhasil dihapus"));
|
||||
}}
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Stack align="center" justify="center" py="xl" gap="sm">
|
||||
<Image
|
||||
src="https://cdn-icons-png.flaticon.com/512/4076/4076549.png"
|
||||
alt="Kosong"
|
||||
w={120}
|
||||
h={120}
|
||||
fit="contain"
|
||||
opacity={0.7}
|
||||
loading="lazy"
|
||||
/>
|
||||
<Text c="dimmed" ta="center">
|
||||
Belum ada foto yang tersedia
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{total && total > 1 && (
|
||||
<Flex justify="center">
|
||||
<Pagination
|
||||
total={total}
|
||||
value={stateFileStorage.page} // Changed from page to value
|
||||
size="md"
|
||||
radius="md"
|
||||
withEdges
|
||||
onChange={(page) => {
|
||||
stateFileStorage.load({ page });
|
||||
}}
|
||||
/>
|
||||
|
||||
</Flex>
|
||||
)}
|
||||
</Stack>
|
||||
<Box>
|
||||
<HeaderSearch
|
||||
title='Foto'
|
||||
placeholder='Cari judul atau deskripsi foto...'
|
||||
searchIcon={<IconSearch size={20} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<ListFoto search={search} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ListFoto({ search }: { search: string }) {
|
||||
const FotoState = useProxy(stateGallery.foto)
|
||||
const router = useRouter();
|
||||
const [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;
|
||||
@@ -1,7 +1,29 @@
|
||||
'use client'
|
||||
import { usePathname } from "next/navigation";
|
||||
import LayoutTabsGallery from "./lib/layoutTabs"
|
||||
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 (
|
||||
<LayoutTabsGallery>
|
||||
{children}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
|
||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
|
||||
import { IconPhoto, IconVideo } from '@tabler/icons-react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { IconPhoto, IconVideo } from '@tabler/icons-react';
|
||||
|
||||
function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
@@ -14,15 +14,13 @@ function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
|
||||
label: "Foto",
|
||||
value: "foto",
|
||||
href: "/admin/desa/gallery/foto",
|
||||
icon: <IconPhoto size={18} stroke={1.8} />,
|
||||
tooltip: "Kelola foto-foto galeri desa"
|
||||
icon: <IconPhoto size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Video",
|
||||
value: "video",
|
||||
href: "/admin/desa/gallery/video",
|
||||
icon: <IconVideo size={18} stroke={1.8} />,
|
||||
tooltip: "Kelola video galeri desa"
|
||||
icon: <IconVideo size={18} stroke={1.8} />
|
||||
},
|
||||
];
|
||||
|
||||
@@ -70,25 +68,18 @@ function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, i) => (
|
||||
<Tooltip
|
||||
<TabsTab
|
||||
key={i}
|
||||
label={tab.tooltip}
|
||||
position="bottom"
|
||||
withArrow
|
||||
transitionProps={{ transition: 'pop', duration: 200 }}
|
||||
value={tab.value}
|
||||
leftSection={tab.icon}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
<TabsTab
|
||||
value={tab.value}
|
||||
leftSection={tab.icon}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
</Tooltip>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
))}
|
||||
</TabsList>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -4,6 +4,7 @@ import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
|
||||
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
@@ -11,11 +12,11 @@ import {
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip
|
||||
Loader
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { IconArrowBack, IconX } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import { convertYoutubeUrlToEmbed } from '../../../lib/youtube-utils';
|
||||
@@ -25,6 +26,14 @@ function EditVideo() {
|
||||
const videoState = useProxy(stateGallery.video);
|
||||
const params = useParams();
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
name: "",
|
||||
deskripsi: "",
|
||||
linkVideo: "",
|
||||
});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
deskripsi: '',
|
||||
@@ -45,6 +54,11 @@ function EditVideo() {
|
||||
deskripsi: data.deskripsi ?? '',
|
||||
linkVideo: data.linkVideo ?? '',
|
||||
});
|
||||
setOriginalData({
|
||||
name: data.name ?? '',
|
||||
deskripsi: data.deskripsi ?? '',
|
||||
linkVideo: data.linkVideo ?? '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading video:', error);
|
||||
@@ -62,43 +76,58 @@ function EditVideo() {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const converted = convertYoutubeUrlToEmbed(formData.linkVideo);
|
||||
if (!converted) {
|
||||
toast.error("Link YouTube tidak valid. Pastikan formatnya benar.");
|
||||
return;
|
||||
}
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
name: originalData.name,
|
||||
deskripsi: originalData.deskripsi,
|
||||
linkVideo: originalData.linkVideo,
|
||||
});
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
videoState.update.form = {
|
||||
name: formData.name,
|
||||
deskripsi: formData.deskripsi,
|
||||
linkVideo: formData.linkVideo,
|
||||
};
|
||||
await videoState.update.update();
|
||||
toast.success('Video berhasil diperbarui!');
|
||||
router.push('/admin/desa/gallery/video');
|
||||
setIsSubmitting(true);
|
||||
const converted = convertYoutubeUrlToEmbed(formData.linkVideo);
|
||||
if (!converted) {
|
||||
toast.error("Link YouTube tidak valid. Pastikan formatnya benar.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
videoState.update.form = {
|
||||
name: formData.name,
|
||||
deskripsi: formData.deskripsi,
|
||||
linkVideo: formData.linkVideo,
|
||||
};
|
||||
await videoState.update.update();
|
||||
toast.success('Video berhasil diperbarui!');
|
||||
router.push('/admin/desa/gallery/video');
|
||||
} catch (error) {
|
||||
console.error('Error updating video:', error);
|
||||
toast.error('Terjadi kesalahan saat memperbarui video');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating video:', error);
|
||||
toast.error('Terjadi kesalahan saat memperbarui video');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const embedLink = convertYoutubeUrlToEmbed(formData.linkVideo);
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Video
|
||||
</Title>
|
||||
@@ -130,7 +159,7 @@ function EditVideo() {
|
||||
required
|
||||
/>
|
||||
{embedLink && (
|
||||
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Box mt="sm" pos="relative" style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<iframe
|
||||
className="rounded"
|
||||
width="100%"
|
||||
@@ -138,7 +167,27 @@ function EditVideo() {
|
||||
src={embedLink}
|
||||
title="Preview Video"
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={() => {
|
||||
setFormData({
|
||||
...formData,
|
||||
linkVideo: '',
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
@@ -154,6 +203,17 @@ function EditVideo() {
|
||||
</Box>
|
||||
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={handleResetForm}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
@@ -164,7 +224,7 @@ function EditVideo() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
|
||||
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Button, Group, Paper, Skeleton, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { Box, Button, Group, Paper, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
@@ -40,7 +40,7 @@ function DetailVideo() {
|
||||
const data = videoState.findUnique.data;
|
||||
|
||||
return (
|
||||
<Box py={10}>
|
||||
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||
{/* Tombol Kembali */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
@@ -54,7 +54,7 @@ function DetailVideo() {
|
||||
{/* Detail Video */}
|
||||
<Paper
|
||||
withBorder
|
||||
w={{ base: "100%", md: "50%" }}
|
||||
w={{ base: "100%", md: "70%" }}
|
||||
bg={colors['white-1']}
|
||||
p="lg"
|
||||
radius="md"
|
||||
@@ -111,7 +111,6 @@ function DetailVideo() {
|
||||
|
||||
{/* Tombol Aksi */}
|
||||
<Group gap="sm">
|
||||
<Tooltip label="Hapus Video" withArrow position="top">
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
@@ -124,9 +123,7 @@ function DetailVideo() {
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Edit Video" withArrow position="top">
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() =>
|
||||
@@ -138,7 +135,6 @@ function DetailVideo() {
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -3,6 +3,7 @@ import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
|
||||
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
@@ -11,9 +12,9 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Loader
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { IconArrowBack, IconX } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -25,6 +26,7 @@ function CreateVideo() {
|
||||
const router = useRouter();
|
||||
const [link, setLink] = useState('');
|
||||
const embedLink = convertYoutubeUrlToEmbed(link);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
videoState.create.form = {
|
||||
@@ -36,31 +38,37 @@ function CreateVideo() {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!embedLink) {
|
||||
toast.error('Link YouTube tidak valid. Pastikan formatnya benar.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
if (!embedLink) {
|
||||
toast.error('Link YouTube tidak valid. Pastikan formatnya benar.');
|
||||
return;
|
||||
}
|
||||
|
||||
videoState.create.form.linkVideo = embedLink;
|
||||
await videoState.create.create();
|
||||
resetForm();
|
||||
router.push('/admin/desa/gallery/video');
|
||||
videoState.create.form.linkVideo = embedLink;
|
||||
await videoState.create.create();
|
||||
resetForm();
|
||||
router.push('/admin/desa/gallery/video');
|
||||
} catch (error) {
|
||||
console.error("Error creating video:", error);
|
||||
toast.error("Terjadi kesalahan saat menambahkan video");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
{/* Header Back Button + Title */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Video
|
||||
</Title>
|
||||
@@ -80,7 +88,7 @@ function CreateVideo() {
|
||||
<TextInput
|
||||
label="Judul Video"
|
||||
placeholder="Masukkan judul video"
|
||||
defaultValue={videoState.create.form.name}
|
||||
value={videoState.create.form.name}
|
||||
onChange={(e) => {
|
||||
videoState.create.form.name = e.currentTarget.value;
|
||||
}}
|
||||
@@ -91,14 +99,14 @@ function CreateVideo() {
|
||||
<TextInput
|
||||
label="Link Video YouTube"
|
||||
placeholder="https://www.youtube.com/watch?v=abc123"
|
||||
defaultValue={link}
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Preview Video */}
|
||||
{embedLink && (
|
||||
<Box mt="sm">
|
||||
<Box mt="sm" pos="relative">
|
||||
<iframe
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
@@ -109,7 +117,24 @@ function CreateVideo() {
|
||||
src={embedLink}
|
||||
title="Preview Video"
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={() => {
|
||||
setLink('');
|
||||
}}
|
||||
style={{
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -128,6 +153,17 @@ function CreateVideo() {
|
||||
|
||||
{/* 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"
|
||||
@@ -138,7 +174,7 @@ function CreateVideo() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -16,10 +16,9 @@ import {
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||
import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
@@ -46,6 +45,7 @@ function Video() {
|
||||
function ListVideo({ search }: { search: string }) {
|
||||
const videoState = useProxy(stateGallery.video)
|
||||
const router = useRouter();
|
||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -56,111 +56,166 @@ function ListVideo({ search }: { search: string }) {
|
||||
} = videoState.findMany;
|
||||
|
||||
useShallowEffect(() => {
|
||||
load(page, 10, search)
|
||||
}, [page, search])
|
||||
load(page, 10, debouncedSearch)
|
||||
}, [page, debouncedSearch])
|
||||
|
||||
const filteredData = data || []
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<Stack py={10}>
|
||||
<Stack py={20}>
|
||||
<Skeleton height={600} radius="md" />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box py={10}>
|
||||
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>Daftar Video</Title>
|
||||
<Tooltip label="Tambah Video Baru" withArrow>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() => router.push('/admin/desa/gallery/video/create')}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Box py={20}>
|
||||
<Paper withBorder bg={colors['white-1']} p={{ base: 'sm', md: 'lg' }} shadow="md" radius="md">
|
||||
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
|
||||
<Title order={4} lh={1.2}>
|
||||
Daftar Video
|
||||
</Title>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() => router.push('/admin/desa/gallery/video/create')}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Group>
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh style={{ width: '25%' }}>Judul Video</TableTh>
|
||||
<TableTh style={{ width: '20%' }}>Tanggal</TableTh>
|
||||
<TableTh style={{ width: '30%' }}>Deskripsi</TableTh>
|
||||
<TableTh style={{ width: '15%' }}>Aksi</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd style={{ width: '25%' }}>
|
||||
<Box w={200}>
|
||||
<Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text>
|
||||
</Box>
|
||||
</TableTd>
|
||||
<TableTd style={{ width: '20%' }}>
|
||||
<Box w={200}>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{new Date(item.createdAt).toLocaleDateString('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
</TableTd>
|
||||
<TableTd style={{ width: '30%' }}>
|
||||
<Box w={200}>
|
||||
<Text fz="sm" truncate="end" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
|
||||
</Box>
|
||||
</TableTd>
|
||||
<TableTd style={{ width: '15%' }}>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={() => router.push(`/admin/desa/gallery/video/${item.id}`)}
|
||||
>
|
||||
<IconDeviceImac size={20} />
|
||||
<Text ml={5}>Detail</Text>
|
||||
</Button>
|
||||
|
||||
{/* Desktop Table */}
|
||||
<Box visibleFrom="md">
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table highlightOnHover striped verticalSpacing="sm">
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>Judul Video</TableTh>
|
||||
<TableTh>Tanggal</TableTh>
|
||||
<TableTh>Deskripsi</TableTh>
|
||||
<TableTh>Aksi</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd style={{ maxWidth: 250 }}>
|
||||
<Text fz="md" fw={500} lh={1.45} truncate="end" lineClamp={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd style={{ maxWidth: 250 }}>
|
||||
<Text fz="sm" c="dimmed" lh={1.45}>
|
||||
{new Date(item.createdAt).toLocaleDateString('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd style={{ maxWidth: 250 }}>
|
||||
<Text fz="sm" lh={1.45} truncate="end" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
|
||||
</TableTd>
|
||||
<TableTd style={{ maxWidth: 250 }}>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={() => router.push(`/admin/desa/gallery/video/${item.id}`)}
|
||||
fz="sm"
|
||||
px="xs"
|
||||
>
|
||||
<IconDeviceImac size={18} />
|
||||
<Text ml={5}>Detail</Text>
|
||||
</Button>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))
|
||||
) : (
|
||||
<TableTr>
|
||||
<TableTd colSpan={4}>
|
||||
<Center py={24}>
|
||||
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||
Tidak ada video yang cocok
|
||||
</Text>
|
||||
</Center>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))
|
||||
) : (
|
||||
<TableTr>
|
||||
<TableTd colSpan={4}>
|
||||
<Center py={20}>
|
||||
<Text c="dimmed">Tidak ada video yang cocok</Text>
|
||||
</Center>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
)}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
)}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<Stack hiddenFrom="md" gap="xs" mt="sm">
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((item) => (
|
||||
<Paper key={item.id} p="sm" withBorder radius="sm">
|
||||
<Stack gap={"xs"}>
|
||||
<Box>
|
||||
<Text fz="xs" fw={600} lh={1.4}>Judul Video</Text>
|
||||
<Text fz="sm" fw={500} lh={1.45}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz="xs" fw={600} lh={1.4}>Tanggal</Text>
|
||||
<Text fz="sm" fw={500} lh={1.45}>
|
||||
{new Date(item.createdAt).toLocaleDateString('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz="xs" fw={600} lh={1.4}>Deskripsi</Text>
|
||||
<Text fz="sm" lineClamp={5} fw={500} lh={1.45} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
fullWidth
|
||||
onClick={() => router.push(`/admin/desa/gallery/video/${item.id}`)}
|
||||
fz="sm"
|
||||
>
|
||||
<IconDeviceImac size={18} />
|
||||
<Text ml={5}>Detail</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))
|
||||
) : (
|
||||
<Center py={24}>
|
||||
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||
Tidak ada video yang cocok
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
<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>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Center mt="xl">
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}}
|
||||
total={totalPages}
|
||||
color="blue"
|
||||
radius="md"
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Video;
|
||||
export default Video;
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
@@ -24,6 +24,16 @@ function EditAjukanPermohonan() {
|
||||
const params = useParams();
|
||||
const stateAjukan = useProxy(stateLayananDesa.ajukanPermohonan);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
nama: "",
|
||||
nik: "",
|
||||
alamat: "",
|
||||
nomorKk: "",
|
||||
kategoriId: "",
|
||||
});
|
||||
|
||||
// State lokal form
|
||||
const [formData, setFormData] = useState({
|
||||
nama: '',
|
||||
@@ -51,6 +61,13 @@ function EditAjukanPermohonan() {
|
||||
nomorKk: data.nomorKk || '',
|
||||
kategoriId: data.kategoriId || '',
|
||||
});
|
||||
setOriginalData({
|
||||
nama: data.nama || '',
|
||||
nik: data.nik || '',
|
||||
alamat: data.alamat || '',
|
||||
nomorKk: data.nomorKk || '',
|
||||
kategoriId: data.kategoriId || '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading ajukan:', error);
|
||||
@@ -69,8 +86,20 @@ function EditAjukanPermohonan() {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
nama: originalData.nama,
|
||||
nik: originalData.nik,
|
||||
alamat: originalData.alamat,
|
||||
nomorKk: originalData.nomorKk,
|
||||
kategoriId: originalData.kategoriId,
|
||||
});
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
stateAjukan.edit.form = {
|
||||
...stateAjukan.edit.form,
|
||||
...formData,
|
||||
@@ -80,18 +109,18 @@ function EditAjukanPermohonan() {
|
||||
} catch (error) {
|
||||
console.error('Error updating ajukan:', error);
|
||||
toast.error('Terjadi kesalahan saat memperbarui ajukan');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
{/* Back Button */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Ajukan Permohonan
|
||||
</Title>
|
||||
@@ -156,6 +185,17 @@ function EditAjukanPermohonan() {
|
||||
/>
|
||||
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={handleResetForm}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
@@ -166,7 +206,7 @@ function EditAjukanPermohonan() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
@@ -49,7 +48,7 @@ function DetailAjukanPermohonan() {
|
||||
const data = ajukanPermohonanState.findUnique.data;
|
||||
|
||||
return (
|
||||
<Box py={10}>
|
||||
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||
{/* Tombol Kembali */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
@@ -62,7 +61,7 @@ function DetailAjukanPermohonan() {
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
w={{ base: '100%', md: '60%' }}
|
||||
w={{ base: '100%', md: '70%' }}
|
||||
bg={colors['white-1']}
|
||||
p="lg"
|
||||
radius="md"
|
||||
@@ -121,7 +120,6 @@ function DetailAjukanPermohonan() {
|
||||
</Box>
|
||||
|
||||
<Group gap="sm">
|
||||
<Tooltip label="Hapus Surat" withArrow position="top">
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
@@ -135,9 +133,7 @@ function DetailAjukanPermohonan() {
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Edit Surat" withArrow position="top">
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() =>
|
||||
@@ -151,7 +147,6 @@ function DetailAjukanPermohonan() {
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import HeaderSearch from '../../../_com/header';
|
||||
import stateLayananDesa from '../../../_state/desa/layananDesa';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
|
||||
function AjukanPermohonan() {
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -44,6 +45,7 @@ function AjukanPermohonan() {
|
||||
function ListAjukanPermohonan({ search }: { search: string }) {
|
||||
const AjukanPermohonanState = useProxy(stateLayananDesa.ajukanPermohonan);
|
||||
const router = useRouter();
|
||||
const [debouncedSearch] = useDebouncedValue(search, 1000); // 500ms delay
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -54,58 +56,56 @@ function ListAjukanPermohonan({ search }: { search: string }) {
|
||||
} = AjukanPermohonanState.findMany;
|
||||
|
||||
useEffect(() => {
|
||||
load(page, 10, search);
|
||||
}, [page, search]);
|
||||
load(page, 10, debouncedSearch);
|
||||
}, [page, debouncedSearch]);
|
||||
|
||||
// Loading state
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<Stack py={10}>
|
||||
<Stack py={{ base: 'sm', md: 'md' }}>
|
||||
<Skeleton height={600} radius="md" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box py={10}>
|
||||
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
|
||||
<Title order={4}>List Ajukan Permohonan</Title>
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table highlightOnHover>
|
||||
<Box py={{ base: 'sm', md: 'md' }}>
|
||||
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
|
||||
<Title order={2} lh={1.2} mb={{ base: 'md', md: 'lg' }}>
|
||||
List Ajukan Permohonan
|
||||
</Title>
|
||||
|
||||
{/* Desktop Table */}
|
||||
<Box visibleFrom="md">
|
||||
<Table highlightOnHover miw={0}>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh style={{ width: '30%' }}>Nama</TableTh>
|
||||
<TableTh style={{ width: '45%' }}>Alamat</TableTh>
|
||||
<TableTh style={{ width: '15%' }}>NIK</TableTh>
|
||||
<TableTh style={{ width: '15%' }}>Aksi</TableTh>
|
||||
<TableTh fz="sm" fw={600} lh={1.4}>Nama</TableTh>
|
||||
<TableTh fz="sm" fw={600} lh={1.4}>Alamat</TableTh>
|
||||
<TableTh fz="sm" fw={600} lh={1.4}>NIK</TableTh>
|
||||
<TableTh fz="sm" fw={600} lh={1.4}>Aksi</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{data.length > 0 ? (
|
||||
data.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd style={{ width: '30%' }}>
|
||||
<Box w={200}>
|
||||
<Text fw={500} truncate="end" lineClamp={1}>
|
||||
{item.nama}
|
||||
</Text>
|
||||
</Box>
|
||||
<TableTd>
|
||||
<Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}>
|
||||
{item.nama}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd style={{ width: '45%' }}>
|
||||
<Box w={200}>
|
||||
<Text fw={500} truncate="end" lineClamp={1}>
|
||||
{item.alamat}
|
||||
</Text>
|
||||
</Box>
|
||||
<TableTd>
|
||||
<Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}>
|
||||
{item.alamat}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd style={{ width: '45%' }}>
|
||||
<Box w={200}>
|
||||
<Text fw={500} truncate="end" lineClamp={1}>
|
||||
{item.nik}
|
||||
</Text>
|
||||
</Box>
|
||||
<TableTd>
|
||||
<Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}>
|
||||
{item.nik}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd style={{ width: '15%' }}>
|
||||
<TableTd>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
@@ -123,9 +123,11 @@ function ListAjukanPermohonan({ search }: { search: string }) {
|
||||
))
|
||||
) : (
|
||||
<TableTr>
|
||||
<TableTd colSpan={3}>
|
||||
<Center py={20}>
|
||||
<Text color="dimmed">Tidak ada data ajukan permohonan yang cocok</Text>
|
||||
<TableTd colSpan={4}>
|
||||
<Center py="xl">
|
||||
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||
Tidak ada data ajukan permohonan yang cocok
|
||||
</Text>
|
||||
</Center>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
@@ -133,23 +135,71 @@ function ListAjukanPermohonan({ search }: { search: string }) {
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Mobile Card View */}
|
||||
<Box hiddenFrom="md">
|
||||
<Stack gap="md">
|
||||
{data.length > 0 ? (
|
||||
data.map((item) => (
|
||||
<Paper key={item.id} withBorder p="md" radius="md" shadow="xs">
|
||||
<Stack gap={'xs'}>
|
||||
<Box>
|
||||
<Text fz="sm" fw={600} lh={1.4}>Nama</Text>
|
||||
<Text fz="sm" fw={500} lh={1.5}>{item.nama}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz="sm" fw={600} lh={1.4}>Alamat</Text>
|
||||
<Text fz="sm" fw={500} lh={1.5}>{item.alamat}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz="sm" fw={600} lh={1.4}>NIK</Text>
|
||||
<Text fz="sm" fw={500} lh={1.5}>{item.nik}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<IconDeviceImacCog size={16} />}
|
||||
onClick={() =>
|
||||
router.push(`/admin/desa/layanan/ajukan_permohonan/${item.id}`)
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
Detail
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))
|
||||
) : (
|
||||
<Center py="xl">
|
||||
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||
Tidak ada data ajukan permohonan yang cocok
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
<Center>
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10, search);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
total={totalPages}
|
||||
mt="md"
|
||||
mb="md"
|
||||
color="blue"
|
||||
radius="md"
|
||||
/>
|
||||
</Center>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Center mt="md">
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10, search);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
total={totalPages}
|
||||
color="blue"
|
||||
radius="md"
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default AjukanPermohonan;
|
||||
export default AjukanPermohonan;
|
||||
@@ -1,10 +1,31 @@
|
||||
'use client'
|
||||
import { usePathname } from "next/navigation";
|
||||
import LayoutTabsLayanan from "../_com/layoutTabLayanan";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
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 (
|
||||
<LayoutTabsLayanan>
|
||||
<Box>
|
||||
{children}
|
||||
</LayoutTabsLayanan>
|
||||
)
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutTabsLayanan>
|
||||
{children}
|
||||
</LayoutTabsLayanan>
|
||||
);
|
||||
}
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
@@ -33,6 +33,14 @@ function EditPelayananPendudukNonPermanent() {
|
||||
deskripsi: '',
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
name: '',
|
||||
deskripsi: '',
|
||||
});
|
||||
|
||||
|
||||
// Load data sekali dari backend
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
@@ -46,6 +54,10 @@ function EditPelayananPendudukNonPermanent() {
|
||||
name: data.name || '',
|
||||
deskripsi: data.deskripsi || '',
|
||||
});
|
||||
setOriginalData({
|
||||
name: data.name || '',
|
||||
deskripsi: data.deskripsi || '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error);
|
||||
@@ -58,41 +70,55 @@ function EditPelayananPendudukNonPermanent() {
|
||||
|
||||
const handleChange =
|
||||
(field: keyof typeof formData) =>
|
||||
(value: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
(value: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
name: originalData.name,
|
||||
deskripsi: originalData.deskripsi,
|
||||
});
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!statePendudukNonPermanent.findById.data) return;
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
if (!statePendudukNonPermanent.findById.data) return;
|
||||
|
||||
// Update global state hanya di submit
|
||||
const updated = {
|
||||
...statePendudukNonPermanent.findById.data,
|
||||
name: formData.name,
|
||||
deskripsi: formData.deskripsi,
|
||||
};
|
||||
// Update global state hanya di submit
|
||||
const updated = {
|
||||
...statePendudukNonPermanent.findById.data,
|
||||
name: formData.name,
|
||||
deskripsi: formData.deskripsi,
|
||||
};
|
||||
|
||||
await statePendudukNonPermanent.update.update(updated);
|
||||
router.push('/admin/desa/layanan/pelayanan_penduduk_non_permanent');
|
||||
await statePendudukNonPermanent.update.update(updated);
|
||||
router.push('/admin/desa/layanan/pelayanan_penduduk_non_permanent');
|
||||
} catch (error) {
|
||||
console.error('Error updating data:', error);
|
||||
toast.error('Gagal memuat data pelayanan penduduk non permanent');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||
<Stack gap="xs">
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Pelayanan Penduduk Non Permanent
|
||||
</Title>
|
||||
@@ -130,25 +156,31 @@ function EditPelayananPendudukNonPermanent() {
|
||||
</Box>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Group>
|
||||
<Button
|
||||
bg={colors['blue-button']}
|
||||
onClick={handleSubmit}
|
||||
loading={statePendudukNonPermanent.update.loading}
|
||||
disabled={!formData.name}
|
||||
>
|
||||
{statePendudukNonPermanent.update.loading
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan Perubahan'}
|
||||
</Button>
|
||||
|
||||
<Group justify="right">
|
||||
{/* Tombol Batal */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
disabled={statePendudukNonPermanent.update.loading}
|
||||
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>
|
||||
|
||||
@@ -5,14 +5,12 @@ import {
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Grid,
|
||||
GridCol,
|
||||
Group,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconEdit } from '@tabler/icons-react';
|
||||
@@ -44,43 +42,42 @@ function PelayananPendudukNonPermanent() {
|
||||
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Grid align="center">
|
||||
<GridCol span={{ base: 12, md: 11 }}>
|
||||
<Title order={3} c={colors['blue-button']}>
|
||||
Preview Pelayanan Penduduk Non Permanen
|
||||
</Title>
|
||||
</GridCol>
|
||||
<GridCol span={{ base: 12, md: 1 }}>
|
||||
<Tooltip label="Edit Data Pelayanan" withArrow>
|
||||
<Button
|
||||
c="green"
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={18} stroke={2} />}
|
||||
radius="md"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/admin/desa/layanan/pelayanan_penduduk_non_permanent/${data.id}`
|
||||
)
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</GridCol>
|
||||
</Grid>
|
||||
<Group justify='space-between' align="center">
|
||||
|
||||
<Title
|
||||
order={3}
|
||||
lh={1.2}
|
||||
c={colors['blue-button']}
|
||||
>
|
||||
Preview Pelayanan Penduduk Non Permanen
|
||||
</Title>
|
||||
<Button
|
||||
c="green"
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={18} stroke={2} />}
|
||||
radius="md"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/admin/desa/layanan/pelayanan_penduduk_non_permanent/${data.id}`
|
||||
)
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Content */}
|
||||
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
|
||||
<Box px={{ base: 0, md: 50 }} pb="xl">
|
||||
<Center>
|
||||
<Text
|
||||
<Title
|
||||
order={2}
|
||||
lh={1.2}
|
||||
ta="center"
|
||||
fz={{ base: '1.2rem', md: '1.8rem' }}
|
||||
fw="bold"
|
||||
c={colors['blue-button']}
|
||||
>
|
||||
{data.name}
|
||||
</Text>
|
||||
</Title>
|
||||
</Center>
|
||||
|
||||
<Divider my="md" color={colors['blue-button']} />
|
||||
@@ -89,9 +86,11 @@ function PelayananPendudukNonPermanent() {
|
||||
<Text
|
||||
py={10}
|
||||
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 }}
|
||||
style={{wordBreak: "break-word", whiteSpace: "normal"}}
|
||||
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -101,4 +100,4 @@ function PelayananPendudukNonPermanent() {
|
||||
);
|
||||
}
|
||||
|
||||
export default PelayananPendudukNonPermanent;
|
||||
export default PelayananPendudukNonPermanent;
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
@@ -35,13 +35,21 @@ function EditPelayananPerizinanBerusaha() {
|
||||
link: '',
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [originalData, setOriginalData] = useState({
|
||||
id: '',
|
||||
name: '',
|
||||
deskripsi: '',
|
||||
link: '',
|
||||
});
|
||||
|
||||
// Load data detail
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
toast.error("ID tidak valid");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -53,6 +61,12 @@ function EditPelayananPerizinanBerusaha() {
|
||||
deskripsi: data.deskripsi || "",
|
||||
link: data.link || "",
|
||||
});
|
||||
setOriginalData({
|
||||
id: data.id,
|
||||
name: data.name || "",
|
||||
deskripsi: data.deskripsi || "",
|
||||
link: data.link || "",
|
||||
});
|
||||
} else {
|
||||
toast.error("Data tidak ditemukan");
|
||||
}
|
||||
@@ -63,10 +77,10 @@ function EditPelayananPerizinanBerusaha() {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadData();
|
||||
}, [id]);
|
||||
|
||||
|
||||
|
||||
const handleChange =
|
||||
(field: keyof typeof formData) =>
|
||||
@@ -77,13 +91,26 @@ function EditPelayananPerizinanBerusaha() {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
id: originalData.id,
|
||||
name: originalData.name,
|
||||
deskripsi: originalData.deskripsi,
|
||||
link: originalData.link,
|
||||
});
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await state.update.update(formData);
|
||||
router.push('/admin/desa/layanan/pelayanan_perizinan_berusaha');
|
||||
} catch (error) {
|
||||
console.error('Error updating pelayanan perizinan berusaha:', error);
|
||||
toast.error('Terjadi kesalahan saat update data');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,20 +123,18 @@ function EditPelayananPerizinanBerusaha() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
<Stack gap="xs">
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Pelayanan Perizinan Berusaha
|
||||
</Title>
|
||||
@@ -150,23 +175,31 @@ function EditPelayananPerizinanBerusaha() {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
bg={colors['blue-button']}
|
||||
onClick={handleSubmit}
|
||||
loading={state.update.loading}
|
||||
disabled={!formData.name}
|
||||
>
|
||||
{state.update.loading ? 'Menyimpan...' : 'Simpan Perubahan'}
|
||||
</Button>
|
||||
|
||||
<Group justify="right">
|
||||
{/* Tombol Batal */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
disabled={state.update.loading}
|
||||
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>
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Grid,
|
||||
GridCol,
|
||||
Group,
|
||||
Paper,
|
||||
Skeleton,
|
||||
@@ -16,14 +14,13 @@ import {
|
||||
StepperCompleted,
|
||||
StepperStep,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconEdit } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import stateLayananDesa from '../../../_state/desa/layananDesa';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import stateLayananDesa from '../../../_state/desa/layananDesa';
|
||||
|
||||
function PerizinanBerusaha() {
|
||||
const router = useRouter();
|
||||
@@ -42,8 +39,7 @@ function PerizinanBerusaha() {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// You should get the ID from your router query or params
|
||||
const id = 'edit'; // Replace with actual ID or get from URL params
|
||||
const id = 'edit';
|
||||
await pelayananPerizinanBerusaha.findById.load(id);
|
||||
} catch (err) {
|
||||
setError('Gagal memuat data');
|
||||
@@ -67,7 +63,7 @@ function PerizinanBerusaha() {
|
||||
if (error || !pelayananPerizinanBerusaha.findById.data) {
|
||||
return (
|
||||
<Center h={200}>
|
||||
<Text>{error || 'Data tidak ditemukan'}</Text>
|
||||
<Text c="dimmed">{error || 'Data tidak ditemukan'}</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -78,67 +74,63 @@ function PerizinanBerusaha() {
|
||||
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Grid align="center">
|
||||
<GridCol span={{ base: 12, md: 11 }}>
|
||||
<Title order={3} c={colors['blue-button']}>
|
||||
Preview Pelayanan Perizinan Berusaha
|
||||
</Title>
|
||||
</GridCol>
|
||||
<GridCol span={{ base: 12, md: 1 }}>
|
||||
<Tooltip label="Edit Data Perizinan" withArrow>
|
||||
<Button
|
||||
c="green"
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={18} stroke={2} />}
|
||||
radius="md"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/admin/desa/layanan/pelayanan_perizinan_berusaha/${data.id}`
|
||||
)
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</GridCol>
|
||||
</Grid>
|
||||
<Group justify='space-between' align="center">
|
||||
<Title order={3} c={colors['blue-button']} lh={1.2}>
|
||||
Preview Pelayanan Perizinan Berusaha
|
||||
</Title>
|
||||
<Button
|
||||
c="green"
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={18} stroke={2} />}
|
||||
radius="md"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/admin/desa/layanan/pelayanan_perizinan_berusaha/${data.id}`
|
||||
)
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Content */}
|
||||
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
|
||||
<Box px={{ base: 0, md: 50 }} pb="xl">
|
||||
<Center>
|
||||
<Text
|
||||
<Title
|
||||
order={3}
|
||||
ta="center"
|
||||
fz={{ base: '1.2rem', md: '1.8rem' }}
|
||||
fw="bold"
|
||||
c={colors['blue-button']}
|
||||
lh={1.15}
|
||||
>
|
||||
{data.name}
|
||||
</Text>
|
||||
</Title>
|
||||
</Center>
|
||||
|
||||
<Divider my="md" color={colors['blue-button']} />
|
||||
|
||||
<Box mt="lg">
|
||||
<Text
|
||||
py={10}
|
||||
ta="justify"
|
||||
fz={{ base: '1rem', md: '1.2rem' }}
|
||||
py="xs"
|
||||
ta={{ base: "left", md: "justify" }}
|
||||
fz={{ base: 'sm', md: 'md' }}
|
||||
lh={1.55}
|
||||
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
|
||||
dangerouslySetInnerHTML={{ __html: data.deskripsi }}
|
||||
style={{wordBreak: "break-word", whiteSpace: "normal"}}
|
||||
/>
|
||||
|
||||
<Text
|
||||
py={10}
|
||||
fz={{ base: '1rem', md: '1.2rem' }}
|
||||
fw="bold"
|
||||
py="xs"
|
||||
fz={{ base: 'sm', md: 'md' }}
|
||||
fw={700}
|
||||
c={colors['blue-button']}
|
||||
lh={1.5}
|
||||
>
|
||||
Proses pendaftaran NIB melalui OSS mencakup beberapa langkah
|
||||
umum:
|
||||
</Text>
|
||||
|
||||
<Box p="xl" w="100%">
|
||||
<Box p="xl" w="100%" visibleFrom='md'>
|
||||
<Stepper
|
||||
active={active}
|
||||
onStepClick={setActive}
|
||||
@@ -146,28 +138,115 @@ function PerizinanBerusaha() {
|
||||
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">
|
||||
Pendaftaran akun pada portal OSS
|
||||
<Text fz="sm" lh={1.5}>
|
||||
Pendaftaran akun pada portal OSS
|
||||
</Text>
|
||||
</StepperStep>
|
||||
<StepperStep label="Langkah Kedua" description="Pengisian Data Perusahaan">
|
||||
Mengisi informasi perusahaan, termasuk data pemegang saham, alamat perusahaan, dan lainnya
|
||||
<Text fz="sm" lh={1.5}>
|
||||
Mengisi informasi perusahaan, termasuk data pemegang saham, alamat perusahaan, dan lainnya
|
||||
</Text>
|
||||
</StepperStep>
|
||||
<StepperStep label="Langkah Ketiga" description="Pemilihan KBLI">
|
||||
Memilih KBLI dengan jenis usaha yang akan didaftarkan
|
||||
<Text fz="sm" lh={1.5}>
|
||||
Memilih KBLI dengan jenis usaha yang akan didaftarkan
|
||||
</Text>
|
||||
</StepperStep>
|
||||
<StepperStep label="Langkah Keempat" description="Pengunggahan Dokumen">
|
||||
Mengunggah dokumen-dokumen yang diperlukan, seperti akta pendirian perusahaan, surat izin usaha, dan dokumen lainnya sesuai dengan ketentuan yang berlaku
|
||||
<Text fz="sm" lh={1.5}>
|
||||
Mengunggah dokumen-dokumen yang diperlukan, seperti akta pendirian perusahaan, surat izin usaha, dan dokumen lainnya sesuai dengan ketentuan yang berlaku
|
||||
</Text>
|
||||
</StepperStep>
|
||||
<StepperStep label="Langkah Kelima" description="Verifikasi dan Persetujuan">
|
||||
Proses verifikasi dan persetujuan oleh instansi terkait
|
||||
<Text fz="sm" lh={1.5}>
|
||||
Proses verifikasi dan persetujuan oleh instansi terkait
|
||||
</Text>
|
||||
</StepperStep>
|
||||
<StepperStep label="Langkah Keenam" description="Penerimaan NIB">
|
||||
Jika proses sebelumnya berhasil, perusahaan akan menerima NIB sebagai identitas resmi usaha anda
|
||||
<Text fz="sm" lh={1.5}>
|
||||
Jika proses sebelumnya berhasil, perusahaan akan menerima NIB sebagai identitas resmi usaha anda
|
||||
</Text>
|
||||
</StepperStep>
|
||||
<StepperCompleted>
|
||||
Selesai, anda telah mengikuti proses pendaftaran NIB melalui OSS
|
||||
<Text fz="sm" lh={1.5}>
|
||||
Selesai, anda telah mengikuti proses pendaftaran NIB melalui OSS
|
||||
</Text>
|
||||
</StepperCompleted>
|
||||
</Stepper>
|
||||
|
||||
<Group justify="center" mt="xl">
|
||||
<Button variant="default" onClick={prevStep}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={nextStep}>Next step</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Box p="xl" w="100%" hiddenFrom='md'>
|
||||
<Stepper
|
||||
active={active}
|
||||
onStepClick={setActive}
|
||||
orientation="vertical"
|
||||
styles={{
|
||||
separator: { marginLeft: 25 },
|
||||
step: { padding: '12px 0' },
|
||||
stepLabel: {
|
||||
fontSize: 'var(--mantine-font-size-sm)',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
stepDescription: {
|
||||
fontSize: 'var(--mantine-font-size-xs)',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<StepperStep label="Langkah Pertama" description="Pendaftaran Akun">
|
||||
<Text fz="sm" lh={1.5}>
|
||||
|
||||
</Text>
|
||||
</StepperStep>
|
||||
<StepperStep label="Langkah Kedua" description="Pengisian Data Perusahaan">
|
||||
<Text fz="sm" lh={1.5}>
|
||||
|
||||
</Text>
|
||||
</StepperStep>
|
||||
<StepperStep label="Langkah Ketiga" description="Pemilihan KBLI">
|
||||
<Text fz="sm" lh={1.5}>
|
||||
|
||||
</Text>
|
||||
</StepperStep>
|
||||
<StepperStep label="Langkah Keempat" description="Pengunggahan Dokumen">
|
||||
<Text fz="sm" lh={1.5}>
|
||||
|
||||
</Text>
|
||||
</StepperStep>
|
||||
<StepperStep label="Langkah Kelima" description="Verifikasi dan Persetujuan">
|
||||
<Text fz="sm" lh={1.5}>
|
||||
|
||||
</Text>
|
||||
</StepperStep>
|
||||
<StepperStep label="Langkah Keenam" description="Penerimaan NIB">
|
||||
<Text fz="sm" lh={1.5}>
|
||||
|
||||
</Text>
|
||||
</StepperStep>
|
||||
<StepperCompleted>
|
||||
<Text fz="sm" lh={1.5}>
|
||||
|
||||
</Text>
|
||||
</StepperCompleted>
|
||||
</Stepper>
|
||||
|
||||
@@ -180,9 +259,10 @@ function PerizinanBerusaha() {
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
py={35}
|
||||
ta="justify"
|
||||
fz={{ base: '1rem', md: '1.2rem' }}
|
||||
py="md"
|
||||
ta={{ base: "left", md: "justify" }}
|
||||
fz={{ base: 'sm', md: 'md' }}
|
||||
lh={1.55}
|
||||
>
|
||||
Penting untuk diingat bahwa prosedur dan persyaratan dapat
|
||||
berubah seiring waktu. Untuk informasi yang lebih akurat dan
|
||||
@@ -206,5 +286,4 @@ function PerizinanBerusaha() {
|
||||
);
|
||||
}
|
||||
|
||||
export default PerizinanBerusaha;
|
||||
|
||||
export default PerizinanBerusaha;
|
||||
@@ -1,127 +1,280 @@
|
||||
'use client'
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
'use client';
|
||||
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
|
||||
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
|
||||
import colors from '@/con/colors';
|
||||
import ApiFetch from '@/lib/api-fetch';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} 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, useCallback } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import * as React from 'react';
|
||||
|
||||
// 🔹 Types
|
||||
interface FormData {
|
||||
name: string;
|
||||
deskripsi: string;
|
||||
imageId: string;
|
||||
image2Id: string;
|
||||
imageUrl: string;
|
||||
image2Url: string;
|
||||
}
|
||||
|
||||
interface FileUploaderProps {
|
||||
title: string;
|
||||
file: File | null;
|
||||
setFile: React.Dispatch<React.SetStateAction<File | null>>;
|
||||
preview: string | null;
|
||||
setPreview: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
}
|
||||
|
||||
// 🔹 File Uploader Component
|
||||
const FileUploader: React.FC<FileUploaderProps> = ({
|
||||
title,
|
||||
file,
|
||||
setFile,
|
||||
preview,
|
||||
setPreview
|
||||
}) => {
|
||||
const handleDrop = (files: File[]) => {
|
||||
const selected = files[0];
|
||||
if (selected) {
|
||||
setFile(selected);
|
||||
setPreview(URL.createObjectURL(selected));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
setPreview(null);
|
||||
setFile(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
{title}
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={handleDrop}
|
||||
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={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 .png, .jpg, .jpeg, .webp
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
|
||||
{preview && (
|
||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={preview}
|
||||
alt="Preview Gambar"
|
||||
radius="md"
|
||||
style={{
|
||||
maxHeight: 200,
|
||||
objectFit: 'contain',
|
||||
border: '1px solid #ddd',
|
||||
}}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={handleRemove}
|
||||
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 🔹 Main Component
|
||||
function EditSuratKeterangan() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const stateSurat = useProxy(stateLayananDesa.suratKeterangan);
|
||||
|
||||
// state lokal untuk form
|
||||
const [formData, setFormData] = useState({
|
||||
// 🧩 State
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
name: '',
|
||||
deskripsi: '',
|
||||
imageId: '',
|
||||
image2Id: '',
|
||||
imageUrl: '',
|
||||
image2Url: '',
|
||||
});
|
||||
|
||||
// state file upload
|
||||
const [originalData, setOriginalData] = useState<FormData>(formData);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [file2, setFile2] = useState<File | null>(null);
|
||||
|
||||
// state preview gambar
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [previewImage2, setPreviewImage2] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// load data awal
|
||||
// 🧭 Load Initial Data
|
||||
useEffect(() => {
|
||||
const loadSurat = async () => {
|
||||
const id = params?.id as string;
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const data = await stateSurat.edit.load(id);
|
||||
const data = await stateLayananDesa.suratKeterangan.edit.load(id);
|
||||
if (!data) return;
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
...{
|
||||
name: prev.name || data.name || "",
|
||||
deskripsi: prev.deskripsi || data.deskripsi || "",
|
||||
imageId: prev.imageId || data.imageId || "",
|
||||
image2Id: prev.image2Id || data.image2Id || "",
|
||||
},
|
||||
}));
|
||||
const mapped: FormData = {
|
||||
name: data.name || '',
|
||||
deskripsi: data.deskripsi || '',
|
||||
imageId: data.imageId || '',
|
||||
image2Id: data.image2Id || '',
|
||||
imageUrl: data.image?.link || '',
|
||||
image2Url: data.image2?.link || ''
|
||||
};
|
||||
|
||||
if (data.image?.link && !previewImage) setPreviewImage(data.image.link);
|
||||
if (data.image2?.link && !previewImage2) setPreviewImage2(data.image2.link);
|
||||
setFormData(mapped);
|
||||
setOriginalData(mapped);
|
||||
|
||||
if (data.image?.link) setPreviewImage(data.image.link);
|
||||
if (data.image2?.link) setPreviewImage2(data.image2.link);
|
||||
} catch (error) {
|
||||
console.error("Error loading surat:", error);
|
||||
toast.error("Gagal memuat data surat");
|
||||
console.error('Error loading surat:', error);
|
||||
toast.error('Gagal memuat data surat');
|
||||
}
|
||||
};
|
||||
|
||||
loadSurat();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [params?.id]);
|
||||
|
||||
// 📤 Upload File Helper
|
||||
const uploadFile = async (file: File): Promise<string | null> => {
|
||||
try {
|
||||
const res = await ApiFetch.api.fileStorage.create.post({
|
||||
file,
|
||||
name: file.name,
|
||||
});
|
||||
const uploaded = res.data?.data;
|
||||
return uploaded?.id || null;
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 🔁 Reset Form
|
||||
const handleResetForm = () => {
|
||||
setFormData(originalData);
|
||||
setPreviewImage(originalData.imageUrl || null);
|
||||
setPreviewImage2(originalData.image2Url || null);
|
||||
setFile(null);
|
||||
setFile2(null);
|
||||
toast.info('Form dikembalikan ke data awal');
|
||||
};
|
||||
|
||||
// handler untuk submit
|
||||
// 💾 Submit Handler
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
// update form global hanya saat submit
|
||||
stateSurat.edit.form = { ...stateSurat.edit.form, ...formData };
|
||||
setIsSubmitting(true);
|
||||
|
||||
// upload file 1
|
||||
// ✅ Access original state directly (not proxy)
|
||||
const originalState = stateLayananDesa.suratKeterangan;
|
||||
|
||||
// Update form data properties individually
|
||||
originalState.edit.form.name = formData.name;
|
||||
originalState.edit.form.deskripsi = formData.deskripsi;
|
||||
originalState.edit.form.imageId = formData.imageId;
|
||||
originalState.edit.form.image2Id = formData.image2Id;
|
||||
|
||||
// Upload file 1 if exists
|
||||
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');
|
||||
stateSurat.edit.form.imageId = uploaded.id;
|
||||
const uploadedId = await uploadFile(file);
|
||||
if (!uploadedId) {
|
||||
toast.error('Gagal upload gambar pertama');
|
||||
return;
|
||||
}
|
||||
originalState.edit.form.imageId = uploadedId;
|
||||
}
|
||||
|
||||
// upload file 2
|
||||
// Upload file 2 if exists
|
||||
if (file2) {
|
||||
const res = await ApiFetch.api.fileStorage.create.post({ file: file2, name: file2.name });
|
||||
const uploaded = res.data?.data;
|
||||
if (!uploaded?.id) return toast.error('Gagal upload gambar');
|
||||
stateSurat.edit.form.image2Id = uploaded.id;
|
||||
const uploadedId = await uploadFile(file2);
|
||||
if (!uploadedId) {
|
||||
toast.error('Gagal upload gambar kedua');
|
||||
return;
|
||||
}
|
||||
originalState.edit.form.image2Id = uploadedId;
|
||||
}
|
||||
|
||||
await stateSurat.edit.update();
|
||||
// Submit update
|
||||
await originalState.edit.update();
|
||||
toast.success('Surat berhasil diperbarui!');
|
||||
router.push('/admin/desa/layanan/pelayanan_surat_keterangan');
|
||||
} catch (error) {
|
||||
console.error('Error updating surat:', error);
|
||||
toast.error('Terjadi kesalahan saat memperbarui surat');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [formData, file, file2, router, stateSurat.edit]);
|
||||
}, [formData, file, file2, router]);
|
||||
|
||||
// 📝 Form Field Handlers
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData(prev => ({ ...prev, name: e.target.value }));
|
||||
};
|
||||
|
||||
const handleDeskripsiChange = (html: string) => {
|
||||
setFormData(prev => ({ ...prev, deskripsi: html }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
{/* Back Button */}
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Surat Keterangan
|
||||
</Title>
|
||||
</Group>
|
||||
|
||||
{/* Form */}
|
||||
<Paper
|
||||
w={{ base: '100%', md: '50%' }}
|
||||
bg={colors['white-1']}
|
||||
@@ -131,154 +284,66 @@ function EditSuratKeterangan() {
|
||||
style={{ border: '1px solid #e0e0e0' }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Input nama */}
|
||||
{/* Nama Surat */}
|
||||
<TextInput
|
||||
label="Nama Surat Keterangan"
|
||||
placeholder="Masukkan nama surat keterangan"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||
onChange={handleNameChange}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Input deskripsi */}
|
||||
{/* Deskripsi */}
|
||||
<Box>
|
||||
<Text fz="sm" fw="bold" mb={6}>
|
||||
Konten
|
||||
</Text>
|
||||
<EditEditor
|
||||
value={formData.deskripsi}
|
||||
onChange={(htmlContent) =>
|
||||
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }))
|
||||
}
|
||||
onChange={handleDeskripsiChange}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Upload Gambar 1 */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar Konten Pelayanan
|
||||
</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 wajib
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
{/* Gambar 1 */}
|
||||
<FileUploader
|
||||
title="Gambar Konten Pelayanan"
|
||||
file={file}
|
||||
setFile={setFile}
|
||||
preview={previewImage}
|
||||
setPreview={setPreviewImage}
|
||||
/>
|
||||
|
||||
{previewImage && (
|
||||
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview Gambar 1"
|
||||
radius="md"
|
||||
style={{
|
||||
maxHeight: 220,
|
||||
objectFit: 'contain',
|
||||
border: `1px solid ${colors['blue-button']}`,
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Upload Gambar 2 */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar Alur Pelayanan Surat
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
const selectedFile = files[0];
|
||||
if (selectedFile) {
|
||||
setFile2(selectedFile);
|
||||
setPreviewImage2(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 wajib
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
|
||||
{previewImage2 && (
|
||||
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Image
|
||||
src={previewImage2}
|
||||
alt="Preview Gambar 2"
|
||||
radius="md"
|
||||
style={{
|
||||
maxHeight: 220,
|
||||
objectFit: 'contain',
|
||||
border: `1px solid ${colors['blue-button']}`,
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{/* Gambar 2 */}
|
||||
<FileUploader
|
||||
title="Gambar Alur Pelayanan Surat"
|
||||
file={file2}
|
||||
setFile={setFile2}
|
||||
preview={previewImage2}
|
||||
setPreview={setPreviewImage2}
|
||||
/>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
onClick={handleResetForm}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
size="md"
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
boxShadow: '0 4px 15px rgba(79,172,254,0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -287,4 +352,4 @@ function EditSuratKeterangan() {
|
||||
);
|
||||
}
|
||||
|
||||
export default EditSuratKeterangan;
|
||||
export default EditSuratKeterangan;
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
@@ -50,7 +49,7 @@ function DetailSuratKeterangan() {
|
||||
const data = suratKeteranganState.findUnique.data;
|
||||
|
||||
return (
|
||||
<Box py={10}>
|
||||
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||
{/* Tombol Kembali */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
@@ -63,7 +62,7 @@ function DetailSuratKeterangan() {
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
w={{ base: '100%', md: '60%' }}
|
||||
w={{ base: '100%', md: '70%' }}
|
||||
bg={colors['white-1']}
|
||||
p="lg"
|
||||
radius="md"
|
||||
@@ -76,20 +75,21 @@ function DetailSuratKeterangan() {
|
||||
|
||||
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
|
||||
<Stack gap="sm">
|
||||
<Box>
|
||||
<Stack gap={"xs"}>
|
||||
<Text fz="lg" fw="bold">
|
||||
Nama
|
||||
</Text>
|
||||
<Text fz="md" c="dimmed">
|
||||
{data?.name || '-'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Stack gap={"xs"}>
|
||||
<Text fz="lg" fw="bold">
|
||||
Deskripsi
|
||||
</Text>
|
||||
<Text
|
||||
<Box pl={10}>
|
||||
<Text
|
||||
fz="md"
|
||||
c="dimmed"
|
||||
dangerouslySetInnerHTML={{
|
||||
@@ -97,9 +97,10 @@ function DetailSuratKeterangan() {
|
||||
}}
|
||||
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Stack gap={"xs"}>
|
||||
<Text fz="lg" fw="bold">
|
||||
Gambar Konten Pelayanan
|
||||
</Text>
|
||||
@@ -118,7 +119,7 @@ function DetailSuratKeterangan() {
|
||||
Tidak ada gambar
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">
|
||||
@@ -142,7 +143,6 @@ function DetailSuratKeterangan() {
|
||||
</Box>
|
||||
|
||||
<Group gap="sm">
|
||||
<Tooltip label="Hapus Surat" withArrow position="top">
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
@@ -156,9 +156,7 @@ function DetailSuratKeterangan() {
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Edit Surat" withArrow position="top">
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() =>
|
||||
@@ -172,7 +170,6 @@ function DetailSuratKeterangan() {
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -5,16 +5,17 @@ import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
|
||||
import colors from '@/con/colors';
|
||||
import ApiFetch from '@/lib/api-fetch';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
||||
@@ -28,6 +29,7 @@ function CreateSuratKeterangan() {
|
||||
const [previewImage2, setPreviewImage2] = useState<{ preview: string; file: File } | null>(null);
|
||||
const [previewImage, setPreviewImage] = useState<{ preview: string; file: File } | null>(null);
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
stateSurat.create.form = {
|
||||
@@ -46,6 +48,7 @@ function CreateSuratKeterangan() {
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
// Upload gambar utama
|
||||
const res1 = await ApiFetch.api.fileStorage.create.post({
|
||||
file: previewImage.file,
|
||||
@@ -78,18 +81,18 @@ function CreateSuratKeterangan() {
|
||||
} catch (error) {
|
||||
console.error('Error creating surat keterangan:', error);
|
||||
toast.error('Terjadi kesalahan saat menambahkan surat keterangan');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Surat Keterangan
|
||||
</Title>
|
||||
@@ -106,7 +109,7 @@ function CreateSuratKeterangan() {
|
||||
<Stack gap="md">
|
||||
{/* Nama Surat */}
|
||||
<TextInput
|
||||
defaultValue={stateSurat.create.form.name}
|
||||
value={stateSurat.create.form.name}
|
||||
onChange={(val) => (stateSurat.create.form.name = val.target.value)}
|
||||
label="Nama Surat Keterangan"
|
||||
placeholder="Masukkan nama surat keterangan"
|
||||
@@ -143,7 +146,7 @@ function CreateSuratKeterangan() {
|
||||
}}
|
||||
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||
maxSize={5 * 1024 ** 2}
|
||||
accept={{ 'image/*': [] }}
|
||||
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||
radius="md"
|
||||
p="xl"
|
||||
>
|
||||
@@ -164,7 +167,7 @@ function CreateSuratKeterangan() {
|
||||
</Dropzone>
|
||||
|
||||
{previewImage && (
|
||||
<Box mt="sm" style={{ textAlign: 'center' }}>
|
||||
<Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewImage.preview}
|
||||
alt="Preview Gambar Utama"
|
||||
@@ -172,6 +175,23 @@ function CreateSuratKeterangan() {
|
||||
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={() => {
|
||||
setPreviewImage(null);
|
||||
}}
|
||||
style={{
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
@@ -193,7 +213,7 @@ function CreateSuratKeterangan() {
|
||||
}}
|
||||
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||
maxSize={5 * 1024 ** 2}
|
||||
accept={{ 'image/*': [] }}
|
||||
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||
radius="md"
|
||||
p="xl"
|
||||
>
|
||||
@@ -214,7 +234,7 @@ function CreateSuratKeterangan() {
|
||||
</Dropzone>
|
||||
|
||||
{previewImage2 ? (
|
||||
<Box mt="sm" style={{ textAlign: 'center' }}>
|
||||
<Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewImage2.preview}
|
||||
alt="Preview Gambar Tambahan"
|
||||
@@ -222,6 +242,23 @@ function CreateSuratKeterangan() {
|
||||
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={() => {
|
||||
setPreviewImage2(null);
|
||||
}}
|
||||
style={{
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" mt="sm" ta="center">
|
||||
@@ -232,6 +269,17 @@ function CreateSuratKeterangan() {
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={resetForm}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
@@ -242,7 +290,7 @@ function CreateSuratKeterangan() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@@ -26,9 +25,10 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import HeaderSearch from '../../../_com/header';
|
||||
import stateLayananDesa from '../../../_state/desa/layananDesa';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
|
||||
function SuratKeterangan() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [search, setSearch] = useState('');
|
||||
return (
|
||||
<Box>
|
||||
<HeaderSearch
|
||||
@@ -46,6 +46,7 @@ function SuratKeterangan() {
|
||||
function ListSuratKeterangan({ search }: { search: string }) {
|
||||
const suratKeteranganState = useProxy(stateLayananDesa.suratKeterangan);
|
||||
const router = useRouter();
|
||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -56,74 +57,80 @@ function ListSuratKeterangan({ search }: { search: string }) {
|
||||
} = suratKeteranganState.findMany;
|
||||
|
||||
useEffect(() => {
|
||||
load(page, 10, search);
|
||||
}, [page, search]);
|
||||
load(page, 10, debouncedSearch);
|
||||
}, [page, debouncedSearch]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const keyword = search.toLowerCase();
|
||||
return data.filter(item =>
|
||||
item.name?.toLowerCase().includes(keyword) ||
|
||||
item.deskripsi?.toLowerCase().includes(keyword)
|
||||
const keyword = debouncedSearch.toLowerCase();
|
||||
return data.filter(
|
||||
(item) =>
|
||||
item.name?.toLowerCase().includes(keyword) ||
|
||||
item.deskripsi?.toLowerCase().includes(keyword)
|
||||
);
|
||||
}, [data, search]);
|
||||
}, [data, debouncedSearch]);
|
||||
|
||||
// Loading state
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<Stack py={10}>
|
||||
<Stack py={{ base: 'sm', md: 'md' }}>
|
||||
<Skeleton height={600} radius="md" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box py={10}>
|
||||
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>List Surat Keterangan</Title>
|
||||
<Tooltip label="Tambah Surat Keterangan" withArrow>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
router.push('/admin/desa/layanan/pelayanan_surat_keterangan/create')
|
||||
}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Box py={{ base: 'sm', md: 'md' }}>
|
||||
<Paper withBorder bg={colors['white-1']} p={{ base: 'sm', md: 'lg' }} shadow="md" radius="md">
|
||||
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
|
||||
<Title order={4} lh={1.2}>
|
||||
List Surat Keterangan
|
||||
</Title>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
router.push('/admin/desa/layanan/pelayanan_surat_keterangan/create')
|
||||
}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Group>
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
|
||||
{/* Desktop Table */}
|
||||
<Box visibleFrom="md">
|
||||
<Table highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh style={{ width: '30%' }}>Nama</TableTh>
|
||||
<TableTh style={{ width: '45%' }}>Deskripsi</TableTh>
|
||||
<TableTh style={{ width: '15%' }}>Aksi</TableTh>
|
||||
<TableTh fz="sm" fw={600} ta="left">
|
||||
Nama
|
||||
</TableTh>
|
||||
<TableTh fz="sm" fw={600} ta="left">
|
||||
Deskripsi
|
||||
</TableTh>
|
||||
<TableTh fz="sm" fw={600} ta="left">
|
||||
Aksi
|
||||
</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd style={{ width: '30%' }}>
|
||||
<Box w={200}>
|
||||
<Text fw={500} truncate="end" lineClamp={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Box>
|
||||
<TableTd>
|
||||
<Text fz="md" fw={500} lh={1.5} truncate="end">
|
||||
{item.name}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd style={{ width: '45%' }}>
|
||||
<Box w={200}>
|
||||
<Text truncate="end" lineClamp={1} fz="sm" c="dimmed"
|
||||
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
|
||||
style={{wordBreak: "break-word", whiteSpace: "normal"}}
|
||||
/>
|
||||
</Box>
|
||||
<TableTd>
|
||||
<Text
|
||||
fz="sm"
|
||||
lh={1.5}
|
||||
dangerouslySetInnerHTML={{ __html: item.deskripsi || '' }}
|
||||
style={{ wordBreak: 'break-word' }}
|
||||
/>
|
||||
</TableTd>
|
||||
<TableTd style={{ width: '15%' }}>
|
||||
<TableTd>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
@@ -131,7 +138,9 @@ function ListSuratKeterangan({ search }: { search: string }) {
|
||||
color="blue"
|
||||
leftSection={<IconDeviceImacCog size={16} />}
|
||||
onClick={() =>
|
||||
router.push(`/admin/desa/layanan/pelayanan_surat_keterangan/${item.id}`)
|
||||
router.push(
|
||||
`/admin/desa/layanan/pelayanan_surat_keterangan/${item.id}`
|
||||
)
|
||||
}
|
||||
>
|
||||
Detail
|
||||
@@ -142,8 +151,10 @@ function ListSuratKeterangan({ search }: { search: string }) {
|
||||
) : (
|
||||
<TableTr>
|
||||
<TableTd colSpan={3}>
|
||||
<Center py={20}>
|
||||
<Text color="dimmed">Tidak ada data surat keterangan yang cocok</Text>
|
||||
<Center py="xl">
|
||||
<Text c="dimmed" fz="sm" ta="center">
|
||||
Tidak ada data surat keterangan yang cocok
|
||||
</Text>
|
||||
</Center>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
@@ -151,7 +162,67 @@ function ListSuratKeterangan({ search }: { search: string }) {
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<Box hiddenFrom="md">
|
||||
<Stack gap="sm">
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((item) => (
|
||||
<Paper key={item.id} withBorder p="sm" radius="md">
|
||||
<Stack gap={'xs'}>
|
||||
<Box>
|
||||
<Text fz="sm" fw={600} lh={1.4}>
|
||||
Nama
|
||||
</Text>
|
||||
<Text fz="sm" fw={500} lh={1.4}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz="sm" fw={600} lh={1.4}>
|
||||
Deskripsi
|
||||
</Text>
|
||||
<Box pl={8}>
|
||||
<Text
|
||||
fz="sm"
|
||||
fw={500}
|
||||
lh={1.4}
|
||||
dangerouslySetInnerHTML={{ __html: item.deskripsi || '' }}
|
||||
style={{ wordBreak: 'break-word' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Button
|
||||
fullWidth
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<IconDeviceImacCog size={16} />}
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/admin/desa/layanan/pelayanan_surat_keterangan/${item.id}`
|
||||
)
|
||||
}
|
||||
>
|
||||
Detail
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))
|
||||
) : (
|
||||
<Center py="xl">
|
||||
<Text c="dimmed" fz="sm" ta="center">
|
||||
Tidak ada data surat keterangan yang cocok
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Center>
|
||||
<Pagination
|
||||
value={page}
|
||||
@@ -170,4 +241,4 @@ function ListSuratKeterangan({ search }: { search: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default SuratKeterangan;
|
||||
export default SuratKeterangan;
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
@@ -22,6 +22,7 @@ function EditPelayananTelunjukSakti() {
|
||||
const stateTelunjukDesa = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
@@ -29,6 +30,12 @@ function EditPelayananTelunjukSakti() {
|
||||
link: '',
|
||||
});
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
name: '',
|
||||
deskripsi: '',
|
||||
link: '',
|
||||
});
|
||||
|
||||
// Load data awal hanya sekali (pas ada id)
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
@@ -43,6 +50,11 @@ function EditPelayananTelunjukSakti() {
|
||||
deskripsi: data.deskripsi ?? '',
|
||||
link: data.link ?? '',
|
||||
});
|
||||
setOriginalData({
|
||||
name: data.name ?? '',
|
||||
deskripsi: data.deskripsi ?? '',
|
||||
link: data.link ?? '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading pelayanan telunjuk sakti:', error);
|
||||
@@ -61,9 +73,19 @@ function EditPelayananTelunjukSakti() {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
name: originalData.name,
|
||||
deskripsi: originalData.deskripsi,
|
||||
link: originalData.link,
|
||||
});
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
// Submit: update global state hanya saat simpan
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
stateTelunjukDesa.edit.form = {
|
||||
...stateTelunjukDesa.edit.form,
|
||||
...formData,
|
||||
@@ -74,18 +96,18 @@ function EditPelayananTelunjukSakti() {
|
||||
} catch (error) {
|
||||
console.error('Error updating pelayanan telunjuk sakti:', error);
|
||||
toast.error('Terjadi kesalahan saat memperbarui pelayanan telunjuk sakti');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
{/* Back Button + Title */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Pelayanan Telunjuk Sakti Desa
|
||||
</Title>
|
||||
@@ -128,6 +150,17 @@ function EditPelayananTelunjukSakti() {
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={handleResetForm}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
@@ -138,7 +171,7 @@ function EditPelayananTelunjukSakti() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user