Compare commits

..

9 Commits

Author SHA1 Message Date
9622eb5a9a Fix QC Kak Inno Admin, Fix QC Keano UI User, Fix QC Pak jun tabel apbdes 2025-11-12 17:42:31 +08:00
417a8937f5 Semua tooltips di admin sudah dihilangkan 2025-11-07 14:38:32 +08:00
db8909b9ed Fix Text to Speech Menu Landing Page && Add barchart Landing Page APBDes 2025-11-06 11:35:04 +08:00
f66a46f645 QC ToolTip Admin Keano Masih di Menu Landing Page - Keamanan, QC Dari Darmasaba Pop Up Notifikasi 2025-11-05 14:32:38 +08:00
fb57698dc9 Add Menu Musik
Add News Reader for Difable
Add Running text news / announcement
2025-11-04 15:08:48 +08:00
d128313e71 Fix QC Keano FrontEnd
Fix QC Kak Ayu Admin 29 Okt
2025-11-03 17:36:00 +08:00
7b4bb1e58e QC Kak Inno FrontEnd Done
QC Kak Ayu FrontEnd Done
QC Keano 31 Okt
2025-11-03 10:28:03 +08:00
0befe6a3f2 QC Kak Inno 28 Okt
QC Kak Ayu 28 Okt
QC Keano 28 Okt
2025-10-30 15:51:12 +08:00
a6663bbcee QC Kak Inno 27 Oct
QC Kak Ayu 27 Oct
QC Keano 27 Oct
QC Pak Jun 27 Oct
2025-10-28 17:34:38 +08:00
627 changed files with 20588 additions and 11365 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -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",
@@ -55,8 +57,9 @@
"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 +83,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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: "",

View File

@@ -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: "",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -351,7 +351,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;

View File

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

View File

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

View File

@@ -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");
@@ -474,7 +474,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");

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,7 +42,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: "",

View File

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

View File

@@ -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: "",

View File

@@ -88,7 +88,7 @@ const statepermohonanInformasiPublik = proxy({
const res = await ApiFetch.api.ppid.permohonaninformasipublik["create"].post(statepermohonanInformasiPublik.create.form); const res = await ApiFetch.api.ppid.permohonaninformasipublik["create"].post(statepermohonanInformasiPublik.create.form);
if (res.status === 200) { if (res.status === 200) {
statepermohonanInformasiPublik.findMany.load(); statepermohonanInformasiPublik.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) {

View File

@@ -37,7 +37,7 @@ const permohonanKeberatanInformasi = proxy({
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["create"].post(permohonanKeberatanInformasi.create.form); const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["create"].post(permohonanKeberatanInformasi.create.form);
if (res.status === 200) { if (res.status === 200) {
permohonanKeberatanInformasi.findMany.load(); permohonanKeberatanInformasi.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) {

View File

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

View File

@@ -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 { 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"
} }
]; ];
@@ -91,14 +86,8 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
}} }}
> >
{tabs.map((tab, i) => ( {tabs.map((tab, i) => (
<Tooltip
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={{
@@ -109,7 +98,6 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
> >
{tab.label} {tab.label}
</TabsTab> </TabsTab>
</Tooltip>
))} ))}
</TabsList> </TabsList>
</ScrollArea> </ScrollArea>

View File

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

View File

@@ -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,6 +86,8 @@ 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);
} }
}; };
@@ -77,7 +95,6 @@ function EditKategoriBerita() {
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* 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>
@@ -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>

View File

@@ -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: 'sm', md: 'lg' }} py="md">
{/* 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>

View File

@@ -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';
@@ -86,7 +85,6 @@ function ListKategoriBerita({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={4}>Daftar Kategori Berita</Title> <Title order={4}>Daftar Kategori Berita</Title>
<Tooltip label="Tambah Kategori Berita" withArrow>
<Button <Button
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
@@ -97,7 +95,6 @@ function ListKategoriBerita({ search }: { search: string }) {
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Tooltip>
</Group> </Group>
<Box style={{ overflowX: 'auto' }}> <Box style={{ overflowX: 'auto' }}>
@@ -123,7 +120,6 @@ function ListKategoriBerita({ search }: { search: string }) {
</Text> </Text>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Tooltip label="Edit Kategori Berita" withArrow>
<Button <Button
variant="light" variant="light"
color="green" color="green"
@@ -135,10 +131,8 @@ function ListKategoriBerita({ search }: { search: string }) {
> >
<IconEdit size={18} /> <IconEdit size={18} />
</Button> </Button>
</Tooltip>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Tooltip label="Hapus Kategori Berita" withArrow>
<Button <Button
variant="light" variant="light"
color="red" color="red"
@@ -150,7 +144,6 @@ function ListKategoriBerita({ search }: { search: string }) {
> >
<IconTrash size={18} /> <IconTrash size={18} />
</Button> </Button>
</Tooltip>
</TableTd> </TableTd>
</TableTr> </TableTr>
)) ))

View File

@@ -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: "sm", md: "lg" }} py="md">
{/* 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>

View File

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

View File

@@ -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: 'sm', md: 'lg' }} py="md">
{/* 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>

View File

@@ -16,8 +16,7 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title, Title
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react'; import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
@@ -68,7 +67,6 @@ function ListBerita({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<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"
@@ -77,7 +75,6 @@ function ListBerita({ search }: { search: string }) {
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Tooltip>
</Group> </Group>
<Box style={{ overflowX: 'auto' }}> <Box style={{ overflowX: 'auto' }}>

View File

@@ -13,8 +13,7 @@ import {
Stack, Stack,
Text, Text,
TextInput, TextInput,
Title, Title
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks"; import { useShallowEffect } from "@mantine/hooks";
import { IconSearch, IconTrash, IconX } from "@tabler/icons-react"; import { IconSearch, IconTrash, IconX } from "@tabler/icons-react";
@@ -103,7 +102,6 @@ export default function ListImage() {
</Box> </Box>
<Group justify="space-between" align="center" pt="xs"> <Group justify="space-between" align="center" pt="xs">
<Tooltip label="Hapus foto" withArrow>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="red" color="red"
@@ -116,7 +114,6 @@ export default function ListImage() {
> >
<IconTrash size={18} /> <IconTrash size={18} />
</ActionIcon> </ActionIcon>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Card> </Card>

View File

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

View File

@@ -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,25 +76,42 @@ 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);
} }
}; };
@@ -89,16 +120,14 @@ function EditVideo() {
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<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>

View File

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

View File

@@ -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: 'sm', md: 'lg' }} py="md">
{/* 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>

View File

@@ -16,8 +16,7 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title, Title
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react'; import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
@@ -74,7 +73,6 @@ function ListVideo({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={4}>Daftar Video</Title> <Title order={4}>Daftar Video</Title>
<Tooltip label="Tambah Video Baru" withArrow>
<Button <Button
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
@@ -83,7 +81,6 @@ function ListVideo({ search }: { search: string }) {
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Tooltip>
</Group> </Group>
<Box style={{ overflowX: "auto" }}> <Box style={{ overflowX: "auto" }}>
<Table highlightOnHover> <Table highlightOnHover>

View File

@@ -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,6 +109,8 @@ 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);
} }
}; };
@@ -87,11 +118,9 @@ function EditAjukanPermohonan() {
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* 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>

View File

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

View File

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

View File

@@ -11,8 +11,7 @@ import {
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';
@@ -51,7 +50,6 @@ function PelayananPendudukNonPermanent() {
</Title> </Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Tooltip label="Edit Data Pelayanan" withArrow>
<Button <Button
c="green" c="green"
variant="light" variant="light"
@@ -65,7 +63,6 @@ function PelayananPendudukNonPermanent() {
> >
Edit Edit
</Button> </Button>
</Tooltip>
</GridCol> </GridCol>
</Grid> </Grid>

View File

@@ -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);
} }
}; };
@@ -100,16 +127,14 @@ function EditPelayananPerizinanBerusaha() {
<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>

View File

@@ -16,14 +16,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();
@@ -85,7 +84,6 @@ function PerizinanBerusaha() {
</Title> </Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Tooltip label="Edit Data Perizinan" withArrow>
<Button <Button
c="green" c="green"
variant="light" variant="light"
@@ -99,7 +97,6 @@ function PerizinanBerusaha() {
> >
Edit Edit
</Button> </Button>
</Tooltip>
</GridCol> </GridCol>
</Grid> </Grid>

View File

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

View File

@@ -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';
@@ -142,7 +141,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 +154,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 +168,6 @@ function DetailSuratKeterangan() {
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -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,6 +81,8 @@ 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);
} }
}; };
@@ -85,11 +90,9 @@ function CreateSuratKeterangan() {
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* 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>

View File

@@ -17,8 +17,7 @@ import {
TableThead, TableThead,
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';
@@ -82,7 +81,6 @@ function ListSuratKeterangan({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={4}>List Surat Keterangan</Title> <Title order={4}>List Surat Keterangan</Title>
<Tooltip label="Tambah Surat Keterangan" withArrow>
<Button <Button
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
@@ -93,7 +91,6 @@ function ListSuratKeterangan({ search }: { search: string }) {
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Tooltip>
</Group> </Group>
<Box style={{ overflowX: "auto" }}> <Box style={{ overflowX: "auto" }}>
<Table highlightOnHover> <Table highlightOnHover>

View File

@@ -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,6 +96,8 @@ 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);
} }
}; };
@@ -81,11 +105,9 @@ function EditPelayananTelunjukSakti() {
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* 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>

View File

@@ -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';
@@ -130,7 +129,6 @@ function DetailPelayananTelunjukSakti() {
</Box> </Box>
<Group gap="sm"> <Group gap="sm">
<Tooltip label="Hapus Layanan" withArrow position="top">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
@@ -144,9 +142,7 @@ function DetailPelayananTelunjukSakti() {
> >
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
</Tooltip>
<Tooltip label="Edit Layanan" withArrow position="top">
<Button <Button
color="green" color="green"
onClick={() => onClick={() =>
@@ -160,7 +156,6 @@ function DetailPelayananTelunjukSakti() {
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -6,20 +6,22 @@ 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 { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreatePelayananTelunjukDesa() { function CreatePelayananTelunjukDesa() {
const stateTelunjukDesa = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa); const stateTelunjukDesa = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa);
const router = useRouter(); const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => { const resetForm = () => {
stateTelunjukDesa.create.form = { stateTelunjukDesa.create.form = {
@@ -31,6 +33,7 @@ function CreatePelayananTelunjukDesa() {
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
await stateTelunjukDesa.create.create(); await stateTelunjukDesa.create.create();
resetForm(); resetForm();
toast.success('Data pelayanan telunjuk sakti berhasil ditambahkan'); toast.success('Data pelayanan telunjuk sakti berhasil ditambahkan');
@@ -38,6 +41,8 @@ function CreatePelayananTelunjukDesa() {
} catch (error) { } catch (error) {
console.error('Error create pelayanan telunjuk sakti:', error); console.error('Error create pelayanan telunjuk sakti:', error);
toast.error('Terjadi kesalahan saat menambahkan data'); toast.error('Terjadi kesalahan saat menambahkan data');
} finally {
setIsSubmitting(false);
} }
}; };
@@ -45,11 +50,9 @@ function CreatePelayananTelunjukDesa() {
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* 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 Pelayanan Telunjuk Sakti Desa Tambah Pelayanan Telunjuk Sakti Desa
</Title> </Title>
@@ -67,7 +70,7 @@ function CreatePelayananTelunjukDesa() {
<Stack gap="md"> <Stack gap="md">
{/* Nama */} {/* Nama */}
<TextInput <TextInput
defaultValue={stateTelunjukDesa.create.form.name} value={stateTelunjukDesa.create.form.name}
onChange={(val) => { onChange={(val) => {
stateTelunjukDesa.create.form.name = val.target.value; stateTelunjukDesa.create.form.name = val.target.value;
}} }}
@@ -78,7 +81,7 @@ function CreatePelayananTelunjukDesa() {
{/* Deskripsi */} {/* Deskripsi */}
<TextInput <TextInput
defaultValue={stateTelunjukDesa.create.form.deskripsi} value={stateTelunjukDesa.create.form.deskripsi}
onChange={(val) => { onChange={(val) => {
stateTelunjukDesa.create.form.deskripsi = val.target.value; stateTelunjukDesa.create.form.deskripsi = val.target.value;
}} }}
@@ -89,7 +92,7 @@ function CreatePelayananTelunjukDesa() {
{/* Link */} {/* Link */}
<TextInput <TextInput
defaultValue={stateTelunjukDesa.create.form.link} value={stateTelunjukDesa.create.form.link}
onChange={(val) => { onChange={(val) => {
stateTelunjukDesa.create.form.link = val.target.value; stateTelunjukDesa.create.form.link = val.target.value;
}} }}
@@ -100,6 +103,17 @@ function CreatePelayananTelunjukDesa() {
{/* 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"
@@ -110,7 +124,7 @@ function CreatePelayananTelunjukDesa() {
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>

View File

@@ -1,159 +1,3 @@
// /* eslint-disable react-hooks/exhaustive-deps */
// 'use client'
// import colors from '@/con/colors';
// import { Box, Button, Center, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core';
// import { IconDeviceImac, IconSearch } from '@tabler/icons-react';
// import { useRouter } from 'next/navigation';
// import { useEffect, useMemo, useState } from 'react';
// import { useProxy } from 'valtio/utils';
// import HeaderSearch from '../../../_com/header';
// import JudulList from '../../../_com/judulList';
// import stateLayananDesa from '../../../_state/desa/layananDesa';
// function PelayananTelunjukSakti() {
// const [search, setSearch] = useState("");
// return (
// <Box>
// <HeaderSearch
// title='Posisi Organisasi'
// placeholder='pencarian'
// searchIcon={<IconSearch size={20} />}
// value={search}
// onChange={(e) => setSearch(e.currentTarget.value)}
// />
// <ListPelayananTelunjukSakti search={search} />
// </Box>
// );
// }
// function ListPelayananTelunjukSakti({ search }: { search: string }) {
// const telunjukSaktiState = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa)
// const router = useRouter()
// const {
// data,
// page,
// totalPages,
// loading,
// load,
// } = telunjukSaktiState.findMany;
// useEffect(() => {
// load(page, 10)
// }, [])
// const filteredData = useMemo(() => {
// if (!data) return [];
// return data.filter(item => {
// const keyword = search.toLowerCase();
// return (
// item.name?.toLowerCase().includes(keyword) ||
// item.link?.toLowerCase().includes(keyword) ||
// item.deskripsi?.toLowerCase().includes(keyword)
// );
// })
// .sort((a, b) => a.posisi?.hierarki - b.posisi?.hierarki);
// }, [data, search]);
// if (loading || !data) {
// return (
// <Stack py={10}>
// <Skeleton height={300} />
// </Stack>
// );
// }
// if (data.length === 0) {
// return (
// <Box py={10}>
// <Paper bg={colors['white-1']} p={'md'}>
// <JudulList
// title='List Pelayanan Telunjuk Sakti Desa'
// href='/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/create'
// />
// <Table striped withTableBorder withRowBorders>
// <TableThead>
// <TableTr>
// <TableTh>Nama</TableTh>
// <TableTh>Link</TableTh>
// <TableTh>Detail</TableTh>
// </TableTr>
// </TableThead>
// <TableTbody>
// <TableTr>
// <TableTd colSpan={3}>
// <Text fz={"sm"} color="gray.5">
// Tidak ada data
// </Text>
// </TableTd>
// </TableTr>
// </TableTbody>
// </Table>
// </Paper>
// </Box>
// );
// }
// return (
// <Box py={10}>
// <Paper bg={colors['white-1']} p={'md'}>
// <JudulList
// title='List Pelayanan Telunjuk Sakti Desa'
// href='/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/create'
// />
// <Table striped withTableBorder withRowBorders>
// <TableThead>
// <TableTr>
// <TableTh>Nama</TableTh>
// <TableTh>Link</TableTh>
// <TableTh>Detail</TableTh>
// </TableTr>
// </TableThead>
// <TableTbody>
// {filteredData.map((item) => (
// <TableTr key={item.id}>
// <TableTd>
// <Box w={100}>
// <Text truncate="end" lineClamp={1} fz={"sm"} dangerouslySetInnerHTML={{ __html: item.name }} />
// </Box>
// </TableTd>
// <TableTd>
// <Box w={100}>
// <a href={item.link} target="_blank" rel="noopener noreferrer">
// <Text dangerouslySetInnerHTML={{ __html: item.deskripsi }} truncate="end" fz={"sm"} />
// </a>
// </Box>
// </TableTd>
// <TableTd>
// <Text>
// <Button onClick={() => router.push(`/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/${item.id}`)}>
// <IconDeviceImac size={20} />
// </Button>
// </Text>
// </TableTd>
// </TableTr>
// ))}
// </TableTbody>
// </Table>
// </Paper>
// <Center>
// <Pagination
// value={page}
// onChange={(newPage) => {
// load(newPage, 10);
// window.scrollTo(0, 0);
// }}
// total={totalPages}
// mt="md"
// mb="md"
// />
// </Center>
// </Box>
// );
// }
// export default PelayananTelunjukSakti;
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
@@ -174,8 +18,7 @@ import {
TableThead, TableThead,
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';
@@ -225,7 +68,6 @@ function ListPelayananTelunjukSakti({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={4}>Daftar Pelayanan Telunjuk Sakti</Title> <Title order={4}>Daftar Pelayanan Telunjuk Sakti</Title>
<Tooltip label="Tambah Layanan" withArrow>
<Button <Button
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
@@ -236,7 +78,6 @@ function ListPelayananTelunjukSakti({ search }: { search: string }) {
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Tooltip>
</Group> </Group>
<Box style={{ overflowX: 'auto' }}> <Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover style={{ minWidth: '700px' }}> <Table highlightOnHover style={{ minWidth: '700px' }}>

View File

@@ -5,16 +5,17 @@ import penghargaanState from '@/app/admin/(dashboard)/_state/desa/penghargaan';
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';
@@ -30,6 +31,15 @@ function EditPenghargaan() {
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
juara: "",
deskripsi: "",
imageId: "",
imageUrl: "",
});
// Lokal formData // Lokal formData
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -47,17 +57,25 @@ function EditPenghargaan() {
try { try {
const data = await statePenghargaan.edit.load(id); const data = await statePenghargaan.edit.load(id);
if (data) { if (data) {
setFormData({ const newForm = {
name: data.name || '', name: data.name || "",
juara: data.juara || '', juara: data.juara || "",
deskripsi: data.deskripsi || '', deskripsi: data.deskripsi || "",
imageId: data.imageId || '', imageId: data.imageId || "",
};
setFormData(newForm);
// simpan juga versi original
const imageUrl = data.image?.link || "";
setOriginalData({
...newForm,
imageUrl: imageUrl,
}); });
if (data?.image?.link) { setPreviewImage(imageUrl || null);
setPreviewImage(data.image.link);
}
} }
} catch (error) { } catch (error) {
console.error('Error loading penghargaan:', error); console.error('Error loading penghargaan:', error);
@@ -68,33 +86,49 @@ function EditPenghargaan() {
loadPenghargaan(); loadPenghargaan();
}, [params?.id]); }, [params?.id]);
const handleResetForm = () => {
setFormData({
name: originalData.name,
juara: originalData.juara,
deskripsi: originalData.deskripsi,
imageId: originalData.imageId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
// Submit // Submit
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
// Sync ke global state saat submit // Sync ke global state saat submit
statePenghargaan.edit.form = { let imageId = formData.imageId;
...statePenghargaan.edit.form,
...formData,
};
// Upload file baru (kalau ada)
if (file) { if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name }); const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) { if (!uploaded?.id) {
return toast.error('Gagal upload gambar'); return toast.error("Gagal upload gambar");
} }
imageId = uploaded.id;
statePenghargaan.edit.form.imageId = uploaded.id;
} }
// Update global state form (baru di sini)
statePenghargaan.edit.form = {
...statePenghargaan.edit.form,
name: formData.name,
juara: formData.juara,
deskripsi: formData.deskripsi,
imageId,
}
await statePenghargaan.edit.update(); await statePenghargaan.edit.update();
toast.success('Penghargaan berhasil diperbarui!'); toast.success('Penghargaan berhasil diperbarui!');
router.push('/admin/desa/penghargaan'); router.push('/admin/desa/penghargaan');
} catch (error) { } catch (error) {
console.error('Error updating penghargaan:', error); console.error('Error updating penghargaan:', error);
toast.error('Terjadi kesalahan saat memperbarui penghargaan'); toast.error('Terjadi kesalahan saat memperbarui penghargaan');
} finally {
setIsSubmitting(false);
} }
}; };
@@ -102,11 +136,9 @@ function EditPenghargaan() {
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Tombol Back + Title */} {/* Tombol Back + 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 Penghargaan Edit Penghargaan
</Title> </Title>
@@ -155,7 +187,7 @@ function EditPenghargaan() {
}} }}
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"
> >
@@ -174,25 +206,47 @@ function EditPenghargaan() {
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 pos="relative" mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Image <Box>
src={previewImage} <Image
alt="Preview Gambar" src={previewImage.startsWith('http') ? previewImage : `${window.location.origin}${previewImage}`}
radius="md" alt="Preview Gambar"
style={{ radius="md"
maxHeight: 220, style={{
objectFit: 'contain', maxHeight: 200,
border: `1px solid ${colors['blue-button']}`, objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
</Box>
{/* 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);
}} }}
loading="lazy" style={{
/> boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box> </Box>
)} )}
</Box> </Box>
@@ -212,6 +266,17 @@ function EditPenghargaan() {
{/* 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"
@@ -222,7 +287,7 @@ function EditPenghargaan() {
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>

View File

@@ -1,9 +1,5 @@
'use client' 'use client'
import React, { useState } from 'react'; import colors from '@/con/colors';
import penghargaanState from '../../../_state/desa/penghargaan';
import { useProxy } from 'valtio/utils';
import { useParams, useRouter } from 'next/navigation';
import { useShallowEffect } from '@mantine/hooks';
import { import {
Box, Box,
Button, Button,
@@ -12,12 +8,15 @@ import {
Paper, Paper,
Skeleton, Skeleton,
Stack, Stack,
Text, Text
Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import colors from '@/con/colors'; 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 { useState } from 'react';
import { useProxy } from 'valtio/utils';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import penghargaanState from '../../../_state/desa/penghargaan';
function DetailPenghargaan() { function DetailPenghargaan() {
const statePenghargaan = useProxy(penghargaanState); const statePenghargaan = useProxy(penghargaanState);
@@ -127,34 +126,30 @@ function DetailPenghargaan() {
</Box> </Box>
<Group gap="sm" mt={10}> <Group gap="sm" mt={10}>
<Tooltip label="Hapus Penghargaan" withArrow position="top"> <Button
<Button color="red"
color="red" onClick={() => {
onClick={() => { setSelectedId(data.id);
setSelectedId(data.id); setModalHapus(true);
setModalHapus(true); }}
}} variant="light"
variant="light" radius="md"
radius="md" size="md"
size="md" >
> <IconTrash size={20} />
<IconTrash size={20} /> </Button>
</Button>
</Tooltip>
<Tooltip label="Edit Penghargaan" withArrow position="top"> <Button
<Button color="green"
color="green" onClick={() =>
onClick={() => router.push(`/admin/desa/penghargaan/${data.id}/edit`)
router.push(`/admin/desa/penghargaan/${data.id}/edit`) }
} variant="light"
variant="light" radius="md"
radius="md" size="md"
size="md" >
> <IconEdit size={20} />
<IconEdit size={20} /> </Button>
</Button>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -2,16 +2,17 @@
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';
@@ -27,6 +28,7 @@ function CreatePenghargaan() {
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);
const resetForm = () => { const resetForm = () => {
statePenghargaan.create.form = { statePenghargaan.create.form = {
@@ -40,37 +42,43 @@ function CreatePenghargaan() {
}; };
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');
}
statePenghargaan.create.form.imageId = uploaded.id;
await statePenghargaan.create.create();
resetForm();
router.push('/admin/desa/penghargaan');
} catch (error) {
console.error('Error creating penghargaan:', error);
toast.error('Terjadi kesalahan saat menambahkan penghargaan');
} 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');
}
statePenghargaan.create.form.imageId = uploaded.id;
await statePenghargaan.create.create();
resetForm();
router.push('/admin/desa/penghargaan');
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* 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 Penghargaan Tambah Penghargaan
</Title> </Title>
@@ -87,7 +95,7 @@ function CreatePenghargaan() {
> >
<Stack gap="md"> <Stack gap="md">
<TextInput <TextInput
defaultValue={statePenghargaan.create.form.name} value={statePenghargaan.create.form.name}
onChange={(val) => (statePenghargaan.create.form.name = val.target.value)} onChange={(val) => (statePenghargaan.create.form.name = val.target.value)}
label="Nama Penghargaan" label="Nama Penghargaan"
placeholder="Masukkan nama penghargaan" placeholder="Masukkan nama penghargaan"
@@ -95,7 +103,7 @@ function CreatePenghargaan() {
/> />
<TextInput <TextInput
defaultValue={statePenghargaan.create.form.juara} value={statePenghargaan.create.form.juara}
onChange={(val) => (statePenghargaan.create.form.juara = val.target.value)} onChange={(val) => (statePenghargaan.create.form.juara = val.target.value)}
label="Juara" label="Juara"
placeholder="Masukkan juara" placeholder="Masukkan juara"
@@ -125,7 +133,7 @@ function CreatePenghargaan() {
}} }}
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"
> >
@@ -146,7 +154,7 @@ function CreatePenghargaan() {
</Dropzone> </Dropzone>
{previewImage && ( {previewImage && (
<Box mt="sm" style={{ textAlign: 'center' }}> <Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}>
<Image <Image
src={previewImage} src={previewImage}
alt="Preview Gambar" alt="Preview Gambar"
@@ -154,12 +162,41 @@ function CreatePenghargaan() {
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);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box> </Box>
)} )}
</Box> </Box>
{/* 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"
@@ -170,7 +207,7 @@ function CreatePenghargaan() {
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>

View File

@@ -18,8 +18,7 @@ import {
TableThead, TableThead,
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';
@@ -69,7 +68,6 @@ function ListPenghargaan({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={4}>List Penghargaan</Title> <Title order={4}>List Penghargaan</Title>
<Tooltip label="Tambah Penghargaan" withArrow>
<Button <Button
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
@@ -78,7 +76,6 @@ function ListPenghargaan({ search }: { search: string }) {
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Tooltip>
</Group> </Group>
<Box style={{ overflowX: 'auto' }}> <Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover> <Table highlightOnHover>

View File

@@ -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, IconListDetails } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { IconListDetails, IconCategory } from '@tabler/icons-react';
function LayoutTabsLayanan({ children }: { children: React.ReactNode }) { function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
const router = useRouter() const router = useRouter()
@@ -14,15 +14,13 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
label: "List Pengumuman", label: "List Pengumuman",
value: "listpengumuman", value: "listpengumuman",
href: "/admin/desa/pengumuman/list-pengumuman", href: "/admin/desa/pengumuman/list-pengumuman",
icon: <IconListDetails size={18} stroke={1.8} />, icon: <IconListDetails size={18} stroke={1.8} />
tooltip: "Lihat semua daftar pengumuman"
}, },
{ {
label: "Kategori Pengumuman", label: "Kategori Pengumuman",
value: "kategoripengumuman", value: "kategoripengumuman",
href: "/admin/desa/pengumuman/kategori-pengumuman", href: "/admin/desa/pengumuman/kategori-pengumuman",
icon: <IconCategory size={18} stroke={1.8} />, icon: <IconCategory size={18} stroke={1.8} />
tooltip: "Kelola kategori pengumuman"
}, },
]; ];
@@ -70,19 +68,18 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
}} }}
> >
{tabs.map((tab, i) => ( {tabs.map((tab, i) => (
<Tooltip 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",
}} }}
> >
{tab.label} {tab.label}
</TabsTab> </TabsTab>
</Tooltip>
))} ))}
</TabsList> </TabsList>
</ScrollArea> </ScrollArea>

View File

@@ -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,8 +23,9 @@ function EditKategoriPengumuman() {
const editState = useProxy(stateDesaPengumuman.category); const editState = useProxy(stateDesaPengumuman.category);
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({ name: '' }); const [formData, setFormData] = useState({ name: '' });
const [originalData, setOriginalData] = useState({ name: '' });
// Load data awal sekali aja // Load data awal sekali aja
useEffect(() => { useEffect(() => {
@@ -36,6 +37,7 @@ function EditKategoriPengumuman() {
const data = await editState.update.load(id); const data = await editState.update.load(id);
if (data) { if (data) {
setFormData({ name: data.name || '' }); setFormData({ name: data.name || '' });
setOriginalData({ name: data.name || '' });
} }
} catch (error) { } catch (error) {
console.error('Error loading kategori Pengumuman:', error); console.error('Error loading kategori Pengumuman:', error);
@@ -55,6 +57,7 @@ function EditKategoriPengumuman() {
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
// Update global state hanya di sini // Update global state hanya di sini
editState.update.form = { editState.update.form = {
...editState.update.form, ...editState.update.form,
@@ -67,14 +70,23 @@ function EditKategoriPengumuman() {
} catch (error) { } catch (error) {
console.error('Error updating kategori Pengumuman:', error); console.error('Error updating kategori Pengumuman:', error);
toast.error('Terjadi kesalahan saat memperbarui kategori Pengumuman'); toast.error('Terjadi kesalahan saat memperbarui kategori Pengumuman');
} finally {
setIsSubmitting(false);
} }
}; };
const handleResetForm = () => {
setFormData({
name: originalData.name,
});
toast.info("Form dikembalikan ke data awal");
};
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* 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()}
@@ -83,7 +95,6 @@ function EditKategoriPengumuman() {
> >
<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 Pengumuman Edit Kategori Pengumuman
</Title> </Title>
@@ -108,6 +119,17 @@ function EditKategoriPengumuman() {
/> />
<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"
@@ -118,7 +140,7 @@ function EditKategoriPengumuman() {
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>

View File

@@ -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 { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { useState } from 'react';
import { toast } from 'react-toastify';
function CreateKategoriPengumuman() { function CreateKategoriPengumuman() {
const createState = useProxy(stateDesaPengumuman.category); const createState = useProxy(stateDesaPengumuman.category);
const router = useRouter(); const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => { const resetForm = () => {
createState.create.form = { createState.create.form = {
@@ -26,16 +29,22 @@ function CreateKategoriPengumuman() {
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
await createState.create.create(); try {
resetForm(); await createState.create.create();
router.push('/admin/desa/pengumuman/kategori-pengumuman'); resetForm();
router.push('/admin/desa/pengumuman/kategori-pengumuman');
} catch (error) {
console.error(error);
toast.error('Gagal menambahkan kategori pengumuman');
} finally {
setIsSubmitting(false);
}
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* 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 +53,6 @@ function CreateKategoriPengumuman() {
> >
<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 Pengumuman Tambah Kategori Pengumuman
</Title> </Title>
@@ -63,12 +71,23 @@ function CreateKategoriPengumuman() {
<TextInput <TextInput
label="Nama Kategori Pengumuman" label="Nama Kategori Pengumuman"
placeholder="Masukkan nama kategori pengumuman" placeholder="Masukkan nama kategori pengumuman"
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 +98,7 @@ function CreateKategoriPengumuman() {
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>

View File

@@ -2,11 +2,13 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
Box, Button, Center, Paper, Skeleton, Stack, Box, Button, Center,
Pagination,
Paper, Skeleton, Stack,
Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Table, TableTbody, TableTd, TableTh, TableThead, TableTr,
Text, Title, Tooltip, Pagination Text, Title
} from '@mantine/core'; } from '@mantine/core';
import { IconEdit, IconSearch, IconTrash, IconPlus } from '@tabler/icons-react'; import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
@@ -66,16 +68,14 @@ function ListKategoriPengumuman({ search }: { search: string }) {
<Stack> <Stack>
<Box style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 15 }}> <Box style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 15 }}>
<Title order={4}>List Kategori Pengumuman</Title> <Title order={4}>List Kategori Pengumuman</Title>
<Tooltip label="Tambah Kategori Pengumuman" withArrow> <Button
<Button leftSection={<IconPlus size={18} />}
leftSection={<IconPlus size={18} />} color="blue"
color="blue" variant="light"
variant="light" onClick={() => router.push('/admin/desa/pengumuman/kategori-pengumuman/create')}
onClick={() => router.push('/admin/desa/pengumuman/kategori-pengumuman/create')} >
> Tambah Baru
Tambah Baru </Button>
</Button>
</Tooltip>
</Box> </Box>
<Box style={{ overflowX: 'auto' }}> <Box style={{ overflowX: 'auto' }}>
@@ -99,29 +99,25 @@ function ListKategoriPengumuman({ search }: { search: string }) {
<Text truncate lineClamp={1}>{item.name}</Text> <Text truncate lineClamp={1}>{item.name}</Text>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Tooltip label="Edit Kategori Pengumuman" withArrow> <Button
<Button variant='light'
variant='light' color='green'
color='green' onClick={() => router.push(`/admin/desa/pengumuman/kategori-pengumuman/${item.id}`)}
onClick={() => router.push(`/admin/desa/pengumuman/kategori-pengumuman/${item.id}`)} >
> <IconEdit size={20} />
<IconEdit size={20} /> </Button>
</Button>
</Tooltip>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Tooltip label="Hapus Kategori Pengumuman" 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) }}>
}}> <IconTrash size={20} />
<IconTrash size={20} /> </Button>
</Button>
</Tooltip>
</TableTd> </TableTd>
</TableTr> </TableTr>
)) ))

View File

@@ -14,7 +14,7 @@ import {
Text, Text,
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";
@@ -34,6 +34,15 @@ function EditPengumuman() {
content: "", content: "",
}); });
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
judul: "",
deskripsi: "",
categoryPengumumanId: "",
content: "",
});
// Load kategori & pengumuman by id saat pertama kali // Load kategori & pengumuman by id saat pertama kali
useEffect(() => { useEffect(() => {
editState.category.findMany.load(); editState.category.findMany.load();
@@ -51,6 +60,12 @@ function EditPengumuman() {
categoryPengumumanId: data.categoryPengumumanId || "", categoryPengumumanId: data.categoryPengumumanId || "",
content: data.content || "", content: data.content || "",
}); });
setOriginalData({
judul: data.judul || "",
deskripsi: data.deskripsi || "",
categoryPengumumanId: data.categoryPengumumanId || "",
content: data.content || "",
});
} }
} catch (error) { } catch (error) {
console.error("Error loading pengumuman:", error); console.error("Error loading pengumuman:", error);
@@ -67,6 +82,7 @@ function EditPengumuman() {
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
// update global state hanya sekali pas submit // update global state hanya sekali pas submit
editState.pengumuman.edit.form = { editState.pengumuman.edit.form = {
...editState.pengumuman.edit.form, ...editState.pengumuman.edit.form,
@@ -79,22 +95,32 @@ function EditPengumuman() {
} catch (error) { } catch (error) {
console.error("Error updating pengumuman:", error); console.error("Error updating pengumuman:", error);
toast.error("Terjadi kesalahan saat memperbarui pengumuman"); toast.error("Terjadi kesalahan saat memperbarui pengumuman");
} finally {
setIsSubmitting(false);
} }
}; };
const handleResetForm = () => {
setFormData({
judul: originalData.judul,
deskripsi: originalData.deskripsi,
categoryPengumumanId: originalData.categoryPengumumanId,
content: originalData.content,
});
toast.info("Form dikembalikan ke data awal");
};
return ( return (
<Box px={{ base: "sm", md: "lg" }} py="md"> <Box px={{ base: "sm", md: "lg" }} py="md">
<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 Pengumuman Edit Pengumuman
</Title> </Title>
@@ -155,17 +181,29 @@ function EditPengumuman() {
</Box> </Box>
<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>

View File

@@ -1,5 +1,7 @@
'use client' 'use client'
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import stateDesaPengumuman from '@/app/admin/(dashboard)/_state/desa/pengumuman';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
Box, Box,
@@ -8,16 +10,13 @@ import {
Paper, Paper,
Skeleton, Skeleton,
Stack, Stack,
Text, Text
Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useRouter, useParams } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { useShallowEffect } from '@mantine/hooks';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import stateDesaPengumuman from '@/app/admin/(dashboard)/_state/desa/pengumuman';
export default function DetailPengumuman() { export default function DetailPengumuman() {
const pengumumanState = useProxy(stateDesaPengumuman); const pengumumanState = useProxy(stateDesaPengumuman);
@@ -117,7 +116,6 @@ export default function DetailPengumuman() {
</Box> </Box>
<Group gap="sm"> <Group gap="sm">
<Tooltip label="Hapus Pengumuman" withArrow position="top">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
@@ -130,9 +128,7 @@ export default function DetailPengumuman() {
> >
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
</Tooltip>
<Tooltip label="Edit Pengumuman" withArrow position="top">
<Button <Button
color="green" color="green"
onClick={() => onClick={() =>
@@ -146,7 +142,6 @@ export default function DetailPengumuman() {
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -13,25 +13,36 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
Tooltip, Loader
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreatePengumuman() { function CreatePengumuman() {
const pengumumanState = useProxy(stateDesaPengumuman); const pengumumanState = useProxy(stateDesaPengumuman);
const router = useRouter(); const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
useShallowEffect(() => { useShallowEffect(() => {
pengumumanState.category.findMany.load(); pengumumanState.category.findMany.load();
}, []); }, []);
const handleSubmit = async () => { const handleSubmit = async () => {
await pengumumanState.pengumuman.create.create(); try {
resetForm(); setIsSubmitting(true);
router.push('/admin/desa/pengumuman/list-pengumuman'); await pengumumanState.pengumuman.create.create();
resetForm();
router.push('/admin/desa/pengumuman/list-pengumuman');
} catch (error) {
console.error('Error creating pengumuman:', error);
toast.error('Terjadi kesalahan saat membuat pengumuman');
} finally {
setIsSubmitting(false);
}
}; };
const resetForm = () => { const resetForm = () => {
@@ -47,11 +58,9 @@ function CreatePengumuman() {
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* 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 Pengumuman Tambah Pengumuman
</Title> </Title>
@@ -68,7 +77,7 @@ function CreatePengumuman() {
<Stack gap="md"> <Stack gap="md">
{/* Judul */} {/* Judul */}
<TextInput <TextInput
defaultValue={pengumumanState.pengumuman.create.form.judul} value={pengumumanState.pengumuman.create.form.judul}
onChange={(val) => (pengumumanState.pengumuman.create.form.judul = val.target.value)} onChange={(val) => (pengumumanState.pengumuman.create.form.judul = val.target.value)}
label="Judul" label="Judul"
placeholder="Masukkan judul pengumuman" placeholder="Masukkan judul pengumuman"
@@ -79,21 +88,32 @@ function CreatePengumuman() {
<Select <Select
label="Kategori" label="Kategori"
placeholder="Pilih kategori" placeholder="Pilih kategori"
value={pengumumanState.pengumuman.create.form.categoryPengumumanId || ""}
onChange={(val) => {
pengumumanState.pengumuman.create.form.categoryPengumumanId = val ?? "";
}}
data={pengumumanState.category.findMany.data?.map((item) => ({ data={pengumumanState.category.findMany.data?.map((item) => ({
label: item.name, label: item.name,
value: item.id, value: item.id,
}))} })) || []}
value={pengumumanState.pengumuman.create.form.categoryPengumumanId || null}
onChange={(val: string | null) => {
if (val) {
const selected = pengumumanState.category.findMany.data?.find(
(item) => item.id === val
);
if (selected) {
pengumumanState.pengumuman.create.form.categoryPengumumanId = selected.id;
}
} else {
pengumumanState.pengumuman.create.form.categoryPengumumanId = '';
}
}}
searchable searchable
clearable
nothingFoundMessage="Tidak ditemukan" nothingFoundMessage="Tidak ditemukan"
required
/> />
{/* Deskripsi Singkat */} {/* Deskripsi Singkat */}
<TextInput <TextInput
defaultValue={pengumumanState.pengumuman.create.form.deskripsi} value={pengumumanState.pengumuman.create.form.deskripsi}
onChange={(val) => (pengumumanState.pengumuman.create.form.deskripsi = val.target.value)} onChange={(val) => (pengumumanState.pengumuman.create.form.deskripsi = val.target.value)}
label="Deskripsi Singkat" label="Deskripsi Singkat"
placeholder="Masukkan deskripsi singkat" placeholder="Masukkan deskripsi singkat"
@@ -115,6 +135,17 @@ function CreatePengumuman() {
{/* Tombol Submit */} {/* Tombol 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"
@@ -125,7 +156,7 @@ function CreatePengumuman() {
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>

View File

@@ -17,8 +17,7 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title, Title
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react'; import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
@@ -69,16 +68,14 @@ function ListPengumuman({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={4}>Daftar Pengumuman</Title> <Title order={4}>Daftar Pengumuman</Title>
<Tooltip label="Tambah Pengumuman" withArrow> <Button
<Button leftSection={<IconCircleDashedPlus size={18} />}
leftSection={<IconCircleDashedPlus size={18} />} color="blue"
color="blue" variant="light"
variant="light" onClick={() => router.push('/admin/desa/pengumuman/list-pengumuman/create')}
onClick={() => router.push('/admin/desa/pengumuman/list-pengumuman/create')} >
> Tambah Baru
Tambah Baru </Button>
</Button>
</Tooltip>
</Group> </Group>
<Box style={{ overflowX: 'auto' }}> <Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover style={{ minWidth: '700px' }}> <Table highlightOnHover style={{ minWidth: '700px' }}>

View File

@@ -1,7 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core'; import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconCategory, IconListCheck } from '@tabler/icons-react'; import { IconCategory, IconListCheck } 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';
@@ -14,15 +14,13 @@ function LayoutTabsPotensi({ children }: { children: React.ReactNode }) {
label: "List Potensi", label: "List Potensi",
value: "list_potensi", value: "list_potensi",
href: "/admin/desa/potensi/list-potensi", href: "/admin/desa/potensi/list-potensi",
icon: <IconListCheck size={18} stroke={1.8} />, icon: <IconListCheck size={18} stroke={1.8} />
tooltip: "Lihat semua potensi desa"
}, },
{ {
label: "Kategori Potensi", label: "Kategori Potensi",
value: "kategori_potensi", value: "kategori_potensi",
href: "/admin/desa/potensi/kategori-potensi", href: "/admin/desa/potensi/kategori-potensi",
icon: <IconCategory size={18} stroke={1.8} />, icon: <IconCategory size={18} stroke={1.8} />
tooltip: "Kelola kategori potensi"
}, },
]; ];
@@ -70,19 +68,18 @@ function LayoutTabsPotensi({ children }: { children: React.ReactNode }) {
}} }}
> >
{tabs.map((tab, i) => ( {tabs.map((tab, i) => (
<Tooltip 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",
}} }}
> >
{tab.label} {tab.label}
</TabsTab> </TabsTab>
</Tooltip>
))} ))}
</TabsList> </TabsList>
</ScrollArea> </ScrollArea>

View File

@@ -10,7 +10,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';
@@ -27,6 +27,12 @@ function EditKategoriPotensi() {
nama: '', nama: '',
}); });
const [originalData, setOriginalData] = useState({
nama: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
// Load data dari backend -> isi ke formData lokal // Load data dari backend -> isi ke formData lokal
useEffect(() => { useEffect(() => {
const loadKategori = async () => { const loadKategori = async () => {
@@ -39,6 +45,9 @@ function EditKategoriPotensi() {
setFormData({ setFormData({
nama: data.nama || '', nama: data.nama || '',
}); });
setOriginalData({
nama: data.nama || '',
});
} }
} catch (error) { } catch (error) {
console.error('Error loading kategori potensi:', error); console.error('Error loading kategori potensi:', error);
@@ -56,8 +65,16 @@ function EditKategoriPotensi() {
})); }));
}; };
const handleResetForm = () => {
setFormData({
nama: originalData.nama,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
// Update global state hanya pas submit // Update global state hanya pas submit
editState.update.form = { editState.update.form = {
...editState.update.form, ...editState.update.form,
@@ -70,13 +87,14 @@ function EditKategoriPotensi() {
} catch (error) { } catch (error) {
console.error('Error updating kategori potensi:', error); console.error('Error updating kategori potensi:', error);
toast.error('Terjadi kesalahan saat memperbarui kategori potensi'); toast.error('Terjadi kesalahan saat memperbarui kategori potensi');
} finally {
setIsSubmitting(false);
} }
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<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()}
@@ -85,7 +103,6 @@ function EditKategoriPotensi() {
> >
<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 Potensi Edit Kategori Potensi
</Title> </Title>
@@ -109,6 +126,17 @@ function EditKategoriPotensi() {
/> />
<Group justify="flex-end"> <Group justify="flex-end">
<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"
@@ -119,7 +147,7 @@ function EditKategoriPotensi() {
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>

View File

@@ -9,15 +9,17 @@ 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 { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreateKategoriPotensi() { function CreateKategoriPotensi() {
const createState = useProxy(potensiDesaState.kategoriPotensi); const createState = useProxy(potensiDesaState.kategoriPotensi);
const router = useRouter(); const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => { const resetForm = () => {
createState.create.form = { createState.create.form = {
@@ -26,25 +28,30 @@ function CreateKategoriPotensi() {
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
await createState.create.create(); try {
resetForm(); setIsSubmitting(true);
router.push('/admin/desa/potensi/kategori-potensi'); await createState.create.create();
resetForm();
router.push('/admin/desa/potensi/kategori-potensi');
} catch (error) {
console.error('Error creating kategori potensi:', error);
} finally {
setIsSubmitting(false);
}
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* 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()} 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 Kategori Potensi Tambah Kategori Potensi
</Title> </Title>
@@ -63,12 +70,23 @@ function CreateKategoriPotensi() {
<TextInput <TextInput
label="Nama Kategori Potensi" label="Nama Kategori Potensi"
placeholder="Masukkan nama kategori potensi" placeholder="Masukkan nama kategori potensi"
defaultValue={createState.create.form.nama || ''} value={createState.create.form.nama || ''}
onChange={(e) => (createState.create.form.nama = e.target.value)} onChange={(e) => (createState.create.form.nama = 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 +97,7 @@ function CreateKategoriPotensi() {
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>

View File

@@ -1,14 +1,14 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title, Tooltip, Pagination, Group } from '@mantine/core'; import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core';
import { IconEdit, IconSearch, IconTrash, IconPlus } from '@tabler/icons-react'; import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import potensiDesaState from '../../../_state/desa/potensi';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import potensiDesaState from '../../../_state/desa/potensi';
function KategoriPotensi() { function KategoriPotensi() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -62,7 +62,6 @@ function ListKategoriPotensi({ search }: { search: string }) {
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Title order={4}>List Kategori Potensi</Title> <Title order={4}>List Kategori Potensi</Title>
<Tooltip label="Tambah Kategori Potensi" withArrow>
<Button <Button
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
@@ -71,7 +70,6 @@ function ListKategoriPotensi({ search }: { search: string }) {
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Tooltip>
</Group> </Group>
<Box style={{ overflowX: 'auto' }}> <Box style={{ overflowX: 'auto' }}>

View File

@@ -16,7 +16,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 { IconArrowBack, IconPhoto, IconUpload, IconX } from "@tabler/icons-react"; import { IconArrowBack, IconPhoto, IconUpload, IconX } from "@tabler/icons-react";
@@ -39,6 +40,16 @@ function EditPotensi() {
content: "", content: "",
imageId: "", imageId: "",
}); });
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
deskripsi: "",
kategoriId: "",
content: "",
imageId: "",
imageUrl: "",
});
// handle input changes // handle input changes
const handleChange = (field: string, value: string) => { const handleChange = (field: string, value: string) => {
@@ -47,11 +58,11 @@ function EditPotensi() {
useEffect(() => { useEffect(() => {
potensiDesaState.kategoriPotensi.findMany.load(); potensiDesaState.kategoriPotensi.findMany.load();
const loadPotensi = async () => { const loadPotensi = async () => {
const id = params?.id as string; const id = params?.id as string;
if (!id) return; if (!id) return;
try { try {
const data = await potensiState.edit.load(id); const data = await potensiState.edit.load(id);
if (data) { if (data) {
@@ -62,35 +73,45 @@ function EditPotensi() {
content: data.content || "", content: data.content || "",
imageId: data.imageId || "", imageId: data.imageId || "",
}); });
// // merge, bukan replace setOriginalData({
// setFormData((prev) => ({ name: data.name || "",
// ...prev, deskripsi: data.deskripsi || "",
// name: data.name ?? prev.name, kategoriId: data.kategoriId || "",
// deskripsi: data.deskripsi ?? prev.deskripsi, content: data.content || "",
// kategoriId: data.kategoriId ?? prev.kategoriId, imageId: data.imageId || "",
// content: data.content ?? prev.content, imageUrl: data.image?.link || "",
// imageId: data.imageId ?? prev.imageId, });
// })); setPreviewImage(data.image.link);
if (data?.image?.link) {
setPreviewImage(data.image.link);
}
} }
} catch (error) { } catch (error) {
console.error("Error loading potensi:", error); console.error("Error loading potensi:", error);
toast.error("Gagal memuat data potensi"); toast.error("Gagal memuat data potensi");
} }
}; };
loadPotensi(); loadPotensi();
}, [params?.id]); }, [params?.id]);
const handleResetForm = () => {
setFormData({
name: originalData.name || "",
deskripsi: originalData.deskripsi || "",
kategoriId: originalData.kategoriId || "",
content: originalData.content || "",
imageId: originalData.imageId || ""
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
let imageId = formData.imageId; let imageId = formData.imageId;
if (file) { if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
file, file,
@@ -116,22 +137,22 @@ function EditPotensi() {
} catch (error) { } catch (error) {
console.error("Error updating potensi:", error); console.error("Error updating potensi:", error);
toast.error("Terjadi kesalahan saat memperbarui potensi"); toast.error("Terjadi kesalahan saat memperbarui potensi");
} finally {
setIsSubmitting(false);
} }
}; };
return ( return (
<Box px={{ base: "sm", md: "lg" }} py="md"> <Box px={{ base: "sm", md: "lg" }} py="md">
<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 Potensi Desa Edit Potensi Desa
</Title> </Title>
@@ -167,6 +188,32 @@ function EditPotensi() {
</Box> </Box>
<Select <Select
label="Kategori"
placeholder="Pilih kategori"
data={potensiDesaState.kategoriPotensi.findMany.data?.map((item) => ({
label: item.nama,
value: item.id,
})) || []}
value={formData.kategoriId || null}
onChange={(val: string | null) => {
if (val) {
const selected = potensiDesaState.kategoriPotensi.findMany.data?.find(
(item) => item.id === val
);
if (selected) {
handleChange("kategoriId", selected.id);
}
} else {
handleChange("kategoriId", "");
}
}}
searchable
clearable
nothingFoundMessage="Tidak ditemukan"
required
/>
{/* <Select
value={formData.kategoriId} value={formData.kategoriId}
onChange={(val) => handleChange("kategoriId", val || "")} onChange={(val) => handleChange("kategoriId", val || "")}
label="Kategori" label="Kategori"
@@ -181,7 +228,7 @@ function EditPotensi() {
searchable searchable
required required
error={!formData.kategoriId ? "Pilih kategori" : undefined} error={!formData.kategoriId ? "Pilih kategori" : undefined}
/> /> */}
<Box> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
@@ -222,25 +269,45 @@ function EditPotensi() {
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"
radius="md" radius="md"
style={{ style={{
maxHeight: 220, maxHeight: 200,
objectFit: "contain", objectFit: 'contain',
border: `1px solid ${colors["blue-button"]}`, border: '1px solid #ddd',
}} }}
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>
@@ -258,17 +325,29 @@ function EditPotensi() {
</Box> </Box>
<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>

View File

@@ -1,13 +1,13 @@
'use client' 'use client'
import colors from '@/con/colors';
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text, Tooltip } from '@mantine/core';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useRouter, useParams } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import { useShallowEffect } from '@mantine/hooks';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import potensiDesaState from '@/app/admin/(dashboard)/_state/desa/potensi'; import potensiDesaState from '@/app/admin/(dashboard)/_state/desa/potensi';
import colors from '@/con/colors';
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
export default function DetailPotensi() { export default function DetailPotensi() {
const router = useRouter(); const router = useRouter();
@@ -108,7 +108,6 @@ export default function DetailPotensi() {
</Box> </Box>
<Group gap="sm"> <Group gap="sm">
<Tooltip label="Hapus Potensi" withArrow position="top">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
@@ -121,9 +120,7 @@ export default function DetailPotensi() {
> >
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
</Tooltip>
<Tooltip label="Edit Potensi" withArrow position="top">
<Button <Button
color="green" color="green"
onClick={() => onClick={() =>
@@ -135,7 +132,6 @@ export default function DetailPotensi() {
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -15,7 +15,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 { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -29,30 +30,39 @@ function CreatePotensi() {
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);
useEffect(() => { useEffect(() => {
potensiDesaState.kategoriPotensi.findMany.load(); potensiDesaState.kategoriPotensi.findMany.load();
}, []); }, []);
const handleSubmit = async () => { const handleSubmit = async () => {
if (!file) return toast.warn('Pilih file gambar terlebih dahulu'); try {
setIsSubmitting(true);
if (!file) return toast.warn('Pilih file gambar terlebih dahulu');
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
file, file,
name: file.name, name: file.name,
}); });
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) { if (!uploaded?.id) {
return toast.error('Gagal upload gambar'); return toast.error('Gagal upload gambar');
}
potensiState.create.form.imageId = uploaded.id;
await potensiState.create.create();
resetForm();
router.push('/admin/desa/potensi/list-potensi');
} catch (error) {
console.error('Error creating potensi:', error);
toast.error('Terjadi kesalahan saat menambahkan potensi');
} finally {
setIsSubmitting(false);
} }
potensiState.create.form.imageId = uploaded.id;
await potensiState.create.create();
resetForm();
router.push('/admin/desa/potensi/list-potensi');
}; };
const resetForm = () => { const resetForm = () => {
@@ -72,11 +82,9 @@ function CreatePotensi() {
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* 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 Potensi Desa Tambah Potensi Desa
</Title> </Title>
@@ -93,7 +101,7 @@ function CreatePotensi() {
<Stack gap="md"> <Stack gap="md">
{/* Judul */} {/* Judul */}
<TextInput <TextInput
defaultValue={potensiState.create.form.name} value={potensiState.create.form.name}
onChange={(val) => (potensiState.create.form.name = val.target.value)} onChange={(val) => (potensiState.create.form.name = val.target.value)}
label="Judul" label="Judul"
placeholder="Masukkan judul potensi" placeholder="Masukkan judul potensi"
@@ -115,6 +123,32 @@ function CreatePotensi() {
{/* Kategori */} {/* Kategori */}
<Select <Select
label="Kategori"
placeholder="Pilih kategori"
data={potensiDesaState.kategoriPotensi.findMany.data?.map((item) => ({
label: item.nama,
value: item.id,
})) || []}
value={potensiState.create.form.kategoriId || null}
onChange={(val: string | null) => {
if (val) {
const selected = potensiDesaState.kategoriPotensi.findMany.data?.find(
(item) => item.id === val
);
if (selected) {
potensiState.create.form.kategoriId = selected.id;
}
} else {
potensiState.create.form.kategoriId = '';
}
}}
searchable
clearable
nothingFoundMessage="Tidak ditemukan"
required
/>
{/* <Select
label="Kategori" label="Kategori"
placeholder="Pilih kategori" placeholder="Pilih kategori"
value={potensiState.create.form.kategoriId || ""} value={potensiState.create.form.kategoriId || ""}
@@ -125,7 +159,7 @@ function CreatePotensi() {
value: item.id, value: item.id,
label: item.nama, label: item.nama,
}))} }))}
/> /> */}
{/* Upload Gambar */} {/* Upload Gambar */}
<Box> <Box>
@@ -142,7 +176,7 @@ function CreatePotensi() {
}} }}
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"
> >
@@ -160,17 +194,44 @@ function CreatePotensi() {
<Text ta="center" mt="sm" size="sm" color="dimmed"> <Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB) Seret gambar atau klik untuk memilih file (maks 5MB)
</Text> </Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</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"
radius="md" radius="md"
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }} style={{
loading='lazy' 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>
)} )}
</Box> </Box>
@@ -190,6 +251,17 @@ function CreatePotensi() {
{/* 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"
@@ -200,7 +272,7 @@ function CreatePotensi() {
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>

View File

@@ -18,8 +18,7 @@ import {
TableThead, TableThead,
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';
@@ -76,7 +75,6 @@ function ListPotensi({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={4}>Daftar Potensi Desa</Title> <Title order={4}>Daftar Potensi Desa</Title>
<Tooltip label="Tambah Potensi" withArrow>
<Button <Button
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
@@ -85,7 +83,6 @@ function ListPotensi({ search }: { search: string }) {
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Tooltip>
</Group> </Group>
<Box style={{ overflowX: "auto" }}> <Box style={{ overflowX: "auto" }}>
<Table highlightOnHover style={{ minWidth: '700px' }}> <Table highlightOnHover style={{ minWidth: '700px' }}>

View File

@@ -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 { IconCalendar, IconUser, IconUsers } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { IconUser, IconUsers, IconCalendar } from '@tabler/icons-react';
function LayoutTabsDetail({ children }: { children: React.ReactNode }) { function LayoutTabsDetail({ children }: { children: React.ReactNode }) {
const router = useRouter() const router = useRouter()
@@ -14,22 +14,19 @@ function LayoutTabsDetail({ children }: { children: React.ReactNode }) {
label: "Profile Desa", label: "Profile Desa",
value: "profiledesa", value: "profiledesa",
href: "/admin/desa/profile/profile-desa", href: "/admin/desa/profile/profile-desa",
icon: <IconUser size={18} stroke={1.8} />, icon: <IconUser size={18} stroke={1.8} />
tooltip: "Lihat dan kelola profil desa"
}, },
{ {
label: "Profile Perbekel", label: "Profile Perbekel",
value: "profileperbekel", value: "profileperbekel",
href: "/admin/desa/profile/profile-perbekel", href: "/admin/desa/profile/profile-perbekel",
icon: <IconUsers size={18} stroke={1.8} />, icon: <IconUsers size={18} stroke={1.8} />
tooltip: "Kelola data Perbekel"
}, },
{ {
label: "Profile Perbekel Dari Masa Ke Masa", label: "Profile Perbekel Dari Masa Ke Masa",
value: "profile-perbekel-dari-masa-ke-masa", value: "profile-perbekel-dari-masa-ke-masa",
href: "/admin/desa/profile/profile-perbekel-dari-masa-ke-masa", href: "/admin/desa/profile/profile-perbekel-dari-masa-ke-masa",
icon: <IconCalendar size={18} stroke={1.8} />, icon: <IconCalendar size={18} stroke={1.8} />
tooltip: "Riwayat Perbekel dari masa ke masa"
} }
]; ];
@@ -76,42 +73,41 @@ function LayoutTabsDetail({ children }: { children: React.ReactNode }) {
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}} }}
> >
{tabs.map((tab, i) => (
<Tooltip key={i} label={tab.tooltip} position="bottom" withArrow transitionProps={{ transition: 'pop', duration: 200 }}>
<TabsTab
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
</Tooltip>
))}
</TabsList>
</ScrollArea>
{tabs.map((tab, i) => ( {tabs.map((tab, i) => (
<TabsPanel <TabsTab
key={i} key={i}
value={tab.value} value={tab.value}
leftSection={tab.icon}
style={{ style={{
padding: "1.5rem", fontWeight: 600,
background: "linear-gradient(180deg, #ffffff, #f5f6fa)", fontSize: "0.9rem",
borderRadius: "1rem", transition: "all 0.2s ease",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}} }}
> >
{/* Konten dummy, bisa diganti sesuai routing */} {tab.label}
<>{children}</> </TabsTab>
</TabsPanel>
))} ))}
</Tabs> </TabsList>
</Stack> </ScrollArea>
);
{tabs.map((tab, i) => (
<TabsPanel
key={i}
value={tab.value}
style={{
padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}}
>
{/* Konten dummy, bisa diganti sesuai routing */}
<>{children}</>
</TabsPanel>
))}
</Tabs>
</Stack>
);
} }
export default LayoutTabsDetail; export default LayoutTabsDetail;

View File

@@ -1,154 +1,244 @@
/* eslint-disable react-hooks/exhaustive-deps */ 'use client';
'use client'
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor'; import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile'; import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Alert, Box, Button, Center, Group, Paper, Stack, Text, TextInput, Title, Tooltip } from '@mantine/core'; import {
Alert,
Box,
Button,
Center,
Group,
Loader,
Paper,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { IconAlertCircle, IconArrowBack } from '@tabler/icons-react'; import { IconAlertCircle, IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
// 🧩 Type untuk form
interface FormData {
judul: string;
deskripsi: string;
}
// 🧩 Main Component
function Page() { function Page() {
const lambangState = useProxy(stateProfileDesa.lambangDesa) const router = useRouter();
const router = useRouter() const params = useParams();
const params = useParams()
const [isSubmitting, setIsSubmitting] = useState(false);
// Load data
useEffect(() => {
const loadData = async () => {
const id = params?.id as string;
if (!id) {
toast.error("ID tidak valid");
router.push("/admin/desa/profile/profile-desa");
return;
}
try { const [formData, setFormData] = useState<FormData>({ judul: '', deskripsi: '' });
const data = await lambangState.findUnique.load(id); const [originalData, setOriginalData] = useState<FormData>({ judul: '', deskripsi: '' });
lambangState.update.initialize(data); const [isLoading, setIsLoading] = useState(true);
} catch (error) { const [isSubmitting, setIsSubmitting] = useState(false);
console.error("Error loading lambang:", error); const [loadError, setLoadError] = useState<string | null>(null);
toast.error("Gagal memuat data lambang desa");
}
};
loadData(); // 🧭 Load data awal
useEffect(() => {
const loadData = async () => {
const id = params?.id as string;
if (!id) {
toast.error('ID tidak valid');
router.push('/admin/desa/profile/profile-desa');
return;
}
return () => { setIsLoading(true);
lambangState.update.reset(); setLoadError(null);
lambangState.findUnique.reset();
};
}, [params?.id, router]);
const handleSubmit = async () => { try {
if (isSubmitting || !lambangState.update.form.judul.trim()) { const data = await stateProfileDesa.lambangDesa.findUnique.load(id);
toast.error("Judul wajib diisi");
return; if (data) {
} const initial: FormData = {
judul: data.judul || '',
setIsSubmitting(true); deskripsi: data.deskripsi || '',
};
try { setFormData(initial);
const success = await lambangState.update.submit(); setOriginalData(initial);
if (success) { // Penting untuk isi id di state sebelum submit
toast.success("Data berhasil disimpan"); stateProfileDesa.lambangDesa.update.initialize(data);
router.push("/admin/desa/profile/profile-desa"); } else {
} setLoadError('Data tidak ditemukan');
} catch (error) {
console.error("Error update lambang desa:", error);
toast.error("Terjadi kesalahan saat update lambang desa");
} finally {
setIsSubmitting(false);
} }
} catch (error) {
console.error('Error loading lambang:', error);
setLoadError('Gagal memuat data lambang desa');
toast.error('Gagal memuat data lambang desa');
} finally {
setIsLoading(false);
}
}; };
const handleBack = () => router.back(); loadData();
// Loading state return () => {
if (lambangState.findUnique.loading || lambangState.update.loading) { stateProfileDesa.lambangDesa.update.reset();
return ( stateProfileDesa.lambangDesa.findUnique.reset();
<Box> };
<Center h={400}> }, [params?.id, router]);
<Text>Memuat data...</Text>
</Center> // 🔁 Reset form
</Box> const handleResetForm = () => {
); setFormData(originalData);
toast.info('Form dikembalikan ke data awal');
};
// 💾 Submit handler
const handleSubmit = async () => {
if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return;
} }
// Error state setIsSubmitting(true);
if (lambangState.findUnique.error) { try {
return ( const state = stateProfileDesa.lambangDesa;
<Box> state.update.form.judul = formData.judul;
<Stack gap="md"> state.update.form.deskripsi = formData.deskripsi;
<Button variant="subtle" onClick={handleBack}>
<IconArrowBack color={colors['blue-button']} size={20} />
</Button>
<Alert icon={<IconAlertCircle size={16} />} color="red">
<Text fw="bold">Error</Text>
<Text>{lambangState.findUnique.error}</Text>
</Alert>
</Stack>
</Box>
);
}
const success = await state.update.submit();
if (success) {
toast.success('Data berhasil disimpan');
router.push('/admin/desa/profile/profile-desa');
} else {
toast.error('Gagal menyimpan data');
}
} catch (error) {
console.error('Error update lambang desa:', error);
toast.error('Terjadi kesalahan saat update lambang desa');
} finally {
setIsSubmitting(false);
}
};
// 📝 Handlers
const handleJudulChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, judul: e.target.value }));
};
const handleDeskripsiChange = (html: string) => {
setFormData(prev => ({ ...prev, deskripsi: html }));
};
const handleBack = () => router.back();
// 🔄 Loading
if (isLoading) {
return ( return (
<Box> <Box>
<Stack gap="xs"> <Center h={400}>
<Group mb="md"> <Stack align="center" gap="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow> <Loader size="lg" color={colors['blue-button']} />
<Button variant="subtle" onClick={handleBack} p="xs" radius="md"> <Text size="lg" fw={500} c="dimmed">
<IconArrowBack color={colors['blue-button']} size={24} /> Memuat data lambang desa...
</Button> </Text>
</Tooltip> </Stack>
<Title order={4} ml="sm" c="dark">Edit Lambang Desa</Title> </Center>
</Group> </Box>
<Paper w={{ base: "100%", md: "50%" }} bg={colors['white-1']} p="md" radius="md" shadow="sm" style={{ border: '1px solid #e0e0e0' }}>
<Stack gap="xs">
<Title order={3}>Edit Lambang Desa</Title>
{/* Judul */}
<TextInput
label={<Text fw="bold">Judul</Text>}
placeholder="Judul lambang"
defaultValue={lambangState.update.form.judul}
onChange={(e) => lambangState.update.form.judul = e.currentTarget.value}
error={!lambangState.update.form.judul && "Judul wajib diisi"}
/>
{/* Deskripsi */}
<Box>
<Text fz={"md"} fw={"bold"}>Deskripsi</Text>
<EditEditor
value={lambangState.update.form.deskripsi}
onChange={(val) => lambangState.update.form.deskripsi = val}
/>
</Box>
{/* Buttons */}
<Group>
<Button
bg={colors['blue-button']}
onClick={handleSubmit}
loading={isSubmitting || lambangState.update.loading}
disabled={!lambangState.update.form.judul}
>
{isSubmitting ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
<Button variant="outline" onClick={handleBack} disabled={isSubmitting || lambangState.update.loading}>
Batal
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Box>
); );
}
// ❌ Error
if (loadError) {
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Stack gap="md">
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Alert icon={<IconAlertCircle size={20} />} color="red" title="Terjadi Kesalahan" radius="md">
{loadError}
</Alert>
<Button onClick={() => router.push('/admin/desa/profile/profile-desa')} variant="outline">
Kembali ke Halaman Utama
</Button>
</Stack>
</Box>
);
}
// 🧱 UI utama
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Stack gap="md">
{/* Header */}
<Group mb="sm">
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Lambang Desa
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: '100%', md: '60%' }}
bg={colors['white-1']}
p="xl"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="lg">
{/* Judul */}
<TextInput
label={<Text fw="bold" size="sm">Judul</Text>}
placeholder="Masukkan judul lambang desa"
value={formData.judul}
onChange={handleJudulChange}
error={!formData.judul.trim() && 'Judul wajib diisi'}
required
size="md"
radius="md"
/>
{/* Deskripsi */}
<Box>
<Text fw="bold" size="sm" mb={8}>
Deskripsi
</Text>
<EditEditor value={formData.deskripsi} onChange={handleDeskripsiChange} />
</Box>
{/* Tombol Aksi */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
disabled={isSubmitting}
>
Batal
</Button>
<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)',
}}
loading={isSubmitting}
>
Simpan
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Box>
);
} }
export default Page; export default Page;

View File

@@ -5,9 +5,9 @@ import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile'; import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Group, Image, Paper, SimpleGrid, Stack, Text, TextInput, Title, Tooltip, Center, Alert } from '@mantine/core'; import { Alert, Box, Button, Center, Group, Image, Loader, Paper, SimpleGrid, Stack, Text, TextInput, Title } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX, IconAlertCircle } from '@tabler/icons-react'; import { IconAlertCircle, IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -19,8 +19,8 @@ function Page() {
const params = useParams(); const params = useParams();
const [images, setImages] = useState< const [images, setImages] = useState<
Array<{ file: File | null; preview: string; label: string; imageId?: string }> Array<{ file: File | null; preview: string; label: string; imageId?: string }>
>([]); >([]);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
judul: '', judul: '',
deskripsi: '', deskripsi: '',
@@ -28,6 +28,12 @@ function Page() {
}); });
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
judul: "",
deskripsi: "",
images: [] as Array<{ label: string; imageId: string }>
});
// Load data // Load data
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
@@ -52,6 +58,17 @@ function Page() {
})), })),
}); });
setOriginalData({
judul: data.judul || '',
deskripsi: data.deskripsi || '',
images: (data.images || []).map((img: any) => ({
label: img.label,
imageId: img.image?.id ?? '',
preview: img.image?.link ?? '',
})),
});
if (data?.images?.length > 0 && data.images[0].image?.link) { if (data?.images?.length > 0 && data.images[0].image?.link) {
setImages(data.images.map((img: any) => ({ setImages(data.images.map((img: any) => ({
file: null, file: null,
@@ -77,15 +94,36 @@ function Page() {
const handleBack = () => router.back(); const handleBack = () => router.back();
const handleResetForm = () => {
setFormData({
judul: originalData.judul,
deskripsi: originalData.deskripsi,
images: originalData.images.map((img) => ({
label: img.label,
imageId: img.imageId,
})),
});
setImages(
originalData.images.map((img: any) => ({
file: null,
preview: img.preview, // pakai preview masing-masing, bukan cuma satu
label: img.label,
imageId: img.imageId,
}))
);
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting || !formData.judul.trim()) { if (isSubmitting || !formData.judul.trim()) {
toast.error("Judul wajib diisi"); toast.error("Judul wajib diisi");
return; return;
} }
setIsSubmitting(true);
try { try {
setIsSubmitting(true);
const uploadedImages = []; const uploadedImages = [];
// Upload semua gambar baru // Upload semua gambar baru
@@ -95,7 +133,7 @@ function Page() {
uploadedImages.push({ imageId: img.imageId, label: img.label }); uploadedImages.push({ imageId: img.imageId, label: img.label });
continue; continue;
} }
// upload baru // upload baru
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
file: img.file, file: img.file,
@@ -108,7 +146,7 @@ function Page() {
} }
uploadedImages.push({ imageId: uploaded.id, label: img.label || "main" }); uploadedImages.push({ imageId: uploaded.id, label: img.label || "main" });
} }
// Update ke global state // Update ke global state
maskotState.update.updateField("judul", formData.judul); maskotState.update.updateField("judul", formData.judul);
@@ -161,11 +199,9 @@ function Page() {
<Box> <Box>
<Stack gap="xs"> <Stack gap="xs">
<Group mb="md"> <Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow> <Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<Button variant="subtle" onClick={handleBack} p="xs" radius="md"> <IconArrowBack color={colors['blue-button']} size={24} />
<IconArrowBack color={colors['blue-button']} size={24} /> </Button>
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">Edit Maskot Desa</Title> <Title order={4} ml="sm" c="dark">Edit Maskot Desa</Title>
</Group> </Group>
@@ -177,7 +213,7 @@ function Page() {
<TextInput <TextInput
label={<Text fw="bold">Judul</Text>} label={<Text fw="bold">Judul</Text>}
placeholder="Masukkan judul maskot" placeholder="Masukkan judul maskot"
defaultValue={formData.judul} value={formData.judul}
onChange={(e) => setFormData({ ...formData, judul: e.currentTarget.value })} onChange={(e) => setFormData({ ...formData, judul: e.currentTarget.value })}
error={!formData.judul && "Judul wajib diisi"} error={!formData.judul && "Judul wajib diisi"}
/> />
@@ -233,7 +269,7 @@ function Page() {
setImages(updated); setImages(updated);
}} }}
> >
Hapus <IconX size={16} />
</Button> </Button>
</Group> </Group>
<Image <Image
@@ -262,18 +298,31 @@ function Page() {
</SimpleGrid> </SimpleGrid>
{/* Buttons */} {/* Buttons */}
<Group> <Group justify="right">
{/* Tombol Batal */}
<Button <Button
bg={colors['blue-button']} variant="outline"
onClick={handleSubmit} color="gray"
loading={isSubmitting || maskotState.update.loading} radius="md"
disabled={!formData.judul} size="md"
onClick={handleResetForm}
> >
{isSubmitting ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
<Button variant="outline" onClick={handleBack} disabled={isSubmitting || maskotState.update.loading}>
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>

View File

@@ -1,148 +1,272 @@
/* eslint-disable react-hooks/exhaustive-deps */ 'use client';
'use client'
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor'; import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile'; import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Alert, Box, Button, Center, Group, Paper, Stack, Text, TextInput, Title, Tooltip } from '@mantine/core'; import {
Alert,
Box,
Button,
Center,
Group,
Loader,
Paper,
Stack,
Text,
TextInput,
Title
} from '@mantine/core';
import { IconAlertCircle, IconArrowBack } from '@tabler/icons-react'; import { IconAlertCircle, IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
// 🔹 Types
interface FormData {
judul: string;
deskripsi: string;
}
// 🔹 Main Component
function Page() { function Page() {
const sejarahState = useProxy(stateProfileDesa.sejarahDesa) const router = useRouter();
const router = useRouter() const params = useParams();
const params = useParams()
const [isSubmitting, setIsSubmitting] = useState(false);
// Load data // 🧩 Local State
const [formData, setFormData] = useState<FormData>({
judul: '',
deskripsi: '',
});
const [originalData, setOriginalData] = useState<FormData>({
judul: '',
deskripsi: '',
});
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
// 🧭 Load Initial Data
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
const id = params?.id as string; const id = params?.id as string;
if (!id) { if (!id) {
toast.error("ID tidak valid"); toast.error('ID tidak valid');
router.push("/admin/desa/profile/profile-desa"); router.push('/admin/desa/profile/profile-desa');
return; return;
} }
setIsLoading(true);
setLoadError(null);
try { try {
const data = await sejarahState.findUnique.load(id); const data = await stateProfileDesa.sejarahDesa.findUnique.load(id);
if (data) { if (data) {
sejarahState.update.initialize(data); const initialData: FormData = {
judul: data.judul || '',
deskripsi: data.deskripsi || '',
};
setFormData(initialData);
setOriginalData(initialData);
stateProfileDesa.sejarahDesa.update.initialize(data);
} else {
setLoadError('Data tidak ditemukan');
} }
} catch (error) { } catch (error) {
console.error("Error loading sejarah:", error); console.error('Error loading sejarah:', error);
toast.error("Gagal memuat data sejarah desa"); setLoadError('Gagal memuat data sejarah desa');
toast.error('Gagal memuat data sejarah desa');
} finally {
setIsLoading(false);
} }
}; };
loadData(); loadData();
return () => { return () => {
sejarahState.update.reset(); stateProfileDesa.sejarahDesa.update.reset();
sejarahState.findUnique.reset(); stateProfileDesa.sejarahDesa.findUnique.reset();
}; };
}, [params?.id, router]); }, [params?.id, router]);
// 🔄 Check if form has changes
// 🔁 Reset Form to Original Data
const handleResetForm = () => {
setFormData(originalData);
toast.info('Form dikembalikan ke data awal');
};
// 💾 Submit Handler
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting || !sejarahState.update.form.judul.trim()) { // Validation
toast.error("Judul wajib diisi"); if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return; return;
} }
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const success = await sejarahState.update.submit(); // Access original state directly (not proxy)
const originalState = stateProfileDesa.sejarahDesa;
// Update form data
originalState.update.form.judul = formData.judul;
originalState.update.form.deskripsi = formData.deskripsi;
// Submit
const success = await originalState.update.submit();
if (success) { if (success) {
toast.success("Data berhasil disimpan"); toast.success('Data berhasil disimpan');
router.push("/admin/desa/profile/profile-desa"); router.push('/admin/desa/profile/profile-desa');
} else {
toast.error('Gagal menyimpan data');
} }
} catch (error) { } catch (error) {
console.error("Error update sejarah desa:", error); console.error('Error update sejarah desa:', error);
toast.error("Terjadi kesalahan saat update sejarah desa"); toast.error('Terjadi kesalahan saat update sejarah desa');
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
const handleBack = () => router.back(); // 📝 Form Field Handlers
const handleJudulChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, judul: e.target.value }));
};
// Loading state const handleDeskripsiChange = (html: string) => {
if (sejarahState.findUnique.loading || sejarahState.update.loading) { setFormData(prev => ({ ...prev, deskripsi: html }));
};
const handleBack = () => {
router.back();
};
// 🔄 Loading State
if (isLoading) {
return ( return (
<Box> <Box>
<Center h={400}> <Center h={400}>
<Text>Memuat data...</Text> <Stack align="center" gap="md">
<Loader size="lg" color={colors['blue-button']} />
<Text size="lg" fw={500} c="dimmed">
Memuat data...
</Text>
</Stack>
</Center> </Center>
</Box> </Box>
); );
} }
// Error state // Error State
if (sejarahState.findUnique.error) { if (loadError) {
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Stack gap="md"> <Stack gap="md">
<Button variant="subtle" onClick={handleBack}> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={20} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
<Alert icon={<IconAlertCircle size={16} />} color="red"> <Alert
<Text fw="bold">Error</Text> icon={<IconAlertCircle size={20} />}
<Text>{sejarahState.findUnique.error}</Text> color="red"
title="Terjadi Kesalahan"
radius="md"
>
{loadError}
</Alert> </Alert>
<Button
onClick={() => router.push('/admin/desa/profile/profile-desa')}
variant="outline"
>
Kembali ke Halaman Utama
</Button>
</Stack> </Stack>
</Box> </Box>
); );
} }
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Stack gap="xs"> <Stack gap="md">
<Group mb="md"> {/* Header */}
<Tooltip label="Kembali ke halaman sebelumnya" withArrow> <Group mb="sm">
<Button variant="subtle" onClick={handleBack} p="xs" radius="md"> <Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Tooltip> <Title order={4} ml="sm" c="dark">
<Title order={4} ml="sm" c="dark">Edit Sejarah Desa</Title> Edit Sejarah Desa
</Title>
</Group> </Group>
<Paper w={{ base: "100%", md: "50%" }} bg={colors['white-1']} p="md" radius="md" shadow="sm" style={{ border: '1px solid #e0e0e0' }}> {/* Form */}
<Stack gap="xs"> <Paper
<Title order={3}>Edit Sejarah Desa</Title> w={{ base: '100%', md: '60%' }}
bg={colors['white-1']}
{/* Judul */} p="xl"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="lg">
{/* Form Title */}
<Box>
<Title order={3} mb={4}>
Informasi Sejarah Desa
</Title>
</Box>
{/* Judul Field */}
<TextInput <TextInput
label={<Text fw="bold">Judul</Text>} label={<Text fw="bold" size="sm">Judul Sejarah</Text>}
placeholder="Judul sejarah" placeholder="Masukkan judul sejarah desa"
defaultValue={sejarahState.update.form.judul} value={formData.judul}
onChange={(e) => sejarahState.update.form.judul = e.currentTarget.value} onChange={handleJudulChange}
error={!sejarahState.update.form.judul && "Judul wajib diisi"} error={!formData.judul.trim() && 'Judul wajib diisi'}
required
size="md"
radius="md"
/> />
{/* Deskripsi */} {/* Deskripsi Field */}
<Box> <Box>
<Text fz={"md"} fw={"bold"}>Deskripsi</Text> <Text fw="bold" size="sm" mb={8}>
Deskripsi Sejarah
</Text>
<EditEditor <EditEditor
value={sejarahState.update.form.deskripsi} value={formData.deskripsi}
onChange={(val) => sejarahState.update.form.deskripsi = val} onChange={handleDeskripsiChange}
/> />
</Box> </Box>
{/* Buttons */} {/* Action Buttons */}
<Group> <Group justify="right">
{/* Tombol Batal */}
<Button <Button
bg={colors['blue-button']} variant="outline"
onClick={handleSubmit} color="gray"
loading={isSubmitting || sejarahState.update.loading} radius="md"
disabled={!sejarahState.update.form.judul} size="md"
onClick={handleResetForm}
> >
{isSubmitting ? 'Menyimpan...' : 'Simpan Perubahan'} Batal
</Button> </Button>
<Button variant="outline" onClick={handleBack} disabled={isSubmitting || sejarahState.update.loading}> {/* Tombol Simpan */}
Batal <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> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -152,4 +276,4 @@ function Page() {
); );
} }
export default Page; export default Page;

View File

@@ -1,155 +1,247 @@
/* eslint-disable react-hooks/exhaustive-deps */ 'use client';
'use client'
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor'; import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile'; import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Alert, Box, Button, Center, Group, Paper, Stack, Text, Title, Tooltip } from '@mantine/core'; import {
Alert,
Box,
Button,
Center,
Group,
Loader,
Paper,
Stack,
Text,
Title,
} from '@mantine/core';
import { IconAlertCircle, IconArrowBack } from '@tabler/icons-react'; import { IconAlertCircle, IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
// 🔹 Types
interface FormData {
visi: string;
misi: string;
}
// 🔹 Main Component
function Page() { function Page() {
const visiMisiState = useProxy(stateProfileDesa.visiMisiDesa) const router = useRouter();
const router = useRouter() const params = useParams();
const params = useParams() const [formData, setFormData] = useState<FormData>({ visi: '', misi: '' });
const [isSubmitting, setIsSubmitting] = useState(false); const [originalData, setOriginalData] = useState<FormData>({ visi: '', misi: '' });
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
// Load data // 🧭 Load Data
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
const id = params?.id as string; const id = params?.id as string;
if (!id) { if (!id) {
toast.error("ID tidak valid"); toast.error('ID tidak valid');
router.push("/admin/desa/profile/profile-desa"); router.push('/admin/desa/profile/profile-desa');
return; return;
} }
try { setIsLoading(true);
const data = await visiMisiState.findUnique.load(id); setLoadError(null);
visiMisiState.update.initialize(data);
} catch (error) {
console.error("Error loading visi misi:", error);
toast.error("Gagal memuat data visi misi desa");
}
};
loadData(); try {
const data = await stateProfileDesa.visiMisiDesa.findUnique.load(id);
if (data) {
const initialData: FormData = {
visi: data.visi || '',
misi: data.misi || '',
};
setFormData(initialData);
setOriginalData(initialData);
return () => { // set id ke state agar submit pakai endpoint benar
visiMisiState.update.reset(); stateProfileDesa.visiMisiDesa.update.initialize(data);
visiMisiState.findUnique.reset(); } else {
}; setLoadError('Data tidak ditemukan');
}, [params?.id, router]);
const handleSubmit = async () => {
if (isSubmitting || !visiMisiState.update.form.visi.trim()) {
toast.error("Visi wajib diisi");
return;
}
setIsSubmitting(true);
try {
const success = await visiMisiState.update.submit();
if (success) {
toast.success("Data berhasil disimpan");
router.push("/admin/desa/profile/profile-desa");
}
} catch (error) {
console.error("Error update visi misi desa:", error);
toast.error("Terjadi kesalahan saat update visi misi desa");
} finally {
setIsSubmitting(false);
} }
} catch (error) {
console.error('Error load visi misi:', error);
setLoadError('Gagal memuat data visi misi desa');
toast.error('Gagal memuat data visi misi desa');
} finally {
setIsLoading(false);
}
}; };
const handleBack = () => router.back(); loadData();
// Loading state return () => {
if (visiMisiState.findUnique.loading || visiMisiState.update.loading) { stateProfileDesa.visiMisiDesa.update.reset();
return ( stateProfileDesa.visiMisiDesa.findUnique.reset();
<Box> };
<Center h={400}> }, [params?.id, router]);
<Text>Memuat data...</Text>
</Center> // 🔄 Reset Form
</Box> const handleResetForm = () => {
); setFormData(originalData);
toast.info('Form dikembalikan ke data awal');
};
// 💾 Submit
const handleSubmit = async () => {
if (!formData.visi.trim()) {
toast.error('Visi wajib diisi');
return;
} }
// Error state setIsSubmitting(true);
if (visiMisiState.findUnique.error) { try {
return ( const originalState = stateProfileDesa.visiMisiDesa;
<Box>
<Stack gap="md">
<Button variant="subtle" onClick={handleBack}>
<IconArrowBack color={colors['blue-button']} size={20} />
</Button>
<Alert icon={<IconAlertCircle size={16} />} color="red">
<Text fw="bold">Error</Text>
<Text>{visiMisiState.findUnique.error}</Text>
</Alert>
</Stack>
</Box>
);
}
// update data form ke state sebelum submit
originalState.update.form.visi = formData.visi;
originalState.update.form.misi = formData.misi;
const success = await originalState.update.submit();
if (success) {
toast.success('Data berhasil disimpan');
router.push('/admin/desa/profile/profile-desa');
} else {
toast.error('Gagal menyimpan data');
}
} catch (error) {
console.error('Error update visi misi desa:', error);
toast.error('Terjadi kesalahan saat update visi misi desa');
} finally {
setIsSubmitting(false);
}
};
// 🧭 Field handlers
const handleVisiChange = (html: string) => setFormData(prev => ({ ...prev, visi: html }));
const handleMisiChange = (html: string) => setFormData(prev => ({ ...prev, misi: html }));
const handleBack = () => router.back();
// ⏳ Loading
if (isLoading) {
return ( return (
<Box> <Box>
<Stack gap="xs"> <Center h={400}>
<Group mb="md"> <Stack align="center" gap="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow> <Loader size="lg" color={colors['blue-button']} />
<Button variant="subtle" onClick={handleBack} p="xs" radius="md"> <Text size="lg" fw={500} c="dimmed">
<IconArrowBack color={colors['blue-button']} size={24} /> Memuat data...
</Button> </Text>
</Tooltip> </Stack>
<Title order={4} ml="sm" c="dark">Edit Visi Misi Desa</Title> </Center>
</Group> </Box>
<Paper w={{ base: "100%", md: "50%" }} bg={colors['white-1']} p="md" radius="md" shadow="sm" style={{ border: '1px solid #e0e0e0' }}>
<Stack gap="xs">
<Title order={3}>Edit Visi Misi Desa</Title>
{/* Visi */}
<Box>
<Text fz={"md"} fw={"bold"}>Visi</Text>
<EditEditor
value={visiMisiState.update.form.visi}
onChange={(val) => visiMisiState.update.form.visi = val}
/>
</Box>
{/* Misi */}
<Box>
<Text fz={"md"} fw={"bold"}>Misi</Text>
<EditEditor
value={visiMisiState.update.form.misi}
onChange={(val) => visiMisiState.update.form.misi = val}
/>
</Box>
{/* Buttons */}
<Group>
<Button
bg={colors['blue-button']}
onClick={handleSubmit}
loading={isSubmitting || visiMisiState.update.loading}
disabled={!visiMisiState.update.form.visi}
>
{isSubmitting ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
<Button variant="outline" onClick={handleBack} disabled={isSubmitting || visiMisiState.update.loading}>
Batal
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Box>
); );
}
// ❌ Error
if (loadError) {
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Stack gap="md">
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Alert
icon={<IconAlertCircle size={20} />}
color="red"
title="Terjadi Kesalahan"
radius="md"
>
{loadError}
</Alert>
<Button
onClick={() => router.push('/admin/desa/profile/profile-desa')}
variant="outline"
>
Kembali ke Halaman Utama
</Button>
</Stack>
</Box>
);
}
// ✅ UI
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Stack gap="md">
{/* Header */}
<Group mb="sm">
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Visi & Misi Desa
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: '100%', md: '60%' }}
bg={colors['white-1']}
p="xl"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="lg">
<Box>
<Title order={3} mb={4}>
Informasi Visi & Misi Desa
</Title>
</Box>
{/* Visi */}
<Box>
<Text fw="bold" size="sm" mb={8}>
Visi
</Text>
<EditEditor value={formData.visi} onChange={handleVisiChange} />
</Box>
{/* Misi */}
<Box>
<Text fw="bold" size="sm" mb={8}>
Misi
</Text>
<EditEditor value={formData.misi} onChange={handleMisiChange} />
</Box>
{/* Actions */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
<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)',
}}
loading={isSubmitting}
>
Simpan
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Box>
);
} }
export default Page; export default Page;

View File

@@ -1,12 +1,12 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Card, Center, Divider, Grid, GridCol, Image, Paper, SimpleGrid, Stack, Text, Title, Tooltip } from '@mantine/core'; import { Box, Button, Card, Center, Divider, Grid, GridCol, Image, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core';
import { useSnapshot } from 'valtio';
import stateProfileDesa from '../../../_state/desa/profile';
import { useEffect } from 'react';
import { IconEdit } from '@tabler/icons-react'; import { IconEdit } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useSnapshot } from 'valtio';
import stateProfileDesa from '../../../_state/desa/profile';
function Page() { function Page() {
const router = useRouter(); const router = useRouter();
@@ -37,7 +37,6 @@ function Page() {
<Title order={3} c={colors['blue-button']}>Preview Sejarah Desa</Title> <Title order={3} c={colors['blue-button']}>Preview Sejarah Desa</Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Tooltip label="Edit Sejarah Desa" withArrow>
<Button <Button
c="green" c="green"
variant="light" variant="light"
@@ -47,7 +46,6 @@ function Page() {
> >
Edit Edit
</Button> </Button>
</Tooltip>
</GridCol> </GridCol>
</Grid> </Grid>
@@ -84,7 +82,6 @@ function Page() {
<Title order={3} c={colors['blue-button']}>Preview Visi Misi Desa</Title> <Title order={3} c={colors['blue-button']}>Preview Visi Misi Desa</Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Tooltip label="Edit Visi Misi Desa" withArrow>
<Button <Button
c="green" c="green"
variant="light" variant="light"
@@ -94,7 +91,6 @@ function Page() {
> >
Edit Edit
</Button> </Button>
</Tooltip>
</GridCol> </GridCol>
</Grid> </Grid>
@@ -134,7 +130,6 @@ function Page() {
<Title order={3} c={colors['blue-button']}>Preview Lambang Desa</Title> <Title order={3} c={colors['blue-button']}>Preview Lambang Desa</Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Tooltip label="Edit Lambang Desa" withArrow>
<Button <Button
c="green" c="green"
variant="light" variant="light"
@@ -144,7 +139,6 @@ function Page() {
> >
Edit Edit
</Button> </Button>
</Tooltip>
</GridCol> </GridCol>
</Grid> </Grid>
@@ -181,7 +175,6 @@ function Page() {
<Title order={3} c={colors['blue-button']}>Preview Maskot Desa</Title> <Title order={3} c={colors['blue-button']}>Preview Maskot Desa</Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Tooltip label="Edit Maskot Desa" withArrow>
<Button <Button
c="green" c="green"
variant="light" variant="light"
@@ -191,7 +184,6 @@ function Page() {
> >
Edit Edit
</Button> </Button>
</Tooltip>
</GridCol> </GridCol>
</Grid> </Grid>

View File

@@ -4,6 +4,7 @@ import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
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,
@@ -13,7 +14,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 { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -36,6 +37,16 @@ function EditPerbekelDariMasaKeMasa() {
imageId: '' imageId: ''
}); });
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
nama: '',
daerah: '',
periode: '',
imageId: '',
imageUrl: "",
});
// load data pertama kali // load data pertama kali
useEffect(() => { useEffect(() => {
const loadFoto = async () => { const loadFoto = async () => {
@@ -50,9 +61,14 @@ function EditPerbekelDariMasaKeMasa() {
periode: data.periode || '', periode: data.periode || '',
imageId: data.imageId || '' imageId: data.imageId || ''
}); });
if (data?.imageGalleryFoto?.link) { setOriginalData({
setPreviewImage(data.imageGalleryFoto.link); nama: data.nama || '',
} daerah: data.daerah || '',
periode: data.periode || '',
imageId: data.imageId || '',
imageUrl: data.image.link || '',
})
setPreviewImage(data.image.link);
} }
} catch (error) { } catch (error) {
console.error('Error loading foto:', error); console.error('Error loading foto:', error);
@@ -70,8 +86,22 @@ function EditPerbekelDariMasaKeMasa() {
})); }));
}; };
const handleResetForm = () => {
setFormData({
nama: originalData.nama,
daerah: originalData.daerah,
periode: originalData.periode,
imageId: originalData.imageId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
// update global state hanya sekali pas submit // update global state hanya sekali pas submit
state.update.form = { ...state.update.form, ...formData }; state.update.form = { ...state.update.form, ...formData };
@@ -91,17 +121,17 @@ function EditPerbekelDariMasaKeMasa() {
} catch (error) { } catch (error) {
console.error('Error updating perbekel dari masa ke masa:', error); console.error('Error updating perbekel dari masa ke masa:', error);
toast.error('Terjadi kesalahan saat memperbarui perbekel dari masa ke masa'); toast.error('Terjadi kesalahan saat memperbarui perbekel dari masa ke masa');
} finally {
setIsSubmitting(false);
} }
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<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 Perbekel Dari Masa Ke Masa Edit Perbekel Dari Masa Ke Masa
</Title> </Title>
@@ -138,7 +168,7 @@ function EditPerbekelDariMasaKeMasa() {
}} }}
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"
> >
@@ -157,25 +187,45 @@ function EditPerbekelDariMasaKeMasa() {
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"
radius="md" radius="md"
style={{ style={{
maxHeight: 220, maxHeight: 200,
objectFit: 'contain', objectFit: 'contain',
border: `1px solid ${colors['blue-button']}`, border: '1px solid #ddd',
}} }}
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>
@@ -197,6 +247,17 @@ function EditPerbekelDariMasaKeMasa() {
/> />
<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"
@@ -207,7 +268,7 @@ function EditPerbekelDariMasaKeMasa() {
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>

View File

@@ -2,7 +2,7 @@
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile'; import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text, Tooltip } from '@mantine/core'; import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconX } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
@@ -98,7 +98,6 @@ function DetailPerbekelDariMasa() {
</Box> </Box>
<Group gap="sm"> <Group gap="sm">
<Tooltip label="Hapus Perbekel" withArrow position="top">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
@@ -111,9 +110,7 @@ function DetailPerbekelDariMasa() {
> >
<IconX size={20} /> <IconX size={20} />
</Button> </Button>
</Tooltip>
<Tooltip label="Edit Perbekel" withArrow position="top">
<Button <Button
color="green" color="green"
onClick={() => router.push(`/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/${data.id}/edit`)} onClick={() => router.push(`/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/${data.id}/edit`)}
@@ -123,7 +120,6 @@ function DetailPerbekelDariMasa() {
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -2,7 +2,7 @@
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile'; import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Group, Image, Paper, Stack, Text, TextInput, Title, Tooltip } from '@mantine/core'; import { Loader, ActionIcon, Box, Button, Group, Image, Paper, Stack, Text, TextInput, Title } 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 { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -14,6 +14,7 @@ function CreatePerbekelDariMasaKeMasa() {
const state = useProxy(stateProfileDesa.mantanPerbekel); const state = useProxy(stateProfileDesa.mantanPerbekel);
const router = useRouter(); const router = useRouter();
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const resetForm = () => { const resetForm = () => {
@@ -28,33 +29,39 @@ function CreatePerbekelDariMasaKeMasa() {
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!file) { try {
return toast.warn('Pilih file gambar terlebih dahulu'); setIsSubmitting(true);
if (!file) {
return toast.warn('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 upload gambar');
state.create.form.imageId = uploaded.id;
await state.create.create();
resetForm();
router.push('/admin/desa/profile/profile-perbekel-dari-masa-ke-masa');
} catch (error) {
console.error(error);
toast.error('Gagal menambahkan perbekel dari masa ke masa');
} 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 upload gambar');
state.create.form.imageId = uploaded.id;
await state.create.create();
resetForm();
router.push('/admin/desa/profile/profile-perbekel-dari-masa-ke-masa');
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* 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">
Create Perbekel Dari Masa Ke Masa Create Perbekel Dari Masa Ke Masa
</Title> </Title>
@@ -72,21 +79,21 @@ function CreatePerbekelDariMasaKeMasa() {
<TextInput <TextInput
label="Nama Perbekel" label="Nama Perbekel"
placeholder="Masukkan nama perbekel" placeholder="Masukkan nama perbekel"
defaultValue={state.create.form.nama} value={state.create.form.nama}
onChange={(e) => (state.create.form.nama = e.target.value)} onChange={(e) => (state.create.form.nama = e.target.value)}
required required
/> />
<TextInput <TextInput
label="Daerah" label="Daerah"
placeholder="Masukkan daerah" placeholder="Masukkan daerah"
defaultValue={state.create.form.daerah} value={state.create.form.daerah}
onChange={(e) => (state.create.form.daerah = e.target.value)} onChange={(e) => (state.create.form.daerah = e.target.value)}
required required
/> />
<TextInput <TextInput
label="Periode" label="Periode"
placeholder="Masukkan periode" placeholder="Masukkan periode"
defaultValue={state.create.form.periode} value={state.create.form.periode}
onChange={(e) => (state.create.form.periode = e.target.value)} onChange={(e) => (state.create.form.periode = e.target.value)}
required required
/> />
@@ -104,7 +111,7 @@ function CreatePerbekelDariMasaKeMasa() {
}} }}
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"
> >
@@ -120,25 +127,63 @@ function CreatePerbekelDariMasaKeMasa() {
</Dropzone.Idle> </Dropzone.Idle>
</Group> </Group>
<Text ta="center" mt="sm" size="sm" color="dimmed"> <Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB) Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text> </Text>
</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"
radius="md" radius="md"
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }} style={{
loading='lazy' 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>
)} )}
</Box> </Box>
{/* Submit */} {/* 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"
@@ -149,7 +194,7 @@ function CreatePerbekelDariMasaKeMasa() {
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>

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title, Tooltip } from '@mantine/core'; import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react'; import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -49,7 +49,6 @@ function ListPerbekelDariMasaKeMasa({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify='space-between' mb="md"> <Group justify='space-between' mb="md">
<Title order={4}>List Perbekel Dari Masa Ke Masa</Title> <Title order={4}>List Perbekel Dari Masa Ke Masa</Title>
<Tooltip label="Tambah Perbekel Baru" withArrow>
<Button <Button
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
@@ -58,7 +57,6 @@ function ListPerbekelDariMasaKeMasa({ search }: { search: string }) {
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Tooltip>
</Group> </Group>
<Box style={{ overflowX: "auto" }}> <Box style={{ overflowX: "auto" }}>

View File

@@ -4,9 +4,9 @@ import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile'; import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Center, Group, Image, Paper, Stack, Text, Title, Tooltip } from '@mantine/core'; import { Box, Button, Center, Group, Image, Paper, Stack, Text, Title } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX, IconImageInPicture } from '@tabler/icons-react'; import { IconArrowBack, IconImageInPicture, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -101,11 +101,9 @@ function ProfilePerbekel() {
<Stack gap="xs"> <Stack gap="xs">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={handleBack} p="xs" radius="md"> <Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Profil Perbekel Edit Profil Perbekel
</Title> </Title>
@@ -128,8 +126,8 @@ function ProfilePerbekel() {
<Dropzone <Dropzone
onDrop={(files) => handleFileChange(files[0])} onDrop={(files) => handleFileChange(files[0])}
onReject={() => toast.error('File tidak valid.')} onReject={() => toast.error('File tidak valid.')}
maxSize={5 * 1024 ** 2} // 5MB maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }} accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
> >
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}> <Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}>
<Dropzone.Accept><IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} /></Dropzone.Accept> <Dropzone.Accept><IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} /></Dropzone.Accept>

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Divider, Grid, GridCol, Image, Paper, Skeleton, Stack, Text, Title, Tooltip } from '@mantine/core'; import { Box, Button, Center, Divider, Grid, GridCol, Image, Paper, Skeleton, Stack, Text, Title } from '@mantine/core';
import { IconEdit } from '@tabler/icons-react'; import { IconEdit } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect } from 'react'; import { useEffect } from 'react';
@@ -36,7 +36,6 @@ function Page() {
<Title order={3} c={colors['blue-button']}>Preview Profil PPID</Title> <Title order={3} c={colors['blue-button']}>Preview Profil PPID</Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Tooltip label="Edit Profil Perbekel" withArrow>
<Button <Button
c="green" c="green"
variant="light" variant="light"
@@ -46,7 +45,6 @@ function Page() {
> >
Edit Edit
</Button> </Button>
</Tooltip>
</GridCol> </GridCol>
</Grid> </Grid>

View File

@@ -2,23 +2,22 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
ScrollArea,
Stack, Stack,
Tabs, Tabs,
TabsList, TabsList,
TabsPanel, TabsPanel,
TabsTab, TabsTab,
Title, Title
Tooltip,
ScrollArea,
} from '@mantine/core'; } from '@mantine/core';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import { import {
IconFileAnalytics,
IconCoins, IconCoins,
IconFileAnalytics,
IconShoppingCart, IconShoppingCart,
IconWallet, IconWallet,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
function LayoutTabs({ children }: { children: React.ReactNode }) { function LayoutTabs({ children }: { children: React.ReactNode }) {
const router = useRouter(); const router = useRouter();
@@ -29,29 +28,25 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
label: "APB Desa", label: "APB Desa",
value: "apbdesa", value: "apbdesa",
href: "/admin/ekonomi/PADesa-pendapatan-asli-desa/apbdesa", href: "/admin/ekonomi/PADesa-pendapatan-asli-desa/apbdesa",
icon: <IconFileAnalytics size={18} stroke={1.8} />, icon: <IconFileAnalytics size={18} stroke={1.8} />
tooltip: "Lihat ringkasan Anggaran Pendapatan dan Belanja Desa",
}, },
{ {
label: "Pendapatan", label: "Pendapatan",
value: "pendapatan", value: "pendapatan",
href: "/admin/ekonomi/PADesa-pendapatan-asli-desa/pendapatan", href: "/admin/ekonomi/PADesa-pendapatan-asli-desa/pendapatan",
icon: <IconCoins size={18} stroke={1.8} />, icon: <IconCoins size={18} stroke={1.8} />,
tooltip: "Kelola data pendapatan desa",
}, },
{ {
label: "Belanja", label: "Belanja",
value: "belanja", value: "belanja",
href: "/admin/ekonomi/PADesa-pendapatan-asli-desa/belanja", href: "/admin/ekonomi/PADesa-pendapatan-asli-desa/belanja",
icon: <IconShoppingCart size={18} stroke={1.8} />, icon: <IconShoppingCart size={18} stroke={1.8} />,
tooltip: "Atur data belanja desa",
}, },
{ {
label: "Pembiayaan", label: "Pembiayaan",
value: "pembiayaan", value: "pembiayaan",
href: "/admin/ekonomi/PADesa-pendapatan-asli-desa/pembiayaan", href: "/admin/ekonomi/PADesa-pendapatan-asli-desa/pembiayaan",
icon: <IconWallet size={18} stroke={1.8} />, icon: <IconWallet size={18} stroke={1.8} />,
tooltip: "Kelola data pembiayaan desa",
}, },
]; ];
@@ -104,26 +99,19 @@ function LayoutTabs({ 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",
flexShrink: 0,
}}
> >
<TabsTab {tab.label}
value={tab.value} </TabsTab>
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
flexShrink: 0,
}}
>
{tab.label}
</TabsTab>
</Tooltip>
))} ))}
</TabsList> </TabsList>
</ScrollArea> </ScrollArea>

View File

@@ -1,11 +1,14 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client';
import PendapatanAsliDesa from '@/app/admin/(dashboard)/_state/ekonomi/PADesa'; import PendapatanAsliDesa from '@/app/admin/(dashboard)/_state/ekonomi/PADesa';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
Alert,
Box, Box,
Button, Button,
Group,
Loader,
MultiSelect, MultiSelect,
Paper, Paper,
Skeleton, Skeleton,
@@ -13,8 +16,6 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
Tooltip,
Group,
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
@@ -23,81 +24,132 @@ import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
// ==================== HELPERS ====================
const safeStringArray = (arr: any[]): string[] => {
if (!Array.isArray(arr)) return [];
return arr
.filter(item => item != null && item !== '')
.map(item => String(item))
.filter(item => item.trim() !== '');
};
const createEmptyForm = () => ({
tahun: '',
pendapatanIds: [] as string[],
belanjaIds: [] as string[],
pembiayaanIds: [] as string[],
});
// ==================== COMPONENT ====================
function EditAPBDesa() { function EditAPBDesa() {
const apbState = useProxy(PendapatanAsliDesa.ApbDesa); const apbState = useProxy(PendapatanAsliDesa.ApbDesa);
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState(createEmptyForm());
tahun: '', const [originalData, setOriginalData] = useState(createEmptyForm());
pendapatanIds: [] as string[], const [isSubmitting, setIsSubmitting] = useState(false);
belanjaIds: [] as string[], const [isLoading, setIsLoading] = useState(true);
pembiayaanIds: [] as string[],
});
// Load APB desa by id → hanya update formData, bukan global state // ==================== LOAD DATA ====================
useEffect(() => { useEffect(() => {
const loadAPBdesa = async () => { const loadAPBdesa = async () => {
const id = params?.id as string; const id = params?.id as string;
if (!id) return; if (!id) {
toast.error('ID tidak valid');
router.push('/admin/ekonomi/PADesa-pendapatan-asli-desa/apbdesa');
return;
}
try { try {
setIsLoading(true);
const data = await apbState.update.load(id); const data = await apbState.update.load(id);
if (data) {
setFormData({ if (!data) {
tahun: String(data.tahun || ''), toast.error('Data APB Desa tidak ditemukan');
pendapatanIds: data.pendapatan?.map((p: any) => p.id) || [], return;
belanjaIds: data.belanja?.map((b: any) => b.id) || [],
pembiayaanIds: data.pembiayaan?.map((p: any) => p.id) || [],
});
} }
} catch (error) {
console.error("Error loading APBdesa:", error); const normalized = {
toast.error("Gagal memuat data APBdesa"); tahun: String(data.tahun || ''),
pendapatanIds: safeStringArray(data.pendapatan?.map((p: any) => p.id) || []),
belanjaIds: safeStringArray(data.belanja?.map((b: any) => b.id) || []),
pembiayaanIds: safeStringArray(data.pembiayaan?.map((p: any) => p.id) || []),
};
setFormData(normalized);
setOriginalData(normalized);
} catch (err) {
console.error('Error loading APBdesa:', err);
toast.error('Gagal memuat data APB Desa');
} finally {
setIsLoading(false);
} }
}; };
loadAPBdesa(); loadAPBdesa();
}, [params?.id]); }, [params?.id]);
// ==================== HANDLERS ====================
const handleChange = (field: keyof typeof formData, value: any) => { const handleChange = (field: keyof typeof formData, value: any) => {
setFormData(prev => ({ ...prev, [field]: value })); setFormData(prev => ({ ...prev, [field]: value }));
}; };
const handleResetForm = () => {
setFormData(originalData);
toast.info('Form dikembalikan ke data awal');
};
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
// update global state cuma pas submit setIsSubmitting(true);
if (!formData.tahun.trim()) {
toast.warning('Tahun harus diisi');
return;
}
apbState.update.form = { apbState.update.form = {
...apbState.update.form, ...apbState.update.form,
tahun: Number(formData.tahun), tahun: Number(formData.tahun),
pendapatanIds: formData.pendapatanIds, pendapatanIds: safeStringArray(formData.pendapatanIds),
belanjaIds: formData.belanjaIds, belanjaIds: safeStringArray(formData.belanjaIds),
pembiayaanIds: formData.pembiayaanIds, pembiayaanIds: safeStringArray(formData.pembiayaanIds),
}; };
await apbState.update.update(); await apbState.update.update();
toast.success("APB Desa berhasil diperbarui!"); toast.success('APB Desa berhasil diperbarui!');
router.push("/admin/ekonomi/PADesa-pendapatan-asli-desa/apbdesa"); router.push('/admin/ekonomi/PADesa-pendapatan-asli-desa/apbdesa');
} catch (error) { } catch (error) {
console.error("Error updating APBdesa:", error); console.error('Error updating APBdesa:', error);
toast.error("Terjadi kesalahan saat memperbarui APBdesa"); toast.error('Terjadi kesalahan saat memperbarui APB Desa');
} finally {
setIsSubmitting(false);
} }
}; };
// ==================== UI ====================
if (isLoading) {
return (
<Stack align="center" py="xl">
<Loader size="lg" type="dots" />
<Text c="dimmed">Memuat data APB Desa...</Text>
</Stack>
);
}
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* 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 APB Desa Edit APB Desa
</Title> </Title>
@@ -117,30 +169,46 @@ function EditAPBDesa() {
<TextInput <TextInput
type="number" type="number"
value={formData.tahun} value={formData.tahun}
onChange={(e) => handleChange("tahun", e.target.value)} onChange={(e) => handleChange('tahun', e.target.value)}
label={<Text fz="sm" fw="bold">Tahun</Text>} label={<Text fz="sm" fw="bold">Tahun</Text>}
placeholder="Masukkan tahun anggaran" placeholder="Masukkan tahun anggaran"
required required
/> />
{/* Selects */} {/* Selects */}
<SelectPendapatan <SelectAPBItem
label="Pendapatan"
state={PendapatanAsliDesa.pendapatan}
selectedIds={formData.pendapatanIds} selectedIds={formData.pendapatanIds}
onSelectionChange={(ids) => handleChange("pendapatanIds", ids)} onSelectionChange={(ids) => handleChange('pendapatanIds', ids)}
/> />
<SelectBelanja <SelectAPBItem
label="Belanja"
state={PendapatanAsliDesa.belanja}
selectedIds={formData.belanjaIds} selectedIds={formData.belanjaIds}
onSelectionChange={(ids) => handleChange("belanjaIds", ids)} onSelectionChange={(ids) => handleChange('belanjaIds', ids)}
/> />
<SelectPembiayaan <SelectAPBItem
label="Pembiayaan"
state={PendapatanAsliDesa.pembiayaan}
selectedIds={formData.pembiayaanIds} selectedIds={formData.pembiayaanIds}
onSelectionChange={(ids) => handleChange("pembiayaanIds", ids)} onSelectionChange={(ids) => handleChange('pembiayaanIds', ids)}
/> />
{/* Save Button */} {/* Save Button */}
<Group justify="right"> <Group justify="right" mt="md">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -151,117 +219,75 @@ function EditAPBDesa() {
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>
</Paper> </Paper>
</Box> </Box>
); );
}
/* --- Sub Components --- */ // ==================== SUB COMPONENT ====================
function SelectAPBItem({
label,
state,
selectedIds,
onSelectionChange,
}: {
label: string;
state: any;
selectedIds: string[];
onSelectionChange: (ids: string[]) => void;
}) {
const proxyState = useProxy(state);
function SelectPendapatan({ useShallowEffect(() => {
selectedIds, proxyState.findMany.load();
onSelectionChange, }, []);
}: {
selectedIds: string[];
onSelectionChange: (ids: string[]) => void;
}) {
const pendapatanState = useProxy(PendapatanAsliDesa.pendapatan);
useShallowEffect(() => { const data = proxyState.findMany.data;
pendapatanState.findMany.load(); const isLoading = !data;
}, []);
if (!pendapatanState.findMany.data) { const options =
return <Skeleton height={38} />; data
} ?.filter((item: any) => item?.id)
.map((item: any) => ({
value: String(item.id),
label: String(item?.name || '(Tanpa Nama)'),
})) || [];
if (isLoading) {
return ( return (
<MultiSelect <Box>
label={<Text fz="sm" fw="bold">Pendapatan</Text>} <Text fz="sm" fw="bold" mb={4}>{label}</Text>
data={pendapatanState.findMany.data.map((p: any) => ({ <Skeleton height={38} radius="sm" />
value: p.id, </Box>
label: p.name,
}))}
value={selectedIds}
onChange={onSelectionChange}
searchable
clearable
placeholder="Pilih pendapatan..."
nothingFoundMessage="Tidak ditemukan"
/>
); );
} }
function SelectBelanja({ if (options.length === 0) {
selectedIds,
onSelectionChange,
}: {
selectedIds: string[];
onSelectionChange: (ids: string[]) => void;
}) {
const belanjaState = useProxy(PendapatanAsliDesa.belanja);
useShallowEffect(() => {
belanjaState.findMany.load();
}, []);
if (!belanjaState.findMany.data) {
return <Skeleton height={38} />;
}
return ( return (
<MultiSelect <Alert color="gray" variant="light">
label={<Text fz="sm" fw="bold">Belanja</Text>} <Text size="sm">
data={belanjaState.findMany.data.map((b: any) => ({ Tidak ada data {label.toLowerCase()} tersedia.
value: b.id, </Text>
label: b.name, </Alert>
}))}
value={selectedIds}
onChange={onSelectionChange}
searchable
clearable
placeholder="Pilih belanja..."
nothingFoundMessage="Tidak ditemukan"
/>
); );
} }
function SelectPembiayaan({ return (
selectedIds, <MultiSelect
onSelectionChange, label={<Text fz="sm" fw="bold">{label}</Text>}
}: { data={options}
selectedIds: string[]; value={selectedIds}
onSelectionChange: (ids: string[]) => void; onChange={(ids) => onSelectionChange(safeStringArray(ids))}
}) { searchable
const pembiayaanState = useProxy(PendapatanAsliDesa.pembiayaan); clearable
placeholder={`Pilih ${label.toLowerCase()}...`}
useShallowEffect(() => { nothingFoundMessage="Tidak ditemukan"
pembiayaanState.findMany.load(); />
}, []); );
if (!pembiayaanState.findMany.data) {
return <Skeleton height={38} />;
}
return (
<MultiSelect
label={<Text fz="sm" fw="bold">Pembiayaan</Text>}
data={pembiayaanState.findMany.data.map((p: any) => ({
value: p.id,
label: p.name,
}))}
value={selectedIds}
onChange={onSelectionChange}
searchable
clearable
placeholder="Pilih pembiayaan..."
nothingFoundMessage="Tidak ditemukan"
/>
);
}
} }
export default EditAPBDesa; export default EditAPBDesa;

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