From 67e5ceb254f4f651c2b9a8f01463c7b5c74888b3 Mon Sep 17 00:00:00 2001 From: nico Date: Tue, 3 Mar 2026 15:41:43 +0800 Subject: [PATCH] feat(ui): add Realisasi Manager component for multiple realisasi CRUD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Component - RealisasiManager: - Add modal form for create/edit realisasi - Input fields: Jumlah (Rp), Tanggal, Keterangan/Uraian - Display list of existing realisasi with edit/delete actions - Summary cards showing: Anggaran, Total Realisasi, Sisa Anggaran, Persentase - Color-coded percentage badges (teal ≥100%, blue ≥80%, yellow ≥60%, red <60%) - Auto-reload data after create/update/delete operations Features: - Multiple realisasi per APBDes item - Each realisasi has its own description (uraian) - Date picker for realisasi tanggal - Format currency in IDR (Rupiah) - Responsive table layout - Empty state when no realisasi exists Integration: - Integrated with existing state.realisasi CRUD functions - Auto-calculate totalRealisasi and persentase (handled by backend) - Display realisasi items from API response - Works with existing APBDes detail page UI/UX: - Clean modal design with form validation - Summary cards with color-coded backgrounds - Icon indicators for date and currency - Confirmation dialog before delete - Loading states during async operations Co-authored-by: Qwen-Coder --- .../apbdes/[id]/RealisasiManager.tsx | 407 ++++++++++++++++++ .../landing-page/apbdes/[id]/page.tsx | 93 ++-- 2 files changed, 460 insertions(+), 40 deletions(-) create mode 100644 src/app/admin/(dashboard)/landing-page/apbdes/[id]/RealisasiManager.tsx diff --git a/src/app/admin/(dashboard)/landing-page/apbdes/[id]/RealisasiManager.tsx b/src/app/admin/(dashboard)/landing-page/apbdes/[id]/RealisasiManager.tsx new file mode 100644 index 00000000..072ab253 --- /dev/null +++ b/src/app/admin/(dashboard)/landing-page/apbdes/[id]/RealisasiManager.tsx @@ -0,0 +1,407 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +'use client'; + +import { useProxy } from 'valtio/utils'; +import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'; +import { + Box, + Button, + Group, + Paper, + Stack, + Text, + TextInput, + NumberInput, + Title, + Table, + TableThead, + TableTbody, + TableTr, + TableTh, + TableTd, + ActionIcon, + Badge, + Modal, + Divider, + Loader, + Center, +} from '@mantine/core'; +import { + IconPlus, + IconEdit, + IconTrash, + IconCalendar, + IconCoin, +} from '@tabler/icons-react'; +import { useState } from 'react'; +import { toast } from 'react-toastify'; +import colors from '@/con/colors'; + +interface RealisasiManagerProps { + itemId: string; + itemKode: string; + itemUraian: string; + itemAnggaran: number; + itemTotalRealisasi: number; + itemPersentase: number; + realisasiItems: any[]; +} + +export default function RealisasiManager({ + itemId, + itemKode, + itemUraian, + itemAnggaran, + itemTotalRealisasi, + itemPersentase, + realisasiItems, +}: RealisasiManagerProps) { + const state = useProxy(apbdes); + const [modalOpened, setModalOpened] = useState(false); + const [editingId, setEditingId] = useState(null); + const [loading, setLoading] = useState(false); + + // Form state + const [formData, setFormData] = useState({ + jumlah: 0, + tanggal: new Date().toISOString().split('T')[0], // YYYY-MM-DD format for input + keterangan: '', + }); + + const resetForm = () => { + setFormData({ + jumlah: 0, + tanggal: new Date().toISOString().split('T')[0], + keterangan: '', + }); + setEditingId(null); + }; + + const handleOpenCreate = () => { + resetForm(); + setModalOpened(true); + }; + + const handleOpenEdit = (realisasi: any) => { + const tanggal = new Date(realisasi.tanggal); + const tanggalStr = tanggal.toISOString().split('T')[0]; // YYYY-MM-DD + + setFormData({ + jumlah: realisasi.jumlah, + tanggal: tanggalStr, + keterangan: realisasi.keterangan || '', + }); + setEditingId(realisasi.id); + setModalOpened(true); + }; + + const handleSubmit = async () => { + if (formData.jumlah <= 0) { + return toast.warn('Jumlah realisasi harus lebih dari 0'); + } + + try { + setLoading(true); + + if (editingId) { + // Update existing realisasi + const success = await state.realisasi.update(editingId, { + jumlah: formData.jumlah, + tanggal: new Date(formData.tanggal).toISOString(), + keterangan: formData.keterangan, + }); + + if (success) { + toast.success('Realisasi berhasil diperbarui'); + } + } else { + // Create new realisasi + const success = await state.realisasi.create(itemId, { + jumlah: formData.jumlah, + tanggal: new Date(formData.tanggal).toISOString(), + keterangan: formData.keterangan, + }); + + if (success) { + toast.success('Realisasi berhasil ditambahkan'); + } + } + + setModalOpened(false); + resetForm(); + } catch (error: any) { + console.error('Error saving realisasi:', error); + toast.error(error?.message || 'Gagal menyimpan realisasi'); + } finally { + setLoading(false); + } + }; + + const handleDelete = async (realisasiId: string) => { + if (!confirm('Apakah Anda yakin ingin menghapus realisasi ini?')) { + return; + } + + try { + setLoading(true); + const success = await state.realisasi.delete(realisasiId); + + if (success) { + toast.success('Realisasi berhasil dihapus'); + } + } catch (error: any) { + console.error('Error deleting realisasi:', error); + toast.error(error?.message || 'Gagal menghapus realisasi'); + } finally { + setLoading(false); + } + }; + + const formatRupiah = (amount: number) => { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + }; + + const getSisaAnggaran = () => { + return itemAnggaran - itemTotalRealisasi; + }; + + const getPersentaseColor = (persen: number) => { + if (persen >= 100) return 'teal'; + if (persen >= 80) return 'blue'; + if (persen >= 60) return 'yellow'; + return 'red'; + }; + + return ( + + {/* Header */} + + + + {itemKode} - {itemUraian} + + + Kelola realisasi untuk item ini + + + + + + {/* Summary Cards */} + + + + ANGGARAN + + + {formatRupiah(itemAnggaran)} + + + + + + TOTAL REALISASI + + + {formatRupiah(itemTotalRealisasi)} + + + + = 0 ? 'green.0' : 'red.0'}> + = 0 ? 'green.9' : 'red.9'} fw={600}> + SISA ANGGARAN + + = 0 ? 'green.9' : 'red.9'} fw={700}> + {formatRupiah(getSisaAnggaran())} + + + + + + PERSENTASE + + + {itemPersentase.toFixed(2)}% + + + + + {/* Realisasi List */} + {realisasiItems && realisasiItems.length > 0 ? ( + + + Daftar Realisasi ({realisasiItems.length}) + + + + + + Tanggal + Uraian + Jumlah + Aksi + + + + {realisasiItems.map((realisasi) => ( + + + + + {formatDate(realisasi.tanggal)} + + + + {realisasi.keterangan || '-'} + + + + {formatRupiah(realisasi.jumlah)} + + + + + handleOpenEdit(realisasi)} + > + + + handleDelete(realisasi.id)} + disabled={loading} + > + + + + + + ))} + +
+
+
+ ) : ( +
+ + + Belum ada realisasi untuk item ini + + + Klik tombol "Tambah Realisasi" untuk menambahkan + + +
+ )} + + {/* Modal Create/Edit */} + { + setModalOpened(false); + resetForm(); + }} + title={ + + {editingId ? 'Edit Realisasi' : 'Tambah Realisasi Baru'} + + } + size="md" + centered + > + + {/* Info Item */} + + + Item: {itemKode} - {itemUraian} + + + Anggaran: {formatRupiah(itemAnggaran)} + + + Sudah terealisasi: {formatRupiah(itemTotalRealisasi)} + + + + setFormData({ ...formData, jumlah: Number(val) || 0 })} + leftSection={} + thousandSeparator + min={0} + step={100000} + required + /> + + setFormData({ ...formData, tanggal: e.target.value })} + leftSection={} + required + /> + + setFormData({ ...formData, keterangan: e.target.value })} + description="Deskripsi singkat tentang realisasi ini" + /> + + + + + + + + + +
+ ); +} 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 94cc5455..36e670b9 100644 --- a/src/app/admin/(dashboard)/landing-page/apbdes/[id]/page.tsx +++ b/src/app/admin/(dashboard)/landing-page/apbdes/[id]/page.tsx @@ -25,6 +25,7 @@ import { useEffect, useState } from 'react'; import colors from '@/con/colors'; import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus'; import apbdes from '../../../_state/landing-page/apbdes'; +import RealisasiManager from './RealisasiManager'; @@ -191,48 +192,60 @@ function DetailAPBDes() { {/* 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.totalRealisasi.toLocaleString('id-ID')} - - = 0 ? 'green' : 'red'}> - {item.selisih.toLocaleString('id-ID')} - - - - {item.persentase.toFixed(2)}% - - - ))} - -
-
-
+ + + + Uraian + Anggaran (Rp) + Realisasi (Rp) + Selisih (Rp) + Persentase (%) + + + + {[...data.items] + .sort((a, b) => a.kode.localeCompare(b.kode)) + .map((item) => ( + + + + {item.kode} + {item.uraian} + + + {item.anggaran.toLocaleString('id-ID')} + {item.totalRealisasi.toLocaleString('id-ID')} + + = 0 ? 'green' : 'red'}> + {item.selisih.toLocaleString('id-ID')} + + + + {item.persentase.toFixed(2)}% + + + ))} + +
+ + {/* Realisasi Manager untuk setiap item */} + {data.items.map((item: any) => ( + + ))} + ) : ( Belum ada data item )}