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:
@@ -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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
|
{item.tipe ? (
|
||||||
{item.tipe}
|
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
|
||||||
</Badge>
|
{item.tipe}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">-</Text>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>
|
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user