Compare commits

...

10 Commits

Author SHA1 Message Date
6712da9ac2 fix(ui): replace Title with Text in Modal title to avoid h5 in h2 nesting
Fix:
- Change Modal title from <Title order={5}> to <Text fz='lg' fw={600}>
- Avoids invalid HTML nesting (<h5> cannot be child of <h2>)
- Maintains same visual appearance

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 16:21:27 +08:00
ac11a9367c fix(api): correct selisih calculation formula
Bug Fix:
- Change selisih formula from: totalRealisasi - anggaran
- To: anggaran - totalRealisasi

Reason:
- Selisih positif = Sisa anggaran (belum digunakan)
- Selisih negatif = Over budget (melebihi anggaran)

Example:
- Anggaran: Rp 30.000.000
- Realisasi: Rp 5.000.000
- Selisih (OLD): 5jt - 30jt = -25jt  Wrong
- Selisih (NEW): 30jt - 5jt = 25jt  Correct (sisa anggaran)

Files Updated:
- create.ts: Fix initial item creation
- updt.ts: Fix item update
- realisasi/create.ts: Fix after adding realisasi
- realisasi/update.ts: Fix after updating realisasi
- realisasi/delete.ts: Fix after deleting realisasi

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 16:16:38 +08:00
67e5ceb254 feat(ui): add Realisasi Manager component for multiple realisasi CRUD
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 <qwen-coder@alibabacloud.com>
2026-03-03 15:41:43 +08:00
65942ac9d2 refactor(create): remove realisasiAwal, simplify to anggaran-only input
Refactoring:
- Remove realisasiAwal field from ItemForm type
- Remove NumberInput for realisasi awal from UI
- Remove realisasiAwal column from preview table
- Simplify state management (no realisasiAwal mapping)
- API create: Create items with totalRealisasi=0 (no auto-create realisasi)

Rationale:
- Cleaner separation: Anggaran dan Realisasi adalah entitas terpisah
- User create item untuk ANGGARAN dulu
- Setelah item dibuat, user bisa add MULTIPLE REALISASI dengan:
  * Uraian yang jelas untuk setiap realisasi
  * Tanggal yang spesifik
  * Keterangan detail
  * Bukti file attachment
- Follows the original schema design more closely

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 15:30:33 +08:00
e0436cc384 feat(create): add realisasi awal input di create page
Features:
- Add realisasiAwal field to ItemForm type
- Add NumberInput for realisasi awal (optional)
- Update table preview to show realisasi awal
- Update state to send realisasiAwal to API
- Update API create to handle realisasiAwal:
  * Create APBDesItem with totalRealisasi = realisasiAwal
  * Auto-create first RealisasiItem if realisasiAwal > 0
  * Auto-calculate selisih and persentase

UX Improvements:
- User can input initial realization during create
- Optional field with clear label and description
- Auto-calculation of percentages on backend
- Single transaction for item + first realisasi

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 15:13:58 +08:00
63682e47b6 feat(state): update APBDes state management for multiple realisasi
State Changes:
- Update ApbdesItemSchema: Remove realisasi, selisih, persentase fields
- Add RealisasiItemSchema for realisasi CRUD operations
- Update normalizeItem: Remove manual calculations (backend handles it)
- Update edit.load: Map items without realisasi fields
- Add realisasi state: create, update, delete functions

UI Changes:
- Update create/page.tsx: Remove realisasi input field and column
- Update edit/page.tsx: Remove realisasi input field and column
- Update ItemForm type: Remove realisasi property
- Simplify forms to only input anggaran, realisasi added separately

Features:
- Support for multiple realisasi per item
- Realisasi CRUD via dedicated state functions
- Auto-reload findUnique after realisasi operations
- Backend auto-calculates totalRealisasi, selisih, persentase

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 14:55:06 +08:00
f4705690a9 feat(api): implement multiple realisasi for APBDes
Schema Changes:
- Add RealisasiItem model for multiple realizations per APBDesItem
- Replace realisasi field with totalRealisasi (auto-calculated sum)
- Add selisih and persentase as auto-calculated fields
- Cascade delete realisasi when item is deleted

API Changes:
- Update index.ts: Add realisasi CRUD endpoints
  - POST /:itemId/realisasi - Create realisasi
  - PUT /realisasi/:realisasiId - Update realisasi
  - DELETE /realisasi/:realisasiId - Delete realisasi
- Update create.ts: Auto-calculate totalRealisasi=0, selisih, persentase
- Update updt.ts: Reset calculations when items updated
- Update findUnique.ts: Include realisasiItems in response
- Update findMany.ts: Include realisasiItems in response
- Remove realisasi field from ApbdesItemSchema

New Files:
- realisasi/create.ts - Create realisasi with auto-calculation
- realisasi/update.ts - Update realisasi with recalculation
- realisasi/delete.ts - Soft delete with recalculation

Features:
- Auto-calculate totalRealisasi from sum of all realisasiItems
- Auto-calculate selisih = totalRealisasi - anggaran
- Auto-calculate persentase = (totalRealisasi / anggaran) * 100
- Support for bukti file attachment
- Support for keterangan (notes) per realisasi
- Soft delete support for audit trail

UI Updates:
- Update admin detail page to use totalRealisasi instead of realisasi
- Update landing page realisasiTable to use totalRealisasi

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 14:45:21 +08:00
239771a714 fix(apbdes): improve UI components and styling
- Update Apbdes component with better conditional rendering
- Enhance grafikRealisasi with improved percentage display
- Refine color coding and feedback messages
- Optimize layout and spacing for better UX

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 14:15:27 +08:00
03451195c8 feat(apbdes) grafik: add detailed percentage comparison
- Display percentage value prominently next to each category title
- Add formatted currency (Rupiah) for better readability
- Color-coded progress bars based on achievement level:
  * Teal: ≥100% (target tercapai)
  * Blue: ≥80% (baik)
  * Yellow: ≥60% (cukup)
  * Red: <60% (perlu perhatian)
- Add contextual feedback messages based on percentage:
  * ✓ Achievement message for 100%
  *  Positive message for 80-99%
  * ⚠️ Warning messages for <80%
- Add TOTAL KESELURUHAN summary section at the top
- Add emoji icons for better visual distinction (💰 💸 📊)
- Animated progress bars for <100% achievement

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 13:58:52 +08:00
597af7e716 fix(apbdes) landing page: fix APBDes component not displaying on darmasaba page
- Restore Apbdes component with full functionality (fetch data, year selector, tables, charts)
- Fix realisasiTable.tsx: add missing items variable
- Fix grafikRealisasi.tsx: dynamic year title instead of hardcoded 2026
- Add eslint-disable comments for TypeScript any types
- Remove unused imports in paguTable.tsx
- Integrate PaguTable, RealisasiTable, GrafikRealisasi into main Apbdes component
- Component now fetches data from Valtio state and displays 3 tables + charts

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 12:51:53 +08:00
18 changed files with 1251 additions and 212 deletions

View File

@@ -209,16 +209,22 @@ model APBDesItem {
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
tipe String? // "pendapatan" | "belanja" | "pembiayaan" | null
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])
// Field kalkulasi (auto-calculated dari realisasi items)
totalRealisasi Float @default(0) // Sum dari semua realisasi
selisih Float @default(0) // totalRealisasi - anggaran
persentase Float @default(0) // (totalRealisasi / anggaran) * 100
// Relasi ke realisasi items
realisasiItems RealisasiItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@ -229,6 +235,26 @@ model APBDesItem {
@@index([apbdesId])
}
// Model baru untuk multiple realisasi per item
model RealisasiItem {
id String @id @default(cuid())
apbdesItemId String
apbdesItem APBDesItem @relation(fields: [apbdesItemId], references: [id], onDelete: Cascade)
jumlah Float // Jumlah realisasi dalam Rupiah
tanggal DateTime @db.Date // Tanggal realisasi
keterangan String? @db.Text // Keterangan tambahan (opsional)
buktiFileId String? // FileStorage ID untuk bukti/foto (opsional)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
isActive Boolean @default(true)
@@index([apbdesItemId])
@@index([tanggal])
}
//========================================= PRESTASI DESA ========================================= //
model PrestasiDesa {
id String @id @default(cuid())

View File

@@ -5,18 +5,23 @@ import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
// --- Zod Schema ---
// --- Zod Schema untuk APBDes Item (tanpa field kalkulasi) ---
const ApbdesItemSchema = z.object({
kode: z.string().min(1, "Kode wajib diisi"),
uraian: z.string().min(1, "Uraian wajib diisi"),
anggaran: z.number().min(0),
realisasi: z.number().min(0),
selisih: z.number(),
persentase: z.number(),
anggaran: z.number().min(0, "Anggaran tidak boleh negatif"),
level: z.number().int().min(1).max(3),
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({
tahun: z.number().int().min(2000, "Tahun tidak valid"),
name: z.string().optional(),
@@ -38,26 +43,14 @@ const defaultApbdesForm = {
items: [] as z.infer<typeof ApbdesItemSchema>[],
};
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
// --- Helper: Normalize item (tanpa kalkulasi, backend yang hitung) ---
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
const anggaran = item.anggaran ?? 0;
const realisasi = item.realisasi ?? 0;
// ✅ Formula yang benar
const selisih = realisasi - anggaran; // positif = sisa anggaran, negatif = over budget
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran
return {
kode: item.kode || "",
uraian: item.uraian || "",
anggaran,
realisasi,
selisih,
persentase,
anggaran: item.anggaran ?? 0,
level: item.level || 1,
tipe: item.tipe, // biarkan null jika memang null
tipe: item.tipe ?? null,
};
}
@@ -259,9 +252,6 @@ const apbdes = proxy({
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
realisasi: item.realisasi,
selisih: item.selisih,
persentase: item.persentase,
level: item.level,
tipe: item.tipe || 'pendapatan',
})),
@@ -326,6 +316,80 @@ const apbdes = proxy({
this.form = { ...defaultApbdesForm };
},
},
// =========================================
// REALISASI STATE MANAGEMENT
// =========================================
realisasi: {
// Create realisasi
async create(itemId: string, data: { jumlah: number; tanggal: string; keterangan?: string; buktiFileId?: string }) {
try {
const res = await (ApiFetch.api.landingpage.apbdes as any)[itemId].realisasi.post(data);
if (res.data?.success) {
toast.success("Realisasi berhasil ditambahkan");
// Reload findUnique untuk update data
if (apbdes.findUnique.data) {
await apbdes.findUnique.load(apbdes.findUnique.data.id);
}
return true;
} else {
toast.error(res.data?.message || "Gagal menambahkan realisasi");
return false;
}
} catch (error: any) {
console.error("Create realisasi error:", error);
toast.error(error?.message || "Terjadi kesalahan saat menambahkan realisasi");
return false;
}
},
// Update realisasi
async update(realisasiId: string, data: { jumlah?: number; tanggal?: string; keterangan?: string; buktiFileId?: string }) {
try {
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].put(data);
if (res.data?.success) {
toast.success("Realisasi berhasil diperbarui");
// Reload findUnique untuk update data
if (apbdes.findUnique.data) {
await apbdes.findUnique.load(apbdes.findUnique.data.id);
}
return true;
} else {
toast.error(res.data?.message || "Gagal memperbarui realisasi");
return false;
}
} catch (error: any) {
console.error("Update realisasi error:", error);
toast.error(error?.message || "Terjadi kesalahan saat memperbarui realisasi");
return false;
}
},
// Delete realisasi
async delete(realisasiId: string) {
try {
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].delete();
if (res.data?.success) {
toast.success("Realisasi berhasil dihapus");
// Reload findUnique untuk update data
if (apbdes.findUnique.data) {
await apbdes.findUnique.load(apbdes.findUnique.data.id);
}
return true;
} else {
toast.error(res.data?.message || "Gagal menghapus realisasi");
return false;
}
} catch (error: any) {
console.error("Delete realisasi error:", error);
toast.error(error?.message || "Terjadi kesalahan saat menghapus realisasi");
return false;
}
},
},
});
export default apbdes;

View File

@@ -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<string | null>(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 (
<Paper withBorder p="md" radius="md" mt="md">
{/* Header */}
<Group justify="space-between" mb="md">
<Stack gap="xs">
<Title order={6}>
{itemKode} - {itemUraian}
</Title>
<Text fz="sm" c="dimmed">
Kelola realisasi untuk item ini
</Text>
</Stack>
<Button
leftSection={<IconPlus size={18} />}
onClick={handleOpenCreate}
color="blue"
variant="light"
radius="md"
>
Tambah Realisasi
</Button>
</Group>
{/* Summary Cards */}
<Group grow mb="md">
<Paper withBorder p="md" radius="md" bg="blue.0">
<Text fz="xs" c="blue.9" fw={600}>
ANGGARAN
</Text>
<Text fz="lg" c="blue.9" fw={700}>
{formatRupiah(itemAnggaran)}
</Text>
</Paper>
<Paper withBorder p="md" radius="md" bg="teal.0">
<Text fz="xs" c="teal.9" fw={600}>
TOTAL REALISASI
</Text>
<Text fz="lg" c="teal.9" fw={700}>
{formatRupiah(itemTotalRealisasi)}
</Text>
</Paper>
<Paper withBorder p="md" radius="md" bg={getSisaAnggaran() >= 0 ? 'green.0' : 'red.0'}>
<Text fz="xs" c={getSisaAnggaran() >= 0 ? 'green.9' : 'red.9'} fw={600}>
SISA ANGGARAN
</Text>
<Text fz="lg" c={getSisaAnggaran() >= 0 ? 'green.9' : 'red.9'} fw={700}>
{formatRupiah(getSisaAnggaran())}
</Text>
</Paper>
<Paper withBorder p="md" radius="md" bg={getPersentaseColor(itemPersentase) + '.0'}>
<Text fz="xs" c={getPersentaseColor(itemPersentase) + '.9'} fw={600}>
PERSENTASE
</Text>
<Text fz="lg" c={getPersentaseColor(itemPersentase) + '.9'} fw={700}>
{itemPersentase.toFixed(2)}%
</Text>
</Paper>
</Group>
{/* Realisasi List */}
{realisasiItems && realisasiItems.length > 0 ? (
<Box>
<Text fz="sm" fw={600} mb="xs">
Daftar Realisasi ({realisasiItems.length})
</Text>
<Box style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover fz="sm">
<TableThead>
<TableTr>
<TableTh>Tanggal</TableTh>
<TableTh>Uraian</TableTh>
<TableTh ta="right">Jumlah</TableTh>
<TableTh ta="center">Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{realisasiItems.map((realisasi) => (
<TableTr key={realisasi.id}>
<TableTd>
<Group gap="xs">
<IconCalendar size={16} />
<Text fz="sm">{formatDate(realisasi.tanggal)}</Text>
</Group>
</TableTd>
<TableTd>
<Text fz="sm">{realisasi.keterangan || '-'}</Text>
</TableTd>
<TableTd ta="right">
<Text fz="sm" fw={600} c="blue">
{formatRupiah(realisasi.jumlah)}
</Text>
</TableTd>
<TableTd ta="center">
<Group gap="xs" justify="center">
<ActionIcon
variant="light"
color="blue"
size="sm"
onClick={() => handleOpenEdit(realisasi)}
>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
size="sm"
onClick={() => handleDelete(realisasi.id)}
disabled={loading}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</Box>
</Box>
) : (
<Center py="xl">
<Stack align="center" gap="xs">
<Text fz="sm" c="dimmed">
Belum ada realisasi untuk item ini
</Text>
<Text fz="xs" c="dimmed">
Klik tombol "Tambah Realisasi" untuk menambahkan
</Text>
</Stack>
</Center>
)}
{/* Modal Create/Edit */}
<Modal
opened={modalOpened}
onClose={() => {
setModalOpened(false);
resetForm();
}}
title={
<Text fz="lg" fw={600}>
{editingId ? 'Edit Realisasi' : 'Tambah Realisasi Baru'}
</Text>
}
size="md"
centered
>
<Stack gap="md">
{/* Info Item */}
<Paper p="sm" bg="gray.0" radius="md">
<Text fz="xs" c="dimmed">
Item: {itemKode} - {itemUraian}
</Text>
<Text fz="xs" c="dimmed">
Anggaran: {formatRupiah(itemAnggaran)}
</Text>
<Text fz="xs" c="dimmed">
Sudah terealisasi: {formatRupiah(itemTotalRealisasi)}
</Text>
</Paper>
<NumberInput
label="Jumlah Realisasi (Rp)"
value={formData.jumlah}
onChange={(val) => setFormData({ ...formData, jumlah: Number(val) || 0 })}
leftSection={<IconCoin size={16} />}
thousandSeparator
min={0}
step={100000}
required
/>
<TextInput
label="Tanggal Realisasi"
type="date"
value={formData.tanggal}
onChange={(e) => setFormData({ ...formData, tanggal: e.target.value })}
leftSection={<IconCalendar size={18} />}
required
/>
<TextInput
label="Keterangan / Uraian"
placeholder="Contoh: Penyaluran BLT Tahap 1"
value={formData.keterangan}
onChange={(e) => setFormData({ ...formData, keterangan: e.target.value })}
description="Deskripsi singkat tentang realisasi ini"
/>
<Divider my="xs" />
<Group justify="right">
<Button
variant="outline"
color="gray"
onClick={() => {
setModalOpened(false);
resetForm();
}}
disabled={loading}
>
Batal
</Button>
<Button
onClick={handleSubmit}
loading={loading}
color="blue"
leftSection={editingId ? <IconEdit size={16} /> : <IconPlus size={16} />}
>
{editingId ? 'Perbarui' : 'Tambah'} Realisasi
</Button>
</Group>
</Stack>
</Modal>
</Paper>
);
}

View File

@@ -42,7 +42,6 @@ type ItemForm = {
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
};
@@ -71,7 +70,6 @@ function EditAPBDes() {
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
@@ -157,32 +155,25 @@ function EditAPBDes() {
};
const handleAddItem = () => {
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
const { kode, uraian, anggaran, level, tipe } = newItem;
if (!kode || !uraian) {
return toast.warn('Kode dan uraian wajib diisi');
}
const finalTipe = level === 1 ? null : tipe;
const selisih = realisasi - anggaran;
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
apbdesState.edit.addItem({
kode,
uraian,
anggaran,
realisasi,
selisih,
persentase,
level,
tipe: finalTipe, // ✅ Tidak akan undefined
tipe: finalTipe,
});
setNewItem({
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
@@ -271,7 +262,6 @@ function EditAPBDes() {
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
@@ -514,13 +504,6 @@ function EditAPBDes() {
thousandSeparator
min={0}
/>
<NumberInput
label="Realisasi (Rp)"
value={newItem.realisasi}
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
thousandSeparator
min={0}
/>
</Group>
<Button
leftSection={<IconPlus size={16} />}
@@ -544,7 +527,6 @@ function EditAPBDes() {
<th>Kode</th>
<th>Uraian</th>
<th>Anggaran</th>
<th>Realisasi</th>
<th>Level</th>
<th>Tipe</th>
<th style={{ width: '50px' }}>Aksi</th>
@@ -560,7 +542,6 @@ function EditAPBDes() {
</td>
<td>{item.uraian}</td>
<td>{item.anggaran.toLocaleString('id-ID')}</td>
<td>{item.realisasi.toLocaleString('id-ID')}</td>
<td>
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
L{item.level}
@@ -572,7 +553,7 @@ function EditAPBDes() {
{item.tipe}
</Badge>
) : (
'-'
<Text size="sm" c="dimmed">-</Text>
)}
</td>
<td>

View File

@@ -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 ? (
<Paper withBorder p="md" radius="md">
<Text fz="lg" fw="bold" mb="sm">
<Stack gap="md">
<Text fz="lg" fw="bold">
Rincian Pendapatan & Belanja ({data.items.length} item)
</Text>
<Box style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Uraian</TableTh>
<TableTh>Anggaran (Rp)</TableTh>
<TableTh>Realisasi (Rp)</TableTh>
<TableTh>Selisih (Rp)</TableTh>
<TableTh>Persentase (%)</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{[...data.items] // Create a new array before sorting
.sort((a, b) => a.kode.localeCompare(b.kode))
.map((item) => (
<TableTr key={item.id}>
<TableTd style={getIndent(item.level)}>
<Group>
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
<Text fz="sm" c="dimmed">{item.uraian}</Text>
</Group>
</TableTd>
<TableTd>{item.anggaran.toLocaleString('id-ID')}</TableTd>
<TableTd>{item.realisasi.toLocaleString('id-ID')}</TableTd>
<TableTd>
<Text c={item.selisih >= 0 ? 'green' : 'red'}>
{item.selisih.toLocaleString('id-ID')}
</Text>
</TableTd>
<TableTd>
<Text fw={500}>{item.persentase.toFixed(2)}%</Text>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</Box>
</Paper>
<Table striped highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Uraian</TableTh>
<TableTh>Anggaran (Rp)</TableTh>
<TableTh>Realisasi (Rp)</TableTh>
<TableTh>Selisih (Rp)</TableTh>
<TableTh>Persentase (%)</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{[...data.items]
.sort((a, b) => a.kode.localeCompare(b.kode))
.map((item) => (
<TableTr key={item.id}>
<TableTd style={getIndent(item.level)}>
<Group>
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
<Text fz="sm" c="dimmed">{item.uraian}</Text>
</Group>
</TableTd>
<TableTd>{item.anggaran.toLocaleString('id-ID')}</TableTd>
<TableTd>{item.totalRealisasi.toLocaleString('id-ID')}</TableTd>
<TableTd>
<Text c={item.selisih >= 0 ? 'green' : 'red'}>
{item.selisih.toLocaleString('id-ID')}
</Text>
</TableTd>
<TableTd>
<Text fw={500}>{item.persentase.toFixed(2)}%</Text>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
{/* Realisasi Manager untuk setiap item */}
{data.items.map((item: any) => (
<RealisasiManager
key={item.id}
itemId={item.id}
itemKode={item.kode}
itemUraian={item.uraian}
itemAnggaran={item.anggaran}
itemTotalRealisasi={item.totalRealisasi}
itemPersentase={item.persentase}
realisasiItems={item.realisasiItems || []}
/>
))}
</Stack>
) : (
<Text>Belum ada data item</Text>
)}

View File

@@ -33,7 +33,6 @@ type ItemForm = {
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
};
@@ -61,7 +60,6 @@ function CreateAPBDes() {
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
@@ -80,7 +78,6 @@ function CreateAPBDes() {
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
@@ -127,22 +124,17 @@ function CreateAPBDes() {
// Tambahkan item ke state
const handleAddItem = () => {
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
const { kode, uraian, anggaran, level, tipe } = newItem;
if (!kode || !uraian) {
return toast.warn("Kode dan uraian wajib diisi");
}
const finalTipe = level === 1 ? null : tipe;
const selisih = realisasi - anggaran;
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
stateAPBDes.create.addItem({
kode,
uraian,
anggaran,
realisasi,
selisih,
persentase,
level,
tipe: finalTipe,
});
@@ -152,7 +144,6 @@ function CreateAPBDes() {
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
@@ -427,13 +418,6 @@ function CreateAPBDes() {
thousandSeparator
min={0}
/>
<NumberInput
label="Realisasi (Rp)"
value={newItem.realisasi}
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
thousandSeparator
min={0}
/>
</Group>
<Button
leftSection={<IconPlus size={16} />}
@@ -455,28 +439,30 @@ function CreateAPBDes() {
<th>Kode</th>
<th>Uraian</th>
<th>Anggaran</th>
<th>Realisasi</th>
<th>Level</th>
<th>Tipe</th>
<th style={{ width: 50 }}>Aksi</th>
</tr>
</thead>
<tbody>
{stateAPBDes.create.form.items.map((item, idx) => (
{stateAPBDes.create.form.items.map((item: any, idx) => (
<tr key={idx}>
<td><Text size="sm" fw={500}>{item.kode}</Text></td>
<td>{item.uraian}</td>
<td>{item.anggaran.toLocaleString('id-ID')}</td>
<td>{item.realisasi.toLocaleString('id-ID')}</td>
<td>
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
L{item.level}
</Badge>
</td>
<td>
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
{item.tipe}
</Badge>
{item.tipe ? (
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
{item.tipe}
</Badge>
) : (
<Text size="sm" c="dimmed">-</Text>
)}
</td>
<td>
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>

View File

@@ -8,9 +8,6 @@ type APBDesItemInput = {
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
selisih: number;
persentase: number;
level: number;
tipe?: string | null;
};
@@ -27,8 +24,7 @@ type FormCreate = {
export default async function apbdesCreate(context: Context) {
const body = context.body as FormCreate;
// Log the incoming request for debugging
console.log('Incoming request body:', JSON.stringify(body, null, 2));
try {
@@ -46,7 +42,7 @@ export default async function apbdesCreate(context: Context) {
throw new Error('At least one item is required');
}
// 1. Buat APBDes + items (tanpa parentId dulu)
// 1. Buat APBDes + items dengan auto-calculate fields
const created = await prisma.$transaction(async (prisma) => {
const apbdes = await prisma.aPBDes.create({
data: {
@@ -59,22 +55,26 @@ export default async function apbdesCreate(context: Context) {
},
});
// Create items in a batch
// Create items dengan auto-calculate totalRealisasi=0, selisih, persentase
const items = await Promise.all(
body.items.map(item => {
// Create a new object with only the fields that exist in the APBDesItem model
body.items.map(async item => {
const anggaran = item.anggaran;
const totalRealisasi = 0; // Belum ada realisasi saat create
const selisih = anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0;
const itemData = {
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
realisasi: item.realisasi,
selisih: item.selisih,
persentase: item.persentase,
anggaran: anggaran,
level: item.level,
tipe: item.tipe, // ✅ sertakan, biar null
tipe: item.tipe || null,
totalRealisasi,
selisih,
persentase,
apbdesId: apbdes.id,
};
return prisma.aPBDesItem.create({
data: itemData,
select: { id: true, kode: true },
@@ -89,20 +89,27 @@ export default async function apbdesCreate(context: Context) {
// 2. Isi parentId berdasarkan kode
await assignParentIdsToApbdesItems(created.items);
// 3. Ambil ulang data lengkap untuk response
// 3. Ambil ulang data lengkap untuk response (include realisasiItems)
const result = await prisma.aPBDes.findUnique({
where: { id: created.id },
include: {
image: true,
file: true,
items: {
where: { isActive: true },
orderBy: { kode: 'asc' },
include: {
realisasiItems: {
where: { isActive: true },
orderBy: { tanggal: 'asc' },
},
},
},
},
});
console.log('APBDes created successfully:', JSON.stringify(result, null, 2));
return {
success: true,
message: "Berhasil membuat APBDes",
@@ -110,7 +117,6 @@ export default async function apbdesCreate(context: Context) {
};
} catch (innerError) {
console.error('Error in post-creation steps:', innerError);
// Even if post-creation steps fail, we still return success since the main record was created
return {
success: true,
message: "APBDes berhasil dibuat, tetapi ada masalah dengan pemrosesan tambahan",
@@ -120,13 +126,12 @@ export default async function apbdesCreate(context: Context) {
}
} catch (error: any) {
console.error("Error creating APBDes:", error);
// Log the full error for debugging
if (error.code) console.error('Prisma error code:', error.code);
if (error.meta) console.error('Prisma error meta:', error.meta);
const errorMessage = error.message || 'Unknown error';
context.set.status = 500;
return {
success: false,

View File

@@ -21,7 +21,7 @@ export default async function apbdesFindMany(context: Context) {
try {
const where: any = { isActive: true };
if (search) {
where.OR = [
{ name: { contains: search, mode: "insensitive" } },
@@ -51,7 +51,10 @@ export default async function apbdesFindMany(context: Context) {
where: { isActive: true },
orderBy: { kode: "asc" },
include: {
parent: true,
realisasiItems: {
where: { isActive: true },
orderBy: { tanggal: 'asc' },
},
},
},
},

View File

@@ -2,15 +2,9 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function apbdesFindUnique(context: Context) {
// ✅ Parse URL secara manual
const url = new URL(context.request.url);
const pathSegments = url.pathname.split('/').filter(Boolean);
console.log("🔍 DEBUG INFO:");
console.log("- Full URL:", context.request.url);
console.log("- Pathname:", url.pathname);
console.log("- Path segments:", pathSegments);
// Expected: ['api', 'landingpage', 'apbdes', 'ID']
if (pathSegments.length < 4) {
context.set.status = 400;
@@ -20,9 +14,9 @@ export default async function apbdesFindUnique(context: Context) {
debug: { pathSegments }
};
}
if (pathSegments[0] !== 'api' ||
pathSegments[1] !== 'landingpage' ||
if (pathSegments[0] !== 'api' ||
pathSegments[1] !== 'landingpage' ||
pathSegments[2] !== 'apbdes') {
context.set.status = 400;
return {
@@ -31,9 +25,9 @@ export default async function apbdesFindUnique(context: Context) {
debug: { pathSegments }
};
}
const id = pathSegments[3]; // ✅ ID ada di index ke-3
const id = pathSegments[3];
if (!id || id.trim() === '') {
context.set.status = 400;
return {
@@ -50,7 +44,10 @@ export default async function apbdesFindUnique(context: Context) {
where: { isActive: true },
orderBy: { kode: 'asc' },
include: {
parent: true, // Include parent item for hierarchy
realisasiItems: {
where: { isActive: true },
orderBy: { tanggal: 'asc' },
},
},
},
image: true,

View File

@@ -5,17 +5,17 @@ import apbdesDelete from "./del";
import apbdesFindMany from "./findMany";
import apbdesFindUnique from "./findUnique";
import apbdesUpdate from "./updt";
import realisasiCreate from "./realisasi/create";
import realisasiUpdate from "./realisasi/update";
import realisasiDelete from "./realisasi/delete";
// Definisikan skema untuk item APBDes
// Definisikan skema untuk item APBDes (tanpa realisasi field)
const ApbdesItemSchema = t.Object({
kode: t.String(),
uraian: t.String(),
anggaran: t.Number(),
realisasi: t.Number(),
selisih: t.Number(),
persentase: t.Number(),
level: t.Number(),
tipe: t.Optional(t.Union([t.String(), t.Null()])) // misal: "pendapatan" atau "belanja"
tipe: t.Optional(t.Union([t.String(), t.Null()])), // "pendapatan" | "belanja" | "pembiayaan" | null
});
const APBDes = new Elysia({
@@ -26,10 +26,10 @@ const APBDes = new Elysia({
// ✅ Find all (dengan query opsional: page, limit, tahun)
.get("/findMany", apbdesFindMany)
// ✅ Find by ID
// ✅ Find by ID (include realisasiItems)
.get("/:id", apbdesFindUnique)
// ✅ Create
// ✅ Create APBDes dengan items (tanpa realisasi)
.post("/create", apbdesCreate, {
body: t.Object({
tahun: t.Number(),
@@ -42,7 +42,7 @@ const APBDes = new Elysia({
}),
})
// ✅ Update
// ✅ Update APBDes dengan items (tanpa realisasi)
.put("/:id", apbdesUpdate, {
params: t.Object({ id: t.String() }),
body: t.Object({
@@ -56,9 +56,40 @@ const APBDes = new Elysia({
}),
})
// ✅ Delete
// ✅ Delete APBDes
.delete("/del/:id", apbdesDelete, {
params: t.Object({ id: t.String() }),
})
// =========================================
// REALISASI ENDPOINTS
// =========================================
// ✅ Create realisasi untuk item tertentu
.post("/:itemId/realisasi", realisasiCreate, {
params: t.Object({ itemId: t.String() }),
body: t.Object({
jumlah: t.Number(),
tanggal: t.String(),
keterangan: t.Optional(t.String()),
buktiFileId: t.Optional(t.String()),
}),
})
// ✅ Update realisasi
.put("/realisasi/:realisasiId", realisasiUpdate, {
params: t.Object({ realisasiId: t.String() }),
body: t.Object({
jumlah: t.Optional(t.Number()),
tanggal: t.Optional(t.String()),
keterangan: t.Optional(t.String()),
buktiFileId: t.Optional(t.String()),
}),
})
// ✅ Delete realisasi
.delete("/realisasi/:realisasiId", realisasiDelete, {
params: t.Object({ realisasiId: t.String() }),
});
export default APBDes;

View File

@@ -0,0 +1,82 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
type RealisasiCreateBody = {
jumlah: number;
tanggal: string; // ISO format
keterangan?: string;
buktiFileId?: string;
};
export default async function realisasiCreate(context: Context) {
const { itemId } = context.params as { itemId: string };
const body = context.body as RealisasiCreateBody;
console.log('Creating realisasi:', JSON.stringify(body, null, 2));
try {
// 1. Pastikan APBDesItem ada
const item = await prisma.aPBDesItem.findUnique({
where: { id: itemId },
});
if (!item) {
context.set.status = 404;
return {
success: false,
message: "Item APBDes tidak ditemukan",
};
}
// 2. Create realisasi item
const realisasi = await prisma.realisasiItem.create({
data: {
apbdesItemId: itemId,
jumlah: body.jumlah,
tanggal: new Date(body.tanggal),
keterangan: body.keterangan,
buktiFileId: body.buktiFileId,
},
});
// 3. Update totalRealisasi, selisih, persentase di APBDesItem
const allRealisasi = await prisma.realisasiItem.findMany({
where: { apbdesItemId: itemId, isActive: true },
select: { jumlah: true },
});
const totalRealisasi = allRealisasi.reduce((sum, r) => sum + r.jumlah, 0);
const selisih = item.anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
await prisma.aPBDesItem.update({
where: { id: itemId },
data: {
totalRealisasi,
selisih,
persentase,
},
});
// 4. Return response
return {
success: true,
message: "Realisasi berhasil ditambahkan",
data: realisasi,
meta: {
totalRealisasi,
selisih,
persentase,
},
};
} catch (error: any) {
console.error("Error creating realisasi:", error);
context.set.status = 500;
return {
success: false,
message: `Gagal menambahkan realisasi: ${error.message}`,
error: process.env.NODE_ENV === 'development' ? error : undefined,
};
}
}

View File

@@ -0,0 +1,73 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function realisasiDelete(context: Context) {
const { realisasiId } = context.params as { realisasiId: string };
console.log('Deleting realisasi:', realisasiId);
try {
// 1. Pastikan realisasi ada
const existing = await prisma.realisasiItem.findUnique({
where: { id: realisasiId },
});
if (!existing) {
context.set.status = 404;
return {
success: false,
message: "Realisasi tidak ditemukan",
};
}
const apbdesItemId = existing.apbdesItemId;
// 2. Soft delete realisasi (set isActive = false)
await prisma.realisasiItem.update({
where: { id: realisasiId },
data: {
isActive: false,
deletedAt: new Date(),
},
});
// 3. Recalculate totalRealisasi, selisih, persentase di APBDesItem
const allRealisasi = await prisma.realisasiItem.findMany({
where: { apbdesItemId, isActive: true },
select: { jumlah: true },
});
const item = await prisma.aPBDesItem.findUnique({
where: { id: apbdesItemId },
});
if (item) {
const totalRealisasi = allRealisasi.reduce((sum, r) => sum + r.jumlah, 0);
const selisih = item.anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
await prisma.aPBDesItem.update({
where: { id: apbdesItemId },
data: {
totalRealisasi,
selisih,
persentase,
},
});
}
return {
success: true,
message: "Realisasi berhasil dihapus",
};
} catch (error: any) {
console.error("Error deleting realisasi:", error);
context.set.status = 500;
return {
success: false,
message: `Gagal menghapus realisasi: ${error.message}`,
error: process.env.NODE_ENV === 'development' ? error : undefined,
};
}
}

View File

@@ -0,0 +1,85 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
type RealisasiUpdateBody = {
jumlah?: number;
tanggal?: string;
keterangan?: string;
buktiFileId?: string;
};
export default async function realisasiUpdate(context: Context) {
const { realisasiId } = context.params as { realisasiId: string };
const body = context.body as RealisasiUpdateBody;
console.log('Updating realisasi:', JSON.stringify(body, null, 2));
try {
// 1. Pastikan realisasi ada
const existing = await prisma.realisasiItem.findUnique({
where: { id: realisasiId },
});
if (!existing) {
context.set.status = 404;
return {
success: false,
message: "Realisasi tidak ditemukan",
};
}
// 2. Update realisasi
const updated = await prisma.realisasiItem.update({
where: { id: realisasiId },
data: {
...(body.jumlah !== undefined && { jumlah: body.jumlah }),
...(body.tanggal !== undefined && { tanggal: new Date(body.tanggal) }),
...(body.keterangan !== undefined && { keterangan: body.keterangan }),
...(body.buktiFileId !== undefined && { buktiFileId: body.buktiFileId }),
},
});
// 3. Recalculate totalRealisasi, selisih, persentase di APBDesItem
const allRealisasi = await prisma.realisasiItem.findMany({
where: { apbdesItemId: existing.apbdesItemId, isActive: true },
select: { jumlah: true },
});
const item = await prisma.aPBDesItem.findUnique({
where: { id: existing.apbdesItemId },
});
if (item) {
const totalRealisasi = allRealisasi.reduce((sum, r) => sum + r.jumlah, 0);
const selisih = item.anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
await prisma.aPBDesItem.update({
where: { id: existing.apbdesItemId },
data: {
totalRealisasi,
selisih,
persentase,
},
});
}
return {
success: true,
message: "Realisasi berhasil diperbarui",
data: updated,
meta: {
totalRealisasi: allRealisasi.reduce((sum, r) => sum + r.jumlah, 0),
},
};
} catch (error: any) {
console.error("Error updating realisasi:", error);
context.set.status = 500;
return {
success: false,
message: `Gagal memperbarui realisasi: ${error.message}`,
error: process.env.NODE_ENV === 'development' ? error : undefined,
};
}
}

View File

@@ -6,9 +6,6 @@ type APBDesItemInput = {
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
selisih: number;
persentase: number;
level: number;
tipe?: string | null;
};
@@ -41,25 +38,32 @@ export default async function apbdesUpdate(context: Context) {
};
}
// 2. Hapus semua item lama
// 2. Hapus semua item lama (cascade akan menghapus realisasiItems juga)
await prisma.aPBDesItem.deleteMany({
where: { apbdesId: id },
});
// 3. Buat item baru tanpa parentId terlebih dahulu
// 3. Buat item baru dengan auto-calculate fields
await prisma.aPBDesItem.createMany({
data: body.items.map((item) => ({
apbdesId: id,
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
realisasi: item.realisasi,
selisih: item.anggaran - item.realisasi,
persentase: item.anggaran > 0 ? (item.realisasi / item.anggaran) * 100 : 0,
level: item.level,
tipe: item.tipe || null,
isActive: true,
})),
data: body.items.map((item) => {
const anggaran = item.anggaran;
const totalRealisasi = 0; // Reset karena items baru
const selisih = anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0;
return {
apbdesId: id,
kode: item.kode,
uraian: item.uraian,
anggaran: anggaran,
level: item.level,
tipe: item.tipe || null,
totalRealisasi,
selisih,
persentase,
isActive: true,
};
}),
});
// 4. Dapatkan semua item yang baru dibuat untuk mendapatkan ID-nya
@@ -69,12 +73,11 @@ export default async function apbdesUpdate(context: Context) {
});
// 5. Update parentId untuk setiap item
// Pastikan allItems memiliki tipe yang benar
const itemsForParentUpdate = allItems.map(item => ({
id: item.id,
kode: item.kode,
}));
await assignParentIdsToApbdesItems(itemsForParentUpdate);
// 6. Update data APBDes
@@ -90,13 +93,19 @@ export default async function apbdesUpdate(context: Context) {
},
});
// 5. Ambil data lengkap untuk response
// 7. Ambil data lengkap untuk response (include realisasiItems)
const result = await prisma.aPBDes.findUnique({
where: { id },
include: {
items: {
where: { isActive: true },
orderBy: { kode: 'asc' }
orderBy: { kode: 'asc' },
include: {
realisasiItems: {
where: { isActive: true },
orderBy: { tanggal: 'asc' },
},
},
},
image: true,
file: true,

View File

@@ -2,12 +2,9 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
import APBDesProgress from '@/app/darmasaba/(tambahan)/apbdes/lib/apbDesaProgress'
import { transformAPBDesData } from '@/app/darmasaba/(tambahan)/apbdes/lib/types'
import colors from '@/con/colors'
import {
ActionIcon,
BackgroundImage,
Box,
Button,
Center,
@@ -23,6 +20,9 @@ import { IconDownload } from '@tabler/icons-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useProxy } from 'valtio/utils'
import PaguTable from './lib/paguTable'
import RealisasiTable from './lib/realisasiTable'
import GrafikRealisasi from './lib/grafikRealisasi'
function Apbdes() {
const state = useProxy(apbdes)
@@ -51,18 +51,17 @@ function Apbdes() {
const dataAPBDes = state.findMany.data || []
const years = Array.from(
new Set(
dataAPBDes
.map((item: any) => item?.tahun)
.filter((tahun): tahun is number => typeof tahun === 'number')
new Set(
dataAPBDes
.map((item: any) => item?.tahun)
.filter((tahun): tahun is number => typeof tahun === 'number')
)
)
)
.sort((a, b) => b - a)
.map(year => ({
value: year.toString(),
label: `Tahun ${year}`,
}))
.sort((a, b) => b - a)
.map(year => ({
value: year.toString(),
label: `Tahun ${year}`,
}))
useEffect(() => {
if (years.length > 0 && !selectedYear) {
@@ -71,7 +70,7 @@ function Apbdes() {
}, [years, selectedYear])
const currentApbdes = dataAPBDes.length > 0
? transformAPBDesData(dataAPBDes.find(item => item?.tahun?.toString() === selectedYear) || dataAPBDes[0])
? dataAPBDes.find((item: any) => item?.tahun?.toString() === selectedYear) || dataAPBDes[0]
: null
const data = (state.findMany.data || []).slice(0, 3)
@@ -131,18 +130,24 @@ function Apbdes() {
/>
</Box>
{/* Progress */}
{currentApbdes ? (
<APBDesProgress apbdesData={currentApbdes} />
) : (
{/* Tabel & Grafik - Hanya tampilkan jika ada data */}
{currentApbdes && currentApbdes.items?.length > 0 ? (
<Box px={{ base: 'md', md: 100 }}>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<PaguTable apbdesData={currentApbdes} />
<RealisasiTable apbdesData={currentApbdes} />
<GrafikRealisasi apbdesData={currentApbdes} />
</SimpleGrid>
</Box>
) : currentApbdes ? (
<Box px={{ base: 'md', md: 100 }} py="md">
<Text fz="sm" c="dimmed" ta="center" lh={1.5}>
Tidak ada data APBDes untuk tahun yang dipilih.
Tidak ada data item untuk tahun yang dipilih.
</Text>
</Box>
)}
) : null}
{/* GRID */}
{/* GRID - Card Preview */}
{loading ? (
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
<Loader size="lg" color="blue" />
@@ -165,14 +170,18 @@ function Apbdes() {
spacing="lg"
pb="xl"
>
{data.map((v, k) => (
<BackgroundImage
{data.map((v: any, k: number) => (
<Box
key={k}
src={v.image?.link || ''}
h={360}
radius="xl"
pos="relative"
style={{ overflow: 'hidden' }}
style={{
backgroundImage: `url(${v.image?.link || ''})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
borderRadius: 16,
height: 360,
overflow: 'hidden',
}}
>
<Box pos="absolute" inset={0} bg="rgba(0,0,0,0.45)" style={{ borderRadius: 16 }} />
@@ -185,7 +194,7 @@ function Apbdes() {
lh={1.35}
lineClamp={2}
>
{v.name}
{v.name || `APBDes Tahun ${v.tahun}`}
</Text>
<Text
@@ -196,7 +205,7 @@ function Apbdes() {
lh={1}
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
>
{v.jumlah}
{v.jumlah || '-'}
</Text>
<Center>
@@ -212,7 +221,7 @@ function Apbdes() {
</ActionIcon>
</Center>
</Stack>
</BackgroundImage>
</Box>
))}
</SimpleGrid>
)}
@@ -220,4 +229,4 @@ function Apbdes() {
)
}
export default Apbdes
export default Apbdes

View File

@@ -0,0 +1,134 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Title, Progress, Stack, Text, Group, Box } from '@mantine/core';
function Summary({ title, data }: any) {
if (!data || data.length === 0) return null;
const totalAnggaran = data.reduce((s: number, i: any) => s + i.anggaran, 0);
const totalRealisasi = data.reduce((s: number, i: any) => s + i.realisasi, 0);
const persen =
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);
};
// 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';
};
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>
<Text fz="sm" c="dimmed" mb="xs">
Realisasi: {formatRupiah(totalRealisasi)} / Anggaran: {formatRupiah(totalAnggaran)}
</Text>
<Progress
value={persen}
size="xl"
radius="xl"
color={getProgressColor(persen)}
striped={persen < 100}
animated={persen < 100}
/>
{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>
)}
</Box>
);
}
export default function GrafikRealisasi({ apbdesData }: any) {
const items = apbdesData.items || [];
const tahun = apbdesData.tahun || new Date().getFullYear();
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');
// Hitung total keseluruhan
const totalAnggaranSemua = items.reduce((s: number, i: any) => s + i.anggaran, 0);
const totalRealisasiSemua = items.reduce((s: number, i: any) => s + i.realisasi, 0);
const persenSemua = totalAnggaranSemua > 0 ? (totalRealisasiSemua / totalAnggaranSemua) * 100 : 0;
const formatRupiah = (angka: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(angka);
};
return (
<Paper withBorder p="md" radius="md">
<Title order={5} mb="md">
GRAFIK REALISASI APBDes {tahun}
</Title>
<Stack gap="lg">
<Summary title="💰 Pendapatan" data={pendapatan} />
<Summary title="💸 Belanja" data={belanja} />
<Summary title="📊 Pembiayaan" data={pembiayaan} />
</Stack>
{/* Summary Total Keseluruhan */}
<Box mb="lg" p="md" bg="gray.0">
<Group justify="space-between" mb="xs">
<Text fw={700} fz="lg">TOTAL KESELURUHAN</Text>
<Text fw={700} fz="xl" c={persenSemua >= 100 ? 'teal' : persenSemua >= 80 ? 'blue' : 'red'}>
{persenSemua.toFixed(2)}%
</Text>
</Group>
<Text fz="sm" c="dimmed" mb="xs">
{formatRupiah(totalRealisasiSemua)} / {formatRupiah(totalAnggaranSemua)}
</Text>
<Progress
value={persenSemua}
size="lg"
radius="xl"
color={persenSemua >= 100 ? 'teal' : persenSemua >= 80 ? 'blue' : 'red'}
/>
</Box>
</Paper>
);
}

View File

@@ -0,0 +1,60 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Table, Title } from '@mantine/core';
function Section({ title, data }: any) {
if (!data || data.length === 0) return null;
return (
<>
<Table.Tr>
<Table.Td colSpan={2}>
<strong>{title}</strong>
</Table.Td>
</Table.Tr>
{data.map((item: any) => (
<Table.Tr key={item.id}>
<Table.Td>
{item.kode} - {item.uraian}
</Table.Td>
<Table.Td ta="right">
Rp {item.anggaran.toLocaleString('id-ID')}
</Table.Td>
</Table.Tr>
))}
</>
);
}
export default function PaguTable({ apbdesData }: any) {
const items = apbdesData.items || [];
const title =
apbdesData.tahun
? `PAGU APBDes Tahun ${apbdesData.tahun}`
: 'PAGU APBDes';
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');
return (
<Paper withBorder p="md" radius="md">
<Title order={5} mb="md">{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>
</Paper>
);
}

View File

@@ -0,0 +1,74 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Table, Title, Badge } from '@mantine/core';
function Section({ title, data }: any) {
if (!data || data.length === 0) return null;
return (
<>
<Table.Tr>
<Table.Td colSpan={3}>
<strong>{title}</strong>
</Table.Td>
</Table.Tr>
{data.map((item: any) => (
<Table.Tr key={item.id}>
<Table.Td>
{item.kode} - {item.uraian}
</Table.Td>
<Table.Td ta="right">
Rp {item.totalRealisasi.toLocaleString('id-ID')}
</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>
))}
</>
);
}
export default function RealisasiTable({ apbdesData }: any) {
const items = apbdesData.items || [];
const title =
apbdesData.tahun
? `REALISASI APBDes Tahun ${apbdesData.tahun}`
: 'REALISASI APBDes';
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');
return (
<Paper withBorder p="md" radius="md">
<Title order={5} mb="md">{title}</Title>
<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>
<Section title="1) PENDAPATAN" data={pendapatan} />
<Section title="2) BELANJA" data={belanja} />
<Section title="3) PEMBIAYAAN" data={pembiayaan} />
</Table.Tbody>
</Table>
</Paper>
);
}