Fix SDGs Desa Barchart sudah responsive, tabel dan bar progress di menu apbdes sudah sesuai dengan data
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
|
||||||
const err = `[${cek.error.issues
|
|
||||||
.map((v) => `${v.path.join(".")}`)
|
|
||||||
.join("\n")}] required`;
|
|
||||||
return toast.error(err);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
apbdes.create.loading = true;
|
|
||||||
const res = await ApiFetch.api.landingpage.apbdes["create"].post({
|
|
||||||
...apbdes.create.form,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 200) {
|
// ✅ Formula yang benar
|
||||||
|
const selisih = anggaran - realisasi; // positif = sisa anggaran, negatif = over budget
|
||||||
|
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran
|
||||||
|
|
||||||
|
return {
|
||||||
|
kode: item.kode || "",
|
||||||
|
uraian: item.uraian || "",
|
||||||
|
anggaran,
|
||||||
|
realisasi,
|
||||||
|
selisih,
|
||||||
|
persentase,
|
||||||
|
level: item.level || 1,
|
||||||
|
tipe: item.tipe || "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,192 +172,200 @@ 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({
|
|
||||||
query
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 200 && res.data?.success) {
|
if (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 };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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) {
|
console.log("🔍 DEBUG INFO:");
|
||||||
throw new Error("ID tidak ditemukan dalam parameter");
|
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 }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await prisma.aPBDes.findUnique({
|
if (pathSegments[0] !== 'api' ||
|
||||||
where: { id },
|
pathSegments[1] !== 'landingpage' ||
|
||||||
include: {
|
pathSegments[2] !== 'apbdes') {
|
||||||
image: true,
|
context.set.status = 400;
|
||||||
file: true,
|
return {
|
||||||
},
|
success: false,
|
||||||
});
|
message: "Route tidak valid",
|
||||||
|
debug: { pathSegments }
|
||||||
if (!data) {
|
};
|
||||||
throw new Error("APB Des tidak ditemukan");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const id = pathSegments[3]; // ✅ ID ada di index ke-3
|
||||||
success: true,
|
|
||||||
message: "Data APB Des ditemukan",
|
if (!id || id.trim() === '') {
|
||||||
data,
|
context.set.status = 400;
|
||||||
};
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "ID APBDes diperlukan",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await prisma.aPBDes.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { kode: 'asc' }
|
||||||
|
},
|
||||||
|
image: true,
|
||||||
|
file: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result || !result.isActive) {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
.delete("/del/:id", apbdesDelete);
|
.delete("/del/:id", apbdesDelete, {
|
||||||
|
params: t.Object({ id: t.String() }),
|
||||||
|
});
|
||||||
|
|
||||||
export default APBDes;
|
export default APBDes;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
208
src/app/darmasaba/(tambahan)/apbdes/lib/apbDesaProgress.tsx
Normal file
208
src/app/darmasaba/(tambahan)/apbdes/lib/apbDesaProgress.tsx
Normal 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;
|
||||||
160
src/app/darmasaba/(tambahan)/apbdes/lib/apbDesaTable.tsx
Normal file
160
src/app/darmasaba/(tambahan)/apbdes/lib/apbDesaTable.tsx
Normal 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;
|
||||||
@@ -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() {
|
|
||||||
// 🔹 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
|
export default Page
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user