Compare commits
64 Commits
main
...
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 |
15
package.json
15
package.json
@@ -3,9 +3,9 @@
|
|||||||
"version": "0.1.5",
|
"version": "0.1.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun --bun next dev",
|
"dev": "next dev",
|
||||||
"build": "bun --bun next build",
|
"build": "next build",
|
||||||
"start": "bun --bun next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "bun run prisma/seed.ts"
|
"seed": "bun run prisma/seed.ts"
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"@elysiajs/static": "^1.3.0",
|
"@elysiajs/static": "^1.3.0",
|
||||||
"@elysiajs/stream": "^1.1.0",
|
"@elysiajs/stream": "^1.1.0",
|
||||||
"@elysiajs/swagger": "^1.2.0",
|
"@elysiajs/swagger": "^1.2.0",
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
"@mantine/carousel": "^7.16.2",
|
"@mantine/carousel": "^7.16.2",
|
||||||
"@mantine/charts": "^7.17.1",
|
"@mantine/charts": "^7.17.1",
|
||||||
"@mantine/core": "^7.17.4",
|
"@mantine/core": "^7.17.4",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"@mantine/dropzone": "^8.1.1",
|
"@mantine/dropzone": "^8.1.1",
|
||||||
"@mantine/form": "^8.1.0",
|
"@mantine/form": "^8.1.0",
|
||||||
"@mantine/hooks": "^7.17.4",
|
"@mantine/hooks": "^7.17.4",
|
||||||
|
"@mantine/modals": "^8.3.6",
|
||||||
"@mantine/tiptap": "^7.17.4",
|
"@mantine/tiptap": "^7.17.4",
|
||||||
"@paljs/types": "^8.1.0",
|
"@paljs/types": "^8.1.0",
|
||||||
"@prisma/client": "^6.3.1",
|
"@prisma/client": "^6.3.1",
|
||||||
@@ -52,11 +54,13 @@
|
|||||||
"chart.js": "^4.4.8",
|
"chart.js": "^4.4.8",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"colors": "^1.4.0",
|
"colors": "^1.4.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"elysia": "^1.3.5",
|
"elysia": "^1.3.5",
|
||||||
"embla-carousel-autoplay": "^8.5.2",
|
"embla-carousel": "^8.6.0",
|
||||||
"embla-carousel-react": "^7.1.0",
|
"embla-carousel-autoplay": "^8.6.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
"extract-zip": "^2.0.1",
|
"extract-zip": "^2.0.1",
|
||||||
"form-data": "^4.0.2",
|
"form-data": "^4.0.2",
|
||||||
"framer-motion": "^12.23.5",
|
"framer-motion": "^12.23.5",
|
||||||
@@ -80,6 +84,7 @@
|
|||||||
"prisma": "^6.3.1",
|
"prisma": "^6.3.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-exif-orientation-img": "^0.1.5",
|
||||||
"react-international-phone": "^4.6.0",
|
"react-international-phone": "^4.6.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-simple-toasts": "^6.1.0",
|
"react-simple-toasts": "^6.1.0",
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
'postcss-preset-mantine': {},
|
'postcss-preset-mantine': {},
|
||||||
'postcss-simple-vars': {
|
'postcss-simple-vars': {
|
||||||
variables: {
|
variables: {
|
||||||
'mantine-breakpoint-xs': '36em',
|
/* Mobile first */
|
||||||
'mantine-breakpoint-sm': '48em',
|
'mantine-breakpoint-xs': '30em', // 480px → mobile kecil–normal
|
||||||
'mantine-breakpoint-md': '62em',
|
'mantine-breakpoint-sm': '48em', // 768px → tablet / mobile landscape
|
||||||
'mantine-breakpoint-lg': '75em',
|
'mantine-breakpoint-md': '64em', // 1024px → laptop & desktop kecil
|
||||||
'mantine-breakpoint-xl': '88em',
|
'mantine-breakpoint-lg': '80em', // 1280px → desktop standar
|
||||||
},
|
'mantine-breakpoint-xl': '90em', // 1440px+ → desktop besar
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
|
};
|
||||||
|
|||||||
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",
|
"id": "cmk27746i0000vnso2aspwf9g",
|
||||||
"name": "S6RIjFaPvdQm3oq4rM4X9-desktop.webp",
|
"name": "Eqlrr1W-pK8ShMGqgPGL3-desktop.webp",
|
||||||
"realName": "bares.png",
|
|
||||||
"path": "uploads/images",
|
|
||||||
"mimeType": "image/webp",
|
|
||||||
"link": "/api/fileStorage/findUnique/S6RIjFaPvdQm3oq4rM4X9-desktop.webp",
|
|
||||||
"category": "image"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cmff0tnf00003vn0t3kgzi0u0",
|
|
||||||
"name": "_pVNEmThU5ICGa8gv3gh_-desktop.webp",
|
|
||||||
"realName": "bicara-darma.png",
|
|
||||||
"path": "uploads/images",
|
|
||||||
"mimeType": "image/webp",
|
|
||||||
"link": "/api/fileStorage/findUnique/_pVNEmThU5ICGa8gv3gh_-desktop.webp",
|
|
||||||
"category": "image"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cmff0uykf0004vn0trmmxpgfh",
|
|
||||||
"name": "bv6rdKvjxkkjUSGLQ0lvB-desktop.webp",
|
|
||||||
"realName": "daves.png",
|
|
||||||
"path": "uploads/images",
|
|
||||||
"mimeType": "image/webp",
|
|
||||||
"link": "/api/fileStorage/findUnique/bv6rdKvjxkkjUSGLQ0lvB-desktop.webp",
|
|
||||||
"category": "image"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cmff0z34f0005vn0tjtvq519p",
|
|
||||||
"name": "Z4hWaV04CvoE20MjccQsV-desktop.webp",
|
|
||||||
"realName": "mangan.png",
|
|
||||||
"path": "uploads/images",
|
|
||||||
"mimeType": "image/webp",
|
|
||||||
"link": "/api/fileStorage/findUnique/Z4hWaV04CvoE20MjccQsV-desktop.webp",
|
|
||||||
"category": "image"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cmff38cyq000bvn0t9f01cz3f",
|
|
||||||
"name": "LvLAtOqWojx4sn6NjJWB9-desktop.webp",
|
|
||||||
"realName": "gelah-melah.png",
|
|
||||||
"path": "uploads/images",
|
|
||||||
"mimeType": "image/webp",
|
|
||||||
"link": "/api/fileStorage/findUnique/LvLAtOqWojx4sn6NjJWB9-desktop.webp",
|
|
||||||
"category": "image"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cmff0zqvd0007vn0tv6o5hjcq",
|
|
||||||
"name": "gR2mcvAQVgJ2-rM5coYJj-desktop.webp",
|
|
||||||
"realName": "inovasi-desa-darmasaba.png",
|
|
||||||
"path": "uploads/images",
|
|
||||||
"mimeType": "image/webp",
|
|
||||||
"link": "/api/fileStorage/findUnique/gR2mcvAQVgJ2-rM5coYJj-desktop.webp",
|
|
||||||
"category": "image"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cmff1013m0008vn0th7t0d64d",
|
|
||||||
"name": "JpL-9F8-IGztMn8E2ce02-desktop.webp",
|
|
||||||
"realName": "pdkt.png",
|
|
||||||
"path": "uploads/images",
|
|
||||||
"mimeType": "image/webp",
|
|
||||||
"link": "/api/fileStorage/findUnique/JpL-9F8-IGztMn8E2ce02-desktop.webp",
|
|
||||||
"category": "image"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cmff10cwq0009vn0tse8dzu3j",
|
|
||||||
"name": "bxAk4AsGbJTC705_IVdes-desktop.webp",
|
|
||||||
"realName": "sajjiana-dharma-raksaka.png",
|
|
||||||
"path": "uploads/images",
|
|
||||||
"mimeType": "image/webp",
|
|
||||||
"link": "/api/fileStorage/findUnique/bxAk4AsGbJTC705_IVdes-desktop.webp",
|
|
||||||
"category": "image"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cmff2w5ly000avn0telhct71k",
|
|
||||||
"name": "Vbj_osnMJUkGEQGDTLwV--desktop.webp",
|
|
||||||
"realName": "perbekel.png",
|
"realName": "perbekel.png",
|
||||||
"path": "uploads/images",
|
"path": "uploads/images",
|
||||||
"mimeType": "image/webp",
|
"mimeType": "image/webp",
|
||||||
"link": "/api/fileStorage/findUnique/Vbj_osnMJUkGEQGDTLwV--desktop.webp",
|
"link": "/api/fileStorage/findUnique/Eqlrr1W-pK8ShMGqgPGL3-desktop.webp",
|
||||||
|
"category": "image"
|
||||||
|
}
|
||||||
|
,
|
||||||
|
{
|
||||||
|
"id": "cmk20mg320000vnevxy0k73fr",
|
||||||
|
"name": "thpgPSJkBxUIRajZt3AVo-desktop.webp",
|
||||||
|
"realName": "bares.png",
|
||||||
|
"path": "uploads/images",
|
||||||
|
"mimeType": "image/webp",
|
||||||
|
"link": "/api/fileStorage/findUnique/thpgPSJkBxUIRajZt3AVo-desktop.webp",
|
||||||
"category": "image"
|
"category": "image"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cmff3joae0000vn6h8sgs0ilg",
|
"id": "cmk20nqmu0001vnevfte29rk0",
|
||||||
"name": "7hox9spUxj56hY_EBYLnj-desktop.webp",
|
"name": "ubna9N6r7RgVWN5plO5mq-desktop.webp",
|
||||||
|
"realName": "bicara-darma.png",
|
||||||
|
"path": "uploads/images",
|
||||||
|
"mimeType": "image/webp",
|
||||||
|
"link": "/api/fileStorage/findUnique/ubna9N6r7RgVWN5plO5mq-desktop.webp",
|
||||||
|
"category": "image"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cmk228urs0007vnevi5b66bqn",
|
||||||
|
"name": "Z4i2RRnnlHq2iWj94ldyo-desktop.webp",
|
||||||
|
"realName": "daves.png",
|
||||||
|
"path": "uploads/images",
|
||||||
|
"mimeType": "image/webp",
|
||||||
|
"link": "/api/fileStorage/findUnique/Z4i2RRnnlHq2iWj94ldyo-desktop.webp",
|
||||||
|
"category": "image"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cmk20nyen0002vnevd0hfr3u8",
|
||||||
|
"name": "y4yaE4XdUP1TSUGhWPW9h-desktop.webp",
|
||||||
|
"realName": "mangan.png",
|
||||||
|
"path": "uploads/images",
|
||||||
|
"mimeType": "image/webp",
|
||||||
|
"link": "/api/fileStorage/findUnique/y4yaE4XdUP1TSUGhWPW9h-desktop.webp",
|
||||||
|
"category": "image"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cmk20o7mf0003vnevohrksm1d",
|
||||||
|
"name": "Vr7CoaYDpk2dIkHx9PxRj-desktop.webp",
|
||||||
|
"realName": "gelah-melah.png",
|
||||||
|
"path": "uploads/images",
|
||||||
|
"mimeType": "image/webp",
|
||||||
|
"link": "/api/fileStorage/findUnique/Vr7CoaYDpk2dIkHx9PxRj-desktop.webp",
|
||||||
|
"category": "image"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cmk20of8m0004vnev9ujy5o0l",
|
||||||
|
"name": "ceoB_sg-HOzljN8j_2nZA-desktop.webp",
|
||||||
|
"realName": "inovasi-desa-darmasaba.png",
|
||||||
|
"path": "uploads/images",
|
||||||
|
"mimeType": "image/webp",
|
||||||
|
"link": "/api/fileStorage/findUnique/ceoB_sg-HOzljN8j_2nZA-desktop.webp",
|
||||||
|
"category": "image"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cmk20omzq0005vnevgi6f4edu",
|
||||||
|
"name": "vOy5YVUXfHXfiFOHylIN7-desktop.webp",
|
||||||
|
"realName": "pdkt.png",
|
||||||
|
"path": "uploads/images",
|
||||||
|
"mimeType": "image/webp",
|
||||||
|
"link": "/api/fileStorage/findUnique/vOy5YVUXfHXfiFOHylIN7-desktop.webp",
|
||||||
|
"category": "image"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cmk20pf3d0006vnev3mkoqpyy",
|
||||||
|
"name": "gE_qcqIbY0mqI6FV9V4CL-desktop.webp",
|
||||||
|
"realName": "sajjiana-dharma-raksaka.png",
|
||||||
|
"path": "uploads/images",
|
||||||
|
"mimeType": "image/webp",
|
||||||
|
"link": "/api/fileStorage/findUnique/gE_qcqIbY0mqI6FV9V4CL-desktop.webp",
|
||||||
|
"category": "image"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cmk2cgqgm0003vn96jun52pik",
|
||||||
|
"name": "q1G995W7cLkC_qquLTlKN-desktop.webp",
|
||||||
"realName": "youtube.png",
|
"realName": "youtube.png",
|
||||||
"path": "uploads/images",
|
"path": "uploads/images",
|
||||||
"mimeType": "image/webp",
|
"mimeType": "image/webp",
|
||||||
"link": "/api/fileStorage/findUnique/7hox9spUxj56hY_EBYLnj-desktop.webp",
|
"link": "/api/fileStorage/findUnique/q1G995W7cLkC_qquLTlKN-desktop.webp",
|
||||||
"category": "image"
|
"category": "image"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cmff3ll130001vn6hkhls3f5y",
|
"id": "cmk2cmr000006vn96qepq6gvl",
|
||||||
"name": "ChihV7_1eS-AGtSg9UwMv-desktop.webp",
|
"name": "I6mlQ4nRmPX26gm79C_rM-desktop.webp",
|
||||||
"realName": "gmail.png",
|
|
||||||
"path": "uploads/images",
|
|
||||||
"mimeType": "image/webp",
|
|
||||||
"link": "/api/fileStorage/findUnique/ChihV7_1eS-AGtSg9UwMv-desktop.webp",
|
|
||||||
"category": "image"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cmff3mtat0002vn6hs8vyyhdd",
|
|
||||||
"name": "z8v9ZREwOJHKGIRYauROt-desktop.webp",
|
|
||||||
"realName": "facebook.png",
|
"realName": "facebook.png",
|
||||||
"path": "uploads/images",
|
"path": "uploads/images",
|
||||||
"mimeType": "image/webp",
|
"mimeType": "image/webp",
|
||||||
"link": "/api/fileStorage/findUnique/z8v9ZREwOJHKGIRYauROt-desktop.webp",
|
"link": "/api/fileStorage/findUnique/I6mlQ4nRmPX26gm79C_rM-desktop.webp",
|
||||||
"category": "image"
|
"category": "image"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cmff3nv180003vn6h5jvedidq",
|
"id": "cmk2cpeba0009vn966jcrpf3u",
|
||||||
"name": "BLjMxTKoCNE31uOURR3IU-desktop.webp",
|
"name": "WArLC_yvU33MjoqEnQeQ1-desktop.webp",
|
||||||
"realName": "telephone-call.png",
|
|
||||||
"path": "uploads/images",
|
|
||||||
"mimeType": "image/webp",
|
|
||||||
"link": "/api/fileStorage/findUnique/BLjMxTKoCNE31uOURR3IU-desktop.webp",
|
|
||||||
"category": "image"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cmff3oouh0004vn6hd94brzv9",
|
|
||||||
"name": "hkJYAeTNWK_vYaYS20w3I-desktop.webp",
|
|
||||||
"realName": "instagram.png",
|
"realName": "instagram.png",
|
||||||
"path": "uploads/images",
|
"path": "uploads/images",
|
||||||
"mimeType": "image/webp",
|
"mimeType": "image/webp",
|
||||||
"link": "/api/fileStorage/findUnique/hkJYAeTNWK_vYaYS20w3I-desktop.webp",
|
"link": "/api/fileStorage/findUnique/WArLC_yvU33MjoqEnQeQ1-desktop.webp",
|
||||||
"category": "image"
|
"category": "image"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cmff3q12g0005vn6h5ojov2qa",
|
"id": "cmk2crcl1000cvn96j8pmgmo5",
|
||||||
"name": "6XEoZ9SFu59COpil03Gya-desktop.webp",
|
"name": "D3RPbNiaNSCjacLjeR_qO-desktop.webp",
|
||||||
"realName": "tiktok.png",
|
"realName": "tiktok.png",
|
||||||
"path": "uploads/images",
|
"path": "uploads/images",
|
||||||
"mimeType": "image/webp",
|
"mimeType": "image/webp",
|
||||||
"link": "/api/fileStorage/findUnique/6XEoZ9SFu59COpil03Gya-desktop.webp",
|
"link": "/api/fileStorage/findUnique/D3RPbNiaNSCjacLjeR_qO-desktop.webp",
|
||||||
"category": "image"
|
"category": "image"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,24 +3,24 @@
|
|||||||
"id": "cmds9023u0008vnbe3oxmhwyf",
|
"id": "cmds9023u0008vnbe3oxmhwyf",
|
||||||
"name": "Desa Darmasaba",
|
"name": "Desa Darmasaba",
|
||||||
"iconUrl": "https://www.youtube.com/channel/UCtPw9WOQO7d2HIKzKgel4Xg",
|
"iconUrl": "https://www.youtube.com/channel/UCtPw9WOQO7d2HIKzKgel4Xg",
|
||||||
"imageId": "cmff3joae0000vn6h8sgs0ilg"
|
"imageId": "cmk2cgqgm0003vn96jun52pik"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cmds90oul000bvnbe2bqkptoi",
|
"id": "cmds90oul000bvnbe2bqkptoi",
|
||||||
"name": "Pemerintah Desa Darmasaba",
|
"name": "Pemerintah Desa Darmasaba",
|
||||||
"iconUrl": "https://www.facebook.com/DarmasabaDesaku",
|
"iconUrl": "https://www.facebook.com/DarmasabaDesaku",
|
||||||
"imageId": "cmff3mtat0002vn6hs8vyyhdd"
|
"imageId": "cmk2cmr000006vn96qepq6gvl"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cmds91i4e000evnbe8gtf1gub",
|
"id": "cmds91i4e000evnbe8gtf1gub",
|
||||||
"name": "ddarmasaba",
|
"name": "ddarmasaba",
|
||||||
"iconUrl": "https://www.instagram.com/ddarmasaba/",
|
"iconUrl": "https://www.instagram.com/ddarmasaba/",
|
||||||
"imageId": "cmff3oouh0004vn6hd94brzv9"
|
"imageId": "cmk2cpeba0009vn966jcrpf3u"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cmds92de5000hvnbemlu6sq5x",
|
"id": "cmds92de5000hvnbemlu6sq5x",
|
||||||
"name": "desa.darmasaba",
|
"name": "desa.darmasaba",
|
||||||
"iconUrl": "https://www.tiktok.com/@desa.darmasaba?is_from_webapp=1&sender_device=pc",
|
"iconUrl": "https://www.tiktok.com/@desa.darmasaba?is_from_webapp=1&sender_device=pc",
|
||||||
"imageId": "cmff3q12g0005vn6h5ojov2qa"
|
"imageId": "cmk2crcl1000cvn96j8pmgmo5"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
"id": "edit",
|
"id": "edit",
|
||||||
"name": "I.B Surya Prabhawa Manuaba, S.H., M.H.",
|
"name": "I.B Surya Prabhawa Manuaba, S.H., M.H.",
|
||||||
"position": "Perbekel Darmasaba periode 2021-2027",
|
"position": "Perbekel Darmasaba periode 2021-2027",
|
||||||
"imageId": "cmff2w5ly000avn0telhct71k"
|
"imageId": "cmk2a2dl6001nvngck1n0k8qc"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,48 +4,55 @@
|
|||||||
"name": "Dmangan",
|
"name": "Dmangan",
|
||||||
"description": "Darmasaba Aman Pangan",
|
"description": "Darmasaba Aman Pangan",
|
||||||
"link": "https://darmasaba.desa.id/berita/61452-kader-d-mangan-berhasil-meraih-prestasi-dalam-ajang-lomba-banjar-bali-quis-bbq-tahun-2024",
|
"link": "https://darmasaba.desa.id/berita/61452-kader-d-mangan-berhasil-meraih-prestasi-dalam-ajang-lomba-banjar-bali-quis-bbq-tahun-2024",
|
||||||
"imageId" : "cmff0z34f0005vn0tjtvq519p"
|
"imageId" : "cmk20nyen0002vnevd0hfr3u8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cmdr76nqk0008vn5rdddvcxnr",
|
"id": "cmdr76nqk0008vn5rdddvcxnr",
|
||||||
"name": "Bicara Darmasaba",
|
"name": "Bicara Darmasaba",
|
||||||
"description": "Bicara Darmasaba",
|
"description": "Bicara Darmasaba",
|
||||||
"link": "https://darmasaba.desa.id/berita/42506-bicara-darmasaba",
|
"link": "https://darmasaba.desa.id/berita/42506-bicara-darmasaba",
|
||||||
"imageId" : "cmff0tnf00003vn0t3kgzi0u0"
|
"imageId" : "cmk20nqmu0001vnevfte29rk0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cmdr77vbw000bvn5rvpmoq31s",
|
"id": "cmdr77vbw000bvn5rvpmoq31s",
|
||||||
"name": "Bares",
|
"name": "Bares",
|
||||||
"description": "Darmasaba Recycling Stock/Exchange",
|
"description": "Darmasaba Recycling Stock/Exchange",
|
||||||
"link": "http://darmasaba.desa.id/berita/56722-bares",
|
"link": "http://darmasaba.desa.id/berita/56722-bares",
|
||||||
"imageId" : "cmff0rr4z0002vn0twp333m2"
|
"imageId" : "cmk20mg320000vnevxy0k73fr"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cmdr7bxtp000evn5rmy85wihx",
|
"id": "cmdr7bxtp000evn5rmy85wihx",
|
||||||
"name": "Sajjana Dharma Raksaka",
|
"name": "Sajjana Dharma Raksaka",
|
||||||
"description": "Sajjana Dharma Raksaka",
|
"description": "Sajjana Dharma Raksaka",
|
||||||
"link": "https://ppid.badungkab.go.id/storage/dokumen/5RS9dldGkrgzMQq6bKdZsqsVRHI8gffWv4PGfb3r.pdf",
|
"link": "https://ppid.badungkab.go.id/storage/dokumen/5RS9dldGkrgzMQq6bKdZsqsVRHI8gffWv4PGfb3r.pdf",
|
||||||
"imageId" : "cmff10cwq0009vn0tse8dzu3j"
|
"imageId" : "cmk20pf3d0006vnev3mkoqpyy"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cmdr7dlnk000hvn5r9lur3z35",
|
"id": "cmdr7dlnk000hvn5r9lur3z35",
|
||||||
"name": "PDKT",
|
"name": "PDKT",
|
||||||
"description": "Perangkat Desa Kuat Teknologi",
|
"description": "Perangkat Desa Kuat Teknologi",
|
||||||
"link": "https://darmasaba.desa.id/berita/53752-p-d-k-t",
|
"link": "https://darmasaba.desa.id/berita/53752-p-d-k-t",
|
||||||
"imageId" : "cmff1013m0008vn0th7t0d64d"
|
"imageId" : "cmk20omzq0005vnevgi6f4edu"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cmdr7ftob000mvn5rfhgdtg8v",
|
"id": "cmdr7ftob000mvn5rfhgdtg8v",
|
||||||
"name": "GM",
|
"name": "GM",
|
||||||
"description": "Galah Melah",
|
"description": "Galah Melah",
|
||||||
"link": "https://darmasaba.desa.id/berita/52880-galah-melah",
|
"link": "https://darmasaba.desa.id/berita/52880-galah-melah",
|
||||||
"imageId" : "cmff38cyq000bvn0t9f01cz3f"
|
"imageId" : "cmk20o7mf0003vnevohrksm1d"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cmdr7glue000pvn5r6onzslju",
|
"id": "cmdr7glue000pvn5r6onzslju",
|
||||||
"name": "Inovasi Desa Darmasaba",
|
"name": "Inovasi Desa Darmasaba",
|
||||||
"description": "Inovasi Desa Darmasaba",
|
"description": "Inovasi Desa Darmasaba",
|
||||||
"link": "https://darmasaba.desa.id/produk-lokal-desa",
|
"link": "https://darmasaba.desa.id/produk-lokal-desa",
|
||||||
"imageId" : "cmff0zqvd0007vn0tv6o5hjcq"
|
"imageId" : "cmk20of8m0004vnev9ujy5o0l"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cmk228ust0009vnev5p8i377o",
|
||||||
|
"name": "Davest",
|
||||||
|
"description": "<p>DAVEST (Darmasaba Investment) merupakan program inovasi Desa Darmasaba yang bertujuan mempromosikan potensi investasi desa secara terintegrasi melalui media digital dan pendampingan langsung. Program ini menjadi sarana penghubung antara pemerintah desa, pelaku usaha, dan investor dalam rangka mendorong pertumbuhan ekonomi desa yang berkelanjutan.</p><p>DAVEST menyajikan informasi potensi unggulan desa seperti sektor UMKM, pariwisata, ekonomi kreatif, serta peluang investasi berbasis sumber daya lokal dengan prinsip transparansi dan kemudahan akses informasi.</p><p>Di tahun 2024 ini Davest (Darmasaba Village Festival) akan diadakan lagi, dengan berbagai kegiatan pemerdayaan, edukasi dan hiburan yang tentunya lebih waahhhh dari dua tahun lalu. Untuk memantapkan hal tersebut, Pemdes Darmasaba melakukan rapat koordinasi (rakor) Davest 2024 yang dipimpin langsung oleh Perbekel Darmasaba I. B. Surya Prabhawa Manuaba, S.H.,M.H. pada hari Senin (22/1/2024) bertempat di Ruang Shanti Gosana Kantor Perbekel Darmasaba.</p><hr><h3>Tujuan Program</h3><ul><li><p>Meningkatkan daya tarik investasi di Desa Darmasaba</p></li><li><p>Mempromosikan potensi unggulan desa secara profesional</p></li><li><p>Mendorong pertumbuhan ekonomi dan penciptaan lapangan kerja</p></li><li><p>Mendukung visi Desa Darmasaba sebagai desa inovatif dan berdaya saing</p></li></ul><hr><h3>Sasaran Program</h3><ul><li><p>Calon investor lokal dan regional</p></li><li><p>Pelaku UMKM dan kelompok usaha desa</p></li><li><p>Masyarakat Desa Darmasaba</p></li></ul><hr><h3>Bentuk Inovasi</h3><ul><li><p>Inovasi ekonomi desa</p></li><li><p>Inovasi digital</p></li><li><p>Inovasi tata kelola pelayanan investasi</p></li></ul><hr><h3>Ruang Lingkup Kegiatan</h3><ul><li><p>Penyusunan profil potensi investasi desa</p></li><li><p>Digitalisasi informasi investasi desa</p></li><li><p>Promosi peluang investasi melalui media online</p></li><li><p>Fasilitasi komunikasi antara investor dan desa</p></li><li><p>Pendampingan awal investasi berbasis desa</p></li></ul>",
|
||||||
|
"link": "https://darmasaba.desa.id/berita/55862-rakor-davest-2024",
|
||||||
|
"imageId" : "cmk228urs0007vnevi5b66bqn"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
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",
|
"id": "0",
|
||||||
"name": "ADMIN DESA",
|
"name": "DEVELOPER",
|
||||||
"description": "Administrator Desa",
|
"description": "Developer",
|
||||||
"permissions": ["manage_users", "manage_content", "view_reports"],
|
"isActive": true
|
||||||
"isActive": true
|
},
|
||||||
},
|
{
|
||||||
{
|
"id": "1",
|
||||||
"id": "role-2",
|
"name": "SUPER ADMIN",
|
||||||
"name": "ADMIN KESEHATAN",
|
"description": "Administrator",
|
||||||
"description": "Administrator Bidang Kesehatan",
|
"isActive": true
|
||||||
"permissions": ["manage_health_data", "view_reports"],
|
},
|
||||||
"isActive": true
|
{
|
||||||
},
|
"id": "2",
|
||||||
{
|
"name": "ADMIN DESA",
|
||||||
"id": "role-3",
|
"description": "Administrator Desa",
|
||||||
"name": "ADMIN SEKOLAH",
|
"isActive": true
|
||||||
"description": "Administrator Sekolah",
|
},
|
||||||
"permissions": ["manage_school_data", "view_reports"],
|
{
|
||||||
"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",
|
"id": "cmie1o0zh0002vn132vtzg7hh",
|
||||||
"nama": "Admin Desa",
|
"username": "SuperAdmin-Nico",
|
||||||
"nomor": "089647037426",
|
"nomor": "6289647037426",
|
||||||
"roleId": "role-1",
|
"roleId": 0,
|
||||||
"isActive": true
|
"isActive": true,
|
||||||
},
|
"sessionInvalid": false
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
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 */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
// helpers/safeSeedUnique.ts
|
import prisma from "@/lib/prisma";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
type SafeSeedOptions = {
|
||||||
|
skipUpdate?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
// prisma/safeseedUnique.ts
|
||||||
* Helper generic buat seed dengan upsert aman
|
|
||||||
*/
|
|
||||||
export async function safeSeedUnique<T extends keyof PrismaClient>(
|
export async function safeSeedUnique<T extends keyof PrismaClient>(
|
||||||
model: T,
|
model: T,
|
||||||
where: Record<string, any>,
|
where: Record<string, any>,
|
||||||
data: Record<string, any>
|
data: Record<string, any>,
|
||||||
|
options: SafeSeedOptions = {}
|
||||||
) {
|
) {
|
||||||
const m = prisma[model];
|
const m = prisma[model] as any;
|
||||||
|
if (!m) throw new Error(`Model ${String(model)} tidak ditemukan`);
|
||||||
if (!m) throw new Error(`Model ${String(model)} tidak ditemukan di PrismaClient`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// @ts-expect-error upsert dynamic
|
// Pastikan `where` berisi field yang benar-benar unique (misal: `id`)
|
||||||
await m.upsert({
|
const result = await m.upsert({
|
||||||
where,
|
where,
|
||||||
update: data,
|
update: options.skipUpdate ? {} : data,
|
||||||
create: { ...where, ...data },
|
create: data, // ✅ Jangan duplikasi `where` ke `create`
|
||||||
});
|
});
|
||||||
console.log(`✅ Seeded ${String(model)} -> ${JSON.stringify(where)}`);
|
console.log(`✅ Seed ${String(model)}:`, where);
|
||||||
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`❌ Gagal seed ${String(model)} -> ${JSON.stringify(where)}`, err);
|
console.error(`❌ Gagal seed ${String(model)}:`, where, err);
|
||||||
|
throw err; // ✅ Rethrow agar seeding berhenti jika kritis
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
// import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
// const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// type SafeSeedOptions = {
|
||||||
|
// skipUpdate?: boolean;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// export async function safeSeedUnique<T extends keyof PrismaClient>(
|
||||||
|
// model: T,
|
||||||
|
// where: Record<string, any>,
|
||||||
|
// data: Record<string, any>,
|
||||||
|
// options: SafeSeedOptions = {}
|
||||||
|
// ) {
|
||||||
|
// const m = prisma[model] as any;
|
||||||
|
// if (!m) throw new Error(`Model ${String(model)} tidak ditemukan`);
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// await m.upsert({
|
||||||
|
// where,
|
||||||
|
// update: options.skipUpdate ? {} : data,
|
||||||
|
// create: { ...where, ...data },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// console.log(`✅ Seed ${String(model)}:`, where);
|
||||||
|
// } catch (err) {
|
||||||
|
// console.error(`❌ Gagal seed ${String(model)}:`, where, err);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ model MediaSosial {
|
|||||||
name String
|
name String
|
||||||
image FileStorage? @relation(fields: [imageId], references: [id])
|
image FileStorage? @relation(fields: [imageId], references: [id])
|
||||||
imageId String?
|
imageId String?
|
||||||
|
icon String?
|
||||||
iconUrl String? @db.VarChar(255)
|
iconUrl String? @db.VarChar(255)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -184,18 +185,46 @@ model SdgsDesa {
|
|||||||
//========================================= APBDes ========================================= //
|
//========================================= APBDes ========================================= //
|
||||||
model APBDes {
|
model APBDes {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
tahun Int?
|
||||||
jumlah String
|
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])
|
image FileStorage? @relation("APBDesImage", fields: [imageId], references: [id])
|
||||||
imageId String?
|
imageId String?
|
||||||
file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id])
|
file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id])
|
||||||
fileId String?
|
fileId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime? // opsional, tidak perlu default now()
|
||||||
isActive Boolean @default(true)
|
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 ========================================= //
|
//========================================= PRESTASI DESA ========================================= //
|
||||||
model PrestasiDesa {
|
model PrestasiDesa {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -754,24 +783,22 @@ model Penghargaan {
|
|||||||
|
|
||||||
// ========================================= FASILITAS KESEHATAN ========================================= //
|
// ========================================= FASILITAS KESEHATAN ========================================= //
|
||||||
model FasilitasKesehatan {
|
model FasilitasKesehatan {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime @default(now())
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
informasiumum InformasiUmum @relation(fields: [informasiUmumId], references: [id])
|
informasiumum InformasiUmum @relation(fields: [informasiUmumId], references: [id])
|
||||||
informasiUmumId String
|
informasiUmumId String
|
||||||
layananunggulan LayananUnggulan @relation(fields: [layananUnggulanId], references: [id])
|
layananunggulan LayananUnggulan @relation(fields: [layananUnggulanId], references: [id])
|
||||||
layananUnggulanId String
|
layananUnggulanId String
|
||||||
dokterdantenagamedis DokterdanTenagaMedis @relation(fields: [dokterdanTenagaMedisId], references: [id])
|
dokterdantenagamedis DokterdanTenagaMedis[] @relation("Dokter")
|
||||||
dokterdanTenagaMedisId String
|
fasilitaspendukung FasilitasPendukung @relation(fields: [fasilitasPendukungId], references: [id])
|
||||||
fasilitaspendukung FasilitasPendukung @relation(fields: [fasilitasPendukungId], references: [id])
|
fasilitasPendukungId String
|
||||||
fasilitasPendukungId String
|
prosedurpendaftaran ProsedurPendaftaran @relation(fields: [prosedurPendaftaranId], references: [id])
|
||||||
prosedurpendaftaran ProsedurPendaftaran @relation(fields: [prosedurPendaftaranId], references: [id])
|
prosedurPendaftaranId String
|
||||||
prosedurPendaftaranId String
|
tarifdanlayanan TarifDanLayanan[] @relation("Tarif")
|
||||||
tarifdanlayanan TarifDanLayanan @relation(fields: [tarifDanLayananId], references: [id])
|
|
||||||
tarifDanLayananId String
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model InformasiUmum {
|
model InformasiUmum {
|
||||||
@@ -797,15 +824,20 @@ model LayananUnggulan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model DokterdanTenagaMedis {
|
model DokterdanTenagaMedis {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
specialist String
|
specialist String
|
||||||
jadwal String
|
jadwal String
|
||||||
createdAt DateTime @default(now())
|
jadwalLibur String?
|
||||||
updatedAt DateTime @updatedAt
|
jamBukaOperasional String?
|
||||||
deletedAt DateTime @default(now())
|
jamTutupOperasional String?
|
||||||
isActive Boolean @default(true)
|
jamBukaLibur String?
|
||||||
FasilitasKesehatan FasilitasKesehatan[]
|
jamTutupLibur String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime @default(now())
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
FasilitasKesehatan FasilitasKesehatan[] @relation("Dokter")
|
||||||
}
|
}
|
||||||
|
|
||||||
model FasilitasPendukung {
|
model FasilitasPendukung {
|
||||||
@@ -836,7 +868,7 @@ model TarifDanLayanan {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime @default(now())
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
FasilitasKesehatan FasilitasKesehatan[]
|
FasilitasKesehatan FasilitasKesehatan[] @relation("Tarif")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================= JADWAL KEGIATAN ========================================= //
|
// ========================================= JADWAL KEGIATAN ========================================= //
|
||||||
@@ -1942,23 +1974,28 @@ model KeunggulanProgram {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model BeasiswaPendaftar {
|
model BeasiswaPendaftar {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
namaLengkap String
|
namaLengkap String
|
||||||
nik String @unique
|
nis String?
|
||||||
|
kelas String?
|
||||||
|
jenisKelamin JenisKelamin
|
||||||
|
alamatDomisili String?
|
||||||
tempatLahir String
|
tempatLahir String
|
||||||
tanggalLahir DateTime
|
tanggalLahir DateTime
|
||||||
jenisKelamin JenisKelamin
|
namaOrtu String?
|
||||||
kewarganegaraan String
|
nik String @unique
|
||||||
agama Agama
|
pekerjaanOrtu String?
|
||||||
alamatKTP String
|
penghasilan String?
|
||||||
alamatDomisili String?
|
|
||||||
noHp String
|
noHp String
|
||||||
email String @unique
|
kewarganegaraan String?
|
||||||
statusPernikahan StatusPernikahan
|
agama Agama?
|
||||||
|
alamatKTP String?
|
||||||
|
email String? @unique
|
||||||
|
statusPernikahan StatusPernikahan?
|
||||||
ukuranBaju UkuranBaju?
|
ukuranBaju UkuranBaju?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
enum JenisKelamin {
|
enum JenisKelamin {
|
||||||
@@ -2130,25 +2167,28 @@ enum StatusPeminjaman {
|
|||||||
// ========================================= USER ========================================= //
|
// ========================================= USER ========================================= //
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
username String
|
username String
|
||||||
nomor String @unique
|
nomor String @unique
|
||||||
role Role @relation(fields: [roleId], references: [id])
|
roleId String @default("2")
|
||||||
roleId String @default("1")
|
isActive Boolean @default(false)
|
||||||
instansi String?
|
sessionInvalid Boolean @default(false)
|
||||||
UserSession UserSession? // Nama instansi (Puskesmas, Sekolah, dll)
|
lastLogin DateTime?
|
||||||
isActive Boolean @default(true)
|
createdAt DateTime @default(now())
|
||||||
lastLogin DateTime?
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
createdAt DateTime @default(now())
|
permissions Json?
|
||||||
updatedAt DateTime @updatedAt
|
sessions UserSession[] // ✅ Relasi one-to-many
|
||||||
deletedAt DateTime?
|
role Role @relation(fields: [roleId], references: [id])
|
||||||
|
menuAccesses UserMenuAccess[]
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Role {
|
model Role {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH
|
name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH
|
||||||
description String?
|
description String?
|
||||||
permissions Json // Menyimpan permission dalam format JSON
|
permissions Json?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -2167,26 +2207,32 @@ model KodeOtp {
|
|||||||
otp Int
|
otp Int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tabel untuk menyimpan permission
|
model UserSession {
|
||||||
model Permission {
|
id String @id @default(cuid())
|
||||||
id String @id @default(cuid())
|
token String @db.Text // ✅ JWT bisa panjang
|
||||||
name String @unique
|
expiresAt DateTime // ✅ Ubah jadi expiresAt (konsisten)
|
||||||
description String?
|
active Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model UserMenuAccess {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
token String
|
userId String
|
||||||
expires DateTime?
|
menuId String // ID menu (misal: "Landing Page", "Kesehatan")
|
||||||
active Boolean @default(true)
|
createdAt DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
updatedAt DateTime @updatedAt
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
|
||||||
User User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
userId String @unique
|
|
||||||
|
@@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================= DATA PENDIDIKAN ========================================= //
|
// ========================================= 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 */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import profilePejabatDesa from "./data/landing-page/profile/profile.json";
|
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 programUnggulan from "./data/pendidikan/program-pendidikan-anak/program-unggulan.json";
|
||||||
import tujuanProgram from "./data/pendidikan/program-pendidikan-anak/tujuan-program.json";
|
import tujuanProgram from "./data/pendidikan/program-pendidikan-anak/tujuan-program.json";
|
||||||
import roles from "./data/user/roles.json";
|
import roles from "./data/user/roles.json";
|
||||||
import users from "./data/user/users.json";
|
|
||||||
import fileStorage from "./data/file-storage.json";
|
import fileStorage from "./data/file-storage.json";
|
||||||
import jenjangPendidikan from "./data/pendidikan/info-sekolah/jenjang-pendidikan.json";
|
import jenjangPendidikan from "./data/pendidikan/info-sekolah/jenjang-pendidikan.json";
|
||||||
import seedAssets from "./seed_assets";
|
import seedAssets from "./seed_assets";
|
||||||
|
import users from "./data/user/users.json";
|
||||||
import { safeSeedUnique } from "./safeseedUnique";
|
import { safeSeedUnique } from "./safeseedUnique";
|
||||||
|
import safeImageId from "./data/safeImageId";
|
||||||
|
import resolveImageIdForSeed from "./data/resolveImageId";
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
// =========== USER & ROLE ===========
|
// seed assets
|
||||||
// In your seed.ts
|
await prisma.fileStorage.deleteMany();
|
||||||
// =========== ROLES ===========
|
console.log("🗑️ Cleared existing fileStorage records");
|
||||||
console.log("🔄 Seeding roles...");
|
await seedAssets();
|
||||||
for (const r of roles) {
|
|
||||||
await safeSeedUnique("role", { id: r.id }, {
|
|
||||||
name: r.name,
|
|
||||||
description: r.description,
|
|
||||||
permissions: r.permissions,
|
|
||||||
isActive: r.isActive,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("✅ Roles seeded");
|
// // =========== FILE STORAGE ===========
|
||||||
|
|
||||||
// =========== 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 ===========
|
|
||||||
console.log("🔄 Seeding file storage...");
|
console.log("🔄 Seeding file storage...");
|
||||||
for (const f of fileStorage) {
|
for (const f of fileStorage) {
|
||||||
await prisma.fileStorage.upsert({
|
await safeSeedUnique(
|
||||||
where: { id: f.id },
|
"fileStorage",
|
||||||
update: {
|
{ name: f.name },
|
||||||
name: f.name,
|
{
|
||||||
realName: f.realName,
|
|
||||||
path: f.path,
|
|
||||||
mimeType: f.mimeType,
|
|
||||||
link: f.link,
|
|
||||||
category: f.category,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
id: f.id,
|
id: f.id,
|
||||||
name: f.name,
|
name: f.name,
|
||||||
realName: f.realName,
|
realName: f.realName,
|
||||||
@@ -119,86 +83,196 @@ import { safeSeedUnique } from "./safeseedUnique";
|
|||||||
mimeType: f.mimeType,
|
mimeType: f.mimeType,
|
||||||
link: f.link,
|
link: f.link,
|
||||||
category: f.category,
|
category: f.category,
|
||||||
},
|
deletedAt: null,
|
||||||
});
|
isActive: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("✅ File storage seeded");
|
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 ===========
|
// =========== LANDING PAGE ===========
|
||||||
// =========== SUBMENU PROFILE ===========
|
// =========== SUBMENU PROFILE ===========
|
||||||
// =========== PROFILE PEJABAT DESA ===========
|
// =========== PROFILE PEJABAT DESA ===========
|
||||||
|
// In your seed.ts file, update the PejabatDesa seeding section to:
|
||||||
|
console.log("🔄 Seeding Pejabat Desa...");
|
||||||
for (const p of profilePejabatDesa) {
|
for (const p of profilePejabatDesa) {
|
||||||
await prisma.pejabatDesa.upsert({
|
try {
|
||||||
where: { id: p.id },
|
// First, verify the image exists
|
||||||
update: {
|
if (p.imageId) {
|
||||||
name: p.name,
|
const imageExists = await prisma.fileStorage.findUnique({
|
||||||
position: p.position,
|
where: { id: p.imageId },
|
||||||
imageId: p.imageId,
|
});
|
||||||
},
|
|
||||||
create: {
|
if (!imageExists) {
|
||||||
id: p.id,
|
console.warn(
|
||||||
name: p.name,
|
`⚠️ Image not found for PejabatDesa ${p.name}, skipping...`
|
||||||
position: p.position,
|
);
|
||||||
imageId: p.imageId,
|
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(
|
console.log("✅ Pejabat Desa seeding completed");
|
||||||
"✅ profilePejabatDesa seeded without imageId (editable later via UI)"
|
|
||||||
);
|
|
||||||
|
|
||||||
// =========== PROGRAM INOVASI ===========
|
// =========== PROGRAM INOVASI ===========
|
||||||
for (const p of programInovasi) {
|
// Add this section after the other seed operations in seed.ts
|
||||||
let imageId: string | null = null;
|
console.log("🔄 Seeding Program Inovasi...");
|
||||||
|
|
||||||
if (p.imageId) {
|
for (const p of programInovasi) {
|
||||||
const imageExists = await prisma.fileStorage.findUnique({
|
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 },
|
where: { id: p.imageId },
|
||||||
|
select: { id: true, name: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (imageExists) {
|
if (fileRecord) {
|
||||||
imageId = p.imageId;
|
imageId = fileRecord.id;
|
||||||
} else {
|
console.log(
|
||||||
console.warn(
|
`✅ Found file by ID: ${fileRecord.name} (${fileRecord.id})`
|
||||||
`⚠️ imageId ${p.imageId} tidak ditemukan untuk ProgramInovasi ${p.name}`
|
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
console.warn(`⚠️ File with ID ${p.imageId} not found for ${p.name}`);
|
||||||
|
imageId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.programInovasi.upsert({
|
await prisma.programInovasi.upsert({
|
||||||
where: { id: p.id },
|
where: { id: p.id },
|
||||||
update: {
|
update: {
|
||||||
name: p.name,
|
name: p.name,
|
||||||
description: p.description,
|
description: p.description,
|
||||||
link: p.link,
|
link: p.link,
|
||||||
imageId: p.imageId,
|
imageId,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
description: p.description,
|
description: p.description,
|
||||||
link: p.link,
|
link: p.link,
|
||||||
imageId: p.imageId,
|
imageId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.log("program inovasi success ...");
|
|
||||||
|
|
||||||
// =========== MEDIA SOSIAL ===========
|
// =========== MEDIA SOSIAL ===========
|
||||||
for (const p of mediaSosial) {
|
for (const m of mediaSosial) {
|
||||||
|
const existing = await prisma.mediaSosial.findUnique({
|
||||||
|
where: { id: m.id },
|
||||||
|
select: { imageId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageId = await resolveImageIdForSeed(existing?.imageId, m.imageId);
|
||||||
|
|
||||||
await prisma.mediaSosial.upsert({
|
await prisma.mediaSosial.upsert({
|
||||||
where: { id: p.id },
|
where: { id: m.id },
|
||||||
update: {
|
update: {
|
||||||
name: p.name,
|
name: m.name,
|
||||||
iconUrl: p.iconUrl,
|
iconUrl: m.iconUrl,
|
||||||
imageId: p.imageId,
|
// ⛔ JANGAN overwrite imageId sembarangan
|
||||||
|
imageId,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
id: p.id,
|
id: m.id,
|
||||||
name: p.name,
|
name: m.name,
|
||||||
iconUrl: p.iconUrl,
|
iconUrl: m.iconUrl,
|
||||||
imageId: p.imageId,
|
imageId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("media sosial success ...");
|
console.log("media sosial success ...");
|
||||||
|
|
||||||
// =========== SUBMENU DESA ANTI KORUPSI ===========
|
// =========== SUBMENU DESA ANTI KORUPSI ===========
|
||||||
@@ -539,15 +613,40 @@ import { safeSeedUnique } from "./safeseedUnique";
|
|||||||
console.log("posisi organisasi berhasil");
|
console.log("posisi organisasi berhasil");
|
||||||
|
|
||||||
// =========== PEGAWAI PPID ===========
|
// =========== PEGAWAI PPID ===========
|
||||||
|
console.log("🔄 Seeding pegawai PPID...");
|
||||||
const flattenedPegawai = pegawaiPPID.flat();
|
const flattenedPegawai = pegawaiPPID.flat();
|
||||||
|
|
||||||
|
// Check for duplicate emails
|
||||||
|
const emails = new Set();
|
||||||
for (const p of flattenedPegawai) {
|
for (const p of flattenedPegawai) {
|
||||||
await prisma.pegawaiPPID.upsert({
|
if (emails.has(p.email)) {
|
||||||
where: { id: p.id },
|
console.warn(`⚠️ Duplicate email found in pegawaiPPID: ${p.email}`);
|
||||||
update: p,
|
}
|
||||||
create: p,
|
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 ===========
|
// =========== SUBMENU VISI MISI PPID ===========
|
||||||
|
|
||||||
@@ -811,7 +910,9 @@ import { safeSeedUnique } from "./safeseedUnique";
|
|||||||
const flattenedPosisiBumdes = posisiOrganisasi.flat();
|
const flattenedPosisiBumdes = posisiOrganisasi.flat();
|
||||||
|
|
||||||
// ✅ Urutkan berdasarkan hierarki
|
// ✅ 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) {
|
for (const p of sortedPosisiBumdes) {
|
||||||
console.log(`Seeding: ${p.nama} (id: ${p.id}, parent: ${p.parentId})`);
|
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
|
// Add IDs to the kategoriKegiatan data
|
||||||
const kategoriKegiatan = kategoriKegiatanData.map((k, index) => ({
|
const kategoriKegiatan = kategoriKegiatanData.map((k, index) => ({
|
||||||
...k,
|
...k,
|
||||||
id: `kategori-${index + 1}`
|
id: `kategori-${index + 1}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
for (const k of kategoriKegiatan) {
|
for (const k of kategoriKegiatan) {
|
||||||
@@ -1180,10 +1281,6 @@ import { safeSeedUnique } from "./safeseedUnique";
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log("✅ Jenjang Pendidikan seeded successfully");
|
console.log("✅ Jenjang Pendidikan seeded successfully");
|
||||||
|
|
||||||
// seed assets
|
|
||||||
await seedAssets();
|
|
||||||
|
|
||||||
})()
|
})()
|
||||||
.then(() => prisma.$disconnect())
|
.then(() => prisma.$disconnect())
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
// prisma/seedAssets.ts
|
// prisma/seedAssets.ts
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import AdmZip from "adm-zip";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import fetch from "node-fetch";
|
import fetchWithRetry from "./data/fetchWithRetry";
|
||||||
import AdmZip from "adm-zip";
|
|
||||||
import prisma from "@/lib/prisma";
|
|
||||||
|
|
||||||
const UPLOADS_DIR =
|
const UPLOADS_DIR =
|
||||||
process.env.WIBU_UPLOAD_DIR || path.join(process.cwd(), "uploads");
|
process.env.WIBU_UPLOAD_DIR || path.join(process.cwd(), "uploads");
|
||||||
@@ -18,7 +19,10 @@ function detectCategory(filename: string): "image" | "document" | "other" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Helper: recursive walk dir ---
|
// --- Helper: recursive walk dir ---
|
||||||
async function walkDir(dir: string, fileList: string[] = []): Promise<string[]> {
|
async function walkDir(
|
||||||
|
dir: string,
|
||||||
|
fileList: string[] = []
|
||||||
|
): Promise<string[]> {
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -41,18 +45,45 @@ export default async function seedAssets() {
|
|||||||
|
|
||||||
// 1. Download zip
|
// 1. Download zip
|
||||||
const url =
|
const url =
|
||||||
"https://cld-dkr-makuro-seafile.wibudev.com/f/ffd5a548a04f47939474/?dl=1";
|
"https://cld-dkr-makuro-seafile.wibudev.com/f/90dd12c9713e42379fcd/?dl=1";
|
||||||
const res = await fetch(url);
|
const res = await fetchWithRetry(url, 3, 20000);
|
||||||
if (!res.ok) throw new Error(`Gagal download assets: ${res.statusText}`);
|
|
||||||
|
// Validasi content-type
|
||||||
|
const contentType = res.headers.get("content-type");
|
||||||
|
if (!contentType?.includes("zip")) {
|
||||||
|
throw new Error(`Invalid content-type (${contentType}). Expected ZIP file`);
|
||||||
|
}
|
||||||
|
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
|
|
||||||
|
// Validasi ukuran file
|
||||||
|
if (buffer.length < 100) {
|
||||||
|
throw new Error("Downloaded ZIP is empty or corrupted");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validasi signature ZIP ("PK")
|
||||||
|
if (buffer.toString("utf8", 0, 2) !== "PK") {
|
||||||
|
throw new Error("Invalid ZIP signature (PK not found)");
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Extract zip ke folder tmp
|
// 2. Extract zip ke folder tmp
|
||||||
const extractDir = path.join(process.cwd(), "tmp_assets");
|
const extractDir = path.join(process.cwd(), "tmp_assets");
|
||||||
await fs.rm(extractDir, { recursive: true, force: true });
|
await fs.rm(extractDir, { recursive: true, force: true });
|
||||||
await fs.mkdir(extractDir, { recursive: true });
|
await fs.mkdir(extractDir, { recursive: true });
|
||||||
|
|
||||||
const zip = new AdmZip(buffer);
|
let zip: AdmZip;
|
||||||
zip.extractAllTo(extractDir, true);
|
|
||||||
|
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)
|
// 3. Cari semua file valid (recursive)
|
||||||
const files = await walkDir(extractDir);
|
const files = await walkDir(extractDir);
|
||||||
@@ -84,18 +115,41 @@ export default async function seedAssets() {
|
|||||||
await fs.copyFile(filePath, targetPath);
|
await fs.copyFile(filePath, targetPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Simpan ke DB
|
const existing = await prisma.fileStorage.findUnique({
|
||||||
await prisma.fileStorage.create({
|
where: { name: finalName },
|
||||||
data: {
|
|
||||||
name: finalName,
|
|
||||||
realName: entryName,
|
|
||||||
path: targetPath,
|
|
||||||
mimeType,
|
|
||||||
link: `/uploads/${category}/${finalName}`,
|
|
||||||
category,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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}`);
|
console.log(`📂 saved: ${category}/${finalName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +157,8 @@ export default async function seedAssets() {
|
|||||||
await fs.rm(extractDir, { recursive: true, force: true });
|
await fs.rm(extractDir, { recursive: true, force: true });
|
||||||
|
|
||||||
console.log("✅ Selesai seed assets!");
|
console.log("✅ Selesai seed assets!");
|
||||||
|
console.log("DB URL (asset):", process.env.DATABASE_URL);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Auto run kalau dipanggil langsung ---
|
// --- Auto run kalau dipanggil langsung ---
|
||||||
|
|||||||
BIN
public/mangupuraaward.jpeg
Normal file
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 TextAlign from '@tiptap/extension-text-align';
|
||||||
import Superscript from '@tiptap/extension-superscript';
|
import Superscript from '@tiptap/extension-superscript';
|
||||||
import SubScript from '@tiptap/extension-subscript';
|
import SubScript from '@tiptap/extension-subscript';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
type CreateEditorProps = {
|
type CreateEditorProps = {
|
||||||
value: string;
|
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 (
|
return (
|
||||||
<RichTextEditor editor={editor}>
|
<RichTextEditor editor={editor}>
|
||||||
<RichTextEditor.Toolbar sticky stickyOffset="var(--docs-header-height)">
|
<RichTextEditor.Toolbar sticky stickyOffset="var(--docs-header-height)">
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export default function EditEditor({ value, onChange }: EditEditorProps) {
|
|||||||
editor.off('update', updateHandler);
|
editor.off('update', updateHandler);
|
||||||
};
|
};
|
||||||
}, [editor, onChange]);
|
}, [editor, onChange]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RichTextEditor editor={editor}>
|
<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 {
|
import {
|
||||||
IconAmbulance,
|
IconAmbulance,
|
||||||
IconCash,
|
IconCash,
|
||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
IconTrophy,
|
IconTrophy,
|
||||||
IconTruckFilled,
|
IconTruckFilled,
|
||||||
IconBuilding,
|
IconBuilding,
|
||||||
IconAlertTriangle
|
IconAlertTriangle,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
@@ -38,26 +38,26 @@ const iconMap = {
|
|||||||
scale: { label: 'Scale', icon: IconScale },
|
scale: { label: 'Scale', icon: IconScale },
|
||||||
clipboard: { label: 'Clipboard', icon: IconClipboardTextFilled },
|
clipboard: { label: 'Clipboard', icon: IconClipboardTextFilled },
|
||||||
trash: { label: 'Trash', icon: IconTrashFilled },
|
trash: { label: 'Trash', icon: IconTrashFilled },
|
||||||
lingkunganSehat: {label: 'Lingkungan Sehat', icon: IconHomeEco},
|
lingkunganSehat: { label: 'Lingkungan Sehat', icon: IconHomeEco },
|
||||||
sumberOksigen: {label: 'Sumber Oksigen', icon: IconChristmasTreeFilled},
|
sumberOksigen: { label: 'Sumber Oksigen', icon: IconChristmasTreeFilled },
|
||||||
ekonomiBerkelanjutan: {label: 'Ekonomi Berkelanjutan', icon: IconTrendingUp},
|
ekonomiBerkelanjutan: { label: 'Ekonomi Berkelanjutan', icon: IconTrendingUp },
|
||||||
mencegahBencana: {label: 'Mencegah Bencana', icon: IconShieldFilled},
|
mencegahBencana: { label: 'Mencegah Bencana', icon: IconShieldFilled },
|
||||||
rumah: {label: 'Rumah', icon: IconHome},
|
rumah: { label: 'Rumah', icon: IconHome },
|
||||||
pohon: {label: 'Pohon', icon: IconTree},
|
pohon: { label: 'Pohon', icon: IconTree },
|
||||||
air: {label: 'Air', icon: IconDroplet},
|
air: { label: 'Air', icon: IconDroplet },
|
||||||
bantuan: {label: 'Bantuan', icon: IconCash},
|
bantuan: { label: 'Bantuan', icon: IconCash },
|
||||||
pelatihan: {label: 'Pelatihan', icon: IconSchool},
|
pelatihan: { label: 'Pelatihan', icon: IconSchool },
|
||||||
subsidi: {label: 'Subsidi', icon: IconShoppingCart},
|
subsidi: { label: 'Subsidi', icon: IconShoppingCart },
|
||||||
layananKesehatan: {label: 'Layanan Kesehatan', icon: IconHospital},
|
layananKesehatan: { label: 'Layanan Kesehatan', icon: IconHospital },
|
||||||
polisi: {label: 'Polisi', icon: IconShieldFilled},
|
polisi: { label: 'Polisi', icon: IconShieldFilled },
|
||||||
ambulans: {label: 'Ambulans', icon: IconAmbulance},
|
ambulans: { label: 'Ambulans', icon: IconAmbulance },
|
||||||
pemadam: {label: 'Pemadam', icon: IconFiretruck},
|
pemadam: { label: 'Pemadam', icon: IconFiretruck },
|
||||||
rumahSakit: {label: 'Rumah Sakit', icon: IconHospital},
|
rumahSakit: { label: 'Rumah Sakit', icon: IconHospital },
|
||||||
bangunan: {label: 'Bangunan', icon: IconBuilding},
|
bangunan: { label: 'Bangunan', icon: IconBuilding },
|
||||||
darurat: {label: 'Darurat', icon: IconAlertTriangle},
|
darurat: { label: 'Darurat', icon: IconAlertTriangle },
|
||||||
};
|
};
|
||||||
|
|
||||||
type IconKey = keyof typeof iconMap;
|
export type IconKey = keyof typeof iconMap;
|
||||||
|
|
||||||
const iconList = Object.entries(iconMap).map(([value, data]) => ({
|
const iconList = Object.entries(iconMap).map(([value, data]) => ({
|
||||||
value,
|
value,
|
||||||
@@ -67,44 +67,52 @@ const iconList = Object.entries(iconMap).map(([value, data]) => ({
|
|||||||
export default function SelectIconProgramEdit({
|
export default function SelectIconProgramEdit({
|
||||||
onChange,
|
onChange,
|
||||||
value,
|
value,
|
||||||
|
...props
|
||||||
}: {
|
}: {
|
||||||
onChange: (value: IconKey) => void;
|
onChange: (value: IconKey | '') => void;
|
||||||
value: IconKey;
|
value: IconKey | '';
|
||||||
}) {
|
} & Omit<SelectProps, 'onChange' | 'value' | 'data'>) {
|
||||||
const IconComponent = iconMap[value]?.icon || null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box maw={300}>
|
<Box maw={300}>
|
||||||
<Select
|
<Select
|
||||||
placeholder="Pilih ikon"
|
placeholder="Pilih ikon"
|
||||||
value={value}
|
value={value || ''}
|
||||||
onChange={(value) => {
|
onChange={(val: string | null) => {
|
||||||
if (value) onChange(value as IconKey);
|
if (val) {
|
||||||
|
onChange(val as IconKey);
|
||||||
|
} else {
|
||||||
|
onChange('');
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
data={iconList}
|
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={
|
leftSection={
|
||||||
IconComponent && (
|
value && iconMap[value as IconKey] ? (
|
||||||
<Box>
|
<Box ml={-4}>
|
||||||
<IconComponent size={24} stroke={1.5} />
|
{(() => {
|
||||||
|
const Icon = iconMap[value as IconKey].icon;
|
||||||
|
return <Icon size={20} stroke={1.5} />;
|
||||||
|
})()}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
) : null
|
||||||
}
|
}
|
||||||
withCheckIcon={false}
|
searchable
|
||||||
searchable={false}
|
|
||||||
rightSectionWidth={0}
|
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
input: {
|
||||||
textAlign: 'left',
|
|
||||||
fontSize: rem(16),
|
|
||||||
paddingLeft: 40,
|
paddingLeft: 40,
|
||||||
},
|
fontSize: rem(16),
|
||||||
section: {
|
|
||||||
left: 10,
|
|
||||||
right: 'auto',
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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) {
|
if (res.status === 200) {
|
||||||
penghargaanState.findMany.load();
|
penghargaanState.findMany.load();
|
||||||
return toast.success("success create");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
console.log(res);
|
console.log(res);
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const category = proxy({
|
|||||||
const res = await ApiFetch.api.desa.kategoripengumuman[
|
const res = await ApiFetch.api.desa.kategoripengumuman[
|
||||||
"findMany"
|
"findMany"
|
||||||
].get({
|
].get({
|
||||||
query: { page, limit },
|
query: { page, limit, search },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 200 && res.data?.success) {
|
if (res.status === 200 && res.data?.success) {
|
||||||
@@ -287,7 +287,7 @@ const pengumuman = proxy({
|
|||||||
);
|
);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
pengumuman.findMany.load();
|
pengumuman.findMany.load();
|
||||||
return toast.success("success create");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
console.log(res);
|
console.log(res);
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const potensiDesa = proxy({
|
|||||||
const res = await ApiFetch.api.desa.potensi[
|
const res = await ApiFetch.api.desa.potensi[
|
||||||
"find-many"
|
"find-many"
|
||||||
].get({
|
].get({
|
||||||
query: { page, limit },
|
query: { page, limit, search },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 200 && res.data?.success) {
|
if (res.status === 200 && res.data?.success) {
|
||||||
|
|||||||
@@ -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: {
|
update: {
|
||||||
id: "",
|
id: "",
|
||||||
form: { ...ApbDesaDefaultForm },
|
form: { ...ApbDesaDefaultForm },
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const demografiPekerjaan = proxy({
|
|||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
const id = res.data?.data?.id;
|
const id = res.data?.data?.id;
|
||||||
if (id) {
|
if (id) {
|
||||||
toast.success("Success create");
|
toast.success("Sukses menambahkan");
|
||||||
demografiPekerjaan.create.form = { ...defaultForm };
|
demografiPekerjaan.create.form = { ...defaultForm };
|
||||||
demografiPekerjaan.findMany.load();
|
demografiPekerjaan.findMany.load();
|
||||||
return id;
|
return id;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const jumlahPendudukMiskin = proxy({
|
|||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
const id = res.data?.data?.id;
|
const id = res.data?.data?.id;
|
||||||
if (id) {
|
if (id) {
|
||||||
toast.success("Success create");
|
toast.success("Sukses menambahkan");
|
||||||
jumlahPendudukMiskin.create.form = {
|
jumlahPendudukMiskin.create.form = {
|
||||||
year: 0,
|
year: 0,
|
||||||
totalPoorPopulation: 0,
|
totalPoorPopulation: 0,
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ const jumlahPengangguran = proxy({
|
|||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
const id = res.data?.id;
|
const id = res.data?.id;
|
||||||
if (id) {
|
if (id) {
|
||||||
toast.success("Success create");
|
toast.success("Sukses menambahkan");
|
||||||
jumlahPengangguran.create.form = { ...jumlahPengangguranForm };
|
jumlahPengangguran.create.form = { ...jumlahPengangguranForm };
|
||||||
jumlahPengangguran.findMany.load();
|
jumlahPengangguran.findMany.load();
|
||||||
return id;
|
return id;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const lowonganKerjaState = proxy({
|
|||||||
);
|
);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
lowonganKerjaState.create.loading = false;
|
lowonganKerjaState.create.loading = false;
|
||||||
return toast.success("success create");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
console.log(res);
|
console.log(res);
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
|
|||||||
@@ -312,15 +312,15 @@ const kategoriProduk = proxy({
|
|||||||
page: 1,
|
page: 1,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
loading: false,
|
loading: false,
|
||||||
search2: "",
|
search: "",
|
||||||
load: async (page = 1, limit = 10, search2 = "") => {
|
load: async (page = 1, limit = 10, search = "") => {
|
||||||
kategoriProduk.findMany.loading = true; // ✅ Akses langsung via nama path
|
kategoriProduk.findMany.loading = true; // ✅ Akses langsung via nama path
|
||||||
kategoriProduk.findMany.page = page;
|
kategoriProduk.findMany.page = page;
|
||||||
kategoriProduk.findMany.search2 = search2;
|
kategoriProduk.findMany.search = search;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const query: any = { page, limit };
|
const query: any = { page, limit };
|
||||||
if (search2) query.search2 = search2;
|
if (search) query.search = search;
|
||||||
|
|
||||||
const res = await ApiFetch.api.ekonomi.kategoriproduk["find-many"].get({ query });
|
const res = await ApiFetch.api.ekonomi.kategoriproduk["find-many"].get({ query });
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const programKemiskinanState = proxy({
|
|||||||
);
|
);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
programKemiskinanState.findMany.load();
|
programKemiskinanState.findMany.load();
|
||||||
return toast.success("success create");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
console.log(res);
|
console.log(res);
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const grafikSektorUnggulan = proxy({
|
|||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
const id = res.data?.data?.id;
|
const id = res.data?.data?.id;
|
||||||
if (id) {
|
if (id) {
|
||||||
toast.success("Success create");
|
toast.success("Sukses menambahkan");
|
||||||
grafikSektorUnggulan.create.form = {
|
grafikSektorUnggulan.create.form = {
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ const posisiOrganisasi = proxy({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
const res = await ApiFetch.api.ekonomi['struktur-organisasi']['posisi-organisasi']['create'].post(this.form);
|
const res = await ApiFetch.api.ekonomi["struktur-organisasi"]["posisi-organisasi"]["create"].post(this.form);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
toast.success("Berhasil menambahkan posisi organisasi");
|
toast.success("Berhasil menambahkan posisi organisasi");
|
||||||
posisiOrganisasi.findMany.load();
|
posisiOrganisasi.findMany.load();
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const grafikBerdasarkanUsiaKerjaNganggur = proxy({
|
|||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
const id = res.data?.data?.id;
|
const id = res.data?.data?.id;
|
||||||
if (id) {
|
if (id) {
|
||||||
toast.success("Success create");
|
toast.success("Sukses menambahkan");
|
||||||
grafikBerdasarkanUsiaKerjaNganggur.create.form = {
|
grafikBerdasarkanUsiaKerjaNganggur.create.form = {
|
||||||
usia18_25: "",
|
usia18_25: "",
|
||||||
usia26_35: "",
|
usia26_35: "",
|
||||||
@@ -255,7 +255,7 @@ const grafikBerdasarkanPendidikan = proxy({
|
|||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
const id = res.data?.data?.id;
|
const id = res.data?.data?.id;
|
||||||
if (id) {
|
if (id) {
|
||||||
toast.success("Success create");
|
toast.success("Sukses menambahkan");
|
||||||
grafikBerdasarkanPendidikan.create.form = {
|
grafikBerdasarkanPendidikan.create.form = {
|
||||||
SD: "",
|
SD: "",
|
||||||
SMP: "",
|
SMP: "",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const desaDigitalState = proxy({
|
|||||||
);
|
);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
desaDigitalState.findMany.load();
|
desaDigitalState.findMany.load();
|
||||||
return toast.success("success create");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
console.log(res);
|
console.log(res);
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const infoTeknoState = proxy({
|
|||||||
);
|
);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
infoTeknoState.findMany.load();
|
infoTeknoState.findMany.load();
|
||||||
return toast.success("success create");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
console.log(res);
|
console.log(res);
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { proxy } from "valtio";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const templateForm = z.object({
|
const templateForm = z.object({
|
||||||
name: z.string().min(1, "Nama minimal 1 karakter"),
|
name: z.string().min(5, "Nama minimal 5 karakter"),
|
||||||
deskripsi: z.string().min(1, "Deskripsi minimal 1 karakter"),
|
deskripsi: z.string().min(5, "Deskripsi minimal 5 karakter"),
|
||||||
slug: z.string().min(1, "Deskripsi singkat minimal 1 karakter"),
|
slug: z.string().min(5, "Deskripsi singkat minimal 5 karakter"),
|
||||||
icon: z.string().min(1, "Icon minimal 1 karakter"),
|
icon: z.string().min(1, "Icon minimal 1 karakter"),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,26 +29,33 @@ const programKreatifState = proxy({
|
|||||||
const err = `[${cek.error.issues
|
const err = `[${cek.error.issues
|
||||||
.map((v) => `${v.path.join(".")}`)
|
.map((v) => `${v.path.join(".")}`)
|
||||||
.join("\n")}] required`;
|
.join("\n")}] required`;
|
||||||
return toast.error(err);
|
toast.error(err);
|
||||||
|
return false; // ⬅️ ini penting
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
programKreatifState.create.loading = true;
|
programKreatifState.create.loading = true;
|
||||||
const res = await ApiFetch.api.inovasi.programkreatif["create"].post(
|
const res = await ApiFetch.api.inovasi.programkreatif["create"].post(
|
||||||
programKreatifState.create.form
|
programKreatifState.create.form
|
||||||
);
|
);
|
||||||
|
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
programKreatifState.findMany.load();
|
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) {
|
} catch (error) {
|
||||||
console.log((error as Error).message);
|
console.error((error as Error).message);
|
||||||
|
toast.error("Terjadi kesalahan saat create");
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
programKreatifState.create.loading = false;
|
programKreatifState.create.loading = false;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
findMany: {
|
findMany: {
|
||||||
data: null as any[] | null,
|
data: null as any[] | null,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const keamananLingkunganState = proxy({
|
|||||||
].post(keamananLingkunganState.create.form);
|
].post(keamananLingkunganState.create.form);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
keamananLingkunganState.findMany.load();
|
keamananLingkunganState.findMany.load();
|
||||||
return toast.success("success create");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
console.log(res);
|
console.log(res);
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const kontakDaruratKeamananState = proxy({
|
|||||||
].post(kontakDaruratKeamananState.create.form);
|
].post(kontakDaruratKeamananState.create.form);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
kontakDaruratKeamananState.findMany.load();
|
kontakDaruratKeamananState.findMany.load();
|
||||||
return toast.success("success create");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
console.log(res);
|
console.log(res);
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
@@ -294,7 +294,7 @@ const kontakDaruratItem = proxy({
|
|||||||
);
|
);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
kontakDaruratItem.findMany.load();
|
kontakDaruratItem.findMany.load();
|
||||||
return toast.success("success create");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
console.log(res);
|
console.log(res);
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ const laporanPublikState = proxy({
|
|||||||
|
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
laporanPublikState.findMany.load();
|
laporanPublikState.findMany.load();
|
||||||
return toast.success("success create");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(res);
|
console.log(res);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const pencegahanKriminalitasState = proxy({
|
|||||||
].post(pencegahanKriminalitasState.create.form);
|
].post(pencegahanKriminalitasState.create.form);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
pencegahanKriminalitasState.findMany.load();
|
pencegahanKriminalitasState.findMany.load();
|
||||||
return toast.success("success create");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
console.log(res);
|
console.log(res);
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const tipsKeamananState = proxy({
|
|||||||
);
|
);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
tipsKeamananState.findMany.load();
|
tipsKeamananState.findMany.load();
|
||||||
return toast.success("success create");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
console.log(res);
|
console.log(res);
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
|
|||||||
@@ -9,29 +9,30 @@ import { z } from "zod";
|
|||||||
// Validasi form
|
// Validasi form
|
||||||
const templateForm = z.object({
|
const templateForm = z.object({
|
||||||
name: z.string().min(1, "Nama harus diisi"),
|
name: z.string().min(1, "Nama harus diisi"),
|
||||||
|
|
||||||
informasiUmum: z.object({
|
informasiUmum: z.object({
|
||||||
fasilitas: z.string().min(1, "Fasilitas harus diisi"),
|
fasilitas: z.string().min(1),
|
||||||
alamat: z.string().min(1, "Alamat harus diisi"),
|
alamat: z.string().min(1),
|
||||||
jamOperasional: z.string().min(1, "Jam operasional harus diisi"),
|
jamOperasional: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
layananUnggulan: z.object({
|
layananUnggulan: z.object({
|
||||||
content: z.string().min(1, "Layanan unggulan harus diisi"),
|
content: z.string().min(1),
|
||||||
}),
|
|
||||||
dokterdanTenagaMedis: z.object({
|
|
||||||
name: z.string().min(1, "Nama dokter harus diisi"),
|
|
||||||
specialist: z.string().min(1, "Spesialis harus diisi"),
|
|
||||||
jadwal: z.string().min(1, "Jadwal harus diisi"),
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// NOW ARRAY OF STRING (ID)
|
||||||
|
dokterdanTenagaMedis: z.array(z.string()).min(1, "Minimal pilih 1 dokter"),
|
||||||
|
|
||||||
fasilitasPendukung: z.object({
|
fasilitasPendukung: z.object({
|
||||||
content: z.string().min(1, "Fasilitas pendukung harus diisi"),
|
content: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
prosedurPendaftaran: z.object({
|
prosedurPendaftaran: z.object({
|
||||||
content: z.string().min(1, "Prosedur pendaftaran harus diisi"),
|
content: z.string().min(1),
|
||||||
}),
|
|
||||||
tarifDanLayanan: z.object({
|
|
||||||
layanan: z.string().min(1, "Layanan harus diisi"),
|
|
||||||
tarif: z.string().min(1, "Tarif harus diisi"),
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// NOW ARRAY OF STRING (ID)
|
||||||
|
tarifDanLayanan: z.array(z.string()).min(1, "Minimal pilih 1 tarif"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Default form kosong
|
// Default form kosong
|
||||||
@@ -45,21 +46,34 @@ const defaultForm = {
|
|||||||
layananUnggulan: {
|
layananUnggulan: {
|
||||||
content: "",
|
content: "",
|
||||||
},
|
},
|
||||||
dokterdanTenagaMedis: {
|
|
||||||
name: "",
|
dokterdanTenagaMedis: [] as string[], // ← array kosong
|
||||||
specialist: "",
|
tarifDanLayanan: [] as string[], // ← array kosong
|
||||||
jadwal: "",
|
|
||||||
},
|
|
||||||
fasilitasPendukung: {
|
fasilitasPendukung: {
|
||||||
content: "",
|
content: "",
|
||||||
},
|
},
|
||||||
prosedurPendaftaran: {
|
prosedurPendaftaran: {
|
||||||
content: "",
|
content: "",
|
||||||
},
|
},
|
||||||
tarifDanLayanan: {
|
};
|
||||||
layanan: "",
|
|
||||||
tarif: "",
|
type DokterItem = {
|
||||||
},
|
id: string;
|
||||||
|
name: string;
|
||||||
|
specialist: string;
|
||||||
|
jadwal: string;
|
||||||
|
jadwalLibur: string;
|
||||||
|
jamBukaOperasional: string;
|
||||||
|
jamTutupOperasional: string;
|
||||||
|
jamBukaLibur: string;
|
||||||
|
jamTutupLibur: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TarifItem = {
|
||||||
|
id: string;
|
||||||
|
layanan: string;
|
||||||
|
tarif: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fasilitasKesehatan = proxy({
|
const fasilitasKesehatan = proxy({
|
||||||
@@ -186,33 +200,26 @@ const fasilitasKesehatan = proxy({
|
|||||||
|
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
const data = result.data;
|
const data = result.data;
|
||||||
|
this.id = data.id;
|
||||||
fasilitasKesehatan.edit.id = data.id;
|
this.form = {
|
||||||
fasilitasKesehatan.edit.form = {
|
|
||||||
name: data.name,
|
name: data.name,
|
||||||
informasiUmum: {
|
informasiUmum: {
|
||||||
fasilitas: data.informasiumum.fasilitas,
|
fasilitas: data.informasiumum.fasilitas,
|
||||||
alamat: data.informasiumum.alamat,
|
alamat: data.informasiumum.alamat,
|
||||||
jamOperasional: data.informasiumum.jamOperasional,
|
jamOperasional: data.informasiumum.jamOperasional,
|
||||||
},
|
},
|
||||||
layananUnggulan: {
|
|
||||||
content: data.layananunggulan.content,
|
|
||||||
},
|
|
||||||
dokterdanTenagaMedis: {
|
|
||||||
name: data.dokterdantenagamedis.name,
|
|
||||||
specialist: data.dokterdantenagamedis.specialist,
|
|
||||||
jadwal: data.dokterdantenagamedis.jadwal,
|
|
||||||
},
|
|
||||||
fasilitasPendukung: {
|
fasilitasPendukung: {
|
||||||
content: data.fasilitaspendukung.content,
|
content: data.fasilitaspendukung.content,
|
||||||
},
|
},
|
||||||
prosedurPendaftaran: {
|
prosedurPendaftaran: {
|
||||||
content: data.prosedurpendaftaran.content,
|
content: data.prosedurpendaftaran.content,
|
||||||
},
|
},
|
||||||
tarifDanLayanan: {
|
// map relasi -> array of IDs
|
||||||
layanan: data.tarifdanlayanan.layanan,
|
layananUnggulan: {
|
||||||
tarif: data.tarifdanlayanan.tarif,
|
content: data.layananunggulan.content,
|
||||||
},
|
},
|
||||||
|
dokterdanTenagaMedis: data.dokterdantenagamedis?.map((v: DokterItem) => v.id) ?? [],
|
||||||
|
tarifDanLayanan: data.tarifdanlayanan?.map((v: TarifItem) => v.id) ?? [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async submit() {
|
async submit() {
|
||||||
@@ -238,22 +245,15 @@ const fasilitasKesehatan = proxy({
|
|||||||
layananUnggulan: {
|
layananUnggulan: {
|
||||||
content: fasilitasKesehatan.edit.form.layananUnggulan.content,
|
content: fasilitasKesehatan.edit.form.layananUnggulan.content,
|
||||||
},
|
},
|
||||||
dokterdanTenagaMedis: {
|
dokterdanTenagaMedis:
|
||||||
name: fasilitasKesehatan.edit.form.dokterdanTenagaMedis.name,
|
fasilitasKesehatan.edit.form.dokterdanTenagaMedis,
|
||||||
specialist:
|
|
||||||
fasilitasKesehatan.edit.form.dokterdanTenagaMedis.specialist,
|
|
||||||
jadwal: fasilitasKesehatan.edit.form.dokterdanTenagaMedis.jadwal,
|
|
||||||
},
|
|
||||||
fasilitasPendukung: {
|
fasilitasPendukung: {
|
||||||
content: fasilitasKesehatan.edit.form.fasilitasPendukung.content,
|
content: fasilitasKesehatan.edit.form.fasilitasPendukung.content,
|
||||||
},
|
},
|
||||||
prosedurPendaftaran: {
|
prosedurPendaftaran: {
|
||||||
content: fasilitasKesehatan.edit.form.prosedurPendaftaran.content,
|
content: fasilitasKesehatan.edit.form.prosedurPendaftaran.content,
|
||||||
},
|
},
|
||||||
tarifDanLayanan: {
|
tarifDanLayanan: fasilitasKesehatan.edit.form.tarifDanLayanan,
|
||||||
layanan: fasilitasKesehatan.edit.form.tarifDanLayanan.layanan,
|
|
||||||
tarif: fasilitasKesehatan.edit.form.tarifDanLayanan.tarif,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
@@ -320,12 +320,26 @@ const templateDokterForm = z.object({
|
|||||||
name: z.string().min(1, "Nama tidak boleh kosong"),
|
name: z.string().min(1, "Nama tidak boleh kosong"),
|
||||||
specialist: z.string().min(1, "Spesialis tidak boleh kosong"),
|
specialist: z.string().min(1, "Spesialis tidak boleh kosong"),
|
||||||
jadwal: z.string().min(1, "Jadwal tidak boleh kosong"),
|
jadwal: z.string().min(1, "Jadwal tidak boleh kosong"),
|
||||||
|
jadwalLibur: z.string().min(1, "Jadwal libur tidak boleh kosong"),
|
||||||
|
jamBukaOperasional: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Jam buka operasional tidak boleh kosong"),
|
||||||
|
jamTutupOperasional: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Jam tutup operasional tidak boleh kosong"),
|
||||||
|
jamBukaLibur: z.string().min(1, "Jam buka libur tidak boleh kosong"),
|
||||||
|
jamTutupLibur: z.string().min(1, "Jam tutup libur tidak boleh kosong"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultDokterForm = {
|
const defaultDokterForm = {
|
||||||
name: "",
|
name: "",
|
||||||
specialist: "",
|
specialist: "",
|
||||||
jadwal: "",
|
jadwal: "",
|
||||||
|
jadwalLibur: "",
|
||||||
|
jamBukaOperasional: "",
|
||||||
|
jamTutupOperasional: "",
|
||||||
|
jamBukaLibur: "",
|
||||||
|
jamTutupLibur: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const dokter = proxy({
|
const dokter = proxy({
|
||||||
@@ -351,7 +365,7 @@ const dokter = proxy({
|
|||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
const id = res.data?.data;
|
const id = res.data?.data;
|
||||||
if (id) {
|
if (id) {
|
||||||
toast.success("Success create");
|
toast.success("Sukses menambahkan");
|
||||||
dokter.create.create.form = { ...defaultDokterForm };
|
dokter.create.create.form = { ...defaultDokterForm };
|
||||||
dokter.findMany.load();
|
dokter.findMany.load();
|
||||||
return id;
|
return id;
|
||||||
@@ -463,6 +477,11 @@ const dokter = proxy({
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
specialist: data.specialist,
|
specialist: data.specialist,
|
||||||
jadwal: data.jadwal,
|
jadwal: data.jadwal,
|
||||||
|
jadwalLibur: data.jadwalLibur,
|
||||||
|
jamBukaOperasional: data.jamBukaOperasional,
|
||||||
|
jamTutupOperasional: data.jamTutupOperasional,
|
||||||
|
jamBukaLibur: data.jamBukaLibur,
|
||||||
|
jamTutupLibur: data.jamTutupLibur,
|
||||||
};
|
};
|
||||||
return data; // Return the loaded data
|
return data; // Return the loaded data
|
||||||
} else {
|
} else {
|
||||||
@@ -487,6 +506,11 @@ const dokter = proxy({
|
|||||||
name: this.form.name,
|
name: this.form.name,
|
||||||
specialist: this.form.specialist,
|
specialist: this.form.specialist,
|
||||||
jadwal: this.form.jadwal,
|
jadwal: this.form.jadwal,
|
||||||
|
jadwalLibur: this.form.jadwalLibur,
|
||||||
|
jamBukaOperasional: this.form.jamBukaOperasional,
|
||||||
|
jamTutupOperasional: this.form.jamTutupOperasional,
|
||||||
|
jamBukaLibur: this.form.jamBukaLibur,
|
||||||
|
jamTutupLibur: this.form.jamTutupLibur,
|
||||||
};
|
};
|
||||||
|
|
||||||
const cek = templateDokterForm.safeParse(formData);
|
const cek = templateDokterForm.safeParse(formData);
|
||||||
@@ -567,9 +591,255 @@ const dokter = proxy({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const templateTarifForm = z.object({
|
||||||
|
tarif: z.string().min(1, "Tarif tidak boleh kosong"),
|
||||||
|
layanan: z.string().min(1, "Layanan tidak boleh kosong"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultTarifForm = {
|
||||||
|
tarif: "",
|
||||||
|
layanan: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const tarif = proxy({
|
||||||
|
create: {
|
||||||
|
form: defaultTarifForm,
|
||||||
|
loading: false,
|
||||||
|
async create() {
|
||||||
|
const cek = templateTarifForm.safeParse(tarif.create.form);
|
||||||
|
if (!cek.success) {
|
||||||
|
const err = `[${cek.error.issues
|
||||||
|
.map((v) => `${v.path.join(".")}`)
|
||||||
|
.join("\n")}] required`;
|
||||||
|
toast.error(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
tarif.create.loading = true;
|
||||||
|
const res = await ApiFetch.api.kesehatan.tarifdanlayanan["create"].post(
|
||||||
|
tarif.create.form
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
const id = res.data?.data;
|
||||||
|
if (id) {
|
||||||
|
toast.success("Sukses menambahkan");
|
||||||
|
tarif.create.form = { ...defaultTarifForm };
|
||||||
|
tarif.findMany.load();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.error("failed create");
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.log((error as Error).message);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
tarif.create.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
findMany: {
|
||||||
|
data: null as
|
||||||
|
| Prisma.TarifDanLayananGetPayload<{
|
||||||
|
omit: {
|
||||||
|
isActive: true;
|
||||||
|
};
|
||||||
|
}>[]
|
||||||
|
| null,
|
||||||
|
page: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
loading: false,
|
||||||
|
search: "",
|
||||||
|
load: async (page = 1, limit = 10, search = "") => {
|
||||||
|
tarif.findMany.loading = true; // ✅ Akses langsung via nama path
|
||||||
|
tarif.findMany.page = page;
|
||||||
|
tarif.findMany.search = search;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query: any = { page, limit };
|
||||||
|
if (search) query.search = search;
|
||||||
|
|
||||||
|
const res = await ApiFetch.api.kesehatan.tarifdanlayanan[
|
||||||
|
"findMany"
|
||||||
|
].get({ query });
|
||||||
|
|
||||||
|
if (res.status === 200 && res.data?.success) {
|
||||||
|
tarif.findMany.data = res.data.data ?? [];
|
||||||
|
tarif.findMany.totalPages = res.data.totalPages ?? 1;
|
||||||
|
} else {
|
||||||
|
tarif.findMany.data = [];
|
||||||
|
tarif.findMany.totalPages = 1;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal fetch tarif dan layanan paginated:", err);
|
||||||
|
tarif.findMany.data = [];
|
||||||
|
tarif.findMany.totalPages = 1;
|
||||||
|
} finally {
|
||||||
|
tarif.findMany.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
findUnique: {
|
||||||
|
data: null as Prisma.TarifDanLayananGetPayload<{
|
||||||
|
omit: { isActive: true };
|
||||||
|
}> | null,
|
||||||
|
async load(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/kesehatan/tarifdanlayanan/${id}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
tarif.findUnique.data = data.data ?? null;
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"Failed to fetch tarif dan layanan",
|
||||||
|
res.statusText
|
||||||
|
);
|
||||||
|
tarif.findUnique.data = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching tarif dan layanan", error);
|
||||||
|
tarif.findUnique.data = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
id: "",
|
||||||
|
form: { ...defaultTarifForm },
|
||||||
|
loading: false,
|
||||||
|
async load(id: string) {
|
||||||
|
if (!id) {
|
||||||
|
toast.warn("ID tidak valid");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/kesehatan/tarifdanlayanan/${id}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result?.success) {
|
||||||
|
const data = result.data;
|
||||||
|
this.id = data.id;
|
||||||
|
this.form = {
|
||||||
|
tarif: data.tarif,
|
||||||
|
layanan: data.layanan
|
||||||
|
};
|
||||||
|
return data; // Return the loaded data
|
||||||
|
} else {
|
||||||
|
throw new Error(result?.message || "Gagal memuat data");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading tarif dan layanan:", error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Gagal memuat data"
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async submit() {
|
||||||
|
const id = this.id;
|
||||||
|
if (!id) {
|
||||||
|
toast.warn("ID tidak valid");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
tarif: this.form.tarif,
|
||||||
|
layanan: this.form.layanan
|
||||||
|
};
|
||||||
|
|
||||||
|
const cek = templateTarifForm.safeParse(formData);
|
||||||
|
if (!cek.success) {
|
||||||
|
const err = `[${cek.error.issues
|
||||||
|
.map((v: any) => `${v.path.join(".")}`)
|
||||||
|
.join("\n")}] required`;
|
||||||
|
toast.error(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
const res = await fetch(`/api/kesehatan/tarifdanlayanan/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || !result?.success) {
|
||||||
|
throw new Error(result?.message || "Gagal update data");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Berhasil update data!");
|
||||||
|
await tarif.findMany.load();
|
||||||
|
return result.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update error:", error);
|
||||||
|
toast.error("Gagal update data tarif dan layanan");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
loading: false,
|
||||||
|
async byId(id: string) {
|
||||||
|
if (!id) {
|
||||||
|
return toast.warn("ID tidak valid");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
tarif.delete.loading = true;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/kesehatan/tarifdanlayanan/del/${id}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result?.success) {
|
||||||
|
toast.success(
|
||||||
|
result.message || "tarif dan layanan berhasil dihapus"
|
||||||
|
);
|
||||||
|
await tarif.findMany.load(); // refresh list
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
result?.message || "Gagal menghapus tarif dan layanan"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gagal delete:", error);
|
||||||
|
toast.error("Terjadi kesalahan saat menghapus tarif dan layanan");
|
||||||
|
} finally {
|
||||||
|
tarif.delete.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const fasilitasKesehatanState = proxy({
|
const fasilitasKesehatanState = proxy({
|
||||||
fasilitasKesehatan,
|
fasilitasKesehatan,
|
||||||
dokter,
|
dokter,
|
||||||
|
tarif
|
||||||
});
|
});
|
||||||
|
|
||||||
export default fasilitasKesehatanState;
|
export default fasilitasKesehatanState;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const grafikkepuasan = proxy({
|
|||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
const id = res.data?.data;
|
const id = res.data?.data;
|
||||||
if (id) {
|
if (id) {
|
||||||
toast.success("Success create");
|
toast.success("Sukses menambahkan");
|
||||||
grafikkepuasan.create.form = { ...defaultForm };
|
grafikkepuasan.create.form = { ...defaultForm };
|
||||||
grafikkepuasan.findMany.load();
|
grafikkepuasan.findMany.load();
|
||||||
return id;
|
return id;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const persentasekelahiran = proxy({
|
|||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
const id = res.data?.data;
|
const id = res.data?.data;
|
||||||
if (id) {
|
if (id) {
|
||||||
toast.success("Success create");
|
toast.success("Sukses menambahkan");
|
||||||
persentasekelahiran.create.form = { ...defaultForm };
|
persentasekelahiran.create.form = { ...defaultForm };
|
||||||
persentasekelahiran.findMany.load();
|
persentasekelahiran.findMany.load();
|
||||||
return id;
|
return id;
|
||||||
|
|||||||
@@ -5,58 +5,117 @@ import { toast } from "react-toastify";
|
|||||||
import { proxy } from "valtio";
|
import { proxy } from "valtio";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const templateapbDesaForm = z.object({
|
// --- Zod Schema ---
|
||||||
name: z.string().min(1, "Judul minimal 1 karakter"),
|
const ApbdesItemSchema = z.object({
|
||||||
jumlah: z.string().min(1, "Deskripsi minimal 1 karakter"),
|
kode: z.string().min(1, "Kode wajib diisi"),
|
||||||
imageId: z.string().min(1, "File minimal 1"),
|
uraian: z.string().min(1, "Uraian wajib diisi"),
|
||||||
fileId: z.string().min(1, "File minimal 1"),
|
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 = {
|
const ApbdesFormSchema = z.object({
|
||||||
name: "",
|
tahun: z.number().int().min(2000, "Tahun tidak valid"),
|
||||||
jumlah: "",
|
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: "",
|
imageId: "",
|
||||||
fileId: "",
|
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({
|
const apbdes = proxy({
|
||||||
create: {
|
create: {
|
||||||
form: { ...defaultapbdesForm },
|
form: { ...defaultApbdesForm },
|
||||||
loading: false,
|
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();
|
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: any) {
|
||||||
} catch (error) {
|
console.error("Create APBDes error:", error);
|
||||||
console.log(error);
|
toast.error(error?.message || "Terjadi kesalahan saat membuat APBDes");
|
||||||
toast.error("Gagal menambahkan data");
|
|
||||||
} finally {
|
} finally {
|
||||||
apbdes.create.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
findMany: {
|
findMany: {
|
||||||
data: null as
|
data: null as
|
||||||
| Prisma.APBDesGetPayload<{
|
| Prisma.APBDesGetPayload<{
|
||||||
include: {
|
include: { image: true; file: true; items: true };
|
||||||
image: true;
|
|
||||||
file: true;
|
|
||||||
};
|
|
||||||
}>[]
|
}>[]
|
||||||
| null,
|
| null,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -64,194 +123,202 @@ const apbdes = proxy({
|
|||||||
total: 0,
|
total: 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
search: "",
|
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.page = page;
|
||||||
apbdes.findMany.search = search;
|
apbdes.findMany.search = search;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const query: any = { page, limit };
|
const query: Record<string, string> = { page: String(page), limit: String(limit) };
|
||||||
if (search) query.search = search;
|
if (search) query.search = search;
|
||||||
|
|
||||||
const res = await ApiFetch.api.landingpage.apbdes[
|
const res = await ApiFetch.api.landingpage.apbdes["findMany"].get({ query });
|
||||||
"findMany"
|
|
||||||
].get({
|
if (res.data?.success) {
|
||||||
query
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 200 && res.data?.success) {
|
|
||||||
apbdes.findMany.data = res.data.data || [];
|
apbdes.findMany.data = res.data.data || [];
|
||||||
apbdes.findMany.total = res.data.total || 0;
|
apbdes.findMany.total = res.data.meta?.total || 0;
|
||||||
apbdes.findMany.totalPages = res.data.totalPages || 1;
|
apbdes.findMany.totalPages = res.data.meta?.totalPages || 1;
|
||||||
} else {
|
} else {
|
||||||
console.error("Failed to load pegawai:", res.data?.message);
|
|
||||||
apbdes.findMany.data = [];
|
apbdes.findMany.data = [];
|
||||||
apbdes.findMany.total = 0;
|
apbdes.findMany.total = 0;
|
||||||
apbdes.findMany.totalPages = 1;
|
apbdes.findMany.totalPages = 1;
|
||||||
|
toast.error(res.data?.message || "Gagal memuat data");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading pegawai:", error);
|
console.error("FindMany error:", error);
|
||||||
apbdes.findMany.data = [];
|
apbdes.findMany.data = [];
|
||||||
apbdes.findMany.total = 0;
|
apbdes.findMany.total = 0;
|
||||||
apbdes.findMany.totalPages = 1;
|
apbdes.findMany.totalPages = 1;
|
||||||
|
toast.error("Gagal memuat daftar APBDes");
|
||||||
} finally {
|
} finally {
|
||||||
apbdes.findMany.loading = false;
|
apbdes.findMany.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
findUnique: {
|
findUnique: {
|
||||||
data: null as Prisma.APBDesGetPayload<{
|
data: null as
|
||||||
include: {
|
| Prisma.APBDesGetPayload<{
|
||||||
image: true;
|
include: { image: true; file: true; items: true };
|
||||||
file: true;
|
}>
|
||||||
};
|
| null,
|
||||||
}> | null,
|
loading: false,
|
||||||
|
error: null as string | null,
|
||||||
|
|
||||||
async load(id: string) {
|
async load(id: string) {
|
||||||
|
if (!id || id.trim() === '') {
|
||||||
|
this.data = null;
|
||||||
|
this.error = "ID tidak valid";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/landingpage/apbdes/${id}`);
|
// Pastikan URL-nya benar
|
||||||
if (res.ok) {
|
const url = `/api/landingpage/apbdes/${id}`;
|
||||||
const data = await res.json();
|
console.log("🌐 Fetching:", url);
|
||||||
apbdes.findUnique.data = data.data ?? null;
|
|
||||||
|
// 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 {
|
} else {
|
||||||
console.error("Failed to fetch data", res.status, res.statusText);
|
this.data = null;
|
||||||
apbdes.findUnique.data = null;
|
this.error = res.message || "Gagal memuat detail APBDes";
|
||||||
|
toast.error(this.error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching data:", error);
|
console.error("❌ FindUnique error:", error);
|
||||||
apbdes.findUnique.data = null;
|
this.data = null;
|
||||||
|
this.error = "Gagal memuat detail APBDes";
|
||||||
|
toast.error(this.error);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: {
|
delete: {
|
||||||
loading: false,
|
loading: false,
|
||||||
async byId(id: string) {
|
async byId(id: string) {
|
||||||
if (!id) return toast.warn("ID tidak valid");
|
if (!id) return toast.warn("ID tidak valid");
|
||||||
|
|
||||||
try {
|
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}`, {
|
if (res.data?.success) {
|
||||||
method: "DELETE",
|
toast.success("APBDes berhasil dihapus");
|
||||||
headers: {
|
apbdes.findMany.load();
|
||||||
"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
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(result?.message || "Gagal menghapus apbdes");
|
toast.error(res.data?.message || "Gagal menghapus APBDes");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Gagal delete:", error);
|
console.error("Delete error:", error);
|
||||||
toast.error("Terjadi kesalahan saat menghapus apbdes");
|
toast.error(error?.message || "Terjadi kesalahan saat menghapus");
|
||||||
} finally {
|
} finally {
|
||||||
apbdes.delete.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
edit: {
|
edit: {
|
||||||
id: "",
|
id: "",
|
||||||
form: { ...defaultapbdesForm },
|
form: { ...defaultApbdesForm },
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|
||||||
async load(id: string) {
|
async load(id: string) {
|
||||||
if (!id) {
|
if (!id) return toast.warn("ID tidak valid");
|
||||||
toast.warn("ID tidak valid");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
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}`, {
|
if (res.data?.success) {
|
||||||
method: "GET",
|
const data = res.data.data;
|
||||||
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.id = data.id;
|
||||||
this.form = {
|
this.form = {
|
||||||
name: data.name,
|
tahun: data.tahun || new Date().getFullYear(),
|
||||||
jumlah: data.jumlah,
|
imageId: data.imageId || "",
|
||||||
imageId: data.imageId,
|
fileId: data.fileId || "",
|
||||||
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;
|
return data;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result?.message || "Gagal memuat data");
|
throw new Error(res.data?.message || "Gagal memuat data");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Error loading apbdes:", error);
|
console.error("Edit load error:", error);
|
||||||
toast.error(
|
toast.error(error.message || "Gagal memuat data untuk diedit");
|
||||||
error instanceof Error ? error.message : "Gagal memuat data"
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
} finally {
|
} finally {
|
||||||
apbdes.edit.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async update() {
|
async update() {
|
||||||
const cek = templateapbDesaForm.safeParse(apbdes.edit.form);
|
const parsed = ApbdesFormSchema.safeParse(this.form);
|
||||||
if (!cek.success) {
|
if (!parsed.success) {
|
||||||
const err = `[${cek.error.issues
|
const errors = parsed.error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`);
|
||||||
.map((v) => `${v.path.join(".")}`)
|
toast.error(`Validasi gagal:\n${errors.join("\n")}`);
|
||||||
.join("\n")}] required`;
|
return false;
|
||||||
return toast.error(err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
apbdes.edit.loading = true;
|
this.loading = true;
|
||||||
const response = await fetch(`/api/landingpage/apbdes/${this.id}`, {
|
// Include the ID in the request body
|
||||||
method: "PUT",
|
const requestData = {
|
||||||
headers: {
|
...parsed.data,
|
||||||
"Content-Type": "application/json",
|
id: this.id, // Add the ID to the request body
|
||||||
},
|
};
|
||||||
body: JSON.stringify({
|
|
||||||
name: this.form.name,
|
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
|
||||||
jumlah: this.form.jumlah,
|
|
||||||
imageId: this.form.imageId,
|
if (res.data?.success) {
|
||||||
fileId: this.form.fileId,
|
toast.success("APBDes berhasil diperbarui");
|
||||||
}),
|
apbdes.findMany.load();
|
||||||
});
|
|
||||||
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
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.message || "Gagal mengupdate apbdes");
|
throw new Error(res.data?.message || "Gagal memperbarui APBDes");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Error updating apbdes:", error);
|
console.error("Update error:", error);
|
||||||
toast.error(
|
toast.error(error.message || "Gagal memperbarui APBDes");
|
||||||
error instanceof Error ? error.message : "Gagal mengupdate apbdes"
|
|
||||||
);
|
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} 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() {
|
reset() {
|
||||||
apbdes.edit.id = "";
|
this.id = "";
|
||||||
apbdes.edit.form = { ...defaultapbdesForm };
|
this.form = { ...defaultApbdesForm };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apbdes;
|
export default apbdes;
|
||||||
@@ -60,13 +60,18 @@ const responden = proxy({
|
|||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
total: 0,
|
total: 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
load: async (page = 1, limit = 10) => {
|
search: "",
|
||||||
|
load: async (page = 1, limit = 10, search = "") => {
|
||||||
// Change to arrow function
|
// Change to arrow function
|
||||||
responden.findMany.loading = true; // Use the full path to access the property
|
responden.findMany.loading = true; // Use the full path to access the property
|
||||||
responden.findMany.page = page;
|
responden.findMany.page = page;
|
||||||
|
responden.findMany.search = search;
|
||||||
try {
|
try {
|
||||||
|
const query: any = { page, limit };
|
||||||
|
if (search) query.search = search;
|
||||||
|
|
||||||
const res = await ApiFetch.api.landingpage.responden["findMany"].get({
|
const res = await ApiFetch.api.landingpage.responden["findMany"].get({
|
||||||
query: { page, limit },
|
query,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 200 && res.data?.success) {
|
if (res.status === 200 && res.data?.success) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const programInovasi = proxy({
|
|||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
imageId: "",
|
imageId: "",
|
||||||
link: ""
|
link: "",
|
||||||
} as ProgramInovasiForm,
|
} as ProgramInovasiForm,
|
||||||
loading: false,
|
loading: false,
|
||||||
async create() {
|
async create() {
|
||||||
@@ -53,7 +53,7 @@ const programInovasi = proxy({
|
|||||||
].post(formData);
|
].post(formData);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
programInovasi.findMany.load();
|
programInovasi.findMany.load();
|
||||||
return toast.success("success create");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
console.log(res);
|
console.log(res);
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
@@ -71,20 +71,21 @@ const programInovasi = proxy({
|
|||||||
total: 0,
|
total: 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
search: "",
|
search: "",
|
||||||
load: async (page = 1, limit = 10, search = "") => { // Change to arrow function
|
load: async (page = 1, limit = 10, search = "") => {
|
||||||
programInovasi.findMany.loading = true; // Use the full path to access the property
|
// Change to arrow function
|
||||||
|
programInovasi.findMany.loading = true; // Use the full path to access the property
|
||||||
programInovasi.findMany.page = page;
|
programInovasi.findMany.page = page;
|
||||||
programInovasi.findMany.search = search;
|
programInovasi.findMany.search = search;
|
||||||
try {
|
try {
|
||||||
const query: any = { page, limit };
|
const query: any = { page, limit };
|
||||||
if (search) query.search = search;
|
if (search) query.search = search;
|
||||||
|
|
||||||
const res = await ApiFetch.api.landingpage.programinovasi[
|
const res = await ApiFetch.api.landingpage.programinovasi[
|
||||||
"findMany"
|
"findMany"
|
||||||
].get({
|
].get({
|
||||||
query
|
query,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 200 && res.data?.success) {
|
if (res.status === 200 && res.data?.success) {
|
||||||
programInovasi.findMany.data = res.data.data || [];
|
programInovasi.findMany.data = res.data.data || [];
|
||||||
programInovasi.findMany.total = res.data.total || 0;
|
programInovasi.findMany.total = res.data.total || 0;
|
||||||
@@ -389,7 +390,10 @@ const pejabatDesa = proxy({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure ID is properly encoded in the URL
|
// Ensure ID is properly encoded in the URL
|
||||||
const url = new URL(`/api/landingpage/pejabatdesa/${encodeURIComponent(this.id)}`, window.location.origin);
|
const url = new URL(
|
||||||
|
`/api/landingpage/pejabatdesa/${encodeURIComponent(this.id)}`,
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
const response = await fetch(url.toString(), {
|
const response = await fetch(url.toString(), {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -438,16 +442,19 @@ const pejabatDesa = proxy({
|
|||||||
|
|
||||||
const templateMediaSosial = z.object({
|
const templateMediaSosial = z.object({
|
||||||
name: z.string().min(3, "Nama minimal 3 karakter"),
|
name: z.string().min(3, "Nama minimal 3 karakter"),
|
||||||
imageId: z.string().min(1, "Gambar wajib dipilih"),
|
imageId: z.string().nullable().optional(),
|
||||||
iconUrl: z.string().min(3, "Icon URL minimal 3 karakter"),
|
iconUrl: z.string().min(3, "Icon URL minimal 3 karakter"),
|
||||||
|
icon: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type MediaSosialForm = {
|
type MediaSosialForm = {
|
||||||
name: string;
|
name: string;
|
||||||
imageId: string;
|
imageId: string | null; // boleh null
|
||||||
iconUrl: string;
|
iconUrl: string;
|
||||||
|
icon: string | null; // boleh null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const mediaSosial = proxy({
|
const mediaSosial = proxy({
|
||||||
create: {
|
create: {
|
||||||
form: {} as MediaSosialForm,
|
form: {} as MediaSosialForm,
|
||||||
@@ -455,9 +462,10 @@ const mediaSosial = proxy({
|
|||||||
async create() {
|
async create() {
|
||||||
// Ensure all required fields are non-null
|
// Ensure all required fields are non-null
|
||||||
const formData = {
|
const formData = {
|
||||||
name: mediaSosial.create.form.name || "",
|
name: mediaSosial.create.form.name ?? "",
|
||||||
imageId: mediaSosial.create.form.imageId || "",
|
imageId: mediaSosial.create.form.imageId ?? null, // FIXED
|
||||||
iconUrl: mediaSosial.create.form.iconUrl || "",
|
iconUrl: mediaSosial.create.form.iconUrl ?? "",
|
||||||
|
icon: mediaSosial.create.form.icon ?? null, // FIXED
|
||||||
};
|
};
|
||||||
|
|
||||||
const cek = templateMediaSosial.safeParse(formData);
|
const cek = templateMediaSosial.safeParse(formData);
|
||||||
@@ -474,7 +482,7 @@ const mediaSosial = proxy({
|
|||||||
);
|
);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
mediaSosial.findMany.load();
|
mediaSosial.findMany.load();
|
||||||
return toast.success("success create");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
console.log(res);
|
console.log(res);
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
@@ -492,20 +500,19 @@ const mediaSosial = proxy({
|
|||||||
total: 0,
|
total: 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
search: "",
|
search: "",
|
||||||
load: async (page = 1, limit = 10, search = "") => { // Change to arrow function
|
load: async (page = 1, limit = 10, search = "") => {
|
||||||
mediaSosial.findMany.loading = true; // Use the full path to access the property
|
// Change to arrow function
|
||||||
|
mediaSosial.findMany.loading = true; // Use the full path to access the property
|
||||||
mediaSosial.findMany.page = page;
|
mediaSosial.findMany.page = page;
|
||||||
mediaSosial.findMany.search = search;
|
mediaSosial.findMany.search = search;
|
||||||
try {
|
try {
|
||||||
const query: any = { page, limit };
|
const query: any = { page, limit };
|
||||||
if (search) query.search = search;
|
if (search) query.search = search;
|
||||||
|
|
||||||
const res = await ApiFetch.api.landingpage.mediasosial[
|
const res = await ApiFetch.api.landingpage.mediasosial["findMany"].get({
|
||||||
"findMany"
|
|
||||||
].get({
|
|
||||||
query,
|
query,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 200 && res.data?.success) {
|
if (res.status === 200 && res.data?.success) {
|
||||||
mediaSosial.findMany.data = res.data.data || [];
|
mediaSosial.findMany.data = res.data.data || [];
|
||||||
mediaSosial.findMany.total = res.data.total || 0;
|
mediaSosial.findMany.total = res.data.total || 0;
|
||||||
@@ -537,7 +544,7 @@ const mediaSosial = proxy({
|
|||||||
toast.warn("ID tidak valid");
|
toast.warn("ID tidak valid");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaSosial.update.loading = true;
|
mediaSosial.update.loading = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/landingpage/mediasosial/${id}`);
|
const res = await fetch(`/api/landingpage/mediasosial/${id}`);
|
||||||
@@ -586,66 +593,72 @@ const mediaSosial = proxy({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
id: "",
|
id: "",
|
||||||
form: {} as MediaSosialForm,
|
form: {} as MediaSosialForm,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|
||||||
async load(id: string) {
|
async load(id: string) {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
toast.warn("ID tidak valid");
|
toast.warn("ID tidak valid");
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaSosial.update.loading = true; // ✅ Tambahkan ini di awal
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/landingpage/mediasosial/${id}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaSosial.update.loading = true; // ✅ Tambahkan ini di awal
|
const result = await response.json();
|
||||||
|
|
||||||
try {
|
if (result?.success) {
|
||||||
const response = await fetch(`/api/landingpage/mediasosial/${id}`, {
|
const data = result.data;
|
||||||
method: "GET",
|
this.id = data.id;
|
||||||
headers: {
|
this.form = {
|
||||||
"Content-Type": "application/json",
|
name: data.name || "",
|
||||||
},
|
imageId: data.imageId || null,
|
||||||
});
|
iconUrl: data.iconUrl || "",
|
||||||
|
icon: data.icon || null,
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
};
|
||||||
}
|
return data;
|
||||||
|
} else {
|
||||||
const result = await response.json();
|
throw new Error(
|
||||||
|
result?.message || "Gagal mengambil data media sosial"
|
||||||
if (result?.success) {
|
);
|
||||||
const data = result.data;
|
|
||||||
this.id = data.id;
|
|
||||||
this.form = {
|
|
||||||
name: data.name || "",
|
|
||||||
imageId: data.imageId || "",
|
|
||||||
iconUrl: data.iconUrl || "",
|
|
||||||
};
|
|
||||||
return data;
|
|
||||||
} else {
|
|
||||||
throw new Error(result?.message || "Gagal mengambil data media sosial");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error((error as Error).message);
|
|
||||||
toast.error("Terjadi kesalahan saat mengambil data media sosial");
|
|
||||||
} finally {
|
|
||||||
mediaSosial.update.loading = false; // ✅ Supaya berhenti loading walau error
|
|
||||||
}
|
}
|
||||||
},
|
} catch (error) {
|
||||||
|
console.error((error as Error).message);
|
||||||
async update() {
|
toast.error("Terjadi kesalahan saat mengambil data media sosial");
|
||||||
const cek = templateMediaSosial.safeParse(mediaSosial.update.form);
|
} finally {
|
||||||
if (!cek.success) {
|
mediaSosial.update.loading = false; // ✅ Supaya berhenti loading walau error
|
||||||
const err = `[${cek.error.issues
|
}
|
||||||
.map((v) => `${v.path.join(".")}`)
|
},
|
||||||
.join("\n")}] required`;
|
|
||||||
toast.error(err);
|
async update() {
|
||||||
return false;
|
const cek = templateMediaSosial.safeParse(mediaSosial.update.form);
|
||||||
}
|
if (!cek.success) {
|
||||||
|
const err = `[${cek.error.issues
|
||||||
try {
|
.map((v) => `${v.path.join(".")}`)
|
||||||
mediaSosial.update.loading = true;
|
.join("\n")}] required`;
|
||||||
|
toast.error(err);
|
||||||
const response = await fetch(`/api/landingpage/mediasosial/${this.id}`, {
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
mediaSosial.update.loading = true;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/landingpage/mediasosial/${this.id}`,
|
||||||
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -654,38 +667,40 @@ const mediaSosial = proxy({
|
|||||||
name: this.form.name,
|
name: this.form.name,
|
||||||
imageId: this.form.imageId,
|
imageId: this.form.imageId,
|
||||||
iconUrl: this.form.iconUrl,
|
iconUrl: this.form.iconUrl,
|
||||||
|
icon: this.form.icon,
|
||||||
}),
|
}),
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(
|
|
||||||
errorData.message || `HTTP error! status: ${response.status}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
const result = await response.json();
|
|
||||||
|
if (!response.ok) {
|
||||||
if (result.success) {
|
const errorData = await response.json().catch(() => ({}));
|
||||||
toast.success("Berhasil update media sosial");
|
throw new Error(
|
||||||
await mediaSosial.findMany.load(); // refresh list
|
errorData.message || `HTTP error! status: ${response.status}`
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
throw new Error(result.message || "Gagal update media sosial");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating media sosial:", error);
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Terjadi kesalahan saat update media sosial"
|
|
||||||
);
|
);
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
mediaSosial.update.loading = false;
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Berhasil update media sosial");
|
||||||
|
await mediaSosial.findMany.load(); // refresh list
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || "Gagal update media sosial");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating media sosial:", error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Terjadi kesalahan saat update media sosial"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
mediaSosial.update.loading = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const profileLandingPageState = proxy({
|
const profileLandingPageState = proxy({
|
||||||
|
|||||||
@@ -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: {
|
findUnique: {
|
||||||
data: null as Prisma.SdgsDesaGetPayload<{
|
data: null as Prisma.SdgsDesaGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const dataLingkunganDesaState = proxy({
|
|||||||
);
|
);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
dataLingkunganDesaState.findMany.load();
|
dataLingkunganDesaState.findMany.load();
|
||||||
return toast.success("success create");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
console.log(res);
|
console.log(res);
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const pengelolaanSampah = proxy({
|
|||||||
].post(pengelolaanSampah.create.form);
|
].post(pengelolaanSampah.create.form);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
pengelolaanSampah.findMany.load();
|
pengelolaanSampah.findMany.load();
|
||||||
return toast.success("success create");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
console.log(res);
|
console.log(res);
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const programPenghijauanState = proxy({
|
|||||||
);
|
);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
programPenghijauanState.findMany.load();
|
programPenghijauanState.findMany.load();
|
||||||
return toast.success("success create");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
console.log(res);
|
console.log(res);
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
|
|||||||
@@ -9,34 +9,32 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const templateBeasiswaPendaftar = z.object({
|
const templateBeasiswaPendaftar = z.object({
|
||||||
namaLengkap: z.string().min(1, "Nama harus diisi"),
|
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"),
|
tempatLahir: z.string().min(1, "Tempat lahir harus diisi"),
|
||||||
tanggalLahir: z.string().min(1, "Tanggal lahir harus diisi"),
|
tanggalLahir: z.string().min(1, "Tanggal lahir harus diisi"),
|
||||||
jenisKelamin: z.string().min(1, "Jenis kelamin harus diisi"),
|
namaOrtu: z.string().min(1, "Nama ortu harus diisi"),
|
||||||
kewarganegaraan: z.string().min(1, "Kewarganegaraan harus diisi"),
|
nik: z.string().min(1, "NIK harus diisi"),
|
||||||
agama: z.string().min(1, "Agama harus diisi"),
|
pekerjaanOrtu: z.string().min(1, "Pekerjaan ortu harus diisi"),
|
||||||
alamatKTP: z.string().min(1, "Alamat KTP harus diisi"),
|
penghasilan: z.string().min(1, "Penghasilan ortu harus diisi"),
|
||||||
alamatDomisili: z.string().min(1, "Alamat domisili harus diisi"),
|
|
||||||
noHp: z.string().min(1, "No HP 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 = {
|
const defaultBeasiswaPendaftar = {
|
||||||
namaLengkap: "",
|
namaLengkap: "",
|
||||||
nik: "",
|
nis: "",
|
||||||
|
kelas: "",
|
||||||
|
jenisKelamin: "",
|
||||||
|
alamatDomisili: "",
|
||||||
tempatLahir: "",
|
tempatLahir: "",
|
||||||
tanggalLahir: "",
|
tanggalLahir: "",
|
||||||
jenisKelamin: "",
|
namaOrtu: "",
|
||||||
kewarganegaraan: "",
|
nik: "",
|
||||||
agama: "",
|
pekerjaanOrtu: "",
|
||||||
alamatKTP: "",
|
penghasilan: "",
|
||||||
alamatDomisili: "",
|
|
||||||
noHp: "",
|
noHp: "",
|
||||||
email: "",
|
|
||||||
statusPernikahan: "",
|
|
||||||
ukuranBaju: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const beasiswaPendaftar = proxy({
|
const beasiswaPendaftar = proxy({
|
||||||
@@ -200,18 +198,17 @@ const beasiswaPendaftar = proxy({
|
|||||||
this.id = data.id;
|
this.id = data.id;
|
||||||
this.form = {
|
this.form = {
|
||||||
namaLengkap: data.namaLengkap,
|
namaLengkap: data.namaLengkap,
|
||||||
nik: data.nik,
|
nis: data.nis,
|
||||||
|
kelas: data.kelas,
|
||||||
|
jenisKelamin: data.jenisKelamin,
|
||||||
|
alamatDomisili: data.alamatDomisili,
|
||||||
tempatLahir: data.tempatLahir,
|
tempatLahir: data.tempatLahir,
|
||||||
tanggalLahir: data.tanggalLahir,
|
tanggalLahir: data.tanggalLahir,
|
||||||
jenisKelamin: data.jenisKelamin,
|
namaOrtu: data.namaOrtu,
|
||||||
kewarganegaraan: data.kewarganegaraan,
|
nik: data.nik,
|
||||||
agama: data.agama,
|
pekerjaanOrtu: data.pekerjaanOrtu,
|
||||||
alamatKTP: data.alamatKTP,
|
penghasilan: data.penghasilan,
|
||||||
alamatDomisili: data.alamatDomisili,
|
|
||||||
noHp: data.noHp,
|
noHp: data.noHp,
|
||||||
email: data.email,
|
|
||||||
statusPernikahan: data.statusPernikahan,
|
|
||||||
ukuranBaju: data.ukuranBaju,
|
|
||||||
};
|
};
|
||||||
return data; // Return the loaded data
|
return data; // Return the loaded data
|
||||||
} else {
|
} else {
|
||||||
@@ -249,17 +246,17 @@ const beasiswaPendaftar = proxy({
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
namaLengkap: this.form.namaLengkap,
|
namaLengkap: this.form.namaLengkap,
|
||||||
nik: this.form.nik,
|
nis: this.form.nis,
|
||||||
tanggalLahir: this.form.tanggalLahir,
|
kelas: this.form.kelas,
|
||||||
jenisKelamin: this.form.jenisKelamin,
|
jenisKelamin: this.form.jenisKelamin,
|
||||||
kewarganegaraan: this.form.kewarganegaraan,
|
|
||||||
agama: this.form.agama,
|
|
||||||
alamatKTP: this.form.alamatKTP,
|
|
||||||
alamatDomisili: this.form.alamatDomisili,
|
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,
|
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 ApiFetch from "@/lib/api-fetch";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
@@ -42,7 +43,7 @@ const dataPendidikan = proxy({
|
|||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
const id = res.data?.data?.id;
|
const id = res.data?.data?.id;
|
||||||
if (id) {
|
if (id) {
|
||||||
toast.success("Success create");
|
toast.success("Sukses menambahkan");
|
||||||
dataPendidikan.create.form = {
|
dataPendidikan.create.form = {
|
||||||
name: "",
|
name: "",
|
||||||
jumlah: "",
|
jumlah: "",
|
||||||
@@ -65,13 +66,46 @@ const dataPendidikan = proxy({
|
|||||||
select: { id: true; name: true; jumlah: true };
|
select: { id: true; name: true; jumlah: true };
|
||||||
}>[]
|
}>[]
|
||||||
| null,
|
| null,
|
||||||
|
page: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
total: 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
async load() {
|
search: "",
|
||||||
const res = await ApiFetch.api.pendidikan.datapendidikan[
|
load: async (page = 1, limit = 10, search = "") => {
|
||||||
"findMany"
|
// Change to arrow function
|
||||||
].get();
|
dataPendidikan.findMany.loading = true; // Use the full path to access the property
|
||||||
if (res.status === 200) {
|
dataPendidikan.findMany.page = page;
|
||||||
dataPendidikan.findMany.data = res.data?.data ?? [];
|
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);
|
].post(daftarInformasiPublik.create.form);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
daftarInformasiPublik.findMany.load();
|
daftarInformasiPublik.findMany.load();
|
||||||
return toast.success("success create");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const grafikBerdasarkanUmur = proxy({
|
|||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
const id = res.data?.data?.id;
|
const id = res.data?.data?.id;
|
||||||
if (id) {
|
if (id) {
|
||||||
toast.success("Success create");
|
toast.success("Sukses menambahkan");
|
||||||
grafikBerdasarkanUmur.create.form = {
|
grafikBerdasarkanUmur.create.form = {
|
||||||
remaja: "",
|
remaja: "",
|
||||||
dewasa: "",
|
dewasa: "",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import ApiFetch from "@/lib/api-fetch";
|
import ApiFetch from "@/lib/api-fetch";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
@@ -6,145 +7,207 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const templateForm = z.object({
|
const templateForm = z.object({
|
||||||
name: z.string().min(3, "Nama minimal 3 karakter"),
|
name: z.string().min(3, "Nama minimal 3 karakter"),
|
||||||
nik: z.string().min(3, "NIK minimal 3 karakter"),
|
nik: z
|
||||||
notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"),
|
.string()
|
||||||
|
.min(3, "NIK minimal 3 karakter")
|
||||||
|
.max(16, "NIK maksimal 16 angka"),
|
||||||
|
notelp: z
|
||||||
|
.string()
|
||||||
|
.min(3, "Nomor Telepon minimal 3 karakter")
|
||||||
|
.max(15, "Nomor Telepon maksimal 15 angka"),
|
||||||
alamat: z.string().min(3, "Alamat minimal 3 karakter"),
|
alamat: z.string().min(3, "Alamat minimal 3 karakter"),
|
||||||
email: z.string().min(3, "Email minimal 3 karakter"),
|
email: z.string().min(3, "Email minimal 3 karakter"),
|
||||||
jenisInformasiDimintaId: z.string().nonempty(),
|
jenisInformasiDimintaId: z.string().nonempty(),
|
||||||
caraMemperolehInformasiId: z.string().nonempty(),
|
caraMemperolehInformasiId: z.string().nonempty(),
|
||||||
caraMemperolehSalinanInformasiId: z.string().nonempty(),
|
caraMemperolehSalinanInformasiId: z.string().nonempty(),
|
||||||
})
|
});
|
||||||
|
|
||||||
const jenisInformasiDiminta = proxy({
|
const jenisInformasiDiminta = proxy({
|
||||||
findMany: {
|
findMany: {
|
||||||
data: null as
|
data: null as
|
||||||
| null
|
| null
|
||||||
| Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[],
|
| Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[],
|
||||||
async load(){
|
async load() {
|
||||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi["find-many"].get();
|
const res =
|
||||||
if (res.status === 200) {
|
await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi[
|
||||||
jenisInformasiDiminta.findMany.data = res.data?.data ?? [];
|
"find-many"
|
||||||
}
|
].get();
|
||||||
}
|
if (res.status === 200) {
|
||||||
}
|
jenisInformasiDiminta.findMany.data = res.data?.data ?? [];
|
||||||
})
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const caraMemperolehInformasi = proxy({
|
const caraMemperolehInformasi = proxy({
|
||||||
findMany: {
|
findMany: {
|
||||||
data: null as
|
data: null as
|
||||||
| null
|
| null
|
||||||
| Prisma.CaraMemperolehInformasiGetPayload<{ omit: { isActive: true } }>[],
|
| Prisma.CaraMemperolehInformasiGetPayload<{
|
||||||
async load() {
|
omit: { isActive: true };
|
||||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi["find-many"].get();
|
}>[],
|
||||||
if (res.status === 200) {
|
async load() {
|
||||||
caraMemperolehInformasi.findMany.data = res.data?.data ?? [];
|
const res =
|
||||||
}
|
await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi[
|
||||||
}
|
"find-many"
|
||||||
}
|
].get();
|
||||||
})
|
if (res.status === 200) {
|
||||||
|
caraMemperolehInformasi.findMany.data = res.data?.data ?? [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const caraMemperolehSalinanInformasi = proxy({
|
const caraMemperolehSalinanInformasi = proxy({
|
||||||
findMany: {
|
findMany: {
|
||||||
data: null as
|
data: null as
|
||||||
| null
|
| null
|
||||||
| Prisma.CaraMemperolehSalinanInformasiGetPayload<{ omit: { isActive: true } }>[],
|
| Prisma.CaraMemperolehSalinanInformasiGetPayload<{
|
||||||
async load() {
|
omit: { isActive: true };
|
||||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi["find-many"].get();
|
}>[],
|
||||||
if (res.status === 200) {
|
async load() {
|
||||||
caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? [];
|
const res =
|
||||||
}
|
await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi[
|
||||||
}
|
"find-many"
|
||||||
}
|
].get();
|
||||||
})
|
if (res.status === 200) {
|
||||||
console.log(caraMemperolehSalinanInformasi)
|
caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(caraMemperolehSalinanInformasi);
|
||||||
|
|
||||||
type PermohonanInformasiPublikForm = Prisma.PermohonanInformasiPublikGetPayload<{
|
type PermohonanInformasiPublikForm =
|
||||||
|
Prisma.PermohonanInformasiPublikGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
name: true;
|
name: true;
|
||||||
nik: true;
|
nik: true;
|
||||||
notelp: true;
|
notelp: true;
|
||||||
alamat: true;
|
alamat: true;
|
||||||
email: true;
|
email: true;
|
||||||
jenisInformasiDimintaId: true;
|
jenisInformasiDimintaId: true;
|
||||||
caraMemperolehInformasiId: true;
|
caraMemperolehInformasiId: true;
|
||||||
caraMemperolehSalinanInformasiId: true;
|
caraMemperolehSalinanInformasiId: true;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const statepermohonanInformasiPublik = proxy({
|
const statepermohonanInformasiPublik = proxy({
|
||||||
create: {
|
create: {
|
||||||
form: {} as PermohonanInformasiPublikForm,
|
form: {} as PermohonanInformasiPublikForm,
|
||||||
loading: false,
|
loading: false,
|
||||||
async create(){
|
async create() {
|
||||||
const cek = templateForm.safeParse(statepermohonanInformasiPublik.create.form);
|
const cek = templateForm.safeParse(
|
||||||
if(!cek.success) {
|
statepermohonanInformasiPublik.create.form
|
||||||
const err = `[${cek.error.issues
|
);
|
||||||
.map((v) => `${v.path.join(".")}`)
|
|
||||||
.join("\n")}] required`;
|
if (!cek.success) {
|
||||||
return toast.error(err);
|
toast.error(cek.error.issues.map((i) => i.message).join("\n"));
|
||||||
}
|
return false; // ⬅️ tambahkan return false
|
||||||
try {
|
}
|
||||||
statepermohonanInformasiPublik.create.loading = true;
|
|
||||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik["create"].post(statepermohonanInformasiPublik.create.form);
|
try {
|
||||||
if (res.status === 200) {
|
statepermohonanInformasiPublik.create.loading = true;
|
||||||
statepermohonanInformasiPublik.findMany.load();
|
const res = await ApiFetch.api.ppid.permohonaninformasipublik[
|
||||||
return toast.success("success create");
|
"create"
|
||||||
}
|
].post(statepermohonanInformasiPublik.create.form);
|
||||||
return toast.error("failed create");
|
|
||||||
} catch (error) {
|
if (res.data?.success === false) {
|
||||||
console.log((error as Error).message);
|
toast.error(res.data?.message);
|
||||||
} finally {
|
return false; // ⬅️ gagal
|
||||||
statepermohonanInformasiPublik.create.loading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast.success("Sukses menambahkan");
|
||||||
|
return true; // ⬅️ sukses
|
||||||
|
} catch {
|
||||||
|
toast.error("Terjadi kesalahan server");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
statepermohonanInformasiPublik.create.loading = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
findMany: {
|
},
|
||||||
data: null as
|
findMany: {
|
||||||
| Prisma.PermohonanInformasiPublikGetPayload<{ include: {
|
data: null as
|
||||||
caraMemperolehSalinanInformasi: true,
|
| Prisma.PermohonanInformasiPublikGetPayload<{
|
||||||
jenisInformasiDiminta: true,
|
|
||||||
caraMemperolehInformasi: true,
|
|
||||||
} }>[]
|
|
||||||
| null,
|
|
||||||
async load() {
|
|
||||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik["find-many"].get();
|
|
||||||
if (res.status === 200) {
|
|
||||||
statepermohonanInformasiPublik.findMany.data = res.data?.data ?? [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
findUnique: {
|
|
||||||
data: null as Prisma.PermohonanInformasiPublikGetPayload<{
|
|
||||||
include: {
|
include: {
|
||||||
jenisInformasiDiminta: true,
|
caraMemperolehSalinanInformasi: true;
|
||||||
caraMemperolehInformasi: true,
|
jenisInformasiDiminta: true;
|
||||||
caraMemperolehSalinanInformasi: true,
|
caraMemperolehInformasi: true;
|
||||||
};
|
};
|
||||||
}> | null,
|
}>[]
|
||||||
async load(id: string) {
|
| null,
|
||||||
try {
|
page: 1,
|
||||||
const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`);
|
totalPages: 1,
|
||||||
if (res.ok) {
|
total: 0,
|
||||||
const data = await res.json();
|
loading: false,
|
||||||
statepermohonanInformasiPublik.findUnique.data = data.data ?? null;
|
search: "",
|
||||||
} else {
|
load: async (page = 1, limit = 10, search = "") => {
|
||||||
console.error("Failed to fetch program inovasi:", res.statusText);
|
// Change to arrow function
|
||||||
statepermohonanInformasiPublik.findUnique.data = null;
|
statepermohonanInformasiPublik.findMany.loading = true; // Use the full path to access the property
|
||||||
}
|
statepermohonanInformasiPublik.findMany.page = page;
|
||||||
} catch (error) {
|
statepermohonanInformasiPublik.findMany.search = search;
|
||||||
console.error("Error fetching program inovasi:", error);
|
try {
|
||||||
statepermohonanInformasiPublik.findUnique.data = null;
|
const query: any = { page, limit };
|
||||||
}
|
if (search) query.search = search;
|
||||||
},
|
|
||||||
},
|
const res = await ApiFetch.api.ppid.permohonaninformasipublik[
|
||||||
|
"find-many"
|
||||||
})
|
].get({
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 200 && res.data?.success) {
|
||||||
|
statepermohonanInformasiPublik.findMany.data = res.data.data || [];
|
||||||
|
statepermohonanInformasiPublik.findMany.total = res.data.total || 0;
|
||||||
|
statepermohonanInformasiPublik.findMany.totalPages = res.data.totalPages || 1;
|
||||||
|
} else {
|
||||||
|
console.error("Failed to load permohonan keberatan informasi:", res.data?.message);
|
||||||
|
statepermohonanInformasiPublik.findMany.data = [];
|
||||||
|
statepermohonanInformasiPublik.findMany.total = 0;
|
||||||
|
statepermohonanInformasiPublik.findMany.totalPages = 1;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading permohonan keberatan informasi:", error);
|
||||||
|
statepermohonanInformasiPublik.findMany.data = [];
|
||||||
|
statepermohonanInformasiPublik.findMany.total = 0;
|
||||||
|
statepermohonanInformasiPublik.findMany.totalPages = 1;
|
||||||
|
} finally {
|
||||||
|
statepermohonanInformasiPublik.findMany.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
findUnique: {
|
||||||
|
data: null as Prisma.PermohonanInformasiPublikGetPayload<{
|
||||||
|
include: {
|
||||||
|
jenisInformasiDiminta: true;
|
||||||
|
caraMemperolehInformasi: true;
|
||||||
|
caraMemperolehSalinanInformasi: true;
|
||||||
|
};
|
||||||
|
}> | null,
|
||||||
|
async load(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
statepermohonanInformasiPublik.findUnique.data = data.data ?? null;
|
||||||
|
} else {
|
||||||
|
console.error("Failed to fetch program inovasi:", res.statusText);
|
||||||
|
statepermohonanInformasiPublik.findUnique.data = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching program inovasi:", error);
|
||||||
|
statepermohonanInformasiPublik.findUnique.data = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const statepermohonanInformasiPublikForm = proxy({
|
const statepermohonanInformasiPublikForm = proxy({
|
||||||
statepermohonanInformasiPublik,
|
statepermohonanInformasiPublik,
|
||||||
jenisInformasiDiminta,
|
jenisInformasiDiminta,
|
||||||
caraMemperolehInformasi,
|
caraMemperolehInformasi,
|
||||||
caraMemperolehSalinanInformasi,
|
caraMemperolehSalinanInformasi,
|
||||||
})
|
});
|
||||||
|
|
||||||
export default statepermohonanInformasiPublikForm;
|
export default statepermohonanInformasiPublikForm;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import ApiFetch from "@/lib/api-fetch";
|
import ApiFetch from "@/lib/api-fetch";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
@@ -5,82 +6,130 @@ import { proxy } from "valtio";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const templateForm = z.object({
|
const templateForm = z.object({
|
||||||
name: z.string().min(3, "Nama minimal 3 karakter"),
|
name: z.string().min(3, "Nama minimal 3 karakter"),
|
||||||
email: z.string().min(3, "Email minimal 3 karakter"),
|
email: z.string().min(3, "Email minimal 3 karakter"),
|
||||||
notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"),
|
notelp: z
|
||||||
alasan: z.string().min(3, "Alasan minimal 3 karakter"),
|
.string()
|
||||||
})
|
.min(3, "Nomor Telepon minimal 3 karakter")
|
||||||
|
.max(15, "Nomor Telepon maksimal 15 angka"),
|
||||||
|
alasan: z.string().min(3, "Alasan minimal 3 karakter"),
|
||||||
|
});
|
||||||
|
|
||||||
type PermohonanKeberatanInformasiForm = Prisma.FormulirPermohonanKeberatanGetPayload<{
|
type PermohonanKeberatanInformasiForm =
|
||||||
|
Prisma.FormulirPermohonanKeberatanGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
name: true;
|
name: true;
|
||||||
email: true;
|
email: true;
|
||||||
notelp: true;
|
notelp: true;
|
||||||
alasan: true;
|
alasan: true;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const permohonanKeberatanInformasi = proxy({
|
const permohonanKeberatanInformasi = proxy({
|
||||||
create: {
|
create: {
|
||||||
form: {} as PermohonanKeberatanInformasiForm,
|
form: {} as PermohonanKeberatanInformasiForm,
|
||||||
loading: false,
|
loading: false,
|
||||||
async create(){
|
async create() {
|
||||||
const cek = templateForm.safeParse(permohonanKeberatanInformasi.create.form);
|
const cek = templateForm.safeParse(
|
||||||
if(!cek.success) {
|
permohonanKeberatanInformasi.create.form
|
||||||
const err = `[${cek.error.issues
|
);
|
||||||
.map((v) => `${v.path.join(".")}`)
|
if (!cek.success) {
|
||||||
.join("\n")}] required`;
|
toast.error(cek.error.issues.map((i) => i.message).join("\n"));
|
||||||
return toast.error(err);
|
return false; // ⬅️ tambahkan return false
|
||||||
}
|
|
||||||
try {
|
|
||||||
permohonanKeberatanInformasi.create.loading = true;
|
|
||||||
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["create"].post(permohonanKeberatanInformasi.create.form);
|
|
||||||
if (res.status === 200) {
|
|
||||||
permohonanKeberatanInformasi.findMany.load();
|
|
||||||
return toast.success("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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
permohonanKeberatanInformasi.create.loading = true;
|
||||||
|
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[
|
||||||
|
"create"
|
||||||
|
].post(permohonanKeberatanInformasi.create.form);
|
||||||
|
if (res.data?.success === false) {
|
||||||
|
toast.error(res.data?.message);
|
||||||
|
return false; // ⬅️ gagal
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Sukses menambahkan");
|
||||||
|
return true; // ⬅️ sukses
|
||||||
|
} catch {
|
||||||
|
toast.error("Terjadi kesalahan server");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
permohonanKeberatanInformasi.create.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
findMany: {
|
||||||
|
data: null as
|
||||||
|
| null
|
||||||
|
| Prisma.FormulirPermohonanKeberatanGetPayload<{
|
||||||
|
omit: { isActive: true };
|
||||||
|
}>[],
|
||||||
|
page: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
total: 0,
|
||||||
|
loading: false,
|
||||||
|
search: "",
|
||||||
|
load: async (page = 1, limit = 10, search = "") => {
|
||||||
|
// Change to arrow function
|
||||||
|
permohonanKeberatanInformasi.findMany.loading = true; // Use the full path to access the property
|
||||||
|
permohonanKeberatanInformasi.findMany.page = page;
|
||||||
|
permohonanKeberatanInformasi.findMany.search = search;
|
||||||
|
try {
|
||||||
|
const query: any = { page, limit };
|
||||||
|
if (search) query.search = search;
|
||||||
|
|
||||||
|
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[
|
||||||
|
"find-many"
|
||||||
|
].get({
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 200 && res.data?.success) {
|
||||||
|
permohonanKeberatanInformasi.findMany.data = res.data.data || [];
|
||||||
|
permohonanKeberatanInformasi.findMany.total = res.data.total || 0;
|
||||||
|
permohonanKeberatanInformasi.findMany.totalPages = res.data.totalPages || 1;
|
||||||
|
} else {
|
||||||
|
console.error("Failed to load permohonan keberatan informasi:", res.data?.message);
|
||||||
|
permohonanKeberatanInformasi.findMany.data = [];
|
||||||
|
permohonanKeberatanInformasi.findMany.total = 0;
|
||||||
|
permohonanKeberatanInformasi.findMany.totalPages = 1;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading permohonan keberatan informasi:", error);
|
||||||
|
permohonanKeberatanInformasi.findMany.data = [];
|
||||||
|
permohonanKeberatanInformasi.findMany.total = 0;
|
||||||
|
permohonanKeberatanInformasi.findMany.totalPages = 1;
|
||||||
|
} finally {
|
||||||
|
permohonanKeberatanInformasi.findMany.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
findUnique: {
|
||||||
|
data: null as Prisma.FormulirPermohonanKeberatanGetPayload<{
|
||||||
|
omit: {
|
||||||
|
isActive: true;
|
||||||
|
};
|
||||||
|
}> | null,
|
||||||
|
async load(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/ppid/permohonankeberataninformasipublik/${id}`
|
||||||
|
);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
permohonanKeberatanInformasi.findUnique.data = data.data ?? null;
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"Failed to fetch permohonan keberatan informasi:",
|
||||||
|
res.statusText
|
||||||
|
);
|
||||||
|
permohonanKeberatanInformasi.findUnique.data = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching permohonan keberatan informasi:", error);
|
||||||
|
permohonanKeberatanInformasi.findUnique.data = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default permohonanKeberatanInformasi;
|
export default permohonanKeberatanInformasi;
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import { toast } from "react-toastify";
|
|||||||
import { proxy } from "valtio";
|
import { proxy } from "valtio";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
/**
|
|
||||||
* Schema validasi form ProfilePPID menggunakan Zod.
|
|
||||||
*/
|
|
||||||
const templateForm = z.object({
|
const templateForm = z.object({
|
||||||
name: z.string().min(3, "Nama minimal 3 karakter"),
|
name: z.string().min(3, "Nama minimal 3 karakter"),
|
||||||
biodata: z.string().min(3, "Biodata minimal 3 karakter"),
|
biodata: z.string().min(3, "Biodata minimal 3 karakter"),
|
||||||
@@ -33,25 +30,16 @@ type ProfilePPIDForm = Prisma.ProfilePPIDGetPayload<{
|
|||||||
pengalaman: true;
|
pengalaman: true;
|
||||||
unggulan: true;
|
unggulan: true;
|
||||||
imageId: true;
|
imageId: true;
|
||||||
image?: {
|
image?: { select: { link: true } };
|
||||||
select: {
|
|
||||||
link: true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Improved State Management - Consolidated and more robust
|
|
||||||
*/
|
|
||||||
const stateProfilePPID = proxy({
|
const stateProfilePPID = proxy({
|
||||||
// Consolidated data management
|
|
||||||
profile: {
|
profile: {
|
||||||
data: null as ProfilePPIDForm | null,
|
data: null as ProfilePPIDForm | null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null as string | null,
|
error: null as string | null,
|
||||||
|
|
||||||
// Single method to load profile data
|
|
||||||
async load(id: string) {
|
async load(id: string) {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
toast.warn("ID tidak valid");
|
toast.warn("ID tidak valid");
|
||||||
@@ -62,52 +50,42 @@ const stateProfilePPID = proxy({
|
|||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/ppid/profileppid/${id}`);
|
const res = await fetch(`/api/ppid/profileppid/${id}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await res.json();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
this.data = result.data;
|
this.data = result.data;
|
||||||
return result.data;
|
return result.data;
|
||||||
} else {
|
} else throw new Error(result.message || "Gagal memuat data profile");
|
||||||
throw new Error(result.message || "Gagal mengambil data profile");
|
} catch (err) {
|
||||||
}
|
const msg = (err as Error).message;
|
||||||
} catch (error) {
|
this.error = msg;
|
||||||
const errorMessage = (error as Error).message;
|
console.error("Load profile error:", msg);
|
||||||
this.error = errorMessage;
|
toast.error("Gagal memuat data profile");
|
||||||
console.error("Load profile error:", errorMessage);
|
|
||||||
toast.error("Terjadi kesalahan saat mengambil data profile");
|
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reset profile data
|
|
||||||
reset() {
|
reset() {
|
||||||
this.data = null;
|
this.data = null;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Edit form management
|
|
||||||
editForm: {
|
editForm: {
|
||||||
id: "",
|
id: "",
|
||||||
form: { ...defaultForm },
|
form: { ...defaultForm },
|
||||||
|
originalForm: { ...defaultForm }, // ✅ Tambah field originalForm
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null as string | null,
|
error: null as string | null,
|
||||||
isReadOnly: false, // Flag untuk data yang tidak bisa diedit
|
|
||||||
|
|
||||||
// Initialize form with profile data
|
|
||||||
initialize(profileData: ProfilePPIDForm) {
|
initialize(profileData: ProfilePPIDForm) {
|
||||||
this.id = profileData.id;
|
this.id = profileData.id;
|
||||||
this.isReadOnly = false; // Semua data bisa diedit
|
const data = {
|
||||||
this.form = {
|
|
||||||
name: profileData.name || "",
|
name: profileData.name || "",
|
||||||
biodata: profileData.biodata || "",
|
biodata: profileData.biodata || "",
|
||||||
riwayat: profileData.riwayat || "",
|
riwayat: profileData.riwayat || "",
|
||||||
@@ -115,23 +93,20 @@ const stateProfilePPID = proxy({
|
|||||||
unggulan: profileData.unggulan || "",
|
unggulan: profileData.unggulan || "",
|
||||||
imageId: profileData.imageId || "",
|
imageId: profileData.imageId || "",
|
||||||
};
|
};
|
||||||
|
this.form = { ...data };
|
||||||
|
this.originalForm = { ...data }; // ✅ Simpan versi original
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update form field
|
|
||||||
updateField(field: keyof typeof defaultForm, value: string) {
|
updateField(field: keyof typeof defaultForm, value: string) {
|
||||||
this.form[field] = value;
|
this.form[field] = value;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Submit form
|
|
||||||
async submit() {
|
async submit() {
|
||||||
// Validate form
|
const check = templateForm.safeParse(this.form);
|
||||||
const validation = templateForm.safeParse(this.form);
|
if (!check.success) {
|
||||||
|
toast.error(
|
||||||
if (!validation.success) {
|
check.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")
|
||||||
const errors = validation.error.issues
|
);
|
||||||
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
||||||
.join(", ");
|
|
||||||
toast.error(`Form tidak valid: ${errors}`);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,63 +114,54 @@ const stateProfilePPID = proxy({
|
|||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/ppid/profileppid/${this.id}`, {
|
const res = await fetch(`/api/ppid/profileppid/${this.id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(this.form),
|
body: JSON.stringify(this.form),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
if (!response.ok) {
|
const result = await res.json();
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success("Berhasil update profile");
|
toast.success("Berhasil update profile");
|
||||||
// Refresh profile data
|
this.originalForm = { ...this.form }; // ✅ Update original setelah sukses
|
||||||
await stateProfilePPID.profile.load(this.id);
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else throw new Error(result.message || "Gagal update profile");
|
||||||
throw new Error(result.message || "Gagal update profile");
|
} catch (err) {
|
||||||
}
|
const msg = (err as Error).message;
|
||||||
} catch (error) {
|
this.error = msg;
|
||||||
const errorMessage = (error as Error).message;
|
toast.error(msg);
|
||||||
this.error = errorMessage;
|
|
||||||
console.error("Update profile error:", errorMessage);
|
|
||||||
toast.error("Terjadi kesalahan saat update profile");
|
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reset form
|
// ✅ Tambahan reset ke original data
|
||||||
|
resetToOriginal() {
|
||||||
|
this.form = { ...this.originalForm };
|
||||||
|
toast.info("Data dikembalikan ke kondisi awal");
|
||||||
|
},
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.id = "";
|
this.id = "";
|
||||||
this.form = { ...defaultForm };
|
this.form = { ...defaultForm };
|
||||||
|
this.originalForm = { ...defaultForm };
|
||||||
this.error = null;
|
this.error = null;
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.isReadOnly = false;
|
},
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Helper methods
|
|
||||||
async loadForEdit(id: string) {
|
async loadForEdit(id: string) {
|
||||||
const profileData = await this.profile.load(id);
|
const data = await this.profile.load(id);
|
||||||
if (profileData) {
|
if (data) this.editForm.initialize(data);
|
||||||
this.editForm.initialize(profileData);
|
return data;
|
||||||
}
|
|
||||||
return profileData;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.profile.reset();
|
this.profile.reset();
|
||||||
this.editForm.reset();
|
this.editForm.reset();
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default stateProfilePPID;
|
export default stateProfilePPID;
|
||||||
|
|||||||
@@ -90,42 +90,96 @@ const userState = proxy({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
updateActive: {
|
deleteUser: {
|
||||||
loading: false,
|
loading: false,
|
||||||
async submit(id: string, isActive: boolean) {
|
|
||||||
this.loading = true;
|
async delete(id: string) {
|
||||||
|
if (!id) return toast.warn("ID tidak valid");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/user/updt`, {
|
userState.deleteUser.loading = true;
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
const response = await fetch(`/api/user/delUser/${id}`, {
|
||||||
body: JSON.stringify({ id, isActive }),
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const result = await response.json();
|
||||||
if (res.status === 200 && data.success) {
|
|
||||||
toast.success(data.message);
|
if (response.ok && result?.success) {
|
||||||
userState.findMany.load(userState.findMany.page, 10, userState.findMany.search);
|
toast.success(result.message || "User berhasil dihapus permanen");
|
||||||
|
await userState.findMany.load(); // refresh list user setelah delete
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.message || "Gagal update status user");
|
toast.error(result?.message || "Gagal menghapus user");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.error(e);
|
console.error("Gagal delete user:", error);
|
||||||
toast.error("Gagal update status user");
|
toast.error("Terjadi kesalahan saat menghapus user");
|
||||||
} finally {
|
} 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({
|
const templateRole = z.object({
|
||||||
name: z.string().min(1, "Nama harus diisi"),
|
name: z.string().min(1, "Nama harus diisi"),
|
||||||
permissions: z.array(z.string()).min(1, "Permission harus diisi"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultRole = {
|
const defaultRole = {
|
||||||
name: "",
|
name: "",
|
||||||
permissions: [] as string[],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const roleState = proxy({
|
const roleState = proxy({
|
||||||
@@ -166,11 +220,34 @@ const roleState = proxy({
|
|||||||
isActive: true;
|
isActive: true;
|
||||||
};
|
};
|
||||||
}>[],
|
}>[],
|
||||||
|
page: 1,
|
||||||
|
totalPages: 1,
|
||||||
loading: false,
|
loading: false,
|
||||||
async load() {
|
search: "",
|
||||||
const res = await ApiFetch.api.role["findMany"].get();
|
load: async (page = 1, limit = 10, search = "") => {
|
||||||
if (res.status === 200) {
|
roleState.findMany.loading = true; // ✅ Akses langsung via nama path
|
||||||
roleState.findMany.data = res.data?.data ?? [];
|
roleState.findMany.page = page;
|
||||||
|
roleState.findMany.search = search;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query: any = { page, limit };
|
||||||
|
if (search) query.search = search;
|
||||||
|
|
||||||
|
const res = await ApiFetch.api.role["findMany"].get({ query });
|
||||||
|
|
||||||
|
if (res.status === 200 && res.data?.success) {
|
||||||
|
roleState.findMany.data = res.data.data ?? [];
|
||||||
|
roleState.findMany.totalPages = res.data.totalPages ?? 1;
|
||||||
|
} else {
|
||||||
|
roleState.findMany.data = [];
|
||||||
|
roleState.findMany.totalPages = 1;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal fetch role paginated:", err);
|
||||||
|
roleState.findMany.data = [];
|
||||||
|
roleState.findMany.totalPages = 1;
|
||||||
|
} finally {
|
||||||
|
roleState.findMany.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -237,7 +314,7 @@ const roleState = proxy({
|
|||||||
toast.warn("ID tidak valid");
|
toast.warn("ID tidak valid");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/role/${id}`, {
|
const response = await fetch(`/api/role/${id}`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -245,31 +322,25 @@ const roleState = proxy({
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
const data = result.data;
|
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,
|
name: data.name,
|
||||||
permissions: data.permissions,
|
|
||||||
};
|
};
|
||||||
return data; // Return the loaded data
|
|
||||||
} else {
|
return data;
|
||||||
throw new Error(result?.message || "Gagal memuat data");
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading role:", error);
|
console.error("Error loading role:", error);
|
||||||
toast.error(
|
toast.error("Gagal memuat data");
|
||||||
error instanceof Error ? error.message : "Gagal memuat data"
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async update() {
|
async update() {
|
||||||
const cek = templateRole.safeParse(roleState.update.form);
|
const cek = templateRole.safeParse(roleState.update.form);
|
||||||
if (!cek.success) {
|
if (!cek.success) {
|
||||||
@@ -290,7 +361,6 @@ const roleState = proxy({
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: this.form.name,
|
name: this.form.name,
|
||||||
permissions: this.form.permissions,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,104 +1,103 @@
|
|||||||
'use client'
|
'use client';
|
||||||
import { apiFetchLogin } from '@/app/admin/auth/_lib/api_fetch_auth';
|
import { apiFetchLogin } from '@/app/api/auth/_lib/api_fetch_auth';
|
||||||
import colors from '@/con/colors';
|
import colors from '@/con/colors';
|
||||||
import { Box, Button, Center, Flex, Image, Paper, Stack, Text, Title } from '@mantine/core';
|
import { Box, Button, Center, Image, Paper, Stack, Title } from '@mantine/core';
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { PhoneInput } from "react-international-phone";
|
import { PhoneInput } from 'react-international-phone';
|
||||||
import "react-international-phone/style.css";
|
import 'react-international-phone/style.css';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function Login() {
|
function Login() {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const [phone, setPhone] = useState("")
|
const [phone, setPhone] = useState('');
|
||||||
const [isError, setError] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
|
// Login.tsx
|
||||||
async function onLogin() {
|
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 {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await apiFetchLogin({ nomor: nomor })
|
const response = await apiFetchLogin({ nomor: cleanPhone });
|
||||||
if (response && response.success) {
|
|
||||||
localStorage.setItem("hipmi_auth_code_id", response.kodeId);
|
console.log(response);
|
||||||
toast.success(response.message);
|
|
||||||
router.push("/validasi", { scroll: false });
|
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 {
|
} else {
|
||||||
setLoading(false);
|
// ❌ User baru: langsung ke registrasi (tanpa kodeId)
|
||||||
toast.error(response?.message);
|
router.push('/registrasi');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setLoading(false)
|
console.error('Error Login:', error);
|
||||||
console.log("Error Login", error)
|
toast.error('Terjadi kesalahan saat login');
|
||||||
toast.error("Terjadi kesalahan saat login")
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack pos={"relative"} bg={colors.Bg}>
|
<Stack pos="relative" bg={colors.Bg}>
|
||||||
<Box px={{ base: 'md', md: 100 }} pb={50}>
|
<Box px={{ base: 'md', md: 100 }} pb={50}>
|
||||||
<Stack align='center' justify='center' h={"100vh"}>
|
<Stack align="center" justify="center" h="100vh">
|
||||||
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
|
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
|
||||||
<Stack align='center' gap={"lg"}>
|
<Stack align="center" gap="lg">
|
||||||
<Box>
|
<Box>
|
||||||
<Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}>
|
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
|
||||||
Login
|
Login
|
||||||
</Title>
|
</Title>
|
||||||
<Center>
|
<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>
|
</Center>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box w="100%">
|
||||||
{/* <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> */}
|
|
||||||
<PhoneInput
|
<PhoneInput
|
||||||
countrySelectorStyleProps={{
|
countrySelectorStyleProps={{
|
||||||
buttonStyle: {
|
buttonStyle: {
|
||||||
backgroundColor: colors['blue-button'],
|
backgroundColor: colors['blue-button'],
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
inputStyle={{ width: "100%"}}
|
inputStyle={{ width: '100%' }}
|
||||||
defaultCountry="id"
|
defaultCountry="id"
|
||||||
onChange={(val) => {
|
value={phone}
|
||||||
setPhone(val);
|
onChange={(val) => setPhone(val)}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isError ? (
|
<Box py={20}>
|
||||||
toast.error("Masukan nomor telepon anda")
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
<Box py={20} >
|
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
bg={colors['blue-button']}
|
bg={colors['blue-button']}
|
||||||
radius={'xl'}
|
radius="xl"
|
||||||
onClick={onLogin}
|
onClick={onLogin}
|
||||||
loading={loading ? true : false}
|
loading={loading}
|
||||||
>Masuk
|
>
|
||||||
|
Masuk
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -108,4 +107,4 @@ function Login() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Login;
|
export default Login;
|
||||||
@@ -1,113 +1,153 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-expressions */
|
// app/registrasi/page.tsx
|
||||||
'use client'
|
'use client';
|
||||||
import { apiFetchRegister } from '@/app/admin/auth/_lib/api_fetch_auth';
|
|
||||||
|
import { apiFetchRegister } from '@/app/api/auth/_lib/api_fetch_auth';
|
||||||
import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto';
|
import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto';
|
||||||
import colors from '@/con/colors';
|
import colors from '@/con/colors';
|
||||||
import { 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 { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { PhoneInput } from "react-international-phone";
|
import { PhoneInput } from 'react-international-phone';
|
||||||
import "react-international-phone/style.css";
|
import 'react-international-phone/style.css';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
function Registrasi() {
|
export default function Registrasi() {
|
||||||
const [phone, setPhone] = useState("")
|
const router = useRouter();
|
||||||
const router = useRouter()
|
const [username, setUsername] = useState('');
|
||||||
const [value, setValue] = useState("")
|
|
||||||
const [isValue, setIsValue] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [phone, setPhone] = useState(''); // ✅ tambahkan state untuk phone
|
||||||
|
const [agree, setAgree] = useState(false)
|
||||||
|
|
||||||
async function onRegistarsi() {
|
// Ambil data dari localStorage (dari login)
|
||||||
if (value.length < 5) {
|
useEffect(() => {
|
||||||
toast.error("Username minimal 5 karakter!");
|
const storedNomor = localStorage.getItem('auth_nomor');
|
||||||
|
if (!storedNomor) {
|
||||||
|
toast.error('Akses tidak valid');
|
||||||
|
router.push('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setPhone(storedNomor);
|
||||||
if (value.includes(" ")) {
|
}, [router]);
|
||||||
toast.error("Username tidak boleh ada spasi!");
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
if (!username || username.trim().length < 5) {
|
||||||
|
toast.error('Username minimal 5 karakter!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (username.includes(' ')) {
|
||||||
if (!phone) {
|
toast.error('Username tidak boleh ada spasi!');
|
||||||
toast.error("Nomor telepon wajib diisi!");
|
|
||||||
return;
|
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 {
|
try {
|
||||||
setLoading(true);
|
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) {
|
if (response.success) {
|
||||||
router.push("/login", { scroll: false });
|
// Simpan sementara
|
||||||
toast.success(respone.message);
|
localStorage.setItem('auth_kodeId', response.kodeId);
|
||||||
|
localStorage.setItem('auth_username', username); // simpan username
|
||||||
|
|
||||||
} else {
|
toast.success('Kode verifikasi dikirim!');
|
||||||
setLoading(false);
|
router.push('/validasi'); // ✅ ke halaman validasi
|
||||||
toast.error(respone.message);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error Registrasi:', error);
|
||||||
|
toast.error('Gagal mengirim OTP');
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
console.log("Error Registrasi", error);
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
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 }}>
|
<Box px={{ base: 'md', md: 100 }}>
|
||||||
<BackButton />
|
<BackButton />
|
||||||
</Box>
|
</Box>
|
||||||
<Box px={{ base: 'md', md: 100 }}>
|
<Box px={{ base: 'md', md: 100 }}>
|
||||||
<Stack justify='center' align='center' h={"80vh"}>
|
<Stack justify="center" align="center" h="80vh">
|
||||||
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
|
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
|
||||||
<Stack align='center'>
|
<Stack align="center">
|
||||||
<Title order={2} fw={'bold'} c={colors['blue-button']}>
|
<Title order={2} fw="bold" c={colors['blue-button']}>
|
||||||
Registrasi
|
Registrasi
|
||||||
</Title>
|
</Title>
|
||||||
<Center>
|
<Center>
|
||||||
<Image loading="lazy" src={"/darmasaba-icon.png"} alt="" w={80} />
|
<Image loading="lazy" src="/darmasaba-icon.png" alt="" w={80} />
|
||||||
</Center>
|
</Center>
|
||||||
<Box>
|
<Box w="100%">
|
||||||
<TextInput placeholder='Username'
|
<TextInput
|
||||||
label='Username'
|
label="Username"
|
||||||
maxLength={50}
|
placeholder="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.currentTarget.value)}
|
||||||
error={
|
error={
|
||||||
value.length > 0 && value.length < 5
|
username.length > 0 && username.length < 5
|
||||||
? "Minimal 5 karakter !"
|
? 'Minimal 5 karakter!'
|
||||||
: value.includes(" ")
|
: username.includes(' ')
|
||||||
? "Tidak boleh ada spasi"
|
? 'Tidak boleh ada spasi'
|
||||||
: isValue
|
: ''
|
||||||
? "Masukan username anda"
|
|
||||||
: ""
|
|
||||||
}
|
}
|
||||||
onChange={(val) => {
|
|
||||||
val.currentTarget.value.length > 0 ? setIsValue(false) : "";
|
|
||||||
setValue(val.currentTarget.value);
|
|
||||||
}}
|
|
||||||
required
|
required
|
||||||
|
|
||||||
/>
|
/>
|
||||||
<Box py={10}>
|
|
||||||
<Text fz={"sm"} >Nomor Telepon</Text>
|
<Box pt="md">
|
||||||
|
<Text fz="sm">Nomor Telepon</Text>
|
||||||
<PhoneInput
|
<PhoneInput
|
||||||
countrySelectorStyleProps={{
|
|
||||||
buttonStyle: {
|
|
||||||
backgroundColor: colors['blue-button'],
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
inputStyle={{ width: "100%" }}
|
|
||||||
defaultCountry="id"
|
defaultCountry="id"
|
||||||
onChange={(val) => {
|
value={phone}
|
||||||
setPhone(val);
|
disabled
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box pb={10}>
|
|
||||||
|
<Box pt="md">
|
||||||
<Checkbox
|
<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>
|
||||||
<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>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -116,6 +156,4 @@ function Registrasi() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Registrasi;
|
|
||||||
@@ -1,31 +1,306 @@
|
|||||||
'use client'
|
'use client';
|
||||||
import colors from '@/con/colors';
|
|
||||||
import { Box, Button, Paper, PinInput, Stack, Text, Title } from '@mantine/core';
|
import colors from '@/con/colors';
|
||||||
import { useRouter } from 'next/navigation';
|
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 (
|
return (
|
||||||
<Stack pos={"relative"} bg={colors.Bg}>
|
<Stack pos="relative" bg={colors.Bg}>
|
||||||
<Box px={{ base: 'md', md: 100 }} pb={50}>
|
<Box px={{ base: 'md', md: 100 }} pb={50}>
|
||||||
<Stack align='center' justify='center' h={"100vh"}>
|
<Stack align="center" justify="center" h="100vh">
|
||||||
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
|
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
|
||||||
<Stack align='center' gap={"lg"}>
|
<Stack align="center" gap="lg">
|
||||||
<Box>
|
<Box>
|
||||||
<Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}>
|
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
|
||||||
Kode Verifikasi
|
{isRegistrationFlow ? 'Verifikasi Registrasi' : 'Verifikasi Login'}
|
||||||
</Title>
|
</Title>
|
||||||
|
<Text ta="center" size="sm" c="dimmed" mt="xs">
|
||||||
|
Kami telah mengirim kode ke nomor <strong>{nomor}</strong>
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box w="100%">
|
||||||
<Box mb={10}>
|
<Box mb={20}>
|
||||||
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masukkan Kode Verifikasi</Text>
|
<Text c={colors['blue-button']} ta="center" fz="sm" fw="bold">
|
||||||
<PinInput type={/^[0-9]*$/} inputType="tel" inputMode="numeric" />
|
Masukkan Kode Verifikasi
|
||||||
|
</Text>
|
||||||
|
<Center>
|
||||||
|
<PinInput
|
||||||
|
length={4}
|
||||||
|
value={otp}
|
||||||
|
onChange={setOtp}
|
||||||
|
onComplete={handleVerify}
|
||||||
|
inputMode="numeric"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
</Box>
|
</Box>
|
||||||
<Box py={20} >
|
|
||||||
<Button onClick={() => router.push("/admin/landing-page/profile/program-inovasi")}>
|
<Button
|
||||||
Page
|
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>
|
</Button>
|
||||||
</Box>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -33,6 +308,4 @@ function Validasi() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Validasi;
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
'use client'
|
'use client'
|
||||||
import colors from '@/con/colors';
|
import colors from '@/con/colors';
|
||||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
|
import { Box, ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
|
||||||
|
import { IconBuildingStore, IconFileText, IconSparkles, IconUsers, IconUsersPlus } from '@tabler/icons-react';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { IconFileText, IconBuildingStore, IconSparkles, IconUsers, IconUsersPlus } from '@tabler/icons-react';
|
|
||||||
|
|
||||||
function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
|
function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -14,36 +14,31 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
|
|||||||
label: "Pelayanan Surat Keterangan",
|
label: "Pelayanan Surat Keterangan",
|
||||||
value: "pelayanansuratketerangan",
|
value: "pelayanansuratketerangan",
|
||||||
href: "/admin/desa/layanan/pelayanan_surat_keterangan",
|
href: "/admin/desa/layanan/pelayanan_surat_keterangan",
|
||||||
icon: <IconFileText size={18} stroke={1.8} />,
|
icon: <IconFileText size={18} stroke={1.8} />
|
||||||
tooltip: "Layanan terkait surat keterangan resmi desa"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Pelayanan Perizinan Berusaha",
|
label: "Pelayanan Perizinan Berusaha",
|
||||||
value: "pelayananperizinanusaha",
|
value: "pelayananperizinanusaha",
|
||||||
href: "/admin/desa/layanan/pelayanan_perizinan_berusaha",
|
href: "/admin/desa/layanan/pelayanan_perizinan_berusaha",
|
||||||
icon: <IconBuildingStore size={18} stroke={1.8} />,
|
icon: <IconBuildingStore size={18} stroke={1.8} />
|
||||||
tooltip: "Layanan untuk izin usaha masyarakat"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Pelayanan Telunjuk Sakti Desa",
|
label: "Pelayanan Telunjuk Sakti Desa",
|
||||||
value: "pelayanantelunjuksaktidesa",
|
value: "pelayanantelunjuksaktidesa",
|
||||||
href: "/admin/desa/layanan/pelayanan_telunjuk_sakti_desa",
|
href: "/admin/desa/layanan/pelayanan_telunjuk_sakti_desa",
|
||||||
icon: <IconSparkles size={18} stroke={1.8} />,
|
icon: <IconSparkles size={18} stroke={1.8} />
|
||||||
tooltip: "Layanan inovasi khusus desa"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Pelayanan Penduduk Non-Permanent",
|
label: "Pelayanan Penduduk Non-Permanent",
|
||||||
value: "pelayanannonpermanent",
|
value: "pelayanannonpermanent",
|
||||||
href: "/admin/desa/layanan/pelayanan_penduduk_non_permanent",
|
href: "/admin/desa/layanan/pelayanan_penduduk_non_permanent",
|
||||||
icon: <IconUsers size={18} stroke={1.8} />,
|
icon: <IconUsers size={18} stroke={1.8} />
|
||||||
tooltip: "Pendataan penduduk non-permanent"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Ajukan Permohonan",
|
label: "Ajukan Permohonan",
|
||||||
value: "ajukanpermohonan",
|
value: "ajukanpermohonan",
|
||||||
href: "/admin/desa/layanan/ajukan_permohonan",
|
href: "/admin/desa/layanan/ajukan_permohonan",
|
||||||
icon: <IconUsersPlus size={18} stroke={1.8} />,
|
icon: <IconUsersPlus size={18} stroke={1.8} />
|
||||||
tooltip: "Ajukan permohonan"
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -77,42 +72,76 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
|
|||||||
keepMounted={false}
|
keepMounted={false}
|
||||||
>
|
>
|
||||||
{/* ✅ Scroll horizontal wrapper */}
|
{/* ✅ Scroll horizontal wrapper */}
|
||||||
<ScrollArea type="auto" offsetScrollbars>
|
<Box visibleFrom='md' pb={10}>
|
||||||
<TabsList
|
<ScrollArea type="auto" offsetScrollbars w="100%">
|
||||||
p="sm"
|
<TabsList
|
||||||
style={{
|
p="sm"
|
||||||
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
|
style={{
|
||||||
borderRadius: "1rem",
|
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
|
||||||
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
|
borderRadius: "1rem",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexWrap: "nowrap",
|
flexWrap: "nowrap",
|
||||||
gap: "0.5rem",
|
gap: "0.5rem",
|
||||||
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
|
width: "max-content", // ⬅️ kunci
|
||||||
}}
|
maxWidth: "100%",
|
||||||
>
|
}}
|
||||||
{tabs.map((tab, i) => (
|
>
|
||||||
<Tooltip
|
{tabs.map((tab, i) => (
|
||||||
key={i}
|
|
||||||
label={tab.tooltip}
|
|
||||||
position="bottom"
|
|
||||||
withArrow
|
|
||||||
transitionProps={{ transition: 'pop', duration: 200 }}
|
|
||||||
>
|
|
||||||
<TabsTab
|
<TabsTab
|
||||||
|
key={i}
|
||||||
value={tab.value}
|
value={tab.value}
|
||||||
leftSection={tab.icon}
|
leftSection={tab.icon}
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: "0.9rem",
|
fontSize: "0.9rem",
|
||||||
transition: "all 0.2s ease",
|
transition: "all 0.2s ease",
|
||||||
|
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</TabsTab>
|
</TabsTab>
|
||||||
</Tooltip>
|
))}
|
||||||
))}
|
</TabsList>
|
||||||
</TabsList>
|
</ScrollArea>
|
||||||
</ScrollArea>
|
</Box>
|
||||||
|
|
||||||
|
<Box hiddenFrom='md' pb={10}>
|
||||||
|
<ScrollArea
|
||||||
|
type="auto"
|
||||||
|
offsetScrollbars={false}
|
||||||
|
w="100%"
|
||||||
|
>
|
||||||
|
|
||||||
|
<TabsList
|
||||||
|
p="xs" // lebih kecil
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
|
||||||
|
borderRadius: "1rem",
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "nowrap",
|
||||||
|
gap: "0.5rem",
|
||||||
|
width: "max-content", // ⬅️ kunci
|
||||||
|
maxWidth: "100%", // ⬅️ penting
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((tab, i) => (
|
||||||
|
<TabsTab
|
||||||
|
key={i}
|
||||||
|
value={tab.value}
|
||||||
|
leftSection={tab.icon}
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
paddingInline: "0.75rem", // ⬅️ lebih ramping
|
||||||
|
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</TabsTab>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</ScrollArea>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{tabs.map((tab, i) => (
|
{tabs.map((tab, i) => (
|
||||||
<TabsPanel
|
<TabsPanel
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
'use client'
|
'use client'
|
||||||
import colors from '@/con/colors';
|
import colors from '@/con/colors';
|
||||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
|
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
|
||||||
|
import { IconCategory, IconNews } from '@tabler/icons-react';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { IconNews, IconCategory } from '@tabler/icons-react';
|
|
||||||
|
|
||||||
function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
|
function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -15,15 +15,13 @@ function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
|
|||||||
label: "List Berita",
|
label: "List Berita",
|
||||||
value: "list_berita",
|
value: "list_berita",
|
||||||
href: "/admin/desa/berita/list-berita",
|
href: "/admin/desa/berita/list-berita",
|
||||||
icon: <IconNews size={18} stroke={1.8} />,
|
icon: <IconNews size={18} stroke={1.8} />
|
||||||
tooltip: "Lihat dan kelola semua berita desa"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Kategori Berita",
|
label: "Kategori Berita",
|
||||||
value: "kategori_berita",
|
value: "kategori_berita",
|
||||||
href: "/admin/desa/berita/kategori-berita",
|
href: "/admin/desa/berita/kategori-berita",
|
||||||
icon: <IconCategory size={18} stroke={1.8} />,
|
icon: <IconCategory size={18} stroke={1.8} />
|
||||||
tooltip: "Kelola kategori berita desa"
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -71,46 +69,39 @@ function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tabs.map((tab, i) => (
|
{tabs.map((tab, i) => (
|
||||||
<Tooltip
|
<TabsTab
|
||||||
key={i}
|
key={i}
|
||||||
label={tab.tooltip}
|
value={tab.value}
|
||||||
position="bottom"
|
leftSection={tab.icon}
|
||||||
withArrow
|
style={{
|
||||||
transitionProps={{ transition: 'pop', duration: 200 }}
|
fontWeight: 600,
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<TabsTab
|
{tab.label}
|
||||||
value={tab.value}
|
</TabsTab>
|
||||||
leftSection={tab.icon}
|
|
||||||
style={{
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
transition: "all 0.2s ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</TabsTab>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{tabs.map((tab, i) => (
|
{tabs.map((tab, i) => (
|
||||||
<TabsPanel
|
<TabsPanel
|
||||||
key={i}
|
key={i}
|
||||||
value={tab.value}
|
value={tab.value}
|
||||||
style={{
|
style={{
|
||||||
padding: "1.5rem",
|
padding: "1.5rem",
|
||||||
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
|
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
|
||||||
borderRadius: "1rem",
|
borderRadius: "1rem",
|
||||||
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
|
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Konten dummy, bisa diganti sesuai routing */}
|
{/* Konten dummy, bisa diganti sesuai routing */}
|
||||||
<>{children}</>
|
<>{children}</>
|
||||||
</TabsPanel>
|
</TabsPanel>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Stack>
|
</Stack >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Loader
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconArrowBack } from '@tabler/icons-react';
|
import { IconArrowBack } from '@tabler/icons-react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
@@ -23,6 +23,11 @@ function EditKategoriBerita() {
|
|||||||
const editState = useProxy(stateDashboardBerita.kategoriBerita);
|
const editState = useProxy(stateDashboardBerita.kategoriBerita);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [originalData, setOriginalData] = useState({
|
||||||
|
name: '',
|
||||||
|
});
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -39,6 +44,9 @@ function EditKategoriBerita() {
|
|||||||
setFormData({
|
setFormData({
|
||||||
name: data.name || '',
|
name: data.name || '',
|
||||||
});
|
});
|
||||||
|
setOriginalData({
|
||||||
|
name: data.name || '',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading kategori Berita:', 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 () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
// update global state hanya saat submit
|
// update global state hanya saat submit
|
||||||
editState.update.form = {
|
editState.update.form = {
|
||||||
...editState.update.form,
|
...editState.update.form,
|
||||||
@@ -70,14 +86,15 @@ function EditKategoriBerita() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating kategori Berita:', error);
|
console.error('Error updating kategori Berita:', error);
|
||||||
toast.error('Terjadi kesalahan saat memperbarui kategori Berita');
|
toast.error('Terjadi kesalahan saat memperbarui kategori Berita');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
{/* Back Button + Title */}
|
{/* Back Button + Title */}
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
@@ -86,7 +103,6 @@ function EditKategoriBerita() {
|
|||||||
>
|
>
|
||||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
<Title order={4} ml="sm" c="dark">
|
<Title order={4} ml="sm" c="dark">
|
||||||
Edit Kategori Berita
|
Edit Kategori Berita
|
||||||
</Title>
|
</Title>
|
||||||
@@ -95,7 +111,7 @@ function EditKategoriBerita() {
|
|||||||
{/* Form Wrapper */}
|
{/* Form Wrapper */}
|
||||||
<Paper
|
<Paper
|
||||||
w={{ base: '100%', md: '50%' }}
|
w={{ base: '100%', md: '50%' }}
|
||||||
bg={colors['white-1']}
|
bg={colors['white-1']}
|
||||||
p="lg"
|
p="lg"
|
||||||
radius="md"
|
radius="md"
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
@@ -112,6 +128,17 @@ function EditKategoriBerita() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={handleResetForm}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -122,7 +149,7 @@ function EditKategoriBerita() {
|
|||||||
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>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -9,15 +9,18 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
Tooltip
|
Loader
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconArrowBack } from '@tabler/icons-react';
|
import { IconArrowBack } from '@tabler/icons-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
import { useProxy } from 'valtio/utils';
|
import { useProxy } from 'valtio/utils';
|
||||||
|
|
||||||
function CreateKategoriBerita() {
|
function CreateKategoriBerita() {
|
||||||
const createState = useProxy(stateDashboardBerita.kategoriBerita);
|
const createState = useProxy(stateDashboardBerita.kategoriBerita);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
createState.create.form = {
|
createState.create.form = {
|
||||||
@@ -26,16 +29,23 @@ function CreateKategoriBerita() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
await createState.create.create();
|
setIsSubmitting(true);
|
||||||
resetForm();
|
try {
|
||||||
router.push('/admin/desa/berita/kategori-berita');
|
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 (
|
return (
|
||||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
{/* Header dengan back button */}
|
{/* Header dengan back button */}
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
@@ -44,7 +54,6 @@ function CreateKategoriBerita() {
|
|||||||
>
|
>
|
||||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
<Title order={4} ml="sm" c="dark">
|
<Title order={4} ml="sm" c="dark">
|
||||||
Tambah Kategori Berita
|
Tambah Kategori Berita
|
||||||
</Title>
|
</Title>
|
||||||
@@ -63,12 +72,23 @@ function CreateKategoriBerita() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Nama Kategori Berita"
|
label="Nama Kategori Berita"
|
||||||
placeholder="Masukkan 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)}
|
onChange={(e) => (createState.create.form.name = e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={resetForm}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -79,7 +99,7 @@ function CreateKategoriBerita() {
|
|||||||
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>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ import {
|
|||||||
TableThead,
|
TableThead,
|
||||||
TableTr,
|
TableTr,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title
|
||||||
Tooltip
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
|
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
@@ -27,6 +26,7 @@ import { useProxy } from 'valtio/utils';
|
|||||||
import HeaderSearch from '../../../_com/header';
|
import HeaderSearch from '../../../_com/header';
|
||||||
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
|
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
|
||||||
import stateDashboardBerita from '../../../_state/desa/berita';
|
import stateDashboardBerita from '../../../_state/desa/berita';
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
|
||||||
function KategoriBerita() {
|
function KategoriBerita() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -49,6 +49,7 @@ function ListKategoriBerita({ search }: { search: string }) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [modalHapus, setModalHapus] = useState(false);
|
const [modalHapus, setModalHapus] = useState(false);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -59,8 +60,8 @@ function ListKategoriBerita({ search }: { search: string }) {
|
|||||||
} = listDataState.findMany;
|
} = listDataState.findMany;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load(page, 10, search);
|
load(page, 10, debouncedSearch);
|
||||||
}, [page, search]);
|
}, [page, debouncedSearch]);
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (selectedId) {
|
if (selectedId) {
|
||||||
@@ -82,83 +83,84 @@ function ListKategoriBerita({ search }: { search: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box py={10}>
|
<Box py={{ base: 'sm', md: 'lg' }}>
|
||||||
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
|
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb={{ base: 'md', md: 'lg' }}>
|
||||||
<Title order={4}>Daftar Kategori Berita</Title>
|
<Title order={4} lh={1.2}>
|
||||||
<Tooltip label="Tambah Kategori Berita" withArrow>
|
Daftar Kategori Berita
|
||||||
<Button
|
</Title>
|
||||||
leftSection={<IconPlus size={18} />}
|
<Button
|
||||||
color="blue"
|
leftSection={<IconPlus size={18} />}
|
||||||
variant="light"
|
color="blue"
|
||||||
onClick={() =>
|
variant="light"
|
||||||
router.push('/admin/desa/berita/kategori-berita/create')
|
onClick={() =>
|
||||||
}
|
router.push('/admin/desa/berita/kategori-berita/create')
|
||||||
>
|
}
|
||||||
Tambah Baru
|
>
|
||||||
</Button>
|
Tambah Baru
|
||||||
</Tooltip>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Box style={{ overflowX: 'auto' }}>
|
{/* Desktop Table */}
|
||||||
<Table highlightOnHover>
|
<Box visibleFrom="md">
|
||||||
|
<Table highlightOnHover miw={0}>
|
||||||
<TableThead>
|
<TableThead>
|
||||||
<TableTr>
|
<TableTr>
|
||||||
<TableTh style={{ width: '10%' }}>No</TableTh>
|
<TableTh w="50%">
|
||||||
<TableTh style={{ width: '50%' }}>Nama</TableTh>
|
<Text fz="sm" fw={600} lh={1.4}>Kategori</Text>
|
||||||
<TableTh style={{ width: '20%' }}>Edit</TableTh>
|
</TableTh>
|
||||||
<TableTh style={{ width: '20%' }}>Hapus</TableTh>
|
<TableTh w="20%">
|
||||||
|
<Text fz="sm" fw={600} lh={1.4} ta="center">Edit</Text>
|
||||||
|
</TableTh>
|
||||||
|
<TableTh w="20%">
|
||||||
|
<Text fz="sm" fw={600} lh={1.4} ta="center">Hapus</Text>
|
||||||
|
</TableTh>
|
||||||
</TableTr>
|
</TableTr>
|
||||||
</TableThead>
|
</TableThead>
|
||||||
<TableTbody>
|
<TableTbody>
|
||||||
{filteredData.length > 0 ? (
|
{filteredData.length > 0 ? (
|
||||||
filteredData.map((item, index) => (
|
filteredData.map((item) => (
|
||||||
<TableTr key={item.id}>
|
<TableTr key={item.id}>
|
||||||
<TableTd>
|
<TableTd>
|
||||||
<Text fz="sm">{index + 1}</Text>
|
<Text fz="sm" fw={500} lh={1.45} truncate="end">
|
||||||
</TableTd>
|
|
||||||
<TableTd>
|
|
||||||
<Text fw={500} truncate="end" lineClamp={1}>
|
|
||||||
{item.name}
|
{item.name}
|
||||||
</Text>
|
</Text>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
<TableTd>
|
<TableTd ta="center">
|
||||||
<Tooltip label="Edit Kategori Berita" withArrow>
|
<Button
|
||||||
<Button
|
variant="light"
|
||||||
variant="light"
|
color="green"
|
||||||
color="green"
|
onClick={() =>
|
||||||
onClick={() =>
|
router.push(
|
||||||
router.push(
|
`/admin/desa/berita/kategori-berita/${item.id}`
|
||||||
`/admin/desa/berita/kategori-berita/${item.id}`
|
)
|
||||||
)
|
}
|
||||||
}
|
size="compact-sm"
|
||||||
>
|
>
|
||||||
<IconEdit size={18} />
|
<IconEdit size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
</TableTd>
|
</TableTd>
|
||||||
<TableTd>
|
<TableTd ta="center">
|
||||||
<Tooltip label="Hapus Kategori Berita" withArrow>
|
<Button
|
||||||
<Button
|
variant="light"
|
||||||
variant="light"
|
color="red"
|
||||||
color="red"
|
disabled={listDataState.delete.loading}
|
||||||
disabled={listDataState.delete.loading}
|
onClick={() => {
|
||||||
onClick={() => {
|
setSelectedId(item.id);
|
||||||
setSelectedId(item.id);
|
setModalHapus(true);
|
||||||
setModalHapus(true);
|
}}
|
||||||
}}
|
size="compact-sm"
|
||||||
>
|
>
|
||||||
<IconTrash size={18} />
|
<IconTrash size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
</TableTd>
|
</TableTd>
|
||||||
</TableTr>
|
</TableTr>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableTr>
|
<TableTr>
|
||||||
<TableTd colSpan={4}>
|
<TableTd colSpan={4}>
|
||||||
<Center py={20}>
|
<Center py={24}>
|
||||||
<Text color="dimmed">
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
Tidak ada data kategori berita yang cocok
|
Tidak ada data kategori berita yang cocok
|
||||||
</Text>
|
</Text>
|
||||||
</Center>
|
</Center>
|
||||||
@@ -168,22 +170,70 @@ function ListKategoriBerita({ search }: { search: string }) {
|
|||||||
</TableTbody>
|
</TableTbody>
|
||||||
</Table>
|
</Table>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Mobile Cards */}
|
||||||
|
<Stack hiddenFrom="md" gap="xs" mt="md">
|
||||||
|
{filteredData.length > 0 ? (
|
||||||
|
filteredData.map((item) => (
|
||||||
|
<Paper key={item.id} withBorder radius="md" p="sm" bg="white">
|
||||||
|
<Box flex={1} ml="md">
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>Kategori</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.45} truncate>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Group mt="sm" justify="flex-end" gap="xs">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
size="compact-xs"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(
|
||||||
|
`/admin/desa/berita/kategori-berita/${item.id}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconEdit size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
size="compact-xs"
|
||||||
|
disabled={listDataState.delete.loading}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(item.id);
|
||||||
|
setModalHapus(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Center py={32}>
|
||||||
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
|
Tidak ada data kategori berita yang cocok
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Center>
|
{totalPages > 1 && (
|
||||||
<Pagination
|
<Center mt={{ base: 'lg', md: 'xl' }}>
|
||||||
value={page}
|
<Pagination
|
||||||
onChange={(newPage) => {
|
value={page}
|
||||||
load(newPage, 10, search);
|
onChange={(newPage) => {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
load(newPage, 10, search);
|
||||||
}}
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
total={totalPages}
|
}}
|
||||||
mt="md"
|
total={totalPages}
|
||||||
mb="md"
|
color="blue"
|
||||||
color="blue"
|
radius="md"
|
||||||
radius="md"
|
/>
|
||||||
/>
|
</Center>
|
||||||
</Center>
|
)}
|
||||||
|
|
||||||
{/* Modal Konfirmasi Hapus */}
|
{/* Modal Konfirmasi Hapus */}
|
||||||
<ModalKonfirmasiHapus
|
<ModalKonfirmasiHapus
|
||||||
@@ -196,4 +246,4 @@ function ListKategoriBerita({ search }: { search: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default KategoriBerita;
|
export default KategoriBerita;
|
||||||
@@ -1,8 +1,30 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import LayoutTabsBerita from './_com/layoutTabs';
|
import LayoutTabsBerita from './_com/layoutTabs';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { Box } from '@mantine/core';
|
||||||
|
|
||||||
function Layout({ children }: { children: React.ReactNode }) {
|
function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Contoh path:
|
||||||
|
// - /darmasaba/desa/berita/semua → panjang 5 → list
|
||||||
|
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
|
||||||
|
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
|
||||||
|
|
||||||
|
const segments = pathname.split('/').filter(Boolean);
|
||||||
|
const isDetailPage = segments.length >= 5;
|
||||||
|
|
||||||
|
if (isDetailPage) {
|
||||||
|
// Tampilkan tanpa tab menu
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutTabsBerita>
|
<LayoutTabsBerita>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import stateDashboardBerita from "@/app/admin/(dashboard)/_state/desa/berita";
|
|||||||
import colors from "@/con/colors";
|
import colors from "@/con/colors";
|
||||||
import ApiFetch from "@/lib/api-fetch";
|
import ApiFetch from "@/lib/api-fetch";
|
||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
@@ -16,7 +17,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Loader
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Dropzone } from "@mantine/dropzone";
|
import { Dropzone } from "@mantine/dropzone";
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +46,17 @@ function EditBerita() {
|
|||||||
imageId: "",
|
imageId: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [originalData, setOriginalData] = useState({
|
||||||
|
judul: "",
|
||||||
|
deskripsi: "",
|
||||||
|
kategoriBeritaId: "",
|
||||||
|
content: "",
|
||||||
|
imageId: "",
|
||||||
|
imageUrl: ""
|
||||||
|
});
|
||||||
|
|
||||||
// Load kategori + berita
|
// Load kategori + berita
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
beritaState.kategoriBerita.findMany.load();
|
beritaState.kategoriBerita.findMany.load();
|
||||||
@@ -64,6 +76,15 @@ function EditBerita() {
|
|||||||
imageId: data.imageId || "",
|
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) {
|
if (data?.image?.link) {
|
||||||
setPreviewImage(data.image.link);
|
setPreviewImage(data.image.link);
|
||||||
}
|
}
|
||||||
@@ -83,6 +104,7 @@ function EditBerita() {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
// Update global state hanya sekali di sini
|
// Update global state hanya sekali di sini
|
||||||
beritaState.berita.edit.form = {
|
beritaState.berita.edit.form = {
|
||||||
...beritaState.berita.edit.form,
|
...beritaState.berita.edit.form,
|
||||||
@@ -109,23 +131,36 @@ function EditBerita() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating berita:", error);
|
console.error("Error updating berita:", error);
|
||||||
toast.error("Terjadi kesalahan saat memperbarui berita");
|
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 (
|
return (
|
||||||
<Box px={{ base: "sm", md: "lg" }} py="md">
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
<Button
|
||||||
<Button
|
variant="subtle"
|
||||||
variant="subtle"
|
onClick={() => router.back()}
|
||||||
onClick={() => router.back()}
|
p="xs"
|
||||||
p="xs"
|
radius="md"
|
||||||
radius="md"
|
>
|
||||||
>
|
<IconArrowBack color={colors["blue-button"]} size={24} />
|
||||||
<IconArrowBack color={colors["blue-button"]} size={24} />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Title order={4} ml="sm" c="dark">
|
<Title order={4} ml="sm" c="dark">
|
||||||
Edit Berita
|
Edit Berita
|
||||||
</Title>
|
</Title>
|
||||||
@@ -219,14 +254,14 @@ function EditBerita() {
|
|||||||
Seret gambar atau klik untuk memilih file
|
Seret gambar atau klik untuk memilih file
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Maksimal 5MB, format gambar wajib
|
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
</Dropzone>
|
</Dropzone>
|
||||||
|
|
||||||
{previewImage && (
|
{previewImage && (
|
||||||
<Box mt="sm" style={{ display: "flex", justifyContent: "center" }}>
|
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||||
<Image
|
<Image
|
||||||
src={previewImage}
|
src={previewImage}
|
||||||
alt="Preview Gambar"
|
alt="Preview Gambar"
|
||||||
@@ -238,6 +273,24 @@ function EditBerita() {
|
|||||||
}}
|
}}
|
||||||
loading="lazy"
|
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>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -257,17 +310,29 @@ function EditBerita() {
|
|||||||
|
|
||||||
{/* Action */}
|
{/* Action */}
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
|
{/* Tombol Batal */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={handleResetForm}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors["blue-button"]}, #4facfe)`,
|
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: "#fff",
|
color: '#fff',
|
||||||
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>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useProxy } from 'valtio/utils';
|
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
|
||||||
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text, Tooltip } from '@mantine/core';
|
|
||||||
import { useShallowEffect } from '@mantine/hooks';
|
import { useShallowEffect } from '@mantine/hooks';
|
||||||
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
|
||||||
import colors from '@/con/colors';
|
|
||||||
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
|
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
|
||||||
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
|
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
|
||||||
function DetailBerita() {
|
function DetailBerita() {
|
||||||
const beritaState = useProxy(stateDashboardBerita);
|
const beritaState = useProxy(stateDashboardBerita);
|
||||||
@@ -41,7 +41,7 @@ function DetailBerita() {
|
|||||||
const data = beritaState.berita.findUnique.data;
|
const data = beritaState.berita.findUnique.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box py={10}>
|
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||||
{/* Tombol Back */}
|
{/* Tombol Back */}
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -111,7 +111,6 @@ function DetailBerita() {
|
|||||||
|
|
||||||
{/* Action Button */}
|
{/* Action Button */}
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Tooltip label="Hapus Berita" withArrow position="top">
|
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color="red"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -124,9 +123,7 @@ function DetailBerita() {
|
|||||||
>
|
>
|
||||||
<IconTrash size={20} />
|
<IconTrash size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip label="Edit Berita" withArrow position="top">
|
|
||||||
<Button
|
<Button
|
||||||
color="green"
|
color="green"
|
||||||
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
|
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
|
||||||
@@ -136,7 +133,6 @@ function DetailBerita() {
|
|||||||
>
|
>
|
||||||
<IconEdit size={20} />
|
<IconEdit size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Loader,
|
||||||
|
ActionIcon
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import { useShallowEffect } from '@mantine/hooks';
|
import { useShallowEffect } from '@mantine/hooks';
|
||||||
@@ -29,6 +30,7 @@ export default function CreateBerita() {
|
|||||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
beritaState.kategoriBerita.findMany.load();
|
beritaState.kategoriBerita.findMany.load();
|
||||||
@@ -47,42 +49,48 @@ export default function CreateBerita() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!file) {
|
try {
|
||||||
return toast.warn('Silakan pilih file gambar terlebih dahulu');
|
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 (
|
return (
|
||||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
{/* Header dengan tombol kembali */}
|
{/* Header dengan tombol kembali */}
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
<Button
|
||||||
<Button
|
variant="subtle"
|
||||||
variant="subtle"
|
onClick={() => router.back()}
|
||||||
onClick={() => router.back()}
|
p="xs"
|
||||||
p="xs"
|
radius="md"
|
||||||
radius="md"
|
>
|
||||||
>
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Title order={4} ml="sm" c="dark">
|
<Title order={4} ml="sm" c="dark">
|
||||||
Tambah Berita
|
Tambah Berita
|
||||||
</Title>
|
</Title>
|
||||||
@@ -100,7 +108,7 @@ export default function CreateBerita() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Judul"
|
label="Judul"
|
||||||
placeholder="Masukkan judul berita"
|
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)}
|
onChange={(e) => (beritaState.berita.create.form.judul = e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -112,7 +120,7 @@ export default function CreateBerita() {
|
|||||||
label: item.name,
|
label: item.name,
|
||||||
value: item.id,
|
value: item.id,
|
||||||
}))}
|
}))}
|
||||||
defaultValue={beritaState.berita.create.form.kategoriBeritaId || null}
|
value={beritaState.berita.create.form.kategoriBeritaId || null}
|
||||||
onChange={(val: string | null) => {
|
onChange={(val: string | null) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
const selected = beritaState.kategoriBerita.findMany.data?.find(
|
const selected = beritaState.kategoriBerita.findMany.data?.find(
|
||||||
@@ -157,7 +165,7 @@ export default function CreateBerita() {
|
|||||||
}}
|
}}
|
||||||
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||||
maxSize={5 * 1024 ** 2}
|
maxSize={5 * 1024 ** 2}
|
||||||
accept={{ 'image/*': [] }}
|
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||||
radius="md"
|
radius="md"
|
||||||
p="xl"
|
p="xl"
|
||||||
>
|
>
|
||||||
@@ -178,7 +186,7 @@ export default function CreateBerita() {
|
|||||||
</Dropzone>
|
</Dropzone>
|
||||||
|
|
||||||
{previewImage && (
|
{previewImage && (
|
||||||
<Box mt="sm" style={{ textAlign: 'center' }}>
|
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||||
<Image
|
<Image
|
||||||
src={previewImage}
|
src={previewImage}
|
||||||
alt="Preview Gambar"
|
alt="Preview Gambar"
|
||||||
@@ -190,6 +198,26 @@ export default function CreateBerita() {
|
|||||||
}}
|
}}
|
||||||
loading="lazy"
|
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>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -207,6 +235,17 @@ export default function CreateBerita() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={resetForm}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -217,7 +256,7 @@ export default function CreateBerita() {
|
|||||||
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>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -16,10 +16,9 @@ import {
|
|||||||
TableThead,
|
TableThead,
|
||||||
TableTr,
|
TableTr,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title
|
||||||
Tooltip
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useShallowEffect } from '@mantine/hooks';
|
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||||
import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
|
import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -46,16 +45,17 @@ function Berita() {
|
|||||||
function ListBerita({ search }: { search: string }) {
|
function ListBerita({ search }: { search: string }) {
|
||||||
const beritaState = useProxy(stateDashboardBerita);
|
const beritaState = useProxy(stateDashboardBerita);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||||
|
|
||||||
const { data, page, totalPages, loading, load } = beritaState.berita.findMany;
|
const { data, page, totalPages, loading, load } = beritaState.berita.findMany;
|
||||||
|
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
load(page, 10, search);
|
load(page, 10, debouncedSearch);
|
||||||
}, [page, search]);
|
}, [page, debouncedSearch]);
|
||||||
|
|
||||||
if (loading || !data) {
|
if (loading || !data) {
|
||||||
return (
|
return (
|
||||||
<Stack py={10}>
|
<Stack py="md">
|
||||||
<Skeleton height={600} radius="md" />
|
<Skeleton height={600} radius="md" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -64,66 +64,66 @@ function ListBerita({ search }: { search: string }) {
|
|||||||
const filteredData = data || [];
|
const filteredData = data || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box py={10}>
|
<Box py="md">
|
||||||
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
|
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
<Title order={4}>Daftar Berita</Title>
|
<Title order={4}>Daftar Berita</Title>
|
||||||
<Tooltip label="Tambah Berita" withArrow>
|
<Button
|
||||||
<Button
|
leftSection={<IconCircleDashedPlus size={18} />}
|
||||||
leftSection={<IconCircleDashedPlus size={18} />}
|
color="blue"
|
||||||
color="blue"
|
variant="light"
|
||||||
variant="light"
|
onClick={() => router.push('/admin/desa/berita/list-berita/create')}
|
||||||
onClick={() => router.push('/admin/desa/berita/list-berita/create')}
|
>
|
||||||
>
|
Tambah Baru
|
||||||
Tambah Baru
|
</Button>
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Box style={{ overflowX: 'auto' }}>
|
{/* Desktop Table */}
|
||||||
<Table highlightOnHover>
|
<Box visibleFrom="md">
|
||||||
|
<Table highlightOnHover miw={0}>
|
||||||
<TableThead>
|
<TableThead>
|
||||||
<TableTr>
|
<TableTr>
|
||||||
<TableTh style={{ width: '30%' }}>Judul</TableTh>
|
<TableTh w="50%">Judul</TableTh>
|
||||||
<TableTh style={{ width: '20%' }}>Kategori</TableTh>
|
<TableTh w="30%">Kategori</TableTh>
|
||||||
<TableTh style={{ width: '15%' }}>Aksi</TableTh>
|
<TableTh w="20%">Aksi</TableTh>
|
||||||
</TableTr>
|
</TableTr>
|
||||||
</TableThead>
|
</TableThead>
|
||||||
<TableTbody>
|
<TableTbody>
|
||||||
{filteredData.length > 0 ? (
|
{filteredData.length > 0 ? (
|
||||||
filteredData.map((item) => (
|
filteredData.map((item) => (
|
||||||
<TableTr key={item.id}>
|
<TableTr key={item.id}>
|
||||||
<TableTd style={{ width: '30%' }}>
|
<TableTd>
|
||||||
<Box w={150}>
|
<Text fz="md" fw={600} lh={1.45} truncate="end">
|
||||||
<Text fw={500} truncate="end" lineClamp={1}>
|
{item.judul}
|
||||||
{item.judul}
|
</Text>
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</TableTd>
|
</TableTd>
|
||||||
<TableTd style={{ width: '20%' }}>
|
<TableTd>
|
||||||
<Text fz="sm" c="dimmed">
|
<Text fz="sm" c="dimmed" lh={1.45}>
|
||||||
{item.kategoriBerita?.name || '-'}
|
{item.kategoriBerita?.name || '-'}
|
||||||
</Text>
|
</Text>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
<TableTd style={{ width: '15%' }}>
|
<TableTd>
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
color="blue"
|
color="blue"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
router.push(`/admin/desa/berita/list-berita/${item.id}`)
|
router.push(`/admin/desa/berita/list-berita/${item.id}`)
|
||||||
}
|
}
|
||||||
|
fz="sm"
|
||||||
|
px="sm"
|
||||||
|
h={36}
|
||||||
>
|
>
|
||||||
<IconDeviceImacCog size={20} />
|
<IconDeviceImacCog size={18} />
|
||||||
<Text ml={5}>Detail</Text>
|
<Text ml="xs">Detail</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
</TableTr>
|
</TableTr>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableTr>
|
<TableTr>
|
||||||
<TableTd colSpan={4}>
|
<TableTd colSpan={3}>
|
||||||
<Center py={20}>
|
<Center py="xl">
|
||||||
<Text color="dimmed">
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
Tidak ada data berita yang cocok
|
Tidak ada data berita yang cocok
|
||||||
</Text>
|
</Text>
|
||||||
</Center>
|
</Center>
|
||||||
@@ -133,6 +133,52 @@ function ListBerita({ search }: { search: string }) {
|
|||||||
</TableTbody>
|
</TableTbody>
|
||||||
</Table>
|
</Table>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Mobile Cards */}
|
||||||
|
<Stack hiddenFrom="md" gap="sm" mt="sm">
|
||||||
|
{filteredData.length > 0 ? (
|
||||||
|
filteredData.map((item) => (
|
||||||
|
<Paper key={item.id} withBorder p="md" radius="md">
|
||||||
|
<Stack gap={"xs"}>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4} c="dimmed">
|
||||||
|
Judul
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.45}>
|
||||||
|
{item.judul}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">
|
||||||
|
Kategori
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" lh={1.45} fw={500}>
|
||||||
|
{item.kategoriBerita?.name || '-'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
fullWidth
|
||||||
|
mt="sm"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/admin/desa/berita/list-berita/${item.id}`)
|
||||||
|
}
|
||||||
|
fz="sm"
|
||||||
|
h={36}
|
||||||
|
>
|
||||||
|
<IconDeviceImacCog size={18} />
|
||||||
|
<Text ml="xs">Detail</Text>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Center py="xl">
|
||||||
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
|
Tidak ada data berita yang cocok
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Center>
|
<Center>
|
||||||
@@ -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";
|
'use client'
|
||||||
import stateFileStorage from "@/state/state-list-image";
|
import colors from '@/con/colors';
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
|
||||||
Box,
|
Box,
|
||||||
Card,
|
Button,
|
||||||
Flex,
|
Center,
|
||||||
Group,
|
Group,
|
||||||
Image,
|
|
||||||
Pagination,
|
Pagination,
|
||||||
Paper,
|
Paper,
|
||||||
SimpleGrid,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableTbody,
|
||||||
|
TableTd,
|
||||||
|
TableTh,
|
||||||
|
TableThead,
|
||||||
|
TableTr,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
Title
|
||||||
Title,
|
} from '@mantine/core';
|
||||||
Tooltip,
|
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||||
} from "@mantine/core";
|
import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
|
||||||
import { useShallowEffect } from "@mantine/hooks";
|
import { useRouter } from 'next/navigation';
|
||||||
import { IconSearch, IconTrash, IconX } from "@tabler/icons-react";
|
import { useState } from 'react';
|
||||||
import { motion } from "framer-motion";
|
import { useProxy } from 'valtio/utils';
|
||||||
import toast from "react-simple-toasts";
|
import HeaderSearch from '../../../_com/header';
|
||||||
import { useSnapshot } from "valtio";
|
import stateGallery from '../../../_state/desa/gallery';
|
||||||
|
|
||||||
export default function ListImage() {
|
|
||||||
const { list, total } = useSnapshot(stateFileStorage);
|
|
||||||
|
|
||||||
useShallowEffect(() => {
|
|
||||||
stateFileStorage.load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
let timeOut: NodeJS.Timer;
|
|
||||||
|
|
||||||
|
function Foto() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
return (
|
return (
|
||||||
<Stack p="lg" gap="lg">
|
<Box>
|
||||||
<Flex justify="space-between" align="center" wrap="wrap" gap="md">
|
<HeaderSearch
|
||||||
<Title order={2} fw={700}>
|
title='Foto'
|
||||||
Galeri Foto
|
placeholder='Cari judul atau deskripsi foto...'
|
||||||
</Title>
|
searchIcon={<IconSearch size={20} />}
|
||||||
<TextInput
|
value={search}
|
||||||
radius="xl"
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
size="md"
|
/>
|
||||||
placeholder="Cari foto berdasarkan nama..."
|
<ListFoto search={search} />
|
||||||
leftSection={<IconSearch size={18} />}
|
</Box>
|
||||||
rightSection={
|
|
||||||
<ActionIcon
|
|
||||||
variant="light"
|
|
||||||
color="gray"
|
|
||||||
radius="xl"
|
|
||||||
onClick={() => stateFileStorage.load()}
|
|
||||||
>
|
|
||||||
<IconX size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (timeOut) clearTimeout(timeOut);
|
|
||||||
timeOut = setTimeout(() => {
|
|
||||||
stateFileStorage.load({ search: e.target.value });
|
|
||||||
}, 300);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<Paper withBorder radius="lg" p="md" shadow="sm">
|
|
||||||
{list && list.length > 0 ? (
|
|
||||||
<SimpleGrid
|
|
||||||
cols={{ base: 2, sm: 3, md: 5, lg: 8 }}
|
|
||||||
spacing="md"
|
|
||||||
verticalSpacing="md"
|
|
||||||
>
|
|
||||||
{list.map((v, k) => (
|
|
||||||
<Card
|
|
||||||
key={k}
|
|
||||||
withBorder
|
|
||||||
radius="md"
|
|
||||||
shadow="sm"
|
|
||||||
className="hover:shadow-md transition-all duration-200"
|
|
||||||
>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<motion.div
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(v.url);
|
|
||||||
toast("Tautan foto berhasil disalin");
|
|
||||||
}}
|
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={`${v.url}?size=200`}
|
|
||||||
alt={v.name}
|
|
||||||
radius="md"
|
|
||||||
h={120}
|
|
||||||
fit="cover"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Text size="sm" fw={500} lineClamp={2}>
|
|
||||||
{v.name}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Group justify="space-between" align="center" pt="xs">
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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'
|
'use client'
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import LayoutTabsGallery from "./lib/layoutTabs"
|
import LayoutTabsGallery from "./lib/layoutTabs"
|
||||||
|
import { Box } from "@mantine/core";
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Contoh path:
|
||||||
|
// - /darmasaba/desa/berita/semua → panjang 5 → list
|
||||||
|
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
|
||||||
|
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
|
||||||
|
|
||||||
|
const segments = pathname.split('/').filter(Boolean);
|
||||||
|
const isDetailPage = segments.length >= 5;
|
||||||
|
|
||||||
|
if (isDetailPage) {
|
||||||
|
// Tampilkan tanpa tab menu
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutTabsGallery>
|
<LayoutTabsGallery>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
'use client'
|
'use client'
|
||||||
import colors from '@/con/colors';
|
import colors from '@/con/colors';
|
||||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
|
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
|
||||||
|
import { IconPhoto, IconVideo } from '@tabler/icons-react';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { IconPhoto, IconVideo } from '@tabler/icons-react';
|
|
||||||
|
|
||||||
function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
|
function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -14,15 +14,13 @@ function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
|
|||||||
label: "Foto",
|
label: "Foto",
|
||||||
value: "foto",
|
value: "foto",
|
||||||
href: "/admin/desa/gallery/foto",
|
href: "/admin/desa/gallery/foto",
|
||||||
icon: <IconPhoto size={18} stroke={1.8} />,
|
icon: <IconPhoto size={18} stroke={1.8} />
|
||||||
tooltip: "Kelola foto-foto galeri desa"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Video",
|
label: "Video",
|
||||||
value: "video",
|
value: "video",
|
||||||
href: "/admin/desa/gallery/video",
|
href: "/admin/desa/gallery/video",
|
||||||
icon: <IconVideo size={18} stroke={1.8} />,
|
icon: <IconVideo size={18} stroke={1.8} />
|
||||||
tooltip: "Kelola video galeri desa"
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -70,25 +68,18 @@ function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tabs.map((tab, i) => (
|
{tabs.map((tab, i) => (
|
||||||
<Tooltip
|
<TabsTab
|
||||||
key={i}
|
key={i}
|
||||||
label={tab.tooltip}
|
value={tab.value}
|
||||||
position="bottom"
|
leftSection={tab.icon}
|
||||||
withArrow
|
style={{
|
||||||
transitionProps={{ transition: 'pop', duration: 200 }}
|
fontWeight: 600,
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<TabsTab
|
{tab.label}
|
||||||
value={tab.value}
|
</TabsTab>
|
||||||
leftSection={tab.icon}
|
|
||||||
style={{
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
transition: "all 0.2s ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</TabsTab>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
|
|||||||
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
||||||
import colors from '@/con/colors';
|
import colors from '@/con/colors';
|
||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
@@ -11,11 +12,11 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
Tooltip
|
Loader
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconArrowBack } from '@tabler/icons-react';
|
import { IconArrowBack, IconX } from '@tabler/icons-react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useProxy } from 'valtio/utils';
|
import { useProxy } from 'valtio/utils';
|
||||||
import { convertYoutubeUrlToEmbed } from '../../../lib/youtube-utils';
|
import { convertYoutubeUrlToEmbed } from '../../../lib/youtube-utils';
|
||||||
@@ -25,6 +26,14 @@ function EditVideo() {
|
|||||||
const videoState = useProxy(stateGallery.video);
|
const videoState = useProxy(stateGallery.video);
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [originalData, setOriginalData] = useState({
|
||||||
|
name: "",
|
||||||
|
deskripsi: "",
|
||||||
|
linkVideo: "",
|
||||||
|
});
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
deskripsi: '',
|
deskripsi: '',
|
||||||
@@ -45,6 +54,11 @@ function EditVideo() {
|
|||||||
deskripsi: data.deskripsi ?? '',
|
deskripsi: data.deskripsi ?? '',
|
||||||
linkVideo: data.linkVideo ?? '',
|
linkVideo: data.linkVideo ?? '',
|
||||||
});
|
});
|
||||||
|
setOriginalData({
|
||||||
|
name: data.name ?? '',
|
||||||
|
deskripsi: data.deskripsi ?? '',
|
||||||
|
linkVideo: data.linkVideo ?? '',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading video:', error);
|
console.error('Error loading video:', error);
|
||||||
@@ -62,43 +76,58 @@ function EditVideo() {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleResetForm = () => {
|
||||||
const converted = convertYoutubeUrlToEmbed(formData.linkVideo);
|
setFormData({
|
||||||
if (!converted) {
|
name: originalData.name,
|
||||||
toast.error("Link YouTube tidak valid. Pastikan formatnya benar.");
|
deskripsi: originalData.deskripsi,
|
||||||
return;
|
linkVideo: originalData.linkVideo,
|
||||||
}
|
});
|
||||||
|
toast.info("Form dikembalikan ke data awal");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
videoState.update.form = {
|
setIsSubmitting(true);
|
||||||
name: formData.name,
|
const converted = convertYoutubeUrlToEmbed(formData.linkVideo);
|
||||||
deskripsi: formData.deskripsi,
|
if (!converted) {
|
||||||
linkVideo: formData.linkVideo,
|
toast.error("Link YouTube tidak valid. Pastikan formatnya benar.");
|
||||||
};
|
return;
|
||||||
await videoState.update.update();
|
}
|
||||||
toast.success('Video berhasil diperbarui!');
|
|
||||||
router.push('/admin/desa/gallery/video');
|
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) {
|
} catch (error) {
|
||||||
console.error('Error updating video:', error);
|
console.error('Error updating video:', error);
|
||||||
toast.error('Terjadi kesalahan saat memperbarui video');
|
toast.error('Terjadi kesalahan saat memperbarui video');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const embedLink = convertYoutubeUrlToEmbed(formData.linkVideo);
|
const embedLink = convertYoutubeUrlToEmbed(formData.linkVideo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
<Button
|
||||||
<Button
|
variant="subtle"
|
||||||
variant="subtle"
|
onClick={() => router.back()}
|
||||||
onClick={() => router.back()}
|
p="xs"
|
||||||
p="xs"
|
radius="md"
|
||||||
radius="md"
|
>
|
||||||
>
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Title order={4} ml="sm" c="dark">
|
<Title order={4} ml="sm" c="dark">
|
||||||
Edit Video
|
Edit Video
|
||||||
</Title>
|
</Title>
|
||||||
@@ -130,7 +159,7 @@ function EditVideo() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{embedLink && (
|
{embedLink && (
|
||||||
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
|
<Box mt="sm" pos="relative" style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
<iframe
|
<iframe
|
||||||
className="rounded"
|
className="rounded"
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -138,7 +167,27 @@ function EditVideo() {
|
|||||||
src={embedLink}
|
src={embedLink}
|
||||||
title="Preview Video"
|
title="Preview Video"
|
||||||
allowFullScreen
|
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>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -154,6 +203,17 @@ function EditVideo() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={handleResetForm}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -164,7 +224,7 @@ function EditVideo() {
|
|||||||
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>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
|
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
|
||||||
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
||||||
import colors from '@/con/colors';
|
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 { useShallowEffect } from '@mantine/hooks';
|
||||||
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
@@ -40,7 +40,7 @@ function DetailVideo() {
|
|||||||
const data = videoState.findUnique.data;
|
const data = videoState.findUnique.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box py={10}>
|
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||||
{/* Tombol Kembali */}
|
{/* Tombol Kembali */}
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -54,7 +54,7 @@ function DetailVideo() {
|
|||||||
{/* Detail Video */}
|
{/* Detail Video */}
|
||||||
<Paper
|
<Paper
|
||||||
withBorder
|
withBorder
|
||||||
w={{ base: "100%", md: "50%" }}
|
w={{ base: "100%", md: "70%" }}
|
||||||
bg={colors['white-1']}
|
bg={colors['white-1']}
|
||||||
p="lg"
|
p="lg"
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -111,7 +111,6 @@ function DetailVideo() {
|
|||||||
|
|
||||||
{/* Tombol Aksi */}
|
{/* Tombol Aksi */}
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Tooltip label="Hapus Video" withArrow position="top">
|
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color="red"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -124,9 +123,7 @@ function DetailVideo() {
|
|||||||
>
|
>
|
||||||
<IconTrash size={20} />
|
<IconTrash size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip label="Edit Video" withArrow position="top">
|
|
||||||
<Button
|
<Button
|
||||||
color="green"
|
color="green"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -138,7 +135,6 @@ function DetailVideo() {
|
|||||||
>
|
>
|
||||||
<IconEdit size={20} />
|
<IconEdit size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
|
|||||||
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
||||||
import colors from '@/con/colors';
|
import colors from '@/con/colors';
|
||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
@@ -11,9 +12,9 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Loader
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconArrowBack } from '@tabler/icons-react';
|
import { IconArrowBack, IconX } from '@tabler/icons-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
@@ -25,6 +26,7 @@ function CreateVideo() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [link, setLink] = useState('');
|
const [link, setLink] = useState('');
|
||||||
const embedLink = convertYoutubeUrlToEmbed(link);
|
const embedLink = convertYoutubeUrlToEmbed(link);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
videoState.create.form = {
|
videoState.create.form = {
|
||||||
@@ -36,31 +38,37 @@ function CreateVideo() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!embedLink) {
|
try {
|
||||||
toast.error('Link YouTube tidak valid. Pastikan formatnya benar.');
|
setIsSubmitting(true);
|
||||||
return;
|
if (!embedLink) {
|
||||||
}
|
toast.error('Link YouTube tidak valid. Pastikan formatnya benar.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
videoState.create.form.linkVideo = embedLink;
|
videoState.create.form.linkVideo = embedLink;
|
||||||
await videoState.create.create();
|
await videoState.create.create();
|
||||||
resetForm();
|
resetForm();
|
||||||
router.push('/admin/desa/gallery/video');
|
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 (
|
return (
|
||||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
{/* Header Back Button + Title */}
|
{/* Header Back Button + Title */}
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
<Button
|
||||||
<Button
|
variant="subtle"
|
||||||
variant="subtle"
|
onClick={() => router.back()}
|
||||||
onClick={() => router.back()}
|
p="xs"
|
||||||
p="xs"
|
radius="md"
|
||||||
radius="md"
|
>
|
||||||
>
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Title order={4} ml="sm" c="dark">
|
<Title order={4} ml="sm" c="dark">
|
||||||
Tambah Video
|
Tambah Video
|
||||||
</Title>
|
</Title>
|
||||||
@@ -80,7 +88,7 @@ function CreateVideo() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Judul Video"
|
label="Judul Video"
|
||||||
placeholder="Masukkan judul video"
|
placeholder="Masukkan judul video"
|
||||||
defaultValue={videoState.create.form.name}
|
value={videoState.create.form.name}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
videoState.create.form.name = e.currentTarget.value;
|
videoState.create.form.name = e.currentTarget.value;
|
||||||
}}
|
}}
|
||||||
@@ -91,14 +99,14 @@ function CreateVideo() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Link Video YouTube"
|
label="Link Video YouTube"
|
||||||
placeholder="https://www.youtube.com/watch?v=abc123"
|
placeholder="https://www.youtube.com/watch?v=abc123"
|
||||||
defaultValue={link}
|
value={link}
|
||||||
onChange={(e) => setLink(e.currentTarget.value)}
|
onChange={(e) => setLink(e.currentTarget.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Preview Video */}
|
{/* Preview Video */}
|
||||||
{embedLink && (
|
{embedLink && (
|
||||||
<Box mt="sm">
|
<Box mt="sm" pos="relative">
|
||||||
<iframe
|
<iframe
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
@@ -109,7 +117,24 @@ function CreateVideo() {
|
|||||||
src={embedLink}
|
src={embedLink}
|
||||||
title="Preview Video"
|
title="Preview Video"
|
||||||
allowFullScreen
|
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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -128,6 +153,17 @@ function CreateVideo() {
|
|||||||
|
|
||||||
{/* Button Submit */}
|
{/* Button Submit */}
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={resetForm}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -138,7 +174,7 @@ function CreateVideo() {
|
|||||||
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>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -16,10 +16,9 @@ import {
|
|||||||
TableThead,
|
TableThead,
|
||||||
TableTr,
|
TableTr,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title
|
||||||
Tooltip
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useShallowEffect } from '@mantine/hooks';
|
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||||
import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
|
import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -46,6 +45,7 @@ function Video() {
|
|||||||
function ListVideo({ search }: { search: string }) {
|
function ListVideo({ search }: { search: string }) {
|
||||||
const videoState = useProxy(stateGallery.video)
|
const videoState = useProxy(stateGallery.video)
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -56,111 +56,166 @@ function ListVideo({ search }: { search: string }) {
|
|||||||
} = videoState.findMany;
|
} = videoState.findMany;
|
||||||
|
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
load(page, 10, search)
|
load(page, 10, debouncedSearch)
|
||||||
}, [page, search])
|
}, [page, debouncedSearch])
|
||||||
|
|
||||||
const filteredData = data || []
|
const filteredData = data || []
|
||||||
|
|
||||||
if (loading || !data) {
|
if (loading || !data) {
|
||||||
return (
|
return (
|
||||||
<Stack py={10}>
|
<Stack py={20}>
|
||||||
<Skeleton height={600} radius="md" />
|
<Skeleton height={600} radius="md" />
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box py={10}>
|
<Box py={20}>
|
||||||
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
|
<Paper withBorder bg={colors['white-1']} p={{ base: 'sm', md: 'lg' }} shadow="md" radius="md">
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
|
||||||
<Title order={4}>Daftar Video</Title>
|
<Title order={4} lh={1.2}>
|
||||||
<Tooltip label="Tambah Video Baru" withArrow>
|
Daftar Video
|
||||||
<Button
|
</Title>
|
||||||
leftSection={<IconPlus size={18} />}
|
<Button
|
||||||
color="blue"
|
leftSection={<IconPlus size={18} />}
|
||||||
variant="light"
|
color="blue"
|
||||||
onClick={() => router.push('/admin/desa/gallery/video/create')}
|
variant="light"
|
||||||
>
|
onClick={() => router.push('/admin/desa/gallery/video/create')}
|
||||||
Tambah Baru
|
>
|
||||||
</Button>
|
Tambah Baru
|
||||||
</Tooltip>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
<Box style={{ overflowX: "auto" }}>
|
|
||||||
<Table highlightOnHover>
|
{/* Desktop Table */}
|
||||||
<TableThead>
|
<Box visibleFrom="md">
|
||||||
<TableTr>
|
<Box style={{ overflowX: 'auto' }}>
|
||||||
<TableTh style={{ width: '25%' }}>Judul Video</TableTh>
|
<Table highlightOnHover striped verticalSpacing="sm">
|
||||||
<TableTh style={{ width: '20%' }}>Tanggal</TableTh>
|
<TableThead>
|
||||||
<TableTh style={{ width: '30%' }}>Deskripsi</TableTh>
|
<TableTr>
|
||||||
<TableTh style={{ width: '15%' }}>Aksi</TableTh>
|
<TableTh>Judul Video</TableTh>
|
||||||
</TableTr>
|
<TableTh>Tanggal</TableTh>
|
||||||
</TableThead>
|
<TableTh>Deskripsi</TableTh>
|
||||||
<TableTbody>
|
<TableTh>Aksi</TableTh>
|
||||||
{filteredData.length > 0 ? (
|
</TableTr>
|
||||||
filteredData.map((item) => (
|
</TableThead>
|
||||||
<TableTr key={item.id}>
|
<TableTbody>
|
||||||
<TableTd style={{ width: '25%' }}>
|
{filteredData.length > 0 ? (
|
||||||
<Box w={200}>
|
filteredData.map((item) => (
|
||||||
<Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text>
|
<TableTr key={item.id}>
|
||||||
</Box>
|
<TableTd style={{ maxWidth: 250 }}>
|
||||||
</TableTd>
|
<Text fz="md" fw={500} lh={1.45} truncate="end" lineClamp={1}>
|
||||||
<TableTd style={{ width: '20%' }}>
|
{item.name}
|
||||||
<Box w={200}>
|
</Text>
|
||||||
<Text fz="sm" c="dimmed">
|
</TableTd>
|
||||||
{new Date(item.createdAt).toLocaleDateString('id-ID', {
|
<TableTd style={{ maxWidth: 250 }}>
|
||||||
day: 'numeric',
|
<Text fz="sm" c="dimmed" lh={1.45}>
|
||||||
month: 'long',
|
{new Date(item.createdAt).toLocaleDateString('id-ID', {
|
||||||
year: 'numeric',
|
day: 'numeric',
|
||||||
})}
|
month: 'long',
|
||||||
</Text>
|
year: 'numeric',
|
||||||
</Box>
|
})}
|
||||||
</TableTd>
|
</Text>
|
||||||
<TableTd style={{ width: '30%' }}>
|
</TableTd>
|
||||||
<Box w={200}>
|
<TableTd style={{ maxWidth: 250 }}>
|
||||||
<Text fz="sm" truncate="end" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
|
<Text fz="sm" lh={1.45} truncate="end" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
|
||||||
</Box>
|
</TableTd>
|
||||||
</TableTd>
|
<TableTd style={{ maxWidth: 250 }}>
|
||||||
<TableTd style={{ width: '15%' }}>
|
<Button
|
||||||
<Button
|
variant="light"
|
||||||
variant="light"
|
color="blue"
|
||||||
color="blue"
|
onClick={() => router.push(`/admin/desa/gallery/video/${item.id}`)}
|
||||||
onClick={() => router.push(`/admin/desa/gallery/video/${item.id}`)}
|
fz="sm"
|
||||||
>
|
px="xs"
|
||||||
<IconDeviceImac size={20} />
|
>
|
||||||
<Text ml={5}>Detail</Text>
|
<IconDeviceImac size={18} />
|
||||||
</Button>
|
<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>
|
</TableTd>
|
||||||
</TableTr>
|
</TableTr>
|
||||||
))
|
)}
|
||||||
) : (
|
</TableTbody>
|
||||||
<TableTr>
|
</Table>
|
||||||
<TableTd colSpan={4}>
|
</Box>
|
||||||
<Center py={20}>
|
|
||||||
<Text c="dimmed">Tidak ada video yang cocok</Text>
|
|
||||||
</Center>
|
|
||||||
</TableTd>
|
|
||||||
</TableTr>
|
|
||||||
)}
|
|
||||||
</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>
|
</Paper>
|
||||||
<Center>
|
|
||||||
<Pagination
|
{totalPages > 1 && (
|
||||||
value={page}
|
<Center mt="xl">
|
||||||
onChange={(newPage) => {
|
<Pagination
|
||||||
load(newPage, 10)
|
value={page}
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
onChange={(newPage) => {
|
||||||
}}
|
load(newPage, 10)
|
||||||
total={totalPages}
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
mt="md"
|
}}
|
||||||
mb="md"
|
total={totalPages}
|
||||||
color="blue"
|
color="blue"
|
||||||
radius="md"
|
radius="md"
|
||||||
/>
|
/>
|
||||||
</Center>
|
</Center>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Video;
|
export default Video;
|
||||||
@@ -6,12 +6,12 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title
|
||||||
Tooltip,
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconArrowBack } from '@tabler/icons-react';
|
import { IconArrowBack } from '@tabler/icons-react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
@@ -24,6 +24,16 @@ function EditAjukanPermohonan() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const stateAjukan = useProxy(stateLayananDesa.ajukanPermohonan);
|
const stateAjukan = useProxy(stateLayananDesa.ajukanPermohonan);
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [originalData, setOriginalData] = useState({
|
||||||
|
nama: "",
|
||||||
|
nik: "",
|
||||||
|
alamat: "",
|
||||||
|
nomorKk: "",
|
||||||
|
kategoriId: "",
|
||||||
|
});
|
||||||
|
|
||||||
// State lokal form
|
// State lokal form
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
nama: '',
|
nama: '',
|
||||||
@@ -51,6 +61,13 @@ function EditAjukanPermohonan() {
|
|||||||
nomorKk: data.nomorKk || '',
|
nomorKk: data.nomorKk || '',
|
||||||
kategoriId: data.kategoriId || '',
|
kategoriId: data.kategoriId || '',
|
||||||
});
|
});
|
||||||
|
setOriginalData({
|
||||||
|
nama: data.nama || '',
|
||||||
|
nik: data.nik || '',
|
||||||
|
alamat: data.alamat || '',
|
||||||
|
nomorKk: data.nomorKk || '',
|
||||||
|
kategoriId: data.kategoriId || '',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading ajukan:', 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 () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
stateAjukan.edit.form = {
|
stateAjukan.edit.form = {
|
||||||
...stateAjukan.edit.form,
|
...stateAjukan.edit.form,
|
||||||
...formData,
|
...formData,
|
||||||
@@ -80,18 +109,18 @@ function EditAjukanPermohonan() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating ajukan:', error);
|
console.error('Error updating ajukan:', error);
|
||||||
toast.error('Terjadi kesalahan saat memperbarui ajukan');
|
toast.error('Terjadi kesalahan saat memperbarui ajukan');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Title order={4} ml="sm" c="dark">
|
<Title order={4} ml="sm" c="dark">
|
||||||
Edit Ajukan Permohonan
|
Edit Ajukan Permohonan
|
||||||
</Title>
|
</Title>
|
||||||
@@ -156,6 +185,17 @@ function EditAjukanPermohonan() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={handleResetForm}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -166,7 +206,7 @@ function EditAjukanPermohonan() {
|
|||||||
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>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text
|
||||||
Tooltip
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useShallowEffect } from '@mantine/hooks';
|
import { useShallowEffect } from '@mantine/hooks';
|
||||||
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||||
@@ -49,7 +48,7 @@ function DetailAjukanPermohonan() {
|
|||||||
const data = ajukanPermohonanState.findUnique.data;
|
const data = ajukanPermohonanState.findUnique.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box py={10}>
|
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||||
{/* Tombol Kembali */}
|
{/* Tombol Kembali */}
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -62,7 +61,7 @@ function DetailAjukanPermohonan() {
|
|||||||
|
|
||||||
<Paper
|
<Paper
|
||||||
withBorder
|
withBorder
|
||||||
w={{ base: '100%', md: '60%' }}
|
w={{ base: '100%', md: '70%' }}
|
||||||
bg={colors['white-1']}
|
bg={colors['white-1']}
|
||||||
p="lg"
|
p="lg"
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -121,7 +120,6 @@ function DetailAjukanPermohonan() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Tooltip label="Hapus Surat" withArrow position="top">
|
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color="red"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -135,9 +133,7 @@ function DetailAjukanPermohonan() {
|
|||||||
>
|
>
|
||||||
<IconTrash size={20} />
|
<IconTrash size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip label="Edit Surat" withArrow position="top">
|
|
||||||
<Button
|
<Button
|
||||||
color="green"
|
color="green"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -151,7 +147,6 @@ function DetailAjukanPermohonan() {
|
|||||||
>
|
>
|
||||||
<IconEdit size={20} />
|
<IconEdit size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useProxy } from 'valtio/utils';
|
import { useProxy } from 'valtio/utils';
|
||||||
import HeaderSearch from '../../../_com/header';
|
import HeaderSearch from '../../../_com/header';
|
||||||
import stateLayananDesa from '../../../_state/desa/layananDesa';
|
import stateLayananDesa from '../../../_state/desa/layananDesa';
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
|
||||||
function AjukanPermohonan() {
|
function AjukanPermohonan() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -44,6 +45,7 @@ function AjukanPermohonan() {
|
|||||||
function ListAjukanPermohonan({ search }: { search: string }) {
|
function ListAjukanPermohonan({ search }: { search: string }) {
|
||||||
const AjukanPermohonanState = useProxy(stateLayananDesa.ajukanPermohonan);
|
const AjukanPermohonanState = useProxy(stateLayananDesa.ajukanPermohonan);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 1000); // 500ms delay
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -54,58 +56,56 @@ function ListAjukanPermohonan({ search }: { search: string }) {
|
|||||||
} = AjukanPermohonanState.findMany;
|
} = AjukanPermohonanState.findMany;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load(page, 10, search);
|
load(page, 10, debouncedSearch);
|
||||||
}, [page, search]);
|
}, [page, debouncedSearch]);
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (loading || !data) {
|
if (loading || !data) {
|
||||||
return (
|
return (
|
||||||
<Stack py={10}>
|
<Stack py={{ base: 'sm', md: 'md' }}>
|
||||||
<Skeleton height={600} radius="md" />
|
<Skeleton height={600} radius="md" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box py={10}>
|
<Box py={{ base: 'sm', md: 'md' }}>
|
||||||
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
|
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
|
||||||
<Title order={4}>List Ajukan Permohonan</Title>
|
<Title order={2} lh={1.2} mb={{ base: 'md', md: 'lg' }}>
|
||||||
<Box style={{ overflowX: "auto" }}>
|
List Ajukan Permohonan
|
||||||
<Table highlightOnHover>
|
</Title>
|
||||||
|
|
||||||
|
{/* Desktop Table */}
|
||||||
|
<Box visibleFrom="md">
|
||||||
|
<Table highlightOnHover miw={0}>
|
||||||
<TableThead>
|
<TableThead>
|
||||||
<TableTr>
|
<TableTr>
|
||||||
<TableTh style={{ width: '30%' }}>Nama</TableTh>
|
<TableTh fz="sm" fw={600} lh={1.4}>Nama</TableTh>
|
||||||
<TableTh style={{ width: '45%' }}>Alamat</TableTh>
|
<TableTh fz="sm" fw={600} lh={1.4}>Alamat</TableTh>
|
||||||
<TableTh style={{ width: '15%' }}>NIK</TableTh>
|
<TableTh fz="sm" fw={600} lh={1.4}>NIK</TableTh>
|
||||||
<TableTh style={{ width: '15%' }}>Aksi</TableTh>
|
<TableTh fz="sm" fw={600} lh={1.4}>Aksi</TableTh>
|
||||||
</TableTr>
|
</TableTr>
|
||||||
</TableThead>
|
</TableThead>
|
||||||
<TableTbody>
|
<TableTbody>
|
||||||
{data.length > 0 ? (
|
{data.length > 0 ? (
|
||||||
data.map((item) => (
|
data.map((item) => (
|
||||||
<TableTr key={item.id}>
|
<TableTr key={item.id}>
|
||||||
<TableTd style={{ width: '30%' }}>
|
<TableTd>
|
||||||
<Box w={200}>
|
<Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}>
|
||||||
<Text fw={500} truncate="end" lineClamp={1}>
|
{item.nama}
|
||||||
{item.nama}
|
</Text>
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</TableTd>
|
</TableTd>
|
||||||
<TableTd style={{ width: '45%' }}>
|
<TableTd>
|
||||||
<Box w={200}>
|
<Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}>
|
||||||
<Text fw={500} truncate="end" lineClamp={1}>
|
{item.alamat}
|
||||||
{item.alamat}
|
</Text>
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</TableTd>
|
</TableTd>
|
||||||
<TableTd style={{ width: '45%' }}>
|
<TableTd>
|
||||||
<Box w={200}>
|
<Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}>
|
||||||
<Text fw={500} truncate="end" lineClamp={1}>
|
{item.nik}
|
||||||
{item.nik}
|
</Text>
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</TableTd>
|
</TableTd>
|
||||||
<TableTd style={{ width: '15%' }}>
|
<TableTd>
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -123,9 +123,11 @@ function ListAjukanPermohonan({ search }: { search: string }) {
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableTr>
|
<TableTr>
|
||||||
<TableTd colSpan={3}>
|
<TableTd colSpan={4}>
|
||||||
<Center py={20}>
|
<Center py="xl">
|
||||||
<Text color="dimmed">Tidak ada data ajukan permohonan yang cocok</Text>
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
|
Tidak ada data ajukan permohonan yang cocok
|
||||||
|
</Text>
|
||||||
</Center>
|
</Center>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
</TableTr>
|
</TableTr>
|
||||||
@@ -133,23 +135,71 @@ function ListAjukanPermohonan({ search }: { search: string }) {
|
|||||||
</TableTbody>
|
</TableTbody>
|
||||||
</Table>
|
</Table>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Mobile Card View */}
|
||||||
|
<Box hiddenFrom="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
{data.length > 0 ? (
|
||||||
|
data.map((item) => (
|
||||||
|
<Paper key={item.id} withBorder p="md" radius="md" shadow="xs">
|
||||||
|
<Stack gap={'xs'}>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>Nama</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.5}>{item.nama}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>Alamat</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.5}>{item.alamat}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>NIK</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.5}>{item.nik}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
leftSection={<IconDeviceImacCog size={16} />}
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/admin/desa/layanan/ajukan_permohonan/${item.id}`)
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Detail
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Center py="xl">
|
||||||
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
|
Tidak ada data ajukan permohonan yang cocok
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
<Center>
|
|
||||||
<Pagination
|
{totalPages > 1 && (
|
||||||
value={page}
|
<Center mt="md">
|
||||||
onChange={(newPage) => {
|
<Pagination
|
||||||
load(newPage, 10, search);
|
value={page}
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
onChange={(newPage) => {
|
||||||
}}
|
load(newPage, 10, search);
|
||||||
total={totalPages}
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
mt="md"
|
}}
|
||||||
mb="md"
|
total={totalPages}
|
||||||
color="blue"
|
color="blue"
|
||||||
radius="md"
|
radius="md"
|
||||||
/>
|
/>
|
||||||
</Center>
|
</Center>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AjukanPermohonan;
|
export default AjukanPermohonan;
|
||||||
@@ -1,10 +1,31 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import LayoutTabsLayanan from "../_com/layoutTabLayanan";
|
import LayoutTabsLayanan from "../_com/layoutTabLayanan";
|
||||||
|
import { Box } from "@mantine/core";
|
||||||
|
|
||||||
export default function Layout({children} : {children: React.ReactNode}) {
|
export default function Layout({children} : {children: React.ReactNode}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Contoh path:
|
||||||
|
// - /darmasaba/desa/layanan/semua → panjang 5 → list
|
||||||
|
// - /darmasaba/desa/layanan/Pemerintahan → panjang 5 → list
|
||||||
|
// - /darmasaba/desa/layanan/Pemerintahan/123 → panjang 6 → detail
|
||||||
|
|
||||||
|
const segments = pathname.split('/').filter(Boolean);
|
||||||
|
const isDetailPage = segments.length >= 5;
|
||||||
|
|
||||||
|
if (isDetailPage) {
|
||||||
|
// Tampilkan tanpa tab menu
|
||||||
return (
|
return (
|
||||||
<LayoutTabsLayanan>
|
<Box>
|
||||||
{children}
|
{children}
|
||||||
</LayoutTabsLayanan>
|
</Box>
|
||||||
)
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayoutTabsLayanan>
|
||||||
|
{children}
|
||||||
|
</LayoutTabsLayanan>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -8,12 +8,12 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title
|
||||||
Tooltip,
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconArrowBack } from '@tabler/icons-react';
|
import { IconArrowBack } from '@tabler/icons-react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
@@ -33,6 +33,14 @@ function EditPelayananPendudukNonPermanent() {
|
|||||||
deskripsi: '',
|
deskripsi: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [originalData, setOriginalData] = useState({
|
||||||
|
name: '',
|
||||||
|
deskripsi: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// Load data sekali dari backend
|
// Load data sekali dari backend
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -46,6 +54,10 @@ function EditPelayananPendudukNonPermanent() {
|
|||||||
name: data.name || '',
|
name: data.name || '',
|
||||||
deskripsi: data.deskripsi || '',
|
deskripsi: data.deskripsi || '',
|
||||||
});
|
});
|
||||||
|
setOriginalData({
|
||||||
|
name: data.name || '',
|
||||||
|
deskripsi: data.deskripsi || '',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading data:', error);
|
console.error('Error loading data:', error);
|
||||||
@@ -58,41 +70,55 @@ function EditPelayananPendudukNonPermanent() {
|
|||||||
|
|
||||||
const handleChange =
|
const handleChange =
|
||||||
(field: keyof typeof formData) =>
|
(field: keyof typeof formData) =>
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[field]: value,
|
[field]: value,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
name: originalData.name,
|
||||||
|
deskripsi: originalData.deskripsi,
|
||||||
|
});
|
||||||
|
toast.info("Form dikembalikan ke data awal");
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!statePendudukNonPermanent.findById.data) return;
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
if (!statePendudukNonPermanent.findById.data) return;
|
||||||
|
|
||||||
// Update global state hanya di submit
|
// Update global state hanya di submit
|
||||||
const updated = {
|
const updated = {
|
||||||
...statePendudukNonPermanent.findById.data,
|
...statePendudukNonPermanent.findById.data,
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
deskripsi: formData.deskripsi,
|
deskripsi: formData.deskripsi,
|
||||||
};
|
};
|
||||||
|
|
||||||
await statePendudukNonPermanent.update.update(updated);
|
await statePendudukNonPermanent.update.update(updated);
|
||||||
router.push('/admin/desa/layanan/pelayanan_penduduk_non_permanent');
|
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 (
|
return (
|
||||||
<Box>
|
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
<Button
|
||||||
<Button
|
variant="subtle"
|
||||||
variant="subtle"
|
onClick={() => router.back()}
|
||||||
onClick={() => router.back()}
|
p="xs"
|
||||||
p="xs"
|
radius="md"
|
||||||
radius="md"
|
>
|
||||||
>
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Title order={4} ml="sm" c="dark">
|
<Title order={4} ml="sm" c="dark">
|
||||||
Edit Pelayanan Penduduk Non Permanent
|
Edit Pelayanan Penduduk Non Permanent
|
||||||
</Title>
|
</Title>
|
||||||
@@ -130,25 +156,31 @@ function EditPelayananPendudukNonPermanent() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<Group>
|
<Group justify="right">
|
||||||
<Button
|
{/* Tombol Batal */}
|
||||||
bg={colors['blue-button']}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
loading={statePendudukNonPermanent.update.loading}
|
|
||||||
disabled={!formData.name}
|
|
||||||
>
|
|
||||||
{statePendudukNonPermanent.update.loading
|
|
||||||
? 'Menyimpan...'
|
|
||||||
: 'Simpan Perubahan'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => router.back()}
|
color="gray"
|
||||||
disabled={statePendudukNonPermanent.update.loading}
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={handleResetForm}
|
||||||
>
|
>
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
|
color: '#fff',
|
||||||
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -5,14 +5,12 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
Divider,
|
Divider,
|
||||||
Grid,
|
Group,
|
||||||
GridCol,
|
|
||||||
Paper,
|
Paper,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title
|
||||||
Tooltip,
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useShallowEffect } from '@mantine/hooks';
|
import { useShallowEffect } from '@mantine/hooks';
|
||||||
import { IconEdit } from '@tabler/icons-react';
|
import { IconEdit } from '@tabler/icons-react';
|
||||||
@@ -44,43 +42,42 @@ function PelayananPendudukNonPermanent() {
|
|||||||
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
|
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Grid align="center">
|
<Group justify='space-between' align="center">
|
||||||
<GridCol span={{ base: 12, md: 11 }}>
|
|
||||||
<Title order={3} c={colors['blue-button']}>
|
<Title
|
||||||
Preview Pelayanan Penduduk Non Permanen
|
order={3}
|
||||||
</Title>
|
lh={1.2}
|
||||||
</GridCol>
|
c={colors['blue-button']}
|
||||||
<GridCol span={{ base: 12, md: 1 }}>
|
>
|
||||||
<Tooltip label="Edit Data Pelayanan" withArrow>
|
Preview Pelayanan Penduduk Non Permanen
|
||||||
<Button
|
</Title>
|
||||||
c="green"
|
<Button
|
||||||
variant="light"
|
c="green"
|
||||||
leftSection={<IconEdit size={18} stroke={2} />}
|
variant="light"
|
||||||
radius="md"
|
leftSection={<IconEdit size={18} stroke={2} />}
|
||||||
onClick={() =>
|
radius="md"
|
||||||
router.push(
|
onClick={() =>
|
||||||
`/admin/desa/layanan/pelayanan_penduduk_non_permanent/${data.id}`
|
router.push(
|
||||||
)
|
`/admin/desa/layanan/pelayanan_penduduk_non_permanent/${data.id}`
|
||||||
}
|
)
|
||||||
>
|
}
|
||||||
Edit
|
>
|
||||||
</Button>
|
Edit
|
||||||
</Tooltip>
|
</Button>
|
||||||
</GridCol>
|
</Group>
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
|
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
|
||||||
<Box px={{ base: 0, md: 50 }} pb="xl">
|
<Box px={{ base: 0, md: 50 }} pb="xl">
|
||||||
<Center>
|
<Center>
|
||||||
<Text
|
<Title
|
||||||
|
order={2}
|
||||||
|
lh={1.2}
|
||||||
ta="center"
|
ta="center"
|
||||||
fz={{ base: '1.2rem', md: '1.8rem' }}
|
|
||||||
fw="bold"
|
|
||||||
c={colors['blue-button']}
|
c={colors['blue-button']}
|
||||||
>
|
>
|
||||||
{data.name}
|
{data.name}
|
||||||
</Text>
|
</Title>
|
||||||
</Center>
|
</Center>
|
||||||
|
|
||||||
<Divider my="md" color={colors['blue-button']} />
|
<Divider my="md" color={colors['blue-button']} />
|
||||||
@@ -89,9 +86,11 @@ function PelayananPendudukNonPermanent() {
|
|||||||
<Text
|
<Text
|
||||||
py={10}
|
py={10}
|
||||||
ta="justify"
|
ta="justify"
|
||||||
fz={{ base: '1rem', md: '1.2rem' }}
|
fz={{ base: 'sm', md: 'md' }}
|
||||||
|
lh={{ base: 1.5, md: 1.55 }}
|
||||||
|
c="dark"
|
||||||
dangerouslySetInnerHTML={{ __html: data.deskripsi }}
|
dangerouslySetInnerHTML={{ __html: data.deskripsi }}
|
||||||
style={{wordBreak: "break-word", whiteSpace: "normal"}}
|
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -101,4 +100,4 @@ function PelayananPendudukNonPermanent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PelayananPendudukNonPermanent;
|
export default PelayananPendudukNonPermanent;
|
||||||
@@ -8,12 +8,12 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title
|
||||||
Tooltip,
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconArrowBack } from '@tabler/icons-react';
|
import { IconArrowBack } from '@tabler/icons-react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
@@ -35,13 +35,21 @@ function EditPelayananPerizinanBerusaha() {
|
|||||||
link: '',
|
link: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [originalData, setOriginalData] = useState({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
deskripsi: '',
|
||||||
|
link: '',
|
||||||
|
});
|
||||||
|
|
||||||
// Load data detail
|
// Load data detail
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
toast.error("ID tidak valid");
|
toast.error("ID tidak valid");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -53,6 +61,12 @@ function EditPelayananPerizinanBerusaha() {
|
|||||||
deskripsi: data.deskripsi || "",
|
deskripsi: data.deskripsi || "",
|
||||||
link: data.link || "",
|
link: data.link || "",
|
||||||
});
|
});
|
||||||
|
setOriginalData({
|
||||||
|
id: data.id,
|
||||||
|
name: data.name || "",
|
||||||
|
deskripsi: data.deskripsi || "",
|
||||||
|
link: data.link || "",
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error("Data tidak ditemukan");
|
toast.error("Data tidak ditemukan");
|
||||||
}
|
}
|
||||||
@@ -63,10 +77,10 @@ function EditPelayananPerizinanBerusaha() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
|
||||||
const handleChange =
|
const handleChange =
|
||||||
(field: keyof typeof formData) =>
|
(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 () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
await state.update.update(formData);
|
await state.update.update(formData);
|
||||||
router.push('/admin/desa/layanan/pelayanan_perizinan_berusaha');
|
router.push('/admin/desa/layanan/pelayanan_perizinan_berusaha');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating pelayanan perizinan berusaha:', error);
|
console.error('Error updating pelayanan perizinan berusaha:', error);
|
||||||
toast.error('Terjadi kesalahan saat update data');
|
toast.error('Terjadi kesalahan saat update data');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,20 +123,18 @@ function EditPelayananPerizinanBerusaha() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
<Button
|
||||||
<Button
|
variant="subtle"
|
||||||
variant="subtle"
|
onClick={() => router.back()}
|
||||||
onClick={() => router.back()}
|
p="xs"
|
||||||
p="xs"
|
radius="md"
|
||||||
radius="md"
|
>
|
||||||
>
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Title order={4} ml="sm" c="dark">
|
<Title order={4} ml="sm" c="dark">
|
||||||
Edit Pelayanan Perizinan Berusaha
|
Edit Pelayanan Perizinan Berusaha
|
||||||
</Title>
|
</Title>
|
||||||
@@ -150,23 +175,31 @@ function EditPelayananPerizinanBerusaha() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Group>
|
<Group justify="right">
|
||||||
<Button
|
{/* Tombol Batal */}
|
||||||
bg={colors['blue-button']}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
loading={state.update.loading}
|
|
||||||
disabled={!formData.name}
|
|
||||||
>
|
|
||||||
{state.update.loading ? 'Menyimpan...' : 'Simpan Perubahan'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => router.back()}
|
color="gray"
|
||||||
disabled={state.update.loading}
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={handleResetForm}
|
||||||
>
|
>
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
|
color: '#fff',
|
||||||
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
Divider,
|
Divider,
|
||||||
Grid,
|
|
||||||
GridCol,
|
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
@@ -16,14 +14,13 @@ import {
|
|||||||
StepperCompleted,
|
StepperCompleted,
|
||||||
StepperStep,
|
StepperStep,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title
|
||||||
Tooltip,
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconEdit } from '@tabler/icons-react';
|
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 { useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import stateLayananDesa from '../../../_state/desa/layananDesa';
|
||||||
|
|
||||||
function PerizinanBerusaha() {
|
function PerizinanBerusaha() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -42,8 +39,7 @@ function PerizinanBerusaha() {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// You should get the ID from your router query or params
|
const id = 'edit';
|
||||||
const id = 'edit'; // Replace with actual ID or get from URL params
|
|
||||||
await pelayananPerizinanBerusaha.findById.load(id);
|
await pelayananPerizinanBerusaha.findById.load(id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Gagal memuat data');
|
setError('Gagal memuat data');
|
||||||
@@ -67,7 +63,7 @@ function PerizinanBerusaha() {
|
|||||||
if (error || !pelayananPerizinanBerusaha.findById.data) {
|
if (error || !pelayananPerizinanBerusaha.findById.data) {
|
||||||
return (
|
return (
|
||||||
<Center h={200}>
|
<Center h={200}>
|
||||||
<Text>{error || 'Data tidak ditemukan'}</Text>
|
<Text c="dimmed">{error || 'Data tidak ditemukan'}</Text>
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -78,67 +74,63 @@ function PerizinanBerusaha() {
|
|||||||
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
|
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Grid align="center">
|
<Group justify='space-between' align="center">
|
||||||
<GridCol span={{ base: 12, md: 11 }}>
|
<Title order={3} c={colors['blue-button']} lh={1.2}>
|
||||||
<Title order={3} c={colors['blue-button']}>
|
Preview Pelayanan Perizinan Berusaha
|
||||||
Preview Pelayanan Perizinan Berusaha
|
</Title>
|
||||||
</Title>
|
<Button
|
||||||
</GridCol>
|
c="green"
|
||||||
<GridCol span={{ base: 12, md: 1 }}>
|
variant="light"
|
||||||
<Tooltip label="Edit Data Perizinan" withArrow>
|
leftSection={<IconEdit size={18} stroke={2} />}
|
||||||
<Button
|
radius="md"
|
||||||
c="green"
|
onClick={() =>
|
||||||
variant="light"
|
router.push(
|
||||||
leftSection={<IconEdit size={18} stroke={2} />}
|
`/admin/desa/layanan/pelayanan_perizinan_berusaha/${data.id}`
|
||||||
radius="md"
|
)
|
||||||
onClick={() =>
|
}
|
||||||
router.push(
|
>
|
||||||
`/admin/desa/layanan/pelayanan_perizinan_berusaha/${data.id}`
|
Edit
|
||||||
)
|
</Button>
|
||||||
}
|
</Group>
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</GridCol>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
|
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
|
||||||
<Box px={{ base: 0, md: 50 }} pb="xl">
|
<Box px={{ base: 0, md: 50 }} pb="xl">
|
||||||
<Center>
|
<Center>
|
||||||
<Text
|
<Title
|
||||||
|
order={3}
|
||||||
ta="center"
|
ta="center"
|
||||||
fz={{ base: '1.2rem', md: '1.8rem' }}
|
|
||||||
fw="bold"
|
|
||||||
c={colors['blue-button']}
|
c={colors['blue-button']}
|
||||||
|
lh={1.15}
|
||||||
>
|
>
|
||||||
{data.name}
|
{data.name}
|
||||||
</Text>
|
</Title>
|
||||||
</Center>
|
</Center>
|
||||||
|
|
||||||
<Divider my="md" color={colors['blue-button']} />
|
<Divider my="md" color={colors['blue-button']} />
|
||||||
|
|
||||||
<Box mt="lg">
|
<Box mt="lg">
|
||||||
<Text
|
<Text
|
||||||
py={10}
|
py="xs"
|
||||||
ta="justify"
|
ta={{ base: "left", md: "justify" }}
|
||||||
fz={{ base: '1rem', md: '1.2rem' }}
|
fz={{ base: 'sm', md: 'md' }}
|
||||||
|
lh={1.55}
|
||||||
|
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
|
||||||
dangerouslySetInnerHTML={{ __html: data.deskripsi }}
|
dangerouslySetInnerHTML={{ __html: data.deskripsi }}
|
||||||
style={{wordBreak: "break-word", whiteSpace: "normal"}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
py={10}
|
py="xs"
|
||||||
fz={{ base: '1rem', md: '1.2rem' }}
|
fz={{ base: 'sm', md: 'md' }}
|
||||||
fw="bold"
|
fw={700}
|
||||||
c={colors['blue-button']}
|
c={colors['blue-button']}
|
||||||
|
lh={1.5}
|
||||||
>
|
>
|
||||||
Proses pendaftaran NIB melalui OSS mencakup beberapa langkah
|
Proses pendaftaran NIB melalui OSS mencakup beberapa langkah
|
||||||
umum:
|
umum:
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Box p="xl" w="100%">
|
<Box p="xl" w="100%" visibleFrom='md'>
|
||||||
<Stepper
|
<Stepper
|
||||||
active={active}
|
active={active}
|
||||||
onStepClick={setActive}
|
onStepClick={setActive}
|
||||||
@@ -146,28 +138,115 @@ function PerizinanBerusaha() {
|
|||||||
styles={{
|
styles={{
|
||||||
separator: { marginLeft: 25 },
|
separator: { marginLeft: 25 },
|
||||||
step: { padding: '12px 0' },
|
step: { padding: '12px 0' },
|
||||||
|
stepLabel: {
|
||||||
|
fontSize: 'var(--mantine-font-size-sm)',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
stepDescription: {
|
||||||
|
fontSize: 'var(--mantine-font-size-xs)',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StepperStep label="Langkah Pertama" description="Pendaftaran Akun">
|
<StepperStep label="Langkah Pertama" description="Pendaftaran Akun">
|
||||||
Pendaftaran akun pada portal OSS
|
<Text fz="sm" lh={1.5}>
|
||||||
|
Pendaftaran akun pada portal OSS
|
||||||
|
</Text>
|
||||||
</StepperStep>
|
</StepperStep>
|
||||||
<StepperStep label="Langkah Kedua" description="Pengisian Data Perusahaan">
|
<StepperStep label="Langkah Kedua" description="Pengisian Data Perusahaan">
|
||||||
Mengisi informasi perusahaan, termasuk data pemegang saham, alamat perusahaan, dan lainnya
|
<Text fz="sm" lh={1.5}>
|
||||||
|
Mengisi informasi perusahaan, termasuk data pemegang saham, alamat perusahaan, dan lainnya
|
||||||
|
</Text>
|
||||||
</StepperStep>
|
</StepperStep>
|
||||||
<StepperStep label="Langkah Ketiga" description="Pemilihan KBLI">
|
<StepperStep label="Langkah Ketiga" description="Pemilihan KBLI">
|
||||||
Memilih KBLI dengan jenis usaha yang akan didaftarkan
|
<Text fz="sm" lh={1.5}>
|
||||||
|
Memilih KBLI dengan jenis usaha yang akan didaftarkan
|
||||||
|
</Text>
|
||||||
</StepperStep>
|
</StepperStep>
|
||||||
<StepperStep label="Langkah Keempat" description="Pengunggahan Dokumen">
|
<StepperStep label="Langkah Keempat" description="Pengunggahan Dokumen">
|
||||||
Mengunggah dokumen-dokumen yang diperlukan, seperti akta pendirian perusahaan, surat izin usaha, dan dokumen lainnya sesuai dengan ketentuan yang berlaku
|
<Text fz="sm" lh={1.5}>
|
||||||
|
Mengunggah dokumen-dokumen yang diperlukan, seperti akta pendirian perusahaan, surat izin usaha, dan dokumen lainnya sesuai dengan ketentuan yang berlaku
|
||||||
|
</Text>
|
||||||
</StepperStep>
|
</StepperStep>
|
||||||
<StepperStep label="Langkah Kelima" description="Verifikasi dan Persetujuan">
|
<StepperStep label="Langkah Kelima" description="Verifikasi dan Persetujuan">
|
||||||
Proses verifikasi dan persetujuan oleh instansi terkait
|
<Text fz="sm" lh={1.5}>
|
||||||
|
Proses verifikasi dan persetujuan oleh instansi terkait
|
||||||
|
</Text>
|
||||||
</StepperStep>
|
</StepperStep>
|
||||||
<StepperStep label="Langkah Keenam" description="Penerimaan NIB">
|
<StepperStep label="Langkah Keenam" description="Penerimaan NIB">
|
||||||
Jika proses sebelumnya berhasil, perusahaan akan menerima NIB sebagai identitas resmi usaha anda
|
<Text fz="sm" lh={1.5}>
|
||||||
|
Jika proses sebelumnya berhasil, perusahaan akan menerima NIB sebagai identitas resmi usaha anda
|
||||||
|
</Text>
|
||||||
</StepperStep>
|
</StepperStep>
|
||||||
<StepperCompleted>
|
<StepperCompleted>
|
||||||
Selesai, anda telah mengikuti proses pendaftaran NIB melalui OSS
|
<Text fz="sm" lh={1.5}>
|
||||||
|
Selesai, anda telah mengikuti proses pendaftaran NIB melalui OSS
|
||||||
|
</Text>
|
||||||
|
</StepperCompleted>
|
||||||
|
</Stepper>
|
||||||
|
|
||||||
|
<Group justify="center" mt="xl">
|
||||||
|
<Button variant="default" onClick={prevStep}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={nextStep}>Next step</Button>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box p="xl" w="100%" hiddenFrom='md'>
|
||||||
|
<Stepper
|
||||||
|
active={active}
|
||||||
|
onStepClick={setActive}
|
||||||
|
orientation="vertical"
|
||||||
|
styles={{
|
||||||
|
separator: { marginLeft: 25 },
|
||||||
|
step: { padding: '12px 0' },
|
||||||
|
stepLabel: {
|
||||||
|
fontSize: 'var(--mantine-font-size-sm)',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
stepDescription: {
|
||||||
|
fontSize: 'var(--mantine-font-size-xs)',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StepperStep label="Langkah Pertama" description="Pendaftaran Akun">
|
||||||
|
<Text fz="sm" lh={1.5}>
|
||||||
|
|
||||||
|
</Text>
|
||||||
|
</StepperStep>
|
||||||
|
<StepperStep label="Langkah Kedua" description="Pengisian Data Perusahaan">
|
||||||
|
<Text fz="sm" lh={1.5}>
|
||||||
|
|
||||||
|
</Text>
|
||||||
|
</StepperStep>
|
||||||
|
<StepperStep label="Langkah Ketiga" description="Pemilihan KBLI">
|
||||||
|
<Text fz="sm" lh={1.5}>
|
||||||
|
|
||||||
|
</Text>
|
||||||
|
</StepperStep>
|
||||||
|
<StepperStep label="Langkah Keempat" description="Pengunggahan Dokumen">
|
||||||
|
<Text fz="sm" lh={1.5}>
|
||||||
|
|
||||||
|
</Text>
|
||||||
|
</StepperStep>
|
||||||
|
<StepperStep label="Langkah Kelima" description="Verifikasi dan Persetujuan">
|
||||||
|
<Text fz="sm" lh={1.5}>
|
||||||
|
|
||||||
|
</Text>
|
||||||
|
</StepperStep>
|
||||||
|
<StepperStep label="Langkah Keenam" description="Penerimaan NIB">
|
||||||
|
<Text fz="sm" lh={1.5}>
|
||||||
|
|
||||||
|
</Text>
|
||||||
|
</StepperStep>
|
||||||
|
<StepperCompleted>
|
||||||
|
<Text fz="sm" lh={1.5}>
|
||||||
|
|
||||||
|
</Text>
|
||||||
</StepperCompleted>
|
</StepperCompleted>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
|
||||||
@@ -180,9 +259,10 @@ function PerizinanBerusaha() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
py={35}
|
py="md"
|
||||||
ta="justify"
|
ta={{ base: "left", md: "justify" }}
|
||||||
fz={{ base: '1rem', md: '1.2rem' }}
|
fz={{ base: 'sm', md: 'md' }}
|
||||||
|
lh={1.55}
|
||||||
>
|
>
|
||||||
Penting untuk diingat bahwa prosedur dan persyaratan dapat
|
Penting untuk diingat bahwa prosedur dan persyaratan dapat
|
||||||
berubah seiring waktu. Untuk informasi yang lebih akurat dan
|
berubah seiring waktu. Untuk informasi yang lebih akurat dan
|
||||||
@@ -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 EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
|
||||||
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
|
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
|
||||||
import colors from '@/con/colors';
|
import colors from '@/con/colors';
|
||||||
import ApiFetch from '@/lib/api-fetch';
|
import ApiFetch from '@/lib/api-fetch';
|
||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
Image,
|
Image,
|
||||||
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
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() {
|
function EditSuratKeterangan() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const stateSurat = useProxy(stateLayananDesa.suratKeterangan);
|
|
||||||
|
|
||||||
// state lokal untuk form
|
// 🧩 State
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState<FormData>({
|
||||||
name: '',
|
name: '',
|
||||||
deskripsi: '',
|
deskripsi: '',
|
||||||
imageId: '',
|
imageId: '',
|
||||||
image2Id: '',
|
image2Id: '',
|
||||||
|
imageUrl: '',
|
||||||
|
image2Url: '',
|
||||||
});
|
});
|
||||||
|
const [originalData, setOriginalData] = useState<FormData>(formData);
|
||||||
// state file upload
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [file2, setFile2] = useState<File | null>(null);
|
const [file2, setFile2] = useState<File | null>(null);
|
||||||
|
|
||||||
// state preview gambar
|
|
||||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
const [previewImage2, setPreviewImage2] = useState<string | null>(null);
|
const [previewImage2, setPreviewImage2] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
// load data awal
|
// 🧭 Load Initial Data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSurat = async () => {
|
const loadSurat = async () => {
|
||||||
const id = params?.id as string;
|
const id = params?.id as string;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await stateSurat.edit.load(id);
|
const data = await stateLayananDesa.suratKeterangan.edit.load(id);
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
setFormData((prev) => ({
|
const mapped: FormData = {
|
||||||
...prev,
|
name: data.name || '',
|
||||||
...{
|
deskripsi: data.deskripsi || '',
|
||||||
name: prev.name || data.name || "",
|
imageId: data.imageId || '',
|
||||||
deskripsi: prev.deskripsi || data.deskripsi || "",
|
image2Id: data.image2Id || '',
|
||||||
imageId: prev.imageId || data.imageId || "",
|
imageUrl: data.image?.link || '',
|
||||||
image2Id: prev.image2Id || data.image2Id || "",
|
image2Url: data.image2?.link || ''
|
||||||
},
|
};
|
||||||
}));
|
|
||||||
|
|
||||||
if (data.image?.link && !previewImage) setPreviewImage(data.image.link);
|
setFormData(mapped);
|
||||||
if (data.image2?.link && !previewImage2) setPreviewImage2(data.image2.link);
|
setOriginalData(mapped);
|
||||||
|
|
||||||
|
if (data.image?.link) setPreviewImage(data.image.link);
|
||||||
|
if (data.image2?.link) setPreviewImage2(data.image2.link);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading surat:", error);
|
console.error('Error loading surat:', error);
|
||||||
toast.error("Gagal memuat data surat");
|
toast.error('Gagal memuat data surat');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadSurat();
|
loadSurat();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [params?.id]);
|
}, [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 () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
// update form global hanya saat submit
|
setIsSubmitting(true);
|
||||||
stateSurat.edit.form = { ...stateSurat.edit.form, ...formData };
|
|
||||||
|
|
||||||
// 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) {
|
if (file) {
|
||||||
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
|
const uploadedId = await uploadFile(file);
|
||||||
const uploaded = res.data?.data;
|
if (!uploadedId) {
|
||||||
if (!uploaded?.id) return toast.error('Gagal upload gambar');
|
toast.error('Gagal upload gambar pertama');
|
||||||
stateSurat.edit.form.imageId = uploaded.id;
|
return;
|
||||||
|
}
|
||||||
|
originalState.edit.form.imageId = uploadedId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// upload file 2
|
// Upload file 2 if exists
|
||||||
if (file2) {
|
if (file2) {
|
||||||
const res = await ApiFetch.api.fileStorage.create.post({ file: file2, name: file2.name });
|
const uploadedId = await uploadFile(file2);
|
||||||
const uploaded = res.data?.data;
|
if (!uploadedId) {
|
||||||
if (!uploaded?.id) return toast.error('Gagal upload gambar');
|
toast.error('Gagal upload gambar kedua');
|
||||||
stateSurat.edit.form.image2Id = uploaded.id;
|
return;
|
||||||
|
}
|
||||||
|
originalState.edit.form.image2Id = uploadedId;
|
||||||
}
|
}
|
||||||
|
|
||||||
await stateSurat.edit.update();
|
// Submit update
|
||||||
|
await originalState.edit.update();
|
||||||
toast.success('Surat berhasil diperbarui!');
|
toast.success('Surat berhasil diperbarui!');
|
||||||
router.push('/admin/desa/layanan/pelayanan_surat_keterangan');
|
router.push('/admin/desa/layanan/pelayanan_surat_keterangan');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating surat:', error);
|
console.error('Error updating surat:', error);
|
||||||
toast.error('Terjadi kesalahan saat memperbarui surat');
|
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 (
|
return (
|
||||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||||
{/* Back Button */}
|
{/* Header */}
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Title order={4} ml="sm" c="dark">
|
<Title order={4} ml="sm" c="dark">
|
||||||
Edit Surat Keterangan
|
Edit Surat Keterangan
|
||||||
</Title>
|
</Title>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
<Paper
|
<Paper
|
||||||
w={{ base: '100%', md: '50%' }}
|
w={{ base: '100%', md: '50%' }}
|
||||||
bg={colors['white-1']}
|
bg={colors['white-1']}
|
||||||
@@ -131,154 +284,66 @@ function EditSuratKeterangan() {
|
|||||||
style={{ border: '1px solid #e0e0e0' }}
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{/* Input nama */}
|
{/* Nama Surat */}
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Nama Surat Keterangan"
|
label="Nama Surat Keterangan"
|
||||||
placeholder="Masukkan nama surat keterangan"
|
placeholder="Masukkan nama surat keterangan"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
onChange={handleNameChange}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Input deskripsi */}
|
{/* Deskripsi */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="sm" fw="bold" mb={6}>
|
<Text fz="sm" fw="bold" mb={6}>
|
||||||
Konten
|
Konten
|
||||||
</Text>
|
</Text>
|
||||||
<EditEditor
|
<EditEditor
|
||||||
value={formData.deskripsi}
|
value={formData.deskripsi}
|
||||||
onChange={(htmlContent) =>
|
onChange={handleDeskripsiChange}
|
||||||
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Upload Gambar 1 */}
|
{/* Gambar 1 */}
|
||||||
<Box>
|
<FileUploader
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
title="Gambar Konten Pelayanan"
|
||||||
Gambar Konten Pelayanan
|
file={file}
|
||||||
</Text>
|
setFile={setFile}
|
||||||
<Dropzone
|
preview={previewImage}
|
||||||
onDrop={(files) => {
|
setPreview={setPreviewImage}
|
||||||
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>
|
|
||||||
|
|
||||||
{previewImage && (
|
{/* Gambar 2 */}
|
||||||
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
|
<FileUploader
|
||||||
<Image
|
title="Gambar Alur Pelayanan Surat"
|
||||||
src={previewImage}
|
file={file2}
|
||||||
alt="Preview Gambar 1"
|
setFile={setFile2}
|
||||||
radius="md"
|
preview={previewImage2}
|
||||||
style={{
|
setPreview={setPreviewImage2}
|
||||||
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>
|
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
onClick={handleResetForm}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
disabled={isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
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>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -287,4 +352,4 @@ function EditSuratKeterangan() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default EditSuratKeterangan;
|
export default EditSuratKeterangan;
|
||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text
|
||||||
Tooltip,
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useShallowEffect } from '@mantine/hooks';
|
import { useShallowEffect } from '@mantine/hooks';
|
||||||
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||||
@@ -50,7 +49,7 @@ function DetailSuratKeterangan() {
|
|||||||
const data = suratKeteranganState.findUnique.data;
|
const data = suratKeteranganState.findUnique.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box py={10}>
|
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||||
{/* Tombol Kembali */}
|
{/* Tombol Kembali */}
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -63,7 +62,7 @@ function DetailSuratKeterangan() {
|
|||||||
|
|
||||||
<Paper
|
<Paper
|
||||||
withBorder
|
withBorder
|
||||||
w={{ base: '100%', md: '60%' }}
|
w={{ base: '100%', md: '70%' }}
|
||||||
bg={colors['white-1']}
|
bg={colors['white-1']}
|
||||||
p="lg"
|
p="lg"
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -76,20 +75,21 @@ function DetailSuratKeterangan() {
|
|||||||
|
|
||||||
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
|
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Box>
|
<Stack gap={"xs"}>
|
||||||
<Text fz="lg" fw="bold">
|
<Text fz="lg" fw="bold">
|
||||||
Nama
|
Nama
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz="md" c="dimmed">
|
<Text fz="md" c="dimmed">
|
||||||
{data?.name || '-'}
|
{data?.name || '-'}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Stack>
|
||||||
|
|
||||||
<Box>
|
<Stack gap={"xs"}>
|
||||||
<Text fz="lg" fw="bold">
|
<Text fz="lg" fw="bold">
|
||||||
Deskripsi
|
Deskripsi
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Box pl={10}>
|
||||||
|
<Text
|
||||||
fz="md"
|
fz="md"
|
||||||
c="dimmed"
|
c="dimmed"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
@@ -97,9 +97,10 @@ function DetailSuratKeterangan() {
|
|||||||
}}
|
}}
|
||||||
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Box>
|
<Stack gap={"xs"}>
|
||||||
<Text fz="lg" fw="bold">
|
<Text fz="lg" fw="bold">
|
||||||
Gambar Konten Pelayanan
|
Gambar Konten Pelayanan
|
||||||
</Text>
|
</Text>
|
||||||
@@ -118,7 +119,7 @@ function DetailSuratKeterangan() {
|
|||||||
Tidak ada gambar
|
Tidak ada gambar
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Stack>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">
|
<Text fz="lg" fw="bold">
|
||||||
@@ -142,7 +143,6 @@ function DetailSuratKeterangan() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Tooltip label="Hapus Surat" withArrow position="top">
|
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color="red"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -156,9 +156,7 @@ function DetailSuratKeterangan() {
|
|||||||
>
|
>
|
||||||
<IconTrash size={20} />
|
<IconTrash size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip label="Edit Surat" withArrow position="top">
|
|
||||||
<Button
|
<Button
|
||||||
color="green"
|
color="green"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -172,7 +170,6 @@ function DetailSuratKeterangan() {
|
|||||||
>
|
>
|
||||||
<IconEdit size={20} />
|
<IconEdit size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -5,16 +5,17 @@ import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
|
|||||||
import colors from '@/con/colors';
|
import colors from '@/con/colors';
|
||||||
import ApiFetch from '@/lib/api-fetch';
|
import ApiFetch from '@/lib/api-fetch';
|
||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
Image,
|
Image,
|
||||||
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title
|
||||||
Tooltip,
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
||||||
@@ -28,6 +29,7 @@ function CreateSuratKeterangan() {
|
|||||||
const [previewImage2, setPreviewImage2] = useState<{ preview: string; file: File } | null>(null);
|
const [previewImage2, setPreviewImage2] = useState<{ preview: string; file: File } | null>(null);
|
||||||
const [previewImage, setPreviewImage] = useState<{ preview: string; file: File } | null>(null);
|
const [previewImage, setPreviewImage] = useState<{ preview: string; file: File } | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
stateSurat.create.form = {
|
stateSurat.create.form = {
|
||||||
@@ -46,6 +48,7 @@ function CreateSuratKeterangan() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
// Upload gambar utama
|
// Upload gambar utama
|
||||||
const res1 = await ApiFetch.api.fileStorage.create.post({
|
const res1 = await ApiFetch.api.fileStorage.create.post({
|
||||||
file: previewImage.file,
|
file: previewImage.file,
|
||||||
@@ -78,18 +81,18 @@ function CreateSuratKeterangan() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating surat keterangan:', error);
|
console.error('Error creating surat keterangan:', error);
|
||||||
toast.error('Terjadi kesalahan saat menambahkan surat keterangan');
|
toast.error('Terjadi kesalahan saat menambahkan surat keterangan');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Title order={4} ml="sm" c="dark">
|
<Title order={4} ml="sm" c="dark">
|
||||||
Tambah Surat Keterangan
|
Tambah Surat Keterangan
|
||||||
</Title>
|
</Title>
|
||||||
@@ -106,7 +109,7 @@ function CreateSuratKeterangan() {
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{/* Nama Surat */}
|
{/* Nama Surat */}
|
||||||
<TextInput
|
<TextInput
|
||||||
defaultValue={stateSurat.create.form.name}
|
value={stateSurat.create.form.name}
|
||||||
onChange={(val) => (stateSurat.create.form.name = val.target.value)}
|
onChange={(val) => (stateSurat.create.form.name = val.target.value)}
|
||||||
label="Nama Surat Keterangan"
|
label="Nama Surat Keterangan"
|
||||||
placeholder="Masukkan nama surat keterangan"
|
placeholder="Masukkan nama surat keterangan"
|
||||||
@@ -143,7 +146,7 @@ function CreateSuratKeterangan() {
|
|||||||
}}
|
}}
|
||||||
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||||
maxSize={5 * 1024 ** 2}
|
maxSize={5 * 1024 ** 2}
|
||||||
accept={{ 'image/*': [] }}
|
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||||
radius="md"
|
radius="md"
|
||||||
p="xl"
|
p="xl"
|
||||||
>
|
>
|
||||||
@@ -164,7 +167,7 @@ function CreateSuratKeterangan() {
|
|||||||
</Dropzone>
|
</Dropzone>
|
||||||
|
|
||||||
{previewImage && (
|
{previewImage && (
|
||||||
<Box mt="sm" style={{ textAlign: 'center' }}>
|
<Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}>
|
||||||
<Image
|
<Image
|
||||||
src={previewImage.preview}
|
src={previewImage.preview}
|
||||||
alt="Preview Gambar Utama"
|
alt="Preview Gambar Utama"
|
||||||
@@ -172,6 +175,23 @@ function CreateSuratKeterangan() {
|
|||||||
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
|
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
|
||||||
loading="lazy"
|
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>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -193,7 +213,7 @@ function CreateSuratKeterangan() {
|
|||||||
}}
|
}}
|
||||||
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||||
maxSize={5 * 1024 ** 2}
|
maxSize={5 * 1024 ** 2}
|
||||||
accept={{ 'image/*': [] }}
|
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||||
radius="md"
|
radius="md"
|
||||||
p="xl"
|
p="xl"
|
||||||
>
|
>
|
||||||
@@ -214,7 +234,7 @@ function CreateSuratKeterangan() {
|
|||||||
</Dropzone>
|
</Dropzone>
|
||||||
|
|
||||||
{previewImage2 ? (
|
{previewImage2 ? (
|
||||||
<Box mt="sm" style={{ textAlign: 'center' }}>
|
<Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}>
|
||||||
<Image
|
<Image
|
||||||
src={previewImage2.preview}
|
src={previewImage2.preview}
|
||||||
alt="Preview Gambar Tambahan"
|
alt="Preview Gambar Tambahan"
|
||||||
@@ -222,6 +242,23 @@ function CreateSuratKeterangan() {
|
|||||||
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
|
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
|
||||||
loading="lazy"
|
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>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Text size="sm" c="dimmed" mt="sm" ta="center">
|
<Text size="sm" c="dimmed" mt="sm" ta="center">
|
||||||
@@ -232,6 +269,17 @@ function CreateSuratKeterangan() {
|
|||||||
|
|
||||||
{/* Tombol Simpan */}
|
{/* Tombol Simpan */}
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={resetForm}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -242,7 +290,7 @@ function CreateSuratKeterangan() {
|
|||||||
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>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
TableTr,
|
TableTr,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
Tooltip
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
|
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
@@ -26,9 +25,10 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { useProxy } from 'valtio/utils';
|
import { useProxy } from 'valtio/utils';
|
||||||
import HeaderSearch from '../../../_com/header';
|
import HeaderSearch from '../../../_com/header';
|
||||||
import stateLayananDesa from '../../../_state/desa/layananDesa';
|
import stateLayananDesa from '../../../_state/desa/layananDesa';
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
|
||||||
function SuratKeterangan() {
|
function SuratKeterangan() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState('');
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<HeaderSearch
|
<HeaderSearch
|
||||||
@@ -46,6 +46,7 @@ function SuratKeterangan() {
|
|||||||
function ListSuratKeterangan({ search }: { search: string }) {
|
function ListSuratKeterangan({ search }: { search: string }) {
|
||||||
const suratKeteranganState = useProxy(stateLayananDesa.suratKeterangan);
|
const suratKeteranganState = useProxy(stateLayananDesa.suratKeterangan);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -56,74 +57,80 @@ function ListSuratKeterangan({ search }: { search: string }) {
|
|||||||
} = suratKeteranganState.findMany;
|
} = suratKeteranganState.findMany;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load(page, 10, search);
|
load(page, 10, debouncedSearch);
|
||||||
}, [page, search]);
|
}, [page, debouncedSearch]);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
const keyword = search.toLowerCase();
|
const keyword = debouncedSearch.toLowerCase();
|
||||||
return data.filter(item =>
|
return data.filter(
|
||||||
item.name?.toLowerCase().includes(keyword) ||
|
(item) =>
|
||||||
item.deskripsi?.toLowerCase().includes(keyword)
|
item.name?.toLowerCase().includes(keyword) ||
|
||||||
|
item.deskripsi?.toLowerCase().includes(keyword)
|
||||||
);
|
);
|
||||||
}, [data, search]);
|
}, [data, debouncedSearch]);
|
||||||
|
|
||||||
// Loading state
|
|
||||||
if (loading || !data) {
|
if (loading || !data) {
|
||||||
return (
|
return (
|
||||||
<Stack py={10}>
|
<Stack py={{ base: 'sm', md: 'md' }}>
|
||||||
<Skeleton height={600} radius="md" />
|
<Skeleton height={600} radius="md" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box py={10}>
|
<Box py={{ base: 'sm', md: 'md' }}>
|
||||||
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
|
<Paper withBorder bg={colors['white-1']} p={{ base: 'sm', md: 'lg' }} shadow="md" radius="md">
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
|
||||||
<Title order={4}>List Surat Keterangan</Title>
|
<Title order={4} lh={1.2}>
|
||||||
<Tooltip label="Tambah Surat Keterangan" withArrow>
|
List Surat Keterangan
|
||||||
<Button
|
</Title>
|
||||||
leftSection={<IconPlus size={18} />}
|
<Button
|
||||||
color="blue"
|
leftSection={<IconPlus size={18} />}
|
||||||
variant="light"
|
color="blue"
|
||||||
onClick={() =>
|
variant="light"
|
||||||
router.push('/admin/desa/layanan/pelayanan_surat_keterangan/create')
|
onClick={() =>
|
||||||
}
|
router.push('/admin/desa/layanan/pelayanan_surat_keterangan/create')
|
||||||
>
|
}
|
||||||
Tambah Baru
|
>
|
||||||
</Button>
|
Tambah Baru
|
||||||
</Tooltip>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
<Box style={{ overflowX: "auto" }}>
|
|
||||||
|
{/* Desktop Table */}
|
||||||
|
<Box visibleFrom="md">
|
||||||
<Table highlightOnHover>
|
<Table highlightOnHover>
|
||||||
<TableThead>
|
<TableThead>
|
||||||
<TableTr>
|
<TableTr>
|
||||||
<TableTh style={{ width: '30%' }}>Nama</TableTh>
|
<TableTh fz="sm" fw={600} ta="left">
|
||||||
<TableTh style={{ width: '45%' }}>Deskripsi</TableTh>
|
Nama
|
||||||
<TableTh style={{ width: '15%' }}>Aksi</TableTh>
|
</TableTh>
|
||||||
|
<TableTh fz="sm" fw={600} ta="left">
|
||||||
|
Deskripsi
|
||||||
|
</TableTh>
|
||||||
|
<TableTh fz="sm" fw={600} ta="left">
|
||||||
|
Aksi
|
||||||
|
</TableTh>
|
||||||
</TableTr>
|
</TableTr>
|
||||||
</TableThead>
|
</TableThead>
|
||||||
<TableTbody>
|
<TableTbody>
|
||||||
{filteredData.length > 0 ? (
|
{filteredData.length > 0 ? (
|
||||||
filteredData.map((item) => (
|
filteredData.map((item) => (
|
||||||
<TableTr key={item.id}>
|
<TableTr key={item.id}>
|
||||||
<TableTd style={{ width: '30%' }}>
|
<TableTd>
|
||||||
<Box w={200}>
|
<Text fz="md" fw={500} lh={1.5} truncate="end">
|
||||||
<Text fw={500} truncate="end" lineClamp={1}>
|
{item.name}
|
||||||
{item.name}
|
</Text>
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</TableTd>
|
</TableTd>
|
||||||
<TableTd style={{ width: '45%' }}>
|
<TableTd>
|
||||||
<Box w={200}>
|
<Text
|
||||||
<Text truncate="end" lineClamp={1} fz="sm" c="dimmed"
|
fz="sm"
|
||||||
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
|
lh={1.5}
|
||||||
style={{wordBreak: "break-word", whiteSpace: "normal"}}
|
dangerouslySetInnerHTML={{ __html: item.deskripsi || '' }}
|
||||||
/>
|
style={{ wordBreak: 'break-word' }}
|
||||||
</Box>
|
/>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
<TableTd style={{ width: '15%' }}>
|
<TableTd>
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -131,7 +138,9 @@ function ListSuratKeterangan({ search }: { search: string }) {
|
|||||||
color="blue"
|
color="blue"
|
||||||
leftSection={<IconDeviceImacCog size={16} />}
|
leftSection={<IconDeviceImacCog size={16} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
router.push(`/admin/desa/layanan/pelayanan_surat_keterangan/${item.id}`)
|
router.push(
|
||||||
|
`/admin/desa/layanan/pelayanan_surat_keterangan/${item.id}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Detail
|
Detail
|
||||||
@@ -142,8 +151,10 @@ function ListSuratKeterangan({ search }: { search: string }) {
|
|||||||
) : (
|
) : (
|
||||||
<TableTr>
|
<TableTr>
|
||||||
<TableTd colSpan={3}>
|
<TableTd colSpan={3}>
|
||||||
<Center py={20}>
|
<Center py="xl">
|
||||||
<Text color="dimmed">Tidak ada data surat keterangan yang cocok</Text>
|
<Text c="dimmed" fz="sm" ta="center">
|
||||||
|
Tidak ada data surat keterangan yang cocok
|
||||||
|
</Text>
|
||||||
</Center>
|
</Center>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
</TableTr>
|
</TableTr>
|
||||||
@@ -151,7 +162,67 @@ function ListSuratKeterangan({ search }: { search: string }) {
|
|||||||
</TableTbody>
|
</TableTbody>
|
||||||
</Table>
|
</Table>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Mobile Cards */}
|
||||||
|
<Box hiddenFrom="md">
|
||||||
|
<Stack gap="sm">
|
||||||
|
{filteredData.length > 0 ? (
|
||||||
|
filteredData.map((item) => (
|
||||||
|
<Paper key={item.id} withBorder p="sm" radius="md">
|
||||||
|
<Stack gap={'xs'}>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
Nama
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.4}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
Deskripsi
|
||||||
|
</Text>
|
||||||
|
<Box pl={8}>
|
||||||
|
<Text
|
||||||
|
fz="sm"
|
||||||
|
fw={500}
|
||||||
|
lh={1.4}
|
||||||
|
dangerouslySetInnerHTML={{ __html: item.deskripsi || '' }}
|
||||||
|
style={{ wordBreak: 'break-word' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
leftSection={<IconDeviceImacCog size={16} />}
|
||||||
|
onClick={() =>
|
||||||
|
router.push(
|
||||||
|
`/admin/desa/layanan/pelayanan_surat_keterangan/${item.id}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Detail
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Center py="xl">
|
||||||
|
<Text c="dimmed" fz="sm" ta="center">
|
||||||
|
Tidak ada data surat keterangan yang cocok
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Center>
|
<Center>
|
||||||
<Pagination
|
<Pagination
|
||||||
value={page}
|
value={page}
|
||||||
@@ -170,4 +241,4 @@ function ListSuratKeterangan({ search }: { search: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SuratKeterangan;
|
export default SuratKeterangan;
|
||||||
@@ -6,11 +6,11 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title
|
||||||
Tooltip
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconArrowBack } from '@tabler/icons-react';
|
import { IconArrowBack } from '@tabler/icons-react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
@@ -22,6 +22,7 @@ function EditPelayananTelunjukSakti() {
|
|||||||
const stateTelunjukDesa = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa);
|
const stateTelunjukDesa = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -29,6 +30,12 @@ function EditPelayananTelunjukSakti() {
|
|||||||
link: '',
|
link: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [originalData, setOriginalData] = useState({
|
||||||
|
name: '',
|
||||||
|
deskripsi: '',
|
||||||
|
link: '',
|
||||||
|
});
|
||||||
|
|
||||||
// Load data awal hanya sekali (pas ada id)
|
// Load data awal hanya sekali (pas ada id)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -43,6 +50,11 @@ function EditPelayananTelunjukSakti() {
|
|||||||
deskripsi: data.deskripsi ?? '',
|
deskripsi: data.deskripsi ?? '',
|
||||||
link: data.link ?? '',
|
link: data.link ?? '',
|
||||||
});
|
});
|
||||||
|
setOriginalData({
|
||||||
|
name: data.name ?? '',
|
||||||
|
deskripsi: data.deskripsi ?? '',
|
||||||
|
link: data.link ?? '',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading pelayanan telunjuk sakti:', 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
|
// Submit: update global state hanya saat simpan
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
stateTelunjukDesa.edit.form = {
|
stateTelunjukDesa.edit.form = {
|
||||||
...stateTelunjukDesa.edit.form,
|
...stateTelunjukDesa.edit.form,
|
||||||
...formData,
|
...formData,
|
||||||
@@ -74,18 +96,18 @@ function EditPelayananTelunjukSakti() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating pelayanan telunjuk sakti:', error);
|
console.error('Error updating pelayanan telunjuk sakti:', error);
|
||||||
toast.error('Terjadi kesalahan saat memperbarui pelayanan telunjuk sakti');
|
toast.error('Terjadi kesalahan saat memperbarui pelayanan telunjuk sakti');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
{/* Back Button + Title */}
|
{/* Back Button + Title */}
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Title order={4} ml="sm" c="dark">
|
<Title order={4} ml="sm" c="dark">
|
||||||
Edit Pelayanan Telunjuk Sakti Desa
|
Edit Pelayanan Telunjuk Sakti Desa
|
||||||
</Title>
|
</Title>
|
||||||
@@ -128,6 +150,17 @@ function EditPelayananTelunjukSakti() {
|
|||||||
|
|
||||||
{/* Tombol Simpan */}
|
{/* Tombol Simpan */}
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={handleResetForm}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -138,7 +171,7 @@ function EditPelayananTelunjukSakti() {
|
|||||||
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>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user