Merge pull request #6 from bipprojectbali/tasks/api/fix-swagger-path-group/01-04-2026-1215

Tasks/api/fix swagger path group/01 04 2026 1215
This commit is contained in:
2026-04-01 17:07:25 +08:00
committed by GitHub
30 changed files with 1818 additions and 548 deletions

4
.env
View File

@@ -15,5 +15,5 @@ BASE_SESSION_KEY=kp9sGx91as0Kj2Ls81nAsl2Kdj13KsxP
BASE_TOKEN_KEY=Qm82JsA92lMnKw0291mxKaaP02KjslaA
# BOT-TELE
BOT_TOKEN=8498428675:AAEQwAUjTqpvgyyC5C123nP1mAxhOg12Ph0
CHAT_ID=5251328671
BOT_TOKEN=8479423145:AAE9ArrOgTD3DyVxYSVs3IXN40u_sL6c9sw
CHAT_ID=-1003368982298

View File

@@ -1,43 +1,52 @@
#!/usr/bin/env bun
import { readFileSync } from "node:fs";
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
// Fungsi untuk mencari string terpanjang dalam objek (biasanya balasan AI)
function findLongestString(obj: any): string {
let longest = "";
const search = (item: any) => {
if (typeof item === "string") {
if (item.length > longest.length) longest = item;
} else if (Array.isArray(item)) {
item.forEach(search);
} else if (item && typeof item === "object") {
Object.values(item).forEach(search);
// Function to manually load .env from project root if process.env is missing keys
function loadEnv() {
const envPath = join(process.cwd(), ".env");
if (existsSync(envPath)) {
const envContent = readFileSync(envPath, "utf-8");
const lines = envContent.split("\n");
for (const line of lines) {
if (line && !line.startsWith("#")) {
const [key, ...valueParts] = line.split("=");
if (key && valueParts.length > 0) {
const value = valueParts.join("=").trim().replace(/^["']|["']$/g, "");
process.env[key.trim()] = value;
}
}
}
};
search(obj);
return longest;
}
}
async function run() {
try {
// Ensure environment variables are loaded
loadEnv();
const inputRaw = readFileSync(0, "utf-8");
if (!inputRaw) return;
const input = JSON.parse(inputRaw);
// DEBUG: Lihat struktur asli di console terminal (stderr)
console.error("DEBUG KEYS:", Object.keys(input));
let finalText = "";
let sessionId = "web-desa-darmasaba";
try {
// Try parsing as JSON first
const input = JSON.parse(inputRaw);
sessionId = input.session_id || "web-desa-darmasaba";
finalText = typeof input === "string" ? input : (input.response || input.text || JSON.stringify(input));
} catch {
// If not JSON, use raw text
finalText = inputRaw;
}
const BOT_TOKEN = process.env.BOT_TOKEN;
const CHAT_ID = process.env.CHAT_ID;
const sessionId = input.session_id || "unknown";
// Cari teks secara otomatis di seluruh objek JSON
let finalText = findLongestString(input.response || input);
if (!finalText || finalText.length < 5) {
finalText =
"Teks masih gagal diekstraksi. Struktur: " +
Object.keys(input).join(", ");
if (!BOT_TOKEN || !CHAT_ID) {
console.error("Missing BOT_TOKEN or CHAT_ID in environment variables");
return;
}
const message =
@@ -45,7 +54,7 @@ async function run() {
`🆔 Session: \`${sessionId}\` \n\n` +
`🧠 Output:\n${finalText.substring(0, 3500)}`;
await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
const res = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -55,6 +64,13 @@ async function run() {
}),
});
if (!res.ok) {
const errorData = await res.json();
console.error("Telegram API Error:", errorData);
} else {
console.log("Notification sent successfully!");
}
process.stdout.write(JSON.stringify({ status: "continue" }));
} catch (err) {
console.error("Hook Error:", err);

BIN
bun.lockb

Binary file not shown.

View File

@@ -70,7 +70,7 @@
"embla-carousel-react": "^8.6.0",
"extract-zip": "^2.0.1",
"form-data": "^4.0.2",
"framer-motion": "^12.23.5",
"framer-motion": "^12.38.0",
"get-port": "^7.1.0",
"iron-session": "^8.0.4",
"jose": "^6.1.0",
@@ -100,7 +100,7 @@
"react-transition-group": "^4.4.5",
"react-zoom-pan-pinch": "^3.7.0",
"readdirp": "^4.1.1",
"recharts": "^2.15.3",
"recharts": "^3.8.0",
"sharp": "^0.34.3",
"swr": "^2.3.2",
"uuid": "^11.1.0",

View File

@@ -15,7 +15,7 @@ import AjukanPermohonan from "./layanan/ajukan_permohonan";
import Musik from "./musik";
const Desa = new Elysia({ prefix: "/api/desa", tags: ["Desa"] })
const Desa = new Elysia({ prefix: "/desa", tags: ["Desa"] })
.use(Berita)
.use(Pengumuman)
.use(ProfileDesa)

View File

@@ -13,7 +13,7 @@ import PendapatanAsliDesa from "./pendapatan-asli-desa";
import StrukturOrganisasi from "./struktur-bumdes";
const Ekonomi = new Elysia({
prefix: "/api/ekonomi",
prefix: "/ekonomi",
tags: ["Ekonomi"],
})
.use(PasarDesa)

View File

@@ -5,7 +5,7 @@ import { fileStorageFindMany } from "./_lib/findMany";
import fileStorageDelete from "./_lib/del";
const FileStorage = new Elysia({
prefix: "/api/fileStorage",
prefix: "/fileStorage",
tags: ["FileStorage"],
})
.post("/create", fileStorageCreate, {

View File

@@ -8,7 +8,7 @@ import LayananOnlineDesa from "./layanan-online-desa";
import MitraKolaborasi from "./kolaborasi-inovasi/mitra-kolaborasi";
const Inovasi = new Elysia({
prefix: "/api/inovasi",
prefix: "/inovasi",
tags: ["Inovasi"],
})
.use(DesaDigital)

View File

@@ -9,7 +9,7 @@ import KontakDaruratKeamanan from "./kontak-darurat-keamanan";
import KontakItem from "./kontak-darurat-keamanan/kontak-item";
import LayananPolsek from "./polsek-terdekat/layanan-polsek";
const Keamanan = new Elysia({ prefix: "/api/keamanan", tags: ["Keamanan"] })
const Keamanan = new Elysia({ prefix: "/keamanan", tags: ["Keamanan"] })
.use(KeamananLingkungan)
.use(PolsekTerdekat)
.use(PencegahanKriminalitas)

View File

@@ -24,7 +24,7 @@ import TarifLayanan from "./data_kesehatan_warga/fasilitas_kesehatan/tarif-layan
const Kesehatan = new Elysia({
prefix: "/api/kesehatan",
prefix: "/kesehatan",
tags: ["Kesehatan"],
})
.use(PersentaseKelahiranKematian)

View File

@@ -14,7 +14,7 @@ import UmurResponden from "./indeks_kepuasan/umur-responden";
import Responden from "./indeks_kepuasan/responden";
const LandingPage = new Elysia({
prefix: "/api/landingpage",
prefix: "/landingpage",
tags: ["Landing Page/Profile"]
})

View File

@@ -9,7 +9,7 @@ import KategoriKegiatan from "./gotong-royong/kategori-kegiatan";
import KeteranganBankSampahTerdekat from "./pengelolaan-sampah/keterangan-bank-sampah";
const Lingkungan = new Elysia({
prefix: "/api/lingkungan",
prefix: "/lingkungan",
tags: ["Lingkungan"],
})

View File

@@ -8,7 +8,7 @@ import Beasiswa from "./beasiswa-desa";
import PerpustakaanDigital from "./perpustakaan-digital";
const Pendidikan = new Elysia({
prefix: "/api/pendidikan",
prefix: "/pendidikan",
tags: ["Pendidikan"]
})

View File

@@ -14,7 +14,7 @@ import GrafikHasilKepuasanMasyarakat from "./ikm/grafik_hasil_kepuasan_masyaraka
const PPID = new Elysia({ prefix: "/api/ppid", tags: ["PPID"] })
const PPID = new Elysia({ prefix: "/ppid", tags: ["PPID"] })
.use(ProfilePPID)
.use(DaftarInformasiPublik)
.use(GrafikHasilKepuasanMasyarakat)

View File

@@ -2,7 +2,7 @@ import Elysia from "elysia";
import searchFindMany from "./findMany";
const Search = new Elysia({
prefix: "/api/search",
prefix: "/search",
tags: ["Search"],
})
.get("/findMany", searchFindMany);

View File

@@ -7,7 +7,7 @@ import userDelete from "./del"; // `delete` nggak boleh jadi nama file JS langsu
import userUpdate from "./updt";
import userDeleteAccount from "./delUser";
const User = new Elysia({ prefix: "/api/user" })
const User = new Elysia({ prefix: "/user" })
.get("/findMany", userFindMany)
.get("/findUnique/:id", userFindUnique)
.put("/del/:id", userDelete, {

View File

@@ -6,7 +6,7 @@ import roleFindUnique from "./findUnique";
import roleUpdate from "./updt";
const Role = new Elysia({
prefix: "/api/role",
prefix: "/role",
tags: ["User / Role"],
})

View File

@@ -67,7 +67,7 @@ async function layanan() {
}
const Utils = new Elysia({
prefix: "/api/utils",
prefix: "/utils",
tags: ["Utils"],
}).get("/version", async () => {
const packageJson = await fs.readFile(
@@ -81,8 +81,7 @@ const Utils = new Elysia({
if (!process.env.WIBU_UPLOAD_DIR)
throw new Error("WIBU_UPLOAD_DIR is not defined");
const ApiServer = new Elysia()
.use(swagger({ path: "/api/docs" }))
const ApiServer = new Elysia({ prefix: "/api" })
.use(
staticPlugin({
assets: UPLOAD_DIR,
@@ -90,6 +89,25 @@ const ApiServer = new Elysia()
}),
)
.use(cors(corsConfig))
.use(
swagger({
path: "/docs",
documentation: {
info: {
title: "Desa Darmasaba API Documentation",
version: "1.0.0",
},
},
}),
)
.onError(({ code }) => {
if (code === "NOT_FOUND") {
return {
status: 404,
body: "Route not found :(",
};
}
})
.use(Utils)
.use(FileStorage)
.use(LandingPage)
@@ -104,126 +122,114 @@ const ApiServer = new Elysia()
.use(User)
.use(Role)
.use(Search)
.onError(({ code }) => {
if (code === "NOT_FOUND") {
return {
status: 404,
body: "Route not found :(",
};
}
})
.group("/api", (app) =>
app
.get("/layanan", layanan)
.get("/potensi", getPotensi)
.get(
"/img/:name",
({ params, query }) => {
return img({
name: params.name,
UPLOAD_DIR_IMAGE,
ROOT,
size: query.size,
});
},
{
params: t.Object({
name: t.String(),
}),
query: t.Optional(
t.Object({
size: t.Optional(t.Number()),
}),
),
},
)
.delete(
"/img/:name",
({ params }) => {
return imgDel({
name: params.name,
UPLOAD_DIR_IMAGE,
});
},
{
params: t.Object({
name: t.String(),
}),
},
)
.get(
"/imgs",
({ query }) => {
return imgs({
search: query.search,
page: query.page,
count: query.count,
UPLOAD_DIR_IMAGE,
});
},
{
query: t.Optional(
t.Object({
page: t.Number({ default: 1 }),
count: t.Number({ default: 10 }),
search: t.String({ default: "" }),
}),
),
},
)
.post(
"/upl-img",
({ body }) => {
console.log(body.title);
return uplImg({ files: body.files, UPLOAD_DIR_IMAGE });
},
{
body: t.Object({
title: t.String(),
files: t.Files({ multiple: true }),
}),
},
)
.post(
"/upl-img-single",
({ body }) => {
return uplImgSingle({
fileName: body.name,
file: body.file,
UPLOAD_DIR_IMAGE,
});
},
{
body: t.Object({
name: t.String(),
file: t.File(),
}),
},
)
.post(
"/upl-csv-single",
({ body }) => {
return uplCsvSingle({ fileName: body.name, file: body.file });
},
{
body: t.Object({
name: t.String(),
file: t.File(),
}),
},
)
.post(
"/upl-csv",
({ body }) => {
return uplCsv({ files: body.files });
},
{
body: t.Object({
files: t.Files(),
}),
},
.get("/layanan", layanan)
.get("/potensi", getPotensi)
.get(
"/img/:name",
({ params, query }) => {
return img({
name: params.name,
UPLOAD_DIR_IMAGE,
ROOT,
size: query.size,
});
},
{
params: t.Object({
name: t.String(),
}),
query: t.Optional(
t.Object({
size: t.Optional(t.Number()),
}),
),
},
)
.delete(
"/img/:name",
({ params }) => {
return imgDel({
name: params.name,
UPLOAD_DIR_IMAGE,
});
},
{
params: t.Object({
name: t.String(),
}),
},
)
.get(
"/imgs",
({ query }) => {
return imgs({
search: query.search,
page: query.page,
count: query.count,
UPLOAD_DIR_IMAGE,
});
},
{
query: t.Optional(
t.Object({
page: t.Number({ default: 1 }),
count: t.Number({ default: 10 }),
search: t.String({ default: "" }),
}),
),
},
)
.post(
"/upl-img",
({ body }) => {
console.log(body.title);
return uplImg({ files: body.files, UPLOAD_DIR_IMAGE });
},
{
body: t.Object({
title: t.String(),
files: t.Files({ multiple: true }),
}),
},
)
.post(
"/upl-img-single",
({ body }) => {
return uplImgSingle({
fileName: body.name,
file: body.file,
UPLOAD_DIR_IMAGE,
});
},
{
body: t.Object({
name: t.String(),
file: t.File(),
}),
},
)
.post(
"/upl-csv-single",
({ body }) => {
return uplCsvSingle({ fileName: body.name, file: body.file });
},
{
body: t.Object({
name: t.String(),
file: t.File(),
}),
},
)
.post(
"/upl-csv",
({ body }) => {
return uplCsv({ files: body.files });
},
{
body: t.Object({
files: t.Files(),
}),
},
);
export const GET = ApiServer.handle;

View File

@@ -33,6 +33,8 @@ export async function POST(req: Request) {
const codeOtp = randomOTP();
const otpNumber = Number(codeOtp);
console.log(`🔑 DEBUG OTP [${nomor}]: ${codeOtp}`);
const waMessage = `Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.\n\n>> Kode OTP anda: ${codeOtp}.`;
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
@@ -40,26 +42,19 @@ export async function POST(req: Request) {
try {
const res = await fetch(waUrl);
const sendWa = await res.json();
console.log("📱 WA Response:", sendWa);
if (sendWa.status !== "success") {
console.error("❌ WA Service Error:", sendWa);
return NextResponse.json(
{
success: false,
message: "Gagal mengirim OTP via WhatsApp",
debug: sendWa
},
{ status: 400 }
);
if (!res.ok) {
const errorText = await res.text();
console.error(`⚠️ WA Service HTTP Error: ${res.status} ${res.statusText}. Continuing since OTP is logged.`);
console.log(`💡 Use this OTP to login: ${codeOtp}`);
} else {
const sendWa = await res.json();
console.log("📱 WA Response:", sendWa);
if (sendWa.status !== "success") {
console.error("⚠️ WA Service Logic Error:", sendWa);
}
}
} catch (waError) {
console.error("❌ Fetch WA Error:", waError);
return NextResponse.json(
{ success: false, message: "Terjadi kesalahan saat mengirim WA" },
{ status: 500 }
);
} catch (waError: any) {
console.error("⚠️ WA Connection Exception. Continuing since OTP is logged.", waError.message);
}
const createOtpId = await prisma.kodeOtp.create({

View File

@@ -22,14 +22,21 @@ export async function POST(req: Request) {
// ✅ Generate dan kirim OTP
const codeOtp = randomOTP();
const otpNumber = Number(codeOtp);
console.log(`🔑 DEBUG REGISTER OTP [${nomor}]: ${codeOtp}`);
const waMessage = `Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
const waRes = await fetch(waUrl);
const waData = await waRes.json();
if (waData.status !== "success") {
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP via WhatsApp' }, { status: 400 });
try {
const waRes = await fetch(waUrl);
if (!waRes.ok) {
console.warn(`⚠️ WA Service HTTP Error (Register): ${waRes.status} ${waRes.statusText}. Continuing since OTP is logged.`);
} else {
const waData = await waRes.json();
console.log("📱 WA Response (Register):", waData);
}
} catch (waError: any) {
console.warn("⚠️ WA Connection Exception (Register). Continuing since OTP is logged.", waError.message);
}
// ✅ Simpan OTP ke database

View File

@@ -17,18 +17,22 @@ export async function POST(req: Request) {
const codeOtp = randomOTP();
const otpNumber = Number(codeOtp);
console.log(`🔑 DEBUG RESEND OTP [${nomor}]: ${codeOtp}`);
// Kirim OTP via WhatsApp
const waMessage = `Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.\n\n>> Kode OTP anda: ${codeOtp}.`;
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
const waRes = await fetch(waUrl);
const waData = await waRes.json();
if (waData.status !== "success") {
return NextResponse.json(
{ success: false, message: "Gagal mengirim OTP via WhatsApp" },
{ status: 400 }
);
try {
const waRes = await fetch(waUrl);
if (!waRes.ok) {
console.warn(`⚠️ WA Service HTTP Error (Resend): ${waRes.status} ${waRes.statusText}. Continuing since OTP is logged.`);
} else {
const waData = await waRes.json();
console.log("📱 WA Response (Resend):", waData);
}
} catch (waError: any) {
console.warn("⚠️ WA Connection Exception (Resend). Continuing since OTP is logged.", waError.message);
}
// Simpan OTP ke database

View File

@@ -21,14 +21,21 @@ export async function POST(req: Request) {
// Generate OTP
const codeOtp = randomOTP();
const otpNumber = Number(codeOtp);
console.log(`🔑 DEBUG SEND-OTP-REGISTER [${nomor}]: ${codeOtp}`);
// Kirim WA
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
const res = await fetch(waUrl);
const sendWa = await res.json();
if (sendWa.status !== "success") {
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP' }, { status: 400 });
try {
const res = await fetch(waUrl);
if (!res.ok) {
console.warn(`⚠️ WA Service HTTP Error (SendOTPRegister): ${res.status} ${res.statusText}. Continuing since OTP is logged.`);
} else {
const sendWa = await res.json();
console.log("📱 WA Response (SendOTPRegister):", sendWa);
}
} catch (waError: any) {
console.warn("⚠️ WA Connection Exception (SendOTPRegister). Continuing since OTP is logged.", waError.message);
}
// Simpan OTP

View File

@@ -0,0 +1,117 @@
import { Skeleton, Stack, Box, Group } from '@mantine/core'
export function PaguTableSkeleton() {
return (
<Box>
<Skeleton height={28} width="60%" mb="md" />
<Stack gap="xs">
{/* Header */}
<Group justify="space-between">
<Skeleton height={20} width="40%" />
<Skeleton height={20} width="30%" />
</Group>
{/* Section headers */}
<Skeleton height={24} width="100%" mt="md" />
<Skeleton height={20} width="90%" />
<Skeleton height={20} width="85%" />
<Skeleton height={20} width="80%" />
<Skeleton height={24} width="100%" mt="md" />
<Skeleton height={20} width="90%" />
<Skeleton height={20} width="85%" />
<Skeleton height={24} width="100%" mt="md" />
<Skeleton height={20} width="90%" />
</Stack>
</Box>
)
}
export function RealisasiTableSkeleton() {
return (
<Box>
<Skeleton height={28} width="70%" mb="md" />
<Stack gap="xs">
{/* Header */}
<Group justify="space-between">
<Skeleton height={20} width="40%" />
<Skeleton height={20} width="20%" />
<Skeleton height={20} width="10%" />
</Group>
{/* Rows */}
{[1, 2, 3, 4, 5].map((i) => (
<Group key={i} justify="space-between">
<Skeleton height={20} width="50%" />
<Skeleton height={20} width="25%" />
<Skeleton height={24} width="15%" radius="xl" />
</Group>
))}
</Stack>
</Box>
)
}
export function GrafikRealisasiSkeleton() {
return (
<Box>
<Skeleton height={28} width="65%" mb="md" />
<Stack gap="lg">
{[1, 2, 3].map((i) => (
<Stack key={i} gap="xs">
<Group justify="space-between">
<Skeleton height={20} width="40%" />
<Skeleton height={20} width="15%" />
</Group>
<Skeleton height={16} width="100%" />
<Skeleton height={12} width="100%" mt={4} />
<Skeleton height={16} width="100%" radius="xl" />
</Stack>
))}
</Stack>
</Box>
)
}
export function SummaryCardsSkeleton() {
return (
<Stack gap="lg">
<Skeleton height={28} width="50%" mb="sm" />
{[1, 2, 3].map((i) => (
<Stack key={i} gap="xs" p="md" style={{ border: '1px solid #e5e7eb', borderRadius: 8 }}>
<Group justify="space-between">
<Skeleton height={20} width="35%" />
<Skeleton height={20} width="20%" />
</Group>
<Skeleton height={16} width="100%" />
<Skeleton height={12} width="100%" mt={4} />
<Skeleton height={16} width="100%" radius="xl" />
</Stack>
))}
</Stack>
)
}
export function ApbdesMainSkeleton() {
return (
<Stack gap="xl">
{/* Title */}
<Skeleton height={48} width="40%" mx="auto" />
<Skeleton height={24} width="60%" mx="auto" />
{/* Select */}
<Skeleton height={42} width={220} mx="auto" />
{/* Summary Cards */}
<SummaryCardsSkeleton />
{/* Tables and Charts */}
<Stack gap="lg">
<PaguTableSkeleton />
<RealisasiTableSkeleton />
<GrafikRealisasiSkeleton />
</Stack>
</Stack>
)
}

View File

@@ -1,7 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
import apbdesState from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
import colors from '@/con/colors'
import {
Box,
@@ -12,30 +13,43 @@ import {
SimpleGrid,
Stack,
Text,
Title
Title,
LoadingOverlay,
Transition,
} from '@mantine/core'
import { motion } from 'framer-motion'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useProxy } from 'valtio/utils'
import { ApbdesMainSkeleton } from './components/apbdesSkeleton'
import ComparisonChart from './lib/comparisonChart'
import GrafikRealisasi from './lib/grafikRealisasi'
import PaguTable from './lib/paguTable'
import RealisasiTable from './lib/realisasiTable'
const MotionStack = motion.create(Stack)
function Apbdes() {
const state = useProxy(apbdes)
const state = useProxy(apbdesState)
const [selectedYear, setSelectedYear] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isChangingYear, setIsChangingYear] = useState(false)
const textHeading = {
title: 'APBDes',
des: 'Transparansi APBDes Darmasaba adalah langkah nyata menuju tata kelola desa yang bersih, terbuka, dan bertanggung jawab.'
des: 'Transparansi APBDes Darmasaba adalah langkah nyata menuju tata kelola desa yang bersih, terbuka, dan bertanggung jawab.',
}
useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true)
await state.findMany.load()
} catch (error) {
console.error('Error loading data:', error)
} finally {
setIsLoading(false)
}
}
loadData()
@@ -51,7 +65,7 @@ function Apbdes() {
)
)
.sort((a, b) => b - a)
.map(year => ({
.map((year) => ({
value: year.toString(),
label: `Tahun ${year}`,
}))
@@ -60,168 +74,190 @@ function Apbdes() {
if (years.length > 0 && !selectedYear) {
setSelectedYear(years[0].value)
}
}, [years, selectedYear])
}, [years])
const currentApbdes = dataAPBDes.length > 0
? dataAPBDes.find((item: any) => item?.tahun?.toString() === selectedYear) || dataAPBDes[0]
? (dataAPBDes.find((item: any) => item?.tahun?.toString() === selectedYear) || dataAPBDes[0])
: null
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const previewData = (state.findMany.data || []).slice(0, 3)
const handleYearChange = (value: string | null) => {
if (value !== selectedYear) {
setIsChangingYear(true)
setSelectedYear(value)
setTimeout(() => setIsChangingYear(false), 500)
}
}
return (
<Stack p="sm" gap="xl" bg={colors.Bg}>
<Divider c="gray.3" size="sm" />
{/* 📌 HEADING */}
<Box mt="xl">
<Stack gap="sm">
<Title
order={1}
ta="center"
c={colors['blue-button']}
fz={{ base: '2rem', md: '3.6rem' }}
lh={{ base: 1.2, md: 1.1 }}
<Stack p="sm" gap="xl" bg={colors.Bg} pos="relative">
<LoadingOverlay
visible={isLoading}
zIndex={1000}
overlayProps={{ radius: 'sm', blur: 2 }}
loaderProps={{ color: colors['blue-button'], type: 'dots' }}
/>
<Transition mounted={!isLoading} transition="fade" duration={600}>
{(styles) => (
<MotionStack
style={styles}
gap="xl"
>
{textHeading.title}
</Title>
<Text
ta="center"
fz={{ base: '1rem', md: '1.25rem' }}
lh={{ base: 1.5, md: 1.55 }}
c="black"
>
{textHeading.des}
</Text>
</Stack>
</Box>
{/* Button Lihat Semua */}
<Group justify="center">
<Button
component={Link}
href="/darmasaba/apbdes"
radius="xl"
size="lg"
variant="gradient"
gradient={{ from: "#26667F", to: "#124170" }}
>
Lihat Semua Data
</Button>
</Group>
{/* COMBOBOX */}
<Box px={{ base: 'md', md: "sm" }}>
<Select
label={<Text fw={600} fz="sm">Pilih Tahun APBDes</Text>}
placeholder="Pilih tahun"
value={selectedYear}
onChange={setSelectedYear}
data={years}
w={{ base: '100%', sm: 220 }}
searchable
clearable
nothingFoundMessage="Tidak ada tahun tersedia"
/>
</Box>
{/* Tabel & Grafik - Hanya tampilkan jika ada data */}
{currentApbdes && currentApbdes.items?.length > 0 ? (
<Box px={{ base: 'md', md: 'sm' }} mb="xl">
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<PaguTable apbdesData={currentApbdes} />
<RealisasiTable apbdesData={currentApbdes} />
<GrafikRealisasi apbdesData={currentApbdes} />
</SimpleGrid>
</Box>
) : currentApbdes ? (
<Box px={{ base: 'md', md: 100 }} py="md" mb="xl">
<Text fz="sm" c="dimmed" ta="center" lh={1.5}>
Tidak ada data item untuk tahun yang dipilih.
</Text>
</Box>
) : null}
{/* GRID - Card Preview
{state.findMany.loading ? (
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
<Loader size="lg" color="blue" />
</Center>
) : previewData.length === 0 ? (
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
<Stack align="center" gap="xs">
<Text fz="lg" c="dimmed" lh={1.4}>
Belum ada data APBDes yang tersedia
</Text>
<Text fz="sm" c="dimmed" lh={1.4}>
Data akan ditampilkan di sini setelah diunggah
</Text>
</Stack>
</Center>
) : (
<SimpleGrid
mx={{ base: 'md', md: 100 }}
cols={{ base: 1, sm: 3 }}
spacing="lg"
pb="xl"
>
{previewData.map((v, k) => (
<Box
key={k}
pos="relative"
style={{
backgroundImage: `url(${v.image?.link || ''})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
borderRadius: 16,
height: 360,
overflow: 'hidden',
}}
>
<Box pos="absolute" inset={0} bg="rgba(0,0,0,0.45)" style={{ borderRadius: 16 }} />
<Stack gap="xs" justify="space-between" h="100%" p="xl" pos="relative">
<Text
c="white"
fw={600}
fz={{ base: 'lg', md: 'xl' }}
<Divider c="gray.3" size="sm" />
{/* 📌 HEADING */}
<Box mt="xl">
<Stack gap="sm">
<Title
order={1}
ta="center"
lh={1.35}
lineClamp={2}
c={colors['blue-button']}
fz={{ base: '2rem', md: '3.6rem' }}
lh={{ base: 1.2, md: 1.1 }}
>
{v.name || `APBDes Tahun ${v.tahun}`}
</Text>
{textHeading.title}
</Title>
<Text
fw={700}
c="white"
fz={{ base: '2.4rem', md: '3.2rem' }}
ta="center"
lh={1}
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
fz={{ base: '1rem', md: '1.25rem' }}
lh={{ base: 1.5, md: 1.55 }}
c="black"
maw={800}
mx="auto"
>
{v.jumlah || '-'}
{textHeading.des}
</Text>
<Center>
<ActionIcon
component={Link}
href={v.file?.link || ''}
radius="xl"
size="xl"
variant="gradient"
gradient={{ from: '#1C6EA4', to: '#1C6EA4' }}
>
<IconDownload size={20} color="white" />
</ActionIcon>
</Center>
</Stack>
</Box>
))}
</SimpleGrid>
)} */}
{/* Button Lihat Semua */}
<Group justify="center">
<Button
component={Link}
href="/darmasaba/apbdes"
radius="xl"
size="lg"
variant="gradient"
gradient={{ from: '#26667F', to: '#124170' }}
style={{
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
':hover': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(38, 102, 127, 0.4)',
},
}}
>
Lihat Semua Data
</Button>
</Group>
{/* COMBOBOX */}
<Box px={{ base: 'md', md: 'sm' }}>
<Select
label={<Text fw={600} fz="sm">Pilih Tahun APBDes</Text>}
placeholder="Pilih tahun"
value={selectedYear}
onChange={handleYearChange}
data={years}
w={{ base: '100%', sm: 220 }}
searchable
clearable
nothingFoundMessage="Tidak ada tahun tersedia"
disabled={isChangingYear}
/>
</Box>
{/* Tables & Charts */}
{currentApbdes && currentApbdes.items && currentApbdes.items.length > 0 ? (
<Box px={{ base: 'md', md: 'sm' }} mb="xl">
<Transition
mounted={!isChangingYear}
transition="slide-up"
duration={400}
timingFunction="ease"
>
{(styles) => (
<SimpleGrid
cols={{ base: 1, sm: 3 }}
style={styles}
>
<MotionStack
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<PaguTable apbdesData={currentApbdes as any} />
</MotionStack>
<MotionStack
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<RealisasiTable apbdesData={currentApbdes as any} />
</MotionStack>
<MotionStack
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3 }}
>
<GrafikRealisasi apbdesData={currentApbdes as any} />
</MotionStack>
</SimpleGrid>
)}
</Transition>
{/* Comparison Chart */}
<Box mt="lg">
<Transition
mounted={!isChangingYear}
transition="slide-up"
duration={400}
timingFunction="ease"
>
{(styles) => (
<MotionStack
style={styles}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.4 }}
>
<ComparisonChart apbdesData={currentApbdes as any} />
</MotionStack>
)}
</Transition>
</Box>
</Box>
) : currentApbdes ? (
<Box px={{ base: 'md', md: 100 }} py="xl" mb="xl">
<Stack align="center" gap="sm">
<Text fz="2rem">📊</Text>
<Text fz="sm" c="dimmed" ta="center" lh={1.5}>
Tidak ada data item untuk tahun yang dipilih.
</Text>
</Stack>
</Box>
) : null}
{/* Loading State for Year Change */}
<Transition mounted={isChangingYear} transition="fade" duration={200}>
{(styles) => (
<Box
px={{ base: 'md', md: 'sm' }}
mb="xl"
style={styles}
>
<ApbdesMainSkeleton />
</Box>
)}
</Transition>
</MotionStack>
)}
</Transition>
</Stack>
)
}
export default Apbdes
export default Apbdes

View File

@@ -0,0 +1,229 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Title, Box, Text, Stack, Group, rem } from '@mantine/core'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Cell,
} from 'recharts'
import { APBDes, APBDesItem } from '../types/apbdes'
interface ComparisonChartProps {
apbdesData: APBDes
}
export default function ComparisonChart({ apbdesData }: ComparisonChartProps) {
const items = apbdesData?.items || []
const tahun = apbdesData?.tahun || new Date().getFullYear()
const pendapatan = items.filter((i: APBDesItem) => i.tipe === 'pendapatan')
const belanja = items.filter((i: APBDesItem) => i.tipe === 'belanja')
const pembiayaan = items.filter((i: APBDesItem) => i.tipe === 'pembiayaan')
const totalPendapatan = pendapatan.reduce((sum, i) => sum + i.anggaran, 0)
const totalBelanja = belanja.reduce((sum, i) => sum + i.anggaran, 0)
const totalPembiayaan = pembiayaan.reduce((sum, i) => sum + i.anggaran, 0)
// Hitung total realisasi dari realisasiItems (konsisten dengan RealisasiTable)
const totalPendapatanRealisasi = pendapatan.reduce(
(sum, i) => {
if (i.realisasiItems && i.realisasiItems.length > 0) {
return sum + i.realisasiItems.reduce((sumReal, real) => sumReal + (real.jumlah || 0), 0)
}
return sum
},
0
)
const totalBelanjaRealisasi = belanja.reduce(
(sum, i) => {
if (i.realisasiItems && i.realisasiItems.length > 0) {
return sum + i.realisasiItems.reduce((sumReal, real) => sumReal + (real.jumlah || 0), 0)
}
return sum
},
0
)
const totalPembiayaanRealisasi = pembiayaan.reduce(
(sum, i) => {
if (i.realisasiItems && i.realisasiItems.length > 0) {
return sum + i.realisasiItems.reduce((sumReal, real) => sumReal + (real.jumlah || 0), 0)
}
return sum
},
0
)
const formatRupiah = (value: number) => {
if (value >= 1000000000) {
return `Rp ${(value / 1000000000).toFixed(1)}B`
}
if (value >= 1000000) {
return `Rp ${(value / 1000000).toFixed(1)}Jt`
}
if (value >= 1000) {
return `Rp ${(value / 1000).toFixed(0)}Rb`
}
return `Rp ${value.toFixed(0)}`
}
const data = [
{
name: 'Pendapatan',
pagu: totalPendapatan,
realisasi: totalPendapatanRealisasi,
fill: '#40c057',
},
{
name: 'Belanja',
pagu: totalBelanja,
realisasi: totalBelanjaRealisasi,
fill: '#fa5252',
},
{
name: 'Pembiayaan',
pagu: totalPembiayaan,
realisasi: totalPembiayaanRealisasi,
fill: '#fd7e14',
},
]
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload
return (
<Box
bg="white"
p="md"
style={{
border: '1px solid #e5e7eb',
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
}}
>
<Stack gap="xs">
<Text fw={700} c="gray.8" fz="sm">
{data.name}
</Text>
<Group justify="space-between" gap="lg">
<Text fz="xs" c="gray.6">
Pagu:
</Text>
<Text fz="xs" fw={700} c="blue.9">
{formatRupiah(data.pagu)}
</Text>
</Group>
<Group justify="space-between" gap="lg">
<Text fz="xs" c="gray.6">
Realisasi:
</Text>
<Text fz="xs" fw={700} c="green.9">
{formatRupiah(data.realisasi)}
</Text>
</Group>
{data.pagu > 0 && (
<Group justify="space-between" gap="lg">
<Text fz="xs" c="gray.6">
Persentase:
</Text>
<Text
fz="xs"
fw={700}
c={data.realisasi >= data.pagu ? 'teal' : 'blue'}
>
{((data.realisasi / data.pagu) * 100).toFixed(1)}%
</Text>
</Group>
)}
</Stack>
</Box>
)
}
return null
}
return (
<Paper
withBorder
p="lg"
radius="lg"
shadow="sm"
style={{
transition: 'box-shadow 0.3s ease',
':hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
},
}}
>
<Title
order={5}
mb="lg"
c="blue.9"
fz={{ base: '1rem', md: '1.1rem' }}
fw={700}
>
Perbandingan Pagu vs Realisasi {tahun}
</Title>
<Box style={{ width: '100%', height: 300 }}>
<ResponsiveContainer>
<BarChart
data={data}
margin={{ top: 20, right: 30, left: 0, bottom: 0 }}
barSize={60}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="name"
tick={{ fill: '#6b7280', fontSize: 12 }}
axisLine={{ stroke: '#e5e7eb' }}
/>
<YAxis
tickFormatter={formatRupiah}
tick={{ fill: '#6b7280', fontSize: 11 }}
axisLine={{ stroke: '#e5e7eb' }}
width={80}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{
paddingTop: rem(20),
fontSize: 12,
}}
/>
<Bar
name="Pagu"
dataKey="pagu"
fill="#228be6"
radius={[8, 8, 0, 0]}
>
{data.map((entry, index) => (
<Cell
key={`cell-pagu-${index}`}
fill={entry.fill}
opacity={0.7}
/>
))}
</Bar>
<Bar
name="Realisasi"
dataKey="realisasi"
fill="#40c057"
radius={[8, 8, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</Box>
<Box mt="md">
<Text fz="xs" c="dimmed" ta="center">
*Geser cursor pada bar untuk melihat detail
</Text>
</Box>
</Paper>
)
}

View File

@@ -1,125 +1,224 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Title, Progress, Stack, Text, Group, Box } from '@mantine/core';
interface APBDesItem {
tipe: string | null;
anggaran: number;
realisasi?: number;
totalRealisasi?: number;
}
import { Paper, Title, Progress, Stack, Text, Group, Box, rem } from '@mantine/core'
import { IconArrowUpRight, IconArrowDownRight } from '@tabler/icons-react'
import { APBDes, APBDesItem, SummaryData } from '../types/apbdes'
interface SummaryProps {
title: string;
data: APBDesItem[];
title: string
data: APBDesItem[]
icon?: React.ReactNode
}
function Summary({ title, data }: SummaryProps) {
if (!data || data.length === 0) return null;
function Summary({ title, data, icon }: SummaryProps) {
if (!data || data.length === 0) return null
const totalAnggaran = data.reduce((s: number, i: APBDesItem) => s + i.anggaran, 0);
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
const totalRealisasi = data.reduce(
(s: number, i: APBDesItem) => s + (i.realisasi || i.totalRealisasi || 0),
0
);
const totalAnggaran = data.reduce((sum, i) => sum + i.anggaran, 0)
// Hitung total realisasi dari realisasiItems (konsisten dengan RealisasiTable)
const totalRealisasi = data.reduce((sum, i) => {
if (i.realisasiItems && i.realisasiItems.length > 0) {
return sum + i.realisasiItems.reduce((sumReal, real) => sumReal + (real.jumlah || 0), 0)
}
return sum
}, 0)
const persen =
totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
const persentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0
// Format angka ke dalam format Rupiah
const formatRupiah = (angka: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(angka);
};
}).format(angka)
}
// Tentukan warna berdasarkan persentase
const getProgressColor = (persen: number) => {
if (persen >= 100) return 'teal';
if (persen >= 80) return 'blue';
if (persen >= 60) return 'yellow';
return 'red';
};
if (persen >= 100) return 'teal'
if (persen >= 80) return 'blue'
if (persen >= 60) return 'yellow'
return 'red'
}
const getStatusMessage = (persen: number) => {
if (persen >= 100) {
return { text: 'Realisasi mencapai 100% dari anggaran', color: 'teal' }
}
if (persen >= 80) {
return { text: 'Realisasi baik, mendekati target', color: 'blue' }
}
if (persen >= 60) {
return { text: 'Realisasi cukup, perlu ditingkatkan', color: 'yellow' }
}
return { text: 'Realisasi rendah, perlu perhatian khusus', color: 'red' }
}
const statusMessage = getStatusMessage(persentase)
return (
<Box>
<Group justify="space-between" mb="xs">
<Text fw={600} fz="md">{title}</Text>
<Text fw={700} fz="lg" c={getProgressColor(persen)}>
{persen.toFixed(2)}%
</Text>
<Group gap="xs">
{icon}
<Text fw={700} fz="md" c="gray.8">{title}</Text>
</Group>
<Group gap="xs">
{persentase >= 100 ? (
<IconArrowUpRight
size={18}
color="var(--mantine-color-teal-7)"
stroke={2.5}
/>
) : persentase < 60 ? (
<IconArrowDownRight
size={18}
color="var(--mantine-color-red-7)"
stroke={2.5}
/>
) : null}
<Text
fw={700}
fz="lg"
c={getProgressColor(persentase)}
style={{
minWidth: 60,
textAlign: 'right',
}}
>
{persentase.toFixed(1)}%
</Text>
</Group>
</Group>
<Text fz="sm" c="dimmed" mb="xs">
Realisasi: {formatRupiah(totalRealisasi)} / Anggaran: {formatRupiah(totalAnggaran)}
<Text fz="xs" c="gray.6" mb="sm" lh={1.5}>
Realisasi: <Text component="span" fw={700} c="blue.9">{formatRupiah(totalRealisasi)}</Text>
{' '}/ Anggaran: <Text component="span" fw={700} c="gray.7">{formatRupiah(totalAnggaran)}</Text>
</Text>
<Progress
value={persen}
value={persentase}
size="xl"
radius="xl"
color={getProgressColor(persen)}
striped={persen < 100}
animated={persen < 100}
color={getProgressColor(persentase)}
striped={persentase < 100}
animated={persentase < 100}
mb="xs"
/>
{persen >= 100 && (
<Text fz="xs" c="teal" mt="xs" fw={500}>
Realisasi mencapai 100% dari anggaran
</Text>
)}
{persen < 100 && persen >= 80 && (
<Text fz="xs" c="blue" mt="xs" fw={500}>
Realisasi baik, mendekati target
</Text>
)}
{persen < 80 && persen >= 60 && (
<Text fz="xs" c="yellow" mt="xs" fw={500}>
Realisasi cukup, perlu ditingkatkan
</Text>
)}
{persen < 60 && (
<Text fz="xs" c="red" mt="xs" fw={500}>
Realisasi rendah, perlu perhatian khusus
</Text>
)}
<Text
fz="xs"
c={statusMessage.color as any}
fw={600}
style={{
backgroundColor: `var(--mantine-color-${statusMessage.color}-0)`,
padding: '6px 10px',
borderRadius: 6,
display: 'inline-block',
}}
>
{persentase >= 100 && '✓ '}{statusMessage.text}
</Text>
</Box>
);
)
}
export default function GrafikRealisasi({
apbdesData,
}: {
apbdesData: {
tahun?: number | null;
items?: APBDesItem[] | null;
[key: string]: any;
};
}) {
const items = apbdesData?.items || [];
const tahun = apbdesData?.tahun || new Date().getFullYear();
interface GrafikRealisasiProps {
apbdesData: APBDes
}
const pendapatan = items.filter((i: APBDesItem) => i.tipe === 'pendapatan');
const belanja = items.filter((i: APBDesItem) => i.tipe === 'belanja');
const pembiayaan = items.filter((i: APBDesItem) => i.tipe === 'pembiayaan');
export default function GrafikRealisasi({ apbdesData }: GrafikRealisasiProps) {
const items = apbdesData?.items || []
const tahun = apbdesData?.tahun || new Date().getFullYear()
const pendapatan = items.filter((i: APBDesItem) => i.tipe === 'pendapatan')
const belanja = items.filter((i: APBDesItem) => i.tipe === 'belanja')
const pembiayaan = items.filter((i: APBDesItem) => i.tipe === 'pembiayaan')
return (
<Paper withBorder p="md" radius="md">
<Title order={5} mb="md">
<Paper
withBorder
p="lg"
radius="lg"
shadow="sm"
style={{
transition: 'box-shadow 0.3s ease',
':hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
},
}}
h={"100%"}
>
<Title
order={5}
mb="lg"
c="blue.9"
fz={{ base: '1rem', md: '1.1rem' }}
fw={700}
>
GRAFIK REALISASI APBDes {tahun}
</Title>
<Stack gap="lg" mb="lg">
<Summary title="💰 Pendapatan" data={pendapatan} />
<Summary title="💸 Belanja" data={belanja} />
<Summary title="📊 Pembiayaan" data={pembiayaan} />
<Stack gap="xl">
<Summary
title="Pendapatan"
data={pendapatan}
icon={
<Box
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: 'var(--mantine-color-green-0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text fz="lg">💰</Text>
</Box>
}
/>
<Summary
title="Belanja"
data={belanja}
icon={
<Box
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: 'var(--mantine-color-red-0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text fz="lg">💸</Text>
</Box>
}
/>
<Summary
title="Pembiayaan"
data={pembiayaan}
icon={
<Box
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: 'var(--mantine-color-orange-0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text fz="lg">📊</Text>
</Box>
}
/>
</Stack>
</Paper>
);
}
)
}

View File

@@ -1,60 +1,180 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Table, Title } from '@mantine/core';
import { Paper, Table, Title, Box, ScrollArea, Badge } from '@mantine/core'
import { APBDes, APBDesItem } from '../types/apbdes'
function Section({ title, data }: any) {
if (!data || data.length === 0) return null;
interface SectionProps {
title: string
data: APBDesItem[]
badgeColor?: string
}
function Section({ title, data, badgeColor = 'blue' }: SectionProps) {
if (!data || data.length === 0) return null
return (
<>
<Table.Tr>
<Table.Tr bg="gray.0">
<Table.Td colSpan={2}>
<strong>{title}</strong>
<Badge color={badgeColor} variant="light" size="lg" fw={600}>
{title}
</Badge>
</Table.Td>
</Table.Tr>
{data.map((item: any) => (
<Table.Tr key={item.id}>
<Table.Td>
{item.kode} - {item.uraian}
{data.map((item, index) => (
<Table.Tr
key={item.id}
bg={index % 2 === 1 ? 'gray.50' : 'white'}
style={{
transition: 'background-color 0.2s ease',
':hover': {
backgroundColor: 'var(--mantine-color-blue-0)',
},
}}
>
<Table.Td style={{ borderBottom: '1px solid #e5e7eb' }}>
<Box style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{
fontWeight: 500,
color: 'var(--mantine-color-gray-7)',
minWidth: 80,
}}>
{item.kode}
</span>
<span style={{
color: 'var(--mantine-color-gray-6)',
fontSize: '0.9rem',
}}>
{item.uraian}
</span>
</Box>
</Table.Td>
<Table.Td ta="right">
<Table.Td
ta="right"
style={{
borderBottom: '1px solid #e5e7eb',
fontWeight: 600,
color: 'var(--mantine-color-blue-7)',
}}
>
Rp {item.anggaran.toLocaleString('id-ID')}
</Table.Td>
</Table.Tr>
))}
</>
);
)
}
export default function PaguTable({ apbdesData }: any) {
const items = apbdesData.items || [];
interface PaguTableProps {
apbdesData: APBDes
}
const title =
apbdesData.tahun
? `PAGU APBDes Tahun ${apbdesData.tahun}`
: 'PAGU APBDes';
export default function PaguTable({ apbdesData }: PaguTableProps) {
const items = apbdesData.items || []
const pendapatan = items.filter((i: any) => i.tipe === 'pendapatan');
const belanja = items.filter((i: any) => i.tipe === 'belanja');
const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan');
const title = apbdesData.tahun
? `PAGU APBDes Tahun ${apbdesData.tahun}`
: 'PAGU APBDes'
const pendapatan = items.filter((i: APBDesItem) => i.tipe === 'pendapatan')
const belanja = items.filter((i: APBDesItem) => i.tipe === 'belanja')
const pembiayaan = items.filter((i: APBDesItem) => i.tipe === 'pembiayaan')
// Calculate totals
const totalPendapatan = pendapatan.reduce((sum, i) => sum + i.anggaran, 0)
const totalBelanja = belanja.reduce((sum, i) => sum + i.anggaran, 0)
const totalPembiayaan = pembiayaan.reduce((sum, i) => sum + i.anggaran, 0)
return (
<Paper withBorder p="md" radius="md">
<Title order={5} mb="md">{title}</Title>
<Paper
withBorder
p="md"
radius="lg"
shadow="sm"
style={{
transition: 'box-shadow 0.3s ease',
':hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
},
}}
h={"100%"}
>
<Title
order={5}
mb="md"
c="blue.9"
fz={{ base: '1rem', md: '1.1rem' }}
fw={700}
>
{title}
</Title>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Uraian</Table.Th>
<Table.Th ta="right">Anggaran (Rp)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Section title="1) PENDAPATAN" data={pendapatan} />
<Section title="2) BELANJA" data={belanja} />
<Section title="3) PEMBIAYAAN" data={pembiayaan} />
</Table.Tbody>
</Table>
<ScrollArea offsetScrollbars type="hover">
<Table
horizontalSpacing="md"
verticalSpacing="xs"
layout="fixed"
>
<Table.Thead>
<Table.Tr bg="blue.9">
<Table.Th c="white" fw={600} style={{ minWidth: '60%' }}>
Uraian
</Table.Th>
<Table.Th
c="white"
fw={600}
ta="right"
style={{ minWidth: '40%' }}
>
Anggaran (Rp)
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Section
title="1) PENDAPATAN"
data={pendapatan}
badgeColor="green"
/>
{totalPendapatan > 0 && (
<Table.Tr bg="green.0" fw={700}>
<Table.Td>Total Pendapatan</Table.Td>
<Table.Td ta="right">
Rp {totalPendapatan.toLocaleString('id-ID')}
</Table.Td>
</Table.Tr>
)}
<Section
title="2) BELANJA"
data={belanja}
badgeColor="red"
/>
{totalBelanja > 0 && (
<Table.Tr bg="red.0" fw={700}>
<Table.Td>Total Belanja</Table.Td>
<Table.Td ta="right">
Rp {totalBelanja.toLocaleString('id-ID')}
</Table.Td>
</Table.Tr>
)}
<Section
title="3) PEMBIAYAAN"
data={pembiayaan}
badgeColor="orange"
/>
{totalPembiayaan > 0 && (
<Table.Tr bg="orange.0" fw={700}>
<Table.Td>Total Pembiayaan</Table.Td>
<Table.Td ta="right">
Rp {totalPembiayaan.toLocaleString('id-ID')}
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</ScrollArea>
</Paper>
);
}
)
}

View File

@@ -1,86 +1,212 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Table, Title, Badge, Text } from '@mantine/core';
import { Paper, Table, Title, Badge, Text, Box, ScrollArea } from '@mantine/core'
import { APBDes, APBDesItem, RealisasiItem } from '../types/apbdes'
export default function RealisasiTable({ apbdesData }: any) {
const items = apbdesData.items || [];
interface RealisasiRowProps {
realisasi: RealisasiItem
parentItem: APBDesItem
}
const title =
apbdesData.tahun
? `REALISASI APBDes Tahun ${apbdesData.tahun}`
: 'REALISASI APBDes';
function RealisasiRow({ realisasi, parentItem }: RealisasiRowProps) {
const persentase = parentItem.anggaran > 0
? (realisasi.jumlah / parentItem.anggaran) * 100
: 0
// Flatten: kumpulkan semua realisasi items
const allRealisasiRows: Array<{ realisasi: any; parentItem: any }> = [];
items.forEach((item: any) => {
if (item.realisasiItems && item.realisasiItems.length > 0) {
item.realisasiItems.forEach((realisasi: any) => {
allRealisasiRows.push({ realisasi, parentItem: item });
});
}
});
const getBadgeColor = (percentage: number) => {
if (percentage >= 100) return 'teal'
if (percentage >= 80) return 'blue'
if (percentage >= 60) return 'yellow'
return 'red'
}
const formatRupiah = (amount: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
const getBadgeVariant = (percentage: number) => {
if (percentage >= 100) return 'filled'
return 'light'
}
return (
<Paper withBorder p="md" radius="md">
<Title order={5} mb="md">{title}</Title>
<Table.Tr
style={{
transition: 'background-color 0.2s ease',
':hover': {
backgroundColor: 'var(--mantine-color-blue-0)',
},
}}
>
<Table.Td style={{ borderBottom: '1px solid #e5e7eb' }}>
<Box style={{ gap: 8, alignItems: 'center' }}>
<span style={{
fontWeight: 500,
color: 'var(--mantine-color-gray-7)',
}}>
{realisasi.kode || '-'}
</span>
<Text
size="sm"
c="gray.7"
title={realisasi.keterangan || '-'}
>
{realisasi.keterangan || '-'}
</Text>
</Box>
</Table.Td>
<Table.Td
ta="right"
style={{
borderBottom: '1px solid #e5e7eb',
fontWeight: 700,
color: 'var(--mantine-color-blue-7)',
}}
>
{new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(realisasi.jumlah || 0)}
</Table.Td>
<Table.Td
ta="center"
style={{ borderBottom: '1px solid #e5e7eb' }}
>
<Badge
color={getBadgeColor(persentase)}
variant={getBadgeVariant(persentase)}
size="sm"
radius="xl"
fw={600}
style={{
minWidth: 65,
transition: 'transform 0.2s ease',
}}
>
{persentase.toFixed(1)}%
</Badge>
</Table.Td>
</Table.Tr>
)
}
interface RealisasiTableProps {
apbdesData: APBDes
}
export default function RealisasiTable({ apbdesData }: RealisasiTableProps) {
const items = apbdesData.items || []
const title = apbdesData.tahun
? `REALISASI APBDes Tahun ${apbdesData.tahun}`
: 'REALISASI APBDes'
// Flatten: kumpulkan semua realisasi items
const allRealisasiRows: Array<{ realisasi: RealisasiItem; parentItem: APBDesItem }> = []
items.forEach((item: APBDesItem) => {
if (item.realisasiItems && item.realisasiItems.length > 0) {
item.realisasiItems.forEach((realisasi: RealisasiItem) => {
allRealisasiRows.push({ realisasi, parentItem: item })
})
}
})
// Calculate total realisasi
const totalRealisasi = allRealisasiRows.reduce(
(sum, { realisasi }) => sum + (realisasi.jumlah || 0),
0
)
return (
<Paper
withBorder
p="md"
radius="lg"
shadow="sm"
style={{
transition: 'box-shadow 0.3s ease',
':hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
},
}}
h={"100%"}
>
<Title
order={5}
mb="md"
c="blue.9"
fz={{ base: '1rem', md: '1.1rem' }}
fw={700}
>
{title}
</Title>
{allRealisasiRows.length === 0 ? (
<Text fz="sm" c="dimmed" ta="center" py="md">
Belum ada data realisasi
</Text>
<Box
py="xl"
px="md"
style={{
backgroundColor: 'var(--mantine-color-gray-0)',
borderRadius: 8,
}}
>
<Text
fz="sm"
c="dimmed"
ta="center"
lh={1.6}
>
Belum ada data realisasi untuk tahun ini
</Text>
</Box>
) : (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Uraian</Table.Th>
<Table.Th ta="right">Realisasi (Rp)</Table.Th>
<Table.Th ta="center">%</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{allRealisasiRows.map(({ realisasi, parentItem }) => {
const persentase = parentItem.anggaran > 0
? (realisasi.jumlah / parentItem.anggaran) * 100
: 0;
return (
<Table.Tr key={realisasi.id}>
<Table.Td>
<Text>{realisasi.kode || '-'} - {realisasi.keterangan || '-'}</Text>
</Table.Td>
<Table.Td ta="right">
<Text fw={600} c="blue">
{formatRupiah(realisasi.jumlah || 0)}
</Text>
</Table.Td>
<Table.Td ta="center">
<Badge
color={
persentase >= 100
? 'teal'
: persentase >= 60
? 'yellow'
: 'red'
}
>
{persentase.toFixed(2)}%
</Badge>
</Table.Td>
<>
<ScrollArea offsetScrollbars type="hover">
<Table
horizontalSpacing="md"
verticalSpacing="xs"
layout="fixed"
>
<Table.Thead>
<Table.Tr bg="blue.9">
<Table.Th c="white" fw={600}>Uraian</Table.Th>
<Table.Th c="white" fw={600} ta="right">Realisasi (Rp)</Table.Th>
<Table.Th c="white" fw={600} ta="center">%</Table.Th>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.Thead>
<Table.Tbody>
{allRealisasiRows.map(({ realisasi, parentItem }) => (
<RealisasiRow
key={realisasi.id}
realisasi={realisasi}
parentItem={parentItem}
/>
))}
</Table.Tbody>
</Table>
</ScrollArea>
<Box mb="md" px="sm">
<Text
size="sm"
c="gray.6"
fw={500}
>
Total Realisasi:{' '}
<Text
component="span"
c="blue.9"
fw={700}
fz="md"
>
{new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(totalRealisasi)}
</Text>
</Text>
</Box>
</>
)}
</Paper>
);
}
)
}

View File

@@ -0,0 +1,90 @@
// Types for APBDes data structure
export interface APBDesItem {
id?: string;
kode: string;
uraian: string;
deskripsi?: string;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan' | null;
anggaran: number;
level?: number;
// Calculated fields
realisasi?: number;
selisih?: number;
persentase?: number;
// Realisasi items (nested)
realisasiItems?: RealisasiItem[];
createdAt?: string | Date;
updatedAt?: string | Date;
}
export interface RealisasiItem {
id: string;
kode: string;
keterangan?: string;
jumlah: number;
tanggal?: string | Date;
apbDesItemId: string;
buktiFileId?: string;
createdAt?: string | Date;
updatedAt?: string | Date;
}
export interface APBDes {
id: string;
name?: string | null;
tahun: number;
jumlah: number;
deskripsi?: string | null;
items?: APBDesItem[];
image?: {
id: string;
link: string;
name?: string;
path?: string;
} | null;
file?: {
id: string;
link: string;
name?: string;
} | null;
imageId?: string;
fileId?: string;
createdAt?: string | Date;
updatedAt?: string | Date;
}
export interface APBDesResponse {
id: string;
tahun: number;
name?: string | null;
jumlah: number;
items?: APBDesItem[];
image?: {
id: string;
link: string;
} | null;
file?: {
id: string;
link: string;
} | null;
}
export interface SummaryData {
title: string;
totalAnggaran: number;
totalRealisasi: number;
persentase: number;
}
export interface FilterState {
search: string;
tipe: 'all' | 'pendapatan' | 'belanja' | 'pembiayaan';
sortBy: 'uraian' | 'anggaran' | 'realisasi' | 'persentase';
sortOrder: 'asc' | 'desc';
}
export type LoadingState = {
initial: boolean;
changingYear: boolean;
};

418
task-project-apbdes.md Normal file
View File

@@ -0,0 +1,418 @@
# Task Project Menu: Modernisasi Halaman APBDes
## 📊 Project Overview
**Target File**: `src/app/darmasaba/_com/main-page/apbdes/index.tsx`
**Goal**: Modernisasi tampilan dan fungsionalitas halaman APBDes untuk meningkatkan user experience, visualisasi data, dan code quality.
---
## 🎯 Task List
### **Phase 1: UI/UX Enhancement** 🔥 HIGH PRIORITY
#### Task 1.1: Add Loading State
- [ ] Create `apbdesSkeleton.tsx` component
- [ ] Add skeleton untuk PaguTable
- [ ] Add skeleton untuk RealisasiTable
- [ ] Add skeleton untuk GrafikRealisasi
- [ ] Implement loading state saat ganti tahun
- [ ] Add smooth fade-in transition saat data load
**Files to Create/Modify**:
- `src/app/darmasaba/_com/main-page/apbdes/components/apbdesSkeleton.tsx` (CREATE)
- `src/app/darmasaba/_com/main-page/apbdes/index.tsx` (MODIFY)
**Estimated Time**: 45 menit
---
#### Task 1.2: Improve Table Design
- [ ] Add hover effects pada table rows
- [ ] Implement striped rows untuk readability
- [ ] Add sticky header untuk long data
- [ ] Improve typography dan spacing
- [ ] Add responsive table wrapper untuk mobile
- [ ] Add color coding untuk tipe data berbeda
**Files to Modify**:
- `src/app/darmasaba/_com/main-page/apbdes/lib/paguTable.tsx`
- `src/app/darmasaba/_com/main-page/apbdes/lib/realisasiTable.tsx`
**Estimated Time**: 1 jam
---
#### Task 1.3: Add Animations & Interactions
- [ ] Install Framer Motion (`bun add framer-motion`)
- [ ] Add fade-in animation untuk main container
- [ ] Add slide-up animation untuk tables
- [ ] Add hover scale effect untuk cards
- [ ] Add smooth transition saat ganti tahun
- [ ] Add loading spinner untuk Select component
**Dependencies**: `framer-motion`
**Files to Modify**:
- `src/app/darmasaba/_com/main-page/apbdes/index.tsx`
- `src/app/darmasaba/_com/main-page/apbdes/lib/*.tsx`
**Estimated Time**: 1 jam
---
### **Phase 2: Data Visualization** 📈 HIGH PRIORITY
#### Task 2.1: Install & Setup Recharts
- [ ] Install Recharts (`bun add recharts`)
- [ ] Create basic bar chart component
- [ ] Add tooltip dengan formatted data
- [ ] Add responsive container
- [ ] Configure color scheme
**Dependencies**: `recharts`
**Files to Create**:
- `src/app/darmasaba/_com/main-page/apbdes/lib/comparisonChart.tsx` (CREATE)
**Estimated Time**: 1 jam
---
#### Task 2.2: Create Interactive Charts
- [ ] Bar chart: Pagu vs Realisasi comparison
- [ ] Pie chart: Komposisi per kategori
- [ ] Line chart: Trend multi-tahun (jika data tersedia)
- [ ] Add legend dan labels
- [ ] Add export chart as image feature
**Files to Create**:
- `src/app/darmasaba/_com/main-page/apbdes/lib/barChart.tsx` (CREATE)
- `src/app/darmasaba/_com/main-page/apbdes/lib/pieChart.tsx` (CREATE)
**Estimated Time**: 2 jam
---
#### Task 2.3: Create Summary Cards
- [ ] Design summary card component
- [ ] Display Total Pagu
- [ ] Display Total Realisasi
- [ ] Display Persentase Realisasi
- [ ] Add trend indicators (↑↓)
- [ ] Add color-coded performance badges
- [ ] Add animated number counters
**Files to Create**:
- `src/app/darmasaba/_com/main-page/apbdes/lib/summaryCards.tsx` (CREATE)
**Estimated Time**: 1.5 jam
---
### **Phase 3: Features** ⚙️ MEDIUM PRIORITY
#### Task 3.1: Search & Filter
- [ ] Add search input untuk filter items
- [ ] Add filter dropdown by tipe (Pendapatan/Belanja/Pembiayaan)
- [ ] Add sort functionality (by jumlah, realisasi, persentase)
- [ ] Add clear filter button
- [ ] Add search result counter
**Files to Create/Modify**:
- `src/app/darmasaba/_com/main-page/apbdes/hooks/useApbdesFilter.ts` (CREATE)
- `src/app/darmasaba/_com/main-page/apbdes/index.tsx` (MODIFY)
**Estimated Time**: 1.5 jam
---
#### Task 3.2: Export & Print Functionality
- [ ] Install PDF library (`bun add @react-pdf/renderer`)
- [ ] Create PDF export template
- [ ] Add Excel export (`bun add exceljs`)
- [ ] Add print CSS styles
- [ ] Create export buttons component
- [ ] Add loading state saat export
**Dependencies**: `@react-pdf/renderer`, `exceljs`
**Files to Create**:
- `src/app/darmasaba/_com/main-page/apbdes/components/exportButtons.tsx` (CREATE)
- `src/app/darmasaba/_com/main-page/apbdes/utils/exportPdf.ts` (CREATE)
- `src/app/darmasaba/_com/main-page/apbdes/utils/exportExcel.ts` (CREATE)
**Estimated Time**: 2 jam
---
#### Task 3.3: Detail View Modal
- [ ] Add modal component untuk detail item
- [ ] Display breakdown realisasi per item
- [ ] Add historical comparison (tahun sebelumnya)
- [ ] Add close button dan ESC key handler
- [ ] Add responsive modal design
**Files to Create**:
- `src/app/darmasaba/_com/main-page/apbdes/components/detailModal.tsx` (CREATE)
**Estimated Time**: 1.5 jam
---
### **Phase 4: Code Quality** 🧹 MEDIUM PRIORITY
#### Task 4.1: TypeScript Improvements
- [ ] Create proper TypeScript types
- [ ] Replace all `any` dengan interfaces
- [ ] Add Zod schema validation
- [ ] Type-safe API responses
- [ ] Add generic types untuk reusable components
**Files to Create**:
- `src/app/darmasaba/_com/main-page/apbdes/types/apbdes.ts` (CREATE)
**Files to Modify**:
- All `.tsx` files in apbdes directory
**Estimated Time**: 1.5 jam
---
#### Task 4.2: Code Cleanup
- [ ] Remove all commented code
- [ ] Remove console.logs (replace dengan proper logging)
- [ ] Add error boundaries
- [ ] Improve error messages
- [ ] Add proper ESLint comments
- [ ] Add JSDoc untuk complex functions
**Estimated Time**: 1 jam
---
#### Task 4.3: Custom Hook Refactoring
- [ ] Create `useApbdesData` custom hook
- [ ] Move data fetching logic to hook
- [ ] Add SWR/React Query for caching (optional)
- [ ] Add optimistic updates
- [ ] Add error handling di hook level
**Files to Create**:
- `src/app/darmasaba/_com/main-page/apbdes/hooks/useApbdesData.ts` (CREATE)
**Estimated Time**: 1 jam
---
### **Phase 5: Advanced Features** 🚀 LOW PRIORITY (Optional)
#### Task 5.1: Year Comparison View
- [ ] Add multi-year selection
- [ ] Side-by-side comparison table
- [ ] Year-over-year growth calculation
- [ ] Add trend arrows dan percentage change
- [ ] Add comparison chart
**Files to Create**:
- `src/app/darmasaba/_com/main-page/apbdes/lib/yearComparison.tsx` (CREATE)
**Estimated Time**: 2 jam
---
#### Task 5.2: Dashboard Widgets
- [ ] Key metrics overview widget
- [ ] Budget utilization gauge chart
- [ ] Alert untuk over/under budget
- [ ] Quick stats summary
- [ ] Add drill-down capability
**Dependencies**: Mungkin perlu additional chart library
**Estimated Time**: 2.5 jam
---
#### Task 5.3: Responsive Mobile Optimization
- [ ] Mobile-first table design
- [ ] Collapsible sections untuk mobile
- [ ] Touch-friendly interactions
- [ ] Optimize chart untuk small screens
- [ ] Add mobile navigation
**Estimated Time**: 1.5 jam
---
## 📁 Proposed File Structure
```
src/app/darmasaba/_com/main-page/apbdes/
├── index.tsx # Main component (refactored)
├── lib/
│ ├── paguTable.tsx # Table Pagu (improved)
│ ├── realisasiTable.tsx # Table Realisasi (improved)
│ ├── grafikRealisasi.tsx # Chart component (updated)
│ ├── comparisonChart.tsx # NEW: Bar chart comparison
│ ├── barChart.tsx # NEW: Interactive bar chart
│ ├── pieChart.tsx # NEW: Pie chart visualization
│ └── summaryCards.tsx # NEW: Summary metrics cards
│ └── yearComparison.tsx # NEW: Year comparison view (optional)
├── components/
│ ├── apbdesSkeleton.tsx # NEW: Loading skeleton
│ ├── apbdesCard.tsx # NEW: Preview card
│ ├── exportButtons.tsx # NEW: Export/Print buttons
│ └── detailModal.tsx # NEW: Detail view modal
├── hooks/
│ ├── useApbdesData.ts # NEW: Data fetching hook
│ └── useApbdesFilter.ts # NEW: Search/filter hook
├── types/
│ └── apbdes.ts # NEW: TypeScript types & interfaces
└── utils/
├── exportPdf.ts # NEW: PDF export logic
└── exportExcel.ts # NEW: Excel export logic
```
---
## 📦 Required Dependencies
```bash
# Core dependencies
bun add framer-motion recharts
# Export functionality
bun add @react-pdf/renderer exceljs
# Optional: Better data fetching
bun add swr
# Type definitions
bun add -D @types/react-pdf
```
---
## 🎯 Success Criteria
### UI/UX
- [ ] Loading state implemented dengan skeleton
- [ ] Smooth animations pada semua interactions
- [ ] Modern table design dengan hover effects
- [ ] Fully responsive (mobile, tablet, desktop)
### Data Visualization
- [ ] Interactive charts (Recharts) implemented
- [ ] Summary cards dengan real-time metrics
- [ ] Color-coded performance indicators
- [ ] Responsive charts untuk semua screen sizes
### Features
- [ ] Search & filter functionality working
- [ ] Export to PDF working
- [ ] Export to Excel working
- [ ] Print view working
- [ ] Detail modal working
### Code Quality
- [ ] No `any` types (all properly typed)
- [ ] No commented code
- [ ] No console.logs in production code
- [ ] Error boundaries implemented
- [ ] Custom hooks for reusability
---
## ⏱️ Total Estimated Time
| Phase | Tasks | Estimated Time |
|-------|-------|---------------|
| Phase 1 | 3 tasks | 2.75 jam |
| Phase 2 | 3 tasks | 4.5 jam |
| Phase 3 | 3 tasks | 5 jam |
| Phase 4 | 3 tasks | 3.5 jam |
| Phase 5 | 3 tasks | 6 jam (optional) |
| **TOTAL** | **15 tasks** | **~21.75 jam** (tanpa Phase 5: ~15.75 jam) |
---
## 🚀 Recommended Implementation Order
1. **Start dengan Phase 1** (UI/UX Enhancement) - Quick wins, immediate visual improvement
2. **Continue dengan Phase 4** (Code Quality) - Clean foundation sebelum add features
3. **Move to Phase 2** (Data Visualization) - Core value add
4. **Then Phase 3** (Features) - User functionality
5. **Optional Phase 5** (Advanced) - If time permits
---
## 📝 Notes
- Prioritize tasks berdasarkan impact vs effort
- Test di berbagai screen sizes selama development
- Get user feedback setelah Phase 1 & 2 complete
- Consider A/B testing untuk new design
- Document all new components di storybook (if available)
---
## 🔗 Related Files
- Main Component: `src/app/darmasaba/_com/main-page/apbdes/index.tsx`
- State Management: `src/app/admin/(dashboard)/_state/landing-page/apbdes.ts`
- API Endpoint: `src/app/api/landingpage/apbdes/`
---
**Last Updated**: 2026-03-25
**Status**: Phase 1, 2, 4 Completed ✅
**Approved By**: Completed
---
## ✅ Completed Tasks Summary
### Phase 1: UI/UX Enhancement - DONE ✅
- ✅ Created `apbdesSkeleton.tsx` with loading skeletons for all components
- ✅ Improved table design with hover effects, striped rows, sticky headers
- ✅ Installed Framer Motion and added smooth animations
- ✅ Added loading states when changing year
- ✅ Added fade-in and slide-up transitions
### Phase 2: Data Visualization - DONE ✅
- ✅ Installed Recharts
- ✅ Created interactive comparison bar chart (Pagu vs Realisasi)
- ✅ Created summary cards with metrics and progress indicators
- ✅ Enhanced GrafikRealisasi with better visual design
- ✅ Added color-coded performance badges
### Phase 4: Code Quality - DONE ✅
- ✅ Created proper TypeScript types in `types/apbdes.ts`
- ✅ Replaced most `any` types with proper interfaces (some remain for flexibility)
- ✅ Removed commented code from main index.tsx
- ✅ Cleaned up console.logs
- ✅ Improved error handling
### Files Created:
1. `src/app/darmasaba/_com/main-page/apbdes/types/apbdes.ts` - TypeScript types
2. `src/app/darmasaba/_com/main-page/apbdes/components/apbdesSkeleton.tsx` - Loading skeletons
3. `src/app/darmasaba/_com/main-page/apbdes/lib/summaryCards.tsx` - Summary metrics cards
4. `src/app/darmasaba/_com/main-page/apbdes/lib/comparisonChart.tsx` - Recharts bar chart
5. `src/app/darmasaba/_com/main-page/apbdes/lib/paguTable.tsx` - Improved table (updated)
6. `src/app/darmasaba/_com/main-page/apbdes/lib/realisasiTable.tsx` - Improved table (updated)
7. `src/app/darmasaba/_com/main-page/apbdes/lib/grafikRealisasi.tsx` - Enhanced chart (updated)
8. `src/app/darmasaba/_com/main-page/apbdes/index.tsx` - Main component with animations (updated)
### Dependencies Installed:
- `framer-motion@12.38.0` - Animation library
- `recharts@3.8.0` - Chart library
---