From 0feeb4de93c2df7cb27f278c0c5921ba6ad8e6db Mon Sep 17 00:00:00 2001 From: nico Date: Tue, 18 Nov 2025 11:56:16 +0800 Subject: [PATCH] Fix SDGs Desa Barchart sudah responsive, tabel dan bar progress di menu apbdes sudah sesuai dengan data --- prisma/schema.prisma | 79 ++- .../(dashboard)/_state/landing-page/apbdes.ts | 422 +++++++----- .../_state/pendidikan/beasiswa-desa.ts | 67 +- .../landing-page/apbdes/[id]/edit/page.tsx | 605 +++++++++++------- .../landing-page/apbdes/[id]/page.tsx | 163 +++-- .../landing-page/apbdes/create/page.tsx | 534 ++++++++++------ .../(dashboard)/landing-page/apbdes/page.tsx | 127 ++-- .../_lib/landing_page/apbdes/create.ts | 141 +++- .../_lib/landing_page/apbdes/del.ts | 61 +- .../_lib/landing_page/apbdes/findMany.ts | 61 +- .../_lib/landing_page/apbdes/findUnique.ts | 92 ++- .../_lib/landing_page/apbdes/index.ts | 37 +- .../landing_page/apbdes/lib/getParentsID.tsx | 54 ++ .../_lib/landing_page/apbdes/updt.ts | 114 +++- .../beasiswa-pendaftar/create.ts | 34 +- .../beasiswa-desa/beasiswa-pendaftar/index.ts | 34 +- .../beasiswa-desa/beasiswa-pendaftar/updt.ts | 41 +- .../(pages)/pendidikan/beasiswa-desa/page.tsx | 97 ++- .../pelajari-lebih-lanjut/page.tsx | 96 ++- .../(tambahan)/apbdes/lib/apbDesaProgress.tsx | 208 ++++++ .../(tambahan)/apbdes/lib/apbDesaTable.tsx | 160 +++++ src/app/darmasaba/(tambahan)/apbdes/page.tsx | 200 +----- .../darmasaba/(tambahan)/sdgs-desa/page.tsx | 94 +-- .../_com/ModernNeewsNotification.tsx | 31 +- .../main-page/landing-page/ProfileView.tsx | 9 +- 25 files changed, 2292 insertions(+), 1269 deletions(-) create mode 100644 src/app/api/[[...slugs]]/_lib/landing_page/apbdes/lib/getParentsID.tsx create mode 100644 src/app/darmasaba/(tambahan)/apbdes/lib/apbDesaProgress.tsx create mode 100644 src/app/darmasaba/(tambahan)/apbdes/lib/apbDesaTable.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a52a0cb1..7aa39b9a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -183,17 +183,45 @@ model SdgsDesa { //========================================= APBDes ========================================= // model APBDes { - id String @id @default(cuid()) - name String - jumlah String - image FileStorage? @relation("APBDesImage", fields: [imageId], references: [id]) - imageId String? - file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id]) - fileId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime @default(now()) - isActive Boolean @default(true) + id String @id @default(cuid()) + tahun Int? + name String? // misalnya: "APBDes Tahun 2025" + deskripsi String? + jumlah String? // total keseluruhan (opsional, bisa juga dihitung dari items) + items APBDesItem[] + image FileStorage? @relation("APBDesImage", fields: [imageId], references: [id]) + imageId String? + file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id]) + fileId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? // 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 ========================================= // @@ -1942,23 +1970,28 @@ model KeunggulanProgram { } model BeasiswaPendaftar { - id String @id @default(cuid()) + id String @id @default(cuid()) namaLengkap String - nik String @unique + nis String? + kelas String? + jenisKelamin JenisKelamin + alamatDomisili String? tempatLahir String tanggalLahir DateTime - jenisKelamin JenisKelamin - kewarganegaraan String - agama Agama - alamatKTP String - alamatDomisili String? + namaOrtu String? + nik String @unique + pekerjaanOrtu String? + penghasilan String? noHp String - email String @unique - statusPernikahan StatusPernikahan + kewarganegaraan String? + agama Agama? + alamatKTP String? + email String? @unique + statusPernikahan StatusPernikahan? ukuranBaju UkuranBaju? - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } enum JenisKelamin { diff --git a/src/app/admin/(dashboard)/_state/landing-page/apbdes.ts b/src/app/admin/(dashboard)/_state/landing-page/apbdes.ts index c780b1a9..af8388f4 100644 --- a/src/app/admin/(dashboard)/_state/landing-page/apbdes.ts +++ b/src/app/admin/(dashboard)/_state/landing-page/apbdes.ts @@ -5,58 +5,166 @@ import { toast } from "react-toastify"; import { proxy } from "valtio"; import { z } from "zod"; -const templateapbDesaForm = z.object({ - name: z.string().min(1, "Judul minimal 1 karakter"), - jumlah: z.string().min(1, "Deskripsi minimal 1 karakter"), - imageId: z.string().min(1, "File minimal 1"), - fileId: z.string().min(1, "File minimal 1"), +// --- Zod Schema --- +const ApbdesItemSchema = z.object({ + kode: z.string().min(1), + uraian: z.string().min(1), + anggaran: z.number().min(0), + realisasi: z.number().min(0), + selisih: z.number(), + persentase: z.number().min(0).max(1000), // allow >100% if overbudget + level: z.number().int().min(1).max(3), + tipe: z.string().min(1), // "pendapatan" | "belanja" }); -const defaultapbdesForm = { - name: "", - jumlah: "", +const ApbdesFormSchema = z.object({ + tahun: z.number().int().min(2000, "Tahun tidak valid"), + imageId: z.string().min(1, "Gambar wajib diunggah"), + fileId: z.string().min(1, "File wajib diunggah"), + items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"), +}); + +// --- Default Form --- +const defaultApbdesForm = { + tahun: new Date().getFullYear(), imageId: "", fileId: "", + items: [] as z.infer[], }; -const apbdes = proxy({ - create: { - form: { ...defaultapbdesForm }, - loading: false, - async create() { - const cek = templateapbDesaForm.safeParse(apbdes.create.form); - if (!cek.success) { - const err = `[${cek.error.issues - .map((v) => `${v.path.join(".")}`) - .join("\n")}] required`; - return toast.error(err); - } - try { - apbdes.create.loading = true; - const res = await ApiFetch.api.landingpage.apbdes["create"].post({ - ...apbdes.create.form, - }); +// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) --- +// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) --- +function normalizeItem(item: Partial>): z.infer { + const anggaran = item.anggaran ?? 0; + const realisasi = item.realisasi ?? 0; + + // ✅ Formula yang benar + const selisih = anggaran - realisasi; // positif = sisa anggaran, negatif = over budget + const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran - if (res.status === 200) { + return { + kode: item.kode || "", + uraian: item.uraian || "", + anggaran, + realisasi, + selisih, + persentase, + level: item.level || 1, + tipe: item.tipe || "pendapatan", + }; +} + +// --- State Utama --- +const apbdes = proxy({ + // create: { + // form: { ...defaultApbdesForm }, + // loading: false, + + // addItem(item: Partial>) { + // const normalized = normalizeItem(item); + // this.form.items.push(normalized); + // }, + + // removeItem(index: number) { + // this.form.items.splice(index, 1); + // }, + + // updateItem(index: number, updates: Partial>) { + // 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>) { + const normalized = normalizeItem(item); + this.form.items.push(normalized); + }, + + removeItem(index: number) { + this.form.items.splice(index, 1); + }, + + updateItem(index: number, updates: Partial>) { + const current = this.form.items[index]; + if (current) { + const updated = normalizeItem({ ...current, ...updates }); + this.form.items[index] = updated; + } + }, + + reset() { + this.form = { ...defaultApbdesForm }; + }, + + async create() { + const parsed = ApbdesFormSchema.safeParse(this.form); + if (!parsed.success) { + const errors = parsed.error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`); + toast.error(`Validasi gagal:\n${errors.join("\n")}`); + return; + } + + try { + this.loading = true; + const res = await ApiFetch.api.landingpage.apbdes["create"].post(parsed.data); + + if (res.data?.success) { + toast.success("APBDes berhasil dibuat"); apbdes.findMany.load(); - return toast.success("Data berhasil ditambahkan"); + this.reset(); + } else { + toast.error(res.data?.message || "Gagal membuat APBDes"); } - return toast.error("Gagal menambahkan data"); - } catch (error) { - console.log(error); - toast.error("Gagal menambahkan data"); + } catch (error: any) { + console.error("Create APBDes error:", error); + toast.error(error?.message || "Terjadi kesalahan saat membuat APBDes"); } finally { - apbdes.create.loading = false; + this.loading = false; } }, }, + findMany: { data: null as | Prisma.APBDesGetPayload<{ - include: { - image: true; - file: true; - }; + include: { image: true; file: true; items: true }; }>[] | null, page: 1, @@ -64,194 +172,202 @@ const apbdes = proxy({ total: 0, loading: false, search: "", - load: async (page = 1, limit = 10, search = "") => { // Change to arrow function - apbdes.findMany.loading = true; // Use the full path to access the property + + load: async (page = 1, limit = 10, search = "") => { + apbdes.findMany.loading = true; apbdes.findMany.page = page; apbdes.findMany.search = search; + try { - const query: any = { page, limit }; + const query: Record = { page: String(page), limit: String(limit) }; if (search) query.search = search; - - const res = await ApiFetch.api.landingpage.apbdes[ - "findMany" - ].get({ - query - }); - - if (res.status === 200 && res.data?.success) { + + const res = await ApiFetch.api.landingpage.apbdes["findMany"].get({ query }); + + if (res.data?.success) { apbdes.findMany.data = res.data.data || []; - apbdes.findMany.total = res.data.total || 0; - apbdes.findMany.totalPages = res.data.totalPages || 1; + apbdes.findMany.total = res.data.meta?.total || 0; + apbdes.findMany.totalPages = res.data.meta?.totalPages || 1; } else { - console.error("Failed to load pegawai:", res.data?.message); apbdes.findMany.data = []; apbdes.findMany.total = 0; apbdes.findMany.totalPages = 1; + toast.error(res.data?.message || "Gagal memuat data"); } } catch (error) { - console.error("Error loading pegawai:", error); + console.error("FindMany error:", error); apbdes.findMany.data = []; apbdes.findMany.total = 0; apbdes.findMany.totalPages = 1; + toast.error("Gagal memuat daftar APBDes"); } finally { apbdes.findMany.loading = false; } }, }, + findUnique: { - data: null as Prisma.APBDesGetPayload<{ - include: { - image: true; - file: true; - }; - }> | null, + data: null as + | Prisma.APBDesGetPayload<{ + include: { image: true; file: true; items: true }; + }> + | null, + loading: false, + error: null as string | null, + async load(id: string) { + if (!id || id.trim() === '') { + this.data = null; + this.error = "ID tidak valid"; + return; + } + + this.loading = true; + this.error = null; + try { - const res = await fetch(`/api/landingpage/apbdes/${id}`); - if (res.ok) { - const data = await res.json(); - apbdes.findUnique.data = data.data ?? null; + // Pastikan URL-nya benar + const url = `/api/landingpage/apbdes/${id}`; + console.log("🌐 Fetching:", url); + + // Gunakan fetch biasa atau ApiFetch dengan cara yang benar + const response = await fetch(url); + const res = await response.json(); + + console.log("📦 Response:", res); + + if (res.success && res.data) { + this.data = res.data; } else { - console.error("Failed to fetch data", res.status, res.statusText); - apbdes.findUnique.data = null; + this.data = null; + this.error = res.message || "Gagal memuat detail APBDes"; + toast.error(this.error); } } catch (error) { - console.error("Error fetching data:", error); - apbdes.findUnique.data = null; + console.error("❌ FindUnique error:", error); + this.data = null; + this.error = "Gagal memuat detail APBDes"; + toast.error(this.error); + } finally { + this.loading = false; } - }, + } }, + delete: { loading: false, async byId(id: string) { if (!id) return toast.warn("ID tidak valid"); try { - apbdes.delete.loading = true; + this.loading = true; + const res = await (ApiFetch.api.landingpage.apbdes as any)["del"][id].delete(); - const response = await fetch(`/api/landingpage/apbdes/del/${id}`, { - method: "DELETE", - headers: { - "Content-Type": "application/json", - }, - }); - - const result = await response.json(); - - if (response.ok && result?.success) { - toast.success(result.message || "apbdes berhasil dihapus"); - await apbdes.findMany.load(); // refresh list + if (res.data?.success) { + toast.success("APBDes berhasil dihapus"); + apbdes.findMany.load(); } else { - toast.error(result?.message || "Gagal menghapus apbdes"); + toast.error(res.data?.message || "Gagal menghapus APBDes"); } - } catch (error) { - console.error("Gagal delete:", error); - toast.error("Terjadi kesalahan saat menghapus apbdes"); + } catch (error: any) { + console.error("Delete error:", error); + toast.error(error?.message || "Terjadi kesalahan saat menghapus"); } finally { - apbdes.delete.loading = false; + this.loading = false; } }, }, + edit: { id: "", - form: { ...defaultapbdesForm }, + form: { ...defaultApbdesForm }, loading: false, async load(id: string) { - if (!id) { - toast.warn("ID tidak valid"); - return null; - } + if (!id) return toast.warn("ID tidak valid"); try { - apbdes.edit.loading = true; + this.loading = true; + const res = await (ApiFetch.api.landingpage.apbdes as any)[id].get(); - const response = await fetch(`/api/landingpage/apbdes/${id}`, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const result = await response.json(); - if (result?.success) { - const data = result.data; + if (res.data?.success) { + const data = res.data.data; this.id = data.id; this.form = { - name: data.name, - jumlah: data.jumlah, - imageId: data.imageId, - fileId: data.fileId, + tahun: data.tahun || new Date().getFullYear(), + imageId: data.imageId || "", + fileId: data.fileId || "", + items: (data.items || []).map((item: any) => ({ + kode: item.kode, + uraian: item.uraian, + anggaran: item.anggaran, + realisasi: item.realisasi, + selisih: item.selisih, + persentase: item.persentase, + level: item.level, + tipe: item.tipe, + })), }; return data; } else { - throw new Error(result?.message || "Gagal memuat data"); + throw new Error(res.data?.message || "Gagal memuat data"); } - } catch (error) { - console.error("Error loading apbdes:", error); - toast.error( - error instanceof Error ? error.message : "Gagal memuat data" - ); - return null; + } catch (error: any) { + console.error("Edit load error:", error); + toast.error(error.message || "Gagal memuat data untuk diedit"); } finally { - apbdes.edit.loading = false; + this.loading = false; } }, async update() { - const cek = templateapbDesaForm.safeParse(apbdes.edit.form); - if (!cek.success) { - const err = `[${cek.error.issues - .map((v) => `${v.path.join(".")}`) - .join("\n")}] required`; - return toast.error(err); + const parsed = ApbdesFormSchema.safeParse(this.form); + if (!parsed.success) { + const errors = parsed.error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`); + toast.error(`Validasi gagal:\n${errors.join("\n")}`); + return false; } try { - apbdes.edit.loading = true; - const response = await fetch(`/api/landingpage/apbdes/${this.id}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: this.form.name, - jumlah: this.form.jumlah, - imageId: this.form.imageId, - fileId: this.form.fileId, - }), - }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - errorData.message || `HTTP error! status: ${response.status}` - ); - } - const result = await response.json(); - if (result.success) { - toast.success("Berhasil update apbdes"); - await apbdes.findMany.load(); // refresh list + this.loading = true; + // Include the ID in the request body + const requestData = { + ...parsed.data, + id: this.id, // Add the ID to the request body + }; + + const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData); + + if (res.data?.success) { + toast.success("APBDes berhasil diperbarui"); + apbdes.findMany.load(); return true; } else { - throw new Error(result.message || "Gagal mengupdate apbdes"); + throw new Error(res.data?.message || "Gagal memperbarui APBDes"); } - } catch (error) { - console.error("Error updating apbdes:", error); - toast.error( - error instanceof Error ? error.message : "Gagal mengupdate apbdes" - ); + } catch (error: any) { + console.error("Update error:", error); + toast.error(error.message || "Gagal memperbarui APBDes"); return false; } finally { - apbdes.edit.loading = false; + this.loading = false; } }, + + addItem(item: Partial>) { + const normalized = normalizeItem(item); + this.form.items.push(normalized); + }, + + removeItem(index: number) { + this.form.items.splice(index, 1); + }, + reset() { - apbdes.edit.id = ""; - apbdes.edit.form = { ...defaultapbdesForm }; + this.id = ""; + this.form = { ...defaultApbdesForm }; }, }, }); -export default apbdes; +export default apbdes; \ No newline at end of file diff --git a/src/app/admin/(dashboard)/_state/pendidikan/beasiswa-desa.ts b/src/app/admin/(dashboard)/_state/pendidikan/beasiswa-desa.ts index 7337d64c..73d1dc94 100644 --- a/src/app/admin/(dashboard)/_state/pendidikan/beasiswa-desa.ts +++ b/src/app/admin/(dashboard)/_state/pendidikan/beasiswa-desa.ts @@ -9,34 +9,32 @@ import { z } from "zod"; const templateBeasiswaPendaftar = z.object({ namaLengkap: z.string().min(1, "Nama harus diisi"), - nik: z.string().min(1, "NIK harus diisi"), + nis: z.string().min(1, "NIS harus diisi"), + kelas: z.string().min(1, "Kelas harus diisi"), + jenisKelamin: z.string().min(1, "Jenis kelamin harus diisi"), + alamatDomisili: z.string().min(1, "Alamat domisili harus diisi"), tempatLahir: z.string().min(1, "Tempat lahir harus diisi"), tanggalLahir: z.string().min(1, "Tanggal lahir harus diisi"), - jenisKelamin: z.string().min(1, "Jenis kelamin harus diisi"), - kewarganegaraan: z.string().min(1, "Kewarganegaraan harus diisi"), - agama: z.string().min(1, "Agama harus diisi"), - alamatKTP: z.string().min(1, "Alamat KTP harus diisi"), - alamatDomisili: z.string().min(1, "Alamat domisili harus diisi"), + namaOrtu: z.string().min(1, "Nama ortu harus diisi"), + nik: z.string().min(1, "NIK harus diisi"), + pekerjaanOrtu: z.string().min(1, "Pekerjaan ortu harus diisi"), + penghasilan: z.string().min(1, "Penghasilan ortu harus diisi"), noHp: z.string().min(1, "No HP harus diisi"), - email: z.string().min(1, "Email harus diisi"), - statusPernikahan: z.string().min(1, "Status pernikahan harus diisi"), - ukuranBaju: z.string().min(1, "Ukuran baju harus diisi"), }); const defaultBeasiswaPendaftar = { namaLengkap: "", - nik: "", + nis: "", + kelas: "", + jenisKelamin: "", + alamatDomisili: "", tempatLahir: "", tanggalLahir: "", - jenisKelamin: "", - kewarganegaraan: "", - agama: "", - alamatKTP: "", - alamatDomisili: "", + namaOrtu: "", + nik: "", + pekerjaanOrtu: "", + penghasilan: "", noHp: "", - email: "", - statusPernikahan: "", - ukuranBaju: "", }; const beasiswaPendaftar = proxy({ @@ -200,18 +198,17 @@ const beasiswaPendaftar = proxy({ this.id = data.id; this.form = { namaLengkap: data.namaLengkap, - nik: data.nik, + nis: data.nis, + kelas: data.kelas, + jenisKelamin: data.jenisKelamin, + alamatDomisili: data.alamatDomisili, tempatLahir: data.tempatLahir, tanggalLahir: data.tanggalLahir, - jenisKelamin: data.jenisKelamin, - kewarganegaraan: data.kewarganegaraan, - agama: data.agama, - alamatKTP: data.alamatKTP, - alamatDomisili: data.alamatDomisili, + namaOrtu: data.namaOrtu, + nik: data.nik, + pekerjaanOrtu: data.pekerjaanOrtu, + penghasilan: data.penghasilan, noHp: data.noHp, - email: data.email, - statusPernikahan: data.statusPernikahan, - ukuranBaju: data.ukuranBaju, }; return data; // Return the loaded data } else { @@ -249,17 +246,17 @@ const beasiswaPendaftar = proxy({ }, body: JSON.stringify({ namaLengkap: this.form.namaLengkap, - nik: this.form.nik, - tanggalLahir: this.form.tanggalLahir, + nis: this.form.nis, + kelas: this.form.kelas, jenisKelamin: this.form.jenisKelamin, - kewarganegaraan: this.form.kewarganegaraan, - agama: this.form.agama, - alamatKTP: this.form.alamatKTP, alamatDomisili: this.form.alamatDomisili, + tempatLahir: this.form.tempatLahir, + tanggalLahir: this.form.tanggalLahir, + namaOrtu: this.form.namaOrtu, + nik: this.form.nik, + pekerjaanOrtu: this.form.pekerjaanOrtu, + penghasilan: this.form.penghasilan, noHp: this.form.noHp, - email: this.form.email, - statusPernikahan: this.form.statusPernikahan, - ukuranBaju: this.form.ukuranBaju, }), } ); diff --git a/src/app/admin/(dashboard)/landing-page/apbdes/[id]/edit/page.tsx b/src/app/admin/(dashboard)/landing-page/apbdes/[id]/edit/page.tsx index 10a262ce..e0aaa34e 100644 --- a/src/app/admin/(dashboard)/landing-page/apbdes/[id]/edit/page.tsx +++ b/src/app/admin/(dashboard)/landing-page/apbdes/[id]/edit/page.tsx @@ -1,94 +1,102 @@ /* eslint-disable react-hooks/exhaustive-deps */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + 'use client'; import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'; import colors from '@/con/colors'; import ApiFetch from '@/lib/api-fetch'; import { + ActionIcon, + Badge, Box, Button, Group, Image, + Loader, + NumberInput, Paper, + Select, Stack, + Table, Text, TextInput, Title, - Loader, - ActionIcon } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; -import { IconArrowBack, IconFile, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; +import { + IconArrowBack, + IconFile, + IconPhoto, + IconPlus, + IconTrash, + IconX +} from '@tabler/icons-react'; import { useParams, useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; import { toast } from 'react-toastify'; import { useProxy } from 'valtio/utils'; +// Tipe untuk form item +type ItemForm = { + kode: string; + uraian: string; + anggaran: number; + realisasi: number; + level: number; + tipe: 'pendapatan' | 'belanja' | 'pembiayaan'; +}; + function EditAPBDes() { const apbdesState = useProxy(apbdes); const router = useRouter(); const params = useParams(); - const [formData, setFormData] = useState({ - name: '', - jumlah: '', - imageId: '', - fileId: '' - }); - const [isSubmitting, setIsSubmitting] = useState(false); - - const [originalData, setOriginalData] = useState({ - name: "", - jumlah: "", - imageId: "", - fileId: "", - imageUrl: "", - docUrl: "", - }); - const [previewImage, setPreviewImage] = useState(null); const [previewDoc, setPreviewDoc] = useState(null); const [imageFile, setImageFile] = useState(null); const [docFile, setDocFile] = useState(null); - // Load data on mount + // Form input untuk item baru + const [newItem, setNewItem] = useState({ + kode: '', + uraian: '', + anggaran: 0, + realisasi: 0, + level: 1, + tipe: 'pendapatan', + }); + + // Type for the API response + interface APBDesResponse { + id: string; + image?: { + link: string; + id: string; + }; + file?: { + link: string; + id: string; + }; + // Add other properties as needed + } + + // Load data saat pertama kali useEffect(() => { - const loadData = async () => { - const id = params?.id as string; - if (!id) return; - - try { - const data = await apbdesState.edit.load(id); + const id = params?.id as string; + if (id) { + apbdesState.edit.load(id).then((response) => { + const data = response as unknown as APBDesResponse; if (data) { - const newForm = { - name: data.name || "", - jumlah: data.jumlah || "", - imageId: data.imageId || "", - fileId: data.fileId || "", - }; - setFormData(newForm); - - // simpan juga versi original - setOriginalData({ - ...newForm, - imageUrl: data.image?.link || "", - docUrl: data.file?.link || "", - }); - + // ✅ Ambil link langsung dari response setPreviewImage(data.image?.link || null); setPreviewDoc(data.file?.link || null); } - } catch (err) { - console.error(err); - toast.error('Gagal memuat data APBDes'); - } - }; - - loadData(); + }); + } }, [params?.id]); - // Generic Dropzone handler const handleDrop = (fileType: 'image' | 'doc') => (files: File[]) => { const file = files[0]; if (!file) return; @@ -102,51 +110,95 @@ function EditAPBDes() { } }; + const handleAddItem = () => { + const { kode, uraian, anggaran, realisasi, level, tipe } = newItem; + if (!kode || !uraian) { + return toast.warn('Kode dan uraian wajib diisi'); + } + + const selisih = realisasi - anggaran; + const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; + + apbdesState.edit.addItem({ + kode, + uraian, + anggaran, + realisasi, + selisih, + persentase, + level, + tipe, + }); + + setNewItem({ + kode: '', + uraian: '', + anggaran: 0, + realisasi: 0, + level: 1, + tipe: 'pendapatan', + }); + }; + + const handleRemoveItem = (index: number) => { + apbdesState.edit.removeItem(index); + }; + const handleSubmit = async () => { + if (apbdesState.edit.form.items.length === 0) { + return toast.warn('Minimal harus ada 1 item APBDes'); + } + try { setIsSubmitting(true); - // Update global state with local form data first - apbdesState.edit.form = { ...apbdesState.edit.form, ...formData }; - // Helper function for uploading file - const uploadFile = async (file: File | null) => { - if (!file) return null; - const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name }); - const uploaded = res.data?.data; - if (!uploaded?.id) throw new Error('Upload gagal'); - return uploaded.id; - }; + // Upload file baru jika ada + if (imageFile) { + const res = await ApiFetch.api.fileStorage.create.post({ + file: imageFile, + name: imageFile.name, + }); + const imageId = res.data?.data?.id; + if (imageId) apbdesState.edit.form.imageId = imageId; + } - // Upload files if selected - const uploadedImageId = await uploadFile(imageFile); - const uploadedDocId = await uploadFile(docFile); + if (docFile) { + const res = await ApiFetch.api.fileStorage.create.post({ + file: docFile, + name: docFile.name, + }); + const fileId = res.data?.data?.id; + if (fileId) apbdesState.edit.form.fileId = fileId; + } - if (uploadedImageId) apbdesState.edit.form.imageId = uploadedImageId; - if (uploadedDocId) apbdesState.edit.form.fileId = uploadedDocId; - - await apbdesState.edit.update(); - toast.success('APBDes berhasil diperbarui!'); - router.push('/admin/landing-page/APBDes'); + const success = await apbdesState.edit.update(); + if (success) { + router.push('/admin/landing-page/APBDes'); + } } catch (err) { - console.error(err); - toast.error('Terjadi kesalahan saat memperbarui APBDes'); + console.error('Update error:', err); + toast.error('Gagal memperbarui APBDes'); } finally { setIsSubmitting(false); } }; - const handleResetForm = () => { - setFormData({ - name: originalData.name, - jumlah: originalData.jumlah, - imageId: originalData.imageId, - fileId: originalData.fileId, - }); - setPreviewImage(originalData.imageUrl || null); - setImageFile(null); - setPreviewDoc(originalData.docUrl || null); - setDocFile(null); - toast.info("Form dikembalikan ke data awal"); + const handleReset = () => { + const id = params?.id as string; + if (id) { + apbdesState.edit.load(id); + setImageFile(null); + setDocFile(null); + setNewItem({ + kode: '', + uraian: '', + anggaran: 0, + realisasi: 0, + level: 1, + tipe: 'pendapatan', + }); + toast.info('Form dikembalikan ke data awal'); + } }; return ( @@ -160,163 +212,272 @@ function EditAPBDes() { - + - {/* Controlled Inputs */} - setFormData({ ...formData, name: e.target.value })} + {/* Header Form */} + + (apbdesState.edit.form.tahun = Number(val) || new Date().getFullYear()) + } + min={2000} + max={2100} required /> - setFormData({ ...formData, jumlah: e.target.value })} - required - /> + {/* Gambar & Dokumen */} + + + + Gambar APBDes + + toast.error('File gambar tidak valid')} + maxSize={5 * 1024 ** 2} + accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }} + radius="md" + p="xl" + > + + + + + + + {previewImage ? 'Ganti gambar' : 'Unggah gambar'} + + + + + {previewImage && ( + + Preview + { + setPreviewImage(null); + setImageFile(null); + }} + > + + + + )} + - {/* Image Dropzone */} - - Gambar APBDes - toast.error('File tidak valid, gunakan format gambar')} - maxSize={5 * 1024 ** 2} - accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }} - radius="md" - p="xl" - > - - - - - - Seret gambar atau klik untuk memilih file - Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp - - - - {previewImage && ( - - Preview Gambar + + Dokumen APBDes + + toast.error('File dokumen tidak valid')} + maxSize={10 * 1024 ** 2} + accept={{ + 'application/pdf': ['.pdf'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + }} + radius="md" + p="xl" + > + + + + + + + {previewDoc ? 'Ganti dokumen' : 'Unggah dokumen'} + + + + + {previewDoc && ( + + + { + setPreviewDoc(null); + setDocFile(null); + }} + > + + + + )} + + + + {/* Input Item Baru */} + + + Tambah Item Pendapatan/Belanja + + + + setNewItem({ ...newItem, kode: e.target.value })} + required + /> + setNewItem({ ...newItem, tipe: (val as any) || 'pendapatan' })} /> - - {/* Tombol hapus (pojok kanan atas) */} - { - setPreviewImage(null); - setImageFile(null); - }} - style={{ - boxShadow: '0 2px 6px rgba(0,0,0,0.15)', - }} - > - - - - )} - - - {/* Document Dropzone */} - - Dokumen APBDes - 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" - > - - - - - - Seret dokumen atau klik untuk memilih file - Maksimal 10MB, format PDF/DOC/DOCX/XLS/XLSX - - - {previewDoc && ( - - Dokumen terpilih: {docFile?.name || 'Dokumen'} - - {/* Tombol hapus (pojok kanan atas) */} - { - setPreviewDoc(null); - setDocFile(null); - }} - style={{ - boxShadow: '0 2px 6px rgba(0,0,0,0.15)', - }} - > - - - - )} - + setNewItem({ ...newItem, uraian: e.target.value })} + required + /> + + setNewItem({ ...newItem, anggaran: Number(val) || 0 })} + thousandSeparator + min={0} + /> + setNewItem({ ...newItem, realisasi: Number(val) || 0 })} + thousandSeparator + min={0} + /> + + + + + {/* Tabel Items */} + {apbdesState.edit.form.items.length > 0 && ( + + + Daftar Item ({apbdesState.edit.form.items.length}) + + + + + + + + + + + + + + + {apbdesState.edit.form.items.map((item, idx) => ( + + + + + + + + + + ))} + +
KodeUraianAnggaranRealisasiLevelTipeAksi
+ + {item.kode} + + {item.uraian}{item.anggaran.toLocaleString('id-ID')}{item.realisasi.toLocaleString('id-ID')} + + L{item.level} + + + + {item.tipe} + + + handleRemoveItem(idx)}> + + +
+
+ )} + + {/* Tombol Aksi */} - {/* Tombol Batal */} - - - {/* Tombol Simpan */}
@@ -325,4 +486,4 @@ function EditAPBDes() { ); } -export default EditAPBDes; +export default EditAPBDes; \ No newline at end of file diff --git a/src/app/admin/(dashboard)/landing-page/apbdes/[id]/page.tsx b/src/app/admin/(dashboard)/landing-page/apbdes/[id]/page.tsx index 3b27da39..8ec636f5 100644 --- a/src/app/admin/(dashboard)/landing-page/apbdes/[id]/page.tsx +++ b/src/app/admin/(dashboard)/landing-page/apbdes/[id]/page.tsx @@ -1,36 +1,53 @@ -'use client' +/* eslint-disable react-hooks/exhaustive-deps */ +'use client'; import { useProxy } from 'valtio/utils'; -import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core'; -import { useShallowEffect } from '@mantine/hooks'; +import { + Box, + Button, + Group, + Image, + Paper, + Skeleton, + Stack, + Table, + TableTbody, + TableTd, + TableTh, + TableThead, + TableTr, + Text +} from '@mantine/core'; import { IconArrowBack, IconEdit, IconFile, IconTrash } from '@tabler/icons-react'; import { useParams, useRouter } from 'next/navigation'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import colors from '@/con/colors'; import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus'; import apbdes from '../../../_state/landing-page/apbdes'; + + function DetailAPBDes() { - const apbdesState = useProxy(apbdes) - const [modalHapus, setModalHapus] = useState(false) - const [selectedId, setSelectedId] = useState(null) - const params = useParams() - const router = useRouter() - - useShallowEffect(() => { - apbdesState.findUnique.load(params?.id as string) - }, []) + const apbdesState = useProxy(apbdes); + const [modalHapus, setModalHapus] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const params = useParams(); + const router = useRouter(); + useEffect(() => { + if (!params?.id) return; + apbdesState.findUnique.load(params.id as string); + }, [params?.id]); const handleHapus = () => { if (selectedId) { - apbdesState.delete.byId(selectedId) - setModalHapus(false) - setSelectedId(null) - router.push("/admin/landing-page/APBDes") + apbdesState.delete.byId(selectedId); + setModalHapus(false); + setSelectedId(null); + router.push('/admin/landing-page/APBDes'); } - } + }; if (!apbdesState.findUnique.data) { return ( @@ -42,6 +59,11 @@ function DetailAPBDes() { const data = apbdesState.findUnique.data; + // Helper: indentasi berdasarkan level + const getIndent = (level: number) => ({ + paddingLeft: `${(level - 1) * 20}px`, + }); + return ( + - +
+ + {/* Tabel Items */} + {data.items && data.items.length > 0 ? ( + + + Rincian Pendapatan & Belanja ({data.items.length} item) + + + + + + Uraian + Anggaran (Rp) + Realisasi (Rp) + Selisih (Rp) + Persentase (%) + + + + {[...data.items] // Create a new array before sorting + .sort((a, b) => a.kode.localeCompare(b.kode)) + .map((item) => ( + + + + {item.kode} + {item.uraian} + + + {item.anggaran.toLocaleString('id-ID')} + {item.realisasi.toLocaleString('id-ID')} + + = 0 ? 'green' : 'red'}> + {item.selisih.toLocaleString('id-ID')} + + + + {item.persentase.toFixed(2)}% + + + ))} + +
+
+
+ ) : ( + Belum ada data item + )}
diff --git a/src/app/admin/(dashboard)/landing-page/apbdes/create/page.tsx b/src/app/admin/(dashboard)/landing-page/apbdes/create/page.tsx index cd9b21bc..f5bbaf2a 100644 --- a/src/app/admin/(dashboard)/landing-page/apbdes/create/page.tsx +++ b/src/app/admin/(dashboard)/landing-page/apbdes/create/page.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable react-hooks/exhaustive-deps */ 'use client'; import colors from '@/con/colors'; @@ -13,46 +14,76 @@ import { TextInput, Title, Loader, - ActionIcon + ActionIcon, + NumberInput, + Select, + Table, + Badge, } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; -import { IconArrowBack, IconFile, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; +import { IconArrowBack, IconFile, IconPhoto, IconUpload, IconX, IconPlus, IconTrash } from '@tabler/icons-react'; import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; import { toast } from 'react-toastify'; import { useProxy } from 'valtio/utils'; import apbdes from '../../../_state/landing-page/apbdes'; +// Tipe item untuk form +type ItemForm = { + kode: string; + uraian: string; + anggaran: number; + realisasi: number; + level: number; + tipe: 'pendapatan' | 'belanja' | 'pembiayaan'; +}; function CreateAPBDes() { const router = useRouter(); - const stateAPBDes = useProxy(apbdes) + const stateAPBDes = useProxy(apbdes); const [previewImage, setPreviewImage] = useState(null); const [previewDoc, setPreviewDoc] = useState(null); const [imageFile, setImageFile] = useState(null); const [docFile, setDocFile] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + // Form sementara untuk input item baru + const [newItem, setNewItem] = useState({ + kode: '', + uraian: '', + anggaran: 0, + realisasi: 0, + level: 1, + tipe: 'pendapatan', + }); useEffect(() => { stateAPBDes.findMany.load(); }, []); const resetForm = () => { - stateAPBDes.create.form = { - name: "", - jumlah: "", - imageId: "", - fileId: "", - }; + stateAPBDes.create.reset(); setImageFile(null); setDocFile(null); setPreviewImage(null); + setPreviewDoc(null); + setNewItem({ + kode: '', + uraian: '', + anggaran: 0, + realisasi: 0, + level: 1, + tipe: 'pendapatan', + }); }; + const handleSubmit = async () => { if (!imageFile || !docFile) { return toast.warn("Pilih gambar dan dokumen terlebih dahulu"); } + if (stateAPBDes.create.form.items.length === 0) { + return toast.warn("Minimal tambahkan 1 item APBDes"); + } try { setIsSubmitting(true); @@ -68,6 +99,7 @@ function CreateAPBDes() { return toast.error("Gagal mengupload file"); } + // Update form dengan ID file stateAPBDes.create.form.imageId = imageId; stateAPBDes.create.form.fileId = fileId; @@ -84,6 +116,43 @@ function CreateAPBDes() { } }; + // Tambahkan item ke state + const handleAddItem = () => { + const { kode, uraian, anggaran, realisasi, level, tipe } = newItem; + if (!kode || !uraian) { + return toast.warn("Kode dan uraian wajib diisi"); + } + + const selisih = realisasi - anggaran; + const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; + + stateAPBDes.create.addItem({ + kode, + uraian, + anggaran, + realisasi, + selisih, + persentase, + level, + tipe, + }); + + // Reset form input + setNewItem({ + kode: '', + uraian: '', + anggaran: 0, + realisasi: 0, + level: 1, + tipe: 'pendapatan', + }); + }; + + // Hapus item + const handleRemoveItem = (index: number) => { + stateAPBDes.create.removeItem(index); + }; + return ( @@ -104,199 +173,288 @@ function CreateAPBDes() { style={{ border: '1px solid #e0e0e0' }} > - {/* Gambar APBDes */} - - - Gambar Program Inovasi - - { - const selectedFile = files[0]; - if (selectedFile) { - setImageFile(selectedFile); - setPreviewImage(URL.createObjectURL(selectedFile)); - } - }} - onReject={() => toast.error('File tidak valid, gunakan format gambar')} - maxSize={5 * 1024 ** 2} - accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }} - radius="md" - p="xl" - > - - - - - - - - - - - - - Seret gambar atau klik untuk memilih file - - - Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp - - - - + {/* Gambar & Dokumen (dipendekkan untuk fokus pada items) */} + + {/* Gambar APBDes */} + + + Gambar APBDes + + { + const selectedFile = files[0]; + if (selectedFile) { + setImageFile(selectedFile); + setPreviewImage(URL.createObjectURL(selectedFile)); + } + }} + onReject={() => toast.error('File tidak valid, gunakan format gambar')} + maxSize={5 * 1024 ** 2} + accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }} + radius="md" + p="xl" + > + + + + + + + + + + + + + Seret gambar atau klik untuk memilih + + + + + {previewImage && ( + + Preview Gambar - {/* ✅ Preview gambar + tombol X */} - {previewImage && ( - - Preview Gambar - - {/* Tombol hapus (pojok kanan atas) */} - { - setPreviewImage(null); - setImageFile(null); - }} - style={{ - boxShadow: '0 2px 6px rgba(0,0,0,0.15)', - }} - > - - - - )} - - - {/* Dokumen APBDes */} - - - Dokumen APBDes - - { - 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" - > - - - - - - - - - - - - - Seret dokumen atau klik untuk memilih file - - - Maksimal 5MB (format: PDF, DOC, DOCX) - + {/* Tombol hapus (pojok kanan atas) */} + { + setPreviewImage(null); + setImageFile(null); + }} + style={{ + boxShadow: '0 2px 6px rgba(0,0,0,0.15)', + }} + > + + - - + )} + - {previewDoc && ( - - - Pratinjau Dokumen - -