Compare commits

..

1 Commits

627 changed files with 11330 additions and 20553 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -3,9 +3,9 @@
"version": "0.1.5",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
"dev": "bun --bun next dev",
"build": "bun --bun next build",
"start": "bun --bun next start"
},
"prisma": {
"seed": "bun run prisma/seed.ts"
@@ -19,7 +19,6 @@
"@elysiajs/static": "^1.3.0",
"@elysiajs/stream": "^1.1.0",
"@elysiajs/swagger": "^1.2.0",
"@emotion/react": "^11.14.0",
"@mantine/carousel": "^7.16.2",
"@mantine/charts": "^7.17.1",
"@mantine/core": "^7.17.4",
@@ -27,7 +26,6 @@
"@mantine/dropzone": "^8.1.1",
"@mantine/form": "^8.1.0",
"@mantine/hooks": "^7.17.4",
"@mantine/modals": "^8.3.6",
"@mantine/tiptap": "^7.17.4",
"@paljs/types": "^8.1.0",
"@prisma/client": "^6.3.1",
@@ -57,9 +55,8 @@
"dayjs": "^1.11.13",
"dotenv": "^17.2.3",
"elysia": "^1.3.5",
"embla-carousel": "^8.6.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"embla-carousel-autoplay": "^8.5.2",
"embla-carousel-react": "^7.1.0",
"extract-zip": "^2.0.1",
"form-data": "^4.0.2",
"framer-motion": "^12.23.5",
@@ -83,7 +80,6 @@
"prisma": "^6.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-exif-orientation-img": "^0.1.5",
"react-international-phone": "^4.6.0",
"react-leaflet": "^5.0.0",
"react-simple-toasts": "^6.1.0",

View File

@@ -7,7 +7,6 @@ import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import Superscript from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript';
import { useEffect } from 'react';
type CreateEditorProps = {
value: string;
@@ -33,13 +32,6 @@ export default function CreateEditor({ value, onChange }: CreateEditorProps) {
},
});
// 👇 Tambahkan efek untuk sinkronisasi value dari luar (resetForm)
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value || '');
}
}, [value, editor]);
return (
<RichTextEditor editor={editor}>
<RichTextEditor.Toolbar sticky stickyOffset="var(--docs-header-height)">

View File

@@ -47,7 +47,6 @@ export default function EditEditor({ value, onChange }: EditEditorProps) {
editor.off('update', updateHandler);
};
}, [editor, onChange]);
return (
<RichTextEditor editor={editor}>

View File

@@ -1,6 +1,6 @@
'use client';
'use client'
import { Box, Group, rem, Select, SelectProps } from '@mantine/core';
import { Box, rem, Select } from '@mantine/core';
import {
IconAmbulance,
IconCash,
@@ -25,7 +25,7 @@ import {
IconTrophy,
IconTruckFilled,
IconBuilding,
IconAlertTriangle,
IconAlertTriangle
} from '@tabler/icons-react';
const iconMap = {
@@ -38,26 +38,26 @@ const iconMap = {
scale: { label: 'Scale', icon: IconScale },
clipboard: { label: 'Clipboard', icon: IconClipboardTextFilled },
trash: { label: 'Trash', icon: IconTrashFilled },
lingkunganSehat: { label: 'Lingkungan Sehat', icon: IconHomeEco },
sumberOksigen: { label: 'Sumber Oksigen', icon: IconChristmasTreeFilled },
ekonomiBerkelanjutan: { label: 'Ekonomi Berkelanjutan', icon: IconTrendingUp },
mencegahBencana: { label: 'Mencegah Bencana', icon: IconShieldFilled },
rumah: { label: 'Rumah', icon: IconHome },
pohon: { label: 'Pohon', icon: IconTree },
air: { label: 'Air', icon: IconDroplet },
bantuan: { label: 'Bantuan', icon: IconCash },
pelatihan: { label: 'Pelatihan', icon: IconSchool },
subsidi: { label: 'Subsidi', icon: IconShoppingCart },
layananKesehatan: { label: 'Layanan Kesehatan', icon: IconHospital },
polisi: { label: 'Polisi', icon: IconShieldFilled },
ambulans: { label: 'Ambulans', icon: IconAmbulance },
pemadam: { label: 'Pemadam', icon: IconFiretruck },
rumahSakit: { label: 'Rumah Sakit', icon: IconHospital },
bangunan: { label: 'Bangunan', icon: IconBuilding },
darurat: { label: 'Darurat', icon: IconAlertTriangle },
lingkunganSehat: {label: 'Lingkungan Sehat', icon: IconHomeEco},
sumberOksigen: {label: 'Sumber Oksigen', icon: IconChristmasTreeFilled},
ekonomiBerkelanjutan: {label: 'Ekonomi Berkelanjutan', icon: IconTrendingUp},
mencegahBencana: {label: 'Mencegah Bencana', icon: IconShieldFilled},
rumah: {label: 'Rumah', icon: IconHome},
pohon: {label: 'Pohon', icon: IconTree},
air: {label: 'Air', icon: IconDroplet},
bantuan: {label: 'Bantuan', icon: IconCash},
pelatihan: {label: 'Pelatihan', icon: IconSchool},
subsidi: {label: 'Subsidi', icon: IconShoppingCart},
layananKesehatan: {label: 'Layanan Kesehatan', icon: IconHospital},
polisi: {label: 'Polisi', icon: IconShieldFilled},
ambulans: {label: 'Ambulans', icon: IconAmbulance},
pemadam: {label: 'Pemadam', icon: IconFiretruck},
rumahSakit: {label: 'Rumah Sakit', icon: IconHospital},
bangunan: {label: 'Bangunan', icon: IconBuilding},
darurat: {label: 'Darurat', icon: IconAlertTriangle},
};
export type IconKey = keyof typeof iconMap;
type IconKey = keyof typeof iconMap;
const iconList = Object.entries(iconMap).map(([value, data]) => ({
value,
@@ -67,52 +67,44 @@ const iconList = Object.entries(iconMap).map(([value, data]) => ({
export default function SelectIconProgramEdit({
onChange,
value,
...props
}: {
onChange: (value: IconKey | '') => void;
value: IconKey | '';
} & Omit<SelectProps, 'onChange' | 'value' | 'data'>) {
onChange: (value: IconKey) => void;
value: IconKey;
}) {
const IconComponent = iconMap[value]?.icon || null;
return (
<Box maw={300}>
<Select
placeholder="Pilih ikon"
value={value || ''}
onChange={(val: string | null) => {
if (val) {
onChange(val as IconKey);
} else {
onChange('');
}
value={value}
onChange={(value) => {
if (value) onChange(value as IconKey);
}}
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={
value && iconMap[value as IconKey] ? (
<Box ml={-4}>
{(() => {
const Icon = iconMap[value as IconKey].icon;
return <Icon size={20} stroke={1.5} />;
})()}
IconComponent && (
<Box>
<IconComponent size={24} stroke={1.5} />
</Box>
) : null
)
}
searchable
withCheckIcon={false}
searchable={false}
rightSectionWidth={0}
styles={{
input: {
paddingLeft: 40,
textAlign: 'left',
fontSize: rem(16),
paddingLeft: 40,
},
section: {
left: 10,
right: 'auto',
},
}}
{...props}
/>
</Box>
);
}
}

View File

@@ -39,7 +39,7 @@ const penghargaanState = proxy({
);
if (res.status === 200) {
penghargaanState.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
console.log(res);
return toast.error("failed create");

View File

@@ -287,7 +287,7 @@ const pengumuman = proxy({
);
if (res.status === 200) {
pengumuman.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
console.log(res);
return toast.error("failed create");

View File

@@ -101,38 +101,6 @@ const ApbDesa = proxy({
}
},
},
findFirst: {
data: null as Prisma.ApbDesaGetPayload<{
include: { pendapatan: true; belanja: true; pembiayaan: true };
}> | null,
loading: false,
async load(params?: Record<string, any>) {
try {
this.loading = true;
// ✅ request ke endpoint find-first
const res = await ApiFetch.api.ekonomi.pendapatanaslidesa.apbdesa[
"find-first"
].get({ query: params || {} });
if (res.status === 200 && res.data?.success) {
this.data = res.data.data ?? null;
} else {
this.data = null;
toast.error(res.data?.message || "Gagal memuat data pertama APB Desa");
}
} catch (error) {
console.error("Error findFirst APB Desa:", error);
toast.error("Gagal memuat data APB Desa pertama");
this.data = null;
} finally {
this.loading = false;
}
},
reset() {
this.data = null;
},
},
update: {
id: "",
form: { ...ApbDesaDefaultForm },

View File

@@ -49,7 +49,7 @@ const demografiPekerjaan = proxy({
if (res.status === 200) {
const id = res.data?.data?.id;
if (id) {
toast.success("Sukses menambahkan");
toast.success("Success create");
demografiPekerjaan.create.form = { ...defaultForm };
demografiPekerjaan.findMany.load();
return id;

View File

@@ -47,7 +47,7 @@ const jumlahPendudukMiskin = proxy({
if (res.status === 200) {
const id = res.data?.data?.id;
if (id) {
toast.success("Sukses menambahkan");
toast.success("Success create");
jumlahPendudukMiskin.create.form = {
year: 0,
totalPoorPopulation: 0,

View File

@@ -89,7 +89,7 @@ const jumlahPengangguran = proxy({
if (res.status === 200) {
const id = res.data?.id;
if (id) {
toast.success("Sukses menambahkan");
toast.success("Success create");
jumlahPengangguran.create.form = { ...jumlahPengangguranForm };
jumlahPengangguran.findMany.load();
return id;

View File

@@ -47,7 +47,7 @@ const lowonganKerjaState = proxy({
);
if (res.status === 200) {
lowonganKerjaState.create.loading = false;
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
console.log(res);
return toast.error("failed create");

View File

@@ -45,7 +45,7 @@ const programKemiskinanState = proxy({
);
if (res.status === 200) {
programKemiskinanState.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
console.log(res);
return toast.error("failed create");

View File

@@ -46,7 +46,7 @@ const grafikSektorUnggulan = proxy({
if (res.status === 200) {
const id = res.data?.data?.id;
if (id) {
toast.success("Sukses menambahkan");
toast.success("Success create");
grafikSektorUnggulan.create.form = {
name: "",
description: "",

View File

@@ -51,7 +51,7 @@ const grafikBerdasarkanUsiaKerjaNganggur = proxy({
if (res.status === 200) {
const id = res.data?.data?.id;
if (id) {
toast.success("Sukses menambahkan");
toast.success("Success create");
grafikBerdasarkanUsiaKerjaNganggur.create.form = {
usia18_25: "",
usia26_35: "",
@@ -255,7 +255,7 @@ const grafikBerdasarkanPendidikan = proxy({
if (res.status === 200) {
const id = res.data?.data?.id;
if (id) {
toast.success("Sukses menambahkan");
toast.success("Success create");
grafikBerdasarkanPendidikan.create.form = {
SD: "",
SMP: "",

View File

@@ -37,7 +37,7 @@ const desaDigitalState = proxy({
);
if (res.status === 200) {
desaDigitalState.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
console.log(res);
return toast.error("failed create");

View File

@@ -37,7 +37,7 @@ const infoTeknoState = proxy({
);
if (res.status === 200) {
infoTeknoState.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
console.log(res);
return toast.error("failed create");

View File

@@ -6,9 +6,9 @@ import { proxy } from "valtio";
import { z } from "zod";
const templateForm = z.object({
name: z.string().min(5, "Nama minimal 5 karakter"),
deskripsi: z.string().min(5, "Deskripsi minimal 5 karakter"),
slug: z.string().min(5, "Deskripsi singkat minimal 5 karakter"),
name: z.string().min(1, "Nama minimal 1 karakter"),
deskripsi: z.string().min(1, "Deskripsi minimal 1 karakter"),
slug: z.string().min(1, "Deskripsi singkat minimal 1 karakter"),
icon: z.string().min(1, "Icon minimal 1 karakter"),
});
@@ -29,33 +29,26 @@ const programKreatifState = proxy({
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
toast.error(err);
return false; // ⬅️ ini penting
return toast.error(err);
}
try {
programKreatifState.create.loading = true;
const res = await ApiFetch.api.inovasi.programkreatif["create"].post(
programKreatifState.create.form
);
if (res.status === 200) {
programKreatifState.findMany.load();
toast.success("Sukses menambahkan");
return true;
return toast.success("success create");
}
toast.error("failed create");
return false;
console.log(res);
return toast.error("failed create");
} catch (error) {
console.error((error as Error).message);
toast.error("Terjadi kesalahan saat create");
return false;
console.log((error as Error).message);
} finally {
programKreatifState.create.loading = false;
}
}
},
},
findMany: {
data: null as any[] | null,

View File

@@ -37,7 +37,7 @@ const keamananLingkunganState = proxy({
].post(keamananLingkunganState.create.form);
if (res.status === 200) {
keamananLingkunganState.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
console.log(res);
return toast.error("failed create");

View File

@@ -38,7 +38,7 @@ const kontakDaruratKeamananState = proxy({
].post(kontakDaruratKeamananState.create.form);
if (res.status === 200) {
kontakDaruratKeamananState.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
console.log(res);
return toast.error("failed create");
@@ -294,7 +294,7 @@ const kontakDaruratItem = proxy({
);
if (res.status === 200) {
kontakDaruratItem.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
console.log(res);
return toast.error("failed create");

View File

@@ -88,7 +88,7 @@ const laporanPublikState = proxy({
if (res.status === 200) {
laporanPublikState.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
console.log(res);

View File

@@ -40,7 +40,7 @@ const pencegahanKriminalitasState = proxy({
].post(pencegahanKriminalitasState.create.form);
if (res.status === 200) {
pencegahanKriminalitasState.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
console.log(res);
return toast.error("failed create");

View File

@@ -37,7 +37,7 @@ const tipsKeamananState = proxy({
);
if (res.status === 200) {
tipsKeamananState.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
console.log(res);
return toast.error("failed create");

View File

@@ -351,7 +351,7 @@ const dokter = proxy({
if (res.status === 200) {
const id = res.data?.data;
if (id) {
toast.success("Sukses menambahkan");
toast.success("Success create");
dokter.create.create.form = { ...defaultDokterForm };
dokter.findMany.load();
return id;

View File

@@ -43,7 +43,7 @@ const grafikkepuasan = proxy({
if (res.status === 200) {
const id = res.data?.data;
if (id) {
toast.success("Sukses menambahkan");
toast.success("Success create");
grafikkepuasan.create.form = { ...defaultForm };
grafikkepuasan.findMany.load();
return id;

View File

@@ -50,7 +50,7 @@ const persentasekelahiran = proxy({
if (res.status === 200) {
const id = res.data?.data;
if (id) {
toast.success("Sukses menambahkan");
toast.success("Success create");
persentasekelahiran.create.form = { ...defaultForm };
persentasekelahiran.findMany.load();
return id;

View File

@@ -53,7 +53,7 @@ const programInovasi = proxy({
].post(formData);
if (res.status === 200) {
programInovasi.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
console.log(res);
return toast.error("failed create");
@@ -474,7 +474,7 @@ const mediaSosial = proxy({
);
if (res.status === 200) {
mediaSosial.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
console.log(res);
return toast.error("failed create");

View File

@@ -93,34 +93,6 @@ const sdgsDesa = proxy({
}
},
},
findManyAll: {
data: null as any[] | null,
loading: false,
load: async () => { // Change to arrow function
sdgsDesa.findManyAll.loading = true; // Use the full path to access the property
try {
const query: any = {};
const res = await ApiFetch.api.landingpage.sdgsdesa[
"findManyAll"
].get({
query,
});
if (res.status === 200 && res.data?.success) {
sdgsDesa.findManyAll.data = res.data.data || [];
} else {
console.error("Failed to load media sosial:", res.data?.message);
sdgsDesa.findManyAll.data = [];
}
} catch (error) {
console.error("Error loading media sosial:", error);
sdgsDesa.findManyAll.data = [];
} finally {
sdgsDesa.findManyAll.loading = false;
}
},
},
findUnique: {
data: null as Prisma.SdgsDesaGetPayload<{
include: {

View File

@@ -39,7 +39,7 @@ const dataLingkunganDesaState = proxy({
);
if (res.status === 200) {
dataLingkunganDesaState.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
console.log(res);
return toast.error("failed create");

View File

@@ -35,7 +35,7 @@ const pengelolaanSampah = proxy({
].post(pengelolaanSampah.create.form);
if (res.status === 200) {
pengelolaanSampah.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
console.log(res);
return toast.error("failed create");

View File

@@ -39,7 +39,7 @@ const programPenghijauanState = proxy({
);
if (res.status === 200) {
programPenghijauanState.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
console.log(res);
return toast.error("failed create");

View File

@@ -42,7 +42,7 @@ const dataPendidikan = proxy({
if (res.status === 200) {
const id = res.data?.data?.id;
if (id) {
toast.success("Sukses menambahkan");
toast.success("Success create");
dataPendidikan.create.form = {
name: "",
jumlah: "",

View File

@@ -38,7 +38,7 @@ const daftarInformasiPublik = proxy({
].post(daftarInformasiPublik.create.form);
if (res.status === 200) {
daftarInformasiPublik.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
return toast.error("failed create");
} catch (error) {

View File

@@ -41,7 +41,7 @@ const grafikBerdasarkanUmur = proxy({
if (res.status === 200) {
const id = res.data?.data?.id;
if (id) {
toast.success("Sukses menambahkan");
toast.success("Success create");
grafikBerdasarkanUmur.create.form = {
remaja: "",
dewasa: "",

View File

@@ -88,7 +88,7 @@ const statepermohonanInformasiPublik = proxy({
const res = await ApiFetch.api.ppid.permohonaninformasipublik["create"].post(statepermohonanInformasiPublik.create.form);
if (res.status === 200) {
statepermohonanInformasiPublik.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
return toast.error("failed create");
} catch (error) {

View File

@@ -37,7 +37,7 @@ const permohonanKeberatanInformasi = proxy({
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["create"].post(permohonanKeberatanInformasi.create.form);
if (res.status === 200) {
permohonanKeberatanInformasi.findMany.load();
return toast.success("Sukses menambahkan");
return toast.success("success create");
}
return toast.error("failed create");
} catch (error) {

View File

@@ -3,6 +3,9 @@ import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
/**
* Schema validasi form ProfilePPID menggunakan Zod.
*/
const templateForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"),
biodata: z.string().min(3, "Biodata minimal 3 karakter"),
@@ -30,16 +33,25 @@ type ProfilePPIDForm = Prisma.ProfilePPIDGetPayload<{
pengalaman: true;
unggulan: true;
imageId: true;
image?: { select: { link: true } };
image?: {
select: {
link: true;
};
};
};
}>;
/**
* Improved State Management - Consolidated and more robust
*/
const stateProfilePPID = proxy({
// Consolidated data management
profile: {
data: null as ProfilePPIDForm | null,
loading: false,
error: null as string | null,
// Single method to load profile data
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
@@ -50,42 +62,52 @@ const stateProfilePPID = proxy({
this.error = null;
try {
const res = await fetch(`/api/ppid/profileppid/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const response = await fetch(`/api/ppid/profileppid/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await res.json();
const result = await response.json();
if (result.success) {
this.data = result.data;
return result.data;
} else throw new Error(result.message || "Gagal memuat data profile");
} catch (err) {
const msg = (err as Error).message;
this.error = msg;
console.error("Load profile error:", msg);
toast.error("Gagal memuat data profile");
} else {
throw new Error(result.message || "Gagal mengambil data profile");
}
} catch (error) {
const errorMessage = (error as Error).message;
this.error = errorMessage;
console.error("Load profile error:", errorMessage);
toast.error("Terjadi kesalahan saat mengambil data profile");
return null;
} finally {
this.loading = false;
}
},
// Reset profile data
reset() {
this.data = null;
this.error = null;
this.loading = false;
},
}
},
// Edit form management
editForm: {
id: "",
form: { ...defaultForm },
originalForm: { ...defaultForm }, // ✅ Tambah field originalForm
loading: false,
error: null as string | null,
isReadOnly: false, // Flag untuk data yang tidak bisa diedit
// Initialize form with profile data
initialize(profileData: ProfilePPIDForm) {
this.id = profileData.id;
const data = {
this.isReadOnly = false; // Semua data bisa diedit
this.form = {
name: profileData.name || "",
biodata: profileData.biodata || "",
riwayat: profileData.riwayat || "",
@@ -93,20 +115,23 @@ const stateProfilePPID = proxy({
unggulan: profileData.unggulan || "",
imageId: profileData.imageId || "",
};
this.form = { ...data };
this.originalForm = { ...data }; // ✅ Simpan versi original
},
// Update form field
updateField(field: keyof typeof defaultForm, value: string) {
this.form[field] = value;
},
// Submit form
async submit() {
const check = templateForm.safeParse(this.form);
if (!check.success) {
toast.error(
check.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")
);
// Validate form
const validation = templateForm.safeParse(this.form);
if (!validation.success) {
const errors = validation.error.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join(", ");
toast.error(`Form tidak valid: ${errors}`);
return false;
}
@@ -114,54 +139,63 @@ const stateProfilePPID = proxy({
this.error = null;
try {
const res = await fetch(`/api/ppid/profileppid/${this.id}`, {
const response = await fetch(`/api/ppid/profileppid/${this.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(this.form),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const result = await res.json();
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
toast.success("Berhasil update profile");
this.originalForm = { ...this.form }; // ✅ Update original setelah sukses
// Refresh profile data
await stateProfilePPID.profile.load(this.id);
return true;
} else throw new Error(result.message || "Gagal update profile");
} catch (err) {
const msg = (err as Error).message;
this.error = msg;
toast.error(msg);
} else {
throw new Error(result.message || "Gagal update profile");
}
} catch (error) {
const errorMessage = (error as Error).message;
this.error = errorMessage;
console.error("Update profile error:", errorMessage);
toast.error("Terjadi kesalahan saat update profile");
return false;
} finally {
this.loading = false;
}
},
// ✅ Tambahan reset ke original data
resetToOriginal() {
this.form = { ...this.originalForm };
toast.info("Data dikembalikan ke kondisi awal");
},
// Reset form
reset() {
this.id = "";
this.form = { ...defaultForm };
this.originalForm = { ...defaultForm };
this.error = null;
this.loading = false;
},
this.isReadOnly = false;
}
},
// Helper methods
async loadForEdit(id: string) {
const data = await this.profile.load(id);
if (data) this.editForm.initialize(data);
return data;
const profileData = await this.profile.load(id);
if (profileData) {
this.editForm.initialize(profileData);
}
return profileData;
},
reset() {
this.profile.reset();
this.editForm.reset();
},
}
});
export default stateProfilePPID;
export default stateProfilePPID;

View File

@@ -1,10 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconBuildingStore, IconFileText, IconSparkles, IconUsers, IconUsersPlus } from '@tabler/icons-react';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import { IconFileText, IconBuildingStore, IconSparkles, IconUsers, IconUsersPlus } from '@tabler/icons-react';
function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
const router = useRouter()
@@ -14,31 +14,36 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
label: "Pelayanan Surat Keterangan",
value: "pelayanansuratketerangan",
href: "/admin/desa/layanan/pelayanan_surat_keterangan",
icon: <IconFileText size={18} stroke={1.8} />
icon: <IconFileText size={18} stroke={1.8} />,
tooltip: "Layanan terkait surat keterangan resmi desa"
},
{
label: "Pelayanan Perizinan Berusaha",
value: "pelayananperizinanusaha",
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",
value: "pelayanantelunjuksaktidesa",
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",
value: "pelayanannonpermanent",
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",
value: "ajukanpermohonan",
href: "/admin/desa/layanan/ajukan_permohonan",
icon: <IconUsersPlus size={18} stroke={1.8} />
icon: <IconUsersPlus size={18} stroke={1.8} />,
tooltip: "Ajukan permohonan"
}
];
@@ -86,8 +91,14 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
}}
>
{tabs.map((tab, i) => (
<TabsTab
<Tooltip
key={i}
label={tab.tooltip}
position="bottom"
withArrow
transitionProps={{ transition: 'pop', duration: 200 }}
>
<TabsTab
value={tab.value}
leftSection={tab.icon}
style={{
@@ -98,6 +109,7 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
>
{tab.label}
</TabsTab>
</Tooltip>
))}
</TabsList>
</ScrollArea>

View File

@@ -1,10 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconCategory, IconNews } from '@tabler/icons-react';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import { IconNews, IconCategory } from '@tabler/icons-react';
function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
const router = useRouter();
@@ -15,13 +15,15 @@ function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
label: "List Berita",
value: "list_berita",
href: "/admin/desa/berita/list-berita",
icon: <IconNews size={18} stroke={1.8} />
icon: <IconNews size={18} stroke={1.8} />,
tooltip: "Lihat dan kelola semua berita desa"
},
{
label: "Kategori Berita",
value: "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"
},
];
@@ -69,39 +71,46 @@ function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
}}
>
{tabs.map((tab, i) => (
<TabsTab
<Tooltip
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
label={tab.tooltip}
position="bottom"
withArrow
transitionProps={{ transition: 'pop', duration: 200 }}
>
{tab.label}
</TabsTab>
<TabsTab
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
</Tooltip>
))}
</TabsList>
</ScrollArea>
</TabsList>
</ScrollArea>
{tabs.map((tab, i) => (
<TabsPanel
key={i}
value={tab.value}
style={{
padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}}
>
{/* Konten dummy, bisa diganti sesuai routing */}
<>{children}</>
</TabsPanel>
))}
</Tabs>
</Stack >
{tabs.map((tab, i) => (
<TabsPanel
key={i}
value={tab.value}
style={{
padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}}
>
{/* Konten dummy, bisa diganti sesuai routing */}
<>{children}</>
</TabsPanel>
))}
</Tabs>
</Stack>
);
}

View File

@@ -11,7 +11,7 @@ import {
Stack,
TextInput,
Title,
Loader
Tooltip,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
@@ -23,11 +23,6 @@ function EditKategoriBerita() {
const editState = useProxy(stateDashboardBerita.kategoriBerita);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: '',
});
const [formData, setFormData] = useState({
name: '',
@@ -44,9 +39,6 @@ function EditKategoriBerita() {
setFormData({
name: data.name || '',
});
setOriginalData({
name: data.name || '',
});
}
} catch (error) {
console.error('Error loading kategori Berita:', error);
@@ -64,16 +56,8 @@ function EditKategoriBerita() {
}));
};
const handleResetForm = () => {
setFormData({
name: originalData.name,
});
toast.info('Form dikembalikan ke data awal');
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// update global state hanya saat submit
editState.update.form = {
...editState.update.form,
@@ -86,8 +70,6 @@ function EditKategoriBerita() {
} catch (error) {
console.error('Error updating kategori Berita:', error);
toast.error('Terjadi kesalahan saat memperbarui kategori Berita');
} finally {
setIsSubmitting(false);
}
};
@@ -95,6 +77,7 @@ function EditKategoriBerita() {
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Back Button + Title */}
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
@@ -103,6 +86,7 @@ function EditKategoriBerita() {
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Kategori Berita
</Title>
@@ -128,17 +112,6 @@ function EditKategoriBerita() {
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -149,7 +122,7 @@ function EditKategoriBerita() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

@@ -9,18 +9,15 @@ import {
Stack,
TextInput,
Title,
Loader
Tooltip
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function CreateKategoriBerita() {
const createState = useProxy(stateDashboardBerita.kategoriBerita);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
createState.create.form = {
@@ -29,23 +26,16 @@ function CreateKategoriBerita() {
};
const handleSubmit = async () => {
setIsSubmitting(true);
try {
await createState.create.create();
resetForm();
router.push('/admin/desa/berita/kategori-berita');
} catch (error) {
console.error('Error creating kategori berita:', error);
toast.error('Gagal menambahkan kategori berita');
} finally {
setIsSubmitting(false);
}
await createState.create.create();
resetForm();
router.push('/admin/desa/berita/kategori-berita');
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header dengan back button */}
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
@@ -54,6 +44,7 @@ function CreateKategoriBerita() {
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Tambah Kategori Berita
</Title>
@@ -72,23 +63,12 @@ function CreateKategoriBerita() {
<TextInput
label="Nama Kategori Berita"
placeholder="Masukkan nama kategori berita"
value={createState.create.form.name || ''}
defaultValue={createState.create.form.name || ''}
onChange={(e) => (createState.create.form.name = e.target.value)}
required
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -99,7 +79,7 @@ function CreateKategoriBerita() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

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

View File

@@ -6,7 +6,6 @@ import stateDashboardBerita from "@/app/admin/(dashboard)/_state/desa/berita";
import colors from "@/con/colors";
import ApiFetch from "@/lib/api-fetch";
import {
ActionIcon,
Box,
Button,
Group,
@@ -17,7 +16,7 @@ import {
Text,
TextInput,
Title,
Loader
Tooltip,
} from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import {
@@ -46,17 +45,6 @@ function EditBerita() {
imageId: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
judul: "",
deskripsi: "",
kategoriBeritaId: "",
content: "",
imageId: "",
imageUrl: ""
});
// Load kategori + berita
useEffect(() => {
beritaState.kategoriBerita.findMany.load();
@@ -76,15 +64,6 @@ function EditBerita() {
imageId: data.imageId || "",
});
setOriginalData({
judul: data.judul || "",
deskripsi: data.deskripsi || "",
kategoriBeritaId: data.kategoriBeritaId || "",
content: data.content || "",
imageId: data.imageId || "",
imageUrl: data.image?.link || ""
});
if (data?.image?.link) {
setPreviewImage(data.image.link);
}
@@ -104,7 +83,6 @@ function EditBerita() {
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// Update global state hanya sekali di sini
beritaState.berita.edit.form = {
...beritaState.berita.edit.form,
@@ -131,36 +109,23 @@ function EditBerita() {
} catch (error) {
console.error("Error updating berita:", error);
toast.error("Terjadi kesalahan saat memperbarui berita");
} finally {
setIsSubmitting(false);
}
};
const handleResetForm = () => {
setFormData({
judul: originalData.judul,
deskripsi: originalData.deskripsi,
kategoriBeritaId: originalData.kategoriBeritaId,
content: originalData.content,
imageId: originalData.imageId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
return (
<Box px={{ base: "sm", md: "lg" }} py="md">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors["blue-button"]} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors["blue-button"]} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Berita
</Title>
@@ -254,14 +219,14 @@ function EditBerita() {
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
Maksimal 5MB, format gambar wajib
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Box mt="sm" style={{ display: "flex", justifyContent: "center" }}>
<Image
src={previewImage}
alt="Preview Gambar"
@@ -273,24 +238,6 @@ function EditBerita() {
}}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
@@ -310,29 +257,17 @@ function EditBerita() {
{/* Action */}
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
background: `linear-gradient(135deg, ${colors["blue-button"]}, #4facfe)`,
color: "#fff",
boxShadow: "0 4px 15px rgba(79, 172, 254, 0.4)",
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

@@ -1,14 +1,14 @@
'use client'
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { useProxy } from 'valtio/utils';
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text, Tooltip } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import colors from '@/con/colors';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
import colors from '@/con/colors';
function DetailBerita() {
const beritaState = useProxy(stateDashboardBerita);
@@ -111,6 +111,7 @@ function DetailBerita() {
{/* Action Button */}
<Group gap="sm">
<Tooltip label="Hapus Berita" withArrow position="top">
<Button
color="red"
onClick={() => {
@@ -123,7 +124,9 @@ function DetailBerita() {
>
<IconTrash size={20} />
</Button>
</Tooltip>
<Tooltip label="Edit Berita" withArrow position="top">
<Button
color="green"
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
@@ -133,6 +136,7 @@ function DetailBerita() {
>
<IconEdit size={20} />
</Button>
</Tooltip>
</Group>
</Stack>
</Paper>

View File

@@ -14,8 +14,7 @@ import {
Text,
TextInput,
Title,
Loader,
ActionIcon
Tooltip,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useShallowEffect } from '@mantine/hooks';
@@ -30,7 +29,6 @@ export default function CreateBerita() {
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
useShallowEffect(() => {
beritaState.kategoriBerita.findMany.load();
@@ -49,48 +47,42 @@ export default function CreateBerita() {
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
if (!file) {
return toast.warn('Silakan pilih file gambar terlebih dahulu');
}
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
}
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);
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');
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header dengan tombol kembali */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Tambah Berita
</Title>
@@ -108,7 +100,7 @@ export default function CreateBerita() {
<TextInput
label="Judul"
placeholder="Masukkan judul berita"
value={beritaState.berita.create.form.judul}
defaultValue={beritaState.berita.create.form.judul}
onChange={(e) => (beritaState.berita.create.form.judul = e.target.value)}
required
/>
@@ -120,7 +112,7 @@ export default function CreateBerita() {
label: item.name,
value: item.id,
}))}
value={beritaState.berita.create.form.kategoriBeritaId || null}
defaultValue={beritaState.berita.create.form.kategoriBeritaId || null}
onChange={(val: string | null) => {
if (val) {
const selected = beritaState.kategoriBerita.findMany.data?.find(
@@ -165,7 +157,7 @@ export default function CreateBerita() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
accept={{ 'image/*': [] }}
radius="md"
p="xl"
>
@@ -186,7 +178,7 @@ export default function CreateBerita() {
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Box mt="sm" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
@@ -198,26 +190,6 @@ export default function CreateBerita() {
}}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
@@ -235,17 +207,6 @@ export default function CreateBerita() {
</Box>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -256,7 +217,7 @@ export default function CreateBerita() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconPhoto, IconVideo } from '@tabler/icons-react';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import { IconPhoto, IconVideo } from '@tabler/icons-react';
function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
const router = useRouter()
@@ -14,13 +14,15 @@ function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
label: "Foto",
value: "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",
value: "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"
},
];
@@ -68,18 +70,25 @@ function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
}}
>
{tabs.map((tab, i) => (
<TabsTab
<Tooltip
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
label={tab.tooltip}
position="bottom"
withArrow
transitionProps={{ transition: 'pop', duration: 200 }}
>
{tab.label}
</TabsTab>
<TabsTab
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
</Tooltip>
))}
</TabsList>
</ScrollArea>

View File

@@ -4,7 +4,6 @@ import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors';
import {
ActionIcon,
Box,
Button,
Group,
@@ -12,11 +11,11 @@ import {
Stack,
TextInput,
Title,
Loader
Tooltip
} from '@mantine/core';
import { IconArrowBack, IconX } from '@tabler/icons-react';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import { convertYoutubeUrlToEmbed } from '../../../lib/youtube-utils';
@@ -26,14 +25,6 @@ function EditVideo() {
const videoState = useProxy(stateGallery.video);
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
deskripsi: "",
linkVideo: "",
});
const [formData, setFormData] = useState({
name: '',
deskripsi: '',
@@ -54,11 +45,6 @@ function EditVideo() {
deskripsi: data.deskripsi ?? '',
linkVideo: data.linkVideo ?? '',
});
setOriginalData({
name: data.name ?? '',
deskripsi: data.deskripsi ?? '',
linkVideo: data.linkVideo ?? '',
});
}
} catch (error) {
console.error('Error loading video:', error);
@@ -76,42 +62,25 @@ function EditVideo() {
[]
);
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
linkVideo: originalData.linkVideo,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
const converted = convertYoutubeUrlToEmbed(formData.linkVideo);
if (!converted) {
toast.error("Link YouTube tidak valid. Pastikan formatnya benar.");
return;
}
const converted = convertYoutubeUrlToEmbed(formData.linkVideo);
if (!converted) {
toast.error("Link YouTube tidak valid. Pastikan formatnya benar.");
return;
}
try {
videoState.update.form = {
name: formData.name,
deskripsi: formData.deskripsi,
linkVideo: formData.linkVideo,
};
await videoState.update.update();
toast.success('Video berhasil diperbarui!');
router.push('/admin/desa/gallery/video');
} catch (error) {
console.error('Error updating video:', error);
toast.error('Terjadi kesalahan saat memperbarui video');
}
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');
} finally {
setIsSubmitting(false);
}
};
@@ -120,14 +89,16 @@ function EditVideo() {
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Video
</Title>
@@ -159,7 +130,7 @@ function EditVideo() {
required
/>
{embedLink && (
<Box mt="sm" pos="relative" style={{ display: 'flex', justifyContent: 'center' }}>
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<iframe
className="rounded"
width="100%"
@@ -167,27 +138,7 @@ function EditVideo() {
src={embedLink}
title="Preview Video"
allowFullScreen
/>
<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>
></iframe>
</Box>
)}
</Box>
@@ -203,17 +154,6 @@ function EditVideo() {
</Box>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -224,7 +164,7 @@ function EditVideo() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

@@ -2,7 +2,7 @@
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors';
import { Box, Button, Group, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { Box, Button, Group, Paper, Skeleton, Stack, Text, Tooltip } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
@@ -111,6 +111,7 @@ function DetailVideo() {
{/* Tombol Aksi */}
<Group gap="sm">
<Tooltip label="Hapus Video" withArrow position="top">
<Button
color="red"
onClick={() => {
@@ -123,7 +124,9 @@ function DetailVideo() {
>
<IconTrash size={20} />
</Button>
</Tooltip>
<Tooltip label="Edit Video" withArrow position="top">
<Button
color="green"
onClick={() =>
@@ -135,6 +138,7 @@ function DetailVideo() {
>
<IconEdit size={20} />
</Button>
</Tooltip>
</Group>
</Stack>
</Paper>

View File

@@ -3,7 +3,6 @@ import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors';
import {
ActionIcon,
Box,
Button,
Group,
@@ -12,9 +11,9 @@ import {
Text,
TextInput,
Title,
Loader
Tooltip,
} from '@mantine/core';
import { IconArrowBack, IconX } from '@tabler/icons-react';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
@@ -26,7 +25,6 @@ function CreateVideo() {
const router = useRouter();
const [link, setLink] = useState('');
const embedLink = convertYoutubeUrlToEmbed(link);
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
videoState.create.form = {
@@ -38,37 +36,31 @@ function CreateVideo() {
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
if (!embedLink) {
toast.error('Link YouTube tidak valid. Pastikan formatnya benar.');
return;
}
videoState.create.form.linkVideo = embedLink;
await videoState.create.create();
resetForm();
router.push('/admin/desa/gallery/video');
} catch (error) {
console.error("Error creating video:", error);
toast.error("Terjadi kesalahan saat menambahkan video");
} finally {
setIsSubmitting(false);
if (!embedLink) {
toast.error('Link YouTube tidak valid. Pastikan formatnya benar.');
return;
}
videoState.create.form.linkVideo = embedLink;
await videoState.create.create();
resetForm();
router.push('/admin/desa/gallery/video');
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header Back Button + Title */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Tambah Video
</Title>
@@ -88,7 +80,7 @@ function CreateVideo() {
<TextInput
label="Judul Video"
placeholder="Masukkan judul video"
value={videoState.create.form.name}
defaultValue={videoState.create.form.name}
onChange={(e) => {
videoState.create.form.name = e.currentTarget.value;
}}
@@ -99,14 +91,14 @@ function CreateVideo() {
<TextInput
label="Link Video YouTube"
placeholder="https://www.youtube.com/watch?v=abc123"
value={link}
defaultValue={link}
onChange={(e) => setLink(e.currentTarget.value)}
required
/>
{/* Preview Video */}
{embedLink && (
<Box mt="sm" pos="relative">
<Box mt="sm">
<iframe
style={{
borderRadius: 10,
@@ -117,24 +109,7 @@ function CreateVideo() {
src={embedLink}
title="Preview Video"
allowFullScreen
/>
<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>
></iframe>
</Box>
)}
@@ -153,17 +128,6 @@ function CreateVideo() {
{/* Button Submit */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -174,7 +138,7 @@ function CreateVideo() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

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

View File

@@ -6,12 +6,12 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Select,
Stack,
TextInput,
Title
Title,
Tooltip,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
@@ -24,16 +24,6 @@ function EditAjukanPermohonan() {
const params = useParams();
const stateAjukan = useProxy(stateLayananDesa.ajukanPermohonan);
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
nama: "",
nik: "",
alamat: "",
nomorKk: "",
kategoriId: "",
});
// State lokal form
const [formData, setFormData] = useState({
nama: '',
@@ -61,13 +51,6 @@ function EditAjukanPermohonan() {
nomorKk: data.nomorKk || '',
kategoriId: data.kategoriId || '',
});
setOriginalData({
nama: data.nama || '',
nik: data.nik || '',
alamat: data.alamat || '',
nomorKk: data.nomorKk || '',
kategoriId: data.kategoriId || '',
});
}
} catch (error) {
console.error('Error loading ajukan:', error);
@@ -86,20 +69,8 @@ function EditAjukanPermohonan() {
}));
};
const handleResetForm = () => {
setFormData({
nama: originalData.nama,
nik: originalData.nik,
alamat: originalData.alamat,
nomorKk: originalData.nomorKk,
kategoriId: originalData.kategoriId,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
stateAjukan.edit.form = {
...stateAjukan.edit.form,
...formData,
@@ -109,8 +80,6 @@ function EditAjukanPermohonan() {
} catch (error) {
console.error('Error updating ajukan:', error);
toast.error('Terjadi kesalahan saat memperbarui ajukan');
} finally {
setIsSubmitting(false);
}
};
@@ -118,9 +87,11 @@ function EditAjukanPermohonan() {
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Back Button */}
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Ajukan Permohonan
</Title>
@@ -185,17 +156,6 @@ function EditAjukanPermohonan() {
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -206,7 +166,7 @@ function EditAjukanPermohonan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

@@ -9,7 +9,8 @@ import {
Paper,
Skeleton,
Stack,
Text
Text,
Tooltip
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
@@ -120,6 +121,7 @@ function DetailAjukanPermohonan() {
</Box>
<Group gap="sm">
<Tooltip label="Hapus Surat" withArrow position="top">
<Button
color="red"
onClick={() => {
@@ -133,7 +135,9 @@ function DetailAjukanPermohonan() {
>
<IconTrash size={20} />
</Button>
</Tooltip>
<Tooltip label="Edit Surat" withArrow position="top">
<Button
color="green"
onClick={() =>
@@ -147,6 +151,7 @@ function DetailAjukanPermohonan() {
>
<IconEdit size={20} />
</Button>
</Tooltip>
</Group>
</Stack>
</Paper>

View File

@@ -8,12 +8,12 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Text,
TextInput,
Title
Title,
Tooltip,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
@@ -33,14 +33,6 @@ function EditPelayananPendudukNonPermanent() {
deskripsi: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: '',
deskripsi: '',
});
// Load data sekali dari backend
useEffect(() => {
const loadData = async () => {
@@ -54,10 +46,6 @@ function EditPelayananPendudukNonPermanent() {
name: data.name || '',
deskripsi: data.deskripsi || '',
});
setOriginalData({
name: data.name || '',
deskripsi: data.deskripsi || '',
});
}
} catch (error) {
console.error('Error loading data:', error);
@@ -70,55 +58,41 @@ function EditPelayananPendudukNonPermanent() {
const handleChange =
(field: keyof typeof formData) =>
(value: string) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
});
toast.info("Form dikembalikan ke data awal");
};
(value: string) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
if (!statePendudukNonPermanent.findById.data) return;
if (!statePendudukNonPermanent.findById.data) return;
// Update global state hanya di submit
const updated = {
...statePendudukNonPermanent.findById.data,
name: formData.name,
deskripsi: formData.deskripsi,
};
// Update global state hanya di submit
const updated = {
...statePendudukNonPermanent.findById.data,
name: formData.name,
deskripsi: formData.deskripsi,
};
await statePendudukNonPermanent.update.update(updated);
router.push('/admin/desa/layanan/pelayanan_penduduk_non_permanent');
} catch (error) {
console.error('Error updating data:', error);
toast.error('Gagal memuat data pelayanan penduduk non permanent');
} finally {
setIsSubmitting(false);
}
await statePendudukNonPermanent.update.update(updated);
router.push('/admin/desa/layanan/pelayanan_penduduk_non_permanent');
};
return (
<Box>
<Stack gap="xs">
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Pelayanan Penduduk Non Permanent
</Title>
@@ -156,30 +130,24 @@ function EditPelayananPendudukNonPermanent() {
</Box>
{/* Submit Button */}
<Group justify="right">
{/* Tombol Batal */}
<Group>
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
bg={colors['blue-button']}
onClick={handleSubmit}
loading={statePendudukNonPermanent.update.loading}
disabled={!formData.name}
>
Batal
{statePendudukNonPermanent.update.loading
? 'Menyimpan...'
: 'Simpan Perubahan'}
</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)',
}}
variant="outline"
onClick={() => router.back()}
disabled={statePendudukNonPermanent.update.loading}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Batal
</Button>
</Group>
</Stack>

View File

@@ -11,7 +11,8 @@ import {
Skeleton,
Stack,
Text,
Title
Title,
Tooltip,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconEdit } from '@tabler/icons-react';
@@ -50,6 +51,7 @@ function PelayananPendudukNonPermanent() {
</Title>
</GridCol>
<GridCol span={{ base: 12, md: 1 }}>
<Tooltip label="Edit Data Pelayanan" withArrow>
<Button
c="green"
variant="light"
@@ -63,6 +65,7 @@ function PelayananPendudukNonPermanent() {
>
Edit
</Button>
</Tooltip>
</GridCol>
</Grid>

View File

@@ -8,12 +8,12 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Skeleton,
Stack,
TextInput,
Title
Title,
Tooltip,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
@@ -35,21 +35,13 @@ function EditPelayananPerizinanBerusaha() {
link: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
id: '',
name: '',
deskripsi: '',
link: '',
});
// Load data detail
useEffect(() => {
if (!id) {
toast.error("ID tidak valid");
return;
}
const loadData = async () => {
try {
setLoading(true);
@@ -61,12 +53,6 @@ function EditPelayananPerizinanBerusaha() {
deskripsi: data.deskripsi || "",
link: data.link || "",
});
setOriginalData({
id: data.id,
name: data.name || "",
deskripsi: data.deskripsi || "",
link: data.link || "",
});
} else {
toast.error("Data tidak ditemukan");
}
@@ -77,10 +63,10 @@ function EditPelayananPerizinanBerusaha() {
setLoading(false);
}
};
loadData();
}, [id]);
const handleChange =
(field: keyof typeof formData) =>
@@ -91,26 +77,13 @@ function EditPelayananPerizinanBerusaha() {
}));
};
const handleResetForm = () => {
setFormData({
id: originalData.id,
name: originalData.name,
deskripsi: originalData.deskripsi,
link: originalData.link,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
await state.update.update(formData);
router.push('/admin/desa/layanan/pelayanan_perizinan_berusaha');
} catch (error) {
console.error('Error updating pelayanan perizinan berusaha:', error);
toast.error('Terjadi kesalahan saat update data');
} finally {
setIsSubmitting(false);
}
};
@@ -127,14 +100,16 @@ function EditPelayananPerizinanBerusaha() {
<Stack gap="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Pelayanan Perizinan Berusaha
</Title>
@@ -175,30 +150,22 @@ function EditPelayananPerizinanBerusaha() {
/>
</Box>
<Group justify="right">
{/* Tombol Batal */}
<Group>
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
bg={colors['blue-button']}
onClick={handleSubmit}
loading={state.update.loading}
disabled={!formData.name}
>
Batal
{state.update.loading ? 'Menyimpan...' : 'Simpan Perubahan'}
</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)',
}}
variant="outline"
onClick={() => router.back()}
disabled={state.update.loading}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Batal
</Button>
</Group>
</Stack>

View File

@@ -16,13 +16,14 @@ import {
StepperCompleted,
StepperStep,
Text,
Title
Title,
Tooltip,
} from '@mantine/core';
import { IconEdit } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import stateLayananDesa from '../../../_state/desa/layananDesa';
import { useProxy } from 'valtio/utils';
import { useRouter } from 'next/navigation';
function PerizinanBerusaha() {
const router = useRouter();
@@ -84,6 +85,7 @@ function PerizinanBerusaha() {
</Title>
</GridCol>
<GridCol span={{ base: 12, md: 1 }}>
<Tooltip label="Edit Data Perizinan" withArrow>
<Button
c="green"
variant="light"
@@ -97,6 +99,7 @@ function PerizinanBerusaha() {
>
Edit
</Button>
</Tooltip>
</GridCol>
</Grid>

View File

@@ -1,280 +1,127 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
'use client';
'use client'
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { toast } from 'react-toastify';
import * as React from 'react';
import { useProxy } from 'valtio/utils';
// 🔹 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() {
const router = useRouter();
const params = useParams();
const stateSurat = useProxy(stateLayananDesa.suratKeterangan);
// 🧩 State
const [formData, setFormData] = useState<FormData>({
// state lokal untuk form
const [formData, setFormData] = useState({
name: '',
deskripsi: '',
imageId: '',
image2Id: '',
imageUrl: '',
image2Url: '',
});
const [originalData, setOriginalData] = useState<FormData>(formData);
// state file upload
const [file, setFile] = useState<File | null>(null);
const [file2, setFile2] = useState<File | null>(null);
// state preview gambar
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewImage2, setPreviewImage2] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// 🧭 Load Initial Data
// load data awal
useEffect(() => {
const loadSurat = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await stateLayananDesa.suratKeterangan.edit.load(id);
const data = await stateSurat.edit.load(id);
if (!data) return;
const mapped: FormData = {
name: data.name || '',
deskripsi: data.deskripsi || '',
imageId: data.imageId || '',
image2Id: data.image2Id || '',
imageUrl: data.image?.link || '',
image2Url: data.image2?.link || ''
};
setFormData((prev) => ({
...prev,
...{
name: prev.name || data.name || "",
deskripsi: prev.deskripsi || data.deskripsi || "",
imageId: prev.imageId || data.imageId || "",
image2Id: prev.image2Id || data.image2Id || "",
},
}));
setFormData(mapped);
setOriginalData(mapped);
if (data.image?.link) setPreviewImage(data.image.link);
if (data.image2?.link) setPreviewImage2(data.image2.link);
if (data.image?.link && !previewImage) setPreviewImage(data.image.link);
if (data.image2?.link && !previewImage2) setPreviewImage2(data.image2.link);
} catch (error) {
console.error('Error loading surat:', error);
toast.error('Gagal memuat data surat');
console.error("Error loading surat:", error);
toast.error("Gagal memuat data surat");
}
};
loadSurat();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params?.id]);
// 📤 Upload File Helper
const uploadFile = async (file: File): Promise<string | null> => {
try {
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
return uploaded?.id || null;
} catch (error) {
console.error('Error uploading file:', error);
return null;
}
};
// 🔁 Reset Form
const handleResetForm = () => {
setFormData(originalData);
setPreviewImage(originalData.imageUrl || null);
setPreviewImage2(originalData.image2Url || null);
setFile(null);
setFile2(null);
toast.info('Form dikembalikan ke data awal');
};
// 💾 Submit Handler
// handler untuk submit
const handleSubmit = useCallback(async () => {
try {
setIsSubmitting(true);
// update form global hanya saat submit
stateSurat.edit.form = { ...stateSurat.edit.form, ...formData };
// ✅ 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
// upload file 1
if (file) {
const uploadedId = await uploadFile(file);
if (!uploadedId) {
toast.error('Gagal upload gambar pertama');
return;
}
originalState.edit.form.imageId = uploadedId;
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
const uploaded = res.data?.data;
if (!uploaded?.id) return toast.error('Gagal upload gambar');
stateSurat.edit.form.imageId = uploaded.id;
}
// Upload file 2 if exists
// upload file 2
if (file2) {
const uploadedId = await uploadFile(file2);
if (!uploadedId) {
toast.error('Gagal upload gambar kedua');
return;
}
originalState.edit.form.image2Id = uploadedId;
const res = await ApiFetch.api.fileStorage.create.post({ file: file2, name: file2.name });
const uploaded = res.data?.data;
if (!uploaded?.id) return toast.error('Gagal upload gambar');
stateSurat.edit.form.image2Id = uploaded.id;
}
// Submit update
await originalState.edit.update();
await stateSurat.edit.update();
toast.success('Surat berhasil diperbarui!');
router.push('/admin/desa/layanan/pelayanan_surat_keterangan');
} catch (error) {
console.error('Error updating surat:', error);
toast.error('Terjadi kesalahan saat memperbarui surat');
} finally {
setIsSubmitting(false);
}
}, [formData, file, file2, router]);
// 📝 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 }));
};
}, [formData, file, file2, router, stateSurat.edit]);
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */}
{/* Back Button */}
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Surat Keterangan
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
@@ -284,66 +131,154 @@ function EditSuratKeterangan() {
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
{/* Nama Surat */}
{/* Input nama */}
<TextInput
label="Nama Surat Keterangan"
placeholder="Masukkan nama surat keterangan"
value={formData.name}
onChange={handleNameChange}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
required
/>
{/* Deskripsi */}
{/* Input deskripsi */}
<Box>
<Text fz="sm" fw="bold" mb={6}>
Konten
</Text>
<EditEditor
value={formData.deskripsi}
onChange={handleDeskripsiChange}
onChange={(htmlContent) =>
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }))
}
/>
</Box>
{/* Gambar 1 */}
<FileUploader
title="Gambar Konten Pelayanan"
file={file}
setFile={setFile}
preview={previewImage}
setPreview={setPreviewImage}
/>
{/* Gambar 2 */}
<FileUploader
title="Gambar Alur Pelayanan Surat"
file={file2}
setFile={setFile2}
preview={previewImage2}
setPreview={setPreviewImage2}
/>
{/* Action Buttons */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
onClick={handleResetForm}
disabled={isSubmitting}
{/* Upload Gambar 1 */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Konten Pelayanan
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
radius="md"
p="xl"
>
Batal
</Button>
<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 && (
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar 1"
radius="md"
style={{
maxHeight: 220,
objectFit: 'contain',
border: `1px solid ${colors['blue-button']}`,
}}
loading="lazy"
/>
</Box>
)}
</Box>
{/* Upload Gambar 2 */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Alur Pelayanan Surat
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile2(selectedFile);
setPreviewImage2(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar wajib
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage2 && (
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Image
src={previewImage2}
alt="Preview Gambar 2"
radius="md"
style={{
maxHeight: 220,
objectFit: 'contain',
border: `1px solid ${colors['blue-button']}`,
}}
loading="lazy"
/>
</Box>
)}
</Box>
<Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
disabled={isSubmitting}
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79,172,254,0.4)',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>
@@ -352,4 +287,4 @@ function EditSuratKeterangan() {
);
}
export default EditSuratKeterangan;
export default EditSuratKeterangan;

View File

@@ -10,7 +10,8 @@ import {
Paper,
Skeleton,
Stack,
Text
Text,
Tooltip,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
@@ -141,6 +142,7 @@ function DetailSuratKeterangan() {
</Box>
<Group gap="sm">
<Tooltip label="Hapus Surat" withArrow position="top">
<Button
color="red"
onClick={() => {
@@ -154,7 +156,9 @@ function DetailSuratKeterangan() {
>
<IconTrash size={20} />
</Button>
</Tooltip>
<Tooltip label="Edit Surat" withArrow position="top">
<Button
color="green"
onClick={() =>
@@ -168,6 +172,7 @@ function DetailSuratKeterangan() {
>
<IconEdit size={20} />
</Button>
</Tooltip>
</Group>
</Stack>
</Paper>

View File

@@ -5,17 +5,16 @@ import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
Paper,
Stack,
Text,
TextInput,
Title
Title,
Tooltip,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -29,7 +28,6 @@ function CreateSuratKeterangan() {
const [previewImage2, setPreviewImage2] = useState<{ preview: string; file: File } | null>(null);
const [previewImage, setPreviewImage] = useState<{ preview: string; file: File } | null>(null);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
stateSurat.create.form = {
@@ -48,7 +46,6 @@ function CreateSuratKeterangan() {
}
try {
setIsSubmitting(true);
// Upload gambar utama
const res1 = await ApiFetch.api.fileStorage.create.post({
file: previewImage.file,
@@ -81,8 +78,6 @@ function CreateSuratKeterangan() {
} catch (error) {
console.error('Error creating surat keterangan:', error);
toast.error('Terjadi kesalahan saat menambahkan surat keterangan');
} finally {
setIsSubmitting(false);
}
};
@@ -90,9 +85,11 @@ function CreateSuratKeterangan() {
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */}
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Tambah Surat Keterangan
</Title>
@@ -109,7 +106,7 @@ function CreateSuratKeterangan() {
<Stack gap="md">
{/* Nama Surat */}
<TextInput
value={stateSurat.create.form.name}
defaultValue={stateSurat.create.form.name}
onChange={(val) => (stateSurat.create.form.name = val.target.value)}
label="Nama Surat Keterangan"
placeholder="Masukkan nama surat keterangan"
@@ -146,7 +143,7 @@ function CreateSuratKeterangan() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
accept={{ 'image/*': [] }}
radius="md"
p="xl"
>
@@ -167,7 +164,7 @@ function CreateSuratKeterangan() {
</Dropzone>
{previewImage && (
<Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}>
<Box mt="sm" style={{ textAlign: 'center' }}>
<Image
src={previewImage.preview}
alt="Preview Gambar Utama"
@@ -175,23 +172,6 @@ function CreateSuratKeterangan() {
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
@@ -213,7 +193,7 @@ function CreateSuratKeterangan() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
accept={{ 'image/*': [] }}
radius="md"
p="xl"
>
@@ -234,7 +214,7 @@ function CreateSuratKeterangan() {
</Dropzone>
{previewImage2 ? (
<Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}>
<Box mt="sm" style={{ textAlign: 'center' }}>
<Image
src={previewImage2.preview}
alt="Preview Gambar Tambahan"
@@ -242,23 +222,6 @@ function CreateSuratKeterangan() {
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage2(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
) : (
<Text size="sm" c="dimmed" mt="sm" ta="center">
@@ -269,17 +232,6 @@ function CreateSuratKeterangan() {
{/* Tombol Simpan */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -290,7 +242,7 @@ function CreateSuratKeterangan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

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

View File

@@ -6,11 +6,11 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title
Title,
Tooltip
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
@@ -22,7 +22,6 @@ function EditPelayananTelunjukSakti() {
const stateTelunjukDesa = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
name: '',
@@ -30,12 +29,6 @@ function EditPelayananTelunjukSakti() {
link: '',
});
const [originalData, setOriginalData] = useState({
name: '',
deskripsi: '',
link: '',
});
// Load data awal hanya sekali (pas ada id)
useEffect(() => {
const loadData = async () => {
@@ -50,11 +43,6 @@ function EditPelayananTelunjukSakti() {
deskripsi: data.deskripsi ?? '',
link: data.link ?? '',
});
setOriginalData({
name: data.name ?? '',
deskripsi: data.deskripsi ?? '',
link: data.link ?? '',
});
}
} catch (error) {
console.error('Error loading pelayanan telunjuk sakti:', error);
@@ -73,19 +61,9 @@ function EditPelayananTelunjukSakti() {
[]
);
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
link: originalData.link,
});
toast.info("Form dikembalikan ke data awal");
};
// Submit: update global state hanya saat simpan
const handleSubmit = async () => {
try {
setIsSubmitting(true);
stateTelunjukDesa.edit.form = {
...stateTelunjukDesa.edit.form,
...formData,
@@ -96,8 +74,6 @@ function EditPelayananTelunjukSakti() {
} catch (error) {
console.error('Error updating pelayanan telunjuk sakti:', error);
toast.error('Terjadi kesalahan saat memperbarui pelayanan telunjuk sakti');
} finally {
setIsSubmitting(false);
}
};
@@ -105,9 +81,11 @@ function EditPelayananTelunjukSakti() {
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Back Button + Title */}
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Pelayanan Telunjuk Sakti Desa
</Title>
@@ -150,17 +128,6 @@ function EditPelayananTelunjukSakti() {
{/* Tombol Simpan */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -171,7 +138,7 @@ function EditPelayananTelunjukSakti() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

@@ -9,7 +9,8 @@ import {
Paper,
Skeleton,
Stack,
Text
Text,
Tooltip,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
@@ -129,6 +130,7 @@ function DetailPelayananTelunjukSakti() {
</Box>
<Group gap="sm">
<Tooltip label="Hapus Layanan" withArrow position="top">
<Button
color="red"
onClick={() => {
@@ -142,7 +144,9 @@ function DetailPelayananTelunjukSakti() {
>
<IconTrash size={20} />
</Button>
</Tooltip>
<Tooltip label="Edit Layanan" withArrow position="top">
<Button
color="green"
onClick={() =>
@@ -156,6 +160,7 @@ function DetailPelayananTelunjukSakti() {
>
<IconEdit size={20} />
</Button>
</Tooltip>
</Group>
</Stack>
</Paper>

View File

@@ -6,22 +6,20 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title
Title,
Tooltip
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function CreatePelayananTelunjukDesa() {
const stateTelunjukDesa = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
stateTelunjukDesa.create.form = {
@@ -33,7 +31,6 @@ function CreatePelayananTelunjukDesa() {
const handleSubmit = async () => {
try {
setIsSubmitting(true);
await stateTelunjukDesa.create.create();
resetForm();
toast.success('Data pelayanan telunjuk sakti berhasil ditambahkan');
@@ -41,8 +38,6 @@ function CreatePelayananTelunjukDesa() {
} catch (error) {
console.error('Error create pelayanan telunjuk sakti:', error);
toast.error('Terjadi kesalahan saat menambahkan data');
} finally {
setIsSubmitting(false);
}
};
@@ -50,9 +45,11 @@ function CreatePelayananTelunjukDesa() {
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */}
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Tambah Pelayanan Telunjuk Sakti Desa
</Title>
@@ -70,7 +67,7 @@ function CreatePelayananTelunjukDesa() {
<Stack gap="md">
{/* Nama */}
<TextInput
value={stateTelunjukDesa.create.form.name}
defaultValue={stateTelunjukDesa.create.form.name}
onChange={(val) => {
stateTelunjukDesa.create.form.name = val.target.value;
}}
@@ -81,7 +78,7 @@ function CreatePelayananTelunjukDesa() {
{/* Deskripsi */}
<TextInput
value={stateTelunjukDesa.create.form.deskripsi}
defaultValue={stateTelunjukDesa.create.form.deskripsi}
onChange={(val) => {
stateTelunjukDesa.create.form.deskripsi = val.target.value;
}}
@@ -92,7 +89,7 @@ function CreatePelayananTelunjukDesa() {
{/* Link */}
<TextInput
value={stateTelunjukDesa.create.form.link}
defaultValue={stateTelunjukDesa.create.form.link}
onChange={(val) => {
stateTelunjukDesa.create.form.link = val.target.value;
}}
@@ -103,17 +100,6 @@ function CreatePelayananTelunjukDesa() {
{/* Tombol Simpan */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -124,7 +110,7 @@ function CreatePelayananTelunjukDesa() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

@@ -1,3 +1,159 @@
// /* 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 */
'use client'
@@ -18,7 +174,8 @@ import {
TableThead,
TableTr,
Text,
Title
Title,
Tooltip,
} from '@mantine/core';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
@@ -68,6 +225,7 @@ function ListPelayananTelunjukSakti({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Pelayanan Telunjuk Sakti</Title>
<Tooltip label="Tambah Layanan" withArrow>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
@@ -78,6 +236,7 @@ function ListPelayananTelunjukSakti({ search }: { search: string }) {
>
Tambah Baru
</Button>
</Tooltip>
</Group>
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover style={{ minWidth: '700px' }}>

View File

@@ -5,17 +5,16 @@ import penghargaanState from '@/app/admin/(dashboard)/_state/desa/penghargaan';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
Paper,
Stack,
Text,
TextInput,
Title
Title,
Tooltip,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -31,15 +30,6 @@ function EditPenghargaan() {
const [previewImage, setPreviewImage] = useState<string | 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
const [formData, setFormData] = useState({
@@ -57,25 +47,17 @@ function EditPenghargaan() {
try {
const data = await statePenghargaan.edit.load(id);
if (data) {
const newForm = {
name: data.name || "",
juara: data.juara || "",
deskripsi: data.deskripsi || "",
imageId: data.imageId || "",
};
setFormData(newForm);
// simpan juga versi original
const imageUrl = data.image?.link || "";
setOriginalData({
...newForm,
imageUrl: imageUrl,
setFormData({
name: data.name || '',
juara: data.juara || '',
deskripsi: data.deskripsi || '',
imageId: data.imageId || '',
});
setPreviewImage(imageUrl || null);
if (data?.image?.link) {
setPreviewImage(data.image.link);
}
}
} catch (error) {
console.error('Error loading penghargaan:', error);
@@ -86,49 +68,33 @@ function EditPenghargaan() {
loadPenghargaan();
}, [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
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// Sync ke global state saat submit
let imageId = formData.imageId;
statePenghargaan.edit.form = {
...statePenghargaan.edit.form,
...formData,
};
// Upload file baru (kalau ada)
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error("Gagal upload gambar");
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();
toast.success('Penghargaan berhasil diperbarui!');
router.push('/admin/desa/penghargaan');
} catch (error) {
console.error('Error updating penghargaan:', error);
toast.error('Terjadi kesalahan saat memperbarui penghargaan');
} finally {
setIsSubmitting(false);
}
};
@@ -136,9 +102,11 @@ function EditPenghargaan() {
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Tombol Back + Title */}
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Penghargaan
</Title>
@@ -187,7 +155,7 @@ function EditPenghargaan() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
accept={{ 'image/*': [] }}
radius="md"
p="xl"
>
@@ -206,47 +174,25 @@ function EditPenghargaan() {
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
Maksimal 5MB, format gambar wajib
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box pos="relative" mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Box>
<Image
src={previewImage.startsWith('http') ? previewImage : `${window.location.origin}${previewImage}`}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 200,
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);
}}
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
maxHeight: 220,
objectFit: 'contain',
border: `1px solid ${colors['blue-button']}`,
}}
>
<IconX size={14} />
</ActionIcon>
loading="lazy"
/>
</Box>
)}
</Box>
@@ -266,17 +212,6 @@ function EditPenghargaan() {
{/* Tombol Simpan */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -287,7 +222,7 @@ function EditPenghargaan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

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

View File

@@ -2,17 +2,16 @@
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
Paper,
Stack,
Text,
TextInput,
Title
Title,
Tooltip,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -28,7 +27,6 @@ function CreatePenghargaan() {
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
statePenghargaan.create.form = {
@@ -42,43 +40,37 @@ function CreatePenghargaan() {
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
if (!file) {
return toast.warn('Silakan pilih file gambar terlebih dahulu');
}
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
}
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);
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');
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */}
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Tambah Penghargaan
</Title>
@@ -95,7 +87,7 @@ function CreatePenghargaan() {
>
<Stack gap="md">
<TextInput
value={statePenghargaan.create.form.name}
defaultValue={statePenghargaan.create.form.name}
onChange={(val) => (statePenghargaan.create.form.name = val.target.value)}
label="Nama Penghargaan"
placeholder="Masukkan nama penghargaan"
@@ -103,7 +95,7 @@ function CreatePenghargaan() {
/>
<TextInput
value={statePenghargaan.create.form.juara}
defaultValue={statePenghargaan.create.form.juara}
onChange={(val) => (statePenghargaan.create.form.juara = val.target.value)}
label="Juara"
placeholder="Masukkan juara"
@@ -133,7 +125,7 @@ function CreatePenghargaan() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
accept={{ 'image/*': [] }}
radius="md"
p="xl"
>
@@ -154,7 +146,7 @@ function CreatePenghargaan() {
</Dropzone>
{previewImage && (
<Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}>
<Box mt="sm" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
@@ -162,41 +154,12 @@ function CreatePenghargaan() {
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Button Submit */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -207,7 +170,7 @@ function CreatePenghargaan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

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

View File

@@ -1,10 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconCategory, IconListDetails } from '@tabler/icons-react';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import { IconListDetails, IconCategory } from '@tabler/icons-react';
function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
const router = useRouter()
@@ -14,13 +14,15 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
label: "List Pengumuman",
value: "listpengumuman",
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",
value: "kategoripengumuman",
href: "/admin/desa/pengumuman/kategori-pengumuman",
icon: <IconCategory size={18} stroke={1.8} />
icon: <IconCategory size={18} stroke={1.8} />,
tooltip: "Kelola kategori pengumuman"
},
];
@@ -68,18 +70,19 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
<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>

View File

@@ -11,7 +11,7 @@ import {
Stack,
TextInput,
Title,
Loader
Tooltip,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
@@ -23,9 +23,8 @@ function EditKategoriPengumuman() {
const editState = useProxy(stateDesaPengumuman.category);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({ name: '' });
const [originalData, setOriginalData] = useState({ name: '' });
// Load data awal sekali aja
useEffect(() => {
@@ -37,7 +36,6 @@ function EditKategoriPengumuman() {
const data = await editState.update.load(id);
if (data) {
setFormData({ name: data.name || '' });
setOriginalData({ name: data.name || '' });
}
} catch (error) {
console.error('Error loading kategori Pengumuman:', error);
@@ -57,7 +55,6 @@ function EditKategoriPengumuman() {
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// Update global state hanya di sini
editState.update.form = {
...editState.update.form,
@@ -70,23 +67,14 @@ function EditKategoriPengumuman() {
} catch (error) {
console.error('Error updating kategori Pengumuman:', error);
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 (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */}
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
@@ -95,6 +83,7 @@ function EditKategoriPengumuman() {
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Kategori Pengumuman
</Title>
@@ -119,17 +108,6 @@ function EditKategoriPengumuman() {
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -140,7 +118,7 @@ function EditKategoriPengumuman() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

@@ -9,18 +9,15 @@ import {
Stack,
TextInput,
Title,
Loader
Tooltip
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils';
import { useState } from 'react';
import { toast } from 'react-toastify';
function CreateKategoriPengumuman() {
const createState = useProxy(stateDesaPengumuman.category);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
createState.create.form = {
@@ -29,22 +26,16 @@ function CreateKategoriPengumuman() {
};
const handleSubmit = async () => {
try {
await createState.create.create();
resetForm();
router.push('/admin/desa/pengumuman/kategori-pengumuman');
} catch (error) {
console.error(error);
toast.error('Gagal menambahkan kategori pengumuman');
} finally {
setIsSubmitting(false);
}
await createState.create.create();
resetForm();
router.push('/admin/desa/pengumuman/kategori-pengumuman');
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header dengan back button */}
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
@@ -53,6 +44,7 @@ function CreateKategoriPengumuman() {
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Tambah Kategori Pengumuman
</Title>
@@ -71,23 +63,12 @@ function CreateKategoriPengumuman() {
<TextInput
label="Nama Kategori Pengumuman"
placeholder="Masukkan nama kategori pengumuman"
value={createState.create.form.name || ''}
defaultValue={createState.create.form.name || ''}
onChange={(e) => (createState.create.form.name = e.target.value)}
required
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -98,7 +79,7 @@ function CreateKategoriPengumuman() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

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

View File

@@ -14,7 +14,7 @@ import {
Text,
TextInput,
Title,
Loader
Tooltip,
} from "@mantine/core";
import { IconArrowBack } from "@tabler/icons-react";
import { useParams, useRouter } from "next/navigation";
@@ -34,15 +34,6 @@ function EditPengumuman() {
content: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
judul: "",
deskripsi: "",
categoryPengumumanId: "",
content: "",
});
// Load kategori & pengumuman by id saat pertama kali
useEffect(() => {
editState.category.findMany.load();
@@ -60,12 +51,6 @@ function EditPengumuman() {
categoryPengumumanId: data.categoryPengumumanId || "",
content: data.content || "",
});
setOriginalData({
judul: data.judul || "",
deskripsi: data.deskripsi || "",
categoryPengumumanId: data.categoryPengumumanId || "",
content: data.content || "",
});
}
} catch (error) {
console.error("Error loading pengumuman:", error);
@@ -82,7 +67,6 @@ function EditPengumuman() {
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// update global state hanya sekali pas submit
editState.pengumuman.edit.form = {
...editState.pengumuman.edit.form,
@@ -95,32 +79,22 @@ function EditPengumuman() {
} catch (error) {
console.error("Error updating pengumuman:", error);
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 (
<Box px={{ base: "sm", md: "lg" }} py="md">
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors["blue-button"]} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors["blue-button"]} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Pengumuman
</Title>
@@ -181,29 +155,17 @@ function EditPengumuman() {
</Box>
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
background: `linear-gradient(135deg, ${colors["blue-button"]}, #4facfe)`,
color: "#fff",
boxShadow: "0 4px 15px rgba(79, 172, 254, 0.4)",
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

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

View File

@@ -13,36 +13,25 @@ import {
Text,
TextInput,
Title,
Loader
Tooltip,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function CreatePengumuman() {
const pengumumanState = useProxy(stateDesaPengumuman);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
useShallowEffect(() => {
pengumumanState.category.findMany.load();
}, []);
const handleSubmit = async () => {
try {
setIsSubmitting(true);
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);
}
await pengumumanState.pengumuman.create.create();
resetForm();
router.push('/admin/desa/pengumuman/list-pengumuman');
};
const resetForm = () => {
@@ -58,9 +47,11 @@ function CreatePengumuman() {
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */}
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Tambah Pengumuman
</Title>
@@ -77,7 +68,7 @@ function CreatePengumuman() {
<Stack gap="md">
{/* Judul */}
<TextInput
value={pengumumanState.pengumuman.create.form.judul}
defaultValue={pengumumanState.pengumuman.create.form.judul}
onChange={(val) => (pengumumanState.pengumuman.create.form.judul = val.target.value)}
label="Judul"
placeholder="Masukkan judul pengumuman"
@@ -88,32 +79,21 @@ function CreatePengumuman() {
<Select
label="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) => ({
label: item.name,
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
clearable
nothingFoundMessage="Tidak ditemukan"
required
/>
{/* Deskripsi Singkat */}
<TextInput
value={pengumumanState.pengumuman.create.form.deskripsi}
defaultValue={pengumumanState.pengumuman.create.form.deskripsi}
onChange={(val) => (pengumumanState.pengumuman.create.form.deskripsi = val.target.value)}
label="Deskripsi Singkat"
placeholder="Masukkan deskripsi singkat"
@@ -135,17 +115,6 @@ function CreatePengumuman() {
{/* Tombol Submit */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -156,7 +125,7 @@ function CreatePengumuman() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

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

View File

@@ -1,7 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
import { IconCategory, IconListCheck } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
@@ -14,13 +14,15 @@ function LayoutTabsPotensi({ children }: { children: React.ReactNode }) {
label: "List Potensi",
value: "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",
value: "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"
},
];
@@ -68,18 +70,19 @@ function LayoutTabsPotensi({ children }: { children: React.ReactNode }) {
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
<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>

View File

@@ -10,7 +10,7 @@ import {
Stack,
TextInput,
Title,
Loader
Tooltip
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
@@ -27,12 +27,6 @@ function EditKategoriPotensi() {
nama: '',
});
const [originalData, setOriginalData] = useState({
nama: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
// Load data dari backend -> isi ke formData lokal
useEffect(() => {
const loadKategori = async () => {
@@ -45,9 +39,6 @@ function EditKategoriPotensi() {
setFormData({
nama: data.nama || '',
});
setOriginalData({
nama: data.nama || '',
});
}
} catch (error) {
console.error('Error loading kategori potensi:', error);
@@ -65,16 +56,8 @@ function EditKategoriPotensi() {
}));
};
const handleResetForm = () => {
setFormData({
nama: originalData.nama,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// Update global state hanya pas submit
editState.update.form = {
...editState.update.form,
@@ -87,14 +70,13 @@ function EditKategoriPotensi() {
} catch (error) {
console.error('Error updating kategori potensi:', error);
toast.error('Terjadi kesalahan saat memperbarui kategori potensi');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
@@ -103,6 +85,7 @@ function EditKategoriPotensi() {
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Kategori Potensi
</Title>
@@ -126,17 +109,6 @@ function EditKategoriPotensi() {
/>
<Group justify="flex-end">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -147,7 +119,7 @@ function EditKategoriPotensi() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

@@ -9,17 +9,15 @@ import {
Stack,
TextInput,
Title,
Loader
Tooltip
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
function CreateKategoriPotensi() {
const createState = useProxy(potensiDesaState.kategoriPotensi);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
createState.create.form = {
@@ -28,30 +26,25 @@ function CreateKategoriPotensi() {
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
await createState.create.create();
resetForm();
router.push('/admin/desa/potensi/kategori-potensi');
} catch (error) {
console.error('Error creating kategori potensi:', error);
} finally {
setIsSubmitting(false);
}
await createState.create.create();
resetForm();
router.push('/admin/desa/potensi/kategori-potensi');
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header dengan back button */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Tambah Kategori Potensi
</Title>
@@ -70,23 +63,12 @@ function CreateKategoriPotensi() {
<TextInput
label="Nama Kategori Potensi"
placeholder="Masukkan nama kategori potensi"
value={createState.create.form.nama || ''}
defaultValue={createState.create.form.nama || ''}
onChange={(e) => (createState.create.form.nama = e.target.value)}
required
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -97,7 +79,7 @@ function CreateKategoriPotensi() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

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

View File

@@ -16,8 +16,7 @@ import {
Text,
TextInput,
Title,
Loader,
ActionIcon
Tooltip,
} from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import { IconArrowBack, IconPhoto, IconUpload, IconX } from "@tabler/icons-react";
@@ -40,16 +39,6 @@ function EditPotensi() {
content: "",
imageId: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
deskripsi: "",
kategoriId: "",
content: "",
imageId: "",
imageUrl: "",
});
// handle input changes
const handleChange = (field: string, value: string) => {
@@ -58,11 +47,11 @@ function EditPotensi() {
useEffect(() => {
potensiDesaState.kategoriPotensi.findMany.load();
const loadPotensi = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await potensiState.edit.load(id);
if (data) {
@@ -73,45 +62,35 @@ function EditPotensi() {
content: data.content || "",
imageId: data.imageId || "",
});
setOriginalData({
name: data.name || "",
deskripsi: data.deskripsi || "",
kategoriId: data.kategoriId || "",
content: data.content || "",
imageId: data.imageId || "",
imageUrl: data.image?.link || "",
});
setPreviewImage(data.image.link);
// // merge, bukan replace
// setFormData((prev) => ({
// ...prev,
// name: data.name ?? prev.name,
// deskripsi: data.deskripsi ?? prev.deskripsi,
// kategoriId: data.kategoriId ?? prev.kategoriId,
// content: data.content ?? prev.content,
// imageId: data.imageId ?? prev.imageId,
// }));
if (data?.image?.link) {
setPreviewImage(data.image.link);
}
}
} catch (error) {
console.error("Error loading potensi:", error);
toast.error("Gagal memuat data potensi");
}
};
loadPotensi();
}, [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 () => {
try {
setIsSubmitting(true);
let imageId = formData.imageId;
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({
file,
@@ -137,22 +116,22 @@ function EditPotensi() {
} catch (error) {
console.error("Error updating potensi:", error);
toast.error("Terjadi kesalahan saat memperbarui potensi");
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: "sm", md: "lg" }} py="md">
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors["blue-button"]} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors["blue-button"]} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Potensi Desa
</Title>
@@ -188,32 +167,6 @@ function EditPotensi() {
</Box>
<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}
onChange={(val) => handleChange("kategoriId", val || "")}
label="Kategori"
@@ -228,7 +181,7 @@ function EditPotensi() {
searchable
required
error={!formData.kategoriId ? "Pilih kategori" : undefined}
/> */}
/>
<Box>
<Text fw="bold" fz="sm" mb={6}>
@@ -269,45 +222,25 @@ function EditPotensi() {
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
Maksimal 5MB, format gambar wajib
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Box mt="sm" style={{ display: "flex", justifyContent: "center" }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
maxHeight: 220,
objectFit: "contain",
border: `1px solid ${colors["blue-button"]}`,
}}
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>
@@ -325,29 +258,17 @@ function EditPotensi() {
</Box>
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
background: `linear-gradient(135deg, ${colors["blue-button"]}, #4facfe)`,
color: "#fff",
boxShadow: "0 4px 15px rgba(79, 172, 254, 0.4)",
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

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

View File

@@ -15,8 +15,7 @@ import {
Text,
TextInput,
Title,
Loader,
ActionIcon
Tooltip,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -30,39 +29,30 @@ function CreatePotensi() {
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
potensiDesaState.kategoriPotensi.findMany.load();
}, []);
const handleSubmit = async () => {
try {
setIsSubmitting(true);
if (!file) return toast.warn('Pilih file gambar terlebih dahulu');
if (!file) return toast.warn('Pilih file gambar terlebih dahulu');
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;
if (!uploaded?.id) {
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);
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal upload gambar');
}
potensiState.create.form.imageId = uploaded.id;
await potensiState.create.create();
resetForm();
router.push('/admin/desa/potensi/list-potensi');
};
const resetForm = () => {
@@ -82,9 +72,11 @@ function CreatePotensi() {
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */}
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Tambah Potensi Desa
</Title>
@@ -101,7 +93,7 @@ function CreatePotensi() {
<Stack gap="md">
{/* Judul */}
<TextInput
value={potensiState.create.form.name}
defaultValue={potensiState.create.form.name}
onChange={(val) => (potensiState.create.form.name = val.target.value)}
label="Judul"
placeholder="Masukkan judul potensi"
@@ -123,32 +115,6 @@ function CreatePotensi() {
{/* Kategori */}
<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"
placeholder="Pilih kategori"
value={potensiState.create.form.kategoriId || ""}
@@ -159,7 +125,7 @@ function CreatePotensi() {
value: item.id,
label: item.nama,
}))}
/> */}
/>
{/* Upload Gambar */}
<Box>
@@ -176,7 +142,7 @@ function CreatePotensi() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
accept={{ 'image/*': [] }}
radius="md"
p="xl"
>
@@ -194,44 +160,17 @@ function CreatePotensi() {
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB)
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Box mt="sm" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading='lazy'
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
@@ -251,17 +190,6 @@ function CreatePotensi() {
{/* Tombol Simpan */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -272,7 +200,7 @@ function CreatePotensi() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

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

View File

@@ -1,10 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconCalendar, IconUser, IconUsers } from '@tabler/icons-react';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import { IconUser, IconUsers, IconCalendar } from '@tabler/icons-react';
function LayoutTabsDetail({ children }: { children: React.ReactNode }) {
const router = useRouter()
@@ -14,19 +14,22 @@ function LayoutTabsDetail({ children }: { children: React.ReactNode }) {
label: "Profile Desa",
value: "profiledesa",
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",
value: "profileperbekel",
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",
value: "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"
}
];
@@ -73,41 +76,42 @@ function LayoutTabsDetail({ children }: { children: React.ReactNode }) {
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) => (
<TabsTab
<TabsPanel
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}}
>
{tab.label}
</TabsTab>
{/* Konten dummy, bisa diganti sesuai routing */}
<>{children}</>
</TabsPanel>
))}
</TabsList>
</ScrollArea>
{tabs.map((tab, i) => (
<TabsPanel
key={i}
value={tab.value}
style={{
padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}}
>
{/* Konten dummy, bisa diganti sesuai routing */}
<>{children}</>
</TabsPanel>
))}
</Tabs>
</Stack>
);
</Tabs>
</Stack>
);
}
export default LayoutTabsDetail;
export default LayoutTabsDetail;

View File

@@ -1,244 +1,154 @@
'use client';
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors';
import {
Alert,
Box,
Button,
Center,
Group,
Loader,
Paper,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { Alert, Box, Button, Center, Group, Paper, Stack, Text, TextInput, Title, Tooltip } from '@mantine/core';
import { IconAlertCircle, IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
// 🧩 Type untuk form
interface FormData {
judul: string;
deskripsi: string;
}
// 🧩 Main Component
function Page() {
const router = useRouter();
const params = useParams();
const lambangState = useProxy(stateProfileDesa.lambangDesa)
const router = useRouter()
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;
}
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);
try {
const data = await lambangState.findUnique.load(id);
lambangState.update.initialize(data);
} catch (error) {
console.error("Error loading lambang:", error);
toast.error("Gagal memuat data lambang desa");
}
};
// 🧭 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;
}
loadData();
setIsLoading(true);
setLoadError(null);
return () => {
lambangState.update.reset();
lambangState.findUnique.reset();
};
}, [params?.id, router]);
try {
const data = await stateProfileDesa.lambangDesa.findUnique.load(id);
if (data) {
const initial: FormData = {
judul: data.judul || '',
deskripsi: data.deskripsi || '',
};
setFormData(initial);
setOriginalData(initial);
// Penting untuk isi id di state sebelum submit
stateProfileDesa.lambangDesa.update.initialize(data);
} else {
setLoadError('Data tidak ditemukan');
const handleSubmit = async () => {
if (isSubmitting || !lambangState.update.form.judul.trim()) {
toast.error("Judul wajib diisi");
return;
}
setIsSubmitting(true);
try {
const success = await lambangState.update.submit();
if (success) {
toast.success("Data berhasil disimpan");
router.push("/admin/desa/profile/profile-desa");
}
} 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);
}
};
loadData();
const handleBack = () => router.back();
return () => {
stateProfileDesa.lambangDesa.update.reset();
stateProfileDesa.lambangDesa.findUnique.reset();
};
}, [params?.id, router]);
// 🔁 Reset form
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;
}
setIsSubmitting(true);
try {
const state = stateProfileDesa.lambangDesa;
state.update.form.judul = formData.judul;
state.update.form.deskripsi = formData.deskripsi;
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 (
<Box>
<Center h={400}>
<Stack align="center" gap="md">
<Loader size="lg" color={colors['blue-button']} />
<Text size="lg" fw={500} c="dimmed">
Memuat data lambang desa...
</Text>
</Stack>
</Center>
</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 */}
// Loading state
if (lambangState.findUnique.loading || lambangState.update.loading) {
return (
<Box>
<Text fw="bold" size="sm" mb={8}>
Deskripsi
</Text>
<EditEditor value={formData.deskripsi} onChange={handleDeskripsiChange} />
<Center h={400}>
<Text>Memuat data...</Text>
</Center>
</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>
);
// Error state
if (lambangState.findUnique.error) {
return (
<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>{lambangState.findUnique.error}</Text>
</Alert>
</Stack>
</Box>
);
}
return (
<Box>
<Stack gap="xs">
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">Edit Lambang Desa</Title>
</Group>
<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>
);
}
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 colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import { Alert, Box, Button, Center, Group, Image, Loader, Paper, SimpleGrid, Stack, Text, TextInput, Title } from '@mantine/core';
import { Box, Button, Group, Image, Paper, SimpleGrid, Stack, Text, TextInput, Title, Tooltip, Center, Alert } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconAlertCircle, IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { IconArrowBack, IconPhoto, IconUpload, IconX, IconAlertCircle } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
@@ -19,8 +19,8 @@ function Page() {
const params = useParams();
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({
judul: '',
deskripsi: '',
@@ -28,12 +28,6 @@ function Page() {
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
judul: "",
deskripsi: "",
images: [] as Array<{ label: string; imageId: string }>
});
// Load data
useEffect(() => {
const loadData = async () => {
@@ -58,17 +52,6 @@ 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) {
setImages(data.images.map((img: any) => ({
file: null,
@@ -94,36 +77,15 @@ function Page() {
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 () => {
if (isSubmitting || !formData.judul.trim()) {
toast.error("Judul wajib diisi");
return;
}
setIsSubmitting(true);
try {
setIsSubmitting(true);
const uploadedImages = [];
// Upload semua gambar baru
@@ -133,7 +95,7 @@ function Page() {
uploadedImages.push({ imageId: img.imageId, label: img.label });
continue;
}
// upload baru
const res = await ApiFetch.api.fileStorage.create.post({
file: img.file,
@@ -146,7 +108,7 @@ function Page() {
}
uploadedImages.push({ imageId: uploaded.id, label: img.label || "main" });
}
// Update ke global state
maskotState.update.updateField("judul", formData.judul);
@@ -199,9 +161,11 @@ function Page() {
<Box>
<Stack gap="xs">
<Group mb="md">
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">Edit Maskot Desa</Title>
</Group>
@@ -213,7 +177,7 @@ function Page() {
<TextInput
label={<Text fw="bold">Judul</Text>}
placeholder="Masukkan judul maskot"
value={formData.judul}
defaultValue={formData.judul}
onChange={(e) => setFormData({ ...formData, judul: e.currentTarget.value })}
error={!formData.judul && "Judul wajib diisi"}
/>
@@ -269,7 +233,7 @@ function Page() {
setImages(updated);
}}
>
<IconX size={16} />
Hapus
</Button>
</Group>
<Image
@@ -298,30 +262,17 @@ function Page() {
</SimpleGrid>
{/* Buttons */}
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Group>
<Button
bg={colors['blue-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 || maskotState.update.loading}
disabled={!formData.judul}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
{isSubmitting ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
<Button variant="outline" onClick={handleBack} disabled={isSubmitting || maskotState.update.loading}>
Batal
</Button>
</Group>
</Stack>

View File

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

View File

@@ -1,247 +1,155 @@
'use client';
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors';
import {
Alert,
Box,
Button,
Center,
Group,
Loader,
Paper,
Stack,
Text,
Title,
} from '@mantine/core';
import { Alert, Box, Button, Center, Group, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
import { IconAlertCircle, IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
// 🔹 Types
interface FormData {
visi: string;
misi: string;
}
// 🔹 Main Component
function Page() {
const router = useRouter();
const params = useParams();
const [formData, setFormData] = useState<FormData>({ visi: '', misi: '' });
const [originalData, setOriginalData] = useState<FormData>({ visi: '', misi: '' });
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const visiMisiState = useProxy(stateProfileDesa.visiMisiDesa)
const router = useRouter()
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;
}
// 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;
}
setIsLoading(true);
setLoadError(null);
try {
const data = await visiMisiState.findUnique.load(id);
visiMisiState.update.initialize(data);
} catch (error) {
console.error("Error loading visi misi:", error);
toast.error("Gagal memuat data visi misi desa");
}
};
try {
const data = await stateProfileDesa.visiMisiDesa.findUnique.load(id);
if (data) {
const initialData: FormData = {
visi: data.visi || '',
misi: data.misi || '',
};
setFormData(initialData);
setOriginalData(initialData);
loadData();
// set id ke state agar submit pakai endpoint benar
stateProfileDesa.visiMisiDesa.update.initialize(data);
} else {
setLoadError('Data tidak ditemukan');
return () => {
visiMisiState.update.reset();
visiMisiState.findUnique.reset();
};
}, [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);
}
};
loadData();
const handleBack = () => router.back();
return () => {
stateProfileDesa.visiMisiDesa.update.reset();
stateProfileDesa.visiMisiDesa.findUnique.reset();
};
}, [params?.id, router]);
// 🔄 Reset Form
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;
// Loading state
if (visiMisiState.findUnique.loading || visiMisiState.update.loading) {
return (
<Box>
<Center h={400}>
<Text>Memuat data...</Text>
</Center>
</Box>
);
}
setIsSubmitting(true);
try {
const originalState = stateProfileDesa.visiMisiDesa;
// 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);
// Error state
if (visiMisiState.findUnique.error) {
return (
<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>
);
}
};
// 🧭 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 (
<Box>
<Center h={400}>
<Stack align="center" gap="md">
<Loader size="lg" color={colors['blue-button']} />
<Text size="lg" fw={500} c="dimmed">
Memuat data...
</Text>
</Stack>
</Center>
</Box>
<Box>
<Stack gap="xs">
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">Edit Visi Misi Desa</Title>
</Group>
<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;

View File

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

View File

@@ -4,7 +4,6 @@ import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
@@ -14,7 +13,7 @@ import {
Text,
TextInput,
Title,
Loader
Tooltip
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -37,16 +36,6 @@ function EditPerbekelDariMasaKeMasa() {
imageId: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
nama: '',
daerah: '',
periode: '',
imageId: '',
imageUrl: "",
});
// load data pertama kali
useEffect(() => {
const loadFoto = async () => {
@@ -61,14 +50,9 @@ function EditPerbekelDariMasaKeMasa() {
periode: data.periode || '',
imageId: data.imageId || ''
});
setOriginalData({
nama: data.nama || '',
daerah: data.daerah || '',
periode: data.periode || '',
imageId: data.imageId || '',
imageUrl: data.image.link || '',
})
setPreviewImage(data.image.link);
if (data?.imageGalleryFoto?.link) {
setPreviewImage(data.imageGalleryFoto.link);
}
}
} catch (error) {
console.error('Error loading foto:', error);
@@ -86,22 +70,8 @@ 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 () => {
try {
setIsSubmitting(true);
// update global state hanya sekali pas submit
state.update.form = { ...state.update.form, ...formData };
@@ -121,17 +91,17 @@ function EditPerbekelDariMasaKeMasa() {
} catch (error) {
console.error('Error updating perbekel dari masa ke masa:', error);
toast.error('Terjadi kesalahan saat memperbarui perbekel dari masa ke masa');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Perbekel Dari Masa Ke Masa
</Title>
@@ -168,7 +138,7 @@ function EditPerbekelDariMasaKeMasa() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
accept={{ 'image/*': [] }}
radius="md"
p="xl"
>
@@ -187,45 +157,25 @@ function EditPerbekelDariMasaKeMasa() {
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
Maksimal 5MB, format gambar wajib
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 200,
maxHeight: 220,
objectFit: 'contain',
border: '1px solid #ddd',
border: `1px solid ${colors['blue-button']}`,
}}
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>
@@ -247,17 +197,6 @@ function EditPerbekelDariMasaKeMasa() {
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -268,7 +207,7 @@ function EditPerbekelDariMasaKeMasa() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

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

View File

@@ -2,7 +2,7 @@
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import { Loader, ActionIcon, Box, Button, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import { Box, Button, Group, Image, Paper, Stack, Text, TextInput, Title, Tooltip } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
@@ -14,7 +14,6 @@ function CreatePerbekelDariMasaKeMasa() {
const state = useProxy(stateProfileDesa.mantanPerbekel);
const router = useRouter();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [file, setFile] = useState<File | null>(null);
const resetForm = () => {
@@ -29,39 +28,33 @@ function CreatePerbekelDariMasaKeMasa() {
};
const handleSubmit = async () => {
try {
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);
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');
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Back button + Title */}
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Create Perbekel Dari Masa Ke Masa
</Title>
@@ -79,21 +72,21 @@ function CreatePerbekelDariMasaKeMasa() {
<TextInput
label="Nama Perbekel"
placeholder="Masukkan nama perbekel"
value={state.create.form.nama}
defaultValue={state.create.form.nama}
onChange={(e) => (state.create.form.nama = e.target.value)}
required
/>
<TextInput
label="Daerah"
placeholder="Masukkan daerah"
value={state.create.form.daerah}
defaultValue={state.create.form.daerah}
onChange={(e) => (state.create.form.daerah = e.target.value)}
required
/>
<TextInput
label="Periode"
placeholder="Masukkan periode"
value={state.create.form.periode}
defaultValue={state.create.form.periode}
onChange={(e) => (state.create.form.periode = e.target.value)}
required
/>
@@ -111,7 +104,7 @@ function CreatePerbekelDariMasaKeMasa() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
accept={{ 'image/*': [] }}
radius="md"
p="xl"
>
@@ -127,63 +120,25 @@ function CreatePerbekelDariMasaKeMasa() {
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
Seret gambar atau klik untuk memilih file (maks 5MB)
</Text>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Box mt="sm" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading='lazy'
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Submit */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -194,7 +149,7 @@ function CreatePerbekelDariMasaKeMasa() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
Simpan
</Button>
</Group>
</Stack>

View File

@@ -1,6 +1,6 @@
'use client'
import colors from '@/con/colors';
import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core';
import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title, Tooltip } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
@@ -49,6 +49,7 @@ function ListPerbekelDariMasaKeMasa({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify='space-between' mb="md">
<Title order={4}>List Perbekel Dari Masa Ke Masa</Title>
<Tooltip label="Tambah Perbekel Baru" withArrow>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
@@ -57,6 +58,7 @@ function ListPerbekelDariMasaKeMasa({ search }: { search: string }) {
>
Tambah Baru
</Button>
</Tooltip>
</Group>
<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 colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Center, Group, Image, Paper, Stack, Text, Title } from '@mantine/core';
import { Box, Button, Center, Group, Image, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconImageInPicture, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { IconArrowBack, IconPhoto, IconUpload, IconX, IconImageInPicture } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
@@ -101,9 +101,11 @@ function ProfilePerbekel() {
<Stack gap="xs">
{/* Header */}
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Profil Perbekel
</Title>
@@ -126,8 +128,8 @@ function ProfilePerbekel() {
<Dropzone
onDrop={(files) => handleFileChange(files[0])}
onReject={() => toast.error('File tidak valid.')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
maxSize={5 * 1024 ** 2} // 5MB
accept={{ 'image/*': [] }}
>
<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>

View File

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

View File

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

View File

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

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