feat(apbdes): modernize ui, charts and refactor (Phase 1, 2, 4)

This commit is contained in:
2026-04-01 15:09:40 +08:00
parent 51ce823b45
commit 0806eb2308
10 changed files with 1582 additions and 347 deletions

View File

@@ -0,0 +1,229 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Title, Box, Text, Stack, Group, rem } from '@mantine/core'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Cell,
} from 'recharts'
import { APBDes, APBDesItem } from '../types/apbdes'
interface ComparisonChartProps {
apbdesData: APBDes
}
export default function ComparisonChart({ apbdesData }: ComparisonChartProps) {
const items = apbdesData?.items || []
const tahun = apbdesData?.tahun || new Date().getFullYear()
const pendapatan = items.filter((i: APBDesItem) => i.tipe === 'pendapatan')
const belanja = items.filter((i: APBDesItem) => i.tipe === 'belanja')
const pembiayaan = items.filter((i: APBDesItem) => i.tipe === 'pembiayaan')
const totalPendapatan = pendapatan.reduce((sum, i) => sum + i.anggaran, 0)
const totalBelanja = belanja.reduce((sum, i) => sum + i.anggaran, 0)
const totalPembiayaan = pembiayaan.reduce((sum, i) => sum + i.anggaran, 0)
// Hitung total realisasi dari realisasiItems (konsisten dengan RealisasiTable)
const totalPendapatanRealisasi = pendapatan.reduce(
(sum, i) => {
if (i.realisasiItems && i.realisasiItems.length > 0) {
return sum + i.realisasiItems.reduce((sumReal, real) => sumReal + (real.jumlah || 0), 0)
}
return sum
},
0
)
const totalBelanjaRealisasi = belanja.reduce(
(sum, i) => {
if (i.realisasiItems && i.realisasiItems.length > 0) {
return sum + i.realisasiItems.reduce((sumReal, real) => sumReal + (real.jumlah || 0), 0)
}
return sum
},
0
)
const totalPembiayaanRealisasi = pembiayaan.reduce(
(sum, i) => {
if (i.realisasiItems && i.realisasiItems.length > 0) {
return sum + i.realisasiItems.reduce((sumReal, real) => sumReal + (real.jumlah || 0), 0)
}
return sum
},
0
)
const formatRupiah = (value: number) => {
if (value >= 1000000000) {
return `Rp ${(value / 1000000000).toFixed(1)}B`
}
if (value >= 1000000) {
return `Rp ${(value / 1000000).toFixed(1)}Jt`
}
if (value >= 1000) {
return `Rp ${(value / 1000).toFixed(0)}Rb`
}
return `Rp ${value.toFixed(0)}`
}
const data = [
{
name: 'Pendapatan',
pagu: totalPendapatan,
realisasi: totalPendapatanRealisasi,
fill: '#40c057',
},
{
name: 'Belanja',
pagu: totalBelanja,
realisasi: totalBelanjaRealisasi,
fill: '#fa5252',
},
{
name: 'Pembiayaan',
pagu: totalPembiayaan,
realisasi: totalPembiayaanRealisasi,
fill: '#fd7e14',
},
]
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload
return (
<Box
bg="white"
p="md"
style={{
border: '1px solid #e5e7eb',
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
}}
>
<Stack gap="xs">
<Text fw={700} c="gray.8" fz="sm">
{data.name}
</Text>
<Group justify="space-between" gap="lg">
<Text fz="xs" c="gray.6">
Pagu:
</Text>
<Text fz="xs" fw={700} c="blue.9">
{formatRupiah(data.pagu)}
</Text>
</Group>
<Group justify="space-between" gap="lg">
<Text fz="xs" c="gray.6">
Realisasi:
</Text>
<Text fz="xs" fw={700} c="green.9">
{formatRupiah(data.realisasi)}
</Text>
</Group>
{data.pagu > 0 && (
<Group justify="space-between" gap="lg">
<Text fz="xs" c="gray.6">
Persentase:
</Text>
<Text
fz="xs"
fw={700}
c={data.realisasi >= data.pagu ? 'teal' : 'blue'}
>
{((data.realisasi / data.pagu) * 100).toFixed(1)}%
</Text>
</Group>
)}
</Stack>
</Box>
)
}
return null
}
return (
<Paper
withBorder
p="lg"
radius="lg"
shadow="sm"
style={{
transition: 'box-shadow 0.3s ease',
':hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
},
}}
>
<Title
order={5}
mb="lg"
c="blue.9"
fz={{ base: '1rem', md: '1.1rem' }}
fw={700}
>
Perbandingan Pagu vs Realisasi {tahun}
</Title>
<Box style={{ width: '100%', height: 300 }}>
<ResponsiveContainer>
<BarChart
data={data}
margin={{ top: 20, right: 30, left: 0, bottom: 0 }}
barSize={60}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="name"
tick={{ fill: '#6b7280', fontSize: 12 }}
axisLine={{ stroke: '#e5e7eb' }}
/>
<YAxis
tickFormatter={formatRupiah}
tick={{ fill: '#6b7280', fontSize: 11 }}
axisLine={{ stroke: '#e5e7eb' }}
width={80}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{
paddingTop: rem(20),
fontSize: 12,
}}
/>
<Bar
name="Pagu"
dataKey="pagu"
fill="#228be6"
radius={[8, 8, 0, 0]}
>
{data.map((entry, index) => (
<Cell
key={`cell-pagu-${index}`}
fill={entry.fill}
opacity={0.7}
/>
))}
</Bar>
<Bar
name="Realisasi"
dataKey="realisasi"
fill="#40c057"
radius={[8, 8, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</Box>
<Box mt="md">
<Text fz="xs" c="dimmed" ta="center">
*Geser cursor pada bar untuk melihat detail
</Text>
</Box>
</Paper>
)
}

View File

@@ -1,125 +1,224 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Title, Progress, Stack, Text, Group, Box } from '@mantine/core';
interface APBDesItem {
tipe: string | null;
anggaran: number;
realisasi?: number;
totalRealisasi?: number;
}
import { Paper, Title, Progress, Stack, Text, Group, Box, rem } from '@mantine/core'
import { IconArrowUpRight, IconArrowDownRight } from '@tabler/icons-react'
import { APBDes, APBDesItem, SummaryData } from '../types/apbdes'
interface SummaryProps {
title: string;
data: APBDesItem[];
title: string
data: APBDesItem[]
icon?: React.ReactNode
}
function Summary({ title, data }: SummaryProps) {
if (!data || data.length === 0) return null;
function Summary({ title, data, icon }: SummaryProps) {
if (!data || data.length === 0) return null
const totalAnggaran = data.reduce((s: number, i: APBDesItem) => s + i.anggaran, 0);
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
const totalRealisasi = data.reduce(
(s: number, i: APBDesItem) => s + (i.realisasi || i.totalRealisasi || 0),
0
);
const totalAnggaran = data.reduce((sum, i) => sum + i.anggaran, 0)
// Hitung total realisasi dari realisasiItems (konsisten dengan RealisasiTable)
const totalRealisasi = data.reduce((sum, i) => {
if (i.realisasiItems && i.realisasiItems.length > 0) {
return sum + i.realisasiItems.reduce((sumReal, real) => sumReal + (real.jumlah || 0), 0)
}
return sum
}, 0)
const persen =
totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
const persentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0
// Format angka ke dalam format Rupiah
const formatRupiah = (angka: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(angka);
};
}).format(angka)
}
// Tentukan warna berdasarkan persentase
const getProgressColor = (persen: number) => {
if (persen >= 100) return 'teal';
if (persen >= 80) return 'blue';
if (persen >= 60) return 'yellow';
return 'red';
};
if (persen >= 100) return 'teal'
if (persen >= 80) return 'blue'
if (persen >= 60) return 'yellow'
return 'red'
}
const getStatusMessage = (persen: number) => {
if (persen >= 100) {
return { text: 'Realisasi mencapai 100% dari anggaran', color: 'teal' }
}
if (persen >= 80) {
return { text: 'Realisasi baik, mendekati target', color: 'blue' }
}
if (persen >= 60) {
return { text: 'Realisasi cukup, perlu ditingkatkan', color: 'yellow' }
}
return { text: 'Realisasi rendah, perlu perhatian khusus', color: 'red' }
}
const statusMessage = getStatusMessage(persentase)
return (
<Box>
<Group justify="space-between" mb="xs">
<Text fw={600} fz="md">{title}</Text>
<Text fw={700} fz="lg" c={getProgressColor(persen)}>
{persen.toFixed(2)}%
</Text>
<Group gap="xs">
{icon}
<Text fw={700} fz="md" c="gray.8">{title}</Text>
</Group>
<Group gap="xs">
{persentase >= 100 ? (
<IconArrowUpRight
size={18}
color="var(--mantine-color-teal-7)"
stroke={2.5}
/>
) : persentase < 60 ? (
<IconArrowDownRight
size={18}
color="var(--mantine-color-red-7)"
stroke={2.5}
/>
) : null}
<Text
fw={700}
fz="lg"
c={getProgressColor(persentase)}
style={{
minWidth: 60,
textAlign: 'right',
}}
>
{persentase.toFixed(1)}%
</Text>
</Group>
</Group>
<Text fz="sm" c="dimmed" mb="xs">
Realisasi: {formatRupiah(totalRealisasi)} / Anggaran: {formatRupiah(totalAnggaran)}
<Text fz="xs" c="gray.6" mb="sm" lh={1.5}>
Realisasi: <Text component="span" fw={700} c="blue.9">{formatRupiah(totalRealisasi)}</Text>
{' '}/ Anggaran: <Text component="span" fw={700} c="gray.7">{formatRupiah(totalAnggaran)}</Text>
</Text>
<Progress
value={persen}
value={persentase}
size="xl"
radius="xl"
color={getProgressColor(persen)}
striped={persen < 100}
animated={persen < 100}
color={getProgressColor(persentase)}
striped={persentase < 100}
animated={persentase < 100}
mb="xs"
/>
{persen >= 100 && (
<Text fz="xs" c="teal" mt="xs" fw={500}>
Realisasi mencapai 100% dari anggaran
</Text>
)}
{persen < 100 && persen >= 80 && (
<Text fz="xs" c="blue" mt="xs" fw={500}>
Realisasi baik, mendekati target
</Text>
)}
{persen < 80 && persen >= 60 && (
<Text fz="xs" c="yellow" mt="xs" fw={500}>
Realisasi cukup, perlu ditingkatkan
</Text>
)}
{persen < 60 && (
<Text fz="xs" c="red" mt="xs" fw={500}>
Realisasi rendah, perlu perhatian khusus
</Text>
)}
<Text
fz="xs"
c={statusMessage.color as any}
fw={600}
style={{
backgroundColor: `var(--mantine-color-${statusMessage.color}-0)`,
padding: '6px 10px',
borderRadius: 6,
display: 'inline-block',
}}
>
{persentase >= 100 && '✓ '}{statusMessage.text}
</Text>
</Box>
);
)
}
export default function GrafikRealisasi({
apbdesData,
}: {
apbdesData: {
tahun?: number | null;
items?: APBDesItem[] | null;
[key: string]: any;
};
}) {
const items = apbdesData?.items || [];
const tahun = apbdesData?.tahun || new Date().getFullYear();
interface GrafikRealisasiProps {
apbdesData: APBDes
}
const pendapatan = items.filter((i: APBDesItem) => i.tipe === 'pendapatan');
const belanja = items.filter((i: APBDesItem) => i.tipe === 'belanja');
const pembiayaan = items.filter((i: APBDesItem) => i.tipe === 'pembiayaan');
export default function GrafikRealisasi({ apbdesData }: GrafikRealisasiProps) {
const items = apbdesData?.items || []
const tahun = apbdesData?.tahun || new Date().getFullYear()
const pendapatan = items.filter((i: APBDesItem) => i.tipe === 'pendapatan')
const belanja = items.filter((i: APBDesItem) => i.tipe === 'belanja')
const pembiayaan = items.filter((i: APBDesItem) => i.tipe === 'pembiayaan')
return (
<Paper withBorder p="md" radius="md">
<Title order={5} mb="md">
<Paper
withBorder
p="lg"
radius="lg"
shadow="sm"
style={{
transition: 'box-shadow 0.3s ease',
':hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
},
}}
h={"100%"}
>
<Title
order={5}
mb="lg"
c="blue.9"
fz={{ base: '1rem', md: '1.1rem' }}
fw={700}
>
GRAFIK REALISASI APBDes {tahun}
</Title>
<Stack gap="lg" mb="lg">
<Summary title="💰 Pendapatan" data={pendapatan} />
<Summary title="💸 Belanja" data={belanja} />
<Summary title="📊 Pembiayaan" data={pembiayaan} />
<Stack gap="xl">
<Summary
title="Pendapatan"
data={pendapatan}
icon={
<Box
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: 'var(--mantine-color-green-0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text fz="lg">💰</Text>
</Box>
}
/>
<Summary
title="Belanja"
data={belanja}
icon={
<Box
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: 'var(--mantine-color-red-0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text fz="lg">💸</Text>
</Box>
}
/>
<Summary
title="Pembiayaan"
data={pembiayaan}
icon={
<Box
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: 'var(--mantine-color-orange-0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text fz="lg">📊</Text>
</Box>
}
/>
</Stack>
</Paper>
);
}
)
}

View File

@@ -1,60 +1,180 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Table, Title } from '@mantine/core';
import { Paper, Table, Title, Box, ScrollArea, Badge } from '@mantine/core'
import { APBDes, APBDesItem } from '../types/apbdes'
function Section({ title, data }: any) {
if (!data || data.length === 0) return null;
interface SectionProps {
title: string
data: APBDesItem[]
badgeColor?: string
}
function Section({ title, data, badgeColor = 'blue' }: SectionProps) {
if (!data || data.length === 0) return null
return (
<>
<Table.Tr>
<Table.Tr bg="gray.0">
<Table.Td colSpan={2}>
<strong>{title}</strong>
<Badge color={badgeColor} variant="light" size="lg" fw={600}>
{title}
</Badge>
</Table.Td>
</Table.Tr>
{data.map((item: any) => (
<Table.Tr key={item.id}>
<Table.Td>
{item.kode} - {item.uraian}
{data.map((item, index) => (
<Table.Tr
key={item.id}
bg={index % 2 === 1 ? 'gray.50' : 'white'}
style={{
transition: 'background-color 0.2s ease',
':hover': {
backgroundColor: 'var(--mantine-color-blue-0)',
},
}}
>
<Table.Td style={{ borderBottom: '1px solid #e5e7eb' }}>
<Box style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{
fontWeight: 500,
color: 'var(--mantine-color-gray-7)',
minWidth: 80,
}}>
{item.kode}
</span>
<span style={{
color: 'var(--mantine-color-gray-6)',
fontSize: '0.9rem',
}}>
{item.uraian}
</span>
</Box>
</Table.Td>
<Table.Td ta="right">
<Table.Td
ta="right"
style={{
borderBottom: '1px solid #e5e7eb',
fontWeight: 600,
color: 'var(--mantine-color-blue-7)',
}}
>
Rp {item.anggaran.toLocaleString('id-ID')}
</Table.Td>
</Table.Tr>
))}
</>
);
)
}
export default function PaguTable({ apbdesData }: any) {
const items = apbdesData.items || [];
interface PaguTableProps {
apbdesData: APBDes
}
const title =
apbdesData.tahun
? `PAGU APBDes Tahun ${apbdesData.tahun}`
: 'PAGU APBDes';
export default function PaguTable({ apbdesData }: PaguTableProps) {
const items = apbdesData.items || []
const pendapatan = items.filter((i: any) => i.tipe === 'pendapatan');
const belanja = items.filter((i: any) => i.tipe === 'belanja');
const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan');
const title = apbdesData.tahun
? `PAGU APBDes Tahun ${apbdesData.tahun}`
: 'PAGU APBDes'
const pendapatan = items.filter((i: APBDesItem) => i.tipe === 'pendapatan')
const belanja = items.filter((i: APBDesItem) => i.tipe === 'belanja')
const pembiayaan = items.filter((i: APBDesItem) => i.tipe === 'pembiayaan')
// Calculate totals
const totalPendapatan = pendapatan.reduce((sum, i) => sum + i.anggaran, 0)
const totalBelanja = belanja.reduce((sum, i) => sum + i.anggaran, 0)
const totalPembiayaan = pembiayaan.reduce((sum, i) => sum + i.anggaran, 0)
return (
<Paper withBorder p="md" radius="md">
<Title order={5} mb="md">{title}</Title>
<Paper
withBorder
p="md"
radius="lg"
shadow="sm"
style={{
transition: 'box-shadow 0.3s ease',
':hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
},
}}
h={"100%"}
>
<Title
order={5}
mb="md"
c="blue.9"
fz={{ base: '1rem', md: '1.1rem' }}
fw={700}
>
{title}
</Title>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Uraian</Table.Th>
<Table.Th ta="right">Anggaran (Rp)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Section title="1) PENDAPATAN" data={pendapatan} />
<Section title="2) BELANJA" data={belanja} />
<Section title="3) PEMBIAYAAN" data={pembiayaan} />
</Table.Tbody>
</Table>
<ScrollArea offsetScrollbars type="hover">
<Table
horizontalSpacing="md"
verticalSpacing="xs"
layout="fixed"
>
<Table.Thead>
<Table.Tr bg="blue.9">
<Table.Th c="white" fw={600} style={{ minWidth: '60%' }}>
Uraian
</Table.Th>
<Table.Th
c="white"
fw={600}
ta="right"
style={{ minWidth: '40%' }}
>
Anggaran (Rp)
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Section
title="1) PENDAPATAN"
data={pendapatan}
badgeColor="green"
/>
{totalPendapatan > 0 && (
<Table.Tr bg="green.0" fw={700}>
<Table.Td>Total Pendapatan</Table.Td>
<Table.Td ta="right">
Rp {totalPendapatan.toLocaleString('id-ID')}
</Table.Td>
</Table.Tr>
)}
<Section
title="2) BELANJA"
data={belanja}
badgeColor="red"
/>
{totalBelanja > 0 && (
<Table.Tr bg="red.0" fw={700}>
<Table.Td>Total Belanja</Table.Td>
<Table.Td ta="right">
Rp {totalBelanja.toLocaleString('id-ID')}
</Table.Td>
</Table.Tr>
)}
<Section
title="3) PEMBIAYAAN"
data={pembiayaan}
badgeColor="orange"
/>
{totalPembiayaan > 0 && (
<Table.Tr bg="orange.0" fw={700}>
<Table.Td>Total Pembiayaan</Table.Td>
<Table.Td ta="right">
Rp {totalPembiayaan.toLocaleString('id-ID')}
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</ScrollArea>
</Paper>
);
}
)
}

View File

@@ -1,86 +1,212 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Table, Title, Badge, Text } from '@mantine/core';
import { Paper, Table, Title, Badge, Text, Box, ScrollArea } from '@mantine/core'
import { APBDes, APBDesItem, RealisasiItem } from '../types/apbdes'
export default function RealisasiTable({ apbdesData }: any) {
const items = apbdesData.items || [];
interface RealisasiRowProps {
realisasi: RealisasiItem
parentItem: APBDesItem
}
const title =
apbdesData.tahun
? `REALISASI APBDes Tahun ${apbdesData.tahun}`
: 'REALISASI APBDes';
function RealisasiRow({ realisasi, parentItem }: RealisasiRowProps) {
const persentase = parentItem.anggaran > 0
? (realisasi.jumlah / parentItem.anggaran) * 100
: 0
// Flatten: kumpulkan semua realisasi items
const allRealisasiRows: Array<{ realisasi: any; parentItem: any }> = [];
items.forEach((item: any) => {
if (item.realisasiItems && item.realisasiItems.length > 0) {
item.realisasiItems.forEach((realisasi: any) => {
allRealisasiRows.push({ realisasi, parentItem: item });
});
}
});
const getBadgeColor = (percentage: number) => {
if (percentage >= 100) return 'teal'
if (percentage >= 80) return 'blue'
if (percentage >= 60) return 'yellow'
return 'red'
}
const formatRupiah = (amount: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
const getBadgeVariant = (percentage: number) => {
if (percentage >= 100) return 'filled'
return 'light'
}
return (
<Paper withBorder p="md" radius="md">
<Title order={5} mb="md">{title}</Title>
<Table.Tr
style={{
transition: 'background-color 0.2s ease',
':hover': {
backgroundColor: 'var(--mantine-color-blue-0)',
},
}}
>
<Table.Td style={{ borderBottom: '1px solid #e5e7eb' }}>
<Box style={{ gap: 8, alignItems: 'center' }}>
<span style={{
fontWeight: 500,
color: 'var(--mantine-color-gray-7)',
}}>
{realisasi.kode || '-'}
</span>
<Text
size="sm"
c="gray.7"
title={realisasi.keterangan || '-'}
>
{realisasi.keterangan || '-'}
</Text>
</Box>
</Table.Td>
<Table.Td
ta="right"
style={{
borderBottom: '1px solid #e5e7eb',
fontWeight: 700,
color: 'var(--mantine-color-blue-7)',
}}
>
{new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(realisasi.jumlah || 0)}
</Table.Td>
<Table.Td
ta="center"
style={{ borderBottom: '1px solid #e5e7eb' }}
>
<Badge
color={getBadgeColor(persentase)}
variant={getBadgeVariant(persentase)}
size="sm"
radius="xl"
fw={600}
style={{
minWidth: 65,
transition: 'transform 0.2s ease',
}}
>
{persentase.toFixed(1)}%
</Badge>
</Table.Td>
</Table.Tr>
)
}
interface RealisasiTableProps {
apbdesData: APBDes
}
export default function RealisasiTable({ apbdesData }: RealisasiTableProps) {
const items = apbdesData.items || []
const title = apbdesData.tahun
? `REALISASI APBDes Tahun ${apbdesData.tahun}`
: 'REALISASI APBDes'
// Flatten: kumpulkan semua realisasi items
const allRealisasiRows: Array<{ realisasi: RealisasiItem; parentItem: APBDesItem }> = []
items.forEach((item: APBDesItem) => {
if (item.realisasiItems && item.realisasiItems.length > 0) {
item.realisasiItems.forEach((realisasi: RealisasiItem) => {
allRealisasiRows.push({ realisasi, parentItem: item })
})
}
})
// Calculate total realisasi
const totalRealisasi = allRealisasiRows.reduce(
(sum, { realisasi }) => sum + (realisasi.jumlah || 0),
0
)
return (
<Paper
withBorder
p="md"
radius="lg"
shadow="sm"
style={{
transition: 'box-shadow 0.3s ease',
':hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
},
}}
h={"100%"}
>
<Title
order={5}
mb="md"
c="blue.9"
fz={{ base: '1rem', md: '1.1rem' }}
fw={700}
>
{title}
</Title>
{allRealisasiRows.length === 0 ? (
<Text fz="sm" c="dimmed" ta="center" py="md">
Belum ada data realisasi
</Text>
<Box
py="xl"
px="md"
style={{
backgroundColor: 'var(--mantine-color-gray-0)',
borderRadius: 8,
}}
>
<Text
fz="sm"
c="dimmed"
ta="center"
lh={1.6}
>
Belum ada data realisasi untuk tahun ini
</Text>
</Box>
) : (
<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;
return (
<Table.Tr key={realisasi.id}>
<Table.Td>
<Text>{realisasi.kode || '-'} - {realisasi.keterangan || '-'}</Text>
</Table.Td>
<Table.Td ta="right">
<Text fw={600} c="blue">
{formatRupiah(realisasi.jumlah || 0)}
</Text>
</Table.Td>
<Table.Td ta="center">
<Badge
color={
persentase >= 100
? 'teal'
: persentase >= 60
? 'yellow'
: 'red'
}
>
{persentase.toFixed(2)}%
</Badge>
</Table.Td>
<>
<ScrollArea offsetScrollbars type="hover">
<Table
horizontalSpacing="md"
verticalSpacing="xs"
layout="fixed"
>
<Table.Thead>
<Table.Tr bg="blue.9">
<Table.Th c="white" fw={600}>Uraian</Table.Th>
<Table.Th c="white" fw={600} ta="right">Realisasi (Rp)</Table.Th>
<Table.Th c="white" fw={600} ta="center">%</Table.Th>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.Thead>
<Table.Tbody>
{allRealisasiRows.map(({ realisasi, parentItem }) => (
<RealisasiRow
key={realisasi.id}
realisasi={realisasi}
parentItem={parentItem}
/>
))}
</Table.Tbody>
</Table>
</ScrollArea>
<Box mb="md" px="sm">
<Text
size="sm"
c="gray.6"
fw={500}
>
Total Realisasi:{' '}
<Text
component="span"
c="blue.9"
fw={700}
fz="md"
>
{new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(totalRealisasi)}
</Text>
</Text>
</Box>
</>
)}
</Paper>
);
}
)
}