diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0f57a8c9..5c12fa2c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -209,16 +209,22 @@ model APBDesItem { kode String // contoh: "4", "4.1", "4.1.2" uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha" anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS) - realisasi Float - selisih Float // realisasi - anggaran - persentase Float - tipe String? // (realisasi / anggaran) * 100 + tipe String? // "pendapatan" | "belanja" | "pembiayaan" | null level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail parentId String? // untuk relasi hierarki parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id]) children APBDesItem[] @relation("APBDesItemParent") apbdesId String apbdes APBDes @relation(fields: [apbdesId], references: [id]) + + // Field kalkulasi (auto-calculated dari realisasi items) + totalRealisasi Float @default(0) // Sum dari semua realisasi + selisih Float @default(0) // totalRealisasi - anggaran + persentase Float @default(0) // (totalRealisasi / anggaran) * 100 + + // Relasi ke realisasi items + realisasiItems RealisasiItem[] + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? @@ -229,6 +235,26 @@ model APBDesItem { @@index([apbdesId]) } +// Model baru untuk multiple realisasi per item +model RealisasiItem { + id String @id @default(cuid()) + apbdesItemId String + apbdesItem APBDesItem @relation(fields: [apbdesItemId], references: [id], onDelete: Cascade) + + jumlah Float // Jumlah realisasi dalam Rupiah + tanggal DateTime @db.Date // Tanggal realisasi + keterangan String? @db.Text // Keterangan tambahan (opsional) + buktiFileId String? // FileStorage ID untuk bukti/foto (opsional) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + isActive Boolean @default(true) + + @@index([apbdesItemId]) + @@index([tanggal]) +} + //========================================= PRESTASI DESA ========================================= // model PrestasiDesa { id String @id @default(cuid()) diff --git a/src/app/admin/(dashboard)/landing-page/apbdes/[id]/page.tsx b/src/app/admin/(dashboard)/landing-page/apbdes/[id]/page.tsx index b7e62296..94cc5455 100644 --- a/src/app/admin/(dashboard)/landing-page/apbdes/[id]/page.tsx +++ b/src/app/admin/(dashboard)/landing-page/apbdes/[id]/page.tsx @@ -218,7 +218,7 @@ function DetailAPBDes() { {item.anggaran.toLocaleString('id-ID')} - {item.realisasi.toLocaleString('id-ID')} + {item.totalRealisasi.toLocaleString('id-ID')} = 0 ? 'green' : 'red'}> {item.selisih.toLocaleString('id-ID')} diff --git a/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/create.ts b/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/create.ts index 89fbc49c..a30e3dde 100644 --- a/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/create.ts +++ b/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/create.ts @@ -8,9 +8,6 @@ type APBDesItemInput = { kode: string; uraian: string; anggaran: number; - realisasi: number; - selisih: number; - persentase: number; level: number; tipe?: string | null; }; @@ -27,8 +24,7 @@ type FormCreate = { export default async function apbdesCreate(context: Context) { const body = context.body as FormCreate; - - // Log the incoming request for debugging + console.log('Incoming request body:', JSON.stringify(body, null, 2)); try { @@ -46,7 +42,7 @@ export default async function apbdesCreate(context: Context) { throw new Error('At least one item is required'); } - // 1. Buat APBDes + items (tanpa parentId dulu) + // 1. Buat APBDes + items dengan auto-calculate fields const created = await prisma.$transaction(async (prisma) => { const apbdes = await prisma.aPBDes.create({ data: { @@ -59,22 +55,26 @@ export default async function apbdesCreate(context: Context) { }, }); - // Create items in a batch + // Create items dengan auto-calculate totalRealisasi=0, selisih, persentase const items = await Promise.all( body.items.map(item => { - // Create a new object with only the fields that exist in the APBDesItem model + 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 itemData = { kode: item.kode, uraian: item.uraian, - anggaran: item.anggaran, - realisasi: item.realisasi, - selisih: item.selisih, - persentase: item.persentase, + anggaran: anggaran, level: item.level, - tipe: item.tipe, // ✅ sertakan, biar null + tipe: item.tipe || null, + totalRealisasi, + selisih, + persentase, apbdesId: apbdes.id, }; - + return prisma.aPBDesItem.create({ data: itemData, select: { id: true, kode: true }, @@ -89,20 +89,27 @@ export default async function apbdesCreate(context: Context) { // 2. Isi parentId berdasarkan kode await assignParentIdsToApbdesItems(created.items); - // 3. Ambil ulang data lengkap untuk response + // 3. Ambil ulang data lengkap untuk response (include realisasiItems) const result = await prisma.aPBDes.findUnique({ where: { id: created.id }, include: { image: true, file: true, items: { + where: { isActive: true }, orderBy: { kode: 'asc' }, + include: { + realisasiItems: { + where: { isActive: true }, + orderBy: { tanggal: 'asc' }, + }, + }, }, }, }); console.log('APBDes created successfully:', JSON.stringify(result, null, 2)); - + return { success: true, message: "Berhasil membuat APBDes", @@ -110,7 +117,6 @@ export default async function apbdesCreate(context: Context) { }; } catch (innerError) { console.error('Error in post-creation steps:', innerError); - // Even if post-creation steps fail, we still return success since the main record was created return { success: true, message: "APBDes berhasil dibuat, tetapi ada masalah dengan pemrosesan tambahan", @@ -120,13 +126,12 @@ export default async function apbdesCreate(context: Context) { } } catch (error: any) { console.error("Error creating APBDes:", error); - - // Log the full error for debugging + if (error.code) console.error('Prisma error code:', error.code); if (error.meta) console.error('Prisma error meta:', error.meta); - + const errorMessage = error.message || 'Unknown error'; - + context.set.status = 500; return { success: false, diff --git a/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/findMany.ts b/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/findMany.ts index e19bcd5d..f985a6ba 100644 --- a/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/findMany.ts +++ b/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/findMany.ts @@ -21,7 +21,7 @@ export default async function apbdesFindMany(context: Context) { try { const where: any = { isActive: true }; - + if (search) { where.OR = [ { name: { contains: search, mode: "insensitive" } }, @@ -51,7 +51,10 @@ export default async function apbdesFindMany(context: Context) { where: { isActive: true }, orderBy: { kode: "asc" }, include: { - parent: true, + realisasiItems: { + where: { isActive: true }, + orderBy: { tanggal: 'asc' }, + }, }, }, }, diff --git a/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/findUnique.ts b/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/findUnique.ts index 41bdff1d..1961bb80 100644 --- a/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/findUnique.ts +++ b/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/findUnique.ts @@ -2,15 +2,9 @@ import prisma from "@/lib/prisma"; import { Context } from "elysia"; export default async function apbdesFindUnique(context: Context) { - // ✅ Parse URL secara manual const url = new URL(context.request.url); const pathSegments = url.pathname.split('/').filter(Boolean); - - console.log("🔍 DEBUG INFO:"); - console.log("- Full URL:", context.request.url); - console.log("- Pathname:", url.pathname); - console.log("- Path segments:", pathSegments); - + // Expected: ['api', 'landingpage', 'apbdes', 'ID'] if (pathSegments.length < 4) { context.set.status = 400; @@ -20,9 +14,9 @@ export default async function apbdesFindUnique(context: Context) { debug: { pathSegments } }; } - - if (pathSegments[0] !== 'api' || - pathSegments[1] !== 'landingpage' || + + if (pathSegments[0] !== 'api' || + pathSegments[1] !== 'landingpage' || pathSegments[2] !== 'apbdes') { context.set.status = 400; return { @@ -31,9 +25,9 @@ export default async function apbdesFindUnique(context: Context) { debug: { pathSegments } }; } - - const id = pathSegments[3]; // ✅ ID ada di index ke-3 - + + const id = pathSegments[3]; + if (!id || id.trim() === '') { context.set.status = 400; return { @@ -50,7 +44,10 @@ export default async function apbdesFindUnique(context: Context) { where: { isActive: true }, orderBy: { kode: 'asc' }, include: { - parent: true, // Include parent item for hierarchy + realisasiItems: { + where: { isActive: true }, + orderBy: { tanggal: 'asc' }, + }, }, }, image: true, diff --git a/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/index.ts b/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/index.ts index 93a30f01..7525b97b 100644 --- a/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/index.ts +++ b/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/index.ts @@ -5,17 +5,17 @@ import apbdesDelete from "./del"; import apbdesFindMany from "./findMany"; import apbdesFindUnique from "./findUnique"; import apbdesUpdate from "./updt"; +import realisasiCreate from "./realisasi/create"; +import realisasiUpdate from "./realisasi/update"; +import realisasiDelete from "./realisasi/delete"; -// Definisikan skema untuk item APBDes +// Definisikan skema untuk item APBDes (tanpa realisasi field) const ApbdesItemSchema = t.Object({ kode: t.String(), uraian: t.String(), anggaran: t.Number(), - realisasi: t.Number(), - selisih: t.Number(), - persentase: t.Number(), level: t.Number(), - tipe: t.Optional(t.Union([t.String(), t.Null()])) // misal: "pendapatan" atau "belanja" + tipe: t.Optional(t.Union([t.String(), t.Null()])), // "pendapatan" | "belanja" | "pembiayaan" | null }); const APBDes = new Elysia({ @@ -26,10 +26,10 @@ const APBDes = new Elysia({ // ✅ Find all (dengan query opsional: page, limit, tahun) .get("/findMany", apbdesFindMany) - // ✅ Find by ID + // ✅ Find by ID (include realisasiItems) .get("/:id", apbdesFindUnique) - // ✅ Create + // ✅ Create APBDes dengan items (tanpa realisasi) .post("/create", apbdesCreate, { body: t.Object({ tahun: t.Number(), @@ -42,7 +42,7 @@ const APBDes = new Elysia({ }), }) - // ✅ Update + // ✅ Update APBDes dengan items (tanpa realisasi) .put("/:id", apbdesUpdate, { params: t.Object({ id: t.String() }), body: t.Object({ @@ -56,9 +56,40 @@ const APBDes = new Elysia({ }), }) - // ✅ Delete + // ✅ Delete APBDes .delete("/del/:id", apbdesDelete, { params: t.Object({ id: t.String() }), + }) + + // ========================================= + // REALISASI ENDPOINTS + // ========================================= + + // ✅ Create realisasi untuk item tertentu + .post("/:itemId/realisasi", realisasiCreate, { + params: t.Object({ itemId: t.String() }), + body: t.Object({ + jumlah: t.Number(), + tanggal: t.String(), + keterangan: t.Optional(t.String()), + buktiFileId: t.Optional(t.String()), + }), + }) + + // ✅ Update realisasi + .put("/realisasi/:realisasiId", realisasiUpdate, { + params: t.Object({ realisasiId: t.String() }), + body: t.Object({ + jumlah: t.Optional(t.Number()), + tanggal: t.Optional(t.String()), + keterangan: t.Optional(t.String()), + buktiFileId: t.Optional(t.String()), + }), + }) + + // ✅ Delete realisasi + .delete("/realisasi/:realisasiId", realisasiDelete, { + params: t.Object({ realisasiId: t.String() }), }); export default APBDes; \ No newline at end of file diff --git a/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/realisasi/create.ts b/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/realisasi/create.ts new file mode 100644 index 00000000..7546bd96 --- /dev/null +++ b/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/realisasi/create.ts @@ -0,0 +1,82 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import prisma from "@/lib/prisma"; +import { Context } from "elysia"; + +type RealisasiCreateBody = { + jumlah: number; + tanggal: string; // ISO format + keterangan?: string; + buktiFileId?: string; +}; + +export default async function realisasiCreate(context: Context) { + const { itemId } = context.params as { itemId: string }; + const body = context.body as RealisasiCreateBody; + + console.log('Creating realisasi:', JSON.stringify(body, null, 2)); + + try { + // 1. Pastikan APBDesItem ada + const item = await prisma.aPBDesItem.findUnique({ + where: { id: itemId }, + }); + + if (!item) { + context.set.status = 404; + return { + success: false, + message: "Item APBDes tidak ditemukan", + }; + } + + // 2. Create realisasi item + const realisasi = await prisma.realisasiItem.create({ + data: { + apbdesItemId: itemId, + jumlah: body.jumlah, + tanggal: new Date(body.tanggal), + keterangan: body.keterangan, + buktiFileId: body.buktiFileId, + }, + }); + + // 3. Update totalRealisasi, selisih, persentase di APBDesItem + const allRealisasi = await prisma.realisasiItem.findMany({ + where: { apbdesItemId: itemId, isActive: true }, + select: { jumlah: true }, + }); + + const totalRealisasi = allRealisasi.reduce((sum, r) => sum + r.jumlah, 0); + const selisih = totalRealisasi - item.anggaran; + const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0; + + await prisma.aPBDesItem.update({ + where: { id: itemId }, + data: { + totalRealisasi, + selisih, + persentase, + }, + }); + + // 4. Return response + return { + success: true, + message: "Realisasi berhasil ditambahkan", + data: realisasi, + meta: { + totalRealisasi, + selisih, + persentase, + }, + }; + } catch (error: any) { + console.error("Error creating realisasi:", error); + context.set.status = 500; + return { + success: false, + message: `Gagal menambahkan realisasi: ${error.message}`, + error: process.env.NODE_ENV === 'development' ? error : undefined, + }; + } +} diff --git a/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/realisasi/delete.ts b/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/realisasi/delete.ts new file mode 100644 index 00000000..63c4d8df --- /dev/null +++ b/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/realisasi/delete.ts @@ -0,0 +1,73 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import prisma from "@/lib/prisma"; +import { Context } from "elysia"; + +export default async function realisasiDelete(context: Context) { + const { realisasiId } = context.params as { realisasiId: string }; + + console.log('Deleting realisasi:', realisasiId); + + try { + // 1. Pastikan realisasi ada + const existing = await prisma.realisasiItem.findUnique({ + where: { id: realisasiId }, + }); + + if (!existing) { + context.set.status = 404; + return { + success: false, + message: "Realisasi tidak ditemukan", + }; + } + + const apbdesItemId = existing.apbdesItemId; + + // 2. Soft delete realisasi (set isActive = false) + await prisma.realisasiItem.update({ + where: { id: realisasiId }, + data: { + isActive: false, + deletedAt: new Date(), + }, + }); + + // 3. Recalculate totalRealisasi, selisih, persentase di APBDesItem + const allRealisasi = await prisma.realisasiItem.findMany({ + where: { apbdesItemId, isActive: true }, + select: { jumlah: true }, + }); + + const item = await prisma.aPBDesItem.findUnique({ + where: { id: apbdesItemId }, + }); + + if (item) { + const totalRealisasi = allRealisasi.reduce((sum, r) => sum + r.jumlah, 0); + const selisih = totalRealisasi - item.anggaran; + const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0; + + await prisma.aPBDesItem.update({ + where: { id: apbdesItemId }, + data: { + totalRealisasi, + selisih, + persentase, + }, + }); + } + + return { + success: true, + message: "Realisasi berhasil dihapus", + }; + } catch (error: any) { + console.error("Error deleting realisasi:", error); + context.set.status = 500; + return { + success: false, + message: `Gagal menghapus realisasi: ${error.message}`, + error: process.env.NODE_ENV === 'development' ? error : undefined, + }; + } +} diff --git a/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/realisasi/update.ts b/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/realisasi/update.ts new file mode 100644 index 00000000..d77ed88a --- /dev/null +++ b/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/realisasi/update.ts @@ -0,0 +1,85 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import prisma from "@/lib/prisma"; +import { Context } from "elysia"; + +type RealisasiUpdateBody = { + jumlah?: number; + tanggal?: string; + keterangan?: string; + buktiFileId?: string; +}; + +export default async function realisasiUpdate(context: Context) { + const { realisasiId } = context.params as { realisasiId: string }; + const body = context.body as RealisasiUpdateBody; + + console.log('Updating realisasi:', JSON.stringify(body, null, 2)); + + try { + // 1. Pastikan realisasi ada + const existing = await prisma.realisasiItem.findUnique({ + where: { id: realisasiId }, + }); + + if (!existing) { + context.set.status = 404; + return { + success: false, + message: "Realisasi tidak ditemukan", + }; + } + + // 2. Update realisasi + const updated = await prisma.realisasiItem.update({ + where: { id: realisasiId }, + data: { + ...(body.jumlah !== undefined && { jumlah: body.jumlah }), + ...(body.tanggal !== undefined && { tanggal: new Date(body.tanggal) }), + ...(body.keterangan !== undefined && { keterangan: body.keterangan }), + ...(body.buktiFileId !== undefined && { buktiFileId: body.buktiFileId }), + }, + }); + + // 3. Recalculate totalRealisasi, selisih, persentase di APBDesItem + const allRealisasi = await prisma.realisasiItem.findMany({ + where: { apbdesItemId: existing.apbdesItemId, isActive: true }, + select: { jumlah: true }, + }); + + const item = await prisma.aPBDesItem.findUnique({ + where: { id: existing.apbdesItemId }, + }); + + if (item) { + const totalRealisasi = allRealisasi.reduce((sum, r) => sum + r.jumlah, 0); + const selisih = totalRealisasi - item.anggaran; + const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0; + + await prisma.aPBDesItem.update({ + where: { id: existing.apbdesItemId }, + data: { + totalRealisasi, + selisih, + persentase, + }, + }); + } + + return { + success: true, + message: "Realisasi berhasil diperbarui", + data: updated, + meta: { + totalRealisasi: allRealisasi.reduce((sum, r) => sum + r.jumlah, 0), + }, + }; + } catch (error: any) { + console.error("Error updating realisasi:", error); + context.set.status = 500; + return { + success: false, + message: `Gagal memperbarui realisasi: ${error.message}`, + error: process.env.NODE_ENV === 'development' ? error : undefined, + }; + } +} diff --git a/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/updt.ts b/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/updt.ts index c1115d5a..255e8c46 100644 --- a/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/updt.ts +++ b/src/app/api/[[...slugs]]/_lib/landing_page/apbdes/updt.ts @@ -6,9 +6,6 @@ type APBDesItemInput = { kode: string; uraian: string; anggaran: number; - realisasi: number; - selisih: number; - persentase: number; level: number; tipe?: string | null; }; @@ -41,25 +38,32 @@ export default async function apbdesUpdate(context: Context) { }; } - // 2. Hapus semua item lama + // 2. Hapus semua item lama (cascade akan menghapus realisasiItems juga) await prisma.aPBDesItem.deleteMany({ where: { apbdesId: id }, }); - // 3. Buat item baru tanpa parentId terlebih dahulu + // 3. Buat item baru dengan auto-calculate fields await prisma.aPBDesItem.createMany({ - data: body.items.map((item) => ({ - apbdesId: id, - kode: item.kode, - uraian: item.uraian, - anggaran: item.anggaran, - realisasi: item.realisasi, - selisih: item.anggaran - item.realisasi, - persentase: item.anggaran > 0 ? (item.realisasi / item.anggaran) * 100 : 0, - level: item.level, - tipe: item.tipe || null, - isActive: true, - })), + data: body.items.map((item) => { + const anggaran = item.anggaran; + const totalRealisasi = 0; // Reset karena items baru + const selisih = totalRealisasi - anggaran; + const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0; + + return { + apbdesId: id, + kode: item.kode, + uraian: item.uraian, + anggaran: anggaran, + level: item.level, + tipe: item.tipe || null, + totalRealisasi, + selisih, + persentase, + isActive: true, + }; + }), }); // 4. Dapatkan semua item yang baru dibuat untuk mendapatkan ID-nya @@ -69,12 +73,11 @@ export default async function apbdesUpdate(context: Context) { }); // 5. Update parentId untuk setiap item - // Pastikan allItems memiliki tipe yang benar const itemsForParentUpdate = allItems.map(item => ({ id: item.id, kode: item.kode, })); - + await assignParentIdsToApbdesItems(itemsForParentUpdate); // 6. Update data APBDes @@ -90,13 +93,19 @@ export default async function apbdesUpdate(context: Context) { }, }); - // 5. Ambil data lengkap untuk response + // 7. Ambil data lengkap untuk response (include realisasiItems) const result = await prisma.aPBDes.findUnique({ where: { id }, include: { items: { where: { isActive: true }, - orderBy: { kode: 'asc' } + orderBy: { kode: 'asc' }, + include: { + realisasiItems: { + where: { isActive: true }, + orderBy: { tanggal: 'asc' }, + }, + }, }, image: true, file: true, diff --git a/src/app/darmasaba/_com/main-page/apbdes/lib/realisasiTable.tsx b/src/app/darmasaba/_com/main-page/apbdes/lib/realisasiTable.tsx index aebb345a..7d720f0b 100644 --- a/src/app/darmasaba/_com/main-page/apbdes/lib/realisasiTable.tsx +++ b/src/app/darmasaba/_com/main-page/apbdes/lib/realisasiTable.tsx @@ -18,7 +18,7 @@ function Section({ title, data }: any) { {item.kode} - {item.uraian} - Rp {item.realisasi.toLocaleString('id-ID')} + Rp {item.totalRealisasi.toLocaleString('id-ID')}