Merge pull request 'nico/5-mar-26/fix-musik-fix-apbdes' (#75) from nico/5-mar-26/fix-musik-fix-apbdes into staggingweb

Reviewed-on: #75
This commit is contained in:
2026-03-05 16:38:07 +08:00
13 changed files with 352 additions and 169 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({
@@ -19,8 +23,9 @@ const ApbdesFormSchema = z.object({
name: z.string().optional(), name: z.string().optional(),
deskripsi: z.string().optional(), deskripsi: z.string().optional(),
jumlah: z.string().optional(), jumlah: z.string().optional(),
imageId: z.string().min(1, "Gambar wajib diunggah"), // Image dan file opsional (bisa kosong)
fileId: z.string().min(1, "File wajib diunggah"), imageId: z.string().optional(),
fileId: z.string().optional(),
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"), items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
}); });
@@ -35,7 +40,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 +48,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 +256,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 +286,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,
}); });
}; };
@@ -193,7 +205,6 @@ function EditAPBDes() {
// Upload file baru jika ada perubahan // Upload file baru jika ada perubahan
if (imageFile) { if (imageFile) {
// Hapus file lama dari form jika ada file baru
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
file: imageFile, file: imageFile,
name: imageFile.name, name: imageFile.name,
@@ -205,7 +216,6 @@ function EditAPBDes() {
} }
if (docFile) { if (docFile) {
// Hapus file lama dari form jika ada file baru
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
file: docFile, file: docFile,
name: docFile.name, name: docFile.name,
@@ -216,15 +226,7 @@ function EditAPBDes() {
} }
} }
// Jika tidak ada file baru, gunakan ID lama (sudah ada di form) // Image dan file sekarang opsional, tidak perlu validasi
// Pastikan imageId dan fileId tetap ada
if (!apbdesState.edit.form.imageId) {
return toast.warn('Gambar wajib diunggah');
}
if (!apbdesState.edit.form.fileId) {
return toast.warn('Dokumen wajib diunggah');
}
const success = await apbdesState.edit.update(); const success = await apbdesState.edit.update();
if (success) { if (success) {
router.push('/admin/landing-page/apbdes'); router.push('/admin/landing-page/apbdes');
@@ -264,6 +266,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');
@@ -328,11 +333,11 @@ function EditAPBDes() {
required required
/> />
{/* Gambar & Dokumen */} {/* Gambar & Dokumen (Opsional) */}
<Stack gap="xs"> <Stack gap="xs">
<Box> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
Gambar APBDes Gambar APBDes (Opsional)
</Text> </Text>
<Dropzone <Dropzone
onDrop={handleDrop('image')} onDrop={handleDrop('image')}
@@ -372,6 +377,7 @@ function EditAPBDes() {
onClick={() => { onClick={() => {
setPreviewImage(null); setPreviewImage(null);
setImageFile(null); setImageFile(null);
apbdesState.edit.form.imageId = ''; // Clear imageId from form
}} }}
> >
<IconX size={14} /> <IconX size={14} />
@@ -382,7 +388,7 @@ function EditAPBDes() {
<Box> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
Dokumen APBDes Dokumen APBDes (Opsional)
</Text> </Text>
<Dropzone <Dropzone
onDrop={handleDrop('doc')} onDrop={handleDrop('doc')}
@@ -431,6 +437,7 @@ function EditAPBDes() {
onClick={() => { onClick={() => {
setPreviewDoc(null); setPreviewDoc(null);
setDocFile(null); setDocFile(null);
apbdesState.edit.form.fileId = ''; // Clear fileId from form
}} }}
> >
<IconX size={14} /> <IconX size={14} />
@@ -527,6 +534,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 +552,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

@@ -46,13 +46,9 @@ function CreateAPBDes() {
const [docFile, setDocFile] = useState<File | null>(null); const [docFile, setDocFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid // Check if form is valid - hanya cek items, gambar dan file opsional
const isFormValid = () => { const isFormValid = () => {
return ( return stateAPBDes.create.form.items.length > 0;
imageFile !== null &&
docFile !== null &&
stateAPBDes.create.form.items.length > 0
);
}; };
// Form sementara untuk input item baru // Form sementara untuk input item baru
@@ -84,28 +80,34 @@ function CreateAPBDes() {
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!imageFile || !docFile) {
return toast.warn("Pilih gambar dan dokumen terlebih dahulu");
}
if (stateAPBDes.create.form.items.length === 0) { if (stateAPBDes.create.form.items.length === 0) {
return toast.warn("Minimal tambahkan 1 item APBDes"); return toast.warn("Minimal tambahkan 1 item APBDes");
} }
try { try {
setIsSubmitting(true); setIsSubmitting(true);
const [uploadImageRes, uploadDocRes] = await Promise.all([
ApiFetch.api.fileStorage.create.post({ file: imageFile, name: imageFile.name }),
ApiFetch.api.fileStorage.create.post({ file: docFile, name: docFile.name }),
]);
const imageId = uploadImageRes?.data?.data?.id; // Upload files hanya jika ada file yang dipilih
const fileId = uploadDocRes?.data?.data?.id; let imageId = '';
let fileId = '';
if (!imageId || !fileId) { if (imageFile) {
return toast.error("Gagal mengupload file"); const uploadImageRes = await ApiFetch.api.fileStorage.create.post({
file: imageFile,
name: imageFile.name,
});
imageId = uploadImageRes?.data?.data?.id || '';
} }
// Update form dengan ID file if (docFile) {
const uploadDocRes = await ApiFetch.api.fileStorage.create.post({
file: docFile,
name: docFile.name,
});
fileId = uploadDocRes?.data?.data?.id || '';
}
// Update form dengan ID file (bisa kosong)
stateAPBDes.create.form.imageId = imageId; stateAPBDes.create.form.imageId = imageId;
stateAPBDes.create.form.fileId = fileId; stateAPBDes.create.form.fileId = fileId;
@@ -174,12 +176,16 @@ function CreateAPBDes() {
style={{ border: '1px solid #e0e0e0' }} style={{ border: '1px solid #e0e0e0' }}
> >
<Stack gap="md"> <Stack gap="md">
{/* Gambar & Dokumen (dipendekkan untuk fokus pada items) */} {/* Info: File opsional */}
<Text fz="sm" c="dimmed" mb="xs">
* Upload gambar dan dokumen bersifat opsional. Bisa dikosongkan jika belum ada.
</Text>
<Stack gap={"xs"}> <Stack gap={"xs"}>
{/* Gambar APBDes */} {/* Gambar APBDes */}
<Box> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
Gambar APBDes Gambar APBDes (Opsional)
</Text> </Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
@@ -249,10 +255,10 @@ function CreateAPBDes() {
)} )}
</Box> </Box>
{/* Dokumen APBDes */} {/* Dokumen APBDes (Opsional) */}
<Box> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
Dokumen APBDes Dokumen APBDes (Opsional)
</Text> </Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {

View File

@@ -17,8 +17,8 @@ type FormCreate = {
name?: string; name?: string;
deskripsi?: string; deskripsi?: string;
jumlah?: string; jumlah?: string;
imageId: string; imageId?: string | null; // Opsional
fileId: string; fileId?: string | null; // Opsional
items: APBDesItemInput[]; items: APBDesItemInput[];
}; };
@@ -32,12 +32,7 @@ export default async function apbdesCreate(context: Context) {
if (!body.tahun) { if (!body.tahun) {
throw new Error('Tahun is required'); throw new Error('Tahun is required');
} }
if (!body.imageId) { // Image dan file sekarang opsional
throw new Error('Image ID is required');
}
if (!body.fileId) {
throw new Error('File ID is required');
}
if (!body.items || body.items.length === 0) { if (!body.items || body.items.length === 0) {
throw new Error('At least one item is required'); throw new Error('At least one item is required');
} }
@@ -50,8 +45,8 @@ export default async function apbdesCreate(context: Context) {
name: body.name || `APBDes Tahun ${body.tahun}`, name: body.name || `APBDes Tahun ${body.tahun}`,
deskripsi: body.deskripsi, deskripsi: body.deskripsi,
jumlah: body.jumlah, jumlah: body.jumlah,
imageId: body.imageId, imageId: body.imageId || null, // null jika tidak ada
fileId: body.fileId, fileId: body.fileId || null, // null jika tidak ada
}, },
}); });

View File

@@ -36,8 +36,8 @@ const APBDes = new Elysia({
name: t.Optional(t.String()), name: t.Optional(t.String()),
deskripsi: t.Optional(t.String()), deskripsi: t.Optional(t.String()),
jumlah: t.Optional(t.String()), jumlah: t.Optional(t.String()),
imageId: t.String(), imageId: t.Optional(t.String()),
fileId: t.String(), fileId: t.Optional(t.String()),
items: t.Array(ApbdesItemSchema), items: t.Array(ApbdesItemSchema),
}), }),
}) })
@@ -50,8 +50,8 @@ const APBDes = new Elysia({
name: t.Optional(t.String()), name: t.Optional(t.String()),
deskripsi: t.Optional(t.String()), deskripsi: t.Optional(t.String()),
jumlah: t.Optional(t.String()), jumlah: t.Optional(t.String()),
imageId: t.String(), imageId: t.Optional(t.String()),
fileId: t.String(), fileId: t.Optional(t.String()),
items: t.Array(ApbdesItemSchema), items: t.Array(ApbdesItemSchema),
}), }),
}) })

View File

@@ -1,6 +1,7 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
import { assignParentIdsToApbdesItems } from "./lib/getParentsID"; import { assignParentIdsToApbdesItems } from "./lib/getParentsID";
import { RealisasiItem } from "@prisma/client";
type APBDesItemInput = { type APBDesItemInput = {
kode: string; kode: string;
@@ -15,8 +16,8 @@ type FormUpdateBody = {
name?: string; name?: string;
deskripsi?: string; deskripsi?: string;
jumlah?: string; jumlah?: string;
imageId: string; imageId?: string | null;
fileId: string; fileId?: string | null;
items: APBDesItemInput[]; items: APBDesItemInput[];
}; };
@@ -28,6 +29,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 +49,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: RealisasiItem[];
}>();
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 +92,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.keys()) {
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 +161,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: {
@@ -88,12 +169,12 @@ export default async function apbdesUpdate(context: Context) {
name: body.name || `APBDes Tahun ${body.tahun}`, name: body.name || `APBDes Tahun ${body.tahun}`,
deskripsi: body.deskripsi, deskripsi: body.deskripsi,
jumlah: body.jumlah, jumlah: body.jumlah,
imageId: body.imageId, imageId: body.imageId === '' ? null : body.imageId,
fileId: body.fileId, fileId: body.fileId === '' ? null : body.fileId,
}, },
}); });
// 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

@@ -82,6 +82,12 @@ export function MusicProvider({ children }: { children: ReactNode }) {
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const isSeekingRef = useRef(false); const isSeekingRef = useRef(false);
const animationFrameRef = useRef<number | null>(null); const animationFrameRef = useRef<number | null>(null);
const isRepeatRef = useRef(false); // Ref untuk avoid stale closure
// Sync ref dengan state
useEffect(() => {
isRepeatRef.current = isRepeat;
}, [isRepeat]);
// Load musik data // Load musik data
const loadMusikData = useCallback(async () => { const loadMusikData = useCallback(async () => {
@@ -111,7 +117,8 @@ export function MusicProvider({ children }: { children: ReactNode }) {
}); });
audioRef.current.addEventListener('ended', () => { audioRef.current.addEventListener('ended', () => {
if (isRepeat) { // Gunakan ref untuk avoid stale closure
if (isRepeatRef.current) {
audioRef.current!.currentTime = 0; audioRef.current!.currentTime = 0;
audioRef.current!.play(); audioRef.current!.play();
} else { } else {
@@ -132,7 +139,7 @@ export function MusicProvider({ children }: { children: ReactNode }) {
} }
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps -- playNext is intentionally not in deps to avoid circular dependency // eslint-disable-next-line react-hooks/exhaustive-deps -- playNext is intentionally not in deps to avoid circular dependency
}, [loadMusikData, isRepeat]); }, [loadMusikData]); // Remove isRepeat dari deps karena sudah pakai ref
// Update time with requestAnimationFrame for smooth progress // Update time with requestAnimationFrame for smooth progress
const updateTime = useCallback(() => { const updateTime = useCallback(() => {

View File

@@ -78,7 +78,8 @@ function APBDesProgress({ apbdesData }: APBDesProgressProps) {
// Hitung total per kategori // Hitung total per kategori
const calcTotal = (items: { anggaran: number; realisasi: number }[]) => { const calcTotal = (items: { anggaran: number; realisasi: number }[]) => {
const anggaran = items.reduce((sum, item) => sum + item.anggaran, 0); const anggaran = items.reduce((sum, item) => sum + item.anggaran, 0);
const realisasi = items.reduce((sum, item) => sum + item.realisasi, 0); // Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
const realisasi = items.reduce((sum, item) => sum + (item.realisasi || 0), 0);
const persen = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; const persen = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
return { anggaran, realisasi, persen }; return { anggaran, realisasi, persen };
}; };

View File

@@ -68,6 +68,7 @@ function APBDesTable({ apbdesData }: APBDesTableProps) {
// Calculate totals // Calculate totals
const totalAnggaran = items.reduce((sum, item) => sum + (item.anggaran || 0), 0); const totalAnggaran = items.reduce((sum, item) => sum + (item.anggaran || 0), 0);
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
const totalRealisasi = items.reduce((sum, item) => sum + (item.realisasi || 0), 0); const totalRealisasi = items.reduce((sum, item) => sum + (item.realisasi || 0), 0);
const totalSelisih = totalAnggaran - totalRealisasi; const totalSelisih = totalAnggaran - totalRealisasi;
const totalPersentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0; const totalPersentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;

View File

@@ -51,7 +51,8 @@ export function transformAPBDesData(data: any): APBDesData {
kode: item.kode || '', kode: item.kode || '',
uraian: item.uraian || '', uraian: item.uraian || '',
anggaran: typeof item.anggaran === 'number' ? item.anggaran : 0, anggaran: typeof item.anggaran === 'number' ? item.anggaran : 0,
realisasi: typeof item.realisasi === 'number' ? item.realisasi : 0, // Map totalRealisasi from backend to realisasi field
realisasi: typeof item.totalRealisasi === 'number' ? item.totalRealisasi : (typeof item.realisasi === 'number' ? item.realisasi : 0),
selisih: typeof item.selisih === 'number' ? item.selisih : 0, selisih: typeof item.selisih === 'number' ? item.selisih : 0,
persentase: typeof item.persentase === 'number' ? item.persentase : 0, persentase: typeof item.persentase === 'number' ? item.persentase : 0,
level: typeof item.level === 'number' ? item.level : 1, level: typeof item.level === 'number' ? item.level : 1,

View File

@@ -3,6 +3,7 @@ import {
ActionIcon, ActionIcon,
Avatar, Avatar,
Box, Box,
Button,
Flex, Flex,
Group, Group,
Paper, Paper,
@@ -12,6 +13,7 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconArrowsShuffle, IconArrowsShuffle,
IconMusic,
IconPlayerPauseFilled, IconPlayerPauseFilled,
IconPlayerPlayFilled, IconPlayerPlayFilled,
IconPlayerSkipBackFilled, IconPlayerSkipBackFilled,
@@ -45,7 +47,7 @@ export default function FixedPlayerBar() {
} = useMusic(); } = useMusic();
const [showVolume, setShowVolume] = useState(false); const [showVolume, setShowVolume] = useState(false);
const [isPlayerVisible, setIsPlayerVisible] = useState(true); const [isMinimized, setIsMinimized] = useState(false);
// Format time // Format time
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
@@ -69,12 +71,55 @@ export default function FixedPlayerBar() {
toggleShuffle(); toggleShuffle();
}; };
// Handle close player // Handle minimize player (show floating icon)
const handleClosePlayer = () => { const handleMinimizePlayer = () => {
setIsPlayerVisible(false); setIsMinimized(true);
}; };
if (!currentSong || !isPlayerVisible) { // Handle restore player from floating icon
const handleRestorePlayer = () => {
setIsMinimized(false);
};
// If minimized, show floating icon instead of player bar
if (isMinimized) {
return (
<>
{/* Floating Music Icon - Shows when player is minimized */}
<Button
color="#0B4F78"
variant="filled"
size="md"
mt="md"
style={{
position: 'fixed',
top: '50%', // Menempatkan titik atas ikon di tengah layar
left: '0px',
transform: 'translateY(-50%)', // Menggeser ikon ke atas sebesar setengah tingginya sendiri agar benar-benar di tengah
borderBottomRightRadius: '20px',
borderTopRightRadius: '20px',
cursor: 'pointer',
transition: 'transform 0.2s ease',
zIndex: 1
}}
onClick={handleRestorePlayer}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-50%) scale(1.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(-50%)';
}}
>
<IconMusic size={28} color="white" />
</Button>
{/* Spacer to prevent content from being hidden behind player */}
<Box h={20} />
</>
);
}
if (!currentSong) {
return null; return null;
} }
@@ -89,12 +134,12 @@ export default function FixedPlayerBar() {
p="sm" p="sm"
shadow="lg" shadow="lg"
style={{ style={{
zIndex: 1000, zIndex: 1,
borderTop: '1px solid rgba(0,0,0,0.1)', borderTop: '1px solid rgba(0,0,0,0.1)',
}} }}
> >
<Flex align="center" gap="md" justify="space-between"> <Flex align="center" gap="md" justify="space-between">
{/* Song Info */} {/* Song Info - Left */}
<Group gap="sm" flex={1} style={{ minWidth: 0 }}> <Group gap="sm" flex={1} style={{ minWidth: 0 }}>
<Avatar <Avatar
src={currentSong.coverImage?.link || ''} src={currentSong.coverImage?.link || ''}
@@ -113,78 +158,81 @@ export default function FixedPlayerBar() {
</Box> </Box>
</Group> </Group>
{/* Controls */} {/* Controls + Progress - Center */}
<Group gap="xs"> <Group gap="xs" flex={2} justify="center">
<ActionIcon {/* Control Buttons */}
variant={isShuffle ? 'filled' : 'subtle'} <Group gap="xs">
color={isShuffle ? 'blue' : 'gray'} <ActionIcon
size="lg" variant={isShuffle ? 'filled' : 'subtle'}
onClick={handleToggleShuffle} color={isShuffle ? 'blue' : 'gray'}
title="Shuffle" size="lg"
> onClick={handleToggleShuffle}
<IconArrowsShuffle size={18} /> title="Shuffle"
</ActionIcon> >
<IconArrowsShuffle size={18} />
</ActionIcon>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
size="lg" size="lg"
onClick={playPrev} onClick={playPrev}
title="Previous" title="Previous"
> >
<IconPlayerSkipBackFilled size={20} /> <IconPlayerSkipBackFilled size={20} />
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon
variant="filled" variant="filled"
color={isPlaying ? 'blue' : 'gray'} color={isPlaying ? 'blue' : 'gray'}
size="xl" size="xl"
radius="xl" radius="xl"
onClick={togglePlayPause} onClick={togglePlayPause}
title={isPlaying ? 'Pause' : 'Play'} title={isPlaying ? 'Pause' : 'Play'}
> >
{isPlaying ? ( {isPlaying ? (
<IconPlayerPauseFilled size={24} /> <IconPlayerPauseFilled size={24} />
) : ( ) : (
<IconPlayerPlayFilled size={24} /> <IconPlayerPlayFilled size={24} />
)} )}
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
size="lg" size="lg"
onClick={playNext} onClick={playNext}
title="Next" title="Next"
> >
<IconPlayerSkipForwardFilled size={20} /> <IconPlayerSkipForwardFilled size={20} />
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color={isRepeat ? 'blue' : 'gray'} color={isRepeat ? 'blue' : 'gray'}
size="lg" size="lg"
onClick={toggleRepeat} onClick={toggleRepeat}
title={isRepeat ? 'Repeat On' : 'Repeat Off'} title={isRepeat ? 'Repeat On' : 'Repeat Off'}
> >
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />} {isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
</ActionIcon> </ActionIcon>
</Group>
{/* Progress Bar - Desktop */}
<Box w={200} display={{ base: 'none', md: 'block' }}>
<Slider
value={currentTime}
max={duration || 100}
onChange={handleSeek}
size="sm"
color="blue"
label={(value) => formatTime(value)}
/>
</Box>
</Group> </Group>
{/* Progress Bar - Desktop */} {/* Right Controls - Volume + Close */}
<Box w={200} display={{ base: 'none', md: 'block' }}> <Group gap="xs" flex={1} justify="flex-end">
<Slider
value={currentTime}
max={duration || 100}
onChange={handleSeek}
size="sm"
color="blue"
label={(value) => formatTime(value)}
/>
</Box>
{/* Right Controls */}
<Group gap="xs">
<Box <Box
onMouseEnter={() => setShowVolume(true)} onMouseEnter={() => setShowVolume(true)}
onMouseLeave={() => setShowVolume(false)} onMouseLeave={() => setShowVolume(false)}
@@ -241,8 +289,8 @@ export default function FixedPlayerBar() {
variant="subtle" variant="subtle"
color="gray" color="gray"
size="lg" size="lg"
onClick={handleClosePlayer} onClick={handleMinimizePlayer}
title="Close player" title="Minimize player"
> >
<IconX size={18} /> <IconX size={18} />
</ActionIcon> </ActionIcon>

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { Button } from '@mantine/core'; import { Button } from '@mantine/core';
import { IconMusic, IconMusicOff } from '@tabler/icons-react'; import { IconDisabled, IconDisabledOff } from '@tabler/icons-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
const NewsReaderLanding = () => { const NewsReaderLanding = () => {
@@ -95,15 +95,17 @@ const NewsReaderLanding = () => {
mt="md" mt="md"
style={{ style={{
position: 'fixed', position: 'fixed',
bottom: '350px', top: '50%', // Menempatkan titik atas ikon di tengah layar
left: '0px', left: '0px',
transform: 'translateY(80%)', // Menggeser ikon ke atas sebesar setengah tingginya sendiri agar benar-benar di tengah
borderBottomRightRadius: '20px', borderBottomRightRadius: '20px',
borderTopRightRadius: '20px', borderTopRightRadius: '20px',
transition: 'all 0.3s ease', cursor: 'pointer',
transition: 'transform 0.2s',
zIndex: 1 zIndex: 1
}} }}
> >
{isPointerMode ? <IconMusicOff /> : <IconMusic />} {isPointerMode ? <IconDisabledOff /> : <IconDisabled />}
</Button> </Button>
); );
}; };

View File

@@ -5,7 +5,8 @@ function Summary({ title, data }: any) {
if (!data || data.length === 0) return null; if (!data || data.length === 0) return null;
const totalAnggaran = data.reduce((s: number, i: any) => s + i.anggaran, 0); const totalAnggaran = data.reduce((s: number, i: any) => s + i.anggaran, 0);
const totalRealisasi = data.reduce((s: number, i: any) => s + i.totalRealisasi, 0); // Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
const totalRealisasi = data.reduce((s: number, i: any) => s + (i.realisasi || i.totalRealisasi || 0), 0);
const persen = const persen =
totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0; totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
@@ -87,7 +88,8 @@ export default function GrafikRealisasi({ apbdesData }: any) {
// Hitung total keseluruhan // Hitung total keseluruhan
const totalAnggaranSemua = items.reduce((s: number, i: any) => s + i.anggaran, 0); const totalAnggaranSemua = items.reduce((s: number, i: any) => s + i.anggaran, 0);
const totalRealisasiSemua = items.reduce((s: number, i: any) => s + i.totalRealisasi, 0); // Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
const totalRealisasiSemua = items.reduce((s: number, i: any) => s + (i.realisasi || i.totalRealisasi || 0), 0);
const persenSemua = totalAnggaranSemua > 0 ? (totalRealisasiSemua / totalAnggaranSemua) * 100 : 0; const persenSemua = totalAnggaranSemua > 0 ? (totalRealisasiSemua / totalAnggaranSemua) * 100 : 0;
const formatRupiah = (angka: number) => { const formatRupiah = (angka: number) => {
@@ -105,8 +107,14 @@ export default function GrafikRealisasi({ apbdesData }: any) {
GRAFIK REALISASI APBDes {tahun} GRAFIK REALISASI APBDes {tahun}
</Title> </Title>
{/* Summary Total Keseluruhan */} <Stack gap="lg" mb="lg">
<Box mb="lg" p="md" bg="gray.0"> <Summary title="💰 Pendapatan" data={pendapatan} />
<Summary title="💸 Belanja" data={belanja} />
<Summary title="📊 Pembiayaan" data={pembiayaan} />
</Stack>
{/* Summary Total Keseluruhan
<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>
@@ -124,13 +132,7 @@ export default function GrafikRealisasi({ apbdesData }: any) {
color={persenSemua >= 100 ? 'teal' : persenSemua >= 80 ? 'blue' : 'red'} color={persenSemua >= 100 ? 'teal' : persenSemua >= 80 ? 'blue' : 'red'}
/> />
</> </>
</Box> </Box> */}
<Stack gap="lg">
<Summary title="💰 Pendapatan" data={pendapatan} />
<Summary title="💸 Belanja" data={belanja} />
<Summary title="📊 Pembiayaan" data={pembiayaan} />
</Stack>
</Paper> </Paper>
); );
} }