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

@@ -183,17 +183,45 @@ model SdgsDesa {
//========================================= APBDes ========================================= // //========================================= APBDes ========================================= //
model APBDes { model APBDes {
id String @id @default(cuid()) id String @id @default(cuid())
name String tahun Int?
jumlah String name String? // misalnya: "APBDes Tahun 2025"
image FileStorage? @relation("APBDesImage", fields: [imageId], references: [id]) deskripsi String?
imageId String? jumlah String? // total keseluruhan (opsional, bisa juga dihitung dari items)
file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id]) items APBDesItem[]
fileId String? image FileStorage? @relation("APBDesImage", fields: [imageId], references: [id])
createdAt DateTime @default(now()) imageId String?
updatedAt DateTime @updatedAt file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id])
deletedAt DateTime @default(now()) fileId String?
isActive Boolean @default(true) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
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 ========================================= // //========================================= PRESTASI DESA ========================================= //
@@ -1942,23 +1970,28 @@ model KeunggulanProgram {
} }
model BeasiswaPendaftar { model BeasiswaPendaftar {
id String @id @default(cuid()) id String @id @default(cuid())
namaLengkap String namaLengkap String
nik String @unique nis String?
kelas String?
jenisKelamin JenisKelamin
alamatDomisili String?
tempatLahir String tempatLahir String
tanggalLahir DateTime tanggalLahir DateTime
jenisKelamin JenisKelamin namaOrtu String?
kewarganegaraan String nik String @unique
agama Agama pekerjaanOrtu String?
alamatKTP String penghasilan String?
alamatDomisili String?
noHp String noHp String
email String @unique kewarganegaraan String?
statusPernikahan StatusPernikahan agama Agama?
alamatKTP String?
email String? @unique
statusPernikahan StatusPernikahan?
ukuranBaju UkuranBaju? ukuranBaju UkuranBaju?
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
enum JenisKelamin { enum JenisKelamin {

View File

@@ -5,58 +5,166 @@ import { toast } from "react-toastify";
import { proxy } from "valtio"; import { proxy } from "valtio";
import { z } from "zod"; import { z } from "zod";
const templateapbDesaForm = z.object({ // --- Zod Schema ---
name: z.string().min(1, "Judul minimal 1 karakter"), const ApbdesItemSchema = z.object({
jumlah: z.string().min(1, "Deskripsi minimal 1 karakter"), kode: z.string().min(1),
imageId: z.string().min(1, "File minimal 1"), uraian: z.string().min(1),
fileId: z.string().min(1, "File minimal 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 = { const ApbdesFormSchema = z.object({
name: "", tahun: z.number().int().min(2000, "Tahun tidak valid"),
jumlah: "", 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: "", imageId: "",
fileId: "", fileId: "",
items: [] as z.infer<typeof ApbdesItemSchema>[],
}; };
const apbdes = proxy({ // --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
create: { // --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
form: { ...defaultapbdesForm }, function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
loading: false, const anggaran = item.anggaran ?? 0;
async create() { const realisasi = item.realisasi ?? 0;
const cek = templateapbDesaForm.safeParse(apbdes.create.form);
if (!cek.success) { // ✅ Formula yang benar
const err = `[${cek.error.issues const selisih = anggaran - realisasi; // positif = sisa anggaran, negatif = over budget
.map((v) => `${v.path.join(".")}`) const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran
.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) { 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(); 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: any) {
} catch (error) { console.error("Create APBDes error:", error);
console.log(error); toast.error(error?.message || "Terjadi kesalahan saat membuat APBDes");
toast.error("Gagal menambahkan data");
} finally { } finally {
apbdes.create.loading = false; this.loading = false;
} }
}, },
}, },
findMany: { findMany: {
data: null as data: null as
| Prisma.APBDesGetPayload<{ | Prisma.APBDesGetPayload<{
include: { include: { image: true; file: true; items: true };
image: true;
file: true;
};
}>[] }>[]
| null, | null,
page: 1, page: 1,
@@ -64,194 +172,202 @@ const apbdes = proxy({
total: 0, total: 0,
loading: false, loading: false,
search: "", 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.page = page;
apbdes.findMany.search = search; apbdes.findMany.search = search;
try { try {
const query: any = { page, limit }; const query: Record<string, string> = { page: String(page), limit: String(limit) };
if (search) query.search = search; if (search) query.search = search;
const res = await ApiFetch.api.landingpage.apbdes[ const res = await ApiFetch.api.landingpage.apbdes["findMany"].get({ query });
"findMany"
].get({ if (res.data?.success) {
query
});
if (res.status === 200 && res.data?.success) {
apbdes.findMany.data = res.data.data || []; apbdes.findMany.data = res.data.data || [];
apbdes.findMany.total = res.data.total || 0; apbdes.findMany.total = res.data.meta?.total || 0;
apbdes.findMany.totalPages = res.data.totalPages || 1; apbdes.findMany.totalPages = res.data.meta?.totalPages || 1;
} else { } else {
console.error("Failed to load pegawai:", res.data?.message);
apbdes.findMany.data = []; apbdes.findMany.data = [];
apbdes.findMany.total = 0; apbdes.findMany.total = 0;
apbdes.findMany.totalPages = 1; apbdes.findMany.totalPages = 1;
toast.error(res.data?.message || "Gagal memuat data");
} }
} catch (error) { } catch (error) {
console.error("Error loading pegawai:", error); console.error("FindMany error:", error);
apbdes.findMany.data = []; apbdes.findMany.data = [];
apbdes.findMany.total = 0; apbdes.findMany.total = 0;
apbdes.findMany.totalPages = 1; apbdes.findMany.totalPages = 1;
toast.error("Gagal memuat daftar APBDes");
} finally { } finally {
apbdes.findMany.loading = false; apbdes.findMany.loading = false;
} }
}, },
}, },
findUnique: { findUnique: {
data: null as Prisma.APBDesGetPayload<{ data: null as
include: { | Prisma.APBDesGetPayload<{
image: true; include: { image: true; file: true; items: true };
file: true; }>
}; | null,
}> | null, loading: false,
error: null as string | null,
async load(id: string) { async load(id: string) {
if (!id || id.trim() === '') {
this.data = null;
this.error = "ID tidak valid";
return;
}
this.loading = true;
this.error = null;
try { try {
const res = await fetch(`/api/landingpage/apbdes/${id}`); // Pastikan URL-nya benar
if (res.ok) { const url = `/api/landingpage/apbdes/${id}`;
const data = await res.json(); console.log("🌐 Fetching:", url);
apbdes.findUnique.data = data.data ?? null;
// 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 { } else {
console.error("Failed to fetch data", res.status, res.statusText); this.data = null;
apbdes.findUnique.data = null; this.error = res.message || "Gagal memuat detail APBDes";
toast.error(this.error);
} }
} catch (error) { } catch (error) {
console.error("Error fetching data:", error); console.error("❌ FindUnique error:", error);
apbdes.findUnique.data = null; this.data = null;
this.error = "Gagal memuat detail APBDes";
toast.error(this.error);
} finally {
this.loading = false;
} }
}, }
}, },
delete: { delete: {
loading: false, loading: false,
async byId(id: string) { async byId(id: string) {
if (!id) return toast.warn("ID tidak valid"); if (!id) return toast.warn("ID tidak valid");
try { 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}`, { if (res.data?.success) {
method: "DELETE", toast.success("APBDes berhasil dihapus");
headers: { apbdes.findMany.load();
"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
} else { } else {
toast.error(result?.message || "Gagal menghapus apbdes"); toast.error(res.data?.message || "Gagal menghapus APBDes");
} }
} catch (error) { } catch (error: any) {
console.error("Gagal delete:", error); console.error("Delete error:", error);
toast.error("Terjadi kesalahan saat menghapus apbdes"); toast.error(error?.message || "Terjadi kesalahan saat menghapus");
} finally { } finally {
apbdes.delete.loading = false; this.loading = false;
} }
}, },
}, },
edit: { edit: {
id: "", id: "",
form: { ...defaultapbdesForm }, form: { ...defaultApbdesForm },
loading: false, loading: false,
async load(id: string) { async load(id: string) {
if (!id) { if (!id) return toast.warn("ID tidak valid");
toast.warn("ID tidak valid");
return null;
}
try { 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}`, { if (res.data?.success) {
method: "GET", const data = res.data.data;
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;
this.id = data.id; this.id = data.id;
this.form = { this.form = {
name: data.name, tahun: data.tahun || new Date().getFullYear(),
jumlah: data.jumlah, imageId: data.imageId || "",
imageId: data.imageId, fileId: data.fileId || "",
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; return data;
} else { } else {
throw new Error(result?.message || "Gagal memuat data"); throw new Error(res.data?.message || "Gagal memuat data");
} }
} catch (error) { } catch (error: any) {
console.error("Error loading apbdes:", error); console.error("Edit load error:", error);
toast.error( toast.error(error.message || "Gagal memuat data untuk diedit");
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
} finally { } finally {
apbdes.edit.loading = false; this.loading = false;
} }
}, },
async update() { async update() {
const cek = templateapbDesaForm.safeParse(apbdes.edit.form); const parsed = ApbdesFormSchema.safeParse(this.form);
if (!cek.success) { if (!parsed.success) {
const err = `[${cek.error.issues const errors = parsed.error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`);
.map((v) => `${v.path.join(".")}`) toast.error(`Validasi gagal:\n${errors.join("\n")}`);
.join("\n")}] required`; return false;
return toast.error(err);
} }
try { try {
apbdes.edit.loading = true; this.loading = true;
const response = await fetch(`/api/landingpage/apbdes/${this.id}`, { // Include the ID in the request body
method: "PUT", const requestData = {
headers: { ...parsed.data,
"Content-Type": "application/json", id: this.id, // Add the ID to the request body
}, };
body: JSON.stringify({
name: this.form.name, const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
jumlah: this.form.jumlah,
imageId: this.form.imageId, if (res.data?.success) {
fileId: this.form.fileId, toast.success("APBDes berhasil diperbarui");
}), apbdes.findMany.load();
});
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
return true; return true;
} else { } else {
throw new Error(result.message || "Gagal mengupdate apbdes"); throw new Error(res.data?.message || "Gagal memperbarui APBDes");
} }
} catch (error) { } catch (error: any) {
console.error("Error updating apbdes:", error); console.error("Update error:", error);
toast.error( toast.error(error.message || "Gagal memperbarui APBDes");
error instanceof Error ? error.message : "Gagal mengupdate apbdes"
);
return false; return false;
} finally { } 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() { reset() {
apbdes.edit.id = ""; this.id = "";
apbdes.edit.form = { ...defaultapbdesForm }; this.form = { ...defaultApbdesForm };
}, },
}, },
}); });
export default apbdes; export default apbdes;

View File

@@ -9,34 +9,32 @@ import { z } from "zod";
const templateBeasiswaPendaftar = z.object({ const templateBeasiswaPendaftar = z.object({
namaLengkap: z.string().min(1, "Nama harus diisi"), 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"), tempatLahir: z.string().min(1, "Tempat lahir harus diisi"),
tanggalLahir: z.string().min(1, "Tanggal lahir harus diisi"), tanggalLahir: z.string().min(1, "Tanggal lahir harus diisi"),
jenisKelamin: z.string().min(1, "Jenis kelamin harus diisi"), namaOrtu: z.string().min(1, "Nama ortu harus diisi"),
kewarganegaraan: z.string().min(1, "Kewarganegaraan harus diisi"), nik: z.string().min(1, "NIK harus diisi"),
agama: z.string().min(1, "Agama harus diisi"), pekerjaanOrtu: z.string().min(1, "Pekerjaan ortu harus diisi"),
alamatKTP: z.string().min(1, "Alamat KTP harus diisi"), penghasilan: z.string().min(1, "Penghasilan ortu harus diisi"),
alamatDomisili: z.string().min(1, "Alamat domisili harus diisi"),
noHp: z.string().min(1, "No HP 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 = { const defaultBeasiswaPendaftar = {
namaLengkap: "", namaLengkap: "",
nik: "", nis: "",
kelas: "",
jenisKelamin: "",
alamatDomisili: "",
tempatLahir: "", tempatLahir: "",
tanggalLahir: "", tanggalLahir: "",
jenisKelamin: "", namaOrtu: "",
kewarganegaraan: "", nik: "",
agama: "", pekerjaanOrtu: "",
alamatKTP: "", penghasilan: "",
alamatDomisili: "",
noHp: "", noHp: "",
email: "",
statusPernikahan: "",
ukuranBaju: "",
}; };
const beasiswaPendaftar = proxy({ const beasiswaPendaftar = proxy({
@@ -200,18 +198,17 @@ const beasiswaPendaftar = proxy({
this.id = data.id; this.id = data.id;
this.form = { this.form = {
namaLengkap: data.namaLengkap, namaLengkap: data.namaLengkap,
nik: data.nik, nis: data.nis,
kelas: data.kelas,
jenisKelamin: data.jenisKelamin,
alamatDomisili: data.alamatDomisili,
tempatLahir: data.tempatLahir, tempatLahir: data.tempatLahir,
tanggalLahir: data.tanggalLahir, tanggalLahir: data.tanggalLahir,
jenisKelamin: data.jenisKelamin, namaOrtu: data.namaOrtu,
kewarganegaraan: data.kewarganegaraan, nik: data.nik,
agama: data.agama, pekerjaanOrtu: data.pekerjaanOrtu,
alamatKTP: data.alamatKTP, penghasilan: data.penghasilan,
alamatDomisili: data.alamatDomisili,
noHp: data.noHp, noHp: data.noHp,
email: data.email,
statusPernikahan: data.statusPernikahan,
ukuranBaju: data.ukuranBaju,
}; };
return data; // Return the loaded data return data; // Return the loaded data
} else { } else {
@@ -249,17 +246,17 @@ const beasiswaPendaftar = proxy({
}, },
body: JSON.stringify({ body: JSON.stringify({
namaLengkap: this.form.namaLengkap, namaLengkap: this.form.namaLengkap,
nik: this.form.nik, nis: this.form.nis,
tanggalLahir: this.form.tanggalLahir, kelas: this.form.kelas,
jenisKelamin: this.form.jenisKelamin, jenisKelamin: this.form.jenisKelamin,
kewarganegaraan: this.form.kewarganegaraan,
agama: this.form.agama,
alamatKTP: this.form.alamatKTP,
alamatDomisili: this.form.alamatDomisili, 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, 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 react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'; 'use client';
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'; import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { import {
ActionIcon,
Badge,
Box, Box,
Button, Button,
Group, Group,
Image, Image,
Loader,
NumberInput,
Paper, Paper,
Select,
Stack, Stack,
Table,
Text, Text,
TextInput, TextInput,
Title, Title,
Loader,
ActionIcon
} from '@mantine/core'; } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; 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 { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; 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() { function EditAPBDes() {
const apbdesState = useProxy(apbdes); const apbdesState = useProxy(apbdes);
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const [formData, setFormData] = useState({
name: '',
jumlah: '',
imageId: '',
fileId: ''
});
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
jumlah: "",
imageId: "",
fileId: "",
imageUrl: "",
docUrl: "",
});
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewDoc, setPreviewDoc] = useState<string | null>(null); const [previewDoc, setPreviewDoc] = useState<string | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null); const [imageFile, setImageFile] = useState<File | null>(null);
const [docFile, setDocFile] = 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(() => { useEffect(() => {
const loadData = async () => { const id = params?.id as string;
const id = params?.id as string; if (id) {
if (!id) return; apbdesState.edit.load(id).then((response) => {
const data = response as unknown as APBDesResponse;
try {
const data = await apbdesState.edit.load(id);
if (data) { if (data) {
const newForm = { // ✅ Ambil link langsung dari response
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 || "",
});
setPreviewImage(data.image?.link || null); setPreviewImage(data.image?.link || null);
setPreviewDoc(data.file?.link || null); setPreviewDoc(data.file?.link || null);
} }
} catch (err) { });
console.error(err); }
toast.error('Gagal memuat data APBDes');
}
};
loadData();
}, [params?.id]); }, [params?.id]);
// Generic Dropzone handler
const handleDrop = (fileType: 'image' | 'doc') => (files: File[]) => { const handleDrop = (fileType: 'image' | 'doc') => (files: File[]) => {
const file = files[0]; const file = files[0];
if (!file) return; 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 () => { const handleSubmit = async () => {
if (apbdesState.edit.form.items.length === 0) {
return toast.warn('Minimal harus ada 1 item APBDes');
}
try { try {
setIsSubmitting(true); setIsSubmitting(true);
// Update global state with local form data first
apbdesState.edit.form = { ...apbdesState.edit.form, ...formData };
// Helper function for uploading file // Upload file baru jika ada
const uploadFile = async (file: File | null) => { if (imageFile) {
if (!file) return null; const res = await ApiFetch.api.fileStorage.create.post({
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name }); file: imageFile,
const uploaded = res.data?.data; name: imageFile.name,
if (!uploaded?.id) throw new Error('Upload gagal'); });
return uploaded.id; const imageId = res.data?.data?.id;
}; if (imageId) apbdesState.edit.form.imageId = imageId;
}
// Upload files if selected if (docFile) {
const uploadedImageId = await uploadFile(imageFile); const res = await ApiFetch.api.fileStorage.create.post({
const uploadedDocId = await uploadFile(docFile); 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; const success = await apbdesState.edit.update();
if (uploadedDocId) apbdesState.edit.form.fileId = uploadedDocId; if (success) {
router.push('/admin/landing-page/APBDes');
await apbdesState.edit.update(); }
toast.success('APBDes berhasil diperbarui!');
router.push('/admin/landing-page/APBDes');
} catch (err) { } catch (err) {
console.error(err); console.error('Update error:', err);
toast.error('Terjadi kesalahan saat memperbarui APBDes'); toast.error('Gagal memperbarui APBDes');
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
const handleResetForm = () => { const handleReset = () => {
setFormData({ const id = params?.id as string;
name: originalData.name, if (id) {
jumlah: originalData.jumlah, apbdesState.edit.load(id);
imageId: originalData.imageId, setImageFile(null);
fileId: originalData.fileId, setDocFile(null);
}); setNewItem({
setPreviewImage(originalData.imageUrl || null); kode: '',
setImageFile(null); uraian: '',
setPreviewDoc(originalData.docUrl || null); anggaran: 0,
setDocFile(null); realisasi: 0,
toast.info("Form dikembalikan ke data awal"); level: 1,
tipe: 'pendapatan',
});
toast.info('Form dikembalikan ke data awal');
}
}; };
return ( return (
@@ -160,163 +212,272 @@ function EditAPBDes() {
</Title> </Title>
</Group> </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"> <Stack gap="md">
{/* Controlled Inputs */} {/* Header Form */}
<TextInput <NumberInput
label="Nama APBDes" label="Tahun"
placeholder="Masukkan nama APBDes" value={apbdesState.edit.form.tahun || new Date().getFullYear()}
value={formData.name} onChange={(val) =>
onChange={(e) => setFormData({ ...formData, name: e.target.value })} (apbdesState.edit.form.tahun = Number(val) || new Date().getFullYear())
}
min={2000}
max={2100}
required required
/> />
<TextInput {/* Gambar & Dokumen */}
label="Jumlah Anggaran" <Stack gap="xs">
placeholder="Masukkan jumlah anggaran" <Box>
value={formData.jumlah} <Text fw="bold" fz="sm" mb={6}>
onChange={(e) => setFormData({ ...formData, jumlah: e.target.value })} Gambar APBDes
required </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>
<Box> <Text fw="bold" fz="sm" mb={6}>
<Text fw="bold" fz="sm" mb={6}>Gambar APBDes</Text> Dokumen APBDes
<Dropzone </Text>
onDrop={handleDrop('image')} <Dropzone
onReject={() => toast.error('File tidak valid, gunakan format gambar')} onDrop={handleDrop('doc')}
maxSize={5 * 1024 ** 2} onReject={() => toast.error('File dokumen tidak valid')}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }} maxSize={10 * 1024 ** 2}
radius="md" accept={{
p="xl" 'application/pdf': ['.pdf'],
> 'application/msword': ['.doc'],
<Group justify="center" gap="xl" mih={180}> 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
<Dropzone.Accept><IconUpload size={48} color={colors['blue-button']} stroke={1.5} /></Dropzone.Accept> 'application/vnd.ms-excel': ['.xls'],
<Dropzone.Reject><IconX size={48} color="red" stroke={1.5} /></Dropzone.Reject> 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
<Dropzone.Idle><IconPhoto size={48} color="#868e96" stroke={1.5} /></Dropzone.Idle> }}
<Stack gap="xs" align="center"> radius="md"
<Text size="md" fw={500}>Seret gambar atau klik untuk memilih file</Text> p="xl"
<Text size="sm" c="dimmed">Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp</Text> >
</Stack> <Group justify="center" gap="xl" mih={180}>
</Group> <Dropzone.Idle>
</Dropzone> <IconFile size={48} color="#868e96" stroke={1.5} />
{previewImage && ( </Dropzone.Idle>
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}> <Stack gap="xs" align="center">
<Image <Text size="md" fw={500}>
src={previewImage} {previewDoc ? 'Ganti dokumen' : 'Unggah dokumen'}
alt="Preview Gambar" </Text>
radius="md" </Stack>
style={{ </Group>
maxHeight: 200, </Dropzone>
objectFit: 'contain', {previewDoc && (
border: '1px solid #ddd', <Box mt="sm">
}} <Button
loading="lazy" 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> </Group>
</Dropzone> <TextInput
{previewDoc && ( label="Uraian"
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}> placeholder="Contoh: Dana Desa"
<Text size="sm" c="dimmed" mb="xs">Dokumen terpilih: {docFile?.name || 'Dokumen'}</Text> value={newItem.uraian}
<Button component="a" href={previewDoc} target="_blank" rel="noopener noreferrer" variant="light" leftSection={<IconFile size={16} />} size="sm"> onChange={(e) => setNewItem({ ...newItem, uraian: e.target.value })}
Lihat Dokumen required
</Button> />
{/* Tombol hapus (pojok kanan atas) */} <Group grow>
<ActionIcon <NumberInput
variant="filled" label="Anggaran (Rp)"
color="red" value={newItem.anggaran}
radius="xl" onChange={(val) => setNewItem({ ...newItem, anggaran: Number(val) || 0 })}
size="sm" thousandSeparator
pos="absolute" min={0}
top={5} />
right={5} <NumberInput
onClick={() => { label="Realisasi (Rp)"
setPreviewDoc(null); value={newItem.realisasi}
setDocFile(null); onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
}} thousandSeparator
style={{ min={0}
boxShadow: '0 2px 6px rgba(0,0,0,0.15)', />
}} </Group>
> <Button
<IconX size={14} /> leftSection={<IconPlus size={16} />}
</ActionIcon> onClick={handleAddItem}
</Box> disabled={!newItem.kode || !newItem.uraian}
)} >
</Box> 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"> <Group justify="right">
{/* Tombol Batal */} <Button variant="outline" color="gray" radius="md" onClick={handleReset}>
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal Batal
</Button> </Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
size="md" disabled={apbdesState.edit.form.items.length === 0 || apbdesState.edit.loading}
style={{ style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`, background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff', color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} {isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan Perubahan'}
</Button> </Button>
</Group> </Group>
</Stack> </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 { useProxy } from 'valtio/utils';
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core'; import {
import { useShallowEffect } from '@mantine/hooks'; 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 { IconArrowBack, IconEdit, IconFile, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import apbdes from '../../../_state/landing-page/apbdes'; import apbdes from '../../../_state/landing-page/apbdes';
function DetailAPBDes() { function DetailAPBDes() {
const apbdesState = useProxy(apbdes) const apbdesState = useProxy(apbdes);
const [modalHapus, setModalHapus] = useState(false) const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null);
const params = useParams() const params = useParams();
const router = useRouter() const router = useRouter();
useShallowEffect(() => {
apbdesState.findUnique.load(params?.id as string)
}, [])
useEffect(() => {
if (!params?.id) return;
apbdesState.findUnique.load(params.id as string);
}, [params?.id]);
const handleHapus = () => { const handleHapus = () => {
if (selectedId) { if (selectedId) {
apbdesState.delete.byId(selectedId) apbdesState.delete.byId(selectedId);
setModalHapus(false) setModalHapus(false);
setSelectedId(null) setSelectedId(null);
router.push("/admin/landing-page/APBDes") router.push('/admin/landing-page/APBDes');
} }
} };
if (!apbdesState.findUnique.data) { if (!apbdesState.findUnique.data) {
return ( return (
@@ -42,6 +59,11 @@ function DetailAPBDes() {
const data = apbdesState.findUnique.data; const data = apbdesState.findUnique.data;
// Helper: indentasi berdasarkan level
const getIndent = (level: number) => ({
paddingLeft: `${(level - 1) * 20}px`,
});
return ( return (
<Box py={10}> <Box py={10}>
<Button <Button
@@ -55,7 +77,7 @@ function DetailAPBDes() {
<Paper <Paper
withBorder withBorder
w={{ base: "100%", md: "60%" }} w={{ base: '100%', md: '100%' }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"
@@ -66,16 +88,21 @@ function DetailAPBDes() {
Detail APBDes Detail APBDes
</Text> </Text>
{/* Info Header */}
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs"> <Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="md"> <Stack gap="md">
<Box> <Box>
<Text fz="lg" fw="bold">Nama APBDes</Text> <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>
<Box> <Box>
<Text fz="lg" fw="bold">Jumlah Anggaran</Text> <Text fz="lg" fw="bold">Tahun</Text>
<Text fz="md" c="dimmed">Rp. {data.jumlah || '-'}</Text> <Text fz="md" c="dimmed">
{data.tahun || '-'}
</Text>
</Box> </Box>
<Box> <Box>
@@ -117,32 +144,80 @@ function DetailAPBDes() {
</Box> </Box>
<Group gap="sm" mt="md"> <Group gap="sm" mt="md">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
setSelectedId(data.id); setSelectedId(data.id);
setModalHapus(true); setModalHapus(true);
}} }}
disabled={apbdesState.delete.loading} disabled={apbdesState.delete.loading}
variant="light" variant="light"
radius="md" radius="md"
size="md" size="md"
> >
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
<Button <Button
color="green" color="green"
onClick={() => router.push(`/admin/landing-page/APBDes/${data.id}/edit`)} onClick={() => router.push(`/admin/landing-page/APBDes/${data.id}/edit`)}
variant="light" variant="light"
radius="md" radius="md"
size="md" size="md"
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </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> </Stack>
</Paper> </Paper>

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client'; 'use client';
import colors from '@/con/colors'; import colors from '@/con/colors';
@@ -13,46 +14,76 @@ import {
TextInput, TextInput,
Title, Title,
Loader, Loader,
ActionIcon ActionIcon,
NumberInput,
Select,
Table,
Badge,
} from '@mantine/core'; } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; 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 { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import apbdes from '../../../_state/landing-page/apbdes'; 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() { function CreateAPBDes() {
const router = useRouter(); const router = useRouter();
const stateAPBDes = useProxy(apbdes) const stateAPBDes = useProxy(apbdes);
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewDoc, setPreviewDoc] = useState<string | null>(null); const [previewDoc, setPreviewDoc] = useState<string | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null); const [imageFile, setImageFile] = useState<File | null>(null);
const [docFile, setDocFile] = useState<File | null>(null); const [docFile, setDocFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); 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(() => { useEffect(() => {
stateAPBDes.findMany.load(); stateAPBDes.findMany.load();
}, []); }, []);
const resetForm = () => { const resetForm = () => {
stateAPBDes.create.form = { stateAPBDes.create.reset();
name: "",
jumlah: "",
imageId: "",
fileId: "",
};
setImageFile(null); setImageFile(null);
setDocFile(null); setDocFile(null);
setPreviewImage(null); setPreviewImage(null);
setPreviewDoc(null);
setNewItem({
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!imageFile || !docFile) { if (!imageFile || !docFile) {
return toast.warn("Pilih gambar dan dokumen terlebih dahulu"); 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 { try {
setIsSubmitting(true); setIsSubmitting(true);
@@ -68,6 +99,7 @@ function CreateAPBDes() {
return toast.error("Gagal mengupload file"); return toast.error("Gagal mengupload file");
} }
// Update form dengan ID file
stateAPBDes.create.form.imageId = imageId; stateAPBDes.create.form.imageId = imageId;
stateAPBDes.create.form.fileId = fileId; 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 ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md"> <Group mb="md">
@@ -104,199 +173,288 @@ function CreateAPBDes() {
style={{ border: '1px solid #e0e0e0' }} style={{ border: '1px solid #e0e0e0' }}
> >
<Stack gap="md"> <Stack gap="md">
{/* Gambar APBDes */} {/* Gambar & Dokumen (dipendekkan untuk fokus pada items) */}
<Box> <Stack gap={"xs"}>
<Text fw="bold" fz="sm" mb={6}> {/* Gambar APBDes */}
Gambar Program Inovasi <Box>
</Text> <Text fw="bold" fz="sm" mb={6}>
<Dropzone Gambar APBDes
onDrop={(files) => { </Text>
const selectedFile = files[0]; <Dropzone
if (selectedFile) { onDrop={(files) => {
setImageFile(selectedFile); const selectedFile = files[0];
setPreviewImage(URL.createObjectURL(selectedFile)); 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'] }} onReject={() => toast.error('File tidak valid, gunakan format gambar')}
radius="md" maxSize={5 * 1024 ** 2}
p="xl" accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
> radius="md"
<Group justify="center" gap="xl" mih={180}> p="xl"
<Dropzone.Accept> >
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} /> <Group justify="center" gap="xl" mih={180}>
</Dropzone.Accept> <Dropzone.Accept>
<Dropzone.Reject> <IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
<IconX size={48} color="red" stroke={1.5} /> </Dropzone.Accept>
</Dropzone.Reject> <Dropzone.Reject>
<Dropzone.Idle> <IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
<IconPhoto size={48} color="#868e96" stroke={1.5} /> </Dropzone.Reject>
</Dropzone.Idle> <Dropzone.Idle>
<Stack gap="xs" align="center"> <IconPhoto size={48} color="#868e96" stroke={1.5} />
<Text size="md" fw={500}> </Dropzone.Idle>
Seret gambar atau klik untuk memilih file <Stack gap="xs" align="center">
</Text> <Text size="md" fw={500}>
<Text size="sm" c="dimmed"> Seret gambar atau klik untuk memilih
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp </Text>
</Text> </Stack>
</Stack> </Group>
</Group> </Dropzone>
</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 */} {/* Tombol hapus (pojok kanan atas) */}
{previewImage && ( <ActionIcon
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}> variant="filled"
<Image color="red"
src={previewImage} radius="xl"
alt="Preview Gambar" size="sm"
radius="md" pos="absolute"
style={{ top={5}
maxHeight: 200, right={5}
objectFit: 'contain', onClick={() => {
border: '1px solid #ddd', setPreviewImage(null);
}} setImageFile(null);
loading="lazy" }}
/> style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
{/* Tombol hapus (pojok kanan atas) */} }}
<ActionIcon >
variant="filled" <IconX size={14} />
color="red" </ActionIcon>
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>
</Box> </Box>
</Group> )}
</Dropzone> </Box>
{previewDoc && ( {/* Dokumen APBDes */}
<Box mt="md" pos="relative" style={{ textAlign: 'center' }}> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
Pratinjau Dokumen Dokumen APBDes
</Text> </Text>
<iframe <Dropzone
src={previewDoc} onDrop={(files) => {
width="100%" const selectedFile = files[0];
height="500px" if (selectedFile) {
style={{ border: '1px solid #ddd', borderRadius: '8px' }} 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 {/* Tabel Items */}
variant="filled" {stateAPBDes.create.form.items.length > 0 && (
color="red" <Paper withBorder p="md" radius="md">
radius="xl" <Title order={6} mb="sm">Daftar Item ({stateAPBDes.create.form.items.length})</Title>
size="sm" <Table striped highlightOnHover>
pos="absolute" <thead>
top={5} <tr>
right={5} <th>Kode</th>
onClick={() => { <th>Uraian</th>
setPreviewDoc(null); <th>Anggaran</th>
setDocFile(null); <th>Realisasi</th>
}} <th>Level</th>
style={{ <th>Tipe</th>
boxShadow: '0 2px 6px rgba(0,0,0,0.15)', <th style={{ width: 50 }}>Aksi</th>
}} </tr>
> </thead>
<IconX size={14} /> <tbody>
</ActionIcon> {stateAPBDes.create.form.items.map((item, idx) => (
</Box> <tr key={idx}>
)} <td><Text size="sm" fw={500}>{item.kode}</Text></td>
</Box> <td>{item.uraian}</td>
<td>{item.anggaran.toLocaleString('id-ID')}</td>
{/* Form Input */} <td>{item.realisasi.toLocaleString('id-ID')}</td>
<TextInput <td>
label="Nama APBDes" <Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
placeholder="Masukkan nama APBDes" L{item.level}
value={stateAPBDes.create.form.name || ''} </Badge>
onChange={(e) => (stateAPBDes.create.form.name = e.target.value)} </td>
required <td>
/> <Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
<TextInput {item.tipe}
label="Jumlah Anggaran" </Badge>
placeholder="14 M / 1 T / 200 JT / 900 RB" </td>
value={stateAPBDes.create.form.jumlah || ''} <td>
onChange={(e) => (stateAPBDes.create.form.jumlah = e.target.value)} <ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>
required <IconTrash size={16} />
/> </ActionIcon>
</td>
</tr>
))}
</tbody>
</Table>
</Paper>
)}
{/* Tombol Aksi */}
<Group justify="right"> <Group justify="right">
{/* Tombol Batal */} <Button variant="outline" color="gray" radius="md" onClick={resetForm}>
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset Reset
</Button> </Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
size="md" disabled={stateAPBDes.create.form.items.length === 0}
style={{ style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`, background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff', color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} {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 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 { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconFile, IconPlus, IconSearch } from '@tabler/icons-react'; import { IconDeviceImacCog, IconFile, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -9,14 +26,13 @@ import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header'; import HeaderSearch from '../../_com/header';
import apbdes from '../../_state/landing-page/apbdes'; import apbdes from '../../_state/landing-page/apbdes';
function APBDes() { function APBDes() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
return ( return (
<Box> <Box>
<HeaderSearch <HeaderSearch
title='APBDes' title="APBDes"
placeholder='Cari APBDes...' placeholder="Cari APBDes..."
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
@@ -27,22 +43,16 @@ function APBDes() {
} }
function ListAPBDes({ search }: { search: string }) { function ListAPBDes({ search }: { search: string }) {
const listState = useProxy(apbdes) const listState = useProxy(apbdes);
const router = useRouter(); const router = useRouter();
const { const { data, page, totalPages, loading, load } = listState.findMany;
data,
page,
totalPages,
loading,
load,
} = listState.findMany
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, search) load(page, 10, search);
}, [page, search]) }, [page, search]);
const filteredData = data || [] const filteredData = data || [];
if (loading || !data) { if (loading || !data) {
return ( return (
@@ -71,8 +81,8 @@ function ListAPBDes({ search }: { search: string }) {
<Table highlightOnHover> <Table highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '30%'}}>Nama APBDes</TableTh> <TableTh style={{ width: '25%' }}>APBDes</TableTh>
<TableTh style={{ width: '30%' }}>Jumlah</TableTh> <TableTh style={{ width: '25%' }}>Tahun</TableTh>
<TableTh style={{ width: '25%' }}>Dokumen</TableTh> <TableTh style={{ width: '25%' }}>Dokumen</TableTh>
<TableTh style={{ width: '25%' }}>Aksi</TableTh> <TableTh style={{ width: '25%' }}>Aksi</TableTh>
</TableTr> </TableTr>
@@ -81,53 +91,54 @@ function ListAPBDes({ search }: { search: string }) {
{filteredData.length > 0 ? ( {filteredData.length > 0 ? (
filteredData.map((item) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd style={{ width: '30%' }}> <TableTd style={{ width: '25%' }}>
<Text fw={500} truncate="end">{item.name}</Text> <Text fw={500} lineClamp={1}>
</TableTd> APBDes {item.tahun}
<TableTd style={{ width: '30%' }}> </Text>
<Box w={150}>
<Text>Rp. {item.jumlah}</Text>
</Box>
</TableTd> </TableTd>
<TableTd style={{ width: '25%' }}> <TableTd style={{ width: '25%' }}>
<Box w={150}> <Text fw={500}>{item.tahun || '-'}</Text>
{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>
</TableTd> </TableTd>
<TableTd style={{ width: '25%' }}> <TableTd style={{ width: '25%' }}>
<Box w={80}> {item.file?.link ? (
<Button <Button
size="xs" component="a"
radius="md" href={item.file.link}
variant="light" target="_blank"
color="blue" rel="noopener noreferrer"
leftSection={<IconDeviceImacCog size={16} />} variant="light"
onClick={() => router.push(`/admin/landing-page/APBDes/${item.id}`)} leftSection={<IconFile size={16} />}
fullWidth size="xs"
> radius="sm"
Detail >
</Button> Lihat Dokumen
</Box> </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> </TableTd>
</TableTr> </TableTr>
)) ))
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={5}>
<Center py={20}> <Center py={20}>
<Text color="dimmed">Tidak ada data APBDes yang cocok</Text> <Text color="dimmed">Tidak ada data APBDes yang cocok</Text>
</Center> </Center>
@@ -152,7 +163,7 @@ function ListAPBDes({ search }: { search: string }) {
/> />
</Center> </Center>
</Box> </Box>
) );
} }
export default APBDes; export default APBDes;

View File

@@ -1,36 +1,137 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
import { assignParentIdsToApbdesItems } from "./lib/getParentsID";
type APBDesItemInput = {
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
selisih: number;
persentase: number;
level: number;
tipe: string;
};
type FormCreate = { type FormCreate = {
name: string; tahun: number;
jumlah: string;
imageId: string; imageId: string;
fileId: string; fileId: string;
items: APBDesItemInput[];
}; };
export default async function apbdesCreate(context: Context) { export default async function apbdesCreate(context: Context) {
const body = context.body as FormCreate; const body = context.body as FormCreate;
// Log the incoming request for debugging
console.log('Incoming request body:', JSON.stringify(body, null, 2));
try { try {
const result = await prisma.aPBDes.create({ // Validate required fields
data: { if (!body.tahun) {
name: body.name, throw new Error('Tahun is required');
jumlah: body.jumlah, }
imageId: body.imageId, if (!body.imageId) {
fileId: body.fileId, throw new Error('Image ID is required');
}, }
include: { if (!body.fileId) {
image: true, throw new Error('File ID is required');
file: true, }
}, if (!body.items || body.items.length === 0) {
throw new Error('At least one item is required');
}
// 1. Buat APBDes + items (tanpa parentId dulu)
const created = await prisma.$transaction(async (prisma) => {
const apbdes = await prisma.aPBDes.create({
data: {
tahun: body.tahun,
name: `APBDes Tahun ${body.tahun}`,
imageId: body.imageId,
fileId: body.fileId,
},
});
// Create items in a batch
const items = await Promise.all(
body.items.map(item => {
// Create a new object with only the fields that exist in the APBDesItem model
const itemData = {
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
realisasi: item.realisasi,
selisih: item.selisih,
persentase: item.persentase,
level: item.level,
apbdesId: apbdes.id,
// Note: tipe field is not included as it doesn't exist in the model
};
return prisma.aPBDesItem.create({
data: itemData,
select: { id: true, kode: true },
});
})
);
return { ...apbdes, items };
}); });
try {
// 2. Isi parentId berdasarkan kode
await assignParentIdsToApbdesItems(created.items);
// 3. Ambil ulang data lengkap untuk response
const result = await prisma.aPBDes.findUnique({
where: { id: created.id },
include: {
image: true,
file: true,
items: {
orderBy: { kode: 'asc' },
},
},
});
console.log('APBDes created successfully:', JSON.stringify(result, null, 2));
return {
success: true,
message: "Berhasil membuat APBDes",
data: result,
};
} catch (innerError) {
console.error('Error in post-creation steps:', innerError);
// Even if post-creation steps fail, we still return success since the main record was created
return {
success: true,
message: "APBDes berhasil dibuat, tetapi ada masalah dengan pemrosesan tambahan",
data: created,
warning: process.env.NODE_ENV === 'development' ? String(innerError) : undefined,
};
}
} catch (error: any) {
console.error("Error creating APBDes:", error);
// Log the full error for debugging
if (error.code) console.error('Prisma error code:', error.code);
if (error.meta) console.error('Prisma error meta:', error.meta);
const errorMessage = error.message || 'Unknown error';
context.set.status = 500;
return { return {
success: true, success: false,
message: "Berhasil membuat APB Des", message: `Gagal membuat APBDes: ${errorMessage}`,
data: result, error: process.env.NODE_ENV === 'development' ? {
message: error.message,
code: error.code,
meta: error.meta,
stack: error.stack
} : undefined
}; };
} catch (error) {
console.error("Error creating APB Des:", error);
throw new Error("Gagal membuat APB Des: " + (error as Error).message);
} }
} }

View File

@@ -2,20 +2,59 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
export default async function apbdesDelete(context: Context) { export default async function apbdesDelete(context: Context) {
const { params } = context; const { id } = context.params as { id: string };
const id = params?.id as string;
if (!id) { if (!id) {
throw new Error("ID tidak ditemukan dalam parameter"); context.set.status = 400;
return {
success: false,
message: "ID APBDes diperlukan",
};
} }
const deleted = await prisma.aPBDes.delete({ try {
where: { id }, // Cek apakah ada
}); const existing = await prisma.aPBDes.findUnique({
where: { id },
});
return { if (!existing) {
success: true, context.set.status = 404;
message: "Berhasil menghapus APB Des", return {
data: deleted, success: false,
}; message: "APBDes tidak ditemukan",
};
}
// Soft delete: set isActive = false & isi deletedAt
const result = await prisma.aPBDes.update({
where: { id },
data: {
isActive: false,
deletedAt: new Date(),
},
});
// Opsional: juga soft delete semua item terkait
await prisma.aPBDesItem.updateMany({
where: { apbdesId: id },
data: {
isActive: false,
deletedAt: new Date()
},
});
return {
success: true,
message: "APBDes berhasil dihapus",
data: result,
};
} catch (error) {
console.error("Error deleting APBDes:", error);
context.set.status = 500;
return {
success: false,
message: "Gagal menghapus APBDes",
};
}
} }

View File

@@ -1,56 +1,57 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
// /api/berita/findManyPaginated.ts // src/app/api/.../apbdes/findMany.ts
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
async function apbdesFindMany(context: Context) { export default async function apbdesFindMany(context: Context) {
const page = Number(context.query.page) || 1; const { page = "1", limit = "10", tahun } = context.query as {
const limit = Number(context.query.limit) || 10; page?: string;
const search = (context.query.search as string) || ''; limit?: string;
const skip = (page - 1) * limit; tahun?: string;
};
// Buat where clause const pageNumber = Math.max(1, parseInt(page, 10) || 1);
const where: any = { isActive: true }; const limitNumber = Math.min(100, Math.max(1, parseInt(limit, 10) || 10));
// Tambahkan pencarian (jika ada) const skip = (pageNumber - 1) * limitNumber;
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
];
}
try { try {
const where: any = { isActive: true };
if (tahun) {
where.tahun = parseInt(tahun, 10);
}
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
prisma.aPBDes.findMany({ prisma.aPBDes.findMany({
where, where,
skip,
take: limitNumber,
orderBy: { createdAt: "desc" },
include: { include: {
image: true, image: true,
file: true, file: true,
items: true,
}, },
skip,
take: limit,
orderBy: { name: "asc" }, // opsional, kalau mau urut berdasarkan waktu
}),
prisma.aPBDes.count({
where,
}), }),
prisma.aPBDes.count({ where }),
]); ]);
return { return {
success: true, success: true,
message: "Success fetch APB Des with pagination",
data, data,
page, meta: {
totalPages: Math.ceil(total / limit), page: pageNumber,
total, limit: limitNumber,
total,
totalPages: Math.ceil(total / limitNumber),
},
}; };
} catch (e) { } catch (error) {
console.error("Find many paginated error:", e); console.error("Error fetching APBDes list:", error);
context.set.status = 500;
return { return {
success: false, success: false,
message: "Failed fetch APB Des with pagination", message: "Gagal mengambil daftar APBDes",
}; };
} }
} }
export default apbdesFindMany;

View File

@@ -2,28 +2,78 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
export default async function apbdesFindUnique(context: Context) { export default async function apbdesFindUnique(context: Context) {
const { params } = context; // ✅ Parse URL secara manual
const id = params?.id as string; const url = new URL(context.request.url);
const pathSegments = url.pathname.split('/').filter(Boolean);
if (!id) {
throw new Error("ID tidak ditemukan dalam parameter"); console.log("🔍 DEBUG INFO:");
console.log("- Full URL:", context.request.url);
console.log("- Pathname:", url.pathname);
console.log("- Path segments:", pathSegments);
// Expected: ['api', 'landingpage', 'apbdes', 'ID']
if (pathSegments.length < 4) {
context.set.status = 400;
return {
success: false,
message: "Route tidak valid. Format: /api/landingpage/apbdes/:id",
debug: { pathSegments }
};
}
if (pathSegments[0] !== 'api' ||
pathSegments[1] !== 'landingpage' ||
pathSegments[2] !== 'apbdes') {
context.set.status = 400;
return {
success: false,
message: "Route tidak valid",
debug: { pathSegments }
};
}
const id = pathSegments[3]; // ✅ ID ada di index ke-3
if (!id || id.trim() === '') {
context.set.status = 400;
return {
success: false,
message: "ID APBDes diperlukan",
};
} }
const data = await prisma.aPBDes.findUnique({ try {
where: { id }, const result = await prisma.aPBDes.findUnique({
include: { where: { id },
image: true, include: {
file: true, items: {
}, where: { isActive: true },
}); orderBy: { kode: 'asc' }
},
image: true,
file: true
}
});
if (!data) { if (!result || !result.isActive) {
throw new Error("APB Des tidak ditemukan"); context.set.status = 404;
return {
success: false,
message: "Data APBDes tidak ditemukan atau tidak aktif",
};
}
return {
success: true,
data: result,
};
} catch (error) {
console.error("❌ Error in apbdesFindUnique:", error);
context.set.status = 500;
return {
success: false,
message: "Terjadi kesalahan saat mengambil data APBDes",
error: process.env.NODE_ENV === 'development' ? String(error) : undefined
};
} }
}
return {
success: true,
message: "Data APB Des ditemukan",
data,
};
}

View File

@@ -1,3 +1,4 @@
// src/app/api/[[...slugs]]/_lib/landing_page/apbdes/index.ts
import Elysia, { t } from "elysia"; import Elysia, { t } from "elysia";
import apbdesCreate from "./create"; import apbdesCreate from "./create";
import apbdesDelete from "./del"; import apbdesDelete from "./del";
@@ -5,12 +6,24 @@ import apbdesFindMany from "./findMany";
import apbdesFindUnique from "./findUnique"; import apbdesFindUnique from "./findUnique";
import apbdesUpdate from "./updt"; import apbdesUpdate from "./updt";
// Definisikan skema untuk item APBDes
const ApbdesItemSchema = t.Object({
kode: t.String(),
uraian: t.String(),
anggaran: t.Number(),
realisasi: t.Number(),
selisih: t.Number(),
persentase: t.Number(),
level: t.Number(),
tipe: t.String(), // misal: "pendapatan" atau "belanja"
});
const APBDes = new Elysia({ const APBDes = new Elysia({
prefix: "/apbdes", prefix: "/apbdes",
tags: ["Landing Page/Profile/APB Des"], tags: ["Landing Page/Profile/APB Des"],
}) })
// ✅ Find all // ✅ Find all (dengan query opsional: page, limit, tahun)
.get("/findMany", apbdesFindMany) .get("/findMany", apbdesFindMany)
// ✅ Find by ID // ✅ Find by ID
@@ -19,23 +32,27 @@ const APBDes = new Elysia({
// ✅ Create // ✅ Create
.post("/create", apbdesCreate, { .post("/create", apbdesCreate, {
body: t.Object({ body: t.Object({
name: t.String(), tahun: t.Number(),
imageId: t.String(), imageId: t.String(),
fileId: t.String(), fileId: t.String(),
jumlah: t.String(), items: t.Array(ApbdesItemSchema),
}), }),
}) })
// ✅ Update // ✅ Update
.put("/:id", apbdesUpdate, { .put("/:id", apbdesUpdate, {
params: t.Object({ id: t.String() }),
body: t.Object({ body: t.Object({
name: t.String(), tahun: t.Number(),
imageId: t.Optional(t.String()), imageId: t.String(),
fileId: t.Optional(t.String()), fileId: t.String(),
jumlah: t.Optional(t.String()), items: t.Array(ApbdesItemSchema),
}), }),
}) })
// ✅ Delete
.delete("/del/:id", apbdesDelete);
export default APBDes; // ✅ Delete
.delete("/del/:id", apbdesDelete, {
params: t.Object({ id: t.String() }),
});
export default APBDes;

View File

@@ -0,0 +1,54 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
/**
* Helper untuk mengisi parentId pada item APBDes berdasarkan struktur kode.
* Harus dipanggil setelah semua item berhasil dibuat (dengan id yang valid).
*
* @param items - Array item yang sudah punya { id, kode }
*/
export async function assignParentIdsToApbdesItems(
items: { id: string; kode: string }[]
): Promise<void> {
// Buat lookup berdasarkan kode → id
const kodeToId = new Map<string, string>();
for (const item of items) {
kodeToId.set(item.kode, item.id);
}
// Siapkan batch update
const updates: Promise<any>[] = [];
for (const item of items) {
const { id, kode } = item;
// Skip jika kode tidak memiliki parent (level 1, misal "4")
if (!kode.includes(".")) {
// Pastikan parentId null untuk akar
updates.push(
prisma.aPBDesItem.update({
where: { id },
data: { parentId: null },
})
);
continue;
}
// Ambil kode parent: "4.1.2" → "4.1"
const parts = kode.split(".");
parts.pop(); // hapus bagian terakhir
const parentKode = parts.join(".");
const parentID = kodeToId.get(parentKode) || null;
updates.push(
prisma.aPBDesItem.update({
where: { id },
data: { parentId: parentID },
})
);
}
// Jalankan semua update secara paralel
await Promise.all(updates);
}

View File

@@ -1,51 +1,113 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
import { assignParentIdsToApbdesItems } from "./lib/getParentsID";
type FormUpdateAPBDes = { type APBDesItemInput = {
name?: string; kode: string;
imageId?: string; uraian: string;
fileId?: string; anggaran: number;
jumlah?: string; realisasi: number;
selisih: number;
persentase: number;
level: number;
tipe: string;
};
type FormUpdateBody = {
tahun: number;
imageId: string;
fileId: string;
items: APBDesItemInput[];
}; };
export default async function apbdesUpdate(context: Context) { export default async function apbdesUpdate(context: Context) {
const body = context.body as FormUpdateAPBDes; const body = context.body as FormUpdateBody;
const { id } = context.params as { id: string };
const id = context.params.id;
if (!id) {
return {
success: false,
message: "ID APB Des wajib diisi",
};
}
try { try {
const updated = await prisma.aPBDes.update({ // 1. Pastikan APBDes ada
const existing = await prisma.aPBDes.findUnique({
where: { id },
});
if (!existing) {
context.set.status = 404;
return {
success: false,
message: "APBDes tidak ditemukan",
};
}
// 2. Hapus semua item lama
await prisma.aPBDesItem.deleteMany({
where: { apbdesId: id },
});
// 3. Buat item baru tanpa parentId terlebih dahulu
await prisma.aPBDesItem.createMany({
data: body.items.map((item) => ({
apbdesId: id,
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
realisasi: item.realisasi,
selisih: item.anggaran - item.realisasi,
persentase: item.anggaran > 0 ? (item.realisasi / item.anggaran) * 100 : 0,
level: item.level,
tipe: item.tipe,
isActive: true,
})),
});
// 4. Dapatkan semua item yang baru dibuat untuk mendapatkan ID-nya
const allItems = await prisma.aPBDesItem.findMany({
where: { apbdesId: id },
select: { id: true, kode: true },
});
// 5. Update parentId untuk setiap item
// Pastikan allItems memiliki tipe yang benar
const itemsForParentUpdate = allItems.map(item => ({
id: item.id,
kode: item.kode,
}));
await assignParentIdsToApbdesItems(itemsForParentUpdate);
// 6. Update data APBDes
await prisma.aPBDes.update({
where: { id }, where: { id },
data: { data: {
name: body.name, tahun: body.tahun,
imageId: body.imageId, imageId: body.imageId,
fileId: body.fileId, fileId: body.fileId,
jumlah: body.jumlah,
}, },
});
// 5. Ambil data lengkap untuk response
const result = await prisma.aPBDes.findUnique({
where: { id },
include: { include: {
items: {
where: { isActive: true },
orderBy: { kode: 'asc' }
},
image: true, image: true,
file: true,
}, },
}); });
return { return {
success: true, success: true,
message: "APB Des berhasil diperbarui", message: "Berhasil memperbarui APBDes",
data: updated, data: result,
}; };
} catch (error: any) { } catch (error) {
console.error("Error update APB Des:", error); console.error("Error updating APBDes:", error);
context.set.status = 500;
return { return {
success: false, success: false,
message: "Gagal memperbarui data APB Des", message: "Gagal memperbarui APBDes",
error: error.message,
}; };
} }
} }

View File

@@ -3,18 +3,17 @@ import { Context } from "elysia";
type FormCreate = { type FormCreate = {
namaLengkap: string; namaLengkap: string;
nik: string; nis: string;
kelas: string;
jenisKelamin: "LAKI_LAKI" | "PEREMPUAN";
alamatDomisili?: string;
tempatLahir: string; tempatLahir: string;
tanggalLahir: string; // ISO date string tanggalLahir: string; // ISO date string
jenisKelamin: "LAKI_LAKI" | "PEREMPUAN"; namaOrtu?: string;
kewarganegaraan: string; nik: string;
agama: "ISLAM" | "KRISTEN_PROTESTAN" | "KRISTEN_KATOLIK" | "HINDU" | "BUDDHA" | "KONGHUCU" | "LAINNYA"; pekerjaanOrtu?: string;
alamatKTP: string; penghasilan?: string;
alamatDomisili?: string;
noHp: string; noHp: string;
email: string;
statusPernikahan: "BELUM_MENIKAH" | "MENIKAH" | "JANDA_DUDA";
ukuranBaju?: "S" | "M" | "L" | "XL" | "XXL" | "LAINNYA";
}; };
export default async function beasiswaPendaftarCreate(context: Context) { export default async function beasiswaPendaftarCreate(context: Context) {
@@ -24,18 +23,17 @@ export default async function beasiswaPendaftarCreate(context: Context) {
const result = await prisma.beasiswaPendaftar.create({ const result = await prisma.beasiswaPendaftar.create({
data: { data: {
namaLengkap: body.namaLengkap, namaLengkap: body.namaLengkap,
nik: body.nik, nis: body.nis,
kelas: body.kelas,
jenisKelamin: body.jenisKelamin,
alamatDomisili: body.alamatDomisili,
tempatLahir: body.tempatLahir, tempatLahir: body.tempatLahir,
tanggalLahir: new Date(body.tanggalLahir), tanggalLahir: new Date(body.tanggalLahir),
jenisKelamin: body.jenisKelamin, namaOrtu: body.namaOrtu,
kewarganegaraan: body.kewarganegaraan, nik: body.nik,
agama: body.agama, pekerjaanOrtu: body.pekerjaanOrtu,
alamatKTP: body.alamatKTP, penghasilan: body.penghasilan,
alamatDomisili: body.alamatDomisili,
noHp: body.noHp, noHp: body.noHp,
email: body.email,
statusPernikahan: body.statusPernikahan,
ukuranBaju: body.ukuranBaju,
}, },
}); });

View File

@@ -13,18 +13,17 @@ const BeasiswaPendaftar = new Elysia({
.post("/create", beasiswaPendaftarCreate, { .post("/create", beasiswaPendaftarCreate, {
body: t.Object({ body: t.Object({
namaLengkap: t.String(), namaLengkap: t.String(),
nik: t.String(), nis: t.String(),
kelas: t.String(),
jenisKelamin: t.String(),
alamatDomisili: t.String(),
tempatLahir: t.String(), tempatLahir: t.String(),
tanggalLahir: t.String(), tanggalLahir: t.String(),
jenisKelamin: t.String(), namaOrtu: t.String(),
kewarganegaraan: t.String(), nik: t.String(),
agama: t.String(), pekerjaanOrtu: t.String(),
alamatKTP: t.String(), penghasilan: t.String(),
alamatDomisili: t.String(),
noHp: t.String(), noHp: t.String(),
email: t.String(),
statusPernikahan: t.String(),
ukuranBaju: t.String(),
}), }),
}) })
.get("/findMany", beasiswaPendaftarFindMany) .get("/findMany", beasiswaPendaftarFindMany)
@@ -43,18 +42,17 @@ const BeasiswaPendaftar = new Elysia({
{ {
body: t.Object({ body: t.Object({
namaLengkap: t.String(), namaLengkap: t.String(),
nik: t.String(), nis: t.String(),
kelas: t.String(),
jenisKelamin: t.String(),
alamatDomisili: t.String(),
tempatLahir: t.String(), tempatLahir: t.String(),
tanggalLahir: t.String(), tanggalLahir: t.String(),
jenisKelamin: t.String(), namaOrtu: t.String(),
kewarganegaraan: t.String(), nik: t.String(),
agama: t.String(), pekerjaanOrtu: t.String(),
alamatKTP: t.String(), penghasilan: t.String(),
alamatDomisili: t.String(),
noHp: t.String(), noHp: t.String(),
email: t.String(),
statusPernikahan: t.String(),
ukuranBaju: t.String(),
}), }),
} }
) )

View File

@@ -3,25 +3,17 @@ import { Context } from "elysia";
type FormUpdate = { type FormUpdate = {
namaLengkap: string; namaLengkap: string;
nik: string; nis: string;
kelas: string;
jenisKelamin: "LAKI_LAKI" | "PEREMPUAN";
alamatDomisili?: string;
tempatLahir: string; tempatLahir: string;
tanggalLahir: string; // ISO date string tanggalLahir: string; // ISO date string
jenisKelamin: "LAKI_LAKI" | "PEREMPUAN"; namaOrtu?: string;
kewarganegaraan: string; nik: string;
agama: pekerjaanOrtu?: string;
| "ISLAM" penghasilan?: string;
| "KRISTEN_PROTESTAN"
| "KRISTEN_KATOLIK"
| "HINDU"
| "BUDDHA"
| "KONGHUCU"
| "LAINNYA";
alamatKTP: string;
alamatDomisili?: string;
noHp: string; noHp: string;
email: string;
statusPernikahan: "BELUM_MENIKAH" | "MENIKAH" | "JANDA_DUDA";
ukuranBaju?: "S" | "M" | "L" | "XL" | "XXL" | "LAINNYA";
}; };
export default async function beasiswaPendaftarUpdate(context: Context) { export default async function beasiswaPendaftarUpdate(context: Context) {
@@ -40,18 +32,17 @@ export default async function beasiswaPendaftarUpdate(context: Context) {
where: { id }, where: { id },
data: { data: {
namaLengkap: body.namaLengkap, namaLengkap: body.namaLengkap,
nik: body.nik, nis: body.nis,
kelas: body.kelas,
jenisKelamin: body.jenisKelamin,
alamatDomisili: body.alamatDomisili,
tempatLahir: body.tempatLahir, tempatLahir: body.tempatLahir,
tanggalLahir: new Date(body.tanggalLahir), tanggalLahir: new Date(body.tanggalLahir),
jenisKelamin: body.jenisKelamin, namaOrtu: body.namaOrtu,
kewarganegaraan: body.kewarganegaraan, nik: body.nik,
agama: body.agama, pekerjaanOrtu: body.pekerjaanOrtu,
alamatKTP: body.alamatKTP, penghasilan: body.penghasilan,
alamatDomisili: body.alamatDomisili,
noHp: body.noHp, noHp: body.noHp,
email: body.email,
statusPernikahan: body.statusPernikahan,
ukuranBaju: body.ukuranBaju,
}, },
}); });

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import beasiswaDesaState from '@/app/admin/(dashboard)/_state/pendidikan/beasiswa-desa'; import beasiswaDesaState from '@/app/admin/(dashboard)/_state/pendidikan/beasiswa-desa';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Group, Image, Modal, Pagination, Paper, Select, SimpleGrid, Skeleton, Stack, Stepper, Text, TextInput, Title } from '@mantine/core'; import { Box, Button, Center, Divider, Group, Image, Modal, Pagination, Paper, Select, SimpleGrid, Skeleton, Stack, Stepper, Text, TextInput, Title } from '@mantine/core';
import { useDisclosure, useShallowEffect } from '@mantine/hooks'; import { useDisclosure, useShallowEffect } from '@mantine/hooks';
import { IconArrowRight, IconCoin, IconInfoCircle, IconSchool, IconUsers } from '@tabler/icons-react'; import { IconArrowRight, IconCoin, IconInfoCircle, IconSchool, IconUsers } from '@tabler/icons-react';
import { useTransitionRouter } from 'next-view-transitions'; import { useTransitionRouter } from 'next-view-transitions';
@@ -23,18 +23,17 @@ function Page() {
const resetForm = () => { const resetForm = () => {
beasiswaDesa.create.form = { beasiswaDesa.create.form = {
namaLengkap: "", namaLengkap: "",
nik: "", nis: "",
kelas: "",
jenisKelamin: "",
alamatDomisili: "",
tempatLahir: "", tempatLahir: "",
tanggalLahir: "", tanggalLahir: "",
jenisKelamin: "", namaOrtu: "",
kewarganegaraan: "WNI", nik: "",
agama: "", pekerjaanOrtu: "",
alamatKTP: "", penghasilan: "",
alamatDomisili: "",
noHp: "", noHp: "",
email: "",
statusPernikahan: "",
ukuranBaju: "",
}; };
}; };
@@ -189,9 +188,22 @@ function Page() {
onChange={(val) => { beasiswaDesa.create.form.namaLengkap = val.target.value }} /> onChange={(val) => { beasiswaDesa.create.form.namaLengkap = val.target.value }} />
<TextInput <TextInput
type="number" type="number"
label="NIK" label="NIS"
placeholder="Masukkan NIK" placeholder="Masukkan NIS"
onChange={(val) => { beasiswaDesa.create.form.nik = val.target.value }} /> onChange={(val) => { beasiswaDesa.create.form.nis = val.target.value }} />
<TextInput
label="Kelas"
placeholder="Masukkan kelas"
onChange={(val) => { beasiswaDesa.create.form.kelas = val.target.value }} />
<Select
label="Jenis Kelamin"
placeholder="Pilih jenis kelamin"
data={[{ value: "LAKI_LAKI", label: "Laki-laki" }, { value: "PEREMPUAN", label: "Perempuan" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.jenisKelamin = val }} />
<TextInput
label="Alamat Domisili"
placeholder="Masukkan alamat domisili"
onChange={(val) => { beasiswaDesa.create.form.alamatDomisili = val.target.value }} />
<TextInput <TextInput
label="Tempat Lahir" label="Tempat Lahir"
placeholder="Masukkan tempat lahir" placeholder="Masukkan tempat lahir"
@@ -201,52 +213,29 @@ function Page() {
label="Tanggal Lahir" label="Tanggal Lahir"
placeholder="Pilih tanggal lahir" placeholder="Pilih tanggal lahir"
onChange={(val) => { beasiswaDesa.create.form.tanggalLahir = val.target.value }} /> onChange={(val) => { beasiswaDesa.create.form.tanggalLahir = val.target.value }} />
<Select <Box pt={15} pb={10}>
label="Jenis Kelamin" <Divider />
placeholder="Pilih jenis kelamin" </Box>
data={[{ value: "LAKI_LAKI", label: "Laki-laki" }, { value: "PEREMPUAN", label: "Perempuan" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.jenisKelamin = val }} />
<TextInput <TextInput
label="Kewarganegaraan" label="Nama Orang Tua / Wali"
placeholder="Masukkan kewarganegaraan" placeholder="Masukkan nama orang tua / wali"
value={beasiswaDesa.create.form.kewarganegaraan || "WNI"} // tampilkan WNI kalau kosong onChange={(val) => { beasiswaDesa.create.form.namaOrtu = val.target.value }} />
onChange={(e) => {
beasiswaDesa.create.form.kewarganegaraan = e.target.value;
}}
/>
<Select
label="Agama"
placeholder="Pilih agama"
data={[{ value: "ISLAM", label: "Islam" }, { value: "KRISTEN_PROTESTAN", label: "Kristen Protestan" }, { value: "KRISTEN_KATOLIK", label: "Kristen Katolik" }, { value: "HINDU", label: "Hindu" }, { value: "BUDDHA", label: "Buddha" }, { value: "KONGHUCU", label: "Konghucu" }, { value: "LAINNYA", label: "Lainnya" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.agama = val }} />
<TextInput <TextInput
label="Alamat KTP" label="NIK Orang Tua / Wali"
placeholder="Masukkan alamat sesuai KTP" placeholder="Masukkan NIK orang tua / wali"
onChange={(val) => { beasiswaDesa.create.form.alamatKTP = val.target.value }} /> onChange={(val) => { beasiswaDesa.create.form.nik = val.target.value }} />
<TextInput <TextInput
label="Alamat Domisili" label="Pekerjaan Orang Tua / Wali"
placeholder="Masukkan alamat domisili" placeholder="Masukkan pekerjaan orang tua / wali"
onChange={(val) => { beasiswaDesa.create.form.alamatDomisili = val.target.value }} /> onChange={(val) => { beasiswaDesa.create.form.pekerjaanOrtu = val.target.value }} />
<TextInput <TextInput
type="number" label="Penghasilan Orang Tua / Wali"
label="Nomor HP" placeholder="Masukkan penghasilan orang tua / wali"
placeholder="Masukkan nomor HP" onChange={(val) => { beasiswaDesa.create.form.penghasilan = val.target.value }} />
<TextInput
label="No HP"
placeholder="Masukkan no hp"
onChange={(val) => { beasiswaDesa.create.form.noHp = val.target.value }} /> onChange={(val) => { beasiswaDesa.create.form.noHp = val.target.value }} />
<TextInput
type="email"
label="Email"
placeholder="Masukkan alamat email"
onChange={(val) => { beasiswaDesa.create.form.email = val.target.value }} />
<Select
label="Status Pernikahan"
placeholder="Pilih status pernikahan"
data={[{ value: "BELUM_MENIKAH", label: "Belum Menikah" }, { value: "MENIKAH", label: "Menikah" }, { value: "JANDA_DUDA", label: "Janda/Duda" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.statusPernikahan = val }} />
<Select
label="Ukuran Baju"
placeholder="Pilih ukuran baju"
data={[{ value: "S", label: "S" }, { value: "M", label: "M" }, { value: "L", label: "L" }, { value: "XL", label: "XL" }, { value: "XXL", label: "XXL" }, { value: "LAINNYA", label: "Lainnya" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.ukuranBaju = val }} />
<Group justify="flex-end" mt="md"> <Group justify="flex-end" mt="md">
<Button variant="default" radius="xl" onClick={close}>Batal</Button> <Button variant="default" radius="xl" onClick={close}>Batal</Button>
<Button radius="xl" bg={colors['blue-button']} onClick={handleSubmit}>Kirim</Button> <Button radius="xl" bg={colors['blue-button']} onClick={handleSubmit}>Kirim</Button>

View File

@@ -4,6 +4,7 @@ import {
Box, Box,
Button, Button,
Container, Container,
Divider,
Group, Group,
Modal, Modal,
Paper, Paper,
@@ -30,18 +31,17 @@ export default function BeasiswaPage() {
const resetForm = () => { const resetForm = () => {
beasiswaDesa.create.form = { beasiswaDesa.create.form = {
namaLengkap: "", namaLengkap: "",
nik: "", nis: "",
kelas: "",
jenisKelamin: "",
alamatDomisili: "",
tempatLahir: "", tempatLahir: "",
tanggalLahir: "", tanggalLahir: "",
jenisKelamin: "", namaOrtu: "",
kewarganegaraan: "WNI", nik: "",
agama: "", pekerjaanOrtu: "",
alamatKTP: "", penghasilan: "",
alamatDomisili: "",
noHp: "", noHp: "",
email: "",
statusPernikahan: "",
ukuranBaju: "",
}; };
}; };
@@ -248,9 +248,22 @@ export default function BeasiswaPage() {
onChange={(val) => { beasiswaDesa.create.form.namaLengkap = val.target.value }} /> onChange={(val) => { beasiswaDesa.create.form.namaLengkap = val.target.value }} />
<TextInput <TextInput
type="number" type="number"
label="NIK" label="NIS"
placeholder="Masukkan NIK" placeholder="Masukkan NIS"
onChange={(val) => { beasiswaDesa.create.form.nik = val.target.value }} /> onChange={(val) => { beasiswaDesa.create.form.nis = val.target.value }} />
<TextInput
label="Kelas"
placeholder="Masukkan kelas"
onChange={(val) => { beasiswaDesa.create.form.kelas = val.target.value }} />
<Select
label="Jenis Kelamin"
placeholder="Pilih jenis kelamin"
data={[{ value: "LAKI_LAKI", label: "Laki-laki" }, { value: "PEREMPUAN", label: "Perempuan" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.jenisKelamin = val }} />
<TextInput
label="Alamat Domisili"
placeholder="Masukkan alamat domisili"
onChange={(val) => { beasiswaDesa.create.form.alamatDomisili = val.target.value }} />
<TextInput <TextInput
label="Tempat Lahir" label="Tempat Lahir"
placeholder="Masukkan tempat lahir" placeholder="Masukkan tempat lahir"
@@ -260,52 +273,29 @@ export default function BeasiswaPage() {
label="Tanggal Lahir" label="Tanggal Lahir"
placeholder="Pilih tanggal lahir" placeholder="Pilih tanggal lahir"
onChange={(val) => { beasiswaDesa.create.form.tanggalLahir = val.target.value }} /> onChange={(val) => { beasiswaDesa.create.form.tanggalLahir = val.target.value }} />
<Select <Box pt={15} pb={10}>
label="Jenis Kelamin" <Divider />
placeholder="Pilih jenis kelamin" </Box>
data={[{ value: "LAKI_LAKI", label: "Laki-laki" }, { value: "PEREMPUAN", label: "Perempuan" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.jenisKelamin = val }} />
<TextInput <TextInput
label="Kewarganegaraan" label="Nama Orang Tua / Wali"
placeholder="Masukkan kewarganegaraan" placeholder="Masukkan nama orang tua / wali"
value={beasiswaDesa.create.form.kewarganegaraan || "WNI"} // tampilkan WNI kalau kosong onChange={(val) => { beasiswaDesa.create.form.namaOrtu = val.target.value }} />
onChange={(e) => {
beasiswaDesa.create.form.kewarganegaraan = e.target.value;
}}
/>
<Select
label="Agama"
placeholder="Pilih agama"
data={[{ value: "ISLAM", label: "Islam" }, { value: "KRISTEN_PROTESTAN", label: "Kristen Protestan" }, { value: "KRISTEN_KATOLIK", label: "Kristen Katolik" }, { value: "HINDU", label: "Hindu" }, { value: "BUDDHA", label: "Buddha" }, { value: "KONGHUCU", label: "Konghucu" }, { value: "LAINNYA", label: "Lainnya" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.agama = val }} />
<TextInput <TextInput
label="Alamat KTP" label="NIK Orang Tua / Wali"
placeholder="Masukkan alamat sesuai KTP" placeholder="Masukkan NIK orang tua / wali"
onChange={(val) => { beasiswaDesa.create.form.alamatKTP = val.target.value }} /> onChange={(val) => { beasiswaDesa.create.form.nik = val.target.value }} />
<TextInput <TextInput
label="Alamat Domisili" label="Pekerjaan Orang Tua / Wali"
placeholder="Masukkan alamat domisili" placeholder="Masukkan pekerjaan orang tua / wali"
onChange={(val) => { beasiswaDesa.create.form.alamatDomisili = val.target.value }} /> onChange={(val) => { beasiswaDesa.create.form.pekerjaanOrtu = val.target.value }} />
<TextInput <TextInput
type="number" label="Penghasilan Orang Tua / Wali"
label="Nomor HP" placeholder="Masukkan penghasilan orang tua / wali"
placeholder="Masukkan nomor HP" onChange={(val) => { beasiswaDesa.create.form.penghasilan = val.target.value }} />
<TextInput
label="No HP"
placeholder="Masukkan no hp"
onChange={(val) => { beasiswaDesa.create.form.noHp = val.target.value }} /> onChange={(val) => { beasiswaDesa.create.form.noHp = val.target.value }} />
<TextInput
type="email"
label="Email"
placeholder="Masukkan alamat email"
onChange={(val) => { beasiswaDesa.create.form.email = val.target.value }} />
<Select
label="Status Pernikahan"
placeholder="Pilih status pernikahan"
data={[{ value: "BELUM_MENIKAH", label: "Belum Menikah" }, { value: "MENIKAH", label: "Menikah" }, { value: "JANDA_DUDA", label: "Janda/Duda" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.statusPernikahan = val }} />
<Select
label="Ukuran Baju"
placeholder="Pilih ukuran baju"
data={[{ value: "S", label: "S" }, { value: "M", label: "M" }, { value: "L", label: "L" }, { value: "XL", label: "XL" }, { value: "XXL", label: "XXL" }, { value: "LAINNYA", label: "Lainnya" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.ukuranBaju = val }} />
<Group justify="flex-end" mt="md"> <Group justify="flex-end" mt="md">
<Button variant="default" radius="xl" onClick={close}>Batal</Button> <Button variant="default" radius="xl" onClick={close}>Batal</Button>
<Button radius="xl" bg={colors['blue-button']} onClick={handleSubmit}>Kirim</Button> <Button radius="xl" bg={colors['blue-button']} onClick={handleSubmit}>Kirim</Button>

View File

@@ -0,0 +1,208 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// src/app/admin/(dashboard)/landing-page/APBDes/APBDesProgress.tsx
'use client';
import { Box, Paper, Progress, Stack, Text, Title } from '@mantine/core';
import { useProxy } from 'valtio/utils';
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
import colors from '@/con/colors';
function formatRupiah(value: number) {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 2,
}).format(value);
}
function APBDesProgress() {
const state = useProxy(apbdes);
const data = state.findMany.data || [];
// Ambil APBDes pertama (misalnya, jika hanya satu tahun ditampilkan)
const apbdesItem = data[0]; // 👈 sesuaikan logika jika ada banyak APBDes
if (!apbdesItem) {
return (
<Box py="md" px={{ base: 'md', md: 100 }}>
<Text c="dimmed">Belum ada data APBDes untuk ditampilkan.</Text>
</Box>
);
}
const items = apbdesItem.items || [];
const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode));
// Kelompokkan berdasarkan tipe
const pendapatanItems = sortedItems.filter(item => item.tipe === 'pendapatan');
const belanjaItems = sortedItems.filter(item => item.tipe === 'belanja');
const pembiayaanItems = sortedItems.filter(item => item.tipe === 'pembiayaan'); // jika ada
// Hitung total per kategori
const calcTotal = (items: any[]) => {
const anggaran = items.reduce((sum, item) => sum + item.anggaran, 0);
const realisasi = items.reduce((sum, item) => sum + item.realisasi, 0);
const persen = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
return { anggaran, realisasi, persen };
};
const pendapatan = calcTotal(pendapatanItems);
const belanja = calcTotal(belanjaItems);
const pembiayaan = calcTotal(pembiayaanItems); // bisa kosong
// Render satu progress bar
const renderProgress = (label: string, dataset: any) => {
const isPembiayaan = label.includes('Pembiayaan');
return (
<Box key={label}>
<Text fw={600} fz="sm">{label}</Text>
<Text fw={700} mb="xs">
{formatRupiah(dataset.realisasi)} | {formatRupiah(dataset.anggaran)}
</Text>
<Progress
value={dataset.persen}
size="xl"
radius="xl"
striped={false}
styles={{
root: { backgroundColor: '#d7e3f1' },
section: {
backgroundColor: isPembiayaan
? 'green' // warna hijau untuk pembiayaan
: colors['blue-button'], // biru untuk pendapatan/belanja
position: 'relative',
'&::after': {
content: `'${dataset.persen.toFixed(2)}%'`,
position: 'absolute',
right: 10,
top: '50%',
transform: 'translateY(-50%)',
color: 'white',
fontWeight: 700,
fontSize: '0.8rem',
},
},
}}
/>
</Box>
);
};
return (
<Paper
mx={{ base: 'md', md: 100 }}
p="xl"
radius="md"
shadow="sm"
withBorder
bg={colors['white-1']}
>
<Stack gap="lg">
<Title order={4} c={colors['blue-button']} ta="center">
Grafik Pelaksanaan APBDes Tahun {apbdesItem.tahun}
</Title>
<Text ta="center" fw="bold" fz="sm" c="dimmed">
Realisasi | Anggaran
</Text>
{renderProgress('Pendapatan Desa', pendapatan)}
{renderProgress('Belanja Desa', belanja)}
{renderProgress('Pembiayaan Desa', pembiayaan)}
{pembiayaanItems.length > 0 && renderProgress('Pembiayaan Desa', pembiayaan)}
</Stack>
</Paper>
);
}
export default APBDesProgress;
// /* eslint-disable @typescript-eslint/no-explicit-any */
// 'use client';
// import { Box, Paper, Stack, Text, Title } from '@mantine/core';
// import { BarChart } from '@mantine/charts';
// import { useProxy } from 'valtio/utils';
// import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
// import colors from '@/con/colors';
// function APBDesProgress() {
// const state = useProxy(apbdes);
// const data = state.findMany.data || [];
// const apbdesItem = data[0];
// if (!apbdesItem) {
// return (
// <Box py="md" px={{ base: 'md', md: 100 }}>
// <Text c="dimmed">Belum ada data APBDes untuk ditampilkan.</Text>
// </Box>
// );
// }
// const items = apbdesItem.items || [];
// const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode));
// const pendapatanItems = sortedItems.filter(i => i.tipe === 'pendapatan');
// const belanjaItems = sortedItems.filter(i => i.tipe === 'belanja');
// const pembiayaanItems = sortedItems.filter(i => i.tipe === 'pembiayaan');
// const total = (rows: any[]) => {
// const anggaran = rows.reduce((s, i) => s + i.anggaran, 0);
// const realisasi = rows.reduce((s, i) => s + i.realisasi, 0);
// return anggaran === 0 ? 0 : (realisasi / anggaran) * 100;
// };
// const chartData = [
// { name: 'Pendapatan', persen: total(pendapatanItems) },
// { name: 'Belanja', persen: total(belanjaItems) },
// ];
// if (pembiayaanItems.length > 0) {
// chartData.push({ name: 'Pembiayaan', persen: total(pembiayaanItems) });
// }
// return (
// <Paper
// mx={{ base: 'md', md: 100 }}
// p="xl"
// radius="md"
// shadow="sm"
// withBorder
// bg={colors['white-1']}
// >
// <Stack gap="lg">
// <Title order={4} c={colors['blue-button']} ta="center">
// Grafik Pelaksanaan APBDes Tahun {apbdesItem.tahun}
// </Title>
// <Text ta="center" fw="bold" fz="sm" c="dimmed">
// Persentase Realisasi (%) dari Anggaran
// </Text>
// <BarChart
// h={200}
// data={chartData}
// orientation="vertical"
// dataKey="name"
// barProps={{ radius: 6 }}
// series={[
// {
// name: 'persen',
// label: 'Persentase',
// color: colors['blue-button'],
// },
// ]}
// yAxisProps={{
// domain: [0, 100],
// }}
// valueFormatter={(v) => `${v.toFixed(1)}%`}
// />
// </Stack>
// </Paper>
// );
// }
// export default APBDesProgress;

View File

@@ -0,0 +1,160 @@
// src/app/admin/(dashboard)/landing-page/APBDes/APBDesTable.tsx
'use client';
import { Box, Paper, Table, Text, Title, Badge, Group } from '@mantine/core';
import { useProxy } from 'valtio/utils';
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
import colors from '@/con/colors';
interface APBDesItem {
id: string;
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
selisih: number;
persentase: number;
level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
}
interface APBDesData {
id: string;
tahun: number;
items: APBDesItem[];
image?: { id: string; url: string } | null;
file?: { id: string; url: string } | null;
}
// Helper: Format Rupiah, tapi jika 0 → tampilkan '-'
function formatRupiahOrEmpty(value: number): string {
if (value === 0 || value === null || value === undefined) {
return '-';
}
return new Intl.NumberFormat('id-ID', {
minimumFractionDigits: 2,
style: 'decimal',
}).format(value);
}
// Helper: Format Persentase, tapi jika 0 → tampilkan '-'
function formatPersentaseOrEmpty(value: number): string {
if (value === 0 || value === null || value === undefined) {
return '-';
}
return `${value.toFixed(2)}%`;
}
function getIndent(level: number) {
return {
paddingLeft: `${(level - 1) * 20}px`,
};
}
function APBDesTable() {
const state = useProxy(apbdes);
const data = state.findMany.data || [];
// Get the first APBDes item
const apbdesItem = data[0] as unknown as APBDesData | undefined;
if (!apbdesItem) {
return (
<Box py="md" px={{ base: 'md', md: 100 }}>
<Text c="dimmed">Belum ada data APBDes untuk ditampilkan.</Text>
</Box>
);
}
const items = Array.isArray(apbdesItem.items) ? apbdesItem.items : [];
const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode));
// Calculate totals
const totalAnggaran = items.reduce((sum, item) => sum + (item.anggaran || 0), 0);
const totalRealisasi = items.reduce((sum, item) => sum + (item.realisasi || 0), 0);
const totalSelisih = totalAnggaran - totalRealisasi;
const totalPersentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
return (
<Box py="md" px={{ base: 'md', md: 100 }}>
<Title order={4} c={colors['blue-button']} mb="sm">
Rincian APBDes Tahun {apbdesItem.tahun}
</Title>
<Paper withBorder radius="md" shadow="xs" p="md">
<Box style={{overflowY: 'auto' }}>
<Table withColumnBorders highlightOnHover>
<Table.Thead bg="#2c5f78">
<Table.Tr>
<Table.Th c="white" style={{ width: '40%' }}>
Uraian
</Table.Th>
<Table.Th c="white" ta="right" style={{ width: '15%' }}>
Anggaran (Rp)
</Table.Th>
<Table.Th c="white" ta="right" style={{ width: '15%' }}>
Realisasi (Rp)
</Table.Th>
<Table.Th c="white" ta="right" style={{ width: '15%' }}>
Lebih/(Kurang) (Rp)
</Table.Th>
<Table.Th c="white" ta="center" style={{ width: '15%' }}>
Persentase (%)
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{sortedItems.map((item) => (
<Table.Tr key={item.id}>
<Table.Td style={getIndent(item.level)}>
<Group gap="xs" align="flex-start">
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
<Text fz="sm" >
{item.uraian}
</Text>
</Group>
</Table.Td>
<Table.Td ta="right">{formatRupiahOrEmpty(item.anggaran)}</Table.Td>
<Table.Td ta="right">{formatRupiahOrEmpty(item.realisasi)}</Table.Td>
<Table.Td ta="right">
<Text c={item.selisih >= 0 ? 'green' : 'red'}>
{formatRupiahOrEmpty(item.selisih)}
</Text>
</Table.Td>
<Table.Td ta="center">
<Badge color={item.persentase >= 100 ? 'teal' : 'yellow'} size="sm">
{formatPersentaseOrEmpty(item.persentase)}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
<Table.Tfoot bg="#e6f0f7">
<Table.Tr>
<Table.Th colSpan={1}>
<Text fw={700}>JUMLAH PENDAPATAN</Text>
</Table.Th>
<Table.Th ta="right">
<Text fw={700}>{formatRupiahOrEmpty(totalAnggaran)}</Text>
</Table.Th>
<Table.Th ta="right">
<Text fw={700}>{formatRupiahOrEmpty(totalRealisasi)}</Text>
</Table.Th>
<Table.Th ta="right">
<Text fw={700} c={totalSelisih >= 0 ? 'green' : 'red'}>
{formatRupiahOrEmpty(totalSelisih)}
</Text>
</Table.Th>
<Table.Th ta="center">
<Text fw={700}>{formatPersentaseOrEmpty(totalPersentase)}</Text>
</Table.Th>
</Table.Tr>
</Table.Tfoot>
</Table>
</Box>
</Paper>
</Box>
);
}
export default APBDesTable;

View File

@@ -4,12 +4,14 @@
import PendapatanAsliDesa from '@/app/admin/(dashboard)/_state/ekonomi/PADesa' import PendapatanAsliDesa from '@/app/admin/(dashboard)/_state/ekonomi/PADesa'
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes' import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
import colors from '@/con/colors' import colors from '@/con/colors'
import { ActionIcon, BackgroundImage, Box, Center, Container, Group, Loader, Paper, Progress, SimpleGrid, Stack, Table, Text, Title } from '@mantine/core' import { ActionIcon, BackgroundImage, Box, Center, Container, Group, Loader, SimpleGrid, Stack, Text, Title } from '@mantine/core'
import { IconDownload } from '@tabler/icons-react' import { IconDownload } from '@tabler/icons-react'
import { Link } from 'next-view-transitions' import { Link } from 'next-view-transitions'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useProxy } from 'valtio/utils' import { useProxy } from 'valtio/utils'
import BackButton from '../../(pages)/desa/layanan/_com/BackButto' import BackButton from '../../(pages)/desa/layanan/_com/BackButto'
import APBDesProgress from './lib/apbDesaProgress'
import APBDesTable from './lib/apbDesaTable'
function Page() { function Page() {
const state = useProxy(apbdes) const state = useProxy(apbdes)
@@ -92,200 +94,10 @@ function Page() {
))} ))}
</SimpleGrid> </SimpleGrid>
)} )}
<DetailAPBDesaTable /> <APBDesTable />
<APBDesaProgress /> <APBDesProgress />
</Stack> </Stack>
) )
} }
function DetailAPBDesaTable() { export default Page
// 🔹 Dummy data
const data = {
tahun: 2024,
pendapatan: [
{ id: 1, nama: 'Pendapatan Asli Desa', anggaran: 32000000, realisasi: 6500000 },
{ id: 2, nama: 'Dana Desa', anggaran: 125000000, realisasi: 120000000 },
{ id: 3, nama: 'Bagi Hasil Pajak dan Retribusi', anggaran: 10000000, realisasi: 9000000 },
],
belanja: [
{ id: 1, nama: 'Belanja Pegawai', anggaran: 80000000, realisasi: 75000000 },
{ id: 2, nama: 'Belanja Barang & Jasa', anggaran: 50000000, realisasi: 42000000 },
],
pembiayaan: [
{ id: 1, nama: 'Penerimaan Pembiayaan', anggaran: 15000000, realisasi: 15000000 },
{ id: 2, nama: 'Pengeluaran Pembiayaan', anggaran: 10000000, realisasi: 8000000 },
],
};
const formatRupiah = (value: number) =>
new Intl.NumberFormat('id-ID', {
minimumFractionDigits: 2,
style: 'decimal',
}).format(value);
// 🔹 Helper buat render satu kategori (Pendapatan, Belanja, Pembiayaan)
const renderSection = (title: string, items: any[]) => {
const totalAnggaran = items.reduce((sum, i) => sum + Number(i.anggaran || 0), 0);
const totalRealisasi = items.reduce((sum, i) => sum + Number(i.realisasi || 0), 0);
const totalSelisih = totalAnggaran - totalRealisasi;
const totalPersen = totalAnggaran
? (totalRealisasi / totalAnggaran) * 100
: 0;
return (
<Paper withBorder radius="md" shadow="xs" p="md">
<Title order={5} mb="sm" c={colors['blue-button']}>
{title.toUpperCase()}
</Title>
<Table withColumnBorders highlightOnHover>
<Table.Thead bg={colors['blue-button']}>
<Table.Tr>
<Table.Th c="white">Uraian</Table.Th>
<Table.Th c="white" ta="right">
Anggaran (Rp)
</Table.Th>
<Table.Th c="white" ta="right">
Realisasi (Rp)
</Table.Th>
<Table.Th c="white" ta="right">
Lebih/(Kurang) (Rp)
</Table.Th>
<Table.Th c="white" ta="center">
Persentase (%)
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item, index) => {
const selisih = Number(item.anggaran) - Number(item.realisasi);
const persen = item.anggaran
? (item.realisasi / item.anggaran) * 100
: 0;
return (
<Table.Tr key={item.id || index}>
<Table.Td>
<strong>{`${index + 1}. ${item.nama}`}</strong>
</Table.Td>
<Table.Td ta="right">{formatRupiah(item.anggaran)}</Table.Td>
<Table.Td ta="right">{formatRupiah(item.realisasi)}</Table.Td>
<Table.Td ta="right">{formatRupiah(selisih)}</Table.Td>
<Table.Td ta="center">{persen.toFixed(2)}</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
<Table.Tfoot bg="#f1f5fb">
<Table.Tr>
<Table.Th>Total {title}</Table.Th>
<Table.Th ta="right">{formatRupiah(totalAnggaran)}</Table.Th>
<Table.Th ta="right">{formatRupiah(totalRealisasi)}</Table.Th>
<Table.Th ta="right">{formatRupiah(totalSelisih)}</Table.Th>
<Table.Th ta="center">{totalPersen.toFixed(2)}</Table.Th>
</Table.Tr>
</Table.Tfoot>
</Table>
</Paper>
);
};
return (
<Box py="md" px={{ base: 'md', md: 100 }}>
<Stack gap="xl">
<Title order={4} c={colors['blue-button']}>
APB Desa Tahun {data.tahun}
</Title>
{renderSection('Pendapatan', data.pendapatan)}
{renderSection('Belanja', data.belanja)}
{renderSection('Pembiayaan', data.pembiayaan)}
</Stack>
</Box>
);
}
function APBDesaProgress() {
const data = {
tahun: 2024,
pendapatan: [
{ id: 1, nama: 'Pendapatan Asli Desa', anggaran: 32000000, realisasi: 6500000 },
{ id: 2, nama: 'Dana Desa', anggaran: 125000000, realisasi: 120000000 },
{ id: 3, nama: 'Bagi Hasil Pajak dan Retribusi', anggaran: 10000000, realisasi: 9000000 },
],
belanja: [
{ id: 1, nama: 'Belanja Pegawai', anggaran: 80000000, realisasi: 75000000 },
{ id: 2, nama: 'Belanja Barang & Jasa', anggaran: 50000000, realisasi: 42000000 },
],
pembiayaan: [
{ id: 1, nama: 'Penerimaan Pembiayaan', anggaran: 15000000, realisasi: 15000000 },
{ id: 2, nama: 'Pengeluaran Pembiayaan', anggaran: 10000000, realisasi: 8000000 },
],
};
const formatRupiah = (value: number) =>
new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 2,
}).format(value);
const calcProgress = (items: any[]) => {
const anggaran = items.reduce((sum, i) => sum + i.anggaran, 0);
const realisasi = items.reduce((sum, i) => sum + i.realisasi, 0);
const persen = anggaran ? (realisasi / anggaran) * 100 : 0;
return { anggaran, realisasi, persen };
};
const pendapatan = calcProgress(data.pendapatan);
const belanja = calcProgress(data.belanja);
const pembiayaan = calcProgress(data.pembiayaan);
const renderProgress = (label: string, dataset: any) => (
<Box>
<Text fw={600}>{label}</Text>
<Text fw={700} mb="xs">
{formatRupiah(dataset.realisasi)} | {formatRupiah(dataset.anggaran)}
</Text>
<Progress
value={dataset.persen}
size="xl"
radius="xl"
striped={false}
styles={{
root: { backgroundColor: '#d7e3f1' },
section: {
backgroundColor: colors['blue-button'],
position: 'relative',
'&::after': {
content: `'${dataset.persen.toFixed(2)}%'`,
position: 'absolute',
right: 10,
top: '50%',
transform: 'translateY(-50%)',
color: 'white',
fontWeight: 700,
fontSize: '0.8rem',
}
},
}}
/>
</Box>
);
return (
<Paper mx={{ base: 'md', md: 100 }} p="xl" radius="md" shadow="sm" withBorder bg={colors['white-1']}>
<Stack gap="lg">
<Title order={4} c={colors['blue-button']}>
Grafik APB Desa Tahun {data.tahun}
</Title>
{renderProgress('Pendapatan Desa', pendapatan)}
{renderProgress('Belanja Desa', belanja)}
{renderProgress('Pembiayaan Desa', pembiayaan)}
</Stack>
</Paper>
);
}
export default Page

View File

@@ -209,50 +209,57 @@ function Page() {
) : null} ) : null}
{/* Chart */} {/* Chart */}
<Box mt={30} style={{ width: '100%', minHeight: 400 }}> <Box mt={30} style={{ width: '100%' }}>
<Paper bg={colors['white-1']} pt={50} pb={170} px={90} mb={"xl"} radius="md" withBorder> <Paper
<Stack gap={"xs"}> bg={colors['white-1']}
<Title ta={"center"} pb={10} order={2}> py={90}
Grafik APBDes px={{ base: 12, sm: 24, md: 50 }}
</Title> radius="md"
withBorder
>
<Stack gap="lg">
<Title ta="center" order={2}>Grafik SDGs Desa</Title>
{mounted && chartData.length > 0 ? ( {mounted && chartData.length > 0 ? (
<Box style={{ padding: '0 30px' }}> {/* Tambahkan padding horizontal agar label tidak keluar */} <Box
<BarChart style={{
h={500} width: "100%",
data={chartData} overflowX: "auto",
dataKey="name" paddingBottom: 185,
type="stacked" }}
withBarValueLabel >
series={[ <Box style={{ minWidth: 900 }}> {/* ⭐ batas minimum biar bisa scroll */}
{ <BarChart
name: 'jumlah', h={350}
color: colors['blue-button'], data={chartData}
// label: 'Jumlah', → HAPUS INI AGAR LEGEND TIDAK MUNCUL dataKey="name"
}, type="stacked"
]} withBarValueLabel
withTooltip series={[
tooltipProps={{ {
labelFormatter: (value) => value, name: 'jumlah',
formatter: (value) => `${value}%`, color: colors['blue-button'],
}} },
xAxisProps={{ ]}
angle: -45, withTooltip
textAnchor: 'end', tooltipProps={{
interval: 0, labelFormatter: (value) => value,
fontSize: 12, formatter: (value) => `${value}%`,
dy: 10, }}
}} xAxisProps={{
yAxisProps={{ angle: -45,
domain: [0, 100], textAnchor: 'end',
tickCount: 6, interval: 0,
}} fontSize: 12,
style={{ dy: 10,
overflowX: 'visible', }}
paddingBottom: 40, // Tambahkan ruang di bawah untuk label yAxisProps={{
}} domain: [0, 100],
// Hilangkan legend secara eksplisit tickCount: 6,
withLegend={false} // ⭐ Ini yang menghilangkan kotak biru + teks "Jumlah" }}
/> withLegend={false}
/>
</Box>
</Box> </Box>
) : ( ) : (
<Text c="dimmed">Belum ada data untuk ditampilkan dalam grafik</Text> <Text c="dimmed">Belum ada data untuk ditampilkan dalam grafik</Text>
@@ -260,6 +267,7 @@ function Page() {
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>
</Box> </Box>
</Box> </Box>
</Stack> </Stack>

View File

@@ -125,7 +125,6 @@ export default function ModernNewsNotification({
position: "fixed", position: "fixed",
bottom: "24px", bottom: "24px",
right: "24px", right: "24px",
zIndex: 1000,
}} }}
> >
<ActionIcon <ActionIcon
@@ -147,20 +146,20 @@ export default function ModernNewsNotification({
<IconBell size={28} /> <IconBell size={28} />
{hasNewNotifications && news.length > 0 && ( {hasNewNotifications && news.length > 0 && (
<Badge <Badge
size="sm" size="sm"
variant="filled" variant="filled"
color="red" color="red"
style={{ style={{
position: "absolute", position: "absolute",
top: "-5px", top: "6px",
right: "-5px", right: "6px",
minWidth: "22px", minWidth: "22px",
height: "22px", height: "22px",
padding: "0 6px", padding: "0 6px",
}} }}
> >
{news.length} {news.length}
</Badge> </Badge>
)} )}
</ActionIcon> </ActionIcon>
</Box> </Box>
@@ -178,7 +177,6 @@ export default function ModernNewsNotification({
right: "24px", right: "24px",
width: "380px", width: "380px",
maxHeight: "500px", maxHeight: "500px",
zIndex: 999,
boxShadow: "0 8px 32px rgba(0,0,0,0.12)", boxShadow: "0 8px 32px rgba(0,0,0,0.12)",
borderRadius: "16px", borderRadius: "16px",
overflow: "hidden", overflow: "hidden",
@@ -274,7 +272,6 @@ export default function ModernNewsNotification({
bottom: "100px", bottom: "100px",
right: "24px", right: "24px",
width: "380px", width: "380px",
zIndex: 1001,
boxShadow: "0 8px 32px rgba(0,0,0,0.15)", boxShadow: "0 8px 32px rgba(0,0,0,0.15)",
borderRadius: "12px", borderRadius: "12px",
overflow: "hidden", overflow: "hidden",

View File

@@ -1,8 +1,7 @@
import { Box, Card, Image, Stack, Text, Tooltip } from '@mantine/core';
import { IconUserCircle } from '@tabler/icons-react';
import React from 'react';
import { Prisma } from '@prisma/client';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Card, Image, Stack, Text } from '@mantine/core';
import { Prisma } from '@prisma/client';
import { IconUserCircle } from '@tabler/icons-react';
interface ProfileViewProps { interface ProfileViewProps {
data: Prisma.PejabatDesaGetPayload<{ include: { image: true } }> | null; data: Prisma.PejabatDesaGetPayload<{ include: { image: true } }> | null;
@@ -95,7 +94,6 @@ export default function ProfileView({ data }: ProfileViewProps) {
backgroundColor: 'rgba(255, 255, 255, 0.95)', backgroundColor: 'rgba(255, 255, 255, 0.95)',
}} }}
> >
<Tooltip label="Jabatan Resmi" withArrow>
<Text <Text
fz={{ base: 'xs', sm: 'sm' }} fz={{ base: 'xs', sm: 'sm' }}
c="dimmed" c="dimmed"
@@ -103,7 +101,6 @@ export default function ProfileView({ data }: ProfileViewProps) {
> >
{data.position || 'Tidak ada jabatan'} {data.position || 'Tidak ada jabatan'}
</Text> </Text>
</Tooltip>
<Text <Text
c={colors['blue-button']} c={colors['blue-button']}
fw={700} fw={700}