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>
This commit is contained in:
2026-03-03 14:55:06 +08:00
parent f4705690a9
commit 63682e47b6
3 changed files with 98 additions and 67 deletions

View File

@@ -5,18 +5,23 @@ import { toast } from "react-toastify";
import { proxy } from "valtio"; import { proxy } from "valtio";
import { z } from "zod"; import { z } from "zod";
// --- Zod Schema --- // --- Zod Schema untuk APBDes Item (tanpa 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: z.number().min(0, "Anggaran tidak boleh negatif"),
realisasi: z.number().min(0),
selisih: z.number(),
persentase: z.number(),
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(),
}); });
// --- Zod Schema untuk Realisasi Item ---
const RealisasiItemSchema = z.object({
jumlah: z.number().min(0, "Jumlah tidak boleh negatif"),
tanggal: z.string(),
keterangan: z.string().optional(),
buktiFileId: z.string().optional(),
});
const ApbdesFormSchema = z.object({ const ApbdesFormSchema = z.object({
tahun: z.number().int().min(2000, "Tahun tidak valid"), tahun: z.number().int().min(2000, "Tahun tidak valid"),
name: z.string().optional(), name: z.string().optional(),
@@ -38,26 +43,14 @@ const defaultApbdesForm = {
items: [] as z.infer<typeof ApbdesItemSchema>[], items: [] as z.infer<typeof ApbdesItemSchema>[],
}; };
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) --- // --- Helper: Normalize item (tanpa kalkulasi, backend yang hitung) ---
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> { 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 { return {
kode: item.kode || "", kode: item.kode || "",
uraian: item.uraian || "", uraian: item.uraian || "",
anggaran, anggaran: item.anggaran ?? 0,
realisasi,
selisih,
persentase,
level: item.level || 1, 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, kode: item.kode,
uraian: item.uraian, uraian: item.uraian,
anggaran: item.anggaran, anggaran: item.anggaran,
realisasi: item.realisasi,
selisih: item.selisih,
persentase: item.persentase,
level: item.level, level: item.level,
tipe: item.tipe || 'pendapatan', tipe: item.tipe || 'pendapatan',
})), })),
@@ -326,6 +316,80 @@ const apbdes = proxy({
this.form = { ...defaultApbdesForm }; 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; export default apbdes;

View File

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

View File

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