fix(apbdes-edit): preserve realisasi data when editing APBDes

- Fix backend updt.ts to preserve realisasiItems from old items
  - Load existing items with realisasiItems before delete
  - Re-create realisasiItems for new items based on kode match
  - Recalculate totalRealisasi, selisih, persentase after restore

- Update frontend state to handle realisasi fields
  - Add realisasi, selisih, persentase to ApbdesItemSchema
  - Fix edit.load() to map totalRealisasi → realisasi
  - Fix edit.update() to omit calculated fields when sending to backend

- Update edit page.tsx to display realisasi data
  - Fix load data to use item.totalRealisasi (not item.realisasi)
  - Add Realisasi, Selisih, % columns to items table
  - Update handleAddItem and handleReset to preserve realisasi fields

Root cause: Backend was resetting totalRealisasi=0 for all items on update,
and frontend was accessing wrong field name (realisasi vs totalRealisasi)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-03-05 11:20:45 +08:00
parent f63aaf916d
commit f90477ed63
4 changed files with 152 additions and 26 deletions

View File

@@ -5,13 +5,17 @@ import { toast } from "react-toastify";
import { proxy } from "valtio"; import { proxy } from "valtio";
import { z } from "zod"; import { z } from "zod";
// --- Zod Schema untuk APBDes Item (tanpa field kalkulasi) --- // --- Zod Schema untuk APBDes Item (dengan field kalkulasi) ---
const ApbdesItemSchema = z.object({ const ApbdesItemSchema = z.object({
kode: z.string().min(1, "Kode wajib diisi"), kode: z.string().min(1, "Kode wajib diisi"),
uraian: z.string().min(1, "Uraian wajib diisi"), uraian: z.string().min(1, "Uraian wajib diisi"),
anggaran: z.number().min(0, "Anggaran tidak boleh negatif"), anggaran: z.number().min(0, "Anggaran tidak boleh negatif"),
level: z.number().int().min(1).max(3), level: z.number().int().min(1).max(3),
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(), tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
// Field kalkulasi dari realisasiItems (auto-calculated di backend)
realisasi: z.number().min(0).default(0),
selisih: z.number().default(0),
persentase: z.number().default(0),
}); });
const ApbdesFormSchema = z.object({ const ApbdesFormSchema = z.object({
@@ -35,7 +39,7 @@ const defaultApbdesForm = {
items: [] as z.infer<typeof ApbdesItemSchema>[], items: [] as z.infer<typeof ApbdesItemSchema>[],
}; };
// --- Helper: Normalize item (tanpa kalkulasi, backend yang hitung) --- // --- Helper: Normalize item (dengan field kalkulasi) ---
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> { function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
return { return {
kode: item.kode || "", kode: item.kode || "",
@@ -43,6 +47,9 @@ function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer
anggaran: item.anggaran ?? 0, anggaran: item.anggaran ?? 0,
level: item.level || 1, level: item.level || 1,
tipe: item.tipe ?? null, tipe: item.tipe ?? null,
realisasi: item.realisasi ?? 0,
selisih: item.selisih ?? 0,
persentase: item.persentase ?? 0,
}; };
} }
@@ -248,6 +255,9 @@ const apbdes = proxy({
kode: item.kode, kode: item.kode,
uraian: item.uraian, uraian: item.uraian,
anggaran: item.anggaran, anggaran: item.anggaran,
realisasi: item.totalRealisasi || 0,
selisih: item.selisih || 0,
persentase: item.persentase || 0,
level: item.level, level: item.level,
tipe: item.tipe || 'pendapatan', tipe: item.tipe || 'pendapatan',
})), })),
@@ -275,11 +285,24 @@ const apbdes = proxy({
try { try {
this.loading = true; this.loading = true;
// Include the ID in the request body // Include the ID in the request body
// Omit realisasi, selisih, persentase karena itu calculated fields di backend
const requestData = { const requestData = {
...parsed.data, tahun: parsed.data.tahun,
id: this.id, // Add the ID to the request body name: parsed.data.name,
deskripsi: parsed.data.deskripsi,
jumlah: parsed.data.jumlah,
imageId: parsed.data.imageId,
fileId: parsed.data.fileId,
id: this.id,
items: parsed.data.items.map(item => ({
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
level: item.level,
tipe: item.tipe ?? null,
})),
}; };
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData); const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
if (res.data?.success) { if (res.data?.success) {

View File

@@ -44,6 +44,9 @@ type ItemForm = {
anggaran: number; anggaran: number;
level: number; level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan'; tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
realisasi?: number;
selisih?: number;
persentase?: number;
}; };
function EditAPBDes() { function EditAPBDes() {
@@ -72,6 +75,9 @@ function EditAPBDes() {
anggaran: 0, anggaran: 0,
level: 1, level: 1,
tipe: 'pendapatan', tipe: 'pendapatan',
realisasi: 0,
selisih: 0,
persentase: 0,
}); });
// Simpan data original untuk reset form // Simpan data original untuk reset form
@@ -125,9 +131,9 @@ function EditAPBDes() {
kode: item.kode, kode: item.kode,
uraian: item.uraian, uraian: item.uraian,
anggaran: item.anggaran, anggaran: item.anggaran,
realisasi: item.realisasi, realisasi: item.totalRealisasi || 0,
selisih: item.selisih, selisih: item.selisih || 0,
persentase: item.persentase, persentase: item.persentase || 0,
level: item.level, level: item.level,
tipe: item.tipe || 'pendapatan', tipe: item.tipe || 'pendapatan',
})), })),
@@ -155,7 +161,7 @@ function EditAPBDes() {
}; };
const handleAddItem = () => { const handleAddItem = () => {
const { kode, uraian, anggaran, level, tipe } = newItem; const { kode, uraian, anggaran, level, tipe, realisasi, selisih, persentase } = newItem;
if (!kode || !uraian) { if (!kode || !uraian) {
return toast.warn('Kode dan uraian wajib diisi'); return toast.warn('Kode dan uraian wajib diisi');
} }
@@ -166,6 +172,9 @@ function EditAPBDes() {
kode, kode,
uraian, uraian,
anggaran, anggaran,
realisasi: realisasi || 0,
selisih: selisih || 0,
persentase: persentase || 0,
level, level,
tipe: finalTipe, tipe: finalTipe,
}); });
@@ -176,6 +185,9 @@ function EditAPBDes() {
anggaran: 0, anggaran: 0,
level: 1, level: 1,
tipe: 'pendapatan', tipe: 'pendapatan',
realisasi: 0,
selisih: 0,
persentase: 0,
}); });
}; };
@@ -264,6 +276,9 @@ function EditAPBDes() {
anggaran: 0, anggaran: 0,
level: 1, level: 1,
tipe: 'pendapatan', tipe: 'pendapatan',
realisasi: 0,
selisih: 0,
persentase: 0,
}); });
toast.info('Form dikembalikan ke data awal'); toast.info('Form dikembalikan ke data awal');
@@ -527,6 +542,9 @@ function EditAPBDes() {
<th>Kode</th> <th>Kode</th>
<th>Uraian</th> <th>Uraian</th>
<th>Anggaran</th> <th>Anggaran</th>
<th>Realisasi</th>
<th>Selisih</th>
<th>%</th>
<th>Level</th> <th>Level</th>
<th>Tipe</th> <th>Tipe</th>
<th style={{ width: '50px' }}>Aksi</th> <th style={{ width: '50px' }}>Aksi</th>
@@ -542,6 +560,11 @@ function EditAPBDes() {
</td> </td>
<td>{item.uraian}</td> <td>{item.uraian}</td>
<td>{item.anggaran.toLocaleString('id-ID')}</td> <td>{item.anggaran.toLocaleString('id-ID')}</td>
<td>{item.realisasi?.toLocaleString('id-ID') || '0'}</td>
<td style={{ color: item.selisih && item.selisih > 0 ? 'red' : 'green' }}>
{item.selisih?.toLocaleString('id-ID') || '0'}
</td>
<td>{item.persentase?.toFixed(2) || '0'}%</td>
<td> <td>
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}> <Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
L{item.level} L{item.level}

View File

@@ -28,6 +28,16 @@ export default async function apbdesUpdate(context: Context) {
// 1. Pastikan APBDes ada // 1. Pastikan APBDes ada
const existing = await prisma.aPBDes.findUnique({ const existing = await prisma.aPBDes.findUnique({
where: { id }, where: { id },
include: {
items: {
where: { isActive: true },
include: {
realisasiItems: {
where: { isActive: true },
},
},
},
},
}); });
if (!existing) { if (!existing) {
@@ -38,17 +48,35 @@ export default async function apbdesUpdate(context: Context) {
}; };
} }
// 2. Hapus semua item lama (cascade akan menghapus realisasiItems juga) // 2. Build map untuk preserve realisasiItems berdasarkan kode
const existingItemsMap = new Map<string, {
id: string;
realisasiItems: any[];
}>();
existing.items.forEach(item => {
existingItemsMap.set(item.kode, {
id: item.id,
realisasiItems: item.realisasiItems,
});
});
// 3. Hapus semua item lama (cascade akan menghapus realisasiItems juga)
// TAPI kita sudah save realisasiItems di map atas
await prisma.aPBDesItem.deleteMany({ await prisma.aPBDesItem.deleteMany({
where: { apbdesId: id }, where: { apbdesId: id },
}); });
// 3. Buat item baru dengan auto-calculate fields // 4. Buat item baru dengan preserve realisasiItems
await prisma.aPBDesItem.createMany({ await prisma.aPBDesItem.createMany({
data: body.items.map((item) => { data: await Promise.all(body.items.map(async (item) => {
const anggaran = item.anggaran; const anggaran = item.anggaran;
const totalRealisasi = 0; // Reset karena items baru
const selisih = anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan) // Check apakah item ini punya realisasiItems lama
const existingItem = existingItemsMap.get(item.kode);
const realisasiItemsData = existingItem?.realisasiItems || [];
const totalRealisasi = realisasiItemsData.reduce((sum, r) => sum + r.jumlah, 0);
const selisih = anggaran - totalRealisasi;
const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0; const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0;
return { return {
@@ -63,16 +91,68 @@ export default async function apbdesUpdate(context: Context) {
persentase, persentase,
isActive: true, isActive: true,
}; };
}), })),
}); });
// 4. Dapatkan semua item yang baru dibuat untuk mendapatkan ID-nya // 5. Dapatkan semua item yang baru dibuat untuk mendapatkan ID-nya
const allItems = await prisma.aPBDesItem.findMany({ const allItems = await prisma.aPBDesItem.findMany({
where: { apbdesId: id }, where: { apbdesId: id },
select: { id: true, kode: true }, select: { id: true, kode: true },
}); });
// 5. Update parentId untuk setiap item // 6. Build map baru untuk item IDs
const newItemIdsMap = new Map<string, string>();
allItems.forEach(item => {
newItemIdsMap.set(item.kode, item.id);
});
// 7. Re-create realisasiItems dengan link ke item IDs yang baru
for (const [oldKode, oldItemData] of existingItemsMap.entries()) {
if (oldItemData.realisasiItems.length > 0) {
const newItemId = newItemIdsMap.get(oldKode);
if (newItemId) {
// Re-create realisasiItems untuk item ini
await prisma.realisasiItem.createMany({
data: oldItemData.realisasiItems.map(r => ({
apbdesItemId: newItemId,
kode: r.kode,
jumlah: r.jumlah,
tanggal: r.tanggal,
keterangan: r.keterangan,
buktiFileId: r.buktiFileId,
isActive: true,
})),
});
}
}
}
// 8. Recalculate totalRealisasi setelah re-create realisasiItems
for (const [kode, _] of existingItemsMap.entries()) {
const newItemId = newItemIdsMap.get(kode);
if (newItemId) {
const realisasiItems = await prisma.realisasiItem.findMany({
where: { apbdesItemId: newItemId, isActive: true },
});
const totalRealisasi = realisasiItems.reduce((sum, r) => sum + r.jumlah, 0);
const item = await prisma.aPBDesItem.findUnique({
where: { id: newItemId },
});
if (item) {
const selisih = item.anggaran - totalRealisasi;
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
await prisma.aPBDesItem.update({
where: { id: newItemId },
data: { totalRealisasi, selisih, persentase },
});
}
}
}
// 9. Update parentId untuk setiap item
const itemsForParentUpdate = allItems.map(item => ({ const itemsForParentUpdate = allItems.map(item => ({
id: item.id, id: item.id,
kode: item.kode, kode: item.kode,
@@ -80,7 +160,7 @@ export default async function apbdesUpdate(context: Context) {
await assignParentIdsToApbdesItems(itemsForParentUpdate); await assignParentIdsToApbdesItems(itemsForParentUpdate);
// 6. Update data APBDes // 10. Update data APBDes
await prisma.aPBDes.update({ await prisma.aPBDes.update({
where: { id }, where: { id },
data: { data: {
@@ -93,7 +173,7 @@ export default async function apbdesUpdate(context: Context) {
}, },
}); });
// 7. Ambil data lengkap untuk response (include realisasiItems) // 11. Ambil data lengkap untuk response (include realisasiItems)
const result = await prisma.aPBDes.findUnique({ const result = await prisma.aPBDes.findUnique({
where: { id }, where: { id },
include: { include: {

View File

@@ -105,8 +105,14 @@ export default function GrafikRealisasi({ apbdesData }: any) {
GRAFIK REALISASI APBDes {tahun} GRAFIK REALISASI APBDes {tahun}
</Title> </Title>
<Stack gap="lg" mb="lg">
<Summary title="💰 Pendapatan" data={pendapatan} />
<Summary title="💸 Belanja" data={belanja} />
<Summary title="📊 Pembiayaan" data={pembiayaan} />
</Stack>
{/* Summary Total Keseluruhan */} {/* Summary Total Keseluruhan */}
<Box mb="lg" p="md" bg="gray.0"> <Box p="md" bg="gray.0">
<> <>
<Group justify="space-between" mb="xs"> <Group justify="space-between" mb="xs">
<Text fw={700} fz="lg">TOTAL KESELURUHAN</Text> <Text fw={700} fz="lg">TOTAL KESELURUHAN</Text>
@@ -125,12 +131,6 @@ export default function GrafikRealisasi({ apbdesData }: any) {
/> />
</> </>
</Box> </Box>
<Stack gap="lg">
<Summary title="💰 Pendapatan" data={pendapatan} />
<Summary title="💸 Belanja" data={belanja} />
<Summary title="📊 Pembiayaan" data={pembiayaan} />
</Stack>
</Paper> </Paper>
); );
} }