Fix SDGs Desa Barchart sudah responsive, tabel dan bar progress di menu apbdes sudah sesuai dengan data

This commit is contained in:
2025-11-18 11:56:16 +08:00
parent 9622eb5a9a
commit 0feeb4de93
25 changed files with 2292 additions and 1269 deletions

View File

@@ -5,58 +5,166 @@ 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),
uraian: z.string().min(1),
anggaran: z.number().min(0),
realisasi: z.number().min(0),
selisih: z.number(),
persentase: z.number().min(0).max(1000), // allow >100% if overbudget
level: z.number().int().min(1).max(3),
tipe: z.string().min(1), // "pendapatan" | "belanja"
});
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>[],
};
const apbdes = proxy({
create: {
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,
});
// --- 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
if (res.status === 200) {
return {
kode: item.kode || "",
uraian: item.uraian || "",
anggaran,
realisasi,
selisih,
persentase,
level: item.level || 1,
tipe: item.tipe || "pendapatan",
};
}
// --- State Utama ---
const apbdes = proxy({
// create: {
// form: { ...defaultApbdesForm },
// 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);
// },
// 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();
// this.reset();
// } else {
// toast.error(res.data?.message || "Gagal membuat APBDes");
// }
// } catch (error: any) {
// console.error("Create APBDes error:", error);
// toast.error(error?.message || "Terjadi kesalahan saat membuat APBDes");
// } finally {
// this.loading = false;
// }
// },
// },
create: {
form: { ...defaultApbdesForm },
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);
},
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 +172,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,
})),
};
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

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

@@ -1,94 +1,102 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Image,
Loader,
NumberInput,
Paper,
Select,
Stack,
Table,
Text,
TextInput,
Title,
Loader,
ActionIcon
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconFile, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import {
IconArrowBack,
IconFile,
IconPhoto,
IconPlus,
IconTrash,
IconX
} from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
// Tipe untuk form item
type ItemForm = {
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
};
function EditAPBDes() {
const apbdesState = useProxy(apbdes);
const router = useRouter();
const params = useParams();
const [formData, setFormData] = useState({
name: '',
jumlah: '',
imageId: '',
fileId: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
jumlah: "",
imageId: "",
fileId: "",
imageUrl: "",
docUrl: "",
});
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewDoc, setPreviewDoc] = useState<string | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null);
const [docFile, setDocFile] = useState<File | null>(null);
// Load data on mount
// Form input untuk item baru
const [newItem, setNewItem] = useState<ItemForm>({
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
// Type for the API response
interface APBDesResponse {
id: string;
image?: {
link: string;
id: string;
};
file?: {
link: string;
id: string;
};
// Add other properties as needed
}
// Load data saat pertama kali
useEffect(() => {
const loadData = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await apbdesState.edit.load(id);
const id = params?.id as string;
if (id) {
apbdesState.edit.load(id).then((response) => {
const data = response as unknown as APBDesResponse;
if (data) {
const newForm = {
name: data.name || "",
jumlah: data.jumlah || "",
imageId: data.imageId || "",
fileId: data.fileId || "",
};
setFormData(newForm);
// simpan juga versi original
setOriginalData({
...newForm,
imageUrl: data.image?.link || "",
docUrl: data.file?.link || "",
});
// ✅ Ambil link langsung dari response
setPreviewImage(data.image?.link || null);
setPreviewDoc(data.file?.link || null);
}
} catch (err) {
console.error(err);
toast.error('Gagal memuat data APBDes');
}
};
loadData();
});
}
}, [params?.id]);
// Generic Dropzone handler
const handleDrop = (fileType: 'image' | 'doc') => (files: File[]) => {
const file = files[0];
if (!file) return;
@@ -102,51 +110,95 @@ function EditAPBDes() {
}
};
const handleAddItem = () => {
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
if (!kode || !uraian) {
return toast.warn('Kode dan uraian wajib diisi');
}
const selisih = realisasi - anggaran;
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
apbdesState.edit.addItem({
kode,
uraian,
anggaran,
realisasi,
selisih,
persentase,
level,
tipe,
});
setNewItem({
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
};
const handleRemoveItem = (index: number) => {
apbdesState.edit.removeItem(index);
};
const handleSubmit = async () => {
if (apbdesState.edit.form.items.length === 0) {
return toast.warn('Minimal harus ada 1 item APBDes');
}
try {
setIsSubmitting(true);
// Update global state with local form data first
apbdesState.edit.form = { ...apbdesState.edit.form, ...formData };
// Helper function for uploading file
const uploadFile = async (file: File | null) => {
if (!file) return null;
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
const uploaded = res.data?.data;
if (!uploaded?.id) throw new Error('Upload gagal');
return uploaded.id;
};
// Upload file baru jika ada
if (imageFile) {
const res = await ApiFetch.api.fileStorage.create.post({
file: imageFile,
name: imageFile.name,
});
const imageId = res.data?.data?.id;
if (imageId) apbdesState.edit.form.imageId = imageId;
}
// Upload files if selected
const uploadedImageId = await uploadFile(imageFile);
const uploadedDocId = await uploadFile(docFile);
if (docFile) {
const res = await ApiFetch.api.fileStorage.create.post({
file: docFile,
name: docFile.name,
});
const fileId = res.data?.data?.id;
if (fileId) apbdesState.edit.form.fileId = fileId;
}
if (uploadedImageId) apbdesState.edit.form.imageId = uploadedImageId;
if (uploadedDocId) apbdesState.edit.form.fileId = uploadedDocId;
await apbdesState.edit.update();
toast.success('APBDes berhasil diperbarui!');
router.push('/admin/landing-page/APBDes');
const success = await apbdesState.edit.update();
if (success) {
router.push('/admin/landing-page/APBDes');
}
} catch (err) {
console.error(err);
toast.error('Terjadi kesalahan saat memperbarui APBDes');
console.error('Update error:', err);
toast.error('Gagal memperbarui APBDes');
} finally {
setIsSubmitting(false);
}
};
const handleResetForm = () => {
setFormData({
name: originalData.name,
jumlah: originalData.jumlah,
imageId: originalData.imageId,
fileId: originalData.fileId,
});
setPreviewImage(originalData.imageUrl || null);
setImageFile(null);
setPreviewDoc(originalData.docUrl || null);
setDocFile(null);
toast.info("Form dikembalikan ke data awal");
const handleReset = () => {
const id = params?.id as string;
if (id) {
apbdesState.edit.load(id);
setImageFile(null);
setDocFile(null);
setNewItem({
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
toast.info('Form dikembalikan ke data awal');
}
};
return (
@@ -160,163 +212,272 @@ function EditAPBDes() {
</Title>
</Group>
<Paper w={{ base: '100%', md: '50%' }} bg={colors['white-1']} p="lg" radius="md" shadow="sm" style={{ border: '1px solid #e0e0e0' }}>
<Paper
w={{ base: '100%', md: '100%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
{/* Controlled Inputs */}
<TextInput
label="Nama APBDes"
placeholder="Masukkan nama APBDes"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
{/* Header Form */}
<NumberInput
label="Tahun"
value={apbdesState.edit.form.tahun || new Date().getFullYear()}
onChange={(val) =>
(apbdesState.edit.form.tahun = Number(val) || new Date().getFullYear())
}
min={2000}
max={2100}
required
/>
<TextInput
label="Jumlah Anggaran"
placeholder="Masukkan jumlah anggaran"
value={formData.jumlah}
onChange={(e) => setFormData({ ...formData, jumlah: e.target.value })}
required
/>
{/* Gambar & Dokumen */}
<Stack gap="xs">
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar APBDes
</Text>
<Dropzone
onDrop={handleDrop('image')}
onReject={() => toast.error('File gambar tidak valid')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
{previewImage ? 'Ganti gambar' : 'Unggah gambar'}
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview"
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={() => {
setPreviewImage(null);
setImageFile(null);
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Image Dropzone */}
<Box>
<Text fw="bold" fz="sm" mb={6}>Gambar APBDes</Text>
<Dropzone
onDrop={handleDrop('image')}
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 gambar .png, .jpg, .jpeg, webp</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
<Box>
<Text fw="bold" fz="sm" mb={6}>
Dokumen APBDes
</Text>
<Dropzone
onDrop={handleDrop('doc')}
onReject={() => toast.error('File dokumen tidak valid')}
maxSize={10 * 1024 ** 2}
accept={{
'application/pdf': ['.pdf'],
'application/msword': ['.doc'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
'application/vnd.ms-excel': ['.xls'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
}}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Idle>
<IconFile size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
{previewDoc ? 'Ganti dokumen' : 'Unggah dokumen'}
</Text>
</Stack>
</Group>
</Dropzone>
{previewDoc && (
<Box mt="sm">
<Button
component="a"
href={previewDoc}
target="_blank"
rel="noopener noreferrer"
variant="light"
size="xs"
leftSection={<IconFile size={14} />}
>
Lihat Dokumen
</Button>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
ml="sm"
onClick={() => {
setPreviewDoc(null);
setDocFile(null);
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
</Stack>
{/* Input Item Baru */}
<Paper withBorder p="md" radius="md">
<Title order={6} mb="sm">
Tambah Item Pendapatan/Belanja
</Title>
<Stack gap="xs">
<Group grow>
<TextInput
label="Kode"
placeholder="Contoh: 4.1.2"
value={newItem.kode}
onChange={(e) => setNewItem({ ...newItem, kode: e.target.value })}
required
/>
<Select
label="Level"
data={[
{ value: '1', label: 'Level 1 (Kelompok)' },
{ value: '2', label: 'Level 2 (Sub-kelompok)' },
{ value: '3', label: 'Level 3 (Detail)' },
]}
value={String(newItem.level)}
onChange={(val) => setNewItem({ ...newItem, level: Number(val) || 1 })}
/>
<Select
label="Tipe"
data={[
{ value: 'pendapatan', label: 'Pendapatan' },
{ value: 'belanja', label: 'Belanja' },
]}
value={newItem.tipe}
onChange={(val) => setNewItem({ ...newItem, tipe: (val as any) || 'pendapatan' })}
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setImageFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Document Dropzone */}
<Box>
<Text fw="bold" fz="sm" mb={6}>Dokumen APBDes</Text>
<Dropzone
onDrop={handleDrop('doc')}
onReject={() => toast.error('File tidak valid, gunakan format dokumen')}
maxSize={10 * 1024 ** 2}
accept={{
'application/pdf': ['.pdf'],
'application/msword': ['.doc'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
'application/vnd.ms-excel': ['.xls'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
}}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={150}>
<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><IconFile size={48} color="#868e96" stroke={1.5} /></Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>Seret dokumen atau klik untuk memilih file</Text>
<Text size="sm" c="dimmed">Maksimal 10MB, format PDF/DOC/DOCX/XLS/XLSX</Text>
</Stack>
</Group>
</Dropzone>
{previewDoc && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Text size="sm" c="dimmed" mb="xs">Dokumen terpilih: {docFile?.name || 'Dokumen'}</Text>
<Button component="a" href={previewDoc} target="_blank" rel="noopener noreferrer" variant="light" leftSection={<IconFile size={16} />} size="sm">
Lihat Dokumen
</Button>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewDoc(null);
setDocFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
<TextInput
label="Uraian"
placeholder="Contoh: Dana Desa"
value={newItem.uraian}
onChange={(e) => setNewItem({ ...newItem, uraian: e.target.value })}
required
/>
<Group grow>
<NumberInput
label="Anggaran (Rp)"
value={newItem.anggaran}
onChange={(val) => setNewItem({ ...newItem, anggaran: Number(val) || 0 })}
thousandSeparator
min={0}
/>
<NumberInput
label="Realisasi (Rp)"
value={newItem.realisasi}
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
thousandSeparator
min={0}
/>
</Group>
<Button
leftSection={<IconPlus size={16} />}
onClick={handleAddItem}
disabled={!newItem.kode || !newItem.uraian}
>
Tambah Item
</Button>
</Stack>
</Paper>
{/* Tabel Items */}
{apbdesState.edit.form.items.length > 0 && (
<Paper withBorder p="md" radius="md">
<Title order={6} mb="sm">
Daftar Item ({apbdesState.edit.form.items.length})
</Title>
<Table striped highlightOnHover>
<thead>
<tr>
<th>Kode</th>
<th>Uraian</th>
<th>Anggaran</th>
<th>Realisasi</th>
<th>Level</th>
<th>Tipe</th>
<th style={{ width: '50px' }}>Aksi</th>
</tr>
</thead>
<tbody>
{apbdesState.edit.form.items.map((item, idx) => (
<tr key={idx}>
<td>
<Text size="sm" fw={500}>
{item.kode}
</Text>
</td>
<td>{item.uraian}</td>
<td>{item.anggaran.toLocaleString('id-ID')}</td>
<td>{item.realisasi.toLocaleString('id-ID')}</td>
<td>
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
L{item.level}
</Badge>
</td>
<td>
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
{item.tipe}
</Badge>
</td>
<td>
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>
<IconTrash size={16} />
</ActionIcon>
</td>
</tr>
))}
</tbody>
</Table>
</Paper>
)}
{/* Tombol Aksi */}
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
<Button variant="outline" color="gray" radius="md" onClick={handleReset}>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={apbdesState.edit.form.items.length === 0 || apbdesState.edit.loading}
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'}
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan Perubahan'}
</Button>
</Group>
</Stack>
@@ -325,4 +486,4 @@ function EditAPBDes() {
);
}
export default EditAPBDes;
export default EditAPBDes;

View File

@@ -1,36 +1,53 @@
'use client'
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import { useProxy } from 'valtio/utils';
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import {
Box,
Button,
Group,
Image,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text
} from '@mantine/core';
import { IconArrowBack, IconEdit, IconFile, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import colors from '@/con/colors';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import apbdes from '../../../_state/landing-page/apbdes';
function DetailAPBDes() {
const apbdesState = useProxy(apbdes)
const [modalHapus, setModalHapus] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const params = useParams()
const router = useRouter()
useShallowEffect(() => {
apbdesState.findUnique.load(params?.id as string)
}, [])
const apbdesState = useProxy(apbdes);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const params = useParams();
const router = useRouter();
useEffect(() => {
if (!params?.id) return;
apbdesState.findUnique.load(params.id as string);
}, [params?.id]);
const handleHapus = () => {
if (selectedId) {
apbdesState.delete.byId(selectedId)
setModalHapus(false)
setSelectedId(null)
router.push("/admin/landing-page/APBDes")
apbdesState.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push('/admin/landing-page/APBDes');
}
}
};
if (!apbdesState.findUnique.data) {
return (
@@ -42,6 +59,11 @@ function DetailAPBDes() {
const data = apbdesState.findUnique.data;
// Helper: indentasi berdasarkan level
const getIndent = (level: number) => ({
paddingLeft: `${(level - 1) * 20}px`,
});
return (
<Box py={10}>
<Button
@@ -55,7 +77,7 @@ function DetailAPBDes() {
<Paper
withBorder
w={{ base: "100%", md: "60%" }}
w={{ base: '100%', md: '100%' }}
bg={colors['white-1']}
p="lg"
radius="md"
@@ -66,16 +88,21 @@ function DetailAPBDes() {
Detail APBDes
</Text>
{/* Info Header */}
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="md">
<Box>
<Text fz="lg" fw="bold">Nama APBDes</Text>
<Text fz="md" c="dimmed">{data.name || '-'}</Text>
<Text fz="md" c="dimmed">
{data.name || '-'}
</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Jumlah Anggaran</Text>
<Text fz="md" c="dimmed">Rp. {data.jumlah || '-'}</Text>
<Text fz="lg" fw="bold">Tahun</Text>
<Text fz="md" c="dimmed">
{data.tahun || '-'}
</Text>
</Box>
<Box>
@@ -117,32 +144,80 @@ function DetailAPBDes() {
</Box>
<Group gap="sm" mt="md">
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
disabled={apbdesState.delete.loading}
variant="light"
radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
disabled={apbdesState.delete.loading}
variant="light"
radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
<Button
color="green"
onClick={() => router.push(`/admin/landing-page/APBDes/${data.id}/edit`)}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
<Button
color="green"
onClick={() => router.push(`/admin/landing-page/APBDes/${data.id}/edit`)}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
</Group>
</Stack>
</Paper>
{/* Tabel Items */}
{data.items && data.items.length > 0 ? (
<Paper withBorder p="md" radius="md">
<Text fz="lg" fw="bold" mb="sm">
Rincian Pendapatan & Belanja ({data.items.length} item)
</Text>
<Box style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Uraian</TableTh>
<TableTh>Anggaran (Rp)</TableTh>
<TableTh>Realisasi (Rp)</TableTh>
<TableTh>Selisih (Rp)</TableTh>
<TableTh>Persentase (%)</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{[...data.items] // Create a new array before sorting
.sort((a, b) => a.kode.localeCompare(b.kode))
.map((item) => (
<TableTr key={item.id}>
<TableTd style={getIndent(item.level)}>
<Group>
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
<Text fz="sm" c="dimmed">{item.uraian}</Text>
</Group>
</TableTd>
<TableTd>{item.anggaran.toLocaleString('id-ID')}</TableTd>
<TableTd>{item.realisasi.toLocaleString('id-ID')}</TableTd>
<TableTd>
<Text c={item.selisih >= 0 ? 'green' : 'red'}>
{item.selisih.toLocaleString('id-ID')}
</Text>
</TableTd>
<TableTd>
<Text fw={500}>{item.persentase.toFixed(2)}%</Text>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</Box>
</Paper>
) : (
<Text>Belum ada data item</Text>
)}
</Stack>
</Paper>

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import colors from '@/con/colors';
@@ -13,46 +14,76 @@ import {
TextInput,
Title,
Loader,
ActionIcon
ActionIcon,
NumberInput,
Select,
Table,
Badge,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconFile, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { IconArrowBack, IconFile, IconPhoto, IconUpload, IconX, IconPlus, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import apbdes from '../../../_state/landing-page/apbdes';
// Tipe item untuk form
type ItemForm = {
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
};
function CreateAPBDes() {
const router = useRouter();
const stateAPBDes = useProxy(apbdes)
const stateAPBDes = useProxy(apbdes);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewDoc, setPreviewDoc] = useState<string | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null);
const [docFile, setDocFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Form sementara untuk input item baru
const [newItem, setNewItem] = useState<ItemForm>({
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
useEffect(() => {
stateAPBDes.findMany.load();
}, []);
const resetForm = () => {
stateAPBDes.create.form = {
name: "",
jumlah: "",
imageId: "",
fileId: "",
};
stateAPBDes.create.reset();
setImageFile(null);
setDocFile(null);
setPreviewImage(null);
setPreviewDoc(null);
setNewItem({
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
};
const handleSubmit = async () => {
if (!imageFile || !docFile) {
return toast.warn("Pilih gambar dan dokumen terlebih dahulu");
}
if (stateAPBDes.create.form.items.length === 0) {
return toast.warn("Minimal tambahkan 1 item APBDes");
}
try {
setIsSubmitting(true);
@@ -68,6 +99,7 @@ function CreateAPBDes() {
return toast.error("Gagal mengupload file");
}
// Update form dengan ID file
stateAPBDes.create.form.imageId = imageId;
stateAPBDes.create.form.fileId = fileId;
@@ -84,6 +116,43 @@ function CreateAPBDes() {
}
};
// Tambahkan item ke state
const handleAddItem = () => {
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
if (!kode || !uraian) {
return toast.warn("Kode dan uraian wajib diisi");
}
const selisih = realisasi - anggaran;
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
stateAPBDes.create.addItem({
kode,
uraian,
anggaran,
realisasi,
selisih,
persentase,
level,
tipe,
});
// Reset form input
setNewItem({
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
};
// Hapus item
const handleRemoveItem = (index: number) => {
stateAPBDes.create.removeItem(index);
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md">
@@ -104,199 +173,288 @@ function CreateAPBDes() {
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
{/* Gambar APBDes */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Program Inovasi
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setImageFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{/* Gambar & Dokumen (dipendekkan untuk fokus pada items) */}
<Stack gap={"xs"}>
{/* Gambar APBDes */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar APBDes
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setImageFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
{/* ✅ Preview gambar + tombol X */}
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setImageFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Dokumen APBDes */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Dokumen APBDes
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setDocFile(selectedFile);
setPreviewDoc(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format PDF, DOC, atau DOCX')}
maxSize={5 * 1024 ** 2}
accept={{
'application/pdf': ['.pdf'],
'application/msword': ['.doc'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
}}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconFile size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
<Box>
<Text size="xl" inline>
Seret dokumen atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed" inline display="block" mt={7}>
Maksimal 5MB (format: PDF, DOC, DOCX)
</Text>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setImageFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
</Group>
</Dropzone>
)}
</Box>
{previewDoc && (
<Box mt="md" pos="relative" style={{ textAlign: 'center' }}>
<Text fw="bold" fz="sm" mb={6}>
Pratinjau Dokumen
</Text>
<iframe
src={previewDoc}
width="100%"
height="500px"
style={{ border: '1px solid #ddd', borderRadius: '8px' }}
{/* Dokumen APBDes */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Dokumen APBDes
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setDocFile(selectedFile);
setPreviewDoc(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid')}
maxSize={5 * 1024 ** 2}
accept={{
'application/pdf': ['.pdf'],
'application/msword': ['.doc'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
}}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconFile size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret dokumen atau klik untuk memilih
</Text>
</Stack>
</Group>
</Dropzone>
{previewDoc && (
<Box mt="md" pos="relative" style={{ textAlign: 'center' }}>
<Text fw="bold" fz="sm" mb={6}>
Pratinjau Dokumen
</Text>
<iframe
src={previewDoc}
width="100%"
height="500px"
style={{ border: '1px solid #ddd', borderRadius: '8px' }}
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewDoc(null);
setDocFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
</Stack>
{/* Form Header */}
<NumberInput
label="Tahun"
value={stateAPBDes.create.form.tahun || new Date().getFullYear()}
onChange={(val) => (stateAPBDes.create.form.tahun = Number(val) || new Date().getFullYear())}
min={2000}
max={2100}
required
/>
{/* Input Item Baru */}
<Paper withBorder p="md" radius="md">
<Title order={6} mb="sm">Tambah Item Pendapatan/Belanja</Title>
<Stack gap="xs">
<Group grow>
<TextInput
label="Kode"
placeholder="Contoh: 4.1.2"
value={newItem.kode}
onChange={(e) => setNewItem({ ...newItem, kode: e.target.value })}
required
/>
<Select
label="Level"
data={[
{ value: '1', label: 'Level 1 (Kelompok)' },
{ value: '2', label: 'Level 2 (Sub-kelompok)' },
{ value: '3', label: 'Level 3 (Detail)' },
]}
value={String(newItem.level)}
onChange={(val) => setNewItem({ ...newItem, level: Number(val) || 1 })}
/>
<Select
label="Tipe"
data={[
{ value: 'pendapatan', label: 'Pendapatan' },
{ value: 'belanja', label: 'Belanja' },
]}
value={newItem.tipe}
onChange={(val) => setNewItem({ ...newItem, tipe: val as any })}
/>
</Group>
<TextInput
label="Uraian"
placeholder="Contoh: Dana Desa"
value={newItem.uraian}
onChange={(e) => setNewItem({ ...newItem, uraian: e.target.value })}
required
/>
<Group grow>
<NumberInput
label="Anggaran (Rp)"
value={newItem.anggaran}
onChange={(val) => setNewItem({ ...newItem, anggaran: Number(val) || 0 })}
thousandSeparator
min={0}
/>
<NumberInput
label="Realisasi (Rp)"
value={newItem.realisasi}
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
thousandSeparator
min={0}
/>
</Group>
<Button
leftSection={<IconPlus size={16} />}
onClick={handleAddItem}
disabled={!newItem.kode || !newItem.uraian}
>
Tambah Item
</Button>
</Stack>
</Paper>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewDoc(null);
setDocFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Form Input */}
<TextInput
label="Nama APBDes"
placeholder="Masukkan nama APBDes"
value={stateAPBDes.create.form.name || ''}
onChange={(e) => (stateAPBDes.create.form.name = e.target.value)}
required
/>
<TextInput
label="Jumlah Anggaran"
placeholder="14 M / 1 T / 200 JT / 900 RB"
value={stateAPBDes.create.form.jumlah || ''}
onChange={(e) => (stateAPBDes.create.form.jumlah = e.target.value)}
required
/>
{/* Tabel Items */}
{stateAPBDes.create.form.items.length > 0 && (
<Paper withBorder p="md" radius="md">
<Title order={6} mb="sm">Daftar Item ({stateAPBDes.create.form.items.length})</Title>
<Table striped highlightOnHover>
<thead>
<tr>
<th>Kode</th>
<th>Uraian</th>
<th>Anggaran</th>
<th>Realisasi</th>
<th>Level</th>
<th>Tipe</th>
<th style={{ width: 50 }}>Aksi</th>
</tr>
</thead>
<tbody>
{stateAPBDes.create.form.items.map((item, idx) => (
<tr key={idx}>
<td><Text size="sm" fw={500}>{item.kode}</Text></td>
<td>{item.uraian}</td>
<td>{item.anggaran.toLocaleString('id-ID')}</td>
<td>{item.realisasi.toLocaleString('id-ID')}</td>
<td>
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
L{item.level}
</Badge>
</td>
<td>
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
{item.tipe}
</Badge>
</td>
<td>
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>
<IconTrash size={16} />
</ActionIcon>
</td>
</tr>
))}
</tbody>
</Table>
</Paper>
)}
{/* Tombol Aksi */}
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
<Button variant="outline" color="gray" radius="md" onClick={resetForm}>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={stateAPBDes.create.form.items.length === 0}
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'}
@@ -308,4 +466,4 @@ function CreateAPBDes() {
);
}
export default CreateAPBDes;
export default CreateAPBDes;

View File

@@ -1,6 +1,23 @@
'use client'
'use client';
import colors from '@/con/colors';
import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconFile, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
@@ -9,14 +26,13 @@ import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
import apbdes from '../../_state/landing-page/apbdes';
function APBDes() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title='APBDes'
placeholder='Cari APBDes...'
title="APBDes"
placeholder="Cari APBDes..."
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
@@ -27,22 +43,16 @@ function APBDes() {
}
function ListAPBDes({ search }: { search: string }) {
const listState = useProxy(apbdes)
const listState = useProxy(apbdes);
const router = useRouter();
const {
data,
page,
totalPages,
loading,
load,
} = listState.findMany
const { data, page, totalPages, loading, load } = listState.findMany;
useShallowEffect(() => {
load(page, 10, search)
}, [page, search])
load(page, 10, search);
}, [page, search]);
const filteredData = data || []
const filteredData = data || [];
if (loading || !data) {
return (
@@ -71,8 +81,8 @@ function ListAPBDes({ search }: { search: string }) {
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh style={{ width: '30%'}}>Nama APBDes</TableTh>
<TableTh style={{ width: '30%' }}>Jumlah</TableTh>
<TableTh style={{ width: '25%' }}>APBDes</TableTh>
<TableTh style={{ width: '25%' }}>Tahun</TableTh>
<TableTh style={{ width: '25%' }}>Dokumen</TableTh>
<TableTh style={{ width: '25%' }}>Aksi</TableTh>
</TableTr>
@@ -81,53 +91,54 @@ function ListAPBDes({ search }: { search: string }) {
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd style={{ width: '30%' }}>
<Text fw={500} truncate="end">{item.name}</Text>
</TableTd>
<TableTd style={{ width: '30%' }}>
<Box w={150}>
<Text>Rp. {item.jumlah}</Text>
</Box>
<TableTd style={{ width: '25%' }}>
<Text fw={500} lineClamp={1}>
APBDes {item.tahun}
</Text>
</TableTd>
<TableTd style={{ width: '25%' }}>
<Box w={150}>
{item.file?.link ? (
<Button
component="a"
href={item.file.link}
target="_blank"
rel="noopener noreferrer"
variant="light"
leftSection={<IconFile size={18} />}
size="sm"
>
Lihat Dokumen
</Button>
) : (
<Text c="dimmed" fz="sm">Tidak ada dokumen</Text>
)}
</Box>
<Text fw={500}>{item.tahun || '-'}</Text>
</TableTd>
<TableTd style={{ width: '25%' }}>
<Box w={80}>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/admin/landing-page/APBDes/${item.id}`)}
fullWidth
>
Detail
</Button>
</Box>
{item.file?.link ? (
<Button
component="a"
href={item.file.link}
target="_blank"
rel="noopener noreferrer"
variant="light"
leftSection={<IconFile size={16} />}
size="xs"
radius="sm"
>
Lihat Dokumen
</Button>
) : (
<Text c="dimmed" fz="sm">
Tidak ada dokumen
</Text>
)}
</TableTd>
<TableTd style={{ width: '25%' }}>
<Box w={100}>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={14} />}
onClick={() => router.push(`/admin/landing-page/APBDes/${item.id}`)}
fullWidth
>
Detail
</Button>
</Box>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<TableTd colSpan={5}>
<Center py={20}>
<Text color="dimmed">Tidak ada data APBDes yang cocok</Text>
</Center>
@@ -152,7 +163,7 @@ function ListAPBDes({ search }: { search: string }) {
/>
</Center>
</Box>
)
);
}
export default APBDes;
export default APBDes;