feat(apbdes): modernize ui, charts and refactor (Phase 1, 2, 4)
This commit is contained in:
229
src/app/darmasaba/_com/main-page/apbdes/lib/comparisonChart.tsx
Normal file
229
src/app/darmasaba/_com/main-page/apbdes/lib/comparisonChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user