Compare commits
7 Commits
backup-stg
...
tasks/api/
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cd6fcbd81 | |||
| 7d9b7b0c60 | |||
| 0806eb2308 | |||
| 51ce823b45 | |||
| f6f0e10935 | |||
| 2108f403aa | |||
| c6c3eebadf |
4
.env
4
.env
@@ -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
|
||||
@@ -12,7 +12,12 @@ WIBU_UPLOAD_DIR=uploads
|
||||
WIBU_DOWNLOAD_DIR=./download
|
||||
|
||||
# Application Configuration
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
# IMPORTANT: For staging/production, set this to your actual domain
|
||||
# Local development: NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
# Staging: NEXT_PUBLIC_BASE_URL=https://desa-darmasaba-stg.wibudev.com
|
||||
# Production: NEXT_PUBLIC_BASE_URL=https://your-production-domain.com
|
||||
# Or use relative URL '/' for automatic protocol/domain detection (recommended)
|
||||
NEXT_PUBLIC_BASE_URL=/
|
||||
|
||||
# Email Configuration (for notifications/subscriptions)
|
||||
EMAIL_USER=your_email@gmail.com
|
||||
|
||||
@@ -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);
|
||||
|
||||
173
MUSIK_CREATE_ANALYSIS.md
Normal file
173
MUSIK_CREATE_ANALYSIS.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Musik Desa - Create Feature Analysis
|
||||
|
||||
## Error Summary
|
||||
**Error**: `ERR_BLOCKED_BY_CLIENT` saat create musik di staging environment
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### 1. **CORS Configuration Issue** (Primary)
|
||||
File: `src/app/api/[[...slugs]]/route.ts`
|
||||
|
||||
The CORS configuration has specific origins listed:
|
||||
```typescript
|
||||
const corsConfig = {
|
||||
origin: [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
|
||||
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
|
||||
"*",
|
||||
],
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Problem**: The wildcard `*` is at the end, but some browsers don't respect it when `credentials: true` is set.
|
||||
|
||||
### 2. **API Fetch Base URL** (Secondary)
|
||||
File: `src/lib/api-fetch.ts`
|
||||
|
||||
```typescript
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
|
||||
```
|
||||
|
||||
**Problem**:
|
||||
- In staging, this might still default to `http://localhost:3000`
|
||||
- Mixed content (HTTPS frontend → HTTP API) gets blocked by browsers
|
||||
- The `NEXT_PUBLIC_BASE_URL` environment variable might not be set in staging
|
||||
|
||||
### 3. **File Storage Upload Path** (Tertiary)
|
||||
File: `src/app/api/[[...slugs]]/_lib/fileStorage/_lib/create.ts`
|
||||
|
||||
```typescript
|
||||
const UPLOAD_DIR = process.env.WIBU_UPLOAD_DIR;
|
||||
```
|
||||
|
||||
**Problem**: If `WIBU_UPLOAD_DIR` is not set or points to a non-writable location, uploads will fail silently.
|
||||
|
||||
## Solution
|
||||
|
||||
### Fix 1: Update CORS Configuration
|
||||
**File**: `src/app/api/[[...slugs]]/route.ts`
|
||||
|
||||
```typescript
|
||||
// Move wildcard to first position and ensure it works with credentials
|
||||
const corsConfig = {
|
||||
origin: [
|
||||
"*", // Allow all origins (for staging flexibility)
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
|
||||
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
|
||||
"https://desa-darmasaba-stg.wibudev.com"
|
||||
],
|
||||
methods: ["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] as HTTPMethod[],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Accept"],
|
||||
exposedHeaders: ["Content-Range", "X-Content-Range"],
|
||||
maxAge: 86400, // 24 hours
|
||||
credentials: true,
|
||||
};
|
||||
```
|
||||
|
||||
### Fix 2: Add Environment Variable Validation
|
||||
**File**: `.env.example` (update)
|
||||
|
||||
```bash
|
||||
# Application Configuration
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
|
||||
# For staging/production, set this to your actual domain
|
||||
# NEXT_PUBLIC_BASE_URL=https://cld-dkr-desa-darmasaba-stg.wibudev.com
|
||||
```
|
||||
|
||||
### Fix 3: Update API Fetch to Handle Relative URLs
|
||||
**File**: `src/lib/api-fetch.ts`
|
||||
|
||||
```typescript
|
||||
import { AppServer } from '@/app/api/[[...slugs]]/route'
|
||||
import { treaty } from '@elysiajs/eden'
|
||||
|
||||
// Use relative URL for better deployment flexibility
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || '/'
|
||||
|
||||
const ApiFetch = treaty<AppServer>(BASE_URL)
|
||||
|
||||
export default ApiFetch
|
||||
```
|
||||
|
||||
### Fix 4: Add Error Handling in Create Page
|
||||
**File**: `src/app/admin/(dashboard)/musik/create/page.tsx`
|
||||
|
||||
Add better error logging to diagnose issues:
|
||||
|
||||
```typescript
|
||||
const handleSubmit = async () => {
|
||||
// ... validation ...
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Upload cover image
|
||||
const coverRes = await ApiFetch.api.fileStorage.create.post({
|
||||
file: coverFile,
|
||||
name: coverFile.name,
|
||||
});
|
||||
|
||||
if (!coverRes.data?.data?.id) {
|
||||
console.error('Cover upload failed:', coverRes);
|
||||
return toast.error('Gagal mengunggah cover, silakan coba lagi');
|
||||
}
|
||||
|
||||
// ... rest of the code ...
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating musik:', {
|
||||
error,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
toast.error('Terjadi kesalahan saat membuat musik');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Local Development
|
||||
- [ ] Test create musik with cover image and audio file
|
||||
- [ ] Verify CORS headers in browser DevTools Network tab
|
||||
- [ ] Check that file uploads are saved to correct directory
|
||||
|
||||
### Staging Environment
|
||||
- [ ] Set `NEXT_PUBLIC_BASE_URL` to staging domain
|
||||
- [ ] Verify HTTPS is used for all API calls
|
||||
- [ ] Check browser console for mixed content warnings
|
||||
- [ ] Verify `WIBU_UPLOAD_DIR` is set and writable
|
||||
- [ ] Test create musik end-to-end
|
||||
|
||||
## Additional Notes
|
||||
|
||||
### ERR_BLOCKED_BY_CLIENT Common Causes:
|
||||
1. **CORS policy blocking** - Most likely cause
|
||||
2. **Ad blockers** - Can block certain API endpoints
|
||||
3. **Mixed content** - HTTPS page making HTTP requests
|
||||
4. **Content Security Policy (CSP)** - Restrictive CSP headers
|
||||
5. **Browser extensions** - Privacy/security extensions blocking requests
|
||||
|
||||
### Debugging Steps:
|
||||
1. Open browser DevTools → Network tab
|
||||
2. Try to create musik
|
||||
3. Look for failed requests (red status)
|
||||
4. Check the "Headers" tab for:
|
||||
- Request URL (should be correct domain)
|
||||
- Response headers (should have `Access-Control-Allow-Origin`)
|
||||
- Status code (4xx/5xx indicates server-side issue)
|
||||
5. Check browser console for CORS errors
|
||||
|
||||
## Recommended Next Steps
|
||||
|
||||
1. **Immediate**: Update CORS configuration to allow staging domain
|
||||
2. **Short-term**: Add proper environment variable validation
|
||||
3. **Long-term**: Implement proper error boundaries and logging
|
||||
@@ -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",
|
||||
|
||||
@@ -123,37 +123,51 @@ export default function CreateMusik() {
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Upload cover image
|
||||
console.log('Uploading cover image:', coverFile.name);
|
||||
const coverRes = await ApiFetch.api.fileStorage.create.post({
|
||||
file: coverFile,
|
||||
name: coverFile.name,
|
||||
});
|
||||
|
||||
console.log('Cover upload response:', coverRes);
|
||||
const coverUploaded = coverRes.data?.data;
|
||||
if (!coverUploaded?.id) {
|
||||
return toast.error('Gagal mengunggah cover, silakan coba lagi');
|
||||
console.error('Cover upload failed:', coverRes);
|
||||
toast.error('Gagal mengunggah cover, silakan coba lagi');
|
||||
return;
|
||||
}
|
||||
|
||||
musikState.musik.create.form.coverImageId = coverUploaded.id;
|
||||
|
||||
// Upload audio file
|
||||
console.log('Uploading audio file:', audioFile.name);
|
||||
const audioRes = await ApiFetch.api.fileStorage.create.post({
|
||||
file: audioFile,
|
||||
name: audioFile.name,
|
||||
});
|
||||
|
||||
console.log('Audio upload response:', audioRes);
|
||||
const audioUploaded = audioRes.data?.data;
|
||||
if (!audioUploaded?.id) {
|
||||
return toast.error('Gagal mengunggah audio, silakan coba lagi');
|
||||
console.error('Audio upload failed:', audioRes);
|
||||
toast.error('Gagal mengunggah audio, silakan coba lagi');
|
||||
return;
|
||||
}
|
||||
|
||||
musikState.musik.create.form.audioFileId = audioUploaded.id;
|
||||
|
||||
// Create musik entry
|
||||
console.log('Creating musik entry with form:', musikState.musik.create.form);
|
||||
await musikState.musik.create.create();
|
||||
|
||||
resetForm();
|
||||
router.push('/admin/musik');
|
||||
} catch (error) {
|
||||
console.error('Error creating musik:', error);
|
||||
console.error('Error creating musik:', {
|
||||
error,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
toast.error('Terjadi kesalahan saat membuat musik');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
})
|
||||
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
|
||||
@@ -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"]
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -6,7 +6,7 @@ import roleFindUnique from "./findUnique";
|
||||
import roleUpdate from "./updt";
|
||||
|
||||
const Role = new Elysia({
|
||||
prefix: "/api/role",
|
||||
prefix: "/role",
|
||||
tags: ["User / Role"],
|
||||
})
|
||||
|
||||
|
||||
@@ -47,15 +47,16 @@ fs.mkdir(UPLOAD_DIR_IMAGE, {
|
||||
|
||||
const corsConfig = {
|
||||
origin: [
|
||||
"*", // Allow all origins - must be first when using credentials: true
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
|
||||
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
|
||||
"*", // Allow all origins in development
|
||||
"https://desa-darmasaba-stg.wibudev.com",
|
||||
],
|
||||
methods: ["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] as HTTPMethod[],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "*"],
|
||||
exposedHeaders: "*",
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Accept", "*"],
|
||||
exposedHeaders: ["Content-Range", "X-Content-Range", "*"],
|
||||
maxAge: 86400, // 24 hours
|
||||
credentials: true,
|
||||
};
|
||||
@@ -66,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,7 +82,17 @@ if (!process.env.WIBU_UPLOAD_DIR)
|
||||
throw new Error("WIBU_UPLOAD_DIR is not defined");
|
||||
|
||||
const ApiServer = new Elysia()
|
||||
.use(swagger({ path: "/api/docs" }))
|
||||
.use(
|
||||
swagger({
|
||||
path: "/api/docs",
|
||||
documentation: {
|
||||
info: {
|
||||
title: "Desa Darmasaba API Documentation",
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
.use(
|
||||
staticPlugin({
|
||||
assets: UPLOAD_DIR,
|
||||
@@ -89,21 +100,6 @@ const ApiServer = new Elysia()
|
||||
}),
|
||||
)
|
||||
.use(cors(corsConfig))
|
||||
.use(Utils)
|
||||
.use(FileStorage)
|
||||
.use(LandingPage)
|
||||
.use(PPID)
|
||||
.use(Desa)
|
||||
.use(Kesehatan)
|
||||
.use(Keamanan)
|
||||
.use(Ekonomi)
|
||||
.use(Inovasi)
|
||||
.use(Lingkungan)
|
||||
.use(Pendidikan)
|
||||
.use(User)
|
||||
.use(Role)
|
||||
.use(Search)
|
||||
|
||||
.onError(({ code }) => {
|
||||
if (code === "NOT_FOUND") {
|
||||
return {
|
||||
@@ -114,6 +110,20 @@ const ApiServer = new Elysia()
|
||||
})
|
||||
.group("/api", (app) =>
|
||||
app
|
||||
.use(Utils)
|
||||
.use(FileStorage)
|
||||
.use(LandingPage)
|
||||
.use(PPID)
|
||||
.use(Desa)
|
||||
.use(Kesehatan)
|
||||
.use(Keamanan)
|
||||
.use(Ekonomi)
|
||||
.use(Inovasi)
|
||||
.use(Lingkungan)
|
||||
.use(Pendidikan)
|
||||
.use(User)
|
||||
.use(Role)
|
||||
.use(Search)
|
||||
.get("/layanan", layanan)
|
||||
.get("/potensi", getPotensi)
|
||||
.get(
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
229
src/app/darmasaba/_com/main-page/apbdes/lib/comparisonChart.tsx
Normal file
229
src/app/darmasaba/_com/main-page/apbdes/lib/comparisonChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
90
src/app/darmasaba/_com/main-page/apbdes/types/apbdes.ts
Normal file
90
src/app/darmasaba/_com/main-page/apbdes/types/apbdes.ts
Normal 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;
|
||||
};
|
||||
@@ -1,9 +1,25 @@
|
||||
import { AppServer } from '@/app/api/[[...slugs]]/route'
|
||||
import { treaty } from '@elysiajs/eden'
|
||||
|
||||
// const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'localhost:3000'
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
|
||||
// Determine the base URL based on environment
|
||||
// treaty requires a full URL, cannot use relative paths like '/'
|
||||
const getBaseUrl = () => {
|
||||
// Development (server-side)
|
||||
if (process.env.NODE_ENV === 'development' && typeof window === 'undefined') {
|
||||
return process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
|
||||
}
|
||||
|
||||
// Client-side (browser) - use current window origin
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.origin
|
||||
}
|
||||
|
||||
// Production/Staging server-side - use environment variable or default
|
||||
return process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
|
||||
}
|
||||
|
||||
const BASE_URL = getBaseUrl()
|
||||
|
||||
const ApiFetch = treaty<AppServer>(BASE_URL)
|
||||
|
||||
export default ApiFetch
|
||||
export default ApiFetch
|
||||
|
||||
418
task-project-apbdes.md
Normal file
418
task-project-apbdes.md
Normal 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
|
||||
|
||||
---
|
||||
Reference in New Issue
Block a user