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>
This commit is contained in:
2026-03-03 15:13:58 +08:00
parent 63682e47b6
commit e0436cc384
3 changed files with 59 additions and 13 deletions

View File

@@ -5,11 +5,12 @@ import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
// --- Zod Schema untuk APBDes Item (tanpa field kalkulasi) ---
// --- Zod Schema untuk APBDes Item (dengan realisasiAwal opsional) ---
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, "Anggaran tidak boleh negatif"),
realisasiAwal: z.number().min(0).optional(), // Realisasi pertama saat create
level: z.number().int().min(1).max(3),
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
});
@@ -91,7 +92,21 @@ const apbdes = proxy({
try {
this.loading = true;
const res = await ApiFetch.api.landingpage.apbdes["create"].post(parsed.data);
// Extract realisasiAwal dari items
const itemsWithRealisasi = this.form.items.map(item => ({
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
level: item.level,
tipe: item.tipe,
realisasiAwal: item.realisasiAwal || 0,
}));
const res = await ApiFetch.api.landingpage.apbdes["create"].post({
...parsed.data,
items: itemsWithRealisasi,
});
if (res.data?.success) {
toast.success("APBDes berhasil dibuat");

View File

@@ -33,6 +33,7 @@ type ItemForm = {
kode: string;
uraian: string;
anggaran: number;
realisasiAwal?: number; // Realisasi pertama saat create
level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
};
@@ -60,6 +61,7 @@ function CreateAPBDes() {
kode: '',
uraian: '',
anggaran: 0,
realisasiAwal: 0,
level: 1,
tipe: 'pendapatan',
});
@@ -78,6 +80,7 @@ function CreateAPBDes() {
kode: '',
uraian: '',
anggaran: 0,
realisasiAwal: 0,
level: 1,
tipe: 'pendapatan',
});
@@ -124,7 +127,7 @@ function CreateAPBDes() {
// Tambahkan item ke state
const handleAddItem = () => {
const { kode, uraian, anggaran, level, tipe } = newItem;
const { kode, uraian, anggaran, realisasiAwal, level, tipe } = newItem;
if (!kode || !uraian) {
return toast.warn("Kode dan uraian wajib diisi");
}
@@ -135,6 +138,7 @@ function CreateAPBDes() {
kode,
uraian,
anggaran,
realisasiAwal: realisasiAwal || 0,
level,
tipe: finalTipe,
});
@@ -144,6 +148,7 @@ function CreateAPBDes() {
kode: '',
uraian: '',
anggaran: 0,
realisasiAwal: 0,
level: 1,
tipe: 'pendapatan',
});
@@ -418,6 +423,14 @@ function CreateAPBDes() {
thousandSeparator
min={0}
/>
<NumberInput
label="Realisasi Awal (Rp) - Opsional"
value={newItem.realisasiAwal}
onChange={(val) => setNewItem({ ...newItem, realisasiAwal: Number(val) || 0 })}
thousandSeparator
min={0}
description="Isi jika sudah ada realisasi saat create"
/>
</Group>
<Button
leftSection={<IconPlus size={16} />}
@@ -439,17 +452,19 @@ function CreateAPBDes() {
<th>Kode</th>
<th>Uraian</th>
<th>Anggaran</th>
<th>Realisasi Awal</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.realisasiAwal || 0).toLocaleString('id-ID')}</td>
<td>
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
L{item.level}

View File

@@ -10,6 +10,7 @@ type APBDesItemInput = {
anggaran: number;
level: number;
tipe?: string | null;
realisasiAwal?: number; // Realisasi pertama saat create
};
type FormCreate = {
@@ -55,14 +56,15 @@ export default async function apbdesCreate(context: Context) {
},
});
// Create items dengan auto-calculate totalRealisasi=0, selisih, persentase
// Create items dengan auto-calculate totalRealisasi, selisih, persentase
const items = await Promise.all(
body.items.map(item => {
body.items.map(async item => {
const anggaran = item.anggaran;
const totalRealisasi = 0; // Belum ada realisasi saat create
const selisih = totalRealisasi - anggaran;
const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0;
const realisasiAwal = item.realisasiAwal || 0;
// Jika ada realisasiAwal, buat realisasi item pertama
let totalRealisasi = realisasiAwal;
const itemData = {
kode: item.kode,
uraian: item.uraian,
@@ -70,15 +72,29 @@ export default async function apbdesCreate(context: Context) {
level: item.level,
tipe: item.tipe || null,
totalRealisasi,
selisih,
persentase,
selisih: totalRealisasi - anggaran,
persentase: anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0,
apbdesId: apbdes.id,
};
return prisma.aPBDesItem.create({
const createdItem = await prisma.aPBDesItem.create({
data: itemData,
select: { id: true, kode: true },
});
// Jika ada realisasiAwal, buat realisasi item pertama
if (realisasiAwal > 0) {
await prisma.realisasiItem.create({
data: {
apbdesItemId: createdItem.id,
jumlah: realisasiAwal,
tanggal: new Date(),
keterangan: 'Realisasi awal saat create APBDes',
},
});
}
return createdItem;
})
);