fix(realisasi): add kode field to RealisasiItem and simplify table display

- Add kode field to RealisasiItem model in Prisma schema
- Update API endpoints (create, update) to accept kode parameter
- Update state management with proper type definitions
- Add kode input field in RealisasiManager component
- Simplify realisasiTable to show flat list (Kode, Uraian, Realisasi, %)
- Remove section grouping and expandable details
- Fix race condition in findUnique.load() with loading guard
- Fix linting errors across multiple files

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-03-04 11:51:58 +08:00
parent a791efe76c
commit 2d901912ea
11 changed files with 144 additions and 216 deletions

View File

@@ -238,19 +238,21 @@ model APBDesItem {
// Model baru untuk multiple realisasi per item // Model baru untuk multiple realisasi per item
model RealisasiItem { model RealisasiItem {
id String @id @default(cuid()) id String @id @default(cuid())
kode String? // Kode realisasi, mirip dengan APBDesItem
apbdesItemId String apbdesItemId String
apbdesItem APBDesItem @relation(fields: [apbdesItemId], references: [id], onDelete: Cascade) apbdesItem APBDesItem @relation(fields: [apbdesItemId], references: [id], onDelete: Cascade)
jumlah Float // Jumlah realisasi dalam Rupiah jumlah Float // Jumlah realisasi dalam Rupiah
tanggal DateTime @db.Date // Tanggal realisasi tanggal DateTime @db.Date // Tanggal realisasi
keterangan String? @db.Text // Keterangan tambahan (opsional) keterangan String? @db.Text // Keterangan tambahan (opsional)
buktiFileId String? // FileStorage ID untuk bukti/foto (opsional) buktiFileId String? // FileStorage ID untuk bukti/foto (opsional)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
isActive Boolean @default(true) isActive Boolean @default(true)
@@index([kode])
@@index([apbdesItemId]) @@index([apbdesItemId])
@@index([tanggal]) @@index([tanggal])
} }

View File

@@ -14,14 +14,6 @@ const ApbdesItemSchema = z.object({
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(), tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
}); });
// --- Zod Schema untuk Realisasi Item ---
const RealisasiItemSchema = z.object({
jumlah: z.number().min(0, "Jumlah tidak boleh negatif"),
tanggal: z.string(),
keterangan: z.string().optional(),
buktiFileId: z.string().optional(),
});
const ApbdesFormSchema = z.object({ const ApbdesFormSchema = z.object({
tahun: z.number().int().min(2000, "Tahun tidak valid"), tahun: z.number().int().min(2000, "Tahun tidak valid"),
name: z.string().optional(), name: z.string().optional(),
@@ -157,33 +149,37 @@ const apbdes = proxy({
findUnique: { findUnique: {
data: null as data: null as
| Prisma.APBDesGetPayload<{ | Prisma.APBDesGetPayload<{
include: { image: true; file: true; items: true }; include: { image: true; file: true; items: { include: { realisasiItems: true } } };
}> }>
| null, | null,
loading: false, loading: false,
error: null as string | null, error: null as string | null,
async load(id: string) { async load(id: string) {
if (!id || id.trim() === '') { if (!id || id.trim() === '') {
this.data = null; this.data = null;
this.error = "ID tidak valid"; this.error = "ID tidak valid";
return; return;
} }
// Prevent multiple simultaneous loads
if (this.loading) {
console.log("⚠️ Already loading, skipping...");
return;
}
this.loading = true; this.loading = true;
this.error = null; this.error = null;
try { try {
// Pastikan URL-nya benar
const url = `/api/landingpage/apbdes/${id}`; const url = `/api/landingpage/apbdes/${id}`;
console.log("🌐 Fetching:", url); console.log("🌐 Fetching:", url);
// Gunakan fetch biasa atau ApiFetch dengan cara yang benar
const response = await fetch(url); const response = await fetch(url);
const res = await response.json(); const res = await response.json();
console.log("📦 Response:", res); console.log("📦 Response:", res);
if (res.success && res.data) { if (res.success && res.data) {
this.data = res.data; this.data = res.data;
} else { } else {
@@ -322,15 +318,16 @@ const apbdes = proxy({
// ========================================= // =========================================
realisasi: { realisasi: {
// Create realisasi // Create realisasi
async create(itemId: string, data: { jumlah: number; tanggal: string; keterangan?: string; buktiFileId?: string }) { async create(itemId: string, data: { kode: string; jumlah: number; tanggal: string; keterangan?: string; buktiFileId?: string }) {
try { try {
const res = await (ApiFetch.api.landingpage.apbdes as any)[itemId].realisasi.post(data); const res = await (ApiFetch.api.landingpage.apbdes as any)[itemId].realisasi.post(data);
if (res.data?.success) { if (res.data?.success) {
toast.success("Realisasi berhasil ditambahkan"); toast.success("Realisasi berhasil ditambahkan");
// Reload findUnique untuk update data // Reload findUnique untuk update data
if (apbdes.findUnique.data) { const currentId = apbdes.findUnique.data?.id;
await apbdes.findUnique.load(apbdes.findUnique.data.id); if (currentId) {
await apbdes.findUnique.load(currentId);
} }
return true; return true;
} else { } else {
@@ -345,15 +342,16 @@ const apbdes = proxy({
}, },
// Update realisasi // Update realisasi
async update(realisasiId: string, data: { jumlah?: number; tanggal?: string; keterangan?: string; buktiFileId?: string }) { async update(realisasiId: string, data: { kode?: string; jumlah?: number; tanggal?: string; keterangan?: string; buktiFileId?: string }) {
try { try {
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].put(data); const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].put(data);
if (res.data?.success) { if (res.data?.success) {
toast.success("Realisasi berhasil diperbarui"); toast.success("Realisasi berhasil diperbarui");
// Reload findUnique untuk update data // Reload findUnique untuk update data
if (apbdes.findUnique.data) { const currentId = apbdes.findUnique.data?.id;
await apbdes.findUnique.load(apbdes.findUnique.data.id); if (currentId) {
await apbdes.findUnique.load(currentId);
} }
return true; return true;
} else { } else {

View File

@@ -3,6 +3,8 @@
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'; import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { import {
Box, Box,
Button, Button,
@@ -23,7 +25,6 @@ import {
Badge, Badge,
Modal, Modal,
Divider, Divider,
Loader,
Center, Center,
} from '@mantine/core'; } from '@mantine/core';
import { import {
@@ -33,9 +34,6 @@ import {
IconCalendar, IconCalendar,
IconCoin, IconCoin,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useState } from 'react';
import { toast } from 'react-toastify';
import colors from '@/con/colors';
interface RealisasiManagerProps { interface RealisasiManagerProps {
itemId: string; itemId: string;
@@ -63,6 +61,7 @@ export default function RealisasiManager({
// Form state // Form state
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
kode: '',
jumlah: 0, jumlah: 0,
tanggal: new Date().toISOString().split('T')[0], // YYYY-MM-DD format for input tanggal: new Date().toISOString().split('T')[0], // YYYY-MM-DD format for input
keterangan: '', keterangan: '',
@@ -70,6 +69,7 @@ export default function RealisasiManager({
const resetForm = () => { const resetForm = () => {
setFormData({ setFormData({
kode: '',
jumlah: 0, jumlah: 0,
tanggal: new Date().toISOString().split('T')[0], tanggal: new Date().toISOString().split('T')[0],
keterangan: '', keterangan: '',
@@ -85,8 +85,9 @@ export default function RealisasiManager({
const handleOpenEdit = (realisasi: any) => { const handleOpenEdit = (realisasi: any) => {
const tanggal = new Date(realisasi.tanggal); const tanggal = new Date(realisasi.tanggal);
const tanggalStr = tanggal.toISOString().split('T')[0]; // YYYY-MM-DD const tanggalStr = tanggal.toISOString().split('T')[0]; // YYYY-MM-DD
setFormData({ setFormData({
kode: realisasi.kode || '',
jumlah: realisasi.jumlah, jumlah: realisasi.jumlah,
tanggal: tanggalStr, tanggal: tanggalStr,
keterangan: realisasi.keterangan || '', keterangan: realisasi.keterangan || '',
@@ -100,12 +101,17 @@ export default function RealisasiManager({
return toast.warn('Jumlah realisasi harus lebih dari 0'); return toast.warn('Jumlah realisasi harus lebih dari 0');
} }
if (!formData.kode || formData.kode.trim() === '') {
return toast.warn('Kode realisasi wajib diisi');
}
try { try {
setLoading(true); setLoading(true);
if (editingId) { if (editingId) {
// Update existing realisasi // Update existing realisasi
const success = await state.realisasi.update(editingId, { const success = await state.realisasi.update(editingId, {
kode: formData.kode,
jumlah: formData.jumlah, jumlah: formData.jumlah,
tanggal: new Date(formData.tanggal).toISOString(), tanggal: new Date(formData.tanggal).toISOString(),
keterangan: formData.keterangan, keterangan: formData.keterangan,
@@ -117,6 +123,7 @@ export default function RealisasiManager({
} else { } else {
// Create new realisasi // Create new realisasi
const success = await state.realisasi.create(itemId, { const success = await state.realisasi.create(itemId, {
kode: formData.kode,
jumlah: formData.jumlah, jumlah: formData.jumlah,
tanggal: new Date(formData.tanggal).toISOString(), tanggal: new Date(formData.tanggal).toISOString(),
keterangan: formData.keterangan, keterangan: formData.keterangan,
@@ -257,6 +264,7 @@ export default function RealisasiManager({
<Table striped highlightOnHover fz="sm"> <Table striped highlightOnHover fz="sm">
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>Kode</TableTh>
<TableTh>Tanggal</TableTh> <TableTh>Tanggal</TableTh>
<TableTh>Uraian</TableTh> <TableTh>Uraian</TableTh>
<TableTh ta="right">Jumlah</TableTh> <TableTh ta="right">Jumlah</TableTh>
@@ -266,6 +274,11 @@ export default function RealisasiManager({
<TableTbody> <TableTbody>
{realisasiItems.map((realisasi) => ( {realisasiItems.map((realisasi) => (
<TableTr key={realisasi.id}> <TableTr key={realisasi.id}>
<TableTd>
<Badge variant="light" color="blue" size="sm">
{realisasi.kode || '-'}
</Badge>
</TableTd>
<TableTd> <TableTd>
<Group gap="xs"> <Group gap="xs">
<IconCalendar size={16} /> <IconCalendar size={16} />
@@ -314,7 +327,7 @@ export default function RealisasiManager({
Belum ada realisasi untuk item ini Belum ada realisasi untuk item ini
</Text> </Text>
<Text fz="xs" c="dimmed"> <Text fz="xs" c="dimmed">
Klik tombol "Tambah Realisasi" untuk menambahkan Klik tombol &quot;Tambah Realisasi&quot; untuk menambahkan
</Text> </Text>
</Stack> </Stack>
</Center> </Center>
@@ -349,6 +362,15 @@ export default function RealisasiManager({
</Text> </Text>
</Paper> </Paper>
<TextInput
label="Kode Realisasi"
placeholder="Contoh: 4.1.1-R1"
value={formData.kode}
onChange={(e) => setFormData({ ...formData, kode: e.target.value })}
description="Kode unik untuk realisasi ini"
required
/>
<NumberInput <NumberInput
label="Jumlah Realisasi (Rp)" label="Jumlah Realisasi (Rp)"
value={formData.jumlah} value={formData.jumlah}

View File

@@ -233,7 +233,7 @@ function DetailAPBDes() {
</Table> </Table>
{/* Realisasi Manager untuk setiap item */} {/* Realisasi Manager untuk setiap item */}
{data.items.map((item: any) => ( {data.items.map((item) => (
<RealisasiManager <RealisasiManager
key={item.id} key={item.id}
itemId={item.id} itemId={item.id}

View File

@@ -69,6 +69,7 @@ const APBDes = new Elysia({
.post("/:itemId/realisasi", realisasiCreate, { .post("/:itemId/realisasi", realisasiCreate, {
params: t.Object({ itemId: t.String() }), params: t.Object({ itemId: t.String() }),
body: t.Object({ body: t.Object({
kode: t.String(),
jumlah: t.Number(), jumlah: t.Number(),
tanggal: t.String(), tanggal: t.String(),
keterangan: t.Optional(t.String()), keterangan: t.Optional(t.String()),
@@ -80,6 +81,7 @@ const APBDes = new Elysia({
.put("/realisasi/:realisasiId", realisasiUpdate, { .put("/realisasi/:realisasiId", realisasiUpdate, {
params: t.Object({ realisasiId: t.String() }), params: t.Object({ realisasiId: t.String() }),
body: t.Object({ body: t.Object({
kode: t.Optional(t.String()),
jumlah: t.Optional(t.Number()), jumlah: t.Optional(t.Number()),
tanggal: t.Optional(t.String()), tanggal: t.Optional(t.String()),
keterangan: t.Optional(t.String()), keterangan: t.Optional(t.String()),

View File

@@ -3,6 +3,7 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
type RealisasiCreateBody = { type RealisasiCreateBody = {
kode: string;
jumlah: number; jumlah: number;
tanggal: string; // ISO format tanggal: string; // ISO format
keterangan?: string; keterangan?: string;
@@ -33,6 +34,7 @@ export default async function realisasiCreate(context: Context) {
const realisasi = await prisma.realisasiItem.create({ const realisasi = await prisma.realisasiItem.create({
data: { data: {
apbdesItemId: itemId, apbdesItemId: itemId,
kode: body.kode,
jumlah: body.jumlah, jumlah: body.jumlah,
tanggal: new Date(body.tanggal), tanggal: new Date(body.tanggal),
keterangan: body.keterangan, keterangan: body.keterangan,

View File

@@ -3,6 +3,7 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
type RealisasiUpdateBody = { type RealisasiUpdateBody = {
kode?: string;
jumlah?: number; jumlah?: number;
tanggal?: string; tanggal?: string;
keterangan?: string; keterangan?: string;
@@ -33,6 +34,7 @@ export default async function realisasiUpdate(context: Context) {
const updated = await prisma.realisasiItem.update({ const updated = await prisma.realisasiItem.update({
where: { id: realisasiId }, where: { id: realisasiId },
data: { data: {
...(body.kode !== undefined && { kode: body.kode }),
...(body.jumlah !== undefined && { jumlah: body.jumlah }), ...(body.jumlah !== undefined && { jumlah: body.jumlah }),
...(body.tanggal !== undefined && { tanggal: new Date(body.tanggal) }), ...(body.tanggal !== undefined && { tanggal: new Date(body.tanggal) }),
...(body.keterangan !== undefined && { keterangan: body.keterangan }), ...(body.keterangan !== undefined && { keterangan: body.keterangan }),

View File

@@ -135,7 +135,7 @@ const MusicPlayer = () => {
} }
// Jika bukan lagu baru, jangan reset currentTime (biar seek tidak kembali ke 0) // Jika bukan lagu baru, jangan reset currentTime (biar seek tidak kembali ke 0)
} }
}, [currentSong?.id]); // Intentional: hanya depend on song ID, bukan isPlaying }, [currentSong?.id]); // eslint-disable-line react-hooks/exhaustive-deps -- Intentional: hanya depend on song ID, bukan isPlaying
// Sync duration dari audio element jika berbeda signifikan (> 1 detik) // Sync duration dari audio element jika berbeda signifikan (> 1 detik)
useEffect(() => { useEffect(() => {
@@ -155,7 +155,7 @@ const MusicPlayer = () => {
audio.addEventListener('loadedmetadata', handleLoadedMetadata); audio.addEventListener('loadedmetadata', handleLoadedMetadata);
return () => audio.removeEventListener('loadedmetadata', handleLoadedMetadata); return () => audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
}, [currentSong?.id]); // Intentional: hanya depend on song ID }, [currentSong?.id]); // eslint-disable-line react-hooks/exhaustive-deps -- Intentional: hanya depend on song ID
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);

View File

@@ -4,29 +4,25 @@
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 { import {
ActionIcon,
Box, Box,
Button, Button,
Center, Divider,
Group, Group,
Loader,
Select, Select,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text, Text,
Title, Title
} from '@mantine/core' } from '@mantine/core'
import { IconDownload } from '@tabler/icons-react'
import Link from 'next/link' import Link from 'next/link'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useProxy } from 'valtio/utils' import { useProxy } from 'valtio/utils'
import GrafikRealisasi from './lib/grafikRealisasi'
import PaguTable from './lib/paguTable' import PaguTable from './lib/paguTable'
import RealisasiTable from './lib/realisasiTable' import RealisasiTable from './lib/realisasiTable'
import GrafikRealisasi from './lib/grafikRealisasi'
function Apbdes() { function Apbdes() {
const state = useProxy(apbdes) const state = useProxy(apbdes)
const [loading, setLoading] = useState(false)
const [selectedYear, setSelectedYear] = useState<string | null>(null) const [selectedYear, setSelectedYear] = useState<string | null>(null)
const textHeading = { const textHeading = {
@@ -37,12 +33,9 @@ function Apbdes() {
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
try { try {
setLoading(true)
await state.findMany.load() await state.findMany.load()
} catch (error) { } catch (error) {
console.error('Error loading data:', error) console.error('Error loading data:', error)
} finally {
setLoading(false)
} }
} }
loadData() loadData()
@@ -73,10 +66,12 @@ function Apbdes() {
? dataAPBDes.find((item: any) => item?.tahun?.toString() === selectedYear) || dataAPBDes[0] ? dataAPBDes.find((item: any) => item?.tahun?.toString() === selectedYear) || dataAPBDes[0]
: null : null
const data = (state.findMany.data || []).slice(0, 3) // eslint-disable-next-line @typescript-eslint/no-unused-vars
const previewData = (state.findMany.data || []).slice(0, 3)
return ( return (
<Stack p="sm" gap="xl" bg={colors.Bg}> <Stack p="sm" gap="xl" bg={colors.Bg}>
<Divider c="gray.3" size="sm" />
{/* 📌 HEADING */} {/* 📌 HEADING */}
<Box mt="xl"> <Box mt="xl">
<Stack gap="sm"> <Stack gap="sm">
@@ -116,7 +111,7 @@ function Apbdes() {
</Group> </Group>
{/* COMBOBOX */} {/* COMBOBOX */}
<Box px={{ base: 'md', md: 100 }}> <Box px={{ base: 'md', md: "sm" }}>
<Select <Select
label={<Text fw={600} fz="sm">Pilih Tahun APBDes</Text>} label={<Text fw={600} fz="sm">Pilih Tahun APBDes</Text>}
placeholder="Pilih tahun" placeholder="Pilih tahun"
@@ -132,7 +127,7 @@ function Apbdes() {
{/* Tabel & Grafik - Hanya tampilkan jika ada data */} {/* Tabel & Grafik - Hanya tampilkan jika ada data */}
{currentApbdes && currentApbdes.items?.length > 0 ? ( {currentApbdes && currentApbdes.items?.length > 0 ? (
<Box px={{ base: 'md', md: 100 }}> <Box px={{ base: 'md', md: 'sm' }} mb="xl">
<SimpleGrid cols={{ base: 1, sm: 3 }}> <SimpleGrid cols={{ base: 1, sm: 3 }}>
<PaguTable apbdesData={currentApbdes} /> <PaguTable apbdesData={currentApbdes} />
<RealisasiTable apbdesData={currentApbdes} /> <RealisasiTable apbdesData={currentApbdes} />
@@ -140,19 +135,19 @@ function Apbdes() {
</SimpleGrid> </SimpleGrid>
</Box> </Box>
) : currentApbdes ? ( ) : currentApbdes ? (
<Box px={{ base: 'md', md: 100 }} py="md"> <Box px={{ base: 'md', md: 100 }} py="md" mb="xl">
<Text fz="sm" c="dimmed" ta="center" lh={1.5}> <Text fz="sm" c="dimmed" ta="center" lh={1.5}>
Tidak ada data item untuk tahun yang dipilih. Tidak ada data item untuk tahun yang dipilih.
</Text> </Text>
</Box> </Box>
) : null} ) : null}
{/* GRID - Card Preview */} {/* GRID - Card Preview
{loading ? ( {state.findMany.loading ? (
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl"> <Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
<Loader size="lg" color="blue" /> <Loader size="lg" color="blue" />
</Center> </Center>
) : data.length === 0 ? ( ) : previewData.length === 0 ? (
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl"> <Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
<Stack align="center" gap="xs"> <Stack align="center" gap="xs">
<Text fz="lg" c="dimmed" lh={1.4}> <Text fz="lg" c="dimmed" lh={1.4}>
@@ -170,7 +165,7 @@ function Apbdes() {
spacing="lg" spacing="lg"
pb="xl" pb="xl"
> >
{data.map((v: any, k: number) => ( {previewData.map((v, k) => (
<Box <Box
key={k} key={k}
pos="relative" pos="relative"
@@ -224,7 +219,7 @@ function Apbdes() {
</Box> </Box>
))} ))}
</SimpleGrid> </SimpleGrid>
)} )} */}
</Stack> </Stack>
) )
} }

View File

@@ -106,7 +106,7 @@ export default function GrafikRealisasi({ apbdesData }: any) {
</Title> </Title>
{/* Summary Total Keseluruhan */} {/* Summary Total Keseluruhan */}
<Box mb="lg" p="md" bg="gray.0" radius="md"> <Box mb="lg" p="md" bg="gray.0">
<> <>
<Group justify="space-between" mb="xs"> <Group justify="space-between" mb="xs">
<Text fw={700} fz="lg">TOTAL KESELURUHAN</Text> <Text fw={700} fz="lg">TOTAL KESELURUHAN</Text>

View File

@@ -1,146 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Table, Title, Badge, Group, Text, Box, Collapse } from '@mantine/core'; import { Paper, Table, Title, Badge, Text } from '@mantine/core';
import { useState } from 'react';
import { IconChevronDown, IconChevronRight } from '@tabler/icons-react';
function RealisasiDetail({ realisasiItems }: { realisasiItems: any[] }) {
if (!realisasiItems || realisasiItems.length === 0) {
return (
<Text fz="xs" c="dimmed" py="xs">
Belum ada realisasi
</Text>
);
}
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', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
};
return (
<Box mt="xs" p="xs" bg="gray.0" radius="md">
<Text fz="xs" fw={600} mb="xs">
Rincian Realisasi ({realisasiItems.length}):
</Text>
<>
{realisasiItems.map((realisasi: any, idx: number) => (
<Box key={realisasi.id} mb={idx < realisasiItems.length - 1 ? 'xs' : 0}>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text fz="xs" style={{ flex: 1 }}>
{realisasi.keterangan || `Realisasi ${idx + 1}`}
</Text>
<Text fz="xs" fw={600} c="blue">
{formatRupiah(realisasi.jumlah)}
</Text>
<Text fz="xs" c="dimmed">
{formatDate(realisasi.tanggal)}
</Text>
</Group>
</Box>
))}
</>
</Box>
);
}
function ItemRow({ item, expanded, onToggle }: any) {
const formatRupiah = (amount: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
const hasRealisasi = item.realisasiItems && item.realisasiItems.length > 0;
return (
<>
<Table.Tr
onClick={onToggle}
style={{ cursor: 'pointer', '&:hover': { backgroundColor: 'var(--mantine-color-gray-0)' } }}
>
<Table.Td>
<Group gap="xs">
{hasRealisasi ? (
expanded ? (
<IconChevronDown size={16} />
) : (
<IconChevronRight size={16} />
)
) : (
<Box w={16} />
)}
<Text fw={500}>{item.kode} - {item.uraian}</Text>
</Group>
</Table.Td>
<Table.Td ta="right">
<Text fw={600} c="blue">
{formatRupiah(item.totalRealisasi)}
</Text>
</Table.Td>
<Table.Td ta="center">
<Badge
color={
item.persentase >= 100
? 'teal'
: item.persentase >= 60
? 'yellow'
: 'red'
}
>
{item.persentase.toFixed(2)}%
</Badge>
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td colSpan={3} p={0}>
<Collapse in={expanded}>
{hasRealisasi && <RealisasiDetail realisasiItems={item.realisasiItems} />}
</Collapse>
</Table.Td>
</Table.Tr>
</>
);
}
function Section({ title, data }: any) {
if (!data || data.length === 0) return null;
const [expandedId, setExpandedId] = useState<string | null>(null);
return (
<>
<Table.Tr>
<Table.Td colSpan={3}>
<Text fw={700} fz="sm">{title}</Text>
</Table.Td>
</Table.Tr>
{data.map((item: any) => (
<ItemRow
key={item.id}
item={item}
expanded={expandedId === item.id}
onToggle={() => setExpandedId(expandedId === item.id ? null : item.id)}
/>
))}
</>
);
}
export default function RealisasiTable({ apbdesData }: any) { export default function RealisasiTable({ apbdesData }: any) {
const items = apbdesData.items || []; const items = apbdesData.items || [];
@@ -150,32 +9,78 @@ export default function RealisasiTable({ apbdesData }: any) {
? `REALISASI APBDes Tahun ${apbdesData.tahun}` ? `REALISASI APBDes Tahun ${apbdesData.tahun}`
: 'REALISASI APBDes'; : 'REALISASI APBDes';
const pendapatan = items.filter((i: any) => i.tipe === 'pendapatan'); // Flatten: kumpulkan semua realisasi items
const belanja = items.filter((i: any) => i.tipe === 'belanja'); const allRealisasiRows: Array<{ realisasi: any; parentItem: any }> = [];
const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan');
items.forEach((item: any) => {
if (item.realisasiItems && item.realisasiItems.length > 0) {
item.realisasiItems.forEach((realisasi: any) => {
allRealisasiRows.push({ realisasi, parentItem: item });
});
}
});
const formatRupiah = (amount: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
return ( return (
<Paper withBorder p="md" radius="md"> <Paper withBorder p="md" radius="md">
<Title order={5} mb="md">{title}</Title> <Title order={5} mb="md">{title}</Title>
<Text fz="xs" c="dimmed" mb="sm"> {allRealisasiRows.length === 0 ? (
💡 Klik pada item untuk melihat detail realisasi <Text fz="sm" c="dimmed" ta="center" py="md">
</Text> Belum ada data realisasi
</Text>
) : (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Uraian</Table.Th>
<Table.Th ta="right">Realisasi (Rp)</Table.Th>
<Table.Th ta="center">%</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{allRealisasiRows.map(({ realisasi, parentItem }) => {
const persentase = parentItem.anggaran > 0
? (realisasi.jumlah / parentItem.anggaran) * 100
: 0;
<Table> return (
<Table.Thead> <Table.Tr key={realisasi.id}>
<Table.Tr> <Table.Td>
<Table.Th>Uraian</Table.Th> <Text>{realisasi.kode} - {realisasi.keterangan}</Text>
<Table.Th ta="right">Total Realisasi (Rp)</Table.Th> </Table.Td>
<Table.Th ta="center">%</Table.Th> <Table.Td ta="right">
</Table.Tr> <Text fw={600} c="blue">
</Table.Thead> {formatRupiah(realisasi.jumlah)}
<Table.Tbody> </Text>
<Section title="1) PENDAPATAN" data={pendapatan} /> </Table.Td>
<Section title="2) BELANJA" data={belanja} /> <Table.Td ta="center">
<Section title="3) PEMBIAYAAN" data={pembiayaan} /> <Badge
</Table.Tbody> color={
</Table> persentase >= 100
? 'teal'
: persentase >= 60
? 'yellow'
: 'red'
}
>
{persentase.toFixed(2)}%
</Badge>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
)}
</Paper> </Paper>
); );
} }