Compare commits

...

46 Commits

Author SHA1 Message Date
26ae1dfb1b Test C 1 2025-11-28 11:58:05 +08:00
234a02e849 Merge branch 'nico/28-nov-25' into staging 2025-11-28 11:45:12 +08:00
066180fc0e Fix registrasi, waitong-room, & tampilan layout sesuai id 2025-11-28 11:13:20 +08:00
56df11419d Merge pull request 'Balik ke awal' (#23) from nico/27-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/23
2025-11-27 18:53:50 +08:00
67f29aabef Balik ke awal 2025-11-27 18:53:33 +08:00
8fed1dfda4 Merge pull request 'Test hapus auth' (#22) from nico/test-hapus-auth into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/22
2025-11-27 18:14:45 +08:00
091c33a73c Test Hapus Auth 2025-11-27 18:13:29 +08:00
ff9c3668fc Merge pull request 'Fix eror registrasi 2' (#21) from nico/27-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/21
2025-11-27 17:10:27 +08:00
dbf7c34228 Fix eror registrasi 2 2025-11-27 17:08:17 +08:00
e2e8b47868 Merge pull request 'Fix eror registrasi 1' (#20) from nico/27-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/20
2025-11-27 16:47:07 +08:00
036fc86fed Fix eror registrasi 1 2025-11-27 16:45:47 +08:00
a389e5ee32 Merge pull request 'Tambah cookies di bagian verifikasi, agar kedeteksi user sudah regis apa belom' (#19) from nico/27-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/19
2025-11-27 14:47:48 +08:00
2cecec733e Tambah cookies di bagian verifikasi, agar kedeteksi user sudah regis apa belom 2025-11-27 14:46:49 +08:00
d7c694d237 Merge pull request 'Fix Seeder User, dan role' (#18) from nico/27-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/18
2025-11-27 12:19:17 +08:00
c64a2e5457 Fix Seeder User, dan role 2025-11-27 12:18:15 +08:00
4d3b2dd3f3 Merge pull request 'Fix Seeder' (#17) from nico/26-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/17
2025-11-26 15:35:21 +08:00
757911d7dd Fix Seeder 2025-11-26 15:32:49 +08:00
662dfc939e Merge pull request 'nico/26-nov-25' (#16) from nico/26-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/16
2025-11-26 15:03:34 +08:00
54232e4465 Menambahkan seed user
Fix Infinite reload di page ikm dan landing page
2025-11-26 15:01:34 +08:00
29a9a59bca saat tampilan user sudah diubah dan login ulan sudah menyesuaikan untuk menunya 2025-11-26 11:01:23 +08:00
2fb3666e57 User yang sudah registrasi sudah langsung diarahkan ke layout sesuai dengan roleIdnya
Superadmin sudah bisa menambah atau mengurangkan menu pad user yang diinginkan
Next-------------------------------
Ada bug saat tampilan menu sudah di edit superamin berhasil namun saat user logout tampilan menunya balik ke sebelumnya
2025-11-26 10:14:05 +08:00
e30b27f7a4 Fix Search 2025-11-25 17:30:41 +08:00
e941ed3893 Sudah fix menunya, superadmin bisa memilihkan menu untuk user 2025-11-25 16:21:15 +08:00
ace5aff1b6 Fix Kondisi Verify Otp Registrasi dan Login
Next mau fix eror saat user sudah terdaftar tetapi di redirect ke login, seharusnya redirect sesuai roleIdnya
2025-11-25 15:03:27 +08:00
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
fee1a6dfb2 Merge pull request 'Fix create admin & progress bar persentase' (#14) from nico/18-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/14
2025-11-18 17:24:52 +08:00
b3c169a2d4 Fix create admin & progress bar persentase 2025-11-18 17:23:38 +08:00
afe8c70cef Merge pull request 'Fix Edit di Admin APbdes, dan fix data real di apbdes user' (#13) from nico/18-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/13

Fix Edit di Admin APbdes, dan fix data real di apbdes user
2025-11-18 16:26:59 +08:00
2608a5ffdd Fix Edit di Admin APbdes, dan fix data real di apbdes user 2025-11-18 16:26:09 +08:00
fb010bd05a Merge pull request 'Fix Route APBdes' (#12) from nico/18-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/12
2025-11-18 14:30:00 +08:00
6c32f3ebdb Fix Route APBdes 2025-11-18 14:27:53 +08:00
cfe60ed8fe Merge pull request 'Fix SDGs Desa Barchart sudah responsive, tabel dan bar progress di menu apbdes sudah sesuai dengan data' (#11) from nico/18-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/11
2025-11-18 11:57:24 +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
dc56a329dc Merge pull request 'nico/12-nov-25' (#10) from nico/12-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/10
2025-11-12 17:43:31 +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
a9195d30bd Merge pull request 'Fix Text to Speech Menu Landing Page && Add barchart Landing Page APBDes' (#9) from nico/6-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/9
2025-11-06 11:36:00 +08:00
569b0d408b Merge pull request 'nico/5-nov-25' (#8) from nico/5-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/8
2025-11-05 14:33:33 +08:00
40985f961a Merge pull request 'nico/4-nov-25' (#7) from nico/4-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/7
2025-11-04 15:10:07 +08:00
22424ef53e Merge pull request 'Fix QC Keano FrontEnd' (#6) from nico/3-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/6
2025-11-03 17:36:47 +08:00
14b49334ac Merge pull request 'nico/3-nov-25' (#5) from nico/3-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/5
2025-11-03 10:29:31 +08:00
4a6829c502 Merge pull request 'nico/28-okt-25' (#4) from nico/28-okt-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/4
2025-10-28 23:05:26 +08:00
60b035749d Merge pull request 'nico/27-okt-25' (#3) from nico/27-okt-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/3
2025-10-27 22:19:56 +08:00
436 changed files with 20229 additions and 6209 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -54,6 +54,7 @@
"chart.js": "^4.4.8",
"classnames": "^2.5.1",
"colors": "^1.4.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"dotenv": "^17.2.3",
"elysia": "^1.3.5",

View File

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

View File

@@ -1,23 +1,10 @@
[
{
"id": "user-1",
"nama": "Admin Desa",
"nomor": "089647037426",
"roleId": "role-1",
"isActive": true
},
{
"id": "user-2",
"nama": "Admin Kesehatan",
"nomor": "082339004198",
"roleId": "role-2",
"isActive": true
},
{
"id": "user-3",
"nama": "Admin Sekolah",
"nomor": "085237157222",
"roleId": "role-3",
"isActive": true
"id": "cmie1o0zh0002vn132vtzg7hh",
"username": "SuperAdmin-Nico",
"nomor": "6289647037426",
"roleId": 0,
"isActive": true,
"sessionInvalid": false
}
]

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,28 @@ enum StatusPeminjaman {
// ========================================= USER ========================================= //
model User {
id String @id @default(cuid())
username String
nomor String @unique
role Role @relation(fields: [roleId], references: [id])
roleId String @default("1")
instansi String?
UserSession UserSession? // Nama instansi (Puskesmas, Sekolah, dll)
isActive Boolean @default(true)
lastLogin DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
id String @id @default(cuid())
username String
nomor String @unique
roleId String @default("2")
isActive Boolean @default(false)
sessionInvalid Boolean @default(false)
lastLogin DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
permissions Json?
sessions UserSession[] // ✅ Relasi one-to-many
role Role @relation(fields: [roleId], references: [id])
menuAccesses UserMenuAccess[]
@@map("users")
}
model Role {
id String @id @default(cuid())
name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH
description String?
permissions Json // Menyimpan permission dalam format JSON
permissions Json?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2167,26 +2203,32 @@ model KodeOtp {
otp Int
}
// Tabel untuk menyimpan permission
model Permission {
id String @id @default(cuid())
name String @unique
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
model UserSession {
id String @id @default(cuid())
token String @db.Text // ✅ JWT bisa panjang
expiresAt DateTime // ✅ Ubah jadi expiresAt (konsisten)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("permissions")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String // ✅ HAPUS @unique - user bisa punya multiple sessions
@@index([userId]) // ✅ Index untuk query cepat
@@index([token]) // ✅ Index untuk verify cepat
@@map("user_sessions")
}
model UserSession {
id String @id @default(cuid())
token String
expires DateTime?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
User User @relation(fields: [userId], references: [id])
userId String @unique
model UserMenuAccess {
id String @id @default(cuid())
userId String
menuId String // ID menu (misal: "Landing Page", "Kesehatan")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali
}
// ========================================= DATA PENDIDIKAN ========================================= //

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
import prisma from "@/lib/prisma";
import profilePejabatDesa from "./data/landing-page/profile/profile.json";
@@ -54,73 +55,110 @@ import tujuanProgram2 from "./data/pendidikan/pendidikan-non-formal/tujuan-progr
import programUnggulan from "./data/pendidikan/program-pendidikan-anak/program-unggulan.json";
import tujuanProgram from "./data/pendidikan/program-pendidikan-anak/tujuan-program.json";
import roles from "./data/user/roles.json";
import users from "./data/user/users.json";
import fileStorage from "./data/file-storage.json";
import jenjangPendidikan from "./data/pendidikan/info-sekolah/jenjang-pendidikan.json";
import seedAssets from "./seed_assets";
import users from "./data/user/users.json";
import { safeSeedUnique } from "./safeseedUnique";
(async () => {
// =========== USER & ROLE ===========
// In your seed.ts
// =========== ROLES ===========
console.log("🔄 Seeding roles...");
for (const r of roles) {
await safeSeedUnique("role", { id: r.id }, {
name: r.name,
description: r.description,
permissions: r.permissions,
isActive: r.isActive,
});
try {
// ✅ Destructure to remove permissions if exists
const { permissions, ...roleData } = r as any;
await safeSeedUnique(
"role",
{ name: roleData.name },
{
id: roleData.id,
name: roleData.name,
description: roleData.description,
permissions: roleData.permissions || {}, // ✅ Include permissions
isActive: roleData.isActive,
}
);
console.log(`✅ Seeded role -> ${roleData.name}`);
} catch (error: any) {
if (error.code === "P2002") {
console.warn(`⚠️ Role already exists (skipping): ${r.name}`);
} else {
console.error(`❌ Failed to seed role ${r.name}:`, error.message);
}
}
}
console.log("✅ Roles seeding completed");
console.log("✅ Roles seeded");
// =========== USERS ===========
// =========== USER ===========
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,
try {
// Verify role exists first
const roleExists = await prisma.role.findUnique({
where: { id: u.roleId.toString() },
select: { id: true }, // Only select id to minimize query
});
if (!roleExists) {
console.error(
`❌ Role with id ${u.roleId} not found for user ${u.username}`
);
continue;
}
await safeSeedUnique(
"user",
{ id: u.id },
{
username: u.username,
nomor: u.nomor,
roleId: u.roleId.toString(),
isActive: u.isActive,
sessionInvalid: false,
}
);
console.log(`✅ Seeded user -> ${u.username}`);
} catch (error: any) {
if (error.code === "P2003") {
console.error(
`❌ Foreign key constraint failed for user ${u.username}: Role ${u.roleId} does not exist`
);
} else {
console.error(`❌ Failed to seed user ${u.username}:`, error.message);
}
}
}
console.log("✅ Users seeded");
console.log("✅ Users seeding completed");
// =========== FILE STORAGE ===========
console.log("🔄 Seeding file storage...");
for (const f of fileStorage) {
await prisma.fileStorage.upsert({
where: { id: f.id },
update: {
name: f.name,
realName: f.realName,
path: f.path,
mimeType: f.mimeType,
link: f.link,
category: f.category,
},
create: {
id: f.id,
name: f.name,
realName: f.realName,
path: f.path,
mimeType: f.mimeType,
link: f.link,
category: f.category,
},
});
try {
await prisma.fileStorage.upsert({
where: { id: f.id },
update: {
name: f.name,
realName: f.realName,
path: f.path,
mimeType: f.mimeType,
link: f.link,
category: f.category,
},
create: {
id: f.id,
name: f.name,
realName: f.realName,
path: f.path,
mimeType: f.mimeType,
link: f.link,
category: f.category,
},
});
} catch (error: any) {
console.error(`❌ Failed to seed file storage ${f.name}:`, error.message);
}
}
console.log("✅ File storage seeded");
// =========== LANDING PAGE ===========
@@ -539,15 +577,40 @@ import { safeSeedUnique } from "./safeseedUnique";
console.log("posisi organisasi berhasil");
// =========== PEGAWAI PPID ===========
console.log("🔄 Seeding pegawai PPID...");
const flattenedPegawai = pegawaiPPID.flat();
// Check for duplicate emails
const emails = new Set();
for (const p of flattenedPegawai) {
await prisma.pegawaiPPID.upsert({
where: { id: p.id },
update: p,
create: p,
});
if (emails.has(p.email)) {
console.warn(`⚠️ Duplicate email found in pegawaiPPID: ${p.email}`);
}
emails.add(p.email);
}
console.log("pegawai berhasil");
for (const p of flattenedPegawai) {
try {
await prisma.pegawaiPPID.upsert({
where: { id: p.id },
update: p,
create: p,
});
console.log(`✅ Seeded pegawai PPID -> ${p.namaLengkap}`);
} catch (error: any) {
if (error.code === "P2002") {
console.warn(
`⚠️ Pegawai PPID with duplicate email (skipping): ${p.email}`
);
} else {
console.error(
`❌ Failed to seed pegawai PPID ${p.namaLengkap}:`,
error.message
);
}
}
}
console.log("✅ pegawai PPID seeding completed");
// =========== SUBMENU VISI MISI PPID ===========
@@ -811,7 +874,9 @@ import { safeSeedUnique } from "./safeseedUnique";
const flattenedPosisiBumdes = posisiOrganisasi.flat();
// ✅ Urutkan berdasarkan hierarki
const sortedPosisiBumdes = flattenedPosisiBumdes.sort((a, b) => a.hierarki - b.hierarki);
const sortedPosisiBumdes = flattenedPosisiBumdes.sort(
(a, b) => a.hierarki - b.hierarki
);
for (const p of sortedPosisiBumdes) {
console.log(`Seeding: ${p.nama} (id: ${p.id}, parent: ${p.parentId})`);
@@ -891,7 +956,7 @@ import { safeSeedUnique } from "./safeseedUnique";
// Add IDs to the kategoriKegiatan data
const kategoriKegiatan = kategoriKegiatanData.map((k, index) => ({
...k,
id: `kategori-${index + 1}`
id: `kategori-${index + 1}`,
}));
for (const k of kategoriKegiatan) {
@@ -1183,7 +1248,6 @@ import { safeSeedUnique } from "./safeseedUnique";
// seed assets
await seedAssets();
})()
.then(() => prisma.$disconnect())
.catch((e) => {

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

@@ -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.deleteUser.loading = false;
}
},
},
// Di file userState.ts atau dimana state user berada
update: {
loading: false,
async submit(payload: { id: string; isActive?: boolean; roleId?: string }) {
this.loading = true;
try {
const res = await fetch(`/api/user/updt`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.status === 200 && data.success) {
// ✅ Tampilkan pesan yang berbeda jika role berubah
if (data.roleChanged) {
toast.success(
`${data.message}\n\nUser akan logout otomatis dalam beberapa detik.`,
{
autoClose: 5000,
}
);
} else {
toast.success(data.message);
}
// Refresh list
await userState.findMany.load(
userState.findMany.page,
10,
userState.findMany.search
);
return true; // ✅ Return success untuk handling di component
} else {
toast.error(data.message || "Gagal update user");
return false;
}
} catch (e) {
console.error("❌ Error update user:", e);
toast.error("Gagal update user");
return false;
} finally {
this.loading = false;
}
},
},
});
const templateRole = z.object({
name: z.string().min(1, "Nama harus diisi"),
permissions: z.array(z.string()).min(1, "Permission harus diisi"),
});
const defaultRole = {
name: "",
permissions: [] as string[],
};
const roleState = proxy({
@@ -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,103 @@
'use client'
import { apiFetchLogin } from '@/app/admin/auth/_lib/api_fetch_auth';
'use client';
import { apiFetchLogin } from '@/app/api/auth/_lib/api_fetch_auth';
import colors from '@/con/colors';
import { Box, Button, Center, Flex, Image, Paper, Stack, Text, Title } from '@mantine/core';
import Link from 'next/link';
import { Box, Button, Center, Image, Paper, Stack, Title } from '@mantine/core';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { PhoneInput } from "react-international-phone";
import "react-international-phone/style.css";
import { PhoneInput } from 'react-international-phone';
import 'react-international-phone/style.css';
import { toast } from 'react-toastify';
function Login() {
const router = useRouter()
const [phone, setPhone] = useState("")
const [isError, setError] = useState(false)
const [loading, setLoading] = useState(false)
const router = useRouter();
const [phone, setPhone] = useState('');
const [loading, setLoading] = useState(false);
// Login.tsx
async function onLogin() {
const nomor = phone.substring(1);
if (nomor.length <= 4) return setError(true)
const cleanPhone = phone.replace(/\D/g, '');
console.log(cleanPhone);
if (cleanPhone.length < 10) {
toast.error('Nomor telepon tidak valid');
return;
}
try {
setLoading(true);
const response = await apiFetchLogin({ nomor: nomor })
if (response && response.success) {
localStorage.setItem("hipmi_auth_code_id", response.kodeId);
toast.success(response.message);
router.push("/validasi", { scroll: false });
const response = await apiFetchLogin({ nomor: cleanPhone });
console.log(response);
if (!response.success) {
toast.error(response.message || 'Gagal memproses login');
return;
}
// Simpan nomor untuk register
localStorage.setItem('auth_nomor', cleanPhone);
if (response.isRegistered) {
// ✅ User lama: simpan kodeId
localStorage.setItem('auth_kodeId', response.kodeId);
// ✅ Cookie sudah di-set oleh API, langsung redirect
router.push('/validasi'); // Clean URL
} else {
setLoading(false);
toast.error(response?.message);
// ❌ User baru: langsung ke registrasi (tanpa kodeId)
router.push('/registrasi');
}
} catch (error) {
setLoading(false)
console.log("Error Login", error)
toast.error("Terjadi kesalahan saat login")
console.error('Error Login:', error);
toast.error('Terjadi kesalahan saat login');
} finally {
setLoading(false);
}
}
return (
<Stack pos={"relative"} bg={colors.Bg}>
<Stack pos="relative" bg={colors.Bg}>
<Box px={{ base: 'md', md: 100 }} pb={50}>
<Stack align='center' justify='center' h={"100vh"}>
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
<Stack align='center' gap={"lg"}>
<Stack align="center" justify="center" h="100vh">
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
<Stack align="center" gap="lg">
<Box>
<Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}>
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
Login
</Title>
<Center>
<Image loading="lazy" src={"/darmasaba-icon.png"} alt="" w={80} />
<Image
loading="lazy"
src="/darmasaba-icon.png"
alt="Logo"
w={80}
h={80}
/>
</Center>
</Box>
<Box>
{/* <Box mb={10}>
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masuk Untuk Akses Admin</Text>
<TextInput
label='Username'
placeholder='Username'
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</Box> */}
<Box w="100%">
<PhoneInput
countrySelectorStyleProps={{
buttonStyle: {
backgroundColor: colors['blue-button'],
},
}}
inputStyle={{ width: "100%"}}
inputStyle={{ width: '100%' }}
defaultCountry="id"
onChange={(val) => {
setPhone(val);
}}
value={phone}
onChange={(val) => setPhone(val)}
/>
{isError ? (
toast.error("Masukan nomor telepon anda")
) : (
""
)}
<Box py={20} >
<Box py={20}>
<Button
fullWidth
bg={colors['blue-button']}
radius={'xl'}
radius="xl"
onClick={onLogin}
loading={loading ? true : false}
>Masuk
loading={loading}
>
Masuk
</Button>
</Box>
<Flex justify={'center'} align={'center'}>
<Text>Belum punya akun? </Text>
<Button variant='transparent' component={Link} href={'/registrasi'}>
<Text c={colors['blue-button']} fw={'bold'}>Registrasi</Text>
</Button>
</Flex>
</Box>
</Stack>
</Paper>
@@ -108,4 +107,4 @@ function Login() {
);
}
export default Login;
export default Login;

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,305 @@
'use client'
import colors from '@/con/colors';
import { Box, Button, Paper, PinInput, Stack, Text, Title } from '@mantine/core';
import { useRouter } from 'next/navigation';
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Loader,
Paper,
PinInput,
Stack,
Text,
Title,
} from '@mantine/core';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { authStore } from '@/store/authStore';
export default function Validasi() {
const router = useRouter();
const [nomor, setNomor] = useState<string | null>(null);
const [otp, setOtp] = useState('');
const [loading, setLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [kodeId, setKodeId] = useState<string | null>(null);
const [isRegistrationFlow, setIsRegistrationFlow] = useState(false);
// ✅ Deteksi flow dari cookie via API
useEffect(() => {
const checkFlow = async () => {
try {
const res = await fetch('/api/get-flow', {
credentials: 'include'
});
const data = await res.json();
if (data.success) {
setIsRegistrationFlow(data.flow === 'register');
console.log('🔍 Flow detected from cookie:', data.flow);
}
} catch (error) {
console.error('❌ Error getting flow:', error);
setIsRegistrationFlow(false);
}
};
checkFlow();
}, []);
useEffect(() => {
const storedKodeId = localStorage.getItem('auth_kodeId');
if (!storedKodeId) {
toast.error('Akses tidak valid');
router.replace('/login');
return;
}
setKodeId(storedKodeId);
const loadOtpData = async () => {
try {
const res = await fetch(`/api/otp-data?kodeId=${encodeURIComponent(storedKodeId)}`);
const result = await res.json();
if (res.ok && result.data?.nomor) {
setNomor(result.data.nomor);
} else {
throw new Error('Data OTP tidak valid');
}
} catch (error) {
console.error('Gagal memuat data OTP:', error);
toast.error('Kode verifikasi tidak valid');
router.replace('/login');
} finally {
setIsLoading(false);
}
};
loadOtpData();
}, [router]);
const handleVerify = async () => {
if (!kodeId || !nomor || otp.length < 4) return;
setLoading(true);
try {
if (isRegistrationFlow) {
await handleRegistrationVerification();
} else {
await handleLoginVerification();
}
} catch (error) {
console.error('Error saat verifikasi:', error);
toast.error('Terjadi kesalahan sistem');
} finally {
setLoading(false);
}
};
const handleRegistrationVerification = async () => {
const username = localStorage.getItem('auth_username');
if (!username) {
toast.error('Data registrasi tidak ditemukan.');
return;
}
const cleanNomor = nomor?.replace(/\D/g, '') ?? '';
if (cleanNomor.length < 10 || username.trim().length < 5) {
toast.error('Data tidak valid');
return;
}
// ✅ Verify OTP
const verifyRes = await fetch('/api/auth/verify-otp-register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor: cleanNomor, otp, kodeId }),
credentials: 'include'
});
const verifyData = await verifyRes.json();
if (!verifyRes.ok) {
toast.error(verifyData.message || 'Verifikasi OTP gagal');
return;
}
const finalizeRes = await fetch('/api/auth/finalize-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor: cleanNomor, username, kodeId }),
credentials: 'include'
});
const data = await finalizeRes.json();
// ✅ Check JSON response (bukan redirect)
if (data.success) {
toast.success('Registrasi berhasil! Menunggu persetujuan admin.');
await cleanupStorage();
// ✅ Client-side redirect
setTimeout(() => {
window.location.href = '/waiting-room';
}, 1000);
} else {
toast.error(data.message || 'Registrasi gagal');
}
};
const handleLoginVerification = async () => {
const loginRes = await fetch('/api/verify-otp-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, otp, kodeId }),
credentials: 'include'
});
const loginData = await loginRes.json();
if (!loginRes.ok) {
toast.error(loginData.message || 'Verifikasi gagal');
return;
}
const { id, name, roleId, isActive } = loginData.user;
authStore.setUser({
id,
name: name || 'User',
roleId: Number(roleId),
});
// ✅ Cleanup setelah login sukses
await cleanupStorage();
if (!isActive) {
window.location.href = '/waiting-room';
return;
}
const redirectPath = getRedirectPath(Number(roleId));
router.replace(redirectPath);
};
const getRedirectPath = (roleId: number): string => {
switch (roleId) {
case 0:
case 1:
case 2:
return '/admin/landing-page/profil/program-inovasi';
case 3:
return '/admin/kesehatan/posyandu';
case 4:
return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
default:
return '/admin';
}
};
// ✅ CLEANUP FUNCTION - Hapus localStorage + Cookie
const cleanupStorage = async () => {
// Clear localStorage
localStorage.removeItem('auth_kodeId');
localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_username');
// Clear cookie
try {
await fetch('/api/clear-flow', {
method: 'POST',
credentials: 'include'
});
} catch (error) {
console.error('Error clearing flow cookie:', error);
}
};
const handleResend = async () => {
if (!nomor) return;
try {
const res = await fetch('/api/resend', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor }),
});
const data = await res.json();
if (data.success) {
localStorage.setItem('auth_kodeId', data.kodeId);
toast.success('OTP baru dikirim');
} else {
toast.error(data.message || 'Gagal mengirim ulang OTP');
}
} catch {
toast.error('Gagal menghubungi server');
}
};
if (isLoading) {
return (
<Stack pos="relative" bg={colors.Bg} align="center" justify="center" h="100vh">
<Loader size="md" color={colors['blue-button']} />
</Stack>
);
}
if (!nomor) return null;
function Validasi() {
const router = useRouter()
return (
<Stack pos={"relative"} bg={colors.Bg}>
<Stack pos="relative" bg={colors.Bg}>
<Box px={{ base: 'md', md: 100 }} pb={50}>
<Stack align='center' justify='center' h={"100vh"}>
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
<Stack align='center' gap={"lg"}>
<Stack align="center" justify="center" h="100vh">
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
<Stack align="center" gap="lg">
<Box>
<Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}>
Kode Verifikasi
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
{isRegistrationFlow ? 'Verifikasi Registrasi' : 'Verifikasi Login'}
</Title>
<Text ta="center" size="sm" c="dimmed" mt="xs">
Kami telah mengirim kode ke nomor <strong>{nomor}</strong>
</Text>
</Box>
<Box>
<Box mb={10}>
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masukkan Kode Verifikasi</Text>
<PinInput type={/^[0-9]*$/} inputType="tel" inputMode="numeric" />
<Box w="100%">
<Box mb={20}>
<Text c={colors['blue-button']} ta="center" fz="sm" fw="bold">
Masukkan Kode Verifikasi
</Text>
<Center>
<PinInput
length={4}
value={otp}
onChange={setOtp}
onComplete={handleVerify}
inputMode="numeric"
size="lg"
/>
</Center>
</Box>
<Box py={20} >
<Button onClick={() => router.push("/admin/landing-page/profile/program-inovasi")}>
Page
<Button
fullWidth
onClick={handleVerify}
loading={loading}
disabled={otp.length < 4}
bg={colors['blue-button']}
radius="xl"
>
Verifikasi
</Button>
<Text ta="center" size="sm" mt="md">
Tidak menerima kode?{' '}
<Button
variant="subtle"
onClick={handleResend}
size="xs"
p={0}
h="auto"
color={colors['blue-button']}
>
Kirim Ulang
</Button>
</Box>
</Text>
</Box>
</Stack>
</Paper>
@@ -33,6 +307,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

@@ -1,19 +1,21 @@
/* 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,
Stack,
Text,
TextInput,
Title
Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack } from '@tabler/icons-react';
@@ -22,67 +24,120 @@ 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 */}
@@ -114,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"
@@ -148,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

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

View File

@@ -6,6 +6,7 @@ import {
Box,
Button,
Group,
Loader,
MultiSelect,
Paper,
Skeleton,
@@ -17,11 +18,14 @@ import {
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 = {
@@ -33,9 +37,17 @@ 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 (
@@ -62,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);
}}
@@ -94,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"
@@ -104,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

@@ -7,6 +7,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
@@ -22,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 =
@@ -58,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);
@@ -70,6 +81,7 @@ function EditBelanja() {
const handleSubmit = async () => {
try {
setIsSubmitting(true);
belanjaState.update.form = {
...belanjaState.update.form,
name: formData.name,
@@ -82,9 +94,19 @@ 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 */}
@@ -135,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"
@@ -145,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,6 +6,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Text,
@@ -14,12 +15,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 CreateBelanja() {
const belanjaState = useProxy(PendapatanAsliDesa.belanja);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const formatRupiah = (value: number | string) => {
const number =
@@ -47,9 +50,17 @@ 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 (
@@ -82,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
/>
@@ -91,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);
@@ -100,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"
@@ -110,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

@@ -6,6 +6,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
@@ -21,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'
@@ -55,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);
@@ -65,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,
@@ -79,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);
}
};
@@ -132,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"
@@ -142,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

@@ -5,6 +5,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Text,
@@ -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 CreatePembiayaan() {
const pembiayaanState = useProxy(PendapatanAsliDesa.pembiayaan);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const formatRupiah = (value: number | string) => {
const number =
@@ -42,13 +45,21 @@ 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 (
@@ -81,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;
}}
@@ -92,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);
@@ -101,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"
@@ -111,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

@@ -6,6 +6,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
@@ -21,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: '',
@@ -55,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);
@@ -72,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,
@@ -86,7 +107,10 @@ function EditPendapatan() {
} catch (error) {
console.error('Error updating jenis pendapatan:', error);
toast.error('Terjadi kesalahan saat memperbarui jenis pendapatan');
} finally {
setIsSubmitting(false);
}
};
return (
@@ -137,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"
@@ -147,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,6 +5,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
@@ -12,11 +13,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 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, ''));
@@ -39,9 +43,17 @@ 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 (
@@ -72,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;
}}
@@ -83,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);
@@ -95,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"
@@ -105,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

@@ -6,6 +6,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
@@ -28,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(() => {
@@ -41,6 +47,7 @@ export default function EditDemografiPekerjaan() {
const loadData = async () => {
try {
setIsSubmitting(true);
stateDemografi.update.id = id;
await stateDemografi.findUnique.load(id);
@@ -51,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);
}
};
@@ -75,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 };
@@ -88,6 +112,8 @@ export default function EditDemografiPekerjaan() {
} catch (error) {
console.error('Error updating data:', error);
toast.error('Gagal memperbarui data');
} finally {
setIsSubmitting(false);
}
};
@@ -145,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"
@@ -155,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,6 +7,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
@@ -17,11 +18,13 @@ 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 = {
@@ -32,16 +35,23 @@ 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 (
@@ -74,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;
@@ -84,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);
@@ -94,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);
@@ -103,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"
@@ -113,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

@@ -7,6 +7,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
@@ -22,7 +23,7 @@ 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
@@ -31,6 +32,11 @@ function EditJumlahPendudukMiskin() {
totalPoorPopulation: 0,
});
const [originalData, setOriginalData] = useState({
year: 0,
totalPoorPopulation: 0,
});
// 🔹 Load data awal dari backend
useEffect(() => {
if (!id) return;
@@ -44,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);
@@ -62,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 };
@@ -75,6 +94,8 @@ function EditJumlahPendudukMiskin() {
} catch (error) {
console.error('Gagal menyimpan data:', error);
toast.error('Terjadi kesalahan saat menyimpan data');
} finally {
setIsSubmitting(false);
}
};
@@ -124,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"
@@ -134,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

@@ -2,17 +2,19 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import colors from '@/con/colors';
import { Box, Button, Group, Paper, Stack, TextInput, Title } from '@mantine/core';
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 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,16 +24,24 @@ 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 (
@@ -59,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;
@@ -71,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);
@@ -80,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"
@@ -90,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

@@ -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 } 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,26 +45,51 @@ 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 (
@@ -83,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"
@@ -129,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

@@ -3,16 +3,18 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import grafikNganggur from '@/app/admin/(dashboard)/_state/ekonomi/usia-kerja-nganggur';
import colors from '@/con/colors';
import { Box, Button, Group, Paper, Stack, TextInput, Title } 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 = {
@@ -26,18 +28,26 @@ 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 (
@@ -64,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
/>
@@ -72,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
/>
@@ -80,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
/>
@@ -88,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
/>
@@ -96,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"
@@ -112,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>

View File

@@ -217,20 +217,22 @@ function ListGrafikBerdasarkanPendidikan({ search }: { search: string }) {
<Title order={3} pb={10}>
Grafik Pengangguran Berdasarkan Pendidikan
</Title>
{donutData.length > 0 ? (
<DonutChart
data={donutData}
withLabels
withTooltip
tooltipDataSource="segment"
size={260}
thickness={40}
/>
) : (
<Text color="dimmed">
Belum ada data untuk ditampilkan dalam grafik
</Text>
)}
<Center>
{donutData.length > 0 ? (
<DonutChart
data={donutData}
withLabels
withTooltip
tooltipDataSource="segment"
size={260}
thickness={40}
/>
) : (
<Text color="dimmed">
Belum ada data untuk ditampilkan dalam grafik
</Text>
)}
</Center>
</Stack>
</Paper>

View File

@@ -6,6 +6,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
@@ -25,6 +26,8 @@ function EditGrafikBerdasarkanUsiaKerjaYangMenganggur() {
);
const id = params.id;
const [isSubmitting, setIsSubmitting] = useState(false);
// ✅ state lokal, controlled
const [formData, setFormData] = useState({
usia18_25: '',
@@ -33,6 +36,13 @@ function EditGrafikBerdasarkanUsiaKerjaYangMenganggur() {
usia46_keatas: '',
});
const [originalData, setOriginalData] = useState({
usia18_25: '',
usia26_35: '',
usia36_45: '',
usia46_keatas: '',
});
// load data dari global state -> masukin ke local state
useEffect(() => {
if (id) {
@@ -45,6 +55,13 @@ function EditGrafikBerdasarkanUsiaKerjaYangMenganggur() {
usia36_45: data.usia36_45 || '',
usia46_keatas: data.usia46_keatas || '',
});
setOriginalData({
usia18_25: data.usia18_25 || '',
usia26_35: data.usia26_35 || '',
usia36_45: data.usia36_45 || '',
usia46_keatas: data.usia46_keatas || '',
});
}
});
}
@@ -57,8 +74,19 @@ function EditGrafikBerdasarkanUsiaKerjaYangMenganggur() {
}));
};
const handleResetForm = () => {
setFormData({
usia18_25: originalData.usia18_25,
usia26_35: originalData.usia26_35,
usia36_45: originalData.usia36_45,
usia46_keatas: originalData.usia46_keatas,
});
toast.info('Form dikembalikan ke data awal');
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// ✅ baru update global state pas submit
stategrafik.update.id = id;
stategrafik.update.form = { ...formData };
@@ -72,6 +100,8 @@ function EditGrafikBerdasarkanUsiaKerjaYangMenganggur() {
} catch (error) {
console.error(error);
toast.error('Terjadi kesalahan saat memperbarui data grafik');
} finally {
setIsSubmitting(false);
}
};
@@ -134,6 +164,17 @@ function EditGrafikBerdasarkanUsiaKerjaYangMenganggur() {
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -144,7 +185,7 @@ function EditGrafikBerdasarkanUsiaKerjaYangMenganggur() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -4,16 +4,18 @@
import grafikNganggur from '@/app/admin/(dashboard)/_state/ekonomi/usia-kerja-nganggur';
import colors from '@/con/colors';
import { Box, Button, Group, Paper, Stack, TextInput, Title } from '@mantine/core';
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 { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function CreateGrafikBerdasarkanUsiaKerjaYangMenganggur() {
const router = useRouter();
const stategrafik = useProxy(grafikNganggur.grafikBerdasarkanUsiaKerjaNganggur);
const [donutData, setDonutData] = useState<any[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
stategrafik.create.form = {
@@ -26,16 +28,24 @@ function CreateGrafikBerdasarkanUsiaKerjaYangMenganggur() {
};
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_usia');
} catch (error) {
console.error('Error creating:', error);
toast.error('Terjadi kesalahan saat membuat data');
} finally {
setIsSubmitting(false);
}
resetForm();
router.push('/admin/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran_berdasarkan_usia');
};
return (
@@ -64,7 +74,7 @@ function CreateGrafikBerdasarkanUsiaKerjaYangMenganggur() {
label="Usia 18 - 25"
type="number"
placeholder="Masukkan jumlah"
defaultValue={stategrafik.create.form.usia18_25}
value={stategrafik.create.form.usia18_25}
onChange={(val) => (stategrafik.create.form.usia18_25 = val.currentTarget.value)}
required
/>
@@ -72,7 +82,7 @@ function CreateGrafikBerdasarkanUsiaKerjaYangMenganggur() {
label="Usia 26 - 35"
type="number"
placeholder="Masukkan jumlah"
defaultValue={stategrafik.create.form.usia26_35}
value={stategrafik.create.form.usia26_35}
onChange={(val) => (stategrafik.create.form.usia26_35 = val.currentTarget.value)}
required
/>
@@ -80,7 +90,7 @@ function CreateGrafikBerdasarkanUsiaKerjaYangMenganggur() {
label="Usia 36 - 45"
type="number"
placeholder="Masukkan jumlah"
defaultValue={stategrafik.create.form.usia36_45}
value={stategrafik.create.form.usia36_45}
onChange={(val) => (stategrafik.create.form.usia36_45 = val.currentTarget.value)}
required
/>
@@ -88,13 +98,24 @@ function CreateGrafikBerdasarkanUsiaKerjaYangMenganggur() {
label="Usia 46 +"
type="number"
placeholder="Masukkan jumlah"
defaultValue={stategrafik.create.form.usia46_keatas}
value={stategrafik.create.form.usia46_keatas}
onChange={(val) => (stategrafik.create.form.usia46_keatas = val.currentTarget.value)}
required
/>
{/* Submit Button */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -105,7 +126,7 @@ function CreateGrafikBerdasarkanUsiaKerjaYangMenganggur() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -7,6 +7,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Text,
@@ -31,6 +32,7 @@ function EditDetailDataPengangguran() {
const stateDetail = useProxy(jumlahPengangguranState.jumlahPengangguran);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
// --- state lokal form
const [formData, setFormData] = useState({
@@ -42,6 +44,15 @@ function EditDetailDataPengangguran() {
percentageChange: 0,
});
const [originalData, setOriginalData] = useState({
month: '',
year: new Date().getFullYear(),
educatedUnemployment: 0,
uneducatedUnemployment: 0,
totalUnemployment: 0,
percentageChange: 0,
});
// --- hitung total + persentase perubahan
const calculateTotalAndChange = useCallback(
async (data: typeof formData) => {
@@ -109,6 +120,15 @@ function EditDetailDataPengangguran() {
totalUnemployment: data.totalUnemployment,
percentageChange: data.percentageChange || 0,
});
setOriginalData({
month: data.month,
year: yearValue,
educatedUnemployment: data.educatedUnemployment,
uneducatedUnemployment: data.uneducatedUnemployment,
totalUnemployment: data.totalUnemployment,
percentageChange: data.percentageChange || 0,
});
} catch (err) {
console.error('Error loading detail:', err);
toast.error('Gagal memuat data detail');
@@ -118,9 +138,22 @@ function EditDetailDataPengangguran() {
loadDetail();
}, [params?.id]);
const handleResetForm = () => {
setFormData({
month: originalData.month,
year: originalData.year,
educatedUnemployment: originalData.educatedUnemployment,
uneducatedUnemployment: originalData.uneducatedUnemployment,
totalUnemployment: originalData.totalUnemployment,
percentageChange: originalData.percentageChange,
});
toast.info("Form dikembalikan ke data awal");
};
// --- submit form
const handleSubmit = async () => {
try {
setIsSubmitting(true);
const { total, percentageChange } = await calculateTotalAndChange(formData);
stateDetail.update.form = {
@@ -137,6 +170,8 @@ function EditDetailDataPengangguran() {
} catch (err) {
console.error('Error updating:', err);
toast.error('Terjadi kesalahan saat memperbarui data');
} finally {
setIsSubmitting(false);
}
};
@@ -205,6 +240,17 @@ function EditDetailDataPengangguran() {
</Text>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -215,7 +261,7 @@ function EditDetailDataPengangguran() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -7,6 +7,7 @@ import {
Box,
Button,
Group,
Loader,
NumberInput,
Paper,
Select,
@@ -17,12 +18,14 @@ import {
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 CreateJumlahPengangguran() {
const stateDetail = useProxy(jumlahPengangguranState.jumlahPengangguran);
const [chartData, setChartData] = useState<any[]>([]);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const monthOptions = [
'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun',
@@ -72,15 +75,23 @@ function CreateJumlahPengangguran() {
};
const handleSubmit = async () => {
await calculateTotalAndChange();
const id = await stateDetail.create.create();
if (id) {
await stateDetail.findUnique.load(String(id));
if (stateDetail.findUnique.data) {
setChartData([stateDetail.findUnique.data]);
try {
setIsSubmitting(true);
await calculateTotalAndChange();
const id = await stateDetail.create.create();
if (id) {
await stateDetail.findUnique.load(String(id));
if (stateDetail.findUnique.data) {
setChartData([stateDetail.findUnique.data]);
}
resetForm();
router.push('/admin/ekonomi/jumlah-pengangguran');
}
resetForm();
router.push('/admin/ekonomi/jumlah-pengangguran');
} catch (error) {
console.error("Error creating jumlah pengangguran:", error);
toast.error("Gagal menambahkan data pengangguran");
} finally {
setIsSubmitting(false);
}
};
@@ -176,7 +187,19 @@ function CreateJumlahPengangguran() {
</Box>
{/* Action Button */}
<Group justify="right" mt="md">
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -186,9 +209,8 @@ function CreateJumlahPengangguran() {
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
disabled={!stateDetail.create.form.month || !stateDetail.create.form.year}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -7,6 +7,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Text,
@@ -23,6 +24,7 @@ function EditLowonganKerja() {
const lowonganState = useProxy(lowonganKerjaState);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
posisi: '',
@@ -35,6 +37,17 @@ function EditLowonganKerja() {
notelp: '',
});
const [originalData, setOriginalData] = useState({
posisi: '',
namaPerusahaan: '',
lokasi: '',
tipePekerjaan: '',
gaji: '',
deskripsi: '',
kualifikasi: '',
notelp: '',
})
// load data sekali aja ketika mount / id berubah
useEffect(() => {
const loadLowongan = async () => {
@@ -54,6 +67,16 @@ function EditLowonganKerja() {
kualifikasi: data.kualifikasi || '',
notelp: data.notelp || '',
});
setOriginalData({
posisi: data.posisi || '',
namaPerusahaan: data.namaPerusahaan || '',
lokasi: data.lokasi || '',
tipePekerjaan: data.tipePekerjaan || '',
gaji: data.gaji || '',
deskripsi: data.deskripsi || '',
kualifikasi: data.kualifikasi || '',
notelp: data.notelp || '',
});
}
} catch (error) {
console.error("Error loading lowongan kerja:", error);
@@ -70,9 +93,23 @@ function EditLowonganKerja() {
[field]: value,
}));
};
const handleResetForm = () => {
setFormData({
posisi: originalData.posisi,
namaPerusahaan: originalData.namaPerusahaan,
lokasi: originalData.lokasi,
tipePekerjaan: originalData.tipePekerjaan,
gaji: originalData.gaji,
deskripsi: originalData.deskripsi,
kualifikasi: originalData.kualifikasi,
notelp: originalData.notelp,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
lowonganState.update.id = params?.id as string;
lowonganState.update.form = { ...formData };
@@ -82,6 +119,8 @@ function EditLowonganKerja() {
} catch (error) {
console.error("Error updating lowongan kerja:", error);
toast.error("Terjadi kesalahan saat memperbarui lowongan kerja");
} finally {
setIsSubmitting(false);
}
};
@@ -175,6 +214,17 @@ function EditLowonganKerja() {
</Box>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -185,7 +235,7 @@ function EditLowonganKerja() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -4,6 +4,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Text,
@@ -15,10 +16,13 @@ import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils';
import CreateEditor from '../../../_com/createEditor';
import lowonganKerjaState from '../../../_state/ekonomi/lowongan-kerja';
import { useState } from 'react';
import { toast } from 'react-toastify';
function CreateLowonganKerja() {
const lowonganState = useProxy(lowonganKerjaState);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
lowonganState.create.form = {
@@ -34,9 +38,19 @@ function CreateLowonganKerja() {
};
const handleSubmit = async () => {
await lowonganState.create.create();
resetForm();
router.push('/admin/ekonomi/lowongan-kerja-lokal');
try {
setIsSubmitting(true);
await lowonganState.create.create();
resetForm();
router.push('/admin/ekonomi/lowongan-kerja-lokal');
} catch (error) {
console.error('Error creating lowongan kerja:', error);
toast.error(
error instanceof Error ? error.message : 'Gagal membuat lowongan kerja'
);
} finally {
setIsSubmitting(false);
}
};
return (
@@ -66,7 +80,7 @@ function CreateLowonganKerja() {
>
<Stack gap="md">
<TextInput
defaultValue={lowonganState.create.form.posisi}
value={lowonganState.create.form.posisi}
onChange={(val) =>
(lowonganState.create.form.posisi = val.target.value)
}
@@ -75,7 +89,7 @@ function CreateLowonganKerja() {
required
/>
<TextInput
defaultValue={lowonganState.create.form.namaPerusahaan}
value={lowonganState.create.form.namaPerusahaan}
onChange={(val) =>
(lowonganState.create.form.namaPerusahaan = val.target.value)
}
@@ -84,7 +98,7 @@ function CreateLowonganKerja() {
required
/>
<TextInput
defaultValue={lowonganState.create.form.notelp}
value={lowonganState.create.form.notelp}
onChange={(val) =>
(lowonganState.create.form.notelp = val.target.value)
}
@@ -93,7 +107,7 @@ function CreateLowonganKerja() {
required
/>
<TextInput
defaultValue={lowonganState.create.form.lokasi}
value={lowonganState.create.form.lokasi}
onChange={(val) =>
(lowonganState.create.form.lokasi = val.target.value)
}
@@ -102,7 +116,7 @@ function CreateLowonganKerja() {
required
/>
<TextInput
defaultValue={lowonganState.create.form.tipePekerjaan}
value={lowonganState.create.form.tipePekerjaan}
onChange={(val) =>
(lowonganState.create.form.tipePekerjaan = val.target.value)
}
@@ -111,7 +125,7 @@ function CreateLowonganKerja() {
required
/>
<TextInput
defaultValue={lowonganState.create.form.gaji}
value={lowonganState.create.form.gaji}
onChange={(val) =>
(lowonganState.create.form.gaji = val.target.value)
}
@@ -146,6 +160,17 @@ function CreateLowonganKerja() {
{/* 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"
@@ -156,7 +181,7 @@ function CreateLowonganKerja() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -5,6 +5,7 @@ import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Text,
@@ -23,9 +24,9 @@ function EditKategoriProduk() {
const params = useParams();
const id = params?.id as string;
const statePasar = useProxy(pasarDesaState.kategoriProduk);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({ nama: '' });
const [loading, setLoading] = useState(true);
const [originalData, setOriginalData] = useState({ nama: '' });
useEffect(() => {
const loadKategoriProduk = async () => {
@@ -40,12 +41,11 @@ function EditKategoriProduk() {
// simpan data ke state lokal
setFormData({ nama: data.nama || '' });
setOriginalData({ nama: data.nama || '' });
}
} catch (error) {
console.error('Error loading kategori produk:', error);
toast.error('Gagal memuat data kategori produk');
} finally {
setLoading(false);
}
};
@@ -59,8 +59,16 @@ function EditKategoriProduk() {
}));
};
const handleResetForm = () => {
setFormData({
nama: originalData.nama,
});
toast.info('Form dikembalikan ke data awal');
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
if (!formData.nama.trim()) {
toast.error('Nama kategori produk tidak boleh kosong');
return;
@@ -81,13 +89,11 @@ function EditKategoriProduk() {
} catch (error) {
console.error('Error updating kategori produk:', error);
toast.error('Terjadi kesalahan saat memperbarui kategori produk');
} finally {
setIsSubmitting(false);
}
};
if (loading) {
return <Text>Loading...</Text>;
}
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header dengan tombol back */}
@@ -125,6 +131,17 @@ function EditKategoriProduk() {
/>
<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 +152,7 @@ function EditKategoriProduk() {
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