Compare commits

...

12 Commits

Author SHA1 Message Date
716db0adca Fix Middleware
Fix Layout sesuai role, dan superadmin bisa menambahkan menu ke user jika diperlukan
Penambahan menu di user & role : menu access
2025-11-24 16:02:13 +08:00
a291bdfb51 Tampilan Layout sudah sesuai dengan roleIdnya
Sudah sessionnya
Sudah disesuaikan juga semisal superadmin ngubah role admin, maka admin tersebut akan logOut dan diarahkan ke halama login
sudah bisa logOut
2025-11-21 17:26:38 +08:00
0dff8f3254 Nico 20 Nov 25
Dibagian layout admin sudah disesuaikan dengan rolenya : supadmin, admin desa, admin kesehatan, admin pendidikan
Fix API User & Role Admin
2025-11-20 16:42:36 +08:00
78b8aa74cd Saat user baru registrasi maka akan diarahkan ke page waiting-room dan menunggu validasi admin 2025-11-20 14:07:26 +08:00
a0537810e8 Login, Register, Verifkasi Code Admin V1 2025-11-20 02:42:39 +08:00
b3c169a2d4 Fix create admin & progress bar persentase 2025-11-18 17:23:38 +08:00
2608a5ffdd Fix Edit di Admin APbdes, dan fix data real di apbdes user 2025-11-18 16:26:09 +08:00
6c32f3ebdb Fix Route APBdes 2025-11-18 14:27:53 +08:00
0feeb4de93 Fix SDGs Desa Barchart sudah responsive, tabel dan bar progress di menu apbdes sudah sesuai dengan data 2025-11-18 11:56:16 +08:00
9622eb5a9a Fix QC Kak Inno Admin, Fix QC Keano UI User, Fix QC Pak jun tabel apbdes 2025-11-12 17:42:31 +08:00
417a8937f5 Semua tooltips di admin sudah dihilangkan 2025-11-07 14:38:32 +08:00
db8909b9ed Fix Text to Speech Menu Landing Page && Add barchart Landing Page APBDes 2025-11-06 11:35:04 +08:00
522 changed files with 20061 additions and 8488 deletions

View File

@@ -3,9 +3,9 @@
"version": "0.1.5",
"private": true,
"scripts": {
"dev": "bun --bun next dev",
"build": "bun --bun next build",
"start": "bun --bun next start"
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"prisma": {
"seed": "bun run prisma/seed.ts"

View File

@@ -1,23 +1,26 @@
[
{
"id": "role-1",
"name": "ADMIN DESA",
"description": "Administrator Desa",
"permissions": ["manage_users", "manage_content", "view_reports"],
"isActive": true
},
{
"id": "role-2",
"name": "ADMIN KESEHATAN",
"description": "Administrator Bidang Kesehatan",
"permissions": ["manage_health_data", "view_reports"],
"isActive": true
},
{
"id": "role-3",
"name": "ADMIN SEKOLAH",
"description": "Administrator Sekolah",
"permissions": ["manage_school_data", "view_reports"],
"isActive": true
}
]
{
"id": "0",
"name": "SUPER ADMIN",
"description": "Administrator",
"isActive": true
},
{
"id": "1",
"name": "ADMIN DESA",
"description": "Administrator Desa",
"isActive": true
},
{
"id": "2",
"name": "ADMIN KESEHATAN",
"description": "Administrator Bidang Kesehatan",
"isActive": true
},
{
"id": "3",
"name": "ADMIN PENDIDIKAN",
"description": "Administrator Bidang Pendidikan",
"isActive": true
}
]

View File

@@ -1,23 +0,0 @@
[
{
"id": "user-1",
"nama": "Admin Desa",
"nomor": "089647037426",
"roleId": "role-1",
"isActive": true
},
{
"id": "user-2",
"nama": "Admin Kesehatan",
"nomor": "082339004198",
"roleId": "role-2",
"isActive": true
},
{
"id": "user-3",
"nama": "Admin Sekolah",
"nomor": "085237157222",
"roleId": "role-3",
"isActive": true
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -184,18 +184,46 @@ model SdgsDesa {
//========================================= APBDes ========================================= //
model APBDes {
id String @id @default(cuid())
name String
jumlah String
tahun Int?
name String? // misalnya: "APBDes Tahun 2025"
deskripsi String?
jumlah String? // total keseluruhan (opsional, bisa juga dihitung dari items)
items APBDesItem[]
image FileStorage? @relation("APBDesImage", fields: [imageId], references: [id])
imageId String?
file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id])
fileId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime? // opsional, tidak perlu default now()
isActive Boolean @default(true)
}
model APBDesItem {
id String @id @default(cuid())
kode String // contoh: "4", "4.1", "4.1.2"
uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha"
anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS)
realisasi Float
selisih Float // realisasi - anggaran
persentase Float
tipe String? // (realisasi / anggaran) * 100
level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail
parentId String? // untuk relasi hierarki
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
children APBDesItem[] @relation("APBDesItemParent")
apbdesId String
apbdes APBDes @relation(fields: [apbdesId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
isActive Boolean @default(true)
@@index([kode])
@@index([level])
@@index([apbdesId])
}
//========================================= PRESTASI DESA ========================================= //
model PrestasiDesa {
id String @id @default(cuid())
@@ -1942,23 +1970,28 @@ model KeunggulanProgram {
}
model BeasiswaPendaftar {
id String @id @default(cuid())
id String @id @default(cuid())
namaLengkap String
nik String @unique
nis String?
kelas String?
jenisKelamin JenisKelamin
alamatDomisili String?
tempatLahir String
tanggalLahir DateTime
jenisKelamin JenisKelamin
kewarganegaraan String
agama Agama
alamatKTP String
alamatDomisili String?
namaOrtu String?
nik String @unique
pekerjaanOrtu String?
penghasilan String?
noHp String
email String @unique
statusPernikahan StatusPernikahan
kewarganegaraan String?
agama Agama?
alamatKTP String?
email String? @unique
statusPernikahan StatusPernikahan?
ukuranBaju UkuranBaju?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum JenisKelamin {
@@ -2130,25 +2163,26 @@ enum StatusPeminjaman {
// ========================================= USER ========================================= //
model User {
id String @id @default(cuid())
username String
nomor String @unique
role Role @relation(fields: [roleId], references: [id])
roleId String @default("1")
instansi String?
UserSession UserSession? // Nama instansi (Puskesmas, Sekolah, dll)
isActive Boolean @default(true)
lastLogin DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
id String @id @default(cuid())
username String @unique
nomor String @unique
role Role @relation(fields: [roleId], references: [id])
roleId String @default("1")
instansi String?
UserSession UserSession? // Nama instansi (Puskesmas, Sekolah, dll)
isActive Boolean @default(true)
lastLogin DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
sessionInvalid Boolean @default(false)
UserMenuAccess UserMenuAccess[]
}
model Role {
id String @id @default(cuid())
name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH
description String?
permissions Json // Menyimpan permission dalam format JSON
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2167,17 +2201,6 @@ model KodeOtp {
otp Int
}
// Tabel untuk menyimpan permission
model Permission {
id String @id @default(cuid())
name String @unique
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("permissions")
}
model UserSession {
id String @id @default(cuid())
token String
@@ -2189,6 +2212,18 @@ model UserSession {
userId String @unique
}
model UserMenuAccess {
id String @id @default(cuid())
userId String
menuId String // ID menu (misal: "Landing Page", "Kesehatan")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali
}
// ========================================= DATA PENDIDIKAN ========================================= //
model DataPendidikan {
id String @id @default(cuid())

View File

@@ -54,14 +54,13 @@ import tujuanProgram2 from "./data/pendidikan/pendidikan-non-formal/tujuan-progr
import programUnggulan from "./data/pendidikan/program-pendidikan-anak/program-unggulan.json";
import tujuanProgram from "./data/pendidikan/program-pendidikan-anak/tujuan-program.json";
import roles from "./data/user/roles.json";
import users from "./data/user/users.json";
import fileStorage from "./data/file-storage.json";
import jenjangPendidikan from "./data/pendidikan/info-sekolah/jenjang-pendidikan.json";
import seedAssets from "./seed_assets";
import { safeSeedUnique } from "./safeseedUnique";
(async () => {
// =========== USER & ROLE ===========
// =========== ROLE ===========
// In your seed.ts
// =========== ROLES ===========
console.log("🔄 Seeding roles...");
@@ -69,35 +68,12 @@ import { safeSeedUnique } from "./safeseedUnique";
await safeSeedUnique("role", { id: r.id }, {
name: r.name,
description: r.description,
permissions: r.permissions,
isActive: r.isActive,
});
}
console.log("✅ Roles seeded");
// =========== USERS ===========
console.log("🔄 Seeding users...");
for (const u of users) {
// First verify the role exists
const roleExists = await prisma.role.findUnique({
where: { id: u.roleId },
});
if (!roleExists) {
console.error(`❌ Role with id ${u.roleId} not found for user ${u.nama}`);
continue;
}
await safeSeedUnique("user", { id: u.id }, {
username: u.nama,
nomor: u.nomor,
roleId: u.roleId,
isActive: u.isActive,
});
}
console.log("✅ Users seeded");
// =========== FILE STORAGE ===========
console.log("🔄 Seeding file storage...");
for (const f of fileStorage) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,7 +41,7 @@ const programKreatifState = proxy({
if (res.status === 200) {
programKreatifState.findMany.load();
toast.success("success create");
toast.success("Sukses menambahkan");
return true;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,58 +5,117 @@ import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
const templateapbDesaForm = z.object({
name: z.string().min(1, "Judul minimal 1 karakter"),
jumlah: z.string().min(1, "Deskripsi minimal 1 karakter"),
imageId: z.string().min(1, "File minimal 1"),
fileId: z.string().min(1, "File minimal 1"),
// --- Zod Schema ---
const ApbdesItemSchema = z.object({
kode: z.string().min(1, "Kode wajib diisi"),
uraian: z.string().min(1, "Uraian wajib diisi"),
anggaran: z.number().min(0),
realisasi: z.number().min(0),
selisih: z.number(),
persentase: z.number(),
level: z.number().int().min(1).max(3),
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
});
const defaultapbdesForm = {
name: "",
jumlah: "",
const ApbdesFormSchema = z.object({
tahun: z.number().int().min(2000, "Tahun tidak valid"),
imageId: z.string().min(1, "Gambar wajib diunggah"),
fileId: z.string().min(1, "File wajib diunggah"),
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
});
// --- Default Form ---
const defaultApbdesForm = {
tahun: new Date().getFullYear(),
imageId: "",
fileId: "",
items: [] as z.infer<typeof ApbdesItemSchema>[],
};
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
const anggaran = item.anggaran ?? 0;
const realisasi = item.realisasi ?? 0;
// ✅ Formula yang benar
const selisih = anggaran - realisasi; // positif = sisa anggaran, negatif = over budget
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran
return {
kode: item.kode || "",
uraian: item.uraian || "",
anggaran,
realisasi,
selisih,
persentase,
level: item.level || 1,
tipe: item.tipe, // biarkan null jika memang null
};
}
// --- State Utama ---
const apbdes = proxy({
create: {
form: { ...defaultapbdesForm },
form: { ...defaultApbdesForm },
loading: false,
async create() {
const cek = templateapbDesaForm.safeParse(apbdes.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
apbdes.create.loading = true;
const res = await ApiFetch.api.landingpage.apbdes["create"].post({
...apbdes.create.form,
});
if (res.status === 200) {
addItem(item: Partial<z.infer<typeof ApbdesItemSchema>>) {
const normalized = normalizeItem(item);
this.form.items.push(normalized);
},
removeItem(index: number) {
this.form.items.splice(index, 1);
},
updateItem(index: number, updates: Partial<z.infer<typeof ApbdesItemSchema>>) {
const current = this.form.items[index];
if (current) {
const updated = normalizeItem({ ...current, ...updates });
this.form.items[index] = updated;
}
},
reset() {
this.form = { ...defaultApbdesForm };
},
async create() {
const parsed = ApbdesFormSchema.safeParse(this.form);
if (!parsed.success) {
const errors = parsed.error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`);
toast.error(`Validasi gagal:\n${errors.join("\n")}`);
return;
}
try {
this.loading = true;
const res = await ApiFetch.api.landingpage.apbdes["create"].post(parsed.data);
if (res.data?.success) {
toast.success("APBDes berhasil dibuat");
apbdes.findMany.load();
return toast.success("Data berhasil ditambahkan");
this.reset();
} else {
toast.error(res.data?.message || "Gagal membuat APBDes");
}
return toast.error("Gagal menambahkan data");
} catch (error) {
console.log(error);
toast.error("Gagal menambahkan data");
} catch (error: any) {
console.error("Create APBDes error:", error);
toast.error(error?.message || "Terjadi kesalahan saat membuat APBDes");
} finally {
apbdes.create.loading = false;
this.loading = false;
}
},
},
findMany: {
data: null as
| Prisma.APBDesGetPayload<{
include: {
image: true;
file: true;
};
include: { image: true; file: true; items: true };
}>[]
| null,
page: 1,
@@ -64,194 +123,202 @@ const apbdes = proxy({
total: 0,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => { // Change to arrow function
apbdes.findMany.loading = true; // Use the full path to access the property
load: async (page = 1, limit = 10, search = "") => {
apbdes.findMany.loading = true;
apbdes.findMany.page = page;
apbdes.findMany.search = search;
try {
const query: any = { page, limit };
const query: Record<string, string> = { page: String(page), limit: String(limit) };
if (search) query.search = search;
const res = await ApiFetch.api.landingpage.apbdes[
"findMany"
].get({
query
});
if (res.status === 200 && res.data?.success) {
const res = await ApiFetch.api.landingpage.apbdes["findMany"].get({ query });
if (res.data?.success) {
apbdes.findMany.data = res.data.data || [];
apbdes.findMany.total = res.data.total || 0;
apbdes.findMany.totalPages = res.data.totalPages || 1;
apbdes.findMany.total = res.data.meta?.total || 0;
apbdes.findMany.totalPages = res.data.meta?.totalPages || 1;
} else {
console.error("Failed to load pegawai:", res.data?.message);
apbdes.findMany.data = [];
apbdes.findMany.total = 0;
apbdes.findMany.totalPages = 1;
toast.error(res.data?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error loading pegawai:", error);
console.error("FindMany error:", error);
apbdes.findMany.data = [];
apbdes.findMany.total = 0;
apbdes.findMany.totalPages = 1;
toast.error("Gagal memuat daftar APBDes");
} finally {
apbdes.findMany.loading = false;
}
},
},
findUnique: {
data: null as Prisma.APBDesGetPayload<{
include: {
image: true;
file: true;
};
}> | null,
data: null as
| Prisma.APBDesGetPayload<{
include: { image: true; file: true; items: true };
}>
| null,
loading: false,
error: null as string | null,
async load(id: string) {
if (!id || id.trim() === '') {
this.data = null;
this.error = "ID tidak valid";
return;
}
this.loading = true;
this.error = null;
try {
const res = await fetch(`/api/landingpage/apbdes/${id}`);
if (res.ok) {
const data = await res.json();
apbdes.findUnique.data = data.data ?? null;
// Pastikan URL-nya benar
const url = `/api/landingpage/apbdes/${id}`;
console.log("🌐 Fetching:", url);
// Gunakan fetch biasa atau ApiFetch dengan cara yang benar
const response = await fetch(url);
const res = await response.json();
console.log("📦 Response:", res);
if (res.success && res.data) {
this.data = res.data;
} else {
console.error("Failed to fetch data", res.status, res.statusText);
apbdes.findUnique.data = null;
this.data = null;
this.error = res.message || "Gagal memuat detail APBDes";
toast.error(this.error);
}
} catch (error) {
console.error("Error fetching data:", error);
apbdes.findUnique.data = null;
console.error("❌ FindUnique error:", error);
this.data = null;
this.error = "Gagal memuat detail APBDes";
toast.error(this.error);
} finally {
this.loading = false;
}
},
}
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
apbdes.delete.loading = true;
this.loading = true;
const res = await (ApiFetch.api.landingpage.apbdes as any)["del"][id].delete();
const response = await fetch(`/api/landingpage/apbdes/del/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "apbdes berhasil dihapus");
await apbdes.findMany.load(); // refresh list
if (res.data?.success) {
toast.success("APBDes berhasil dihapus");
apbdes.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus apbdes");
toast.error(res.data?.message || "Gagal menghapus APBDes");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus apbdes");
} catch (error: any) {
console.error("Delete error:", error);
toast.error(error?.message || "Terjadi kesalahan saat menghapus");
} finally {
apbdes.delete.loading = false;
this.loading = false;
}
},
},
edit: {
id: "",
form: { ...defaultapbdesForm },
form: { ...defaultApbdesForm },
loading: false,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
if (!id) return toast.warn("ID tidak valid");
try {
apbdes.edit.loading = true;
this.loading = true;
const res = await (ApiFetch.api.landingpage.apbdes as any)[id].get();
const response = await fetch(`/api/landingpage/apbdes/${id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result?.success) {
const data = result.data;
if (res.data?.success) {
const data = res.data.data;
this.id = data.id;
this.form = {
name: data.name,
jumlah: data.jumlah,
imageId: data.imageId,
fileId: data.fileId,
tahun: data.tahun || new Date().getFullYear(),
imageId: data.imageId || "",
fileId: data.fileId || "",
items: (data.items || []).map((item: any) => ({
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
realisasi: item.realisasi,
selisih: item.selisih,
persentase: item.persentase,
level: item.level,
tipe: item.tipe || 'pendapatan',
})),
};
return data;
} else {
throw new Error(result?.message || "Gagal memuat data");
throw new Error(res.data?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error loading apbdes:", error);
toast.error(
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
} catch (error: any) {
console.error("Edit load error:", error);
toast.error(error.message || "Gagal memuat data untuk diedit");
} finally {
apbdes.edit.loading = false;
this.loading = false;
}
},
async update() {
const cek = templateapbDesaForm.safeParse(apbdes.edit.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
const parsed = ApbdesFormSchema.safeParse(this.form);
if (!parsed.success) {
const errors = parsed.error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`);
toast.error(`Validasi gagal:\n${errors.join("\n")}`);
return false;
}
try {
apbdes.edit.loading = true;
const response = await fetch(`/api/landingpage/apbdes/${this.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: this.form.name,
jumlah: this.form.jumlah,
imageId: this.form.imageId,
fileId: this.form.fileId,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
}
const result = await response.json();
if (result.success) {
toast.success("Berhasil update apbdes");
await apbdes.findMany.load(); // refresh list
this.loading = true;
// Include the ID in the request body
const requestData = {
...parsed.data,
id: this.id, // Add the ID to the request body
};
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
if (res.data?.success) {
toast.success("APBDes berhasil diperbarui");
apbdes.findMany.load();
return true;
} else {
throw new Error(result.message || "Gagal mengupdate apbdes");
throw new Error(res.data?.message || "Gagal memperbarui APBDes");
}
} catch (error) {
console.error("Error updating apbdes:", error);
toast.error(
error instanceof Error ? error.message : "Gagal mengupdate apbdes"
);
} catch (error: any) {
console.error("Update error:", error);
toast.error(error.message || "Gagal memperbarui APBDes");
return false;
} finally {
apbdes.edit.loading = false;
this.loading = false;
}
},
addItem(item: Partial<z.infer<typeof ApbdesItemSchema>>) {
const normalized = normalizeItem(item);
this.form.items.push(normalized);
},
removeItem(index: number) {
this.form.items.splice(index, 1);
},
reset() {
apbdes.edit.id = "";
apbdes.edit.form = { ...defaultapbdesForm };
this.id = "";
this.form = { ...defaultApbdesForm };
},
},
});
export default apbdes;
export default apbdes;

View File

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

View File

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

View File

@@ -9,34 +9,32 @@ import { z } from "zod";
const templateBeasiswaPendaftar = z.object({
namaLengkap: z.string().min(1, "Nama harus diisi"),
nik: z.string().min(1, "NIK harus diisi"),
nis: z.string().min(1, "NIS harus diisi"),
kelas: z.string().min(1, "Kelas harus diisi"),
jenisKelamin: z.string().min(1, "Jenis kelamin harus diisi"),
alamatDomisili: z.string().min(1, "Alamat domisili harus diisi"),
tempatLahir: z.string().min(1, "Tempat lahir harus diisi"),
tanggalLahir: z.string().min(1, "Tanggal lahir harus diisi"),
jenisKelamin: z.string().min(1, "Jenis kelamin harus diisi"),
kewarganegaraan: z.string().min(1, "Kewarganegaraan harus diisi"),
agama: z.string().min(1, "Agama harus diisi"),
alamatKTP: z.string().min(1, "Alamat KTP harus diisi"),
alamatDomisili: z.string().min(1, "Alamat domisili harus diisi"),
namaOrtu: z.string().min(1, "Nama ortu harus diisi"),
nik: z.string().min(1, "NIK harus diisi"),
pekerjaanOrtu: z.string().min(1, "Pekerjaan ortu harus diisi"),
penghasilan: z.string().min(1, "Penghasilan ortu harus diisi"),
noHp: z.string().min(1, "No HP harus diisi"),
email: z.string().min(1, "Email harus diisi"),
statusPernikahan: z.string().min(1, "Status pernikahan harus diisi"),
ukuranBaju: z.string().min(1, "Ukuran baju harus diisi"),
});
const defaultBeasiswaPendaftar = {
namaLengkap: "",
nik: "",
nis: "",
kelas: "",
jenisKelamin: "",
alamatDomisili: "",
tempatLahir: "",
tanggalLahir: "",
jenisKelamin: "",
kewarganegaraan: "",
agama: "",
alamatKTP: "",
alamatDomisili: "",
namaOrtu: "",
nik: "",
pekerjaanOrtu: "",
penghasilan: "",
noHp: "",
email: "",
statusPernikahan: "",
ukuranBaju: "",
};
const beasiswaPendaftar = proxy({
@@ -200,18 +198,17 @@ const beasiswaPendaftar = proxy({
this.id = data.id;
this.form = {
namaLengkap: data.namaLengkap,
nik: data.nik,
nis: data.nis,
kelas: data.kelas,
jenisKelamin: data.jenisKelamin,
alamatDomisili: data.alamatDomisili,
tempatLahir: data.tempatLahir,
tanggalLahir: data.tanggalLahir,
jenisKelamin: data.jenisKelamin,
kewarganegaraan: data.kewarganegaraan,
agama: data.agama,
alamatKTP: data.alamatKTP,
alamatDomisili: data.alamatDomisili,
namaOrtu: data.namaOrtu,
nik: data.nik,
pekerjaanOrtu: data.pekerjaanOrtu,
penghasilan: data.penghasilan,
noHp: data.noHp,
email: data.email,
statusPernikahan: data.statusPernikahan,
ukuranBaju: data.ukuranBaju,
};
return data; // Return the loaded data
} else {
@@ -249,17 +246,17 @@ const beasiswaPendaftar = proxy({
},
body: JSON.stringify({
namaLengkap: this.form.namaLengkap,
nik: this.form.nik,
tanggalLahir: this.form.tanggalLahir,
nis: this.form.nis,
kelas: this.form.kelas,
jenisKelamin: this.form.jenisKelamin,
kewarganegaraan: this.form.kewarganegaraan,
agama: this.form.agama,
alamatKTP: this.form.alamatKTP,
alamatDomisili: this.form.alamatDomisili,
tempatLahir: this.form.tempatLahir,
tanggalLahir: this.form.tanggalLahir,
namaOrtu: this.form.namaOrtu,
nik: this.form.nik,
pekerjaanOrtu: this.form.pekerjaanOrtu,
penghasilan: this.form.penghasilan,
noHp: this.form.noHp,
email: this.form.email,
statusPernikahan: this.form.statusPernikahan,
ukuranBaju: this.form.ukuranBaju,
}),
}
);

View File

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

View File

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

View File

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

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("success create");
return toast.success("Sukses menambahkan");
}
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("success create");
return toast.success("Sukses menambahkan");
}
return toast.error("failed create");
} catch (error) {

View File

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

View File

@@ -90,42 +90,96 @@ const userState = proxy({
}
},
},
updateActive: {
deleteUser: {
loading: false,
async submit(id: string, isActive: boolean) {
this.loading = true;
async delete(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
const res = await fetch(`/api/user/updt`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, isActive }),
userState.deleteUser.loading = true;
const response = await fetch(`/api/user/delUser/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
const data = await res.json();
if (res.status === 200 && data.success) {
toast.success(data.message);
userState.findMany.load(userState.findMany.page, 10, userState.findMany.search);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "User berhasil dihapus permanen");
await userState.findMany.load(); // refresh list user setelah delete
} else {
toast.error(data.message || "Gagal update status user");
toast.error(result?.message || "Gagal menghapus user");
}
} catch (e) {
console.error(e);
toast.error("Gagal update status user");
} catch (error) {
console.error("Gagal delete user:", error);
toast.error("Terjadi kesalahan saat menghapus user");
} finally {
this.loading = false;
userState.delete.loading = false;
}
},
},
// Di file userState.ts atau dimana state user berada
update: {
loading: false,
async submit(payload: { id: string; isActive?: boolean; roleId?: string }) {
this.loading = true;
try {
const res = await fetch(`/api/user/updt`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.status === 200 && data.success) {
// ✅ Tampilkan pesan yang berbeda jika role berubah
if (data.roleChanged) {
toast.success(
`${data.message}\n\nUser akan logout otomatis dalam beberapa detik.`,
{
autoClose: 5000,
}
);
} else {
toast.success(data.message);
}
// Refresh list
await userState.findMany.load(
userState.findMany.page,
10,
userState.findMany.search
);
return true; // ✅ Return success untuk handling di component
} else {
toast.error(data.message || "Gagal update user");
return false;
}
} catch (e) {
console.error("❌ Error update user:", e);
toast.error("Gagal update user");
return false;
} finally {
this.loading = false;
}
},
},
});
const templateRole = z.object({
name: z.string().min(1, "Nama harus diisi"),
permissions: z.array(z.string()).min(1, "Permission harus diisi"),
});
const defaultRole = {
name: "",
permissions: [] as string[],
};
const roleState = proxy({
@@ -237,7 +291,7 @@ const roleState = proxy({
toast.warn("ID tidak valid");
return null;
}
try {
const response = await fetch(`/api/role/${id}`, {
method: "GET",
@@ -245,31 +299,25 @@ const roleState = proxy({
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result?.success) {
const data = result.data;
this.id = data.id;
this.form = {
// langsung set melalui root state, bukan this
roleState.update.id = data.id;
roleState.update.form = {
name: data.name,
permissions: data.permissions,
};
return data; // Return the loaded data
} else {
throw new Error(result?.message || "Gagal memuat data");
return data;
}
} catch (error) {
console.error("Error loading role:", error);
toast.error(
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
toast.error("Gagal memuat data");
}
},
},
async update() {
const cek = templateRole.safeParse(roleState.update.form);
if (!cek.success) {
@@ -290,7 +338,6 @@ const roleState = proxy({
},
body: JSON.stringify({
name: this.form.name,
permissions: this.form.permissions,
}),
});

View File

@@ -1,104 +1,98 @@
'use client'
import { apiFetchLogin } from '@/app/admin/auth/_lib/api_fetch_auth';
'use client';
import { apiFetchLogin } from '@/app/api/auth/_lib/api_fetch_auth';
import colors from '@/con/colors';
import { Box, Button, Center, Flex, Image, Paper, Stack, Text, Title } from '@mantine/core';
import Link from 'next/link';
import { Box, Button, Center, Image, Paper, Stack, Title } from '@mantine/core';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { PhoneInput } from "react-international-phone";
import "react-international-phone/style.css";
import { PhoneInput } from 'react-international-phone';
import 'react-international-phone/style.css';
import { toast } from 'react-toastify';
function Login() {
const router = useRouter()
const [phone, setPhone] = useState("")
const [isError, setError] = useState(false)
const [loading, setLoading] = useState(false)
const router = useRouter();
const [phone, setPhone] = useState('');
const [loading, setLoading] = useState(false);
// Login.tsx
async function onLogin() {
const nomor = phone.substring(1);
if (nomor.length <= 4) return setError(true)
const cleanPhone = phone.replace(/\D/g, '');
if (cleanPhone.length < 10) {
toast.error('Nomor telepon tidak valid');
return;
}
try {
setLoading(true);
const response = await apiFetchLogin({ nomor: nomor })
if (response && response.success) {
localStorage.setItem("hipmi_auth_code_id", response.kodeId);
toast.success(response.message);
router.push("/validasi", { scroll: false });
const response = await apiFetchLogin({ nomor: cleanPhone });
if (!response.success) {
toast.error(response.message || 'Gagal memproses login');
return;
}
// Simpan nomor untuk register
localStorage.setItem('auth_nomor', cleanPhone);
if (response.isRegistered) {
// ✅ User lama: simpan kodeId & ke validasi
localStorage.setItem('auth_kodeId', response.kodeId);
router.push('/validasi');
} else {
setLoading(false);
toast.error(response?.message);
// ❌ User baru: langsung ke registrasi (tanpa kodeId)
router.push('/registrasi');
}
} catch (error) {
setLoading(false)
console.log("Error Login", error)
toast.error("Terjadi kesalahan saat login")
console.error('Error Login:', error);
toast.error('Terjadi kesalahan saat login');
} finally {
setLoading(false);
}
}
return (
<Stack pos={"relative"} bg={colors.Bg}>
<Stack pos="relative" bg={colors.Bg}>
<Box px={{ base: 'md', md: 100 }} pb={50}>
<Stack align='center' justify='center' h={"100vh"}>
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
<Stack align='center' gap={"lg"}>
<Stack align="center" justify="center" h="100vh">
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
<Stack align="center" gap="lg">
<Box>
<Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}>
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
Login
</Title>
<Center>
<Image loading="lazy" src={"/darmasaba-icon.png"} alt="" w={80} />
<Image
loading="lazy"
src="/darmasaba-icon.png"
alt="Logo"
w={80}
h={80}
/>
</Center>
</Box>
<Box>
{/* <Box mb={10}>
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masuk Untuk Akses Admin</Text>
<TextInput
label='Username'
placeholder='Username'
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</Box> */}
<Box w="100%">
<PhoneInput
countrySelectorStyleProps={{
buttonStyle: {
backgroundColor: colors['blue-button'],
},
}}
inputStyle={{ width: "100%"}}
inputStyle={{ width: '100%' }}
defaultCountry="id"
onChange={(val) => {
setPhone(val);
}}
value={phone}
onChange={(val) => setPhone(val)}
/>
{isError ? (
toast.error("Masukan nomor telepon anda")
) : (
""
)}
<Box py={20} >
<Box py={20}>
<Button
fullWidth
bg={colors['blue-button']}
radius={'xl'}
radius="xl"
onClick={onLogin}
loading={loading ? true : false}
>Masuk
loading={loading}
>
Masuk
</Button>
</Box>
<Flex justify={'center'} align={'center'}>
<Text>Belum punya akun? </Text>
<Button variant='transparent' component={Link} href={'/registrasi'}>
<Text c={colors['blue-button']} fw={'bold'}>Registrasi</Text>
</Button>
</Flex>
</Box>
</Stack>
</Paper>
@@ -108,4 +102,4 @@ function Login() {
);
}
export default Login;
export default Login;

View File

@@ -1,113 +1,127 @@
/* eslint-disable @typescript-eslint/no-unused-expressions */
'use client'
import { apiFetchRegister } from '@/app/admin/auth/_lib/api_fetch_auth';
// app/registrasi/page.tsx
'use client';
import { apiFetchRegister } from '@/app/api/auth/_lib/api_fetch_auth';
import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto';
import colors from '@/con/colors';
import { Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import {
Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title,
} from '@mantine/core';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { PhoneInput } from "react-international-phone";
import "react-international-phone/style.css";
import { useEffect, useState } from 'react';
import { PhoneInput } from 'react-international-phone';
import 'react-international-phone/style.css';
import { toast } from 'react-toastify';
function Registrasi() {
const [phone, setPhone] = useState("")
const router = useRouter()
const [value, setValue] = useState("")
const [isValue, setIsValue] = useState(false);
export default function Registrasi() {
const router = useRouter();
const [username, setUsername] = useState('');
const [loading, setLoading] = useState(false);
const [phone, setPhone] = useState(''); // ✅ tambahkan state untuk phone
async function onRegistarsi() {
if (value.length < 5) {
toast.error("Username minimal 5 karakter!");
// Ambil data dari localStorage (dari login)
useEffect(() => {
const storedNomor = localStorage.getItem('auth_nomor');
if (!storedNomor) {
toast.error('Akses tidak valid');
router.push('/login');
return;
}
if (value.includes(" ")) {
toast.error("Username tidak boleh ada spasi!");
setPhone(storedNomor);
}, [router]);
const handleRegister = async () => {
if (!username || username.trim().length < 5) {
toast.error('Username minimal 5 karakter!');
return;
}
if (!phone) {
toast.error("Nomor telepon wajib diisi!");
if (username.includes(' ')) {
toast.error('Username tidak boleh ada spasi!');
return;
}
const cleanPhone = phone.replace(/\D/g, '');
if (cleanPhone.length < 10) {
toast.error('Nomor tidak valid!');
return;
}
try {
setLoading(true);
const respone = await apiFetchRegister({ nomor: phone, username: value });
// ✅ Hanya kirim username & nomor → dapat kodeId
const response = await apiFetchRegister({ username, nomor: cleanPhone });
if (respone.success) {
router.push("/login", { scroll: false });
toast.success(respone.message);
if (response.success) {
// Simpan sementara
localStorage.setItem('auth_kodeId', response.kodeId);
localStorage.setItem('auth_username', username); // simpan username
} else {
setLoading(false);
toast.error(respone.message);
toast.success('Kode verifikasi dikirim!');
router.push('/validasi'); // ✅ ke halaman validasi
}
} catch (error) {
console.error('Error Registrasi:', error);
toast.error('Gagal mengirim OTP');
} finally {
setLoading(false);
console.log("Error Registrasi", error);
}
}
};
return (
<Stack pos={"relative"} bg={colors.Bg} gap={"22"} py={"xl"} h={"100vh"}>
<Stack pos="relative" bg={colors.Bg} gap="22" py="xl" h="100vh">
<Box px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
<Box px={{ base: 'md', md: 100 }}>
<Stack justify='center' align='center' h={"80vh"}>
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
<Stack align='center'>
<Title order={2} fw={'bold'} c={colors['blue-button']}>
<Stack justify="center" align="center" h="80vh">
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
<Stack align="center">
<Title order={2} fw="bold" c={colors['blue-button']}>
Registrasi
</Title>
<Center>
<Image loading="lazy" src={"/darmasaba-icon.png"} alt="" w={80} />
<Image loading="lazy" src="/darmasaba-icon.png" alt="" w={80} />
</Center>
<Box>
<TextInput placeholder='Username'
label='Username'
maxLength={50}
<Box w="100%">
<TextInput
label="Username"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
error={
value.length > 0 && value.length < 5
? "Minimal 5 karakter !"
: value.includes(" ")
? "Tidak boleh ada spasi"
: isValue
? "Masukan username anda"
: ""
username.length > 0 && username.length < 5
? 'Minimal 5 karakter!'
: username.includes(' ')
? 'Tidak boleh ada spasi'
: ''
}
onChange={(val) => {
val.currentTarget.value.length > 0 ? setIsValue(false) : "";
setValue(val.currentTarget.value);
}}
required
/>
<Box py={10}>
<Text fz={"sm"} >Nomor Telepon</Text>
<Box pt="md">
<Text fz="sm">Nomor Telepon</Text>
<PhoneInput
countrySelectorStyleProps={{
buttonStyle: {
backgroundColor: colors['blue-button'],
},
}}
inputStyle={{ width: "100%" }}
defaultCountry="id"
onChange={(val) => {
setPhone(val);
}}
value={phone}
disabled
/>
</Box>
<Box pb={10}>
<Checkbox
label="Saya menyetujui syarat dan ketentuan yang berlaku"
/>
<Box pt="md">
<Checkbox label="Saya menyetujui syarat dan ketentuan" defaultChecked />
</Box>
<Box pb={20} >
<Button fullWidth bg={colors['blue-button']} radius={'xl'} onClick={onRegistarsi} loading={loading ? true : false}>Daftar</Button>
<Box pt="xl">
<Button
fullWidth
bg={colors['blue-button']}
radius="xl"
onClick={handleRegister}
loading={loading}
disabled={username.length < 5}
>
Kirim Kode Verifikasi
</Button>
</Box>
</Box>
</Stack>
@@ -116,6 +130,4 @@ function Registrasi() {
</Box>
</Stack>
);
}
export default Registrasi;
}

View File

@@ -1,31 +1,261 @@
'use client'
import colors from '@/con/colors';
import { Box, Button, Paper, PinInput, Stack, Text, Title } from '@mantine/core';
import { useRouter } from 'next/navigation';
//
'use client';
import { apiFetchOtpData, apiFetchVerifyOtp } from '@/app/api/auth/_lib/api_fetch_auth';
import colors from '@/con/colors';
import { Box, Button, Center, Loader, Paper, PinInput, Stack, Text, Title } from '@mantine/core';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { authStore } from '@/store/authStore';
export default function Validasi() {
const router = useRouter();
const [nomor, setNomor] = useState<string | null>(null);
const [otp, setOtp] = useState('');
const [loading, setLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [kodeId, setKodeId] = useState<string | null>(null);
// Inisialisasi data OTP
useEffect(() => {
const storedKodeId = localStorage.getItem('auth_kodeId');
if (!storedKodeId) {
toast.error('Akses tidak valid');
router.replace('/login');
return;
}
setKodeId(storedKodeId);
const loadOtpData = async () => {
try {
const result = await apiFetchOtpData({ kodeId: storedKodeId });
if (result.success && result.data?.nomor) {
setNomor(result.data.nomor);
} else {
throw new Error('Data OTP tidak valid');
}
} catch (error) {
console.error('Gagal memuat data OTP:', error);
toast.error('Kode verifikasi tidak valid');
router.replace('/login');
} finally {
setIsLoading(false);
}
};
loadOtpData();
}, [router]);
// Verifikasi OTP
const handleVerify = async () => {
if (!kodeId || !nomor || otp.length < 4) return;
setLoading(true);
try {
const verifyResult = await apiFetchVerifyOtp({ nomor, otp, kodeId });
if (!verifyResult.success) {
// Registrasi baru?
if (
verifyResult.status === 404 &&
verifyResult.message?.includes('Akun tidak ditemukan')
) {
await handleNewRegistration();
return;
}
// Error lain
toast.error(verifyResult.message || 'Verifikasi gagal');
return;
}
// ✅ Verifikasi sukses → simpan user ke store
const user = verifyResult.user;
console.log('=== DEBUG USER ===');
console.log('Full user object:', user);
if (!user || !user.id) {
toast.error('Data pengguna tidak lengkap');
return;
}
const roleId = Number(user.roleId);
authStore.setUser({
id: user.id,
name: user.name || user.username || 'User',
roleId: roleId,
});
cleanupStorage();
const isUserActive = user.isActive ?? user.is_active ?? true;
// Redirect berdasarkan status approval
if (!isUserActive) {
router.replace('/waiting-room');
return;
}
// ✅ Switch statement lebih clean
let redirectPath: string;
switch (roleId) {
case 0:
case 1:
redirectPath = '/admin/landing-page/profil/program-inovasi';
break;
case 2:
redirectPath = '/admin/kesehatan/posyandu';
break;
case 3:
redirectPath = '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
break;
default:
redirectPath = '/admin';
console.warn('Unknown roleId:', roleId);
}
console.log('Redirecting to:', redirectPath);
router.replace(redirectPath);
} catch (error) {
console.error('Error saat verifikasi:', error);
toast.error('Terjadi kesalahan sistem');
} finally {
setLoading(false);
}
};
// Registrasi baru
const handleNewRegistration = async () => {
const username = localStorage.getItem('auth_username');
if (!username) {
toast.error('Data registrasi tidak ditemukan');
return;
}
try {
const res = await fetch('/api/auth/finalize-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, username, otp, kodeId }),
});
const data = await res.json();
if (data.success) {
// Set user sementara (tanpa roleId, akan diisi saat approve)
authStore.setUser({
id: 'pending',
name: username,
roleId: 1,
});
cleanupStorage();
router.replace('/waiting-room');
} else {
toast.error(data.message || 'Registrasi gagal');
}
} catch (error) {
console.error('Error registrasi:', error);
toast.error('Gagal menyelesaikan registrasi');
}
};
const cleanupStorage = () => {
localStorage.removeItem('auth_kodeId');
localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_username');
};
const handleResend = async () => {
if (!nomor) return;
try {
const res = await fetch('/api/auth/resend-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor }),
});
const data = await res.json();
if (data.success) {
localStorage.setItem('auth_kodeId', data.kodeId);
toast.success('OTP baru dikirim');
} else {
toast.error(data.message || 'Gagal mengirim ulang OTP');
}
} catch {
toast.error('Gagal menghubungi server');
}
};
// Loading
if (isLoading) {
return (
<Stack pos="relative" bg={colors.Bg} align="center" justify="center" h="100vh">
<Loader size="md" color={colors['blue-button']} />
</Stack>
);
}
if (!nomor) return null;
function Validasi() {
const router = useRouter()
return (
<Stack pos={"relative"} bg={colors.Bg}>
<Stack pos="relative" bg={colors.Bg}>
<Box px={{ base: 'md', md: 100 }} pb={50}>
<Stack align='center' justify='center' h={"100vh"}>
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
<Stack align='center' gap={"lg"}>
<Stack align="center" justify="center" h="100vh">
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
<Stack align="center" gap="lg">
<Box>
<Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}>
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
Kode Verifikasi
</Title>
<Text ta="center" size="sm" c="dimmed" mt="xs">
Kami telah mengirim kode ke nomor <strong>{nomor}</strong>
</Text>
</Box>
<Box>
<Box mb={10}>
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masukkan Kode Verifikasi</Text>
<PinInput type={/^[0-9]*$/} inputType="tel" inputMode="numeric" />
<Box w="100%">
<Box mb={20}>
<Text c={colors['blue-button']} ta="center" fz="sm" fw="bold">
Masukkan Kode Verifikasi
</Text>
<Center>
<PinInput
length={4}
value={otp}
onChange={setOtp}
onComplete={handleVerify}
inputMode="numeric"
size="lg"
/>
</Center>
</Box>
<Box py={20} >
<Button onClick={() => router.push("/admin/landing-page/profile/program-inovasi")}>
Page
<Button
fullWidth
onClick={handleVerify}
loading={loading}
disabled={otp.length < 4}
bg={colors['blue-button']}
radius="xl"
>
Verifikasi
</Button>
<Text ta="center" size="sm" mt="md">
Tidak menerima kode?{' '}
<Button
variant="subtle"
onClick={handleResend}
size="xs"
p={0}
h="auto"
color={colors['blue-button']}
>
Kirim Ulang
</Button>
</Box>
</Text>
</Box>
</Stack>
</Paper>
@@ -33,6 +263,4 @@ function Validasi() {
</Box>
</Stack>
);
}
export default Validasi;
}

View File

@@ -10,7 +10,8 @@ import {
Paper,
Stack,
TextInput,
Title
Title,
Loader
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
@@ -22,6 +23,11 @@ function EditKategoriBerita() {
const editState = useProxy(stateDashboardBerita.kategoriBerita);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: '',
});
const [formData, setFormData] = useState({
name: '',
@@ -38,6 +44,9 @@ function EditKategoriBerita() {
setFormData({
name: data.name || '',
});
setOriginalData({
name: data.name || '',
});
}
} catch (error) {
console.error('Error loading kategori Berita:', error);
@@ -55,8 +64,16 @@ function EditKategoriBerita() {
}));
};
const handleResetForm = () => {
setFormData({
name: originalData.name,
});
toast.info('Form dikembalikan ke data awal');
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// update global state hanya saat submit
editState.update.form = {
...editState.update.form,
@@ -69,6 +86,8 @@ function EditKategoriBerita() {
} catch (error) {
console.error('Error updating kategori Berita:', error);
toast.error('Terjadi kesalahan saat memperbarui kategori Berita');
} finally {
setIsSubmitting(false);
}
};
@@ -109,6 +128,17 @@ function EditKategoriBerita() {
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -119,7 +149,7 @@ function EditKategoriBerita() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -8,15 +8,19 @@ import {
Paper,
Stack,
TextInput,
Title
Title,
Loader
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function CreateKategoriBerita() {
const createState = useProxy(stateDashboardBerita.kategoriBerita);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
createState.create.form = {
@@ -25,9 +29,17 @@ function CreateKategoriBerita() {
};
const handleSubmit = async () => {
await createState.create.create();
resetForm();
router.push('/admin/desa/berita/kategori-berita');
setIsSubmitting(true);
try {
await createState.create.create();
resetForm();
router.push('/admin/desa/berita/kategori-berita');
} catch (error) {
console.error('Error creating kategori berita:', error);
toast.error('Gagal menambahkan kategori berita');
} finally {
setIsSubmitting(false);
}
};
return (
@@ -60,12 +72,23 @@ function CreateKategoriBerita() {
<TextInput
label="Nama Kategori Berita"
placeholder="Masukkan nama kategori berita"
defaultValue={createState.create.form.name || ''}
value={createState.create.form.name || ''}
onChange={(e) => (createState.create.form.name = e.target.value)}
required
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -76,7 +99,7 @@ function CreateKategoriBerita() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Select,
Stack,
@@ -23,6 +24,16 @@ function EditAjukanPermohonan() {
const params = useParams();
const stateAjukan = useProxy(stateLayananDesa.ajukanPermohonan);
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
nama: "",
nik: "",
alamat: "",
nomorKk: "",
kategoriId: "",
});
// State lokal form
const [formData, setFormData] = useState({
nama: '',
@@ -50,6 +61,13 @@ function EditAjukanPermohonan() {
nomorKk: data.nomorKk || '',
kategoriId: data.kategoriId || '',
});
setOriginalData({
nama: data.nama || '',
nik: data.nik || '',
alamat: data.alamat || '',
nomorKk: data.nomorKk || '',
kategoriId: data.kategoriId || '',
});
}
} catch (error) {
console.error('Error loading ajukan:', error);
@@ -68,8 +86,20 @@ function EditAjukanPermohonan() {
}));
};
const handleResetForm = () => {
setFormData({
nama: originalData.nama,
nik: originalData.nik,
alamat: originalData.alamat,
nomorKk: originalData.nomorKk,
kategoriId: originalData.kategoriId,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
stateAjukan.edit.form = {
...stateAjukan.edit.form,
...formData,
@@ -79,6 +109,8 @@ function EditAjukanPermohonan() {
} catch (error) {
console.error('Error updating ajukan:', error);
toast.error('Terjadi kesalahan saat memperbarui ajukan');
} finally {
setIsSubmitting(false);
}
};
@@ -86,9 +118,9 @@ 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>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Ajukan Permohonan
</Title>
@@ -153,6 +185,17 @@ function EditAjukanPermohonan() {
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -163,7 +206,7 @@ function EditAjukanPermohonan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -8,6 +8,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Text,
@@ -32,6 +33,14 @@ function EditPelayananPendudukNonPermanent() {
deskripsi: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: '',
deskripsi: '',
});
// Load data sekali dari backend
useEffect(() => {
const loadData = async () => {
@@ -45,6 +54,10 @@ function EditPelayananPendudukNonPermanent() {
name: data.name || '',
deskripsi: data.deskripsi || '',
});
setOriginalData({
name: data.name || '',
deskripsi: data.deskripsi || '',
});
}
} catch (error) {
console.error('Error loading data:', error);
@@ -57,39 +70,55 @@ function EditPelayananPendudukNonPermanent() {
const handleChange =
(field: keyof typeof formData) =>
(value: string) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
(value: string) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
if (!statePendudukNonPermanent.findById.data) return;
try {
setIsSubmitting(true);
if (!statePendudukNonPermanent.findById.data) return;
// Update global state hanya di submit
const updated = {
...statePendudukNonPermanent.findById.data,
name: formData.name,
deskripsi: formData.deskripsi,
};
// Update global state hanya di submit
const updated = {
...statePendudukNonPermanent.findById.data,
name: formData.name,
deskripsi: formData.deskripsi,
};
await statePendudukNonPermanent.update.update(updated);
router.push('/admin/desa/layanan/pelayanan_penduduk_non_permanent');
await statePendudukNonPermanent.update.update(updated);
router.push('/admin/desa/layanan/pelayanan_penduduk_non_permanent');
} catch (error) {
console.error('Error updating data:', error);
toast.error('Gagal memuat data pelayanan penduduk non permanent');
} finally {
setIsSubmitting(false);
}
};
return (
<Box>
<Stack gap="xs">
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Pelayanan Penduduk Non Permanent
</Title>
@@ -127,25 +156,31 @@ function EditPelayananPendudukNonPermanent() {
</Box>
{/* Submit Button */}
<Group>
<Button
bg={colors['blue-button']}
onClick={handleSubmit}
loading={statePendudukNonPermanent.update.loading}
disabled={!formData.name}
>
{statePendudukNonPermanent.update.loading
? 'Menyimpan...'
: 'Simpan Perubahan'}
</Button>
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
onClick={() => router.back()}
disabled={statePendudukNonPermanent.update.loading}
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>

View File

@@ -8,6 +8,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Skeleton,
Stack,
@@ -34,13 +35,21 @@ function EditPelayananPerizinanBerusaha() {
link: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
id: '',
name: '',
deskripsi: '',
link: '',
});
// Load data detail
useEffect(() => {
if (!id) {
toast.error("ID tidak valid");
return;
}
const loadData = async () => {
try {
setLoading(true);
@@ -52,6 +61,12 @@ function EditPelayananPerizinanBerusaha() {
deskripsi: data.deskripsi || "",
link: data.link || "",
});
setOriginalData({
id: data.id,
name: data.name || "",
deskripsi: data.deskripsi || "",
link: data.link || "",
});
} else {
toast.error("Data tidak ditemukan");
}
@@ -62,10 +77,10 @@ function EditPelayananPerizinanBerusaha() {
setLoading(false);
}
};
loadData();
}, [id]);
const handleChange =
(field: keyof typeof formData) =>
@@ -76,13 +91,26 @@ function EditPelayananPerizinanBerusaha() {
}));
};
const handleResetForm = () => {
setFormData({
id: originalData.id,
name: originalData.name,
deskripsi: originalData.deskripsi,
link: originalData.link,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
await state.update.update(formData);
router.push('/admin/desa/layanan/pelayanan_perizinan_berusaha');
} catch (error) {
console.error('Error updating pelayanan perizinan berusaha:', error);
toast.error('Terjadi kesalahan saat update data');
} finally {
setIsSubmitting(false);
}
};
@@ -99,14 +127,14 @@ 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>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Pelayanan Perizinan Berusaha
</Title>
@@ -147,23 +175,31 @@ function EditPelayananPerizinanBerusaha() {
/>
</Box>
<Group>
<Button
bg={colors['blue-button']}
onClick={handleSubmit}
loading={state.update.loading}
disabled={!formData.name}
>
{state.update.loading ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
onClick={() => router.back()}
disabled={state.update.loading}
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>

View File

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

View File

@@ -5,10 +5,12 @@ 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,
@@ -27,6 +29,7 @@ function CreateSuratKeterangan() {
const [previewImage2, setPreviewImage2] = useState<{ preview: string; file: File } | null>(null);
const [previewImage, setPreviewImage] = useState<{ preview: string; file: File } | null>(null);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
stateSurat.create.form = {
@@ -45,6 +48,7 @@ function CreateSuratKeterangan() {
}
try {
setIsSubmitting(true);
// Upload gambar utama
const res1 = await ApiFetch.api.fileStorage.create.post({
file: previewImage.file,
@@ -77,6 +81,8 @@ function CreateSuratKeterangan() {
} catch (error) {
console.error('Error creating surat keterangan:', error);
toast.error('Terjadi kesalahan saat menambahkan surat keterangan');
} finally {
setIsSubmitting(false);
}
};
@@ -84,9 +90,9 @@ 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>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Surat Keterangan
</Title>
@@ -103,7 +109,7 @@ function CreateSuratKeterangan() {
<Stack gap="md">
{/* Nama Surat */}
<TextInput
defaultValue={stateSurat.create.form.name}
value={stateSurat.create.form.name}
onChange={(val) => (stateSurat.create.form.name = val.target.value)}
label="Nama Surat Keterangan"
placeholder="Masukkan nama surat keterangan"
@@ -140,7 +146,7 @@ function CreateSuratKeterangan() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
@@ -161,7 +167,7 @@ function CreateSuratKeterangan() {
</Dropzone>
{previewImage && (
<Box mt="sm" style={{ textAlign: 'center' }}>
<Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}>
<Image
src={previewImage.preview}
alt="Preview Gambar Utama"
@@ -169,6 +175,23 @@ function CreateSuratKeterangan() {
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
@@ -190,7 +213,7 @@ function CreateSuratKeterangan() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
@@ -211,7 +234,7 @@ function CreateSuratKeterangan() {
</Dropzone>
{previewImage2 ? (
<Box mt="sm" style={{ textAlign: 'center' }}>
<Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}>
<Image
src={previewImage2.preview}
alt="Preview Gambar Tambahan"
@@ -219,6 +242,23 @@ function CreateSuratKeterangan() {
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage2(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
) : (
<Text size="sm" c="dimmed" mt="sm" ta="center">
@@ -229,6 +269,17 @@ function CreateSuratKeterangan() {
{/* Tombol Simpan */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -239,7 +290,7 @@ function CreateSuratKeterangan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -6,6 +6,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
@@ -21,6 +22,7 @@ function EditPelayananTelunjukSakti() {
const stateTelunjukDesa = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
name: '',
@@ -28,6 +30,12 @@ function EditPelayananTelunjukSakti() {
link: '',
});
const [originalData, setOriginalData] = useState({
name: '',
deskripsi: '',
link: '',
});
// Load data awal hanya sekali (pas ada id)
useEffect(() => {
const loadData = async () => {
@@ -42,6 +50,11 @@ function EditPelayananTelunjukSakti() {
deskripsi: data.deskripsi ?? '',
link: data.link ?? '',
});
setOriginalData({
name: data.name ?? '',
deskripsi: data.deskripsi ?? '',
link: data.link ?? '',
});
}
} catch (error) {
console.error('Error loading pelayanan telunjuk sakti:', error);
@@ -60,9 +73,19 @@ function EditPelayananTelunjukSakti() {
[]
);
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
link: originalData.link,
});
toast.info("Form dikembalikan ke data awal");
};
// Submit: update global state hanya saat simpan
const handleSubmit = async () => {
try {
setIsSubmitting(true);
stateTelunjukDesa.edit.form = {
...stateTelunjukDesa.edit.form,
...formData,
@@ -73,6 +96,8 @@ function EditPelayananTelunjukSakti() {
} catch (error) {
console.error('Error updating pelayanan telunjuk sakti:', error);
toast.error('Terjadi kesalahan saat memperbarui pelayanan telunjuk sakti');
} finally {
setIsSubmitting(false);
}
};
@@ -125,6 +150,17 @@ function EditPelayananTelunjukSakti() {
{/* Tombol Simpan */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -135,7 +171,7 @@ function EditPelayananTelunjukSakti() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -6,6 +6,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
@@ -13,12 +14,14 @@ import {
} 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 = {
@@ -30,6 +33,7 @@ function CreatePelayananTelunjukDesa() {
const handleSubmit = async () => {
try {
setIsSubmitting(true);
await stateTelunjukDesa.create.create();
resetForm();
toast.success('Data pelayanan telunjuk sakti berhasil ditambahkan');
@@ -37,6 +41,8 @@ function CreatePelayananTelunjukDesa() {
} catch (error) {
console.error('Error create pelayanan telunjuk sakti:', error);
toast.error('Terjadi kesalahan saat menambahkan data');
} finally {
setIsSubmitting(false);
}
};
@@ -64,7 +70,7 @@ function CreatePelayananTelunjukDesa() {
<Stack gap="md">
{/* Nama */}
<TextInput
defaultValue={stateTelunjukDesa.create.form.name}
value={stateTelunjukDesa.create.form.name}
onChange={(val) => {
stateTelunjukDesa.create.form.name = val.target.value;
}}
@@ -75,7 +81,7 @@ function CreatePelayananTelunjukDesa() {
{/* Deskripsi */}
<TextInput
defaultValue={stateTelunjukDesa.create.form.deskripsi}
value={stateTelunjukDesa.create.form.deskripsi}
onChange={(val) => {
stateTelunjukDesa.create.form.deskripsi = val.target.value;
}}
@@ -86,7 +92,7 @@ function CreatePelayananTelunjukDesa() {
{/* Link */}
<TextInput
defaultValue={stateTelunjukDesa.create.form.link}
value={stateTelunjukDesa.create.form.link}
onChange={(val) => {
stateTelunjukDesa.create.form.link = val.target.value;
}}
@@ -97,6 +103,17 @@ 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"
@@ -107,7 +124,7 @@ function CreatePelayananTelunjukDesa() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -5,10 +5,12 @@ 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,
@@ -29,6 +31,15 @@ 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({
@@ -46,17 +57,25 @@ function EditPenghargaan() {
try {
const data = await statePenghargaan.edit.load(id);
if (data) {
setFormData({
name: data.name || '',
juara: data.juara || '',
deskripsi: data.deskripsi || '',
imageId: data.imageId || '',
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,
});
if (data?.image?.link) {
setPreviewImage(data.image.link);
}
setPreviewImage(imageUrl || null);
}
} catch (error) {
console.error('Error loading penghargaan:', error);
@@ -67,33 +86,49 @@ 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
statePenghargaan.edit.form = {
...statePenghargaan.edit.form,
...formData,
};
// Upload file baru (kalau ada)
let imageId = formData.imageId;
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");
}
statePenghargaan.edit.form.imageId = uploaded.id;
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);
}
};
@@ -152,7 +187,7 @@ function EditPenghargaan() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
@@ -171,25 +206,47 @@ function EditPenghargaan() {
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar wajib
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 220,
objectFit: 'contain',
border: `1px solid ${colors['blue-button']}`,
<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);
}}
loading="lazy"
/>
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
@@ -209,6 +266,17 @@ 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"
@@ -219,7 +287,7 @@ function EditPenghargaan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -2,10 +2,12 @@
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
Paper,
Stack,
Text,
@@ -26,6 +28,7 @@ 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 = {
@@ -39,26 +42,34 @@ function CreatePenghargaan() {
};
const handleSubmit = async () => {
if (!file) {
return toast.warn('Silakan pilih file gambar terlebih dahulu');
try {
setIsSubmitting(true);
if (!file) {
return toast.warn('Silakan pilih file gambar terlebih dahulu');
}
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
}
statePenghargaan.create.form.imageId = uploaded.id;
await statePenghargaan.create.create();
resetForm();
router.push('/admin/desa/penghargaan');
} catch (error) {
console.error('Error creating penghargaan:', error);
toast.error('Terjadi kesalahan saat menambahkan penghargaan');
} finally {
setIsSubmitting(false);
}
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
}
statePenghargaan.create.form.imageId = uploaded.id;
await statePenghargaan.create.create();
resetForm();
router.push('/admin/desa/penghargaan');
};
return (
@@ -84,7 +95,7 @@ function CreatePenghargaan() {
>
<Stack gap="md">
<TextInput
defaultValue={statePenghargaan.create.form.name}
value={statePenghargaan.create.form.name}
onChange={(val) => (statePenghargaan.create.form.name = val.target.value)}
label="Nama Penghargaan"
placeholder="Masukkan nama penghargaan"
@@ -92,7 +103,7 @@ function CreatePenghargaan() {
/>
<TextInput
defaultValue={statePenghargaan.create.form.juara}
value={statePenghargaan.create.form.juara}
onChange={(val) => (statePenghargaan.create.form.juara = val.target.value)}
label="Juara"
placeholder="Masukkan juara"
@@ -122,7 +133,7 @@ function CreatePenghargaan() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
@@ -143,7 +154,7 @@ function CreatePenghargaan() {
</Dropzone>
{previewImage && (
<Box mt="sm" style={{ textAlign: 'center' }}>
<Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
@@ -151,12 +162,41 @@ 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"
@@ -167,7 +207,7 @@ function CreatePenghargaan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -10,7 +10,8 @@ import {
Paper,
Stack,
TextInput,
Title
Title,
Loader
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
@@ -22,8 +23,9 @@ 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(() => {
@@ -35,6 +37,7 @@ 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);
@@ -54,6 +57,7 @@ function EditKategoriPengumuman() {
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// Update global state hanya di sini
editState.update.form = {
...editState.update.form,
@@ -66,9 +70,19 @@ 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 */}
@@ -105,6 +119,17 @@ 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"
@@ -115,7 +140,7 @@ function EditKategoriPengumuman() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -8,15 +8,19 @@ import {
Paper,
Stack,
TextInput,
Title
Title,
Loader
} 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 = {
@@ -25,9 +29,16 @@ function CreateKategoriPengumuman() {
};
const handleSubmit = async () => {
await createState.create.create();
resetForm();
router.push('/admin/desa/pengumuman/kategori-pengumuman');
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);
}
};
return (
@@ -60,12 +71,23 @@ function CreateKategoriPengumuman() {
<TextInput
label="Nama Kategori Pengumuman"
placeholder="Masukkan nama kategori pengumuman"
defaultValue={createState.create.form.name || ''}
value={createState.create.form.name || ''}
onChange={(e) => (createState.create.form.name = e.target.value)}
required
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -76,7 +98,7 @@ function CreateKategoriPengumuman() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -13,7 +13,8 @@ import {
Stack,
Text,
TextInput,
Title
Title,
Loader
} from "@mantine/core";
import { IconArrowBack } from "@tabler/icons-react";
import { useParams, useRouter } from "next/navigation";
@@ -33,6 +34,15 @@ 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();
@@ -50,6 +60,12 @@ 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);
@@ -66,6 +82,7 @@ function EditPengumuman() {
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// update global state hanya sekali pas submit
editState.pengumuman.edit.form = {
...editState.pengumuman.edit.form,
@@ -78,9 +95,21 @@ 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">
@@ -152,17 +181,29 @@ 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)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -12,25 +12,37 @@ import {
Stack,
Text,
TextInput,
Title
Title,
Loader
} 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 () => {
await pengumumanState.pengumuman.create.create();
resetForm();
router.push('/admin/desa/pengumuman/list-pengumuman');
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);
}
};
const resetForm = () => {
@@ -46,9 +58,9 @@ 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>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Pengumuman
</Title>
@@ -65,7 +77,7 @@ function CreatePengumuman() {
<Stack gap="md">
{/* Judul */}
<TextInput
defaultValue={pengumumanState.pengumuman.create.form.judul}
value={pengumumanState.pengumuman.create.form.judul}
onChange={(val) => (pengumumanState.pengumuman.create.form.judul = val.target.value)}
label="Judul"
placeholder="Masukkan judul pengumuman"
@@ -76,21 +88,32 @@ 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
defaultValue={pengumumanState.pengumuman.create.form.deskripsi}
value={pengumumanState.pengumuman.create.form.deskripsi}
onChange={(val) => (pengumumanState.pengumuman.create.form.deskripsi = val.target.value)}
label="Deskripsi Singkat"
placeholder="Masukkan deskripsi singkat"
@@ -112,6 +135,17 @@ 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"
@@ -122,7 +156,7 @@ function CreatePengumuman() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -9,7 +9,8 @@ import {
Paper,
Stack,
TextInput,
Title
Title,
Loader
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
@@ -26,6 +27,12 @@ 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 () => {
@@ -38,6 +45,9 @@ function EditKategoriPotensi() {
setFormData({
nama: data.nama || '',
});
setOriginalData({
nama: data.nama || '',
});
}
} catch (error) {
console.error('Error loading kategori potensi:', error);
@@ -55,8 +65,16 @@ 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,
@@ -69,6 +87,8 @@ function EditKategoriPotensi() {
} catch (error) {
console.error('Error updating kategori potensi:', error);
toast.error('Terjadi kesalahan saat memperbarui kategori potensi');
} finally {
setIsSubmitting(false);
}
};
@@ -106,6 +126,17 @@ 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"
@@ -116,7 +147,7 @@ function EditKategoriPotensi() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -8,15 +8,18 @@ import {
Paper,
Stack,
TextInput,
Title
Title,
Loader
} 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 = {
@@ -25,23 +28,30 @@ function CreateKategoriPotensi() {
};
const handleSubmit = async () => {
await createState.create.create();
resetForm();
router.push('/admin/desa/potensi/kategori-potensi');
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);
}
};
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>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Kategori Potensi
</Title>
@@ -60,12 +70,23 @@ function CreateKategoriPotensi() {
<TextInput
label="Nama Kategori Potensi"
placeholder="Masukkan nama kategori potensi"
defaultValue={createState.create.form.nama || ''}
value={createState.create.form.nama || ''}
onChange={(e) => (createState.create.form.nama = e.target.value)}
required
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -76,7 +97,7 @@ function CreateKategoriPotensi() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -15,7 +15,9 @@ import {
Stack,
Text,
TextInput,
Title
Title,
Loader,
ActionIcon
} from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import { IconArrowBack, IconPhoto, IconUpload, IconX } from "@tabler/icons-react";
@@ -38,6 +40,16 @@ 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) => {
@@ -46,11 +58,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) {
@@ -61,35 +73,45 @@ function EditPotensi() {
content: data.content || "",
imageId: data.imageId || "",
});
// // 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);
}
setOriginalData({
name: data.name || "",
deskripsi: data.deskripsi || "",
kategoriId: data.kategoriId || "",
content: data.content || "",
imageId: data.imageId || "",
imageUrl: 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,
@@ -115,20 +137,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>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors["blue-button"]} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Potensi Desa
</Title>
@@ -164,6 +188,32 @@ 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"
@@ -178,7 +228,7 @@ function EditPotensi() {
searchable
required
error={!formData.kategoriId ? "Pilih kategori" : undefined}
/>
/> */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
@@ -219,25 +269,45 @@ function EditPotensi() {
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar wajib
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" style={{ display: "flex", justifyContent: "center" }}>
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 220,
objectFit: "contain",
border: `1px solid ${colors["blue-button"]}`,
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>
@@ -255,17 +325,29 @@ 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)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -14,7 +14,9 @@ import {
Stack,
Text,
TextInput,
Title
Title,
Loader,
ActionIcon
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -28,30 +30,39 @@ 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 () => {
if (!file) return toast.warn('Pilih file gambar terlebih dahulu');
try {
setIsSubmitting(true);
if (!file) return toast.warn('Pilih file gambar terlebih dahulu');
const res = await ApiFetch.api.fileStorage.create.post({
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');
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);
}
potensiState.create.form.imageId = uploaded.id;
await potensiState.create.create();
resetForm();
router.push('/admin/desa/potensi/list-potensi');
};
const resetForm = () => {
@@ -71,9 +82,9 @@ 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>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Potensi Desa
</Title>
@@ -90,7 +101,7 @@ function CreatePotensi() {
<Stack gap="md">
{/* Judul */}
<TextInput
defaultValue={potensiState.create.form.name}
value={potensiState.create.form.name}
onChange={(val) => (potensiState.create.form.name = val.target.value)}
label="Judul"
placeholder="Masukkan judul potensi"
@@ -112,6 +123,32 @@ 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 || ""}
@@ -122,7 +159,7 @@ function CreatePotensi() {
value: item.id,
label: item.nama,
}))}
/>
/> */}
{/* Upload Gambar */}
<Box>
@@ -139,7 +176,7 @@ function CreatePotensi() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
@@ -157,17 +194,44 @@ 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" style={{ textAlign: 'center' }}>
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading='lazy'
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>
@@ -187,6 +251,17 @@ 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"
@@ -197,7 +272,7 @@ function CreatePotensi() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -1,152 +1,244 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
'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, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import {
Alert,
Box,
Button,
Center,
Group,
Loader,
Paper,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { IconAlertCircle, IconArrowBack } from '@tabler/icons-react';
import { 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 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 router = useRouter();
const params = useParams();
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");
}
};
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);
loadData();
// 🧭 Load data awal
useEffect(() => {
const loadData = async () => {
const id = params?.id as string;
if (!id) {
toast.error('ID tidak valid');
router.push('/admin/desa/profile/profile-desa');
return;
}
return () => {
lambangState.update.reset();
lambangState.findUnique.reset();
};
}, [params?.id, router]);
setIsLoading(true);
setLoadError(null);
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);
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');
}
} catch (error) {
console.error('Error loading lambang:', error);
setLoadError('Gagal memuat data lambang desa');
toast.error('Gagal memuat data lambang desa');
} finally {
setIsLoading(false);
}
};
const handleBack = () => router.back();
loadData();
// Loading state
if (lambangState.findUnique.loading || lambangState.update.loading) {
return (
<Box>
<Center h={400}>
<Text>Memuat data...</Text>
</Center>
</Box>
);
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;
}
// 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>
);
}
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>
<Stack gap="xs">
<Group mb="md">
<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>
<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>
<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 */}
<Box>
<Text fw="bold" size="sm" mb={8}>
Deskripsi
</Text>
<EditEditor value={formData.deskripsi} onChange={handleDeskripsiChange} />
</Box>
{/* Tombol Aksi */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
disabled={isSubmitting}
>
Batal
</Button>
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
loading={isSubmitting}
>
Simpan
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Box>
);
}
export default Page;

View File

@@ -5,7 +5,7 @@ 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, Paper, SimpleGrid, Stack, Text, TextInput, Title } from '@mantine/core';
import { Alert, Box, Button, Center, Group, Image, Loader, Paper, SimpleGrid, Stack, Text, TextInput, Title } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconAlertCircle, IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
@@ -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,6 +28,12 @@ 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 () => {
@@ -52,6 +58,17 @@ function Page() {
})),
});
setOriginalData({
judul: data.judul || '',
deskripsi: data.deskripsi || '',
images: (data.images || []).map((img: any) => ({
label: img.label,
imageId: img.image?.id ?? '',
preview: img.image?.link ?? '',
})),
});
if (data?.images?.length > 0 && data.images[0].image?.link) {
setImages(data.images.map((img: any) => ({
file: null,
@@ -77,15 +94,36 @@ 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
@@ -95,7 +133,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,
@@ -108,7 +146,7 @@ function Page() {
}
uploadedImages.push({ imageId: uploaded.id, label: img.label || "main" });
}
// Update ke global state
maskotState.update.updateField("judul", formData.judul);
@@ -161,9 +199,9 @@ 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>
<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 Maskot Desa</Title>
</Group>
@@ -175,7 +213,7 @@ function Page() {
<TextInput
label={<Text fw="bold">Judul</Text>}
placeholder="Masukkan judul maskot"
defaultValue={formData.judul}
value={formData.judul}
onChange={(e) => setFormData({ ...formData, judul: e.currentTarget.value })}
error={!formData.judul && "Judul wajib diisi"}
/>
@@ -231,7 +269,7 @@ function Page() {
setImages(updated);
}}
>
Hapus
<IconX size={16} />
</Button>
</Group>
<Image
@@ -260,18 +298,31 @@ function Page() {
</SimpleGrid>
{/* Buttons */}
<Group>
<Group justify="right">
{/* Tombol Batal */}
<Button
bg={colors['blue-button']}
onClick={handleSubmit}
loading={isSubmitting || maskotState.update.loading}
disabled={!formData.judul}
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
{isSubmitting ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
<Button variant="outline" onClick={handleBack} disabled={isSubmitting || maskotState.update.loading}>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>

View File

@@ -1,146 +1,272 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
'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, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import {
Alert,
Box,
Button,
Center,
Group,
Loader,
Paper,
Stack,
Text,
TextInput,
Title
} from '@mantine/core';
import { IconAlertCircle, IconArrowBack } from '@tabler/icons-react';
import { 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 sejarahState = useProxy(stateProfileDesa.sejarahDesa)
const router = useRouter()
const params = useParams()
const [isSubmitting, setIsSubmitting] = useState(false);
const router = useRouter();
const params = useParams();
// Load data
// 🧩 Local State
const [formData, setFormData] = useState<FormData>({
judul: '',
deskripsi: '',
});
const [originalData, setOriginalData] = useState<FormData>({
judul: '',
deskripsi: '',
});
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
// 🧭 Load Initial Data
useEffect(() => {
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 sejarahState.findUnique.load(id);
const data = await stateProfileDesa.sejarahDesa.findUnique.load(id);
if (data) {
sejarahState.update.initialize(data);
const initialData: FormData = {
judul: data.judul || '',
deskripsi: data.deskripsi || '',
};
setFormData(initialData);
setOriginalData(initialData);
stateProfileDesa.sejarahDesa.update.initialize(data);
} else {
setLoadError('Data tidak ditemukan');
}
} catch (error) {
console.error("Error loading sejarah:", error);
toast.error("Gagal memuat data sejarah desa");
console.error('Error loading sejarah:', error);
setLoadError('Gagal memuat data sejarah desa');
toast.error('Gagal memuat data sejarah desa');
} finally {
setIsLoading(false);
}
};
loadData();
return () => {
sejarahState.update.reset();
sejarahState.findUnique.reset();
stateProfileDesa.sejarahDesa.update.reset();
stateProfileDesa.sejarahDesa.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 () => {
if (isSubmitting || !sejarahState.update.form.judul.trim()) {
toast.error("Judul wajib diisi");
// Validation
if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return;
}
setIsSubmitting(true);
try {
const success = await sejarahState.update.submit();
// Access original state directly (not proxy)
const originalState = stateProfileDesa.sejarahDesa;
// Update form data
originalState.update.form.judul = formData.judul;
originalState.update.form.deskripsi = formData.deskripsi;
// Submit
const success = await originalState.update.submit();
if (success) {
toast.success("Data berhasil disimpan");
router.push("/admin/desa/profile/profile-desa");
toast.success('Data berhasil disimpan');
router.push('/admin/desa/profile/profile-desa');
} else {
toast.error('Gagal menyimpan data');
}
} 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);
}
};
const handleBack = () => router.back();
// 📝 Form Field Handlers
const handleJudulChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, judul: e.target.value }));
};
// Loading state
if (sejarahState.findUnique.loading || sejarahState.update.loading) {
const handleDeskripsiChange = (html: string) => {
setFormData(prev => ({ ...prev, deskripsi: html }));
};
const handleBack = () => {
router.back();
};
// 🔄 Loading State
if (isLoading) {
return (
<Box>
<Center h={400}>
<Text>Memuat data...</Text>
<Stack align="center" gap="md">
<Loader size="lg" color={colors['blue-button']} />
<Text size="lg" fw={500} c="dimmed">
Memuat data...
</Text>
</Stack>
</Center>
</Box>
);
}
// Error state
if (sejarahState.findUnique.error) {
// Error State
if (loadError) {
return (
<Box>
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Stack gap="md">
<Button variant="subtle" onClick={handleBack}>
<IconArrowBack color={colors['blue-button']} size={20} />
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Alert icon={<IconAlertCircle size={16} />} color="red">
<Text fw="bold">Error</Text>
<Text>{sejarahState.findUnique.error}</Text>
<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>
);
}
return (
<Box>
<Stack gap="xs">
<Group mb="md">
<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>
<Title order={4} ml="sm" c="dark">
Edit Sejarah 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 Sejarah Desa</Title>
{/* Judul */}
{/* 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 */}
<TextInput
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"}
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"
/>
{/* Deskripsi */}
{/* Deskripsi Field */}
<Box>
<Text fz={"md"} fw={"bold"}>Deskripsi</Text>
<Text fw="bold" size="sm" mb={8}>
Deskripsi Sejarah
</Text>
<EditEditor
value={sejarahState.update.form.deskripsi}
onChange={(val) => sejarahState.update.form.deskripsi = val}
value={formData.deskripsi}
onChange={handleDeskripsiChange}
/>
</Box>
{/* Buttons */}
<Group>
{/* Action Buttons */}
<Group justify="right">
{/* Tombol Batal */}
<Button
bg={colors['blue-button']}
onClick={handleSubmit}
loading={isSubmitting || sejarahState.update.loading}
disabled={!sejarahState.update.form.judul}
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
{isSubmitting ? 'Menyimpan...' : 'Simpan Perubahan'}
Batal
</Button>
<Button variant="outline" onClick={handleBack} disabled={isSubmitting || sejarahState.update.loading}>
Batal
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
@@ -150,4 +276,4 @@ function Page() {
);
}
export default Page;
export default Page;

View File

@@ -1,155 +1,247 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
'use client';
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors';
import { Alert, Box, Button, Center, Group, Paper, Stack, Text, Title } from '@mantine/core';
import {
Alert,
Box,
Button,
Center,
Group,
Loader,
Paper,
Stack,
Text,
Title,
} from '@mantine/core';
import { IconAlertCircle, IconArrowBack } from '@tabler/icons-react';
import { 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 visiMisiState = useProxy(stateProfileDesa.visiMisiDesa)
const router = useRouter()
const params = useParams()
const [isSubmitting, setIsSubmitting] = useState(false);
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);
// 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;
}
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");
}
};
setIsLoading(true);
setLoadError(null);
loadData();
try {
const data = await stateProfileDesa.visiMisiDesa.findUnique.load(id);
if (data) {
const initialData: FormData = {
visi: data.visi || '',
misi: data.misi || '',
};
setFormData(initialData);
setOriginalData(initialData);
return () => {
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);
// set id ke state agar submit pakai endpoint benar
stateProfileDesa.visiMisiDesa.update.initialize(data);
} else {
setLoadError('Data tidak ditemukan');
}
} catch (error) {
console.error('Error load visi misi:', error);
setLoadError('Gagal memuat data visi misi desa');
toast.error('Gagal memuat data visi misi desa');
} finally {
setIsLoading(false);
}
};
const handleBack = () => router.back();
loadData();
// Loading state
if (visiMisiState.findUnique.loading || visiMisiState.update.loading) {
return (
<Box>
<Center h={400}>
<Text>Memuat data...</Text>
</Center>
</Box>
);
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;
}
// 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>
);
}
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);
}
};
// 🧭 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>
<Stack gap="xs">
<Group mb="md">
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Button>
<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>
<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>
);
}
// ❌ 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

@@ -4,6 +4,7 @@ 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,
@@ -12,7 +13,8 @@ import {
Stack,
Text,
TextInput,
Title
Title,
Loader
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -35,6 +37,16 @@ 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 () => {
@@ -49,9 +61,14 @@ function EditPerbekelDariMasaKeMasa() {
periode: data.periode || '',
imageId: data.imageId || ''
});
if (data?.imageGalleryFoto?.link) {
setPreviewImage(data.imageGalleryFoto.link);
}
setOriginalData({
nama: data.nama || '',
daerah: data.daerah || '',
periode: data.periode || '',
imageId: data.imageId || '',
imageUrl: data.image.link || '',
})
setPreviewImage(data.image.link);
}
} catch (error) {
console.error('Error loading foto:', error);
@@ -69,8 +86,22 @@ function EditPerbekelDariMasaKeMasa() {
}));
};
const handleResetForm = () => {
setFormData({
nama: originalData.nama,
daerah: originalData.daerah,
periode: originalData.periode,
imageId: originalData.imageId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// update global state hanya sekali pas submit
state.update.form = { ...state.update.form, ...formData };
@@ -90,15 +121,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>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Perbekel Dari Masa Ke Masa
</Title>
@@ -135,7 +168,7 @@ function EditPerbekelDariMasaKeMasa() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
@@ -154,25 +187,45 @@ function EditPerbekelDariMasaKeMasa() {
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar wajib
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 220,
maxHeight: 200,
objectFit: 'contain',
border: `1px solid ${colors['blue-button']}`,
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>
@@ -194,6 +247,17 @@ 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"
@@ -204,7 +268,7 @@ function EditPerbekelDariMasaKeMasa() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

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 { Box, Button, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import { Loader, ActionIcon, Box, Button, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
@@ -14,6 +14,7 @@ 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 = () => {
@@ -28,31 +29,39 @@ function CreatePerbekelDariMasaKeMasa() {
};
const handleSubmit = async () => {
if (!file) {
return toast.warn('Pilih file gambar terlebih dahulu');
try {
setIsSubmitting(true);
if (!file) {
return toast.warn('Pilih file gambar terlebih dahulu');
}
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) return toast.error('Gagal upload gambar');
state.create.form.imageId = uploaded.id;
await state.create.create();
resetForm();
router.push('/admin/desa/profile/profile-perbekel-dari-masa-ke-masa');
} catch (error) {
console.error(error);
toast.error('Gagal menambahkan perbekel dari masa ke masa');
} finally {
setIsSubmitting(false);
}
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) return toast.error('Gagal upload gambar');
state.create.form.imageId = uploaded.id;
await state.create.create();
resetForm();
router.push('/admin/desa/profile/profile-perbekel-dari-masa-ke-masa');
};
return (
<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>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Create Perbekel Dari Masa Ke Masa
</Title>
@@ -70,21 +79,21 @@ function CreatePerbekelDariMasaKeMasa() {
<TextInput
label="Nama Perbekel"
placeholder="Masukkan nama perbekel"
defaultValue={state.create.form.nama}
value={state.create.form.nama}
onChange={(e) => (state.create.form.nama = e.target.value)}
required
/>
<TextInput
label="Daerah"
placeholder="Masukkan daerah"
defaultValue={state.create.form.daerah}
value={state.create.form.daerah}
onChange={(e) => (state.create.form.daerah = e.target.value)}
required
/>
<TextInput
label="Periode"
placeholder="Masukkan periode"
defaultValue={state.create.form.periode}
value={state.create.form.periode}
onChange={(e) => (state.create.form.periode = e.target.value)}
required
/>
@@ -102,7 +111,7 @@ function CreatePerbekelDariMasaKeMasa() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
@@ -118,25 +127,63 @@ function CreatePerbekelDariMasaKeMasa() {
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB)
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Dropzone>
{previewImage && (
<Box mt="sm" style={{ textAlign: 'center' }}>
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading='lazy'
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"
@@ -147,7 +194,7 @@ function CreatePerbekelDariMasaKeMasa() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -126,8 +126,8 @@ function ProfilePerbekel() {
<Dropzone
onDrop={(files) => handleFileChange(files[0])}
onReject={() => toast.error('File tidak valid.')}
maxSize={5 * 1024 ** 2} // 5MB
accept={{ 'image/*': [] }}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
>
<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

@@ -2,23 +2,22 @@
'use client'
import colors from '@/con/colors';
import {
ScrollArea,
Stack,
Tabs,
TabsList,
TabsPanel,
TabsTab,
Title,
Tooltip,
ScrollArea,
Title
} from '@mantine/core';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import {
IconFileAnalytics,
IconCoins,
IconFileAnalytics,
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();
@@ -29,29 +28,25 @@ 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} />,
tooltip: "Lihat ringkasan Anggaran Pendapatan dan Belanja Desa",
icon: <IconFileAnalytics size={18} stroke={1.8} />
},
{
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",
},
];
@@ -104,26 +99,19 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
}}
>
{tabs.map((tab, i) => (
<Tooltip
<TabsTab
key={i}
label={tab.tooltip}
position="bottom"
withArrow
transitionProps={{ transition: "pop", duration: 200 }}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
flexShrink: 0,
}}
>
<TabsTab
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
flexShrink: 0,
}}
>
{tab.label}
</TabsTab>
</Tooltip>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>

View File

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

View File

@@ -9,8 +9,7 @@ import {
Paper,
Skeleton,
Stack,
Text,
Tooltip
Text
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
@@ -81,7 +80,7 @@ function DetailAPBDesa() {
Detail APB Desa
</Text>
<Paper bg={colors['BG-trans']} p="md" radius="md" shadow="xs">
<Paper bg="#EEF3FBFF" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">
@@ -159,36 +158,32 @@ function DetailAPBDesa() {
</Box>
<Group gap="sm" mt={10}>
<Tooltip label="Hapus APB Desa" 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="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
variant="light"
radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
<Tooltip label="Edit APB Desa" withArrow position="top">
<Button
color="green"
onClick={() =>
router.push(
`/admin/ekonomi/PADesa-pendapatan-asli-desa/apbdesa/${data.id}/edit`
)
}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
</Tooltip>
<Button
color="green"
onClick={() =>
router.push(
`/admin/ekonomi/PADesa-pendapatan-asli-desa/apbdesa/${data.id}/edit`
)
}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
</Group>
</Stack>
</Paper>

View File

@@ -6,23 +6,26 @@ import {
Box,
Button,
Group,
Loader,
MultiSelect,
Paper,
Skeleton,
Stack,
Text,
TextInput,
Title,
Tooltip,
Title
} 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 CreateAPBDesa() {
const apbDesaState = useProxy(PendapatanAsliDesa.ApbDesa);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
apbDesaState.create.form = {
@@ -34,20 +37,26 @@ function CreateAPBDesa() {
};
const handleSubmit = async () => {
await apbDesaState.create.submit();
resetForm();
router.push('/admin/ekonomi/PADesa-pendapatan-asli-desa/apbdesa');
try {
setIsSubmitting(true);
await apbDesaState.create.submit();
resetForm();
router.push('/admin/ekonomi/PADesa-pendapatan-asli-desa/apbdesa');
} catch (error) {
console.error('Error creating APB Desa:', error);
toast.error('Gagal membuat APB Desa');
} finally {
setIsSubmitting(false);
}
};
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()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah APB Desa
</Title>
@@ -65,7 +74,7 @@ function CreateAPBDesa() {
<Stack gap="md">
<TextInput
type="number"
defaultValue={apbDesaState.create.form.tahun}
value={apbDesaState.create.form.tahun}
onChange={(val) => {
apbDesaState.create.form.tahun = Number(val.target.value);
}}
@@ -97,6 +106,17 @@ function CreateAPBDesa() {
{/* Action */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -107,7 +127,7 @@ function CreateAPBDesa() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -5,8 +5,8 @@ import {
Button,
Center,
Group,
Paper,
Pagination,
Paper,
Skeleton,
Stack,
Table,
@@ -15,8 +15,7 @@ import {
TableTh,
TableThead,
TableTr,
Text,
Tooltip,
Text
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
@@ -82,20 +81,18 @@ function ListAPBDesa({ search }: { search: string }) {
<Text fw={600} fz="lg">
List APB Desa
</Text>
<Tooltip label="Tambah APB Desa" withArrow>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push(
"/admin/ekonomi/PADesa-pendapatan-asli-desa/apbdesa/create"
)
}
>
Tambah Baru
</Button>
</Tooltip>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push(
"/admin/ekonomi/PADesa-pendapatan-asli-desa/apbdesa/create"
)
}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>
@@ -138,20 +135,18 @@ function ListAPBDesa({ search }: { search: string }) {
)}
</TableTd>
<TableTd>
<Tooltip label="Lihat Detail" withArrow>
<Button
variant="light"
color="green"
onClick={() =>
router.push(
`/admin/ekonomi/PADesa-pendapatan-asli-desa/apbdesa/${item.id}`
)
}
>
<IconDeviceImacCog size={20} />
<Text ml={5}>Detail</Text>
</Button>
</Tooltip>
<Button
variant="light"
color="green"
onClick={() =>
router.push(
`/admin/ekonomi/PADesa-pendapatan-asli-desa/apbdesa/${item.id}`
)
}
>
<IconDeviceImacCog size={20} />
<Text ml={5}>Detail</Text>
</Button>
</TableTd>
</TableTr>
))

View File

@@ -7,11 +7,11 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title,
Tooltip
Title
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
@@ -23,12 +23,18 @@ function EditBelanja() {
const belanjaState = useProxy(PendapatanAsliDesa.belanja);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
name: '',
value: '',
});
const [originalData, setOriginalData] = useState({
name: '',
value: '',
});
// format angka ke rupiah
const formatRupiah = (value: number | string) => {
const number =
@@ -59,6 +65,10 @@ function EditBelanja() {
name: data.name || '',
value: String(data.value || ''),
});
setOriginalData({
name: data.name || '',
value: String(data.value || ''),
});
}
} catch (error) {
console.error("Error loading belanja:", error);
@@ -71,6 +81,7 @@ function EditBelanja() {
const handleSubmit = async () => {
try {
setIsSubmitting(true);
belanjaState.update.form = {
...belanjaState.update.form,
name: formData.name,
@@ -83,23 +94,31 @@ function EditBelanja() {
} catch (error) {
console.error("Error updating jenis belanja:", error);
toast.error("Terjadi kesalahan saat memperbarui jenis belanja");
} finally {
setIsSubmitting(false);
}
};
const handleResetForm = () => {
setFormData({
name: originalData.name,
value: originalData.value,
});
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()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Jenis Belanja
</Title>
@@ -138,6 +157,17 @@ function EditBelanja() {
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -148,7 +178,7 @@ function EditBelanja() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -6,21 +6,23 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
Title
} 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';
import { useProxy } from 'valtio/utils';
function CreateBelanja() {
const belanjaState = useProxy(PendapatanAsliDesa.belanja);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const formatRupiah = (value: number | string) => {
const number =
@@ -48,25 +50,31 @@ function CreateBelanja() {
return toast.warn('Lengkapi semua field terlebih dahulu');
}
await belanjaState.create.submit();
resetForm();
router.push('/admin/ekonomi/PADesa-pendapatan-asli-desa/belanja');
try {
setIsSubmitting(true);
await belanjaState.create.submit();
resetForm();
router.push('/admin/ekonomi/PADesa-pendapatan-asli-desa/belanja');
} catch (error) {
console.error('Error creating belanja:', error);
toast.error('Gagal menambahkan jenis belanja');
} finally {
setIsSubmitting(false);
}
};
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()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Jenis Belanja
</Title>
@@ -85,7 +93,7 @@ function CreateBelanja() {
<TextInput
label={<Text fw="bold" fz="sm">Nama Jenis Belanja</Text>}
placeholder="Masukkan nama jenis belanja"
defaultValue={belanjaState.create.form.name}
value={belanjaState.create.form.name}
onChange={(e) => (belanjaState.create.form.name = e.target.value)}
required
/>
@@ -94,7 +102,7 @@ function CreateBelanja() {
type="text"
label={<Text fw="bold" fz="sm">Nilai</Text>}
placeholder="Masukkan nilai belanja"
defaultValue={formatRupiah(belanjaState.create.form.value)}
value={formatRupiah(belanjaState.create.form.value)}
onChange={(e) => {
const raw = e.currentTarget.value;
belanjaState.create.form.value = unformatRupiah(raw);
@@ -103,6 +111,17 @@ function CreateBelanja() {
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -113,7 +132,7 @@ function CreateBelanja() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -17,8 +17,7 @@ import {
TableThead,
TableTr,
Text,
Title,
Tooltip
Title
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
@@ -96,18 +95,16 @@ function ListBelanja({ 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 Belanja</Title>
<Tooltip label="Tambah Belanja" withArrow>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push('/admin/ekonomi/PADesa-pendapatan-asli-desa/belanja/create')
}
>
Tambah Baru
</Button>
</Tooltip>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push('/admin/ekonomi/PADesa-pendapatan-asli-desa/belanja/create')
}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: 'auto' }}>
@@ -138,34 +135,30 @@ function ListBelanja({ search }: { search: string }) {
</TableTd>
<TableTd>
<Group gap="xs">
<Tooltip label="Edit" withArrow>
<Button
size="xs"
variant="light"
color="green"
onClick={() =>
router.push(
`/admin/ekonomi/PADesa-pendapatan-asli-desa/belanja/${item.id}`
)
}
>
<IconEdit size={16} />
</Button>
</Tooltip>
<Tooltip label="Hapus" withArrow>
<Button
size="xs"
variant="light"
color="red"
disabled={belanjaState.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={16} />
</Button>
</Tooltip>
<Button
size="xs"
variant="light"
color="green"
onClick={() =>
router.push(
`/admin/ekonomi/PADesa-pendapatan-asli-desa/belanja/${item.id}`
)
}
>
<IconEdit size={16} />
</Button>
<Button
size="xs"
variant="light"
color="red"
disabled={belanjaState.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={16} />
</Button>
</Group>
</TableTd>
</TableTr>

View File

@@ -6,11 +6,11 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title,
Tooltip
Title
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
@@ -22,12 +22,18 @@ function EditPembiayaan() {
const pembiayaanState = useProxy(PendapatanAsliDesa.pembiayaan);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
name: '',
value: '',
});
const [originalData, setOriginalData] = useState({
name: '',
value: '',
});
const formatRupiah = (value: number | string) => {
const number =
typeof value === 'number'
@@ -56,6 +62,10 @@ function EditPembiayaan() {
name: data.name || '',
value: String(data.value || ''),
});
setOriginalData({
name: data.name || '',
value: String(data.value || ''),
});
}
} catch (error) {
console.error('Error loading pembiayaan:', error);
@@ -66,8 +76,17 @@ function EditPembiayaan() {
loadPembiayaan();
}, [params?.id]);
const handleResetForm = () => {
setFormData({
name: originalData.name,
value: originalData.value,
});
toast.info('Form dikembalikan ke data awal');
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
pembiayaanState.update.form = {
...pembiayaanState.update.form,
name: formData.name,
@@ -80,6 +99,8 @@ function EditPembiayaan() {
} catch (error) {
console.error('Error updating jenis pembiayaan:', error);
toast.error('Terjadi kesalahan saat memperbarui jenis pembiayaan');
} finally {
setIsSubmitting(false);
}
};
@@ -87,16 +108,14 @@ function EditPembiayaan() {
<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>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Jenis Pembiayaan
</Title>
@@ -135,6 +154,17 @@ function EditPembiayaan() {
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -145,7 +175,7 @@ function EditPembiayaan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -1,26 +1,27 @@
'use client';
import React from 'react';
import { useProxy } from 'valtio/utils';
import PendapatanAsliDesa from '@/app/admin/(dashboard)/_state/ekonomi/PADesa';
import { useRouter } from 'next/navigation';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Title,
TextInput,
Text,
Tooltip,
TextInput,
Title
} 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 CreatePembiayaan() {
const pembiayaanState = useProxy(PendapatanAsliDesa.pembiayaan);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const formatRupiah = (value: number | string) => {
const number =
@@ -44,29 +45,35 @@ function CreatePembiayaan() {
};
const handleSubmit = async () => {
if (!pembiayaanState.create.form.name || !pembiayaanState.create.form.value) {
return toast.warn('Nama dan nilai wajib diisi');
}
try {
setIsSubmitting(true);
if (!pembiayaanState.create.form.name || !pembiayaanState.create.form.value) {
return toast.warn('Nama dan nilai wajib diisi');
}
await pembiayaanState.create.submit();
resetForm();
router.push('/admin/ekonomi/PADesa-pendapatan-asli-desa/pembiayaan');
await pembiayaanState.create.submit();
resetForm();
router.push('/admin/ekonomi/PADesa-pendapatan-asli-desa/pembiayaan');
} catch (error) {
console.error('Error creating pembiayaan:', error);
toast.error('Gagal menambahkan jenis pembiayaan');
} finally {
setIsSubmitting(false);
}
};
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()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Jenis Pembiayaan
</Title>
@@ -85,7 +92,7 @@ function CreatePembiayaan() {
<TextInput
label={<Text fw="bold" fz="sm">Nama Jenis Pembiayaan</Text>}
placeholder="Masukkan nama jenis pembiayaan"
defaultValue={pembiayaanState.create.form.name}
value={pembiayaanState.create.form.name}
onChange={(e) => {
pembiayaanState.create.form.name = e.currentTarget.value;
}}
@@ -96,7 +103,7 @@ function CreatePembiayaan() {
type="text"
label={<Text fw="bold" fz="sm">Nilai</Text>}
placeholder="Masukkan nilai"
defaultValue={formatRupiah(pembiayaanState.create.form.value)}
value={formatRupiah(pembiayaanState.create.form.value)}
onChange={(e) => {
const raw = e.currentTarget.value;
pembiayaanState.create.form.value = unformatRupiah(raw);
@@ -105,6 +112,17 @@ function CreatePembiayaan() {
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -115,7 +133,7 @@ function CreatePembiayaan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -1,9 +1,11 @@
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
@@ -14,19 +16,16 @@ import {
TableThead,
TableTr,
Text,
Title,
Tooltip,
Pagination,
Title
} from '@mantine/core';
import React, { useState } from 'react';
import HeaderSearch from '../../../_com/header';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import PendapatanAsliDesa from '../../../_state/ekonomi/PADesa';
import { useProxy } from 'valtio/utils';
import { useRouter } from 'next/navigation';
import { useShallowEffect } from '@mantine/hooks';
import colors from '@/con/colors';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import PendapatanAsliDesa from '../../../_state/ekonomi/PADesa';
function Pembiayaan() {
const [search, setSearch] = useState("");
@@ -95,18 +94,16 @@ function ListPembiayaan({ 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 Pembiayaan</Title>
<Tooltip label="Tambah Pembiayaan" withArrow>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push('/admin/ekonomi/PADesa-pendapatan-asli-desa/pembiayaan/create')
}
>
Tambah Baru
</Button>
</Tooltip>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push('/admin/ekonomi/PADesa-pendapatan-asli-desa/pembiayaan/create')
}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: 'auto' }}>

View File

@@ -6,11 +6,11 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title,
Tooltip
Title
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
@@ -22,6 +22,12 @@ function EditPendapatan() {
const pendapatanState = useProxy(PendapatanAsliDesa.pendapatan);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
value: "",
});
const [formData, setFormData] = useState({
name: '',
@@ -56,6 +62,10 @@ function EditPendapatan() {
name: data.name ?? '',
value: data.value?.toString() ?? '',
});
setOriginalData({
name: data.name ?? '',
value: data.value?.toString() ?? '',
});
}
} catch (error) {
console.error('Error loading pendapatan:', error);
@@ -73,8 +83,18 @@ function EditPendapatan() {
}));
};
const handleResetForm = () => {
setFormData({
name: originalData.name,
value: originalData.value,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
pendapatanState.update.form = {
...pendapatanState.update.form,
name: formData.name,
@@ -87,23 +107,24 @@ function EditPendapatan() {
} catch (error) {
console.error('Error updating jenis pendapatan:', error);
toast.error('Terjadi kesalahan saat memperbarui jenis pendapatan');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header with Back Button */}
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Jenis Pendapatan
</Title>
@@ -140,6 +161,17 @@ function EditPendapatan() {
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -150,7 +182,7 @@ function EditPendapatan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -5,19 +5,22 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title,
Tooltip
Title
} 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 CreatePendapatan() {
const pendapatanState = useProxy(PendapatanAsliDesa.pendapatan);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const formatRupiah = (value: number | string) => {
const number = typeof value === 'number' ? value : Number(value.replace(/\D/g, ''));
@@ -40,25 +43,31 @@ function CreatePendapatan() {
};
const handleSubmit = async () => {
await pendapatanState.create.submit();
resetForm();
router.push('/admin/ekonomi/PADesa-pendapatan-asli-desa/pendapatan');
try {
setIsSubmitting(true);
await pendapatanState.create.submit();
resetForm();
router.push('/admin/ekonomi/PADesa-pendapatan-asli-desa/pendapatan');
} catch (error) {
console.error('Error creating pendapatan:', error);
toast.error('Gagal menambahkan jenis pendapatan');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header dengan tombol back + judul */}
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Jenis Pendapatan
</Title>
@@ -75,7 +84,7 @@ function CreatePendapatan() {
>
<Stack gap="md">
<TextInput
defaultValue={pendapatanState.create.form.name}
value={pendapatanState.create.form.name}
onChange={(val) => {
pendapatanState.create.form.name = val.target.value;
}}
@@ -86,7 +95,7 @@ function CreatePendapatan() {
<TextInput
type="text"
defaultValue={formatRupiah(pendapatanState.create.form.value)}
value={formatRupiah(pendapatanState.create.form.value)}
onChange={(val) => {
const raw = val.currentTarget.value;
const cleanValue = unformatRupiah(raw);
@@ -98,6 +107,17 @@ function CreatePendapatan() {
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -108,7 +128,7 @@ function CreatePendapatan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -17,8 +17,7 @@ import {
TableThead,
TableTr,
Text,
Title,
Tooltip
Title
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
@@ -96,18 +95,16 @@ function ListPendapatan({ 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 Pendapatan</Title>
<Tooltip label="Tambah Pendapatan Baru" withArrow>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push('/admin/ekonomi/PADesa-pendapatan-asli-desa/pendapatan/create')
}
>
Tambah Baru
</Button>
</Tooltip>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push('/admin/ekonomi/PADesa-pendapatan-asli-desa/pendapatan/create')
}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: 'auto' }}>

View File

@@ -6,15 +6,15 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title,
Tooltip,
Title
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState, useCallback } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import demografiPekerjaan from '../../../_state/ekonomi/demografi-pekerjaan';
@@ -29,12 +29,17 @@ export default function EditDemografiPekerjaan() {
const router = useRouter();
const { id } = useParams() as { id: string };
const stateDemografi = useProxy(demografiPekerjaan);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<FormData>({
pekerjaan: '',
lakiLaki: 0,
perempuan: 0,
});
const [originalData, setOriginalData] = useState<FormData>({
pekerjaan: '',
lakiLaki: 0,
perempuan: 0,
});
// ✅ Load data hanya sekali di awal (tidak reset form)
useEffect(() => {
@@ -42,6 +47,7 @@ export default function EditDemografiPekerjaan() {
const loadData = async () => {
try {
setIsSubmitting(true);
stateDemografi.update.id = id;
await stateDemografi.findUnique.load(id);
@@ -52,10 +58,17 @@ export default function EditDemografiPekerjaan() {
lakiLaki: Number(data.lakiLaki ?? 0),
perempuan: Number(data.perempuan ?? 0),
});
setOriginalData({
pekerjaan: data.pekerjaan ?? '',
lakiLaki: Number(data.lakiLaki ?? 0),
perempuan: Number(data.perempuan ?? 0),
});
}
} catch (error) {
console.error('Error loading data:', error);
toast.error('Gagal memuat data');
} finally {
setIsSubmitting(false);
}
};
@@ -76,9 +89,19 @@ export default function EditDemografiPekerjaan() {
[]
);
const handleResetForm = () => {
setFormData({
pekerjaan: originalData.pekerjaan,
lakiLaki: Number(originalData.lakiLaki),
perempuan: Number(originalData.perempuan),
});
toast.info("Form dikembalikan ke data awal");
};
// ✅ Submit hanya update global state sekali
const handleSubmit = async () => {
try {
setIsSubmitting(true);
stateDemografi.update.id = id;
stateDemografi.update.form = { ...formData };
@@ -89,6 +112,8 @@ export default function EditDemografiPekerjaan() {
} catch (error) {
console.error('Error updating data:', error);
toast.error('Gagal memperbarui data');
} finally {
setIsSubmitting(false);
}
};
@@ -96,16 +121,14 @@ export default function EditDemografiPekerjaan() {
<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>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Demografi Pekerjaan
</Title>
@@ -148,6 +171,17 @@ export default function EditDemografiPekerjaan() {
/>
<Group justify="flex-end">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -158,7 +192,7 @@ export default function EditDemografiPekerjaan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -7,22 +7,24 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title,
Tooltip,
Title
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import demografiPekerjaan from '../../../_state/ekonomi/demografi-pekerjaan';
import { toast } from 'react-toastify';
function CreateDemografiPekerjaan() {
const stateDemografi = useProxy(demografiPekerjaan);
const [chartData, setChartData] = useState<any[]>([]);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
stateDemografi.create.form = {
@@ -33,32 +35,37 @@ function CreateDemografiPekerjaan() {
};
const handleSubmit = async () => {
const id = await stateDemografi.create.create();
if (id) {
const idStr = String(id);
await stateDemografi.findUnique.load(idStr);
if (stateDemografi.findUnique.data) {
setChartData([stateDemografi.findUnique.data]);
try {
const id = await stateDemografi.create.create();
if (id) {
const idStr = String(id);
await stateDemografi.findUnique.load(idStr);
if (stateDemografi.findUnique.data) {
setChartData([stateDemografi.findUnique.data]);
}
}
resetForm();
router.push('/admin/ekonomi/demografi-pekerjaan');
} catch (error) {
console.error('Error creating demografi pekerjaan:', error);
toast.error('Terjadi kesalahan saat menambah demografi pekerjaan');
} finally {
setIsSubmitting(false);
}
resetForm();
router.push('/admin/ekonomi/demografi-pekerjaan');
};
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()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Demografi Pekerjaan
</Title>
@@ -77,7 +84,7 @@ function CreateDemografiPekerjaan() {
<TextInput
label="Pekerjaan"
type="text"
defaultValue={stateDemografi.create.form.pekerjaan}
value={stateDemografi.create.form.pekerjaan}
placeholder="Masukkan pekerjaan"
onChange={(val) => {
stateDemografi.create.form.pekerjaan = val.currentTarget.value;
@@ -87,7 +94,7 @@ function CreateDemografiPekerjaan() {
<TextInput
label="Jumlah Pekerja Laki-Laki"
type="number"
defaultValue={stateDemografi.create.form.lakiLaki}
value={stateDemografi.create.form.lakiLaki}
placeholder="Masukkan jumlah pekerja laki-laki"
onChange={(val) => {
stateDemografi.create.form.lakiLaki = Number(val.currentTarget.value);
@@ -97,7 +104,7 @@ function CreateDemografiPekerjaan() {
<TextInput
label="Jumlah Pekerja Perempuan"
type="number"
defaultValue={stateDemografi.create.form.perempuan}
value={stateDemografi.create.form.perempuan}
placeholder="Masukkan jumlah pekerja perempuan"
onChange={(val) => {
stateDemografi.create.form.perempuan = Number(val.currentTarget.value);
@@ -106,6 +113,17 @@ function CreateDemografiPekerjaan() {
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -116,7 +134,7 @@ function CreateDemografiPekerjaan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import colors from '@/con/colors';
import { BarChart } from '@mantine/charts';
@@ -5,7 +6,9 @@ import {
Box,
Button,
Center,
Flex,
Group,
Pagination,
Paper,
Skeleton,
Stack,
@@ -16,10 +19,7 @@ import {
TableThead,
TableTr,
Text,
Title,
Tooltip,
Pagination,
Flex,
Title
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
@@ -85,7 +85,7 @@ function ListDemografiPekerjaan({ search }: { search: string }) {
useEffect(() => {
if (data) {
setChartData(
data.map((item) => ({
data.map((item: any) => ({
id: item.id,
pekerjaan: item.pekerjaan,
lakiLaki: Number(item.lakiLaki),
@@ -110,16 +110,14 @@ function ListDemografiPekerjaan({ 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 Demografi Pekerjaan</Title>
<Tooltip label="Tambah Data Pekerjaan" withArrow>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/ekonomi/demografi-pekerjaan/create')}
>
Tambah Baru
</Button>
</Tooltip>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/ekonomi/demografi-pekerjaan/create')}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: 'auto' }}>

View File

@@ -6,24 +6,24 @@ import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title,
Group,
Tooltip,
Title
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function EditJumlahPendudukMiskin() {
const router = useRouter();
const params = useParams() as { id: string };
const stateJPM = useProxy(jumlahPendudukMiskin);
const [isSubmitting, setIsSubmitting] = useState(false);
const id = params.id;
// 🔹 State lokal untuk form
@@ -32,6 +32,11 @@ function EditJumlahPendudukMiskin() {
totalPoorPopulation: 0,
});
const [originalData, setOriginalData] = useState({
year: 0,
totalPoorPopulation: 0,
});
// 🔹 Load data awal dari backend
useEffect(() => {
if (!id) return;
@@ -45,6 +50,10 @@ function EditJumlahPendudukMiskin() {
year: data.year || 0,
totalPoorPopulation: data.totalPoorPopulation || 0,
});
setOriginalData({
year: data.year || 0,
totalPoorPopulation: data.totalPoorPopulation || 0,
});
}
} catch (error) {
console.error('Gagal memuat data:', error);
@@ -63,9 +72,18 @@ function EditJumlahPendudukMiskin() {
}));
};
const handleResetForm = () => {
setFormData({
year: originalData.year,
totalPoorPopulation: originalData.totalPoorPopulation,
});
toast.info('Form dikembalikan ke data awal');
};
// 🔹 Submit form
const handleSubmit = async () => {
try {
setIsSubmitting(true);
stateJPM.update.id = id;
// update global state cuma saat submit
stateJPM.update.form = { ...formData };
@@ -76,22 +94,22 @@ function EditJumlahPendudukMiskin() {
} catch (error) {
console.error('Gagal menyimpan data:', error);
toast.error('Terjadi kesalahan saat menyimpan data');
} 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()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Jumlah Penduduk Miskin
</Title>
@@ -127,6 +145,17 @@ function EditJumlahPendudukMiskin() {
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -137,7 +166,7 @@ function EditJumlahPendudukMiskin() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -1,18 +1,20 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import { Box, Button, Group, Paper, Stack, TextInput, Title, Tooltip } from '@mantine/core';
import colors from '@/con/colors';
import { Box, Button, Group, Loader, Paper, Stack, TextInput, Title } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import colors from '@/con/colors';
import jumlahPendudukMiskin from '../../../_state/ekonomi/jumlah-penduduk-miskin';
import { toast } from 'react-toastify';
export default function CreateJumlahPendudukMiskin() {
const stateJPM = useProxy(jumlahPendudukMiskin);
const [chartData, setChartData] = useState<any[]>([]);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
stateJPM.create.form = {
@@ -22,27 +24,33 @@ export default function CreateJumlahPendudukMiskin() {
};
const handleSubmit = async () => {
const id = await stateJPM.create.create();
if (id) {
const idStr = String(id);
await stateJPM.findUnique.load(idStr);
if (stateJPM.findUnique.data) {
setChartData([stateJPM.findUnique.data]);
try {
setIsSubmitting(true);
const id = await stateJPM.create.create();
if (id) {
const idStr = String(id);
await stateJPM.findUnique.load(idStr);
if (stateJPM.findUnique.data) {
setChartData([stateJPM.findUnique.data]);
}
}
resetForm();
router.push('/admin/ekonomi/jumlah-penduduk-miskin');
} catch (error) {
console.error(error)
toast.error(error instanceof Error ? error.message : "Gagal menambahkan jumlah penduduk miskin")
} finally {
setIsSubmitting(false);
}
resetForm();
router.push('/admin/ekonomi/jumlah-penduduk-miskin');
};
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()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Jumlah Penduduk Miskin
</Title>
@@ -61,7 +69,7 @@ export default function CreateJumlahPendudukMiskin() {
<TextInput
label="Tahun"
type="number"
defaultValue={stateJPM.create.form.year || ''}
value={stateJPM.create.form.year || ''}
placeholder="Masukkan tahun"
onChange={(e) => {
const value = e.currentTarget.value;
@@ -73,7 +81,7 @@ export default function CreateJumlahPendudukMiskin() {
<TextInput
label="Jumlah Penduduk Miskin"
type="number"
defaultValue={stateJPM.create.form.totalPoorPopulation}
value={stateJPM.create.form.totalPoorPopulation}
placeholder="Masukkan jumlah penduduk miskin"
onChange={(e) => {
stateJPM.create.form.totalPoorPopulation = Number(e.currentTarget.value);
@@ -82,6 +90,17 @@ export default function CreateJumlahPendudukMiskin() {
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -92,7 +111,7 @@ export default function CreateJumlahPendudukMiskin() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -16,11 +16,10 @@ import {
TableThead,
TableTr,
Text,
Title,
Tooltip,
Title
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconEdit, IconSearch, IconTrash, IconPlus } from '@tabler/icons-react';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
@@ -101,18 +100,16 @@ function ListJumlahPendudukMiskin({ 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 Jumlah Penduduk Miskin</Title>
<Tooltip label="Tambah Data" withArrow>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push('/admin/ekonomi/jumlah-penduduk-miskin/create')
}
>
Tambah Baru
</Button>
</Tooltip>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push('/admin/ekonomi/jumlah-penduduk-miskin/create')
}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: 'auto' }}>

View File

@@ -2,18 +2,17 @@
'use client'
import colors from '@/con/colors';
import {
ScrollArea,
Stack,
Tabs,
TabsList,
TabsPanel,
TabsTab,
Title,
Tooltip,
ScrollArea,
Title
} from '@mantine/core';
import { IconSchool, IconUsers } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import { IconUsers, IconSchool } from '@tabler/icons-react';
function LayoutTabs({ children }: { children: React.ReactNode }) {
const router = useRouter();
@@ -24,15 +23,13 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
label: "Pengangguran Berdasarkan Usia",
value: "pengangguranberdasarkanusia",
href: "/admin/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran_berdasarkan_usia",
icon: <IconUsers size={18} stroke={1.8} />,
tooltip: "Data pengangguran menurut kelompok usia",
icon: <IconUsers size={18} stroke={1.8} />
},
{
label: "Pengangguran Berdasarkan Pendidikan",
value: "pengangguranberdasarkanpendidikan",
href: "/admin/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran_berdasarkan_pendidikan",
icon: <IconSchool size={18} stroke={1.8} />,
tooltip: "Data pengangguran menurut tingkat pendidikan",
icon: <IconSchool size={18} stroke={1.8} />
},
];
@@ -78,26 +75,19 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
}}
>
{tabs.map((tab, i) => (
<Tooltip
<TabsTab
key={i}
label={tab.tooltip}
position="bottom"
withArrow
transitionProps={{ transition: "pop", duration: 200 }}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
flexShrink: 0,
}}
>
<TabsTab
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
flexShrink: 0,
}}
>
{tab.label}
</TabsTab>
</Tooltip>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>

View File

@@ -2,10 +2,11 @@
'use client';
import grafikNganggur from '@/app/admin/(dashboard)/_state/ekonomi/usia-kerja-nganggur';
import colors from '@/con/colors';
import { Box, Button, Group, Paper, Stack, TextInput, Title, Tooltip } from '@mantine/core';
import { Box, Button, Group, Loader, Paper, Stack, TextInput, Title } from '@mantine/core';
import { 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';
function EditGrafikBerdasarkanPendidikan() {
@@ -13,6 +14,7 @@ function EditGrafikBerdasarkanPendidikan() {
const params = useParams() as { id: string };
const stategrafik = useProxy(grafikNganggur.grafikBerdasarkanPendidikan);
const id = params.id;
const [isSubmitting, setIsSubmitting] = useState(false);
// state lokal untuk form
const [formData, setFormData] = useState({
@@ -23,6 +25,14 @@ function EditGrafikBerdasarkanPendidikan() {
S1: '',
});
const [originalData, setOriginalData] = useState({
SD: '',
SMP: '',
SMA: '',
D3: '',
S1: '',
});
useEffect(() => {
if (id) {
stategrafik.findUnique.load(id).then(() => {
@@ -35,41 +45,64 @@ function EditGrafikBerdasarkanPendidikan() {
D3: data.D3 || '',
S1: data.S1 || '',
});
setOriginalData({
SD: data.SD || '',
SMP: data.SMP || '',
SMA: data.SMA || '',
D3: data.D3 || '',
S1: data.S1 || '',
});
}
});
}
}, [id]);
const handleChange = (field: keyof typeof formData) =>
(e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({
...prev,
[field]: e.currentTarget.value,
}));
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.currentTarget;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleResetForm = () => {
setFormData({
SD: originalData.SD,
SMP: originalData.SMP,
SMA: originalData.SMA,
D3: originalData.D3,
S1: originalData.S1,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
stategrafik.update.id = id;
stategrafik.update.form = { ...formData }; // update global state pas submit aja
await stategrafik.update.submit();
router.push(
'/admin/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran_berdasarkan_pendidikan'
);
try {
setIsSubmitting(true);
stategrafik.update.id = id;
stategrafik.update.form = { ...formData }; // update global state pas submit aja
await stategrafik.update.submit();
router.push(
'/admin/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran_berdasarkan_pendidikan'
);
} catch (error) {
console.error(error);
toast.error('Terjadi kesalahan saat memperbarui data grafik');
} 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()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Grafik Pengangguran Berdasarkan Pendidikan
</Title>
@@ -85,42 +118,58 @@ function EditGrafikBerdasarkanPendidikan() {
>
<Stack gap="md">
<TextInput
name="SD"
label="SD"
type="number"
placeholder="Masukkan jumlah"
value={formData.SD}
onChange={handleChange('SD')}
onChange={handleChange}
/>
<TextInput
name="SMP"
label="SMP"
type="number"
placeholder="Masukkan jumlah"
value={formData.SMP}
onChange={handleChange('SMP')}
onChange={handleChange}
/>
<TextInput
name="SMA"
label="SMA"
type="number"
placeholder="Masukkan jumlah"
value={formData.SMA}
onChange={handleChange('SMA')}
onChange={handleChange}
/>
<TextInput
name="D3"
label="D3"
type="number"
placeholder="Masukkan jumlah"
value={formData.D3}
onChange={handleChange('D3')}
onChange={handleChange}
/>
<TextInput
name="S1"
label="S1"
type="number"
placeholder="Masukkan jumlah"
value={formData.S1}
onChange={handleChange('S1')}
onChange={handleChange}
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -131,7 +180,7 @@ function EditGrafikBerdasarkanPendidikan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -1,19 +1,20 @@
'use client';
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from 'react';
import { useRouter } from 'next/navigation';
import grafikNganggur from '@/app/admin/(dashboard)/_state/ekonomi/usia-kerja-nganggur';
import { useProxy } from 'valtio/utils';
import { useState } from 'react';
import colors from '@/con/colors';
import { Box, Button, Paper, Stack, Title, TextInput, Group, Tooltip } from '@mantine/core';
import { Box, Button, Loader, Group, Paper, Stack, TextInput, Title } 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 CreateGrafikBerdasarkanPendidikan() {
const router = useRouter();
const stategrafik = useProxy(grafikNganggur.grafikBerdasarkanPendidikan);
const [donutData, setDonutData] = useState<any[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
stategrafik.create.form = {
@@ -27,28 +28,34 @@ function CreateGrafikBerdasarkanPendidikan() {
};
const handleSubmit = async () => {
const id = await stategrafik.create.create();
if (id) {
const idStr = String(id);
await stategrafik.findUnique.load(idStr);
if (stategrafik.findUnique.data) {
setDonutData([stategrafik.findUnique.data]);
try {
setIsSubmitting(true);
const id = await stategrafik.create.create();
if (id) {
const idStr = String(id);
await stategrafik.findUnique.load(idStr);
if (stategrafik.findUnique.data) {
setDonutData([stategrafik.findUnique.data]);
}
}
resetForm();
router.push(
'/admin/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran_berdasarkan_pendidikan'
);
} catch (error) {
console.error(error);
toast.error('Terjadi kesalahan saat menambahkan data grafik');
} finally {
setIsSubmitting(false);
}
resetForm();
router.push(
'/admin/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran_berdasarkan_pendidikan'
);
};
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()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Data Pengangguran Berdasarkan Pendidikan
</Title>
@@ -67,7 +74,7 @@ function CreateGrafikBerdasarkanPendidikan() {
label="SD"
type="number"
placeholder="Masukkan jumlah"
defaultValue={stategrafik.create.form.SD}
value={stategrafik.create.form.SD}
onChange={(val) => (stategrafik.create.form.SD = val.currentTarget.value)}
required
/>
@@ -75,7 +82,7 @@ function CreateGrafikBerdasarkanPendidikan() {
label="SMP"
type="number"
placeholder="Masukkan jumlah"
defaultValue={stategrafik.create.form.SMP}
value={stategrafik.create.form.SMP}
onChange={(val) => (stategrafik.create.form.SMP = val.currentTarget.value)}
required
/>
@@ -83,7 +90,7 @@ function CreateGrafikBerdasarkanPendidikan() {
label="SMA"
type="number"
placeholder="Masukkan jumlah"
defaultValue={stategrafik.create.form.SMA}
value={stategrafik.create.form.SMA}
onChange={(val) => (stategrafik.create.form.SMA = val.currentTarget.value)}
required
/>
@@ -91,7 +98,7 @@ function CreateGrafikBerdasarkanPendidikan() {
label="D3"
type="number"
placeholder="Masukkan jumlah"
defaultValue={stategrafik.create.form.D3}
value={stategrafik.create.form.D3}
onChange={(val) => (stategrafik.create.form.D3 = val.currentTarget.value)}
required
/>
@@ -99,12 +106,23 @@ function CreateGrafikBerdasarkanPendidikan() {
label="S1"
type="number"
placeholder="Masukkan jumlah"
defaultValue={stategrafik.create.form.S1}
value={stategrafik.create.form.S1}
onChange={(val) => (stategrafik.create.form.S1 = val.currentTarget.value)}
required
/>
<Group justify="right" mt="md">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -115,7 +133,7 @@ function CreateGrafikBerdasarkanPendidikan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

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