Compare commits
107 Commits
nico/2-mar
...
tasks/api/
| Author | SHA1 | Date | |
|---|---|---|---|
| 8777c45a44 | |||
| b751f031cd | |||
| a3940321a7 | |||
| 3cd6fcbd81 | |||
| 7d9b7b0c60 | |||
| 0806eb2308 | |||
| 51ce823b45 | |||
| f6f0e10935 | |||
| 2108f403aa | |||
| c6c3eebadf | |||
|
|
0dabc204bc | ||
|
|
e8f8b51686 | ||
|
|
a4db3a149d | ||
|
|
fece983ac5 | ||
| 8b7eef5fee | |||
| 8b22d01e0d | |||
| dc13e37a02 | |||
| 2d2cbef29b | |||
| 8c8a96b830 | |||
| dc3eccacbf | |||
| ffe94992e5 | |||
| 4fb522f88f | |||
| 85332a8225 | |||
| 3fe2a5ccab | |||
| 363bfa65fb | |||
| dccf590cbf | |||
| f076b81d14 | |||
| b5ea3216e0 | |||
| 64b116588b | |||
| 63161e1a39 | |||
| 8b8c65dd1e | |||
| 159fb3cec6 | |||
| 4821934224 | |||
| ee39b88b00 | |||
| ce46d3b5f7 | |||
| 144ac37e12 | |||
| f90477ed63 | |||
| 4a7811e06f | |||
| f63aaf916d | |||
| 3803c79c95 | |||
| 2d901912ea | |||
| a791efe76c | |||
| e9f7bc2043 | |||
| 6712da9ac2 | |||
| ac11a9367c | |||
| 67e5ceb254 | |||
| 65942ac9d2 | |||
| e0436cc384 | |||
| 63682e47b6 | |||
| f4705690a9 | |||
| 239771a714 | |||
| 03451195c8 | |||
| 597af7e716 | |||
| 0a8a026b94 | |||
| 0160fa636d | |||
| 3684e83187 | |||
| 77c54b5c8a | |||
| bb80b0ecc1 | |||
| 1b59d6bf09 | |||
| eb1ad54db6 | |||
| 21ec3ad1c1 | |||
| 3a115908c4 | |||
| 5ff791642c | |||
| b803c7a90c | |||
| fb2fe67c23 | |||
| 51460558d4 | |||
| d105ceeb6b | |||
| c865aee766 | |||
| 273dfdfd09 | |||
| 1d1d8e50dc | |||
| 092afe67d2 | |||
| 2d9170705d | |||
| fdf9a951a4 | |||
| ca74029688 | |||
| 1a8fc1a670 | |||
| 19235f0791 | |||
| 61de7d8d33 | |||
| 8fb85ce56c | |||
| 1f98b6993d | |||
| f3a10d63d1 | |||
| 7a42bec63b | |||
| 44c421129e | |||
| ddff427926 | |||
| 00c8caade4 | |||
| 0209f49449 | |||
| 344c6ada6d | |||
| 11acd04419 | |||
| 8d49213b68 | |||
| 96911e3cf1 | |||
| 9950c28b9b | |||
| fa0f3538d1 | |||
| 2778f53aff | |||
| 37ac91d4f4 | |||
| 217f4a9a3b | |||
| 5d6a7437ed | |||
| 752a6cabee | |||
| 134ddc6154 | |||
| 28979c6b49 | |||
| b2066caa13 | |||
| 023c77d636 | |||
| 9bf3ec72cf | |||
| f359f5b1ce | |||
| 1c1e8fb190 | |||
| 54f83da3b8 | |||
| f8985c550f | |||
| e3d909e760 | |||
| 16a8df50c1 |
19
.env
Normal file
19
.env
Normal file
@@ -0,0 +1,19 @@
|
||||
DATABASE_URL="postgresql://bip:Production_123@localhost:5433/desa-darmasaba-v0.0.1?schema=public"
|
||||
# Seafile
|
||||
SEAFILE_TOKEN=20a19f4a04032215d50ce53292e6abdd38b9f806
|
||||
SEAFILE_REPO_ID=f0e9ee4a-fd13-49a2-81c0-f253951d063a
|
||||
SEAFILE_URL=https://cld-dkr-makuro-seafile.wibudev.com
|
||||
SEAFILE_PUBLIC_SHARE_TOKEN=3a9a9ecb5e244f4da8ae
|
||||
|
||||
# Upload
|
||||
WIBU_UPLOAD_DIR=uploads
|
||||
WIBU_DOWNLOAD_DIR="./download"
|
||||
NEXT_PUBLIC_BASE_URL="http://localhost:3000"
|
||||
EMAIL_USER=nicoarya20@gmail.com
|
||||
EMAIL_PASS=hymmfpcaqzqkfgbh
|
||||
BASE_SESSION_KEY=kp9sGx91as0Kj2Ls81nAsl2Kdj13KsxP
|
||||
BASE_TOKEN_KEY=Qm82JsA92lMnKw0291mxKaaP02KjslaA
|
||||
|
||||
# BOT-TELE
|
||||
BOT_TOKEN=8479423145:AAE9ArrOgTD3DyVxYSVs3IXN40u_sL6c9sw
|
||||
CHAT_ID=-1003368982298
|
||||
41
.env.example
Normal file
41
.env.example
Normal file
@@ -0,0 +1,41 @@
|
||||
# Database Configuration
|
||||
DATABASE_URL="postgresql://username:password@localhost:5432/desa-darmasaba?schema=public"
|
||||
|
||||
# Seafile Configuration (File Storage)
|
||||
SEAFILE_TOKEN=your_seafile_token
|
||||
SEAFILE_REPO_ID=your_seafile_repo_id
|
||||
SEAFILE_URL=https://your-seafile-instance.com
|
||||
SEAFILE_PUBLIC_SHARE_TOKEN=your_seafile_public_share_token
|
||||
|
||||
# Upload Configuration
|
||||
WIBU_UPLOAD_DIR=uploads
|
||||
WIBU_DOWNLOAD_DIR=./download
|
||||
|
||||
# Application Configuration
|
||||
# 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
|
||||
EMAIL_PASS=your_email_app_password
|
||||
|
||||
# Session Configuration
|
||||
BASE_SESSION_KEY=your_session_key_generate_secure_random_string
|
||||
BASE_TOKEN_KEY=your_jwt_secret_key_generate_secure_random_string
|
||||
|
||||
# Telegram Bot Configuration (for notifications)
|
||||
BOT_TOKEN=your_telegram_bot_token
|
||||
CHAT_ID=your_telegram_chat_id
|
||||
|
||||
# Session Password (for iron-session)
|
||||
SESSION_PASSWORD="your_session_password_min_32_characters_long_secure"
|
||||
|
||||
# ElevenLabs API Key (for TTS features - optional)
|
||||
ELEVENLABS_API_KEY=your_elevenlabs_api_key
|
||||
|
||||
# Environment (optional, defaults to development)
|
||||
# NODE_ENV=development
|
||||
@@ -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);
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -29,7 +29,12 @@ yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env
|
||||
.env*
|
||||
# env local files (keep .env.example)
|
||||
.env.local
|
||||
.env*.local
|
||||
.env.production
|
||||
.env.development
|
||||
!.env.example
|
||||
|
||||
# QC
|
||||
QC
|
||||
@@ -52,7 +57,5 @@ next-env.d.ts
|
||||
|
||||
.github/
|
||||
|
||||
.env.*
|
||||
|
||||
*.tar.gz
|
||||
|
||||
|
||||
73
AUDIT_REPORT.md
Normal file
73
AUDIT_REPORT.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Engineering Audit Report: Desa Darmasaba
|
||||
**Status:** Production Readiness Review (Critical)
|
||||
**Auditor:** Staff Technical Architect
|
||||
|
||||
---
|
||||
|
||||
## 📊 Executive Summary & Scores
|
||||
|
||||
| Category | Score | Status |
|
||||
| :--- | :---: | :--- |
|
||||
| **Project Architecture** | 3/10 | 🔴 Critical Failure |
|
||||
| **Code Quality** | 4/10 | 🟠 Poor |
|
||||
| **Performance** | 5/10 | 🟡 Mediocre |
|
||||
| **Security** | 5/10 | 🟠 Risk Detected |
|
||||
| **Production Readiness** | 2/10 | 🔴 Not Ready |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 1. Project Architecture
|
||||
The project suffers from a **"Frankenstein Architecture"**. It attempts to run a full Elysia.js instance inside a Next.js Catch-All route.
|
||||
- **Fractured Backend:** Logic is split between standard Next.js routes (`/api/auth`) and embedded Elysia modules.
|
||||
- **Stateful Dependency:** Reliance on local filesystem (`WIBU_UPLOAD_DIR`) makes the application impossible to deploy on modern serverless platforms like Vercel.
|
||||
- **Polluted Namespace:** Routing tree contains "test/coba" folders (`src/app/coba`, `src/app/percobaan`) that would be accessible in production.
|
||||
|
||||
## ⚛️ 2. Frontend Engineering (React / Next.js)
|
||||
- **State Management Chaos:** Simultaneous use of `Valtio`, `Jotai`, `React Context`, and `localStorage`.
|
||||
- **Tight Coupling:** Public pages (`/darmasaba`) import state directly from Admin internal states (`/admin/(dashboard)/_state`).
|
||||
- **Heavy Client-Side Logic:** Logic that belongs in Server Actions or Hooks is embedded in presentational components (e.g., `Footer.tsx`).
|
||||
|
||||
## 📡 3. Backend / API Design
|
||||
- **Framework Overhead:** Running Elysia inside Next.js adds unnecessary cold-boot overhead and complexity.
|
||||
- **Weak Validation:** Widespread use of `as Type` casting in API handlers instead of runtime validation (Zod/Schema).
|
||||
- **Service Integration:** OTP codes are sent via external `GET` requests with sensitive data in the query string—a major logging risk.
|
||||
|
||||
## 🗄️ 4. Database & Data Modeling (Prisma)
|
||||
- **Schema Over-Normalization:** ~2000 lines of schema. Every minor content type (e.g., `LambangDesa`) is a separate table instead of a unified CMS model.
|
||||
- **Polymorphic Monolith:** `FileStorage` is a "god table" with optional relations to ~40 other tables, creating a massive bottleneck and data integrity risk.
|
||||
- **Connection Mismanagement:** Manual `prisma.$disconnect()` in API routes kills connection pooling performance.
|
||||
|
||||
## 🚀 5. Performance Engineering
|
||||
- **Bypassing Optimization:** Custom `/api/utils/img` endpoint bypasses `next/image` optimization, serving uncompressed assets.
|
||||
- **Aggressive Polling:** Client-side 30s polling for notifications is battery-draining and inefficient compared to SSE or SWR.
|
||||
|
||||
## 🔒 6. Security Audit
|
||||
- **Insecure OTP Delivery:** Credentials passed as URL parameters to the WhatsApp service.
|
||||
- **File Upload Risks:** Potential for Arbitrary File Upload due to direct local filesystem writes without rigorous sanitization.
|
||||
|
||||
## 🧹 7. Code Quality
|
||||
- **Inconsistency:** Mixed English/Indonesian naming (e.g., `nomor` vs `createdAt`).
|
||||
- **Artifacts:** Root directory is littered with scratch files: `xcoba.ts`, `xx.ts`, `test.txt`.
|
||||
|
||||
---
|
||||
|
||||
## 🚩 Top 10 Critical Problems
|
||||
1. **Architectural Fracture:** Embedding Elysia inside Next.js creates a "split-brain" system.
|
||||
2. **Serverless Incompatibility:** Dependency on local disk storage for uploads.
|
||||
3. **Database Bloat:** Over-complicated schema with a fragile `FileStorage` monolith.
|
||||
4. **State Fragmentation:** Mixed usage of Jotai and Valtio without a clear standard.
|
||||
5. **Credential Leakage:** OTP codes sent via GET query parameters.
|
||||
6. **Poor Cleanup:** Trial/Test folders and files committed to the production source.
|
||||
7. **Asset Performance:** Bypassing Next.js image optimization.
|
||||
8. **Coupling:** High dependency between public UI and internal Admin state.
|
||||
9. **Type Safety:** Manual casting in APIs instead of runtime validation.
|
||||
10. **Connection Pooling:** Inefficient Prisma connection management.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Tech Lead Refactoring Priorities
|
||||
1. **Unify the API:** Decommission the Elysia wrapper. Port all logic to standard Next.js Route Handlers with Zod validation.
|
||||
2. **Stateless Storage:** Implement an S3-compatible adapter for all file uploads. Remove `fs` usage.
|
||||
3. **Schema Consolidation:** Refactor the schema to use generic content models where possible.
|
||||
4. **Standardize State:** Choose one global state manager and migrate all components.
|
||||
5. **Project Sanitization:** Delete all `coba`, `percobaan`, and scratch files (`xcoba.ts`, etc.).
|
||||
60
Dockerfile
Normal file
60
Dockerfile
Normal file
@@ -0,0 +1,60 @@
|
||||
# Stage 1: Build
|
||||
FROM oven/bun:1.3 AS build
|
||||
|
||||
# Install build dependencies for native modules
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json bun.lock* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Use .env.example as default env for build
|
||||
RUN cp .env.example .env
|
||||
|
||||
# Generate Prisma client
|
||||
RUN bun x prisma generate
|
||||
|
||||
# Build the application frontend
|
||||
ENV NODE_ENV=production
|
||||
RUN bun run build
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM oven/bun:1.3-slim AS runtime
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy necessary files from build stage
|
||||
COPY --from=build /app/package.json ./
|
||||
COPY --from=build /app/tsconfig.json ./
|
||||
COPY --from=build /app/.next ./.next
|
||||
COPY --from=build /app/public ./public
|
||||
COPY --from=build /app/src ./src
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/prisma ./prisma
|
||||
|
||||
# Expose the port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the application
|
||||
CMD ["bun", "start"]
|
||||
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
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ['@elysiajs/static', 'elysia'],
|
||||
experimental: {},
|
||||
allowedDevOrigins: [
|
||||
"http://192.168.1.82:3000", // buat akses dari HP/device lain
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@mantine/modals": "^8.3.6",
|
||||
"@mantine/tiptap": "^7.17.4",
|
||||
"@paljs/types": "^8.1.0",
|
||||
"@prisma/client": "^6.3.1",
|
||||
"@prisma/client": "6.3.1",
|
||||
"@tabler/icons-react": "^3.30.0",
|
||||
"@tiptap/extension-highlight": "^2.11.7",
|
||||
"@tiptap/extension-link": "^2.11.7",
|
||||
@@ -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",
|
||||
@@ -89,7 +89,7 @@
|
||||
"p-limit": "^6.2.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.6",
|
||||
"prisma": "^6.3.1",
|
||||
"prisma": "6.3.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-exif-orientation-img": "^0.1.5",
|
||||
@@ -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",
|
||||
|
||||
@@ -209,16 +209,22 @@ model APBDesItem {
|
||||
kode String // contoh: "4", "4.1", "4.1.2"
|
||||
uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha"
|
||||
anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS)
|
||||
realisasi Float
|
||||
selisih Float // realisasi - anggaran
|
||||
persentase Float
|
||||
tipe String? // (realisasi / anggaran) * 100
|
||||
tipe String? // "pendapatan" | "belanja" | "pembiayaan" | null
|
||||
level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail
|
||||
parentId String? // untuk relasi hierarki
|
||||
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
|
||||
children APBDesItem[] @relation("APBDesItemParent")
|
||||
apbdesId String
|
||||
apbdes APBDes @relation(fields: [apbdesId], references: [id])
|
||||
|
||||
// Field kalkulasi (auto-calculated dari realisasi items)
|
||||
totalRealisasi Float @default(0) // Sum dari semua realisasi
|
||||
selisih Float @default(0) // totalRealisasi - anggaran
|
||||
persentase Float @default(0) // (totalRealisasi / anggaran) * 100
|
||||
|
||||
// Relasi ke realisasi items
|
||||
realisasiItems RealisasiItem[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
@@ -229,6 +235,28 @@ model APBDesItem {
|
||||
@@index([apbdesId])
|
||||
}
|
||||
|
||||
// Model baru untuk multiple realisasi per item
|
||||
model RealisasiItem {
|
||||
id String @id @default(cuid())
|
||||
kode String? // Kode realisasi, mirip dengan APBDesItem
|
||||
apbdesItemId String
|
||||
apbdesItem APBDesItem @relation(fields: [apbdesItemId], references: [id], onDelete: Cascade)
|
||||
|
||||
jumlah Float // Jumlah realisasi dalam Rupiah
|
||||
tanggal DateTime @db.Date // Tanggal realisasi
|
||||
keterangan String? @db.Text // Keterangan tambahan (opsional)
|
||||
buktiFileId String? // FileStorage ID untuk bukti/foto (opsional)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
@@index([kode])
|
||||
@@index([apbdesItemId])
|
||||
@@index([tanggal])
|
||||
}
|
||||
|
||||
//========================================= PRESTASI DESA ========================================= //
|
||||
model PrestasiDesa {
|
||||
id String @id @default(cuid())
|
||||
|
||||
@@ -5,53 +5,52 @@ import { toast } from "react-toastify";
|
||||
import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
|
||||
// --- Zod Schema ---
|
||||
// --- Zod Schema untuk APBDes Item (dengan field kalkulasi) ---
|
||||
const ApbdesItemSchema = z.object({
|
||||
kode: z.string().min(1, "Kode wajib diisi"),
|
||||
uraian: z.string().min(1, "Uraian wajib diisi"),
|
||||
anggaran: z.number().min(0),
|
||||
realisasi: z.number().min(0),
|
||||
selisih: z.number(),
|
||||
persentase: z.number(),
|
||||
anggaran: z.number().min(0, "Anggaran tidak boleh negatif"),
|
||||
level: z.number().int().min(1).max(3),
|
||||
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
|
||||
// Field kalkulasi dari realisasiItems (auto-calculated di backend)
|
||||
realisasi: z.number().min(0).default(0),
|
||||
selisih: z.number().default(0),
|
||||
persentase: z.number().default(0),
|
||||
});
|
||||
|
||||
const ApbdesFormSchema = z.object({
|
||||
tahun: z.number().int().min(2000, "Tahun tidak valid"),
|
||||
imageId: z.string().min(1, "Gambar wajib diunggah"),
|
||||
fileId: z.string().min(1, "File wajib diunggah"),
|
||||
name: z.string().optional(),
|
||||
deskripsi: z.string().optional(),
|
||||
jumlah: z.string().optional(),
|
||||
// Image dan file opsional (bisa kosong)
|
||||
imageId: z.string().optional(),
|
||||
fileId: z.string().optional(),
|
||||
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
|
||||
});
|
||||
|
||||
// --- Default Form ---
|
||||
const defaultApbdesForm = {
|
||||
tahun: new Date().getFullYear(),
|
||||
name: "",
|
||||
deskripsi: "",
|
||||
jumlah: "",
|
||||
imageId: "",
|
||||
fileId: "",
|
||||
items: [] as z.infer<typeof ApbdesItemSchema>[],
|
||||
};
|
||||
|
||||
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
|
||||
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
|
||||
// --- Helper: Normalize item (dengan field kalkulasi) ---
|
||||
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
|
||||
const anggaran = item.anggaran ?? 0;
|
||||
const realisasi = item.realisasi ?? 0;
|
||||
|
||||
|
||||
// ✅ Formula yang benar
|
||||
const selisih = realisasi - anggaran; // positif = sisa anggaran, negatif = over budget
|
||||
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran
|
||||
|
||||
return {
|
||||
kode: item.kode || "",
|
||||
uraian: item.uraian || "",
|
||||
anggaran,
|
||||
realisasi,
|
||||
selisih,
|
||||
persentase,
|
||||
anggaran: item.anggaran ?? 0,
|
||||
level: item.level || 1,
|
||||
tipe: item.tipe, // biarkan null jika memang null
|
||||
tipe: item.tipe ?? null,
|
||||
realisasi: item.realisasi ?? 0,
|
||||
selisih: item.selisih ?? 0,
|
||||
persentase: item.persentase ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,7 +112,7 @@ const apbdes = proxy({
|
||||
findMany: {
|
||||
data: null as
|
||||
| Prisma.APBDesGetPayload<{
|
||||
include: { image: true; file: true; items: true };
|
||||
include: { image: true; file: true; items: { include: { realisasiItems: true } } };
|
||||
}>[]
|
||||
| null,
|
||||
page: 1,
|
||||
@@ -158,33 +157,37 @@ const apbdes = proxy({
|
||||
findUnique: {
|
||||
data: null as
|
||||
| Prisma.APBDesGetPayload<{
|
||||
include: { image: true; file: true; items: true };
|
||||
include: { image: true; file: true; items: { include: { realisasiItems: true } } };
|
||||
}>
|
||||
| null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
|
||||
|
||||
async load(id: string) {
|
||||
if (!id || id.trim() === '') {
|
||||
this.data = null;
|
||||
this.error = "ID tidak valid";
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Prevent multiple simultaneous loads
|
||||
if (this.loading) {
|
||||
console.log("⚠️ Already loading, skipping...");
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
|
||||
try {
|
||||
// Pastikan URL-nya benar
|
||||
const url = `/api/landingpage/apbdes/${id}`;
|
||||
console.log("🌐 Fetching:", url);
|
||||
|
||||
// Gunakan fetch biasa atau ApiFetch dengan cara yang benar
|
||||
|
||||
const response = await fetch(url);
|
||||
const res = await response.json();
|
||||
|
||||
|
||||
console.log("📦 Response:", res);
|
||||
|
||||
|
||||
if (res.success && res.data) {
|
||||
this.data = res.data;
|
||||
} else {
|
||||
@@ -244,15 +247,18 @@ const apbdes = proxy({
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
tahun: data.tahun || new Date().getFullYear(),
|
||||
name: data.name || "",
|
||||
deskripsi: data.deskripsi || "",
|
||||
jumlah: data.jumlah || "",
|
||||
imageId: data.imageId || "",
|
||||
fileId: data.fileId || "",
|
||||
items: (data.items || []).map((item: any) => ({
|
||||
kode: item.kode,
|
||||
uraian: item.uraian,
|
||||
anggaran: item.anggaran,
|
||||
realisasi: item.realisasi,
|
||||
selisih: item.selisih,
|
||||
persentase: item.persentase,
|
||||
realisasi: item.totalRealisasi || 0,
|
||||
selisih: item.selisih || 0,
|
||||
persentase: item.persentase || 0,
|
||||
level: item.level,
|
||||
tipe: item.tipe || 'pendapatan',
|
||||
})),
|
||||
@@ -280,11 +286,24 @@ const apbdes = proxy({
|
||||
try {
|
||||
this.loading = true;
|
||||
// Include the ID in the request body
|
||||
// Omit realisasi, selisih, persentase karena itu calculated fields di backend
|
||||
const requestData = {
|
||||
...parsed.data,
|
||||
id: this.id, // Add the ID to the request body
|
||||
tahun: parsed.data.tahun,
|
||||
name: parsed.data.name,
|
||||
deskripsi: parsed.data.deskripsi,
|
||||
jumlah: parsed.data.jumlah,
|
||||
imageId: parsed.data.imageId,
|
||||
fileId: parsed.data.fileId,
|
||||
id: this.id,
|
||||
items: parsed.data.items.map(item => ({
|
||||
kode: item.kode,
|
||||
uraian: item.uraian,
|
||||
anggaran: item.anggaran,
|
||||
level: item.level,
|
||||
tipe: item.tipe ?? null,
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
|
||||
|
||||
if (res.data?.success) {
|
||||
@@ -317,6 +336,82 @@ const apbdes = proxy({
|
||||
this.form = { ...defaultApbdesForm };
|
||||
},
|
||||
},
|
||||
|
||||
// =========================================
|
||||
// REALISASI STATE MANAGEMENT
|
||||
// =========================================
|
||||
realisasi: {
|
||||
// Create realisasi
|
||||
async create(itemId: string, data: { kode: string; jumlah: number; tanggal: string; keterangan?: string; buktiFileId?: string }) {
|
||||
try {
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any)[itemId].realisasi.post(data);
|
||||
|
||||
if (res.data?.success) {
|
||||
toast.success("Realisasi berhasil ditambahkan");
|
||||
// Reload findUnique untuk update data
|
||||
const currentId = apbdes.findUnique.data?.id;
|
||||
if (currentId) {
|
||||
await apbdes.findUnique.load(currentId);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
toast.error(res.data?.message || "Gagal menambahkan realisasi");
|
||||
return false;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Create realisasi error:", error);
|
||||
toast.error(error?.message || "Terjadi kesalahan saat menambahkan realisasi");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// Update realisasi
|
||||
async update(realisasiId: string, data: { kode?: string; jumlah?: number; tanggal?: string; keterangan?: string; buktiFileId?: string }) {
|
||||
try {
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].put(data);
|
||||
|
||||
if (res.data?.success) {
|
||||
toast.success("Realisasi berhasil diperbarui");
|
||||
// Reload findUnique untuk update data
|
||||
const currentId = apbdes.findUnique.data?.id;
|
||||
if (currentId) {
|
||||
await apbdes.findUnique.load(currentId);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
toast.error(res.data?.message || "Gagal memperbarui realisasi");
|
||||
return false;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Update realisasi error:", error);
|
||||
toast.error(error?.message || "Terjadi kesalahan saat memperbarui realisasi");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// Delete realisasi
|
||||
async delete(realisasiId: string) {
|
||||
try {
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].delete();
|
||||
|
||||
if (res.data?.success) {
|
||||
toast.success("Realisasi berhasil dihapus");
|
||||
// Reload findUnique untuk update data
|
||||
if (apbdes.findUnique.data) {
|
||||
await apbdes.findUnique.load(apbdes.findUnique.data.id);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
toast.error(res.data?.message || "Gagal menghapus realisasi");
|
||||
return false;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Delete realisasi error:", error);
|
||||
toast.error(error?.message || "Terjadi kesalahan saat menghapus realisasi");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default apbdes;
|
||||
@@ -0,0 +1,429 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
'use client';
|
||||
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Title,
|
||||
Table,
|
||||
TableThead,
|
||||
TableTbody,
|
||||
TableTr,
|
||||
TableTh,
|
||||
TableTd,
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Modal,
|
||||
Divider,
|
||||
Center,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconPlus,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconCalendar,
|
||||
IconCoin,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
interface RealisasiManagerProps {
|
||||
itemId: string;
|
||||
itemKode: string;
|
||||
itemUraian: string;
|
||||
itemAnggaran: number;
|
||||
itemTotalRealisasi: number;
|
||||
itemPersentase: number;
|
||||
realisasiItems: any[];
|
||||
}
|
||||
|
||||
export default function RealisasiManager({
|
||||
itemId,
|
||||
itemKode,
|
||||
itemUraian,
|
||||
itemAnggaran,
|
||||
itemTotalRealisasi,
|
||||
itemPersentase,
|
||||
realisasiItems,
|
||||
}: RealisasiManagerProps) {
|
||||
const state = useProxy(apbdes);
|
||||
const [modalOpened, setModalOpened] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
kode: '',
|
||||
jumlah: 0,
|
||||
tanggal: new Date().toISOString().split('T')[0], // YYYY-MM-DD format for input
|
||||
keterangan: '',
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
kode: '',
|
||||
jumlah: 0,
|
||||
tanggal: new Date().toISOString().split('T')[0],
|
||||
keterangan: '',
|
||||
});
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
resetForm();
|
||||
setModalOpened(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (realisasi: any) => {
|
||||
const tanggal = new Date(realisasi.tanggal);
|
||||
const tanggalStr = tanggal.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
|
||||
setFormData({
|
||||
kode: realisasi.kode || '',
|
||||
jumlah: realisasi.jumlah,
|
||||
tanggal: tanggalStr,
|
||||
keterangan: realisasi.keterangan || '',
|
||||
});
|
||||
setEditingId(realisasi.id);
|
||||
setModalOpened(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (formData.jumlah <= 0) {
|
||||
return toast.warn('Jumlah realisasi harus lebih dari 0');
|
||||
}
|
||||
|
||||
if (!formData.kode || formData.kode.trim() === '') {
|
||||
return toast.warn('Kode realisasi wajib diisi');
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
if (editingId) {
|
||||
// Update existing realisasi
|
||||
const success = await state.realisasi.update(editingId, {
|
||||
kode: formData.kode,
|
||||
jumlah: formData.jumlah,
|
||||
tanggal: new Date(formData.tanggal).toISOString(),
|
||||
keterangan: formData.keterangan,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
toast.success('Realisasi berhasil diperbarui');
|
||||
}
|
||||
} else {
|
||||
// Create new realisasi
|
||||
const success = await state.realisasi.create(itemId, {
|
||||
kode: formData.kode,
|
||||
jumlah: formData.jumlah,
|
||||
tanggal: new Date(formData.tanggal).toISOString(),
|
||||
keterangan: formData.keterangan,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
toast.success('Realisasi berhasil ditambahkan');
|
||||
}
|
||||
}
|
||||
|
||||
setModalOpened(false);
|
||||
resetForm();
|
||||
} catch (error: any) {
|
||||
console.error('Error saving realisasi:', error);
|
||||
toast.error(error?.message || 'Gagal menyimpan realisasi');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (realisasiId: string) => {
|
||||
if (!confirm('Apakah Anda yakin ingin menghapus realisasi ini?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const success = await state.realisasi.delete(realisasiId);
|
||||
|
||||
if (success) {
|
||||
toast.success('Realisasi berhasil dihapus');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting realisasi:', error);
|
||||
toast.error(error?.message || 'Gagal menghapus realisasi');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatRupiah = (amount: number) => {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('id-ID', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getSisaAnggaran = () => {
|
||||
return itemAnggaran - itemTotalRealisasi;
|
||||
};
|
||||
|
||||
const getPersentaseColor = (persen: number) => {
|
||||
if (persen >= 100) return 'teal';
|
||||
if (persen >= 80) return 'blue';
|
||||
if (persen >= 60) return 'yellow';
|
||||
return 'red';
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md" mt="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between" mb="md">
|
||||
<Stack gap="xs">
|
||||
<Title order={6}>
|
||||
{itemKode} - {itemUraian}
|
||||
</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
Kelola realisasi untuk item ini
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
onClick={handleOpenCreate}
|
||||
color="blue"
|
||||
variant="light"
|
||||
radius="md"
|
||||
>
|
||||
Tambah Realisasi
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<Group grow mb="md">
|
||||
<Paper withBorder p="md" radius="md" bg="blue.0">
|
||||
<Text fz="xs" c="blue.9" fw={600}>
|
||||
ANGGARAN
|
||||
</Text>
|
||||
<Text fz="lg" c="blue.9" fw={700}>
|
||||
{formatRupiah(itemAnggaran)}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="md" radius="md" bg="teal.0">
|
||||
<Text fz="xs" c="teal.9" fw={600}>
|
||||
TOTAL REALISASI
|
||||
</Text>
|
||||
<Text fz="lg" c="teal.9" fw={700}>
|
||||
{formatRupiah(itemTotalRealisasi)}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="md" radius="md" bg={getSisaAnggaran() >= 0 ? 'green.0' : 'red.0'}>
|
||||
<Text fz="xs" c={getSisaAnggaran() >= 0 ? 'green.9' : 'red.9'} fw={600}>
|
||||
SISA ANGGARAN
|
||||
</Text>
|
||||
<Text fz="lg" c={getSisaAnggaran() >= 0 ? 'green.9' : 'red.9'} fw={700}>
|
||||
{formatRupiah(getSisaAnggaran())}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="md" radius="md" bg={getPersentaseColor(itemPersentase) + '.0'}>
|
||||
<Text fz="xs" c={getPersentaseColor(itemPersentase) + '.9'} fw={600}>
|
||||
PERSENTASE
|
||||
</Text>
|
||||
<Text fz="lg" c={getPersentaseColor(itemPersentase) + '.9'} fw={700}>
|
||||
{itemPersentase.toFixed(2)}%
|
||||
</Text>
|
||||
</Paper>
|
||||
</Group>
|
||||
|
||||
{/* Realisasi List */}
|
||||
{realisasiItems && realisasiItems.length > 0 ? (
|
||||
<Box>
|
||||
<Text fz="sm" fw={600} mb="xs">
|
||||
Daftar Realisasi ({realisasiItems.length})
|
||||
</Text>
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table striped highlightOnHover fz="sm">
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>Kode</TableTh>
|
||||
<TableTh>Tanggal</TableTh>
|
||||
<TableTh>Uraian</TableTh>
|
||||
<TableTh ta="right">Jumlah</TableTh>
|
||||
<TableTh ta="center">Aksi</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{realisasiItems.map((realisasi) => (
|
||||
<TableTr key={realisasi.id}>
|
||||
<TableTd>
|
||||
<Badge variant="light" color="blue" size="sm">
|
||||
{realisasi.kode || '-'}
|
||||
</Badge>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Group gap="xs">
|
||||
<IconCalendar size={16} />
|
||||
<Text fz="sm">{formatDate(realisasi.tanggal)}</Text>
|
||||
</Group>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Text fz="sm">{realisasi.keterangan || '-'}</Text>
|
||||
</TableTd>
|
||||
<TableTd ta="right">
|
||||
<Text fz="sm" fw={600} c="blue">
|
||||
{formatRupiah(realisasi.jumlah)}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd ta="center">
|
||||
<Group gap="xs" justify="center">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => handleOpenEdit(realisasi)}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(realisasi.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
<Text fz="sm" c="dimmed">
|
||||
Belum ada realisasi untuk item ini
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Klik tombol "Tambah Realisasi" untuk menambahkan
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* Modal Create/Edit */}
|
||||
<Modal
|
||||
opened={modalOpened}
|
||||
onClose={() => {
|
||||
setModalOpened(false);
|
||||
resetForm();
|
||||
}}
|
||||
title={
|
||||
<Text fz="lg" fw={600}>
|
||||
{editingId ? 'Edit Realisasi' : 'Tambah Realisasi Baru'}
|
||||
</Text>
|
||||
}
|
||||
size="md"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Info Item */}
|
||||
<Paper p="sm" bg="gray.0" radius="md">
|
||||
<Text fz="xs" c="dimmed">
|
||||
Item: {itemKode} - {itemUraian}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Anggaran: {formatRupiah(itemAnggaran)}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Sudah terealisasi: {formatRupiah(itemTotalRealisasi)}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
<TextInput
|
||||
label="Kode Realisasi"
|
||||
placeholder="Contoh: 4.1.1-R1"
|
||||
value={formData.kode}
|
||||
onChange={(e) => setFormData({ ...formData, kode: e.target.value })}
|
||||
description="Kode unik untuk realisasi ini"
|
||||
required
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Jumlah Realisasi (Rp)"
|
||||
value={formData.jumlah}
|
||||
onChange={(val) => setFormData({ ...formData, jumlah: Number(val) || 0 })}
|
||||
leftSection={<IconCoin size={16} />}
|
||||
thousandSeparator
|
||||
min={0}
|
||||
step={100000}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Tanggal Realisasi"
|
||||
type="date"
|
||||
value={formData.tanggal}
|
||||
onChange={(e) => setFormData({ ...formData, tanggal: e.target.value })}
|
||||
leftSection={<IconCalendar size={18} />}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Keterangan / Uraian"
|
||||
placeholder="Contoh: Penyaluran BLT Tahap 1"
|
||||
value={formData.keterangan}
|
||||
onChange={(e) => setFormData({ ...formData, keterangan: e.target.value })}
|
||||
description="Deskripsi singkat tentang realisasi ini"
|
||||
/>
|
||||
|
||||
<Divider my="xs" />
|
||||
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setModalOpened(false);
|
||||
resetForm();
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
color="blue"
|
||||
leftSection={editingId ? <IconEdit size={16} /> : <IconPlus size={16} />}
|
||||
>
|
||||
{editingId ? 'Perbarui' : 'Tambah'} Realisasi
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -42,9 +42,11 @@ type ItemForm = {
|
||||
kode: string;
|
||||
uraian: string;
|
||||
anggaran: number;
|
||||
realisasi: number;
|
||||
level: number;
|
||||
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
|
||||
realisasi?: number;
|
||||
selisih?: number;
|
||||
persentase?: number;
|
||||
};
|
||||
|
||||
function EditAPBDes() {
|
||||
@@ -71,14 +73,19 @@ function EditAPBDes() {
|
||||
kode: '',
|
||||
uraian: '',
|
||||
anggaran: 0,
|
||||
realisasi: 0,
|
||||
level: 1,
|
||||
tipe: 'pendapatan',
|
||||
realisasi: 0,
|
||||
selisih: 0,
|
||||
persentase: 0,
|
||||
});
|
||||
|
||||
// Simpan data original untuk reset form
|
||||
const [originalData, setOriginalData] = useState({
|
||||
tahun: 0,
|
||||
name: '',
|
||||
deskripsi: '',
|
||||
jumlah: '',
|
||||
imageId: '',
|
||||
fileId: '',
|
||||
imageUrl: '',
|
||||
@@ -103,6 +110,9 @@ function EditAPBDes() {
|
||||
// Simpan data original untuk reset
|
||||
setOriginalData({
|
||||
tahun: data.tahun || new Date().getFullYear(),
|
||||
name: data.name || '',
|
||||
deskripsi: data.deskripsi || '',
|
||||
jumlah: data.jumlah || '',
|
||||
imageId: data.imageId || '',
|
||||
fileId: data.fileId || '',
|
||||
imageUrl: data.image?.link || '',
|
||||
@@ -112,15 +122,18 @@ function EditAPBDes() {
|
||||
// Set form dengan data lama (termasuk imageId dan fileId)
|
||||
apbdesState.edit.form = {
|
||||
tahun: data.tahun || new Date().getFullYear(),
|
||||
name: data.name || '',
|
||||
deskripsi: data.deskripsi || '',
|
||||
jumlah: data.jumlah || '',
|
||||
imageId: data.imageId || '',
|
||||
fileId: data.fileId || '',
|
||||
items: (data.items || []).map((item: any) => ({
|
||||
kode: item.kode,
|
||||
uraian: item.uraian,
|
||||
anggaran: item.anggaran,
|
||||
realisasi: item.realisasi,
|
||||
selisih: item.selisih,
|
||||
persentase: item.persentase,
|
||||
realisasi: item.totalRealisasi || 0,
|
||||
selisih: item.selisih || 0,
|
||||
persentase: item.persentase || 0,
|
||||
level: item.level,
|
||||
tipe: item.tipe || 'pendapatan',
|
||||
})),
|
||||
@@ -148,34 +161,33 @@ function EditAPBDes() {
|
||||
};
|
||||
|
||||
const handleAddItem = () => {
|
||||
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
|
||||
const { kode, uraian, anggaran, level, tipe, realisasi, selisih, persentase } = newItem;
|
||||
if (!kode || !uraian) {
|
||||
return toast.warn('Kode dan uraian wajib diisi');
|
||||
}
|
||||
|
||||
const finalTipe = level === 1 ? null : tipe;
|
||||
const selisih = realisasi - anggaran;
|
||||
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
|
||||
|
||||
apbdesState.edit.addItem({
|
||||
kode,
|
||||
uraian,
|
||||
anggaran,
|
||||
realisasi,
|
||||
selisih,
|
||||
persentase,
|
||||
realisasi: realisasi || 0,
|
||||
selisih: selisih || 0,
|
||||
persentase: persentase || 0,
|
||||
level,
|
||||
tipe: finalTipe, // ✅ Tidak akan undefined
|
||||
tipe: finalTipe,
|
||||
});
|
||||
|
||||
|
||||
setNewItem({
|
||||
kode: '',
|
||||
uraian: '',
|
||||
anggaran: 0,
|
||||
realisasi: 0,
|
||||
level: 1,
|
||||
tipe: 'pendapatan',
|
||||
realisasi: 0,
|
||||
selisih: 0,
|
||||
persentase: 0,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -193,7 +205,6 @@ function EditAPBDes() {
|
||||
|
||||
// Upload file baru jika ada perubahan
|
||||
if (imageFile) {
|
||||
// Hapus file lama dari form jika ada file baru
|
||||
const res = await ApiFetch.api.fileStorage.create.post({
|
||||
file: imageFile,
|
||||
name: imageFile.name,
|
||||
@@ -205,7 +216,6 @@ function EditAPBDes() {
|
||||
}
|
||||
|
||||
if (docFile) {
|
||||
// Hapus file lama dari form jika ada file baru
|
||||
const res = await ApiFetch.api.fileStorage.create.post({
|
||||
file: docFile,
|
||||
name: docFile.name,
|
||||
@@ -216,15 +226,7 @@ function EditAPBDes() {
|
||||
}
|
||||
}
|
||||
|
||||
// Jika tidak ada file baru, gunakan ID lama (sudah ada di form)
|
||||
// Pastikan imageId dan fileId tetap ada
|
||||
if (!apbdesState.edit.form.imageId) {
|
||||
return toast.warn('Gambar wajib diunggah');
|
||||
}
|
||||
if (!apbdesState.edit.form.fileId) {
|
||||
return toast.warn('Dokumen wajib diunggah');
|
||||
}
|
||||
|
||||
// Image dan file sekarang opsional, tidak perlu validasi
|
||||
const success = await apbdesState.edit.update();
|
||||
if (success) {
|
||||
router.push('/admin/landing-page/apbdes');
|
||||
@@ -238,9 +240,12 @@ function EditAPBDes() {
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
// Reset ke data original (tahun, imageId, fileId)
|
||||
// Reset ke data original (tahun, name, deskripsi, jumlah, imageId, fileId)
|
||||
apbdesState.edit.form = {
|
||||
tahun: originalData.tahun,
|
||||
name: originalData.name,
|
||||
deskripsi: originalData.deskripsi,
|
||||
jumlah: originalData.jumlah,
|
||||
imageId: originalData.imageId,
|
||||
fileId: originalData.fileId,
|
||||
items: [...apbdesState.edit.form.items], // keep existing items
|
||||
@@ -249,21 +254,23 @@ function EditAPBDes() {
|
||||
// Reset preview ke data original
|
||||
setPreviewImage(originalData.imageUrl || null);
|
||||
setPreviewDoc(originalData.fileUrl || null);
|
||||
|
||||
|
||||
// Reset file uploads
|
||||
setImageFile(null);
|
||||
setDocFile(null);
|
||||
|
||||
|
||||
// Reset new item form
|
||||
setNewItem({
|
||||
kode: '',
|
||||
uraian: '',
|
||||
anggaran: 0,
|
||||
realisasi: 0,
|
||||
level: 1,
|
||||
tipe: 'pendapatan',
|
||||
realisasi: 0,
|
||||
selisih: 0,
|
||||
persentase: 0,
|
||||
});
|
||||
|
||||
|
||||
toast.info('Form dikembalikan ke data awal');
|
||||
};
|
||||
|
||||
@@ -288,6 +295,33 @@ function EditAPBDes() {
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Header Form */}
|
||||
<TextInput
|
||||
label="Nama APBDes"
|
||||
placeholder="Contoh: APBDes Tahun 2025"
|
||||
value={apbdesState.edit.form.name}
|
||||
onChange={(e) =>
|
||||
(apbdesState.edit.form.name = e.target.value)
|
||||
}
|
||||
description="Opsional - akan diisi otomatis jika kosong"
|
||||
/>
|
||||
<TextInput
|
||||
label="Deskripsi"
|
||||
placeholder="Deskripsi APBDes (opsional)"
|
||||
value={apbdesState.edit.form.deskripsi}
|
||||
onChange={(e) =>
|
||||
(apbdesState.edit.form.deskripsi = e.target.value)
|
||||
}
|
||||
description="Opsional"
|
||||
/>
|
||||
<TextInput
|
||||
label="Jumlah Total"
|
||||
placeholder="Contoh: Rp 1.000.000.000"
|
||||
value={apbdesState.edit.form.jumlah}
|
||||
onChange={(e) =>
|
||||
(apbdesState.edit.form.jumlah = e.target.value)
|
||||
}
|
||||
description="Opsional - total keseluruhan anggaran"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Tahun"
|
||||
value={apbdesState.edit.form.tahun || new Date().getFullYear()}
|
||||
@@ -299,11 +333,11 @@ function EditAPBDes() {
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Gambar & Dokumen */}
|
||||
{/* Gambar & Dokumen (Opsional) */}
|
||||
<Stack gap="xs">
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar APBDes
|
||||
Gambar APBDes (Opsional)
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={handleDrop('image')}
|
||||
@@ -343,6 +377,7 @@ function EditAPBDes() {
|
||||
onClick={() => {
|
||||
setPreviewImage(null);
|
||||
setImageFile(null);
|
||||
apbdesState.edit.form.imageId = ''; // Clear imageId from form
|
||||
}}
|
||||
>
|
||||
<IconX size={14} />
|
||||
@@ -353,7 +388,7 @@ function EditAPBDes() {
|
||||
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Dokumen APBDes
|
||||
Dokumen APBDes (Opsional)
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={handleDrop('doc')}
|
||||
@@ -402,6 +437,7 @@ function EditAPBDes() {
|
||||
onClick={() => {
|
||||
setPreviewDoc(null);
|
||||
setDocFile(null);
|
||||
apbdesState.edit.form.fileId = ''; // Clear fileId from form
|
||||
}}
|
||||
>
|
||||
<IconX size={14} />
|
||||
@@ -475,13 +511,6 @@ function EditAPBDes() {
|
||||
thousandSeparator
|
||||
min={0}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Realisasi (Rp)"
|
||||
value={newItem.realisasi}
|
||||
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
|
||||
thousandSeparator
|
||||
min={0}
|
||||
/>
|
||||
</Group>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
@@ -506,6 +535,8 @@ function EditAPBDes() {
|
||||
<th>Uraian</th>
|
||||
<th>Anggaran</th>
|
||||
<th>Realisasi</th>
|
||||
<th>Selisih</th>
|
||||
<th>%</th>
|
||||
<th>Level</th>
|
||||
<th>Tipe</th>
|
||||
<th style={{ width: '50px' }}>Aksi</th>
|
||||
@@ -521,7 +552,11 @@ function EditAPBDes() {
|
||||
</td>
|
||||
<td>{item.uraian}</td>
|
||||
<td>{item.anggaran.toLocaleString('id-ID')}</td>
|
||||
<td>{item.realisasi.toLocaleString('id-ID')}</td>
|
||||
<td>{item.realisasi?.toLocaleString('id-ID') || '0'}</td>
|
||||
<td style={{ color: item.selisih && item.selisih > 0 ? 'red' : 'green' }}>
|
||||
{item.selisih?.toLocaleString('id-ID') || '0'}
|
||||
</td>
|
||||
<td>{item.persentase?.toFixed(2) || '0'}%</td>
|
||||
<td>
|
||||
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
|
||||
L{item.level}
|
||||
@@ -533,7 +568,7 @@ function EditAPBDes() {
|
||||
{item.tipe}
|
||||
</Badge>
|
||||
) : (
|
||||
'-'
|
||||
<Text size="sm" c="dimmed">-</Text>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useEffect, useState } from 'react';
|
||||
import colors from '@/con/colors';
|
||||
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
|
||||
import apbdes from '../../../_state/landing-page/apbdes';
|
||||
import RealisasiManager from './RealisasiManager';
|
||||
|
||||
|
||||
|
||||
@@ -94,7 +95,7 @@ function DetailAPBDes() {
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Nama APBDes</Text>
|
||||
<Text fz="md" c="dimmed">
|
||||
{data.name || '-'}
|
||||
{data.name || `APBDes Tahun ${data.tahun}`}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -105,6 +106,24 @@ function DetailAPBDes() {
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{data.deskripsi && (
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Deskripsi</Text>
|
||||
<Text fz="md" c="dimmed">
|
||||
{data.deskripsi}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{data.jumlah && (
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Jumlah Total</Text>
|
||||
<Text fz="md" c="dimmed">
|
||||
{data.jumlah}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Gambar</Text>
|
||||
{data.image?.link ? (
|
||||
@@ -173,48 +192,60 @@ function DetailAPBDes() {
|
||||
|
||||
{/* Tabel Items */}
|
||||
{data.items && data.items.length > 0 ? (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Text fz="lg" fw="bold" mb="sm">
|
||||
<Stack gap="md">
|
||||
<Text fz="lg" fw="bold">
|
||||
Rincian Pendapatan & Belanja ({data.items.length} item)
|
||||
</Text>
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>Uraian</TableTh>
|
||||
<TableTh>Anggaran (Rp)</TableTh>
|
||||
<TableTh>Realisasi (Rp)</TableTh>
|
||||
<TableTh>Selisih (Rp)</TableTh>
|
||||
<TableTh>Persentase (%)</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{[...data.items] // Create a new array before sorting
|
||||
.sort((a, b) => a.kode.localeCompare(b.kode))
|
||||
.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd style={getIndent(item.level)}>
|
||||
<Group>
|
||||
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
|
||||
<Text fz="sm" c="dimmed">{item.uraian}</Text>
|
||||
</Group>
|
||||
</TableTd>
|
||||
<TableTd>{item.anggaran.toLocaleString('id-ID')}</TableTd>
|
||||
<TableTd>{item.realisasi.toLocaleString('id-ID')}</TableTd>
|
||||
<TableTd>
|
||||
<Text c={item.selisih >= 0 ? 'green' : 'red'}>
|
||||
{item.selisih.toLocaleString('id-ID')}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Text fw={500}>{item.persentase.toFixed(2)}%</Text>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Paper>
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>Uraian</TableTh>
|
||||
<TableTh>Anggaran (Rp)</TableTh>
|
||||
<TableTh>Realisasi (Rp)</TableTh>
|
||||
<TableTh>Selisih (Rp)</TableTh>
|
||||
<TableTh>Persentase (%)</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{[...data.items]
|
||||
.sort((a, b) => a.kode.localeCompare(b.kode))
|
||||
.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd style={getIndent(item.level)}>
|
||||
<Group>
|
||||
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
|
||||
<Text fz="sm" c="dimmed">{item.uraian}</Text>
|
||||
</Group>
|
||||
</TableTd>
|
||||
<TableTd>{item.anggaran.toLocaleString('id-ID')}</TableTd>
|
||||
<TableTd>{item.totalRealisasi.toLocaleString('id-ID')}</TableTd>
|
||||
<TableTd>
|
||||
<Text c={item.selisih >= 0 ? 'green' : 'red'}>
|
||||
{item.selisih.toLocaleString('id-ID')}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Text fw={500}>{item.persentase.toFixed(2)}%</Text>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
{/* Realisasi Manager untuk setiap item */}
|
||||
{data.items.map((item) => (
|
||||
<RealisasiManager
|
||||
key={item.id}
|
||||
itemId={item.id}
|
||||
itemKode={item.kode}
|
||||
itemUraian={item.uraian}
|
||||
itemAnggaran={item.anggaran}
|
||||
itemTotalRealisasi={item.totalRealisasi}
|
||||
itemPersentase={item.persentase}
|
||||
realisasiItems={item.realisasiItems || []}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text>Belum ada data item</Text>
|
||||
)}
|
||||
|
||||
@@ -33,7 +33,6 @@ type ItemForm = {
|
||||
kode: string;
|
||||
uraian: string;
|
||||
anggaran: number;
|
||||
realisasi: number;
|
||||
level: number;
|
||||
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
|
||||
};
|
||||
@@ -47,13 +46,9 @@ function CreateAPBDes() {
|
||||
const [docFile, setDocFile] = useState<File | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Check if form is valid
|
||||
// Check if form is valid - hanya cek items, gambar dan file opsional
|
||||
const isFormValid = () => {
|
||||
return (
|
||||
imageFile !== null &&
|
||||
docFile !== null &&
|
||||
stateAPBDes.create.form.items.length > 0
|
||||
);
|
||||
return stateAPBDes.create.form.items.length > 0;
|
||||
};
|
||||
|
||||
// Form sementara untuk input item baru
|
||||
@@ -61,7 +56,6 @@ function CreateAPBDes() {
|
||||
kode: '',
|
||||
uraian: '',
|
||||
anggaran: 0,
|
||||
realisasi: 0,
|
||||
level: 1,
|
||||
tipe: 'pendapatan',
|
||||
});
|
||||
@@ -80,35 +74,40 @@ function CreateAPBDes() {
|
||||
kode: '',
|
||||
uraian: '',
|
||||
anggaran: 0,
|
||||
realisasi: 0,
|
||||
level: 1,
|
||||
tipe: 'pendapatan',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!imageFile || !docFile) {
|
||||
return toast.warn("Pilih gambar dan dokumen terlebih dahulu");
|
||||
}
|
||||
if (stateAPBDes.create.form.items.length === 0) {
|
||||
return toast.warn("Minimal tambahkan 1 item APBDes");
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const [uploadImageRes, uploadDocRes] = await Promise.all([
|
||||
ApiFetch.api.fileStorage.create.post({ file: imageFile, name: imageFile.name }),
|
||||
ApiFetch.api.fileStorage.create.post({ file: docFile, name: docFile.name }),
|
||||
]);
|
||||
|
||||
const imageId = uploadImageRes?.data?.data?.id;
|
||||
const fileId = uploadDocRes?.data?.data?.id;
|
||||
// Upload files hanya jika ada file yang dipilih
|
||||
let imageId = '';
|
||||
let fileId = '';
|
||||
|
||||
if (!imageId || !fileId) {
|
||||
return toast.error("Gagal mengupload file");
|
||||
if (imageFile) {
|
||||
const uploadImageRes = await ApiFetch.api.fileStorage.create.post({
|
||||
file: imageFile,
|
||||
name: imageFile.name,
|
||||
});
|
||||
imageId = uploadImageRes?.data?.data?.id || '';
|
||||
}
|
||||
|
||||
// Update form dengan ID file
|
||||
if (docFile) {
|
||||
const uploadDocRes = await ApiFetch.api.fileStorage.create.post({
|
||||
file: docFile,
|
||||
name: docFile.name,
|
||||
});
|
||||
fileId = uploadDocRes?.data?.data?.id || '';
|
||||
}
|
||||
|
||||
// Update form dengan ID file (bisa kosong)
|
||||
stateAPBDes.create.form.imageId = imageId;
|
||||
stateAPBDes.create.form.fileId = fileId;
|
||||
|
||||
@@ -117,9 +116,9 @@ function CreateAPBDes() {
|
||||
toast.success("Berhasil menambahkan APBDes");
|
||||
resetForm();
|
||||
router.push("/admin/landing-page/apbdes");
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error("Gagal submit:", error);
|
||||
toast.error("Gagal menyimpan data");
|
||||
toast.error(error?.message || "Gagal menyimpan data");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -127,22 +126,17 @@ function CreateAPBDes() {
|
||||
|
||||
// Tambahkan item ke state
|
||||
const handleAddItem = () => {
|
||||
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
|
||||
const { kode, uraian, anggaran, level, tipe } = newItem;
|
||||
if (!kode || !uraian) {
|
||||
return toast.warn("Kode dan uraian wajib diisi");
|
||||
}
|
||||
|
||||
const finalTipe = level === 1 ? null : tipe;
|
||||
const selisih = realisasi - anggaran;
|
||||
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
|
||||
|
||||
stateAPBDes.create.addItem({
|
||||
kode,
|
||||
uraian,
|
||||
anggaran,
|
||||
realisasi,
|
||||
selisih,
|
||||
persentase,
|
||||
level,
|
||||
tipe: finalTipe,
|
||||
});
|
||||
@@ -152,7 +146,6 @@ function CreateAPBDes() {
|
||||
kode: '',
|
||||
uraian: '',
|
||||
anggaran: 0,
|
||||
realisasi: 0,
|
||||
level: 1,
|
||||
tipe: 'pendapatan',
|
||||
});
|
||||
@@ -183,12 +176,16 @@ function CreateAPBDes() {
|
||||
style={{ border: '1px solid #e0e0e0' }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Gambar & Dokumen (dipendekkan untuk fokus pada items) */}
|
||||
{/* Info: File opsional */}
|
||||
<Text fz="sm" c="dimmed" mb="xs">
|
||||
* Upload gambar dan dokumen bersifat opsional. Bisa dikosongkan jika belum ada.
|
||||
</Text>
|
||||
|
||||
<Stack gap={"xs"}>
|
||||
{/* Gambar APBDes */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar APBDes
|
||||
Gambar APBDes (Opsional)
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
@@ -258,10 +255,10 @@ function CreateAPBDes() {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Dokumen APBDes */}
|
||||
{/* Dokumen APBDes (Opsional) */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Dokumen APBDes
|
||||
Dokumen APBDes (Opsional)
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
@@ -334,6 +331,27 @@ function CreateAPBDes() {
|
||||
</Stack>
|
||||
|
||||
{/* Form Header */}
|
||||
<TextInput
|
||||
label="Nama APBDes"
|
||||
placeholder="Contoh: APBDes Tahun 2025"
|
||||
value={stateAPBDes.create.form.name}
|
||||
onChange={(e) => (stateAPBDes.create.form.name = e.target.value)}
|
||||
description="Opsional - akan diisi otomatis jika kosong"
|
||||
/>
|
||||
<TextInput
|
||||
label="Deskripsi"
|
||||
placeholder="Deskripsi APBDes (opsional)"
|
||||
value={stateAPBDes.create.form.deskripsi}
|
||||
onChange={(e) => (stateAPBDes.create.form.deskripsi = e.target.value)}
|
||||
description="Opsional"
|
||||
/>
|
||||
<TextInput
|
||||
label="Jumlah Total"
|
||||
placeholder="Contoh: Rp 1.000.000.000"
|
||||
value={stateAPBDes.create.form.jumlah}
|
||||
onChange={(e) => (stateAPBDes.create.form.jumlah = e.target.value)}
|
||||
description="Opsional - total keseluruhan anggaran"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Tahun"
|
||||
value={stateAPBDes.create.form.tahun || new Date().getFullYear()}
|
||||
@@ -406,13 +424,6 @@ function CreateAPBDes() {
|
||||
thousandSeparator
|
||||
min={0}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Realisasi (Rp)"
|
||||
value={newItem.realisasi}
|
||||
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
|
||||
thousandSeparator
|
||||
min={0}
|
||||
/>
|
||||
</Group>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
@@ -434,28 +445,30 @@ function CreateAPBDes() {
|
||||
<th>Kode</th>
|
||||
<th>Uraian</th>
|
||||
<th>Anggaran</th>
|
||||
<th>Realisasi</th>
|
||||
<th>Level</th>
|
||||
<th>Tipe</th>
|
||||
<th style={{ width: 50 }}>Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stateAPBDes.create.form.items.map((item, idx) => (
|
||||
{stateAPBDes.create.form.items.map((item: any, idx) => (
|
||||
<tr key={idx}>
|
||||
<td><Text size="sm" fw={500}>{item.kode}</Text></td>
|
||||
<td>{item.uraian}</td>
|
||||
<td>{item.anggaran.toLocaleString('id-ID')}</td>
|
||||
<td>{item.realisasi.toLocaleString('id-ID')}</td>
|
||||
<td>
|
||||
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
|
||||
L{item.level}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>
|
||||
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
|
||||
{item.tipe}
|
||||
</Badge>
|
||||
{item.tipe ? (
|
||||
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
|
||||
{item.tipe}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">-</Text>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>
|
||||
|
||||
@@ -45,7 +45,7 @@ function APBDes() {
|
||||
function ListAPBDes({ search }: { search: string }) {
|
||||
const listState = useProxy(apbdes);
|
||||
const router = useRouter();
|
||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||
|
||||
const { data, page, totalPages, loading, load } = listState.findMany;
|
||||
|
||||
|
||||
@@ -211,6 +211,9 @@ function ListKategoriPrestasi({ search }: { search: string }) {
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
|
||||
|
||||
|
||||
{/* Modal Konfirmasi Hapus */}
|
||||
<ModalKonfirmasiHapus
|
||||
opened={modalHapus}
|
||||
|
||||
@@ -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);
|
||||
|
||||
25
src/app/admin/(dashboard)/user&role/_com/getMenuIdByRole.ts
Normal file
25
src/app/admin/(dashboard)/user&role/_com/getMenuIdByRole.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// src/app/admin/_com/getMenuIdsByRoleId.ts
|
||||
import { navBar, role1, role2, role3 } from '@/app/admin/_com/list_PageAdmin';
|
||||
|
||||
/**
|
||||
* Mengembalikan daftar ID menu (string[]) berdasarkan roleId
|
||||
*/
|
||||
export function getMenuIdsByRoleId(roleId: string | number): string[] {
|
||||
const id = typeof roleId === 'string' ? parseInt(roleId, 10) : roleId;
|
||||
|
||||
switch (id) {
|
||||
case 0:
|
||||
// Asumsikan devBar ada dan punya struktur sama
|
||||
return []; // atau sesuaikan jika ada devBar
|
||||
case 1:
|
||||
return navBar.map(section => section.id);
|
||||
case 2:
|
||||
return role1.map(section => section.id);
|
||||
case 3:
|
||||
return role2.map(section => section.id);
|
||||
case 4:
|
||||
return role3.map(section => section.id);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -8,24 +8,23 @@ type APBDesItemInput = {
|
||||
kode: string;
|
||||
uraian: string;
|
||||
anggaran: number;
|
||||
realisasi: number;
|
||||
selisih: number;
|
||||
persentase: number;
|
||||
level: number;
|
||||
tipe?: string | null;
|
||||
};
|
||||
|
||||
type FormCreate = {
|
||||
tahun: number;
|
||||
imageId: string;
|
||||
fileId: string;
|
||||
name?: string;
|
||||
deskripsi?: string;
|
||||
jumlah?: string;
|
||||
imageId?: string | null; // Opsional
|
||||
fileId?: string | null; // Opsional
|
||||
items: APBDesItemInput[];
|
||||
};
|
||||
|
||||
export default async function apbdesCreate(context: Context) {
|
||||
const body = context.body as FormCreate;
|
||||
|
||||
// Log the incoming request for debugging
|
||||
|
||||
console.log('Incoming request body:', JSON.stringify(body, null, 2));
|
||||
|
||||
try {
|
||||
@@ -33,43 +32,44 @@ export default async function apbdesCreate(context: Context) {
|
||||
if (!body.tahun) {
|
||||
throw new Error('Tahun is required');
|
||||
}
|
||||
if (!body.imageId) {
|
||||
throw new Error('Image ID is required');
|
||||
}
|
||||
if (!body.fileId) {
|
||||
throw new Error('File ID is required');
|
||||
}
|
||||
// Image dan file sekarang opsional
|
||||
if (!body.items || body.items.length === 0) {
|
||||
throw new Error('At least one item is required');
|
||||
}
|
||||
|
||||
// 1. Buat APBDes + items (tanpa parentId dulu)
|
||||
// 1. Buat APBDes + items dengan auto-calculate fields
|
||||
const created = await prisma.$transaction(async (prisma) => {
|
||||
const apbdes = await prisma.aPBDes.create({
|
||||
data: {
|
||||
tahun: body.tahun,
|
||||
name: `APBDes Tahun ${body.tahun}`,
|
||||
imageId: body.imageId,
|
||||
fileId: body.fileId,
|
||||
name: body.name || `APBDes Tahun ${body.tahun}`,
|
||||
deskripsi: body.deskripsi,
|
||||
jumlah: body.jumlah,
|
||||
imageId: body.imageId || null, // null jika tidak ada
|
||||
fileId: body.fileId || null, // null jika tidak ada
|
||||
},
|
||||
});
|
||||
|
||||
// Create items in a batch
|
||||
// Create items dengan auto-calculate totalRealisasi=0, selisih, persentase
|
||||
const items = await Promise.all(
|
||||
body.items.map(item => {
|
||||
// Create a new object with only the fields that exist in the APBDesItem model
|
||||
body.items.map(async item => {
|
||||
const anggaran = item.anggaran;
|
||||
const totalRealisasi = 0; // Belum ada realisasi saat create
|
||||
const selisih = anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
|
||||
const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0;
|
||||
|
||||
const itemData = {
|
||||
kode: item.kode,
|
||||
uraian: item.uraian,
|
||||
anggaran: item.anggaran,
|
||||
realisasi: item.realisasi,
|
||||
selisih: item.selisih,
|
||||
persentase: item.persentase,
|
||||
anggaran: anggaran,
|
||||
level: item.level,
|
||||
tipe: item.tipe, // ✅ sertakan, biar null
|
||||
tipe: item.tipe || null,
|
||||
totalRealisasi,
|
||||
selisih,
|
||||
persentase,
|
||||
apbdesId: apbdes.id,
|
||||
};
|
||||
|
||||
|
||||
return prisma.aPBDesItem.create({
|
||||
data: itemData,
|
||||
select: { id: true, kode: true },
|
||||
@@ -84,20 +84,27 @@ export default async function apbdesCreate(context: Context) {
|
||||
// 2. Isi parentId berdasarkan kode
|
||||
await assignParentIdsToApbdesItems(created.items);
|
||||
|
||||
// 3. Ambil ulang data lengkap untuk response
|
||||
// 3. Ambil ulang data lengkap untuk response (include realisasiItems)
|
||||
const result = await prisma.aPBDes.findUnique({
|
||||
where: { id: created.id },
|
||||
include: {
|
||||
image: true,
|
||||
file: true,
|
||||
items: {
|
||||
where: { isActive: true },
|
||||
orderBy: { kode: 'asc' },
|
||||
include: {
|
||||
realisasiItems: {
|
||||
where: { isActive: true },
|
||||
orderBy: { tanggal: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log('APBDes created successfully:', JSON.stringify(result, null, 2));
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil membuat APBDes",
|
||||
@@ -105,7 +112,6 @@ export default async function apbdesCreate(context: Context) {
|
||||
};
|
||||
} catch (innerError) {
|
||||
console.error('Error in post-creation steps:', innerError);
|
||||
// Even if post-creation steps fail, we still return success since the main record was created
|
||||
return {
|
||||
success: true,
|
||||
message: "APBDes berhasil dibuat, tetapi ada masalah dengan pemrosesan tambahan",
|
||||
@@ -115,13 +121,12 @@ export default async function apbdesCreate(context: Context) {
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error creating APBDes:", error);
|
||||
|
||||
// Log the full error for debugging
|
||||
|
||||
if (error.code) console.error('Prisma error code:', error.code);
|
||||
if (error.meta) console.error('Prisma error meta:', error.meta);
|
||||
|
||||
|
||||
const errorMessage = error.message || 'Unknown error';
|
||||
|
||||
|
||||
context.set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -21,7 +21,7 @@ export default async function apbdesFindMany(context: Context) {
|
||||
|
||||
try {
|
||||
const where: any = { isActive: true };
|
||||
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search, mode: "insensitive" } },
|
||||
@@ -47,7 +47,16 @@ export default async function apbdesFindMany(context: Context) {
|
||||
include: {
|
||||
image: true,
|
||||
file: true,
|
||||
items: true,
|
||||
items: {
|
||||
where: { isActive: true },
|
||||
orderBy: { kode: "asc" },
|
||||
include: {
|
||||
realisasiItems: {
|
||||
where: { isActive: true },
|
||||
orderBy: { tanggal: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.aPBDes.count({ where }),
|
||||
|
||||
@@ -2,15 +2,9 @@ import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
export default async function apbdesFindUnique(context: Context) {
|
||||
// ✅ Parse URL secara manual
|
||||
const url = new URL(context.request.url);
|
||||
const pathSegments = url.pathname.split('/').filter(Boolean);
|
||||
|
||||
console.log("🔍 DEBUG INFO:");
|
||||
console.log("- Full URL:", context.request.url);
|
||||
console.log("- Pathname:", url.pathname);
|
||||
console.log("- Path segments:", pathSegments);
|
||||
|
||||
|
||||
// Expected: ['api', 'landingpage', 'apbdes', 'ID']
|
||||
if (pathSegments.length < 4) {
|
||||
context.set.status = 400;
|
||||
@@ -20,9 +14,9 @@ export default async function apbdesFindUnique(context: Context) {
|
||||
debug: { pathSegments }
|
||||
};
|
||||
}
|
||||
|
||||
if (pathSegments[0] !== 'api' ||
|
||||
pathSegments[1] !== 'landingpage' ||
|
||||
|
||||
if (pathSegments[0] !== 'api' ||
|
||||
pathSegments[1] !== 'landingpage' ||
|
||||
pathSegments[2] !== 'apbdes') {
|
||||
context.set.status = 400;
|
||||
return {
|
||||
@@ -31,9 +25,9 @@ export default async function apbdesFindUnique(context: Context) {
|
||||
debug: { pathSegments }
|
||||
};
|
||||
}
|
||||
|
||||
const id = pathSegments[3]; // ✅ ID ada di index ke-3
|
||||
|
||||
|
||||
const id = pathSegments[3];
|
||||
|
||||
if (!id || id.trim() === '') {
|
||||
context.set.status = 400;
|
||||
return {
|
||||
@@ -48,11 +42,17 @@ export default async function apbdesFindUnique(context: Context) {
|
||||
include: {
|
||||
items: {
|
||||
where: { isActive: true },
|
||||
orderBy: { kode: 'asc' }
|
||||
orderBy: { kode: 'asc' },
|
||||
include: {
|
||||
realisasiItems: {
|
||||
where: { isActive: true },
|
||||
orderBy: { tanggal: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
image: true,
|
||||
file: true
|
||||
}
|
||||
file: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result || !result.isActive) {
|
||||
|
||||
@@ -5,17 +5,17 @@ import apbdesDelete from "./del";
|
||||
import apbdesFindMany from "./findMany";
|
||||
import apbdesFindUnique from "./findUnique";
|
||||
import apbdesUpdate from "./updt";
|
||||
import realisasiCreate from "./realisasi/create";
|
||||
import realisasiUpdate from "./realisasi/update";
|
||||
import realisasiDelete from "./realisasi/delete";
|
||||
|
||||
// Definisikan skema untuk item APBDes
|
||||
// Definisikan skema untuk item APBDes (tanpa realisasi field)
|
||||
const ApbdesItemSchema = t.Object({
|
||||
kode: t.String(),
|
||||
uraian: t.String(),
|
||||
anggaran: t.Number(),
|
||||
realisasi: t.Number(),
|
||||
selisih: t.Number(),
|
||||
persentase: t.Number(),
|
||||
level: t.Number(),
|
||||
tipe: t.Optional(t.Union([t.String(), t.Null()])) // misal: "pendapatan" atau "belanja"
|
||||
tipe: t.Optional(t.Union([t.String(), t.Null()])), // "pendapatan" | "belanja" | "pembiayaan" | null
|
||||
});
|
||||
|
||||
const APBDes = new Elysia({
|
||||
@@ -26,33 +26,72 @@ const APBDes = new Elysia({
|
||||
// ✅ Find all (dengan query opsional: page, limit, tahun)
|
||||
.get("/findMany", apbdesFindMany)
|
||||
|
||||
// ✅ Find by ID
|
||||
// ✅ Find by ID (include realisasiItems)
|
||||
.get("/:id", apbdesFindUnique)
|
||||
|
||||
// ✅ Create
|
||||
// ✅ Create APBDes dengan items (tanpa realisasi)
|
||||
.post("/create", apbdesCreate, {
|
||||
body: t.Object({
|
||||
tahun: t.Number(),
|
||||
imageId: t.String(),
|
||||
fileId: t.String(),
|
||||
name: t.Optional(t.String()),
|
||||
deskripsi: t.Optional(t.String()),
|
||||
jumlah: t.Optional(t.String()),
|
||||
imageId: t.Optional(t.String()),
|
||||
fileId: t.Optional(t.String()),
|
||||
items: t.Array(ApbdesItemSchema),
|
||||
}),
|
||||
})
|
||||
|
||||
// ✅ Update
|
||||
// ✅ Update APBDes dengan items (tanpa realisasi)
|
||||
.put("/:id", apbdesUpdate, {
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({
|
||||
tahun: t.Number(),
|
||||
imageId: t.String(),
|
||||
fileId: t.String(),
|
||||
name: t.Optional(t.String()),
|
||||
deskripsi: t.Optional(t.String()),
|
||||
jumlah: t.Optional(t.String()),
|
||||
imageId: t.Optional(t.String()),
|
||||
fileId: t.Optional(t.String()),
|
||||
items: t.Array(ApbdesItemSchema),
|
||||
}),
|
||||
})
|
||||
|
||||
// ✅ Delete
|
||||
// ✅ Delete APBDes
|
||||
.delete("/del/:id", apbdesDelete, {
|
||||
params: t.Object({ id: t.String() }),
|
||||
})
|
||||
|
||||
// =========================================
|
||||
// REALISASI ENDPOINTS
|
||||
// =========================================
|
||||
|
||||
// ✅ Create realisasi untuk item tertentu
|
||||
.post("/:itemId/realisasi", realisasiCreate, {
|
||||
params: t.Object({ itemId: t.String() }),
|
||||
body: t.Object({
|
||||
kode: t.String(),
|
||||
jumlah: t.Number(),
|
||||
tanggal: t.String(),
|
||||
keterangan: t.Optional(t.String()),
|
||||
buktiFileId: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
|
||||
// ✅ Update realisasi
|
||||
.put("/realisasi/:realisasiId", realisasiUpdate, {
|
||||
params: t.Object({ realisasiId: t.String() }),
|
||||
body: t.Object({
|
||||
kode: t.Optional(t.String()),
|
||||
jumlah: t.Optional(t.Number()),
|
||||
tanggal: t.Optional(t.String()),
|
||||
keterangan: t.Optional(t.String()),
|
||||
buktiFileId: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
|
||||
// ✅ Delete realisasi
|
||||
.delete("/realisasi/:realisasiId", realisasiDelete, {
|
||||
params: t.Object({ realisasiId: t.String() }),
|
||||
});
|
||||
|
||||
export default APBDes;
|
||||
@@ -0,0 +1,84 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
type RealisasiCreateBody = {
|
||||
kode: string;
|
||||
jumlah: number;
|
||||
tanggal: string; // ISO format
|
||||
keterangan?: string;
|
||||
buktiFileId?: string;
|
||||
};
|
||||
|
||||
export default async function realisasiCreate(context: Context) {
|
||||
const { itemId } = context.params as { itemId: string };
|
||||
const body = context.body as RealisasiCreateBody;
|
||||
|
||||
console.log('Creating realisasi:', JSON.stringify(body, null, 2));
|
||||
|
||||
try {
|
||||
// 1. Pastikan APBDesItem ada
|
||||
const item = await prisma.aPBDesItem.findUnique({
|
||||
where: { id: itemId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
context.set.status = 404;
|
||||
return {
|
||||
success: false,
|
||||
message: "Item APBDes tidak ditemukan",
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Create realisasi item
|
||||
const realisasi = await prisma.realisasiItem.create({
|
||||
data: {
|
||||
apbdesItemId: itemId,
|
||||
kode: body.kode,
|
||||
jumlah: body.jumlah,
|
||||
tanggal: new Date(body.tanggal),
|
||||
keterangan: body.keterangan,
|
||||
buktiFileId: body.buktiFileId,
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Update totalRealisasi, selisih, persentase di APBDesItem
|
||||
const allRealisasi = await prisma.realisasiItem.findMany({
|
||||
where: { apbdesItemId: itemId, isActive: true },
|
||||
select: { jumlah: true },
|
||||
});
|
||||
|
||||
const totalRealisasi = allRealisasi.reduce((sum, r) => sum + r.jumlah, 0);
|
||||
const selisih = item.anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
|
||||
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
|
||||
|
||||
await prisma.aPBDesItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
totalRealisasi,
|
||||
selisih,
|
||||
persentase,
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Return response
|
||||
return {
|
||||
success: true,
|
||||
message: "Realisasi berhasil ditambahkan",
|
||||
data: realisasi,
|
||||
meta: {
|
||||
totalRealisasi,
|
||||
selisih,
|
||||
persentase,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("Error creating realisasi:", error);
|
||||
context.set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
message: `Gagal menambahkan realisasi: ${error.message}`,
|
||||
error: process.env.NODE_ENV === 'development' ? error : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
export default async function realisasiDelete(context: Context) {
|
||||
const { realisasiId } = context.params as { realisasiId: string };
|
||||
|
||||
console.log('Deleting realisasi:', realisasiId);
|
||||
|
||||
try {
|
||||
// 1. Pastikan realisasi ada
|
||||
const existing = await prisma.realisasiItem.findUnique({
|
||||
where: { id: realisasiId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
context.set.status = 404;
|
||||
return {
|
||||
success: false,
|
||||
message: "Realisasi tidak ditemukan",
|
||||
};
|
||||
}
|
||||
|
||||
const apbdesItemId = existing.apbdesItemId;
|
||||
|
||||
// 2. Soft delete realisasi (set isActive = false)
|
||||
await prisma.realisasiItem.update({
|
||||
where: { id: realisasiId },
|
||||
data: {
|
||||
isActive: false,
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Recalculate totalRealisasi, selisih, persentase di APBDesItem
|
||||
const allRealisasi = await prisma.realisasiItem.findMany({
|
||||
where: { apbdesItemId, isActive: true },
|
||||
select: { jumlah: true },
|
||||
});
|
||||
|
||||
const item = await prisma.aPBDesItem.findUnique({
|
||||
where: { id: apbdesItemId },
|
||||
});
|
||||
|
||||
if (item) {
|
||||
const totalRealisasi = allRealisasi.reduce((sum, r) => sum + r.jumlah, 0);
|
||||
const selisih = item.anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
|
||||
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
|
||||
|
||||
await prisma.aPBDesItem.update({
|
||||
where: { id: apbdesItemId },
|
||||
data: {
|
||||
totalRealisasi,
|
||||
selisih,
|
||||
persentase,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Realisasi berhasil dihapus",
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting realisasi:", error);
|
||||
context.set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
message: `Gagal menghapus realisasi: ${error.message}`,
|
||||
error: process.env.NODE_ENV === 'development' ? error : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
type RealisasiUpdateBody = {
|
||||
kode?: string;
|
||||
jumlah?: number;
|
||||
tanggal?: string;
|
||||
keterangan?: string;
|
||||
buktiFileId?: string;
|
||||
};
|
||||
|
||||
export default async function realisasiUpdate(context: Context) {
|
||||
const { realisasiId } = context.params as { realisasiId: string };
|
||||
const body = context.body as RealisasiUpdateBody;
|
||||
|
||||
console.log('Updating realisasi:', JSON.stringify(body, null, 2));
|
||||
|
||||
try {
|
||||
// 1. Pastikan realisasi ada
|
||||
const existing = await prisma.realisasiItem.findUnique({
|
||||
where: { id: realisasiId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
context.set.status = 404;
|
||||
return {
|
||||
success: false,
|
||||
message: "Realisasi tidak ditemukan",
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Update realisasi
|
||||
const updated = await prisma.realisasiItem.update({
|
||||
where: { id: realisasiId },
|
||||
data: {
|
||||
...(body.kode !== undefined && { kode: body.kode }),
|
||||
...(body.jumlah !== undefined && { jumlah: body.jumlah }),
|
||||
...(body.tanggal !== undefined && { tanggal: new Date(body.tanggal) }),
|
||||
...(body.keterangan !== undefined && { keterangan: body.keterangan }),
|
||||
...(body.buktiFileId !== undefined && { buktiFileId: body.buktiFileId }),
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Recalculate totalRealisasi, selisih, persentase di APBDesItem
|
||||
const allRealisasi = await prisma.realisasiItem.findMany({
|
||||
where: { apbdesItemId: existing.apbdesItemId, isActive: true },
|
||||
select: { jumlah: true },
|
||||
});
|
||||
|
||||
const item = await prisma.aPBDesItem.findUnique({
|
||||
where: { id: existing.apbdesItemId },
|
||||
});
|
||||
|
||||
if (item) {
|
||||
const totalRealisasi = allRealisasi.reduce((sum, r) => sum + r.jumlah, 0);
|
||||
const selisih = item.anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
|
||||
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
|
||||
|
||||
await prisma.aPBDesItem.update({
|
||||
where: { id: existing.apbdesItemId },
|
||||
data: {
|
||||
totalRealisasi,
|
||||
selisih,
|
||||
persentase,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Realisasi berhasil diperbarui",
|
||||
data: updated,
|
||||
meta: {
|
||||
totalRealisasi: allRealisasi.reduce((sum, r) => sum + r.jumlah, 0),
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("Error updating realisasi:", error);
|
||||
context.set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
message: `Gagal memperbarui realisasi: ${error.message}`,
|
||||
error: process.env.NODE_ENV === 'development' ? error : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,23 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
import { assignParentIdsToApbdesItems } from "./lib/getParentsID";
|
||||
import { RealisasiItem } from "@prisma/client";
|
||||
|
||||
type APBDesItemInput = {
|
||||
kode: string;
|
||||
uraian: string;
|
||||
anggaran: number;
|
||||
realisasi: number;
|
||||
selisih: number;
|
||||
persentase: number;
|
||||
level: number;
|
||||
tipe?: string | null;
|
||||
};
|
||||
|
||||
type FormUpdateBody = {
|
||||
tahun: number;
|
||||
imageId: string;
|
||||
fileId: string;
|
||||
name?: string;
|
||||
deskripsi?: string;
|
||||
jumlah?: string;
|
||||
imageId?: string | null;
|
||||
fileId?: string | null;
|
||||
items: APBDesItemInput[];
|
||||
};
|
||||
|
||||
@@ -28,6 +29,16 @@ export default async function apbdesUpdate(context: Context) {
|
||||
// 1. Pastikan APBDes ada
|
||||
const existing = await prisma.aPBDes.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
items: {
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
realisasiItems: {
|
||||
where: { isActive: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
@@ -38,59 +49,144 @@ export default async function apbdesUpdate(context: Context) {
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Hapus semua item lama
|
||||
// 2. Build map untuk preserve realisasiItems berdasarkan kode
|
||||
const existingItemsMap = new Map<string, {
|
||||
id: string;
|
||||
realisasiItems: RealisasiItem[];
|
||||
}>();
|
||||
|
||||
existing.items.forEach(item => {
|
||||
existingItemsMap.set(item.kode, {
|
||||
id: item.id,
|
||||
realisasiItems: item.realisasiItems,
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Hapus semua item lama (cascade akan menghapus realisasiItems juga)
|
||||
// TAPI kita sudah save realisasiItems di map atas
|
||||
await prisma.aPBDesItem.deleteMany({
|
||||
where: { apbdesId: id },
|
||||
});
|
||||
|
||||
// 3. Buat item baru tanpa parentId terlebih dahulu
|
||||
// 4. Buat item baru dengan preserve realisasiItems
|
||||
await prisma.aPBDesItem.createMany({
|
||||
data: body.items.map((item) => ({
|
||||
apbdesId: id,
|
||||
kode: item.kode,
|
||||
uraian: item.uraian,
|
||||
anggaran: item.anggaran,
|
||||
realisasi: item.realisasi,
|
||||
selisih: item.anggaran - item.realisasi,
|
||||
persentase: item.anggaran > 0 ? (item.realisasi / item.anggaran) * 100 : 0,
|
||||
level: item.level,
|
||||
tipe: item.tipe || null,
|
||||
isActive: true,
|
||||
data: await Promise.all(body.items.map(async (item) => {
|
||||
const anggaran = item.anggaran;
|
||||
|
||||
// Check apakah item ini punya realisasiItems lama
|
||||
const existingItem = existingItemsMap.get(item.kode);
|
||||
const realisasiItemsData = existingItem?.realisasiItems || [];
|
||||
const totalRealisasi = realisasiItemsData.reduce((sum, r) => sum + r.jumlah, 0);
|
||||
const selisih = anggaran - totalRealisasi;
|
||||
const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0;
|
||||
|
||||
return {
|
||||
apbdesId: id,
|
||||
kode: item.kode,
|
||||
uraian: item.uraian,
|
||||
anggaran: anggaran,
|
||||
level: item.level,
|
||||
tipe: item.tipe || null,
|
||||
totalRealisasi,
|
||||
selisih,
|
||||
persentase,
|
||||
isActive: true,
|
||||
};
|
||||
})),
|
||||
});
|
||||
|
||||
// 4. Dapatkan semua item yang baru dibuat untuk mendapatkan ID-nya
|
||||
// 5. Dapatkan semua item yang baru dibuat untuk mendapatkan ID-nya
|
||||
const allItems = await prisma.aPBDesItem.findMany({
|
||||
where: { apbdesId: id },
|
||||
select: { id: true, kode: true },
|
||||
});
|
||||
|
||||
// 5. Update parentId untuk setiap item
|
||||
// Pastikan allItems memiliki tipe yang benar
|
||||
// 6. Build map baru untuk item IDs
|
||||
const newItemIdsMap = new Map<string, string>();
|
||||
allItems.forEach(item => {
|
||||
newItemIdsMap.set(item.kode, item.id);
|
||||
});
|
||||
|
||||
// 7. Re-create realisasiItems dengan link ke item IDs yang baru
|
||||
for (const [oldKode, oldItemData] of existingItemsMap.entries()) {
|
||||
if (oldItemData.realisasiItems.length > 0) {
|
||||
const newItemId = newItemIdsMap.get(oldKode);
|
||||
if (newItemId) {
|
||||
// Re-create realisasiItems untuk item ini
|
||||
await prisma.realisasiItem.createMany({
|
||||
data: oldItemData.realisasiItems.map(r => ({
|
||||
apbdesItemId: newItemId,
|
||||
kode: r.kode,
|
||||
jumlah: r.jumlah,
|
||||
tanggal: r.tanggal,
|
||||
keterangan: r.keterangan,
|
||||
buktiFileId: r.buktiFileId,
|
||||
isActive: true,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Recalculate totalRealisasi setelah re-create realisasiItems
|
||||
for (const kode of existingItemsMap.keys()) {
|
||||
const newItemId = newItemIdsMap.get(kode);
|
||||
if (newItemId) {
|
||||
const realisasiItems = await prisma.realisasiItem.findMany({
|
||||
where: { apbdesItemId: newItemId, isActive: true },
|
||||
});
|
||||
const totalRealisasi = realisasiItems.reduce((sum, r) => sum + r.jumlah, 0);
|
||||
|
||||
const item = await prisma.aPBDesItem.findUnique({
|
||||
where: { id: newItemId },
|
||||
});
|
||||
|
||||
if (item) {
|
||||
const selisih = item.anggaran - totalRealisasi;
|
||||
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
|
||||
|
||||
await prisma.aPBDesItem.update({
|
||||
where: { id: newItemId },
|
||||
data: { totalRealisasi, selisih, persentase },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Update parentId untuk setiap item
|
||||
const itemsForParentUpdate = allItems.map(item => ({
|
||||
id: item.id,
|
||||
kode: item.kode,
|
||||
}));
|
||||
|
||||
|
||||
await assignParentIdsToApbdesItems(itemsForParentUpdate);
|
||||
|
||||
// 6. Update data APBDes
|
||||
// 10. Update data APBDes
|
||||
await prisma.aPBDes.update({
|
||||
where: { id },
|
||||
data: {
|
||||
tahun: body.tahun,
|
||||
imageId: body.imageId,
|
||||
fileId: body.fileId,
|
||||
name: body.name || `APBDes Tahun ${body.tahun}`,
|
||||
deskripsi: body.deskripsi,
|
||||
jumlah: body.jumlah,
|
||||
imageId: body.imageId === '' ? null : body.imageId,
|
||||
fileId: body.fileId === '' ? null : body.fileId,
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Ambil data lengkap untuk response
|
||||
// 11. Ambil data lengkap untuk response (include realisasiItems)
|
||||
const result = await prisma.aPBDes.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
items: {
|
||||
where: { isActive: true },
|
||||
orderBy: { kode: 'asc' }
|
||||
orderBy: { kode: 'asc' },
|
||||
include: {
|
||||
realisasiItems: {
|
||||
where: { isActive: true },
|
||||
orderBy: { tanggal: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
image: true,
|
||||
file: true,
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { getMenuIdsByRoleId } from "@/app/admin/(dashboard)/user&role/_com/getMenuIdByRole";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
@@ -34,11 +35,25 @@ export default async function userUpdate(context: Context) {
|
||||
const isActiveChanged =
|
||||
isActive !== undefined && currentUser.isActive !== isActive;
|
||||
|
||||
// ✅ Jika role berubah, hapus semua akses menu yang ada
|
||||
if (isRoleChanged) {
|
||||
// ✅ Jika role berubah, reset dan set ulang akses menu
|
||||
if (isRoleChanged && roleId) {
|
||||
// Hapus akses lama
|
||||
await prisma.userMenuAccess.deleteMany({
|
||||
where: { userId: id }
|
||||
});
|
||||
|
||||
// Ambil menu default untuk role baru
|
||||
const menuIds = getMenuIdsByRoleId(roleId);
|
||||
|
||||
if (menuIds.length > 0) {
|
||||
// Buat akses baru
|
||||
await prisma.userMenuAccess.createMany({
|
||||
data: menuIds.map(menuId => ({
|
||||
userId: id,
|
||||
menuId
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update user
|
||||
|
||||
@@ -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(
|
||||
@@ -80,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,
|
||||
@@ -89,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)
|
||||
@@ -103,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;
|
||||
|
||||
@@ -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) {
|
||||
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: unknown) {
|
||||
const errorMessage = waError instanceof Error ? waError.message : String(waError);
|
||||
console.error("⚠️ WA Connection Exception. Continuing since OTP is logged.", errorMessage);
|
||||
}
|
||||
|
||||
const createOtpId = await prisma.kodeOtp.create({
|
||||
|
||||
@@ -22,14 +22,22 @@ 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: unknown) {
|
||||
const errorMessage = waError instanceof Error ? waError.message : String(waError);
|
||||
console.warn("⚠️ WA Connection Exception (Register). Continuing since OTP is logged.", errorMessage);
|
||||
}
|
||||
|
||||
// ✅ Simpan OTP ke database
|
||||
|
||||
@@ -17,18 +17,23 @@ 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: unknown) {
|
||||
const errorMessage = waError instanceof Error ? waError.message : String(waError);
|
||||
console.warn("⚠️ WA Connection Exception (Resend). Continuing since OTP is logged.", errorMessage);
|
||||
}
|
||||
|
||||
// Simpan OTP ke database
|
||||
|
||||
@@ -21,14 +21,22 @@ 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: unknown) {
|
||||
const errorMessage = waError instanceof Error ? waError.message : String(waError);
|
||||
console.warn("⚠️ WA Connection Exception (SendOTPRegister). Continuing since OTP is logged.", errorMessage);
|
||||
}
|
||||
|
||||
// Simpan OTP
|
||||
|
||||
320
src/app/context/MusicContext.tsx
Normal file
320
src/app/context/MusicContext.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
interface MusicFile {
|
||||
id: string;
|
||||
name: string;
|
||||
realName: string;
|
||||
path: string;
|
||||
mimeType: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface Musik {
|
||||
id: string;
|
||||
judul: string;
|
||||
artis: string;
|
||||
deskripsi: string | null;
|
||||
durasi: string;
|
||||
genre: string | null;
|
||||
tahunRilis: number | null;
|
||||
audioFile: MusicFile | null;
|
||||
coverImage: MusicFile | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface MusicContextType {
|
||||
// State
|
||||
isPlaying: boolean;
|
||||
currentSong: Musik | null;
|
||||
currentSongIndex: number;
|
||||
musikData: Musik[];
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
isRepeat: boolean;
|
||||
isShuffle: boolean;
|
||||
isLoading: boolean;
|
||||
isPlayerOpen: boolean;
|
||||
|
||||
// Actions
|
||||
playSong: (song: Musik) => void;
|
||||
togglePlayPause: () => void;
|
||||
playNext: () => void;
|
||||
playPrev: () => void;
|
||||
seek: (time: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
toggleMute: () => void;
|
||||
toggleRepeat: () => void;
|
||||
toggleShuffle: () => void;
|
||||
togglePlayer: () => void;
|
||||
loadMusikData: () => Promise<void>;
|
||||
}
|
||||
|
||||
const MusicContext = createContext<MusicContextType | undefined>(undefined);
|
||||
|
||||
export function MusicProvider({ children }: { children: ReactNode }) {
|
||||
// State
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentSong, setCurrentSong] = useState<Musik | null>(null);
|
||||
const [currentSongIndex, setCurrentSongIndex] = useState(-1);
|
||||
const [musikData, setMusikData] = useState<Musik[]>([]);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolumeState] = useState(70);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isRepeat, setIsRepeat] = useState(false);
|
||||
const [isShuffle, setIsShuffle] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
|
||||
|
||||
// Refs
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const isSeekingRef = useRef(false);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const isRepeatRef = useRef(false); // Ref untuk avoid stale closure
|
||||
|
||||
// Sync ref dengan state
|
||||
useEffect(() => {
|
||||
isRepeatRef.current = isRepeat;
|
||||
}, [isRepeat]);
|
||||
|
||||
// Load musik data
|
||||
const loadMusikData = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await fetch('/api/desa/musik/find-many?page=1&limit=50');
|
||||
const data = await res.json();
|
||||
if (data.success && data.data) {
|
||||
const activeMusik = data.data.filter((m: Musik) => m.isActive);
|
||||
setMusikData(activeMusik);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching musik:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize audio element
|
||||
useEffect(() => {
|
||||
audioRef.current = new Audio();
|
||||
audioRef.current.preload = 'metadata';
|
||||
|
||||
// Event listeners
|
||||
audioRef.current.addEventListener('loadedmetadata', () => {
|
||||
setDuration(Math.floor(audioRef.current!.duration));
|
||||
});
|
||||
|
||||
audioRef.current.addEventListener('ended', () => {
|
||||
// Gunakan ref untuk avoid stale closure
|
||||
if (isRepeatRef.current) {
|
||||
audioRef.current!.currentTime = 0;
|
||||
audioRef.current!.play();
|
||||
} else {
|
||||
playNext();
|
||||
}
|
||||
});
|
||||
|
||||
// Load initial data
|
||||
loadMusikData();
|
||||
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- playNext is intentionally not in deps to avoid circular dependency
|
||||
}, [loadMusikData]); // Remove isRepeat dari deps karena sudah pakai ref
|
||||
|
||||
// Update time with requestAnimationFrame for smooth progress
|
||||
const updateTime = useCallback(() => {
|
||||
if (audioRef.current && !audioRef.current.paused && !isSeekingRef.current) {
|
||||
setCurrentTime(Math.floor(audioRef.current.currentTime));
|
||||
animationFrameRef.current = requestAnimationFrame(updateTime);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
animationFrameRef.current = requestAnimationFrame(updateTime);
|
||||
} else {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPlaying, updateTime]);
|
||||
|
||||
// Play song
|
||||
const playSong = useCallback(
|
||||
(song: Musik) => {
|
||||
if (!song?.audioFile?.link || !audioRef.current) return;
|
||||
|
||||
const songIndex = musikData.findIndex(m => m.id === song.id);
|
||||
setCurrentSongIndex(songIndex);
|
||||
setCurrentSong(song);
|
||||
setIsPlaying(true);
|
||||
|
||||
audioRef.current.src = song.audioFile.link;
|
||||
audioRef.current.load();
|
||||
audioRef.current
|
||||
.play()
|
||||
.catch((err) => console.error('Error playing audio:', err));
|
||||
},
|
||||
[musikData]
|
||||
);
|
||||
|
||||
// Toggle play/pause
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (!audioRef.current || !currentSong) return;
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
audioRef.current
|
||||
.play()
|
||||
.then(() => setIsPlaying(true))
|
||||
.catch((err) => console.error('Error playing audio:', err));
|
||||
}
|
||||
}, [isPlaying, currentSong]);
|
||||
|
||||
// Play next
|
||||
const playNext = useCallback(() => {
|
||||
if (musikData.length === 0) return;
|
||||
|
||||
let nextIndex: number;
|
||||
if (isShuffle) {
|
||||
nextIndex = Math.floor(Math.random() * musikData.length);
|
||||
} else {
|
||||
nextIndex = (currentSongIndex + 1) % musikData.length;
|
||||
}
|
||||
const nextSong = musikData[nextIndex];
|
||||
if (nextSong) {
|
||||
playSong(nextSong);
|
||||
}
|
||||
}, [musikData, isShuffle, currentSongIndex, playSong]);
|
||||
|
||||
// Play previous
|
||||
const playPrev = useCallback(() => {
|
||||
if (musikData.length === 0) return;
|
||||
|
||||
// If more than 3 seconds into song, restart it
|
||||
if (currentTime > 3) {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const prevIndex =
|
||||
currentSongIndex <= 0 ? musikData.length - 1 : currentSongIndex - 1;
|
||||
const prevSong = musikData[prevIndex];
|
||||
if (prevSong) {
|
||||
playSong(prevSong);
|
||||
}
|
||||
}, [musikData, currentSongIndex, currentTime, playSong]);
|
||||
|
||||
// Seek
|
||||
const seek = useCallback((time: number) => {
|
||||
if (!audioRef.current) return;
|
||||
audioRef.current.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
}, []);
|
||||
|
||||
// Set volume
|
||||
const setVolume = useCallback((vol: number) => {
|
||||
if (!audioRef.current) return;
|
||||
const normalizedVol = Math.max(0, Math.min(100, vol)) / 100;
|
||||
audioRef.current.volume = normalizedVol;
|
||||
setVolumeState(Math.max(0, Math.min(100, vol)));
|
||||
setIsMuted(normalizedVol === 0);
|
||||
}, []);
|
||||
|
||||
// Toggle mute
|
||||
const toggleMute = useCallback(() => {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
const newMuted = !isMuted;
|
||||
audioRef.current.muted = newMuted;
|
||||
setIsMuted(newMuted);
|
||||
|
||||
if (newMuted && volume > 0) {
|
||||
audioRef.current.volume = 0;
|
||||
} else if (!newMuted && volume > 0) {
|
||||
audioRef.current.volume = volume / 100;
|
||||
}
|
||||
}, [isMuted, volume]);
|
||||
|
||||
// Toggle repeat
|
||||
const toggleRepeat = useCallback(() => {
|
||||
setIsRepeat((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Toggle shuffle
|
||||
const toggleShuffle = useCallback(() => {
|
||||
setIsShuffle((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Toggle player
|
||||
const togglePlayer = useCallback(() => {
|
||||
setIsPlayerOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const value: MusicContextType = {
|
||||
isPlaying,
|
||||
currentSong,
|
||||
currentSongIndex,
|
||||
musikData,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
isMuted,
|
||||
isRepeat,
|
||||
isShuffle,
|
||||
isLoading,
|
||||
isPlayerOpen,
|
||||
playSong,
|
||||
togglePlayPause,
|
||||
playNext,
|
||||
playPrev,
|
||||
seek,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
toggleRepeat,
|
||||
toggleShuffle,
|
||||
togglePlayer,
|
||||
loadMusikData,
|
||||
};
|
||||
|
||||
return (
|
||||
<MusicContext.Provider value={value}>{children}</MusicContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMusic() {
|
||||
const context = useContext(MusicContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useMusic must be used within a MusicProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,77 +1,41 @@
|
||||
'use client'
|
||||
import { useMusic } from '@/app/context/MusicContext';
|
||||
import { ActionIcon, Avatar, Badge, Box, Card, Flex, Grid, Group, Paper, ScrollArea, Slider, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { IconArrowsShuffle, IconPlayerPauseFilled, IconPlayerPlayFilled, IconPlayerSkipBackFilled, IconPlayerSkipForwardFilled, IconRepeat, IconRepeatOff, IconSearch, IconVolume, IconVolumeOff, IconX } from '@tabler/icons-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import BackButton from '../../desa/layanan/_com/BackButto';
|
||||
import { togglePlayPause } from '../lib/playPause';
|
||||
import { getNextIndex, getPrevIndex } from '../lib/nextPrev';
|
||||
import { handleRepeatOrNext } from '../lib/repeat';
|
||||
import { toggleShuffle } from '../lib/shuffle';
|
||||
import { setAudioVolume, toggleMute as toggleMuteUtil } from '../lib/volume';
|
||||
import { useAudioProgress } from '../lib/useAudioProgress';
|
||||
|
||||
interface MusicFile {
|
||||
id: string;
|
||||
name: string;
|
||||
realName: string;
|
||||
path: string;
|
||||
mimeType: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
interface Musik {
|
||||
id: string;
|
||||
judul: string;
|
||||
artis: string;
|
||||
deskripsi: string | null;
|
||||
durasi: string;
|
||||
genre: string | null;
|
||||
tahunRilis: number | null;
|
||||
audioFile: MusicFile | null;
|
||||
coverImage: MusicFile | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const MusicPlayer = () => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(70);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isRepeat, setIsRepeat] = useState(false);
|
||||
const [isShuffle, setIsShuffle] = useState(false);
|
||||
const {
|
||||
isPlaying,
|
||||
currentSong,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
isMuted,
|
||||
isRepeat,
|
||||
isShuffle,
|
||||
isLoading,
|
||||
musikData,
|
||||
playSong,
|
||||
togglePlayPause,
|
||||
playNext,
|
||||
playPrev,
|
||||
seek,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
toggleRepeat,
|
||||
toggleShuffle,
|
||||
} = useMusic();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [musikData, setMusikData] = useState<Musik[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentSongIndex, setCurrentSongIndex] = useState(-1);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const isSeekingRef = useRef(false);
|
||||
const lastPlayedSongIdRef = useRef<string | null>(null);
|
||||
const lastSeekTimeRef = useRef<number>(0); // Track last seek time
|
||||
|
||||
// Smooth progress update dengan requestAnimationFrame
|
||||
useAudioProgress(audioRef as React.RefObject<HTMLAudioElement>, isPlaying, setCurrentTime, isSeekingRef);
|
||||
// Fetch musik data from global state
|
||||
const { loadMusikData } = useMusic();
|
||||
|
||||
// Fetch musik data from API
|
||||
useEffect(() => {
|
||||
const fetchMusik = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/desa/musik/find-many?page=1&limit=50');
|
||||
const data = await res.json();
|
||||
if (data.success && data.data) {
|
||||
const activeMusik = data.data.filter((m: Musik) => m.isActive);
|
||||
setMusikData(activeMusik);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching musik:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMusik();
|
||||
}, []);
|
||||
loadMusikData();
|
||||
}, [loadMusikData]);
|
||||
|
||||
// Filter musik based on search - gunakan useMemo untuk mencegah re-calculate setiap render
|
||||
const filteredMusik = useMemo(() => {
|
||||
@@ -82,146 +46,42 @@ const MusicPlayer = () => {
|
||||
);
|
||||
}, [musikData, search]);
|
||||
|
||||
const currentSong = currentSongIndex >= 0 && currentSongIndex < filteredMusik.length
|
||||
? filteredMusik[currentSongIndex]
|
||||
: null;
|
||||
|
||||
// // Update progress bar
|
||||
// useEffect(() => {
|
||||
// if (isPlaying && audioRef.current) {
|
||||
// progressInterval.current = window.setInterval(() => {
|
||||
// if (audioRef.current) {
|
||||
// setCurrentTime(Math.floor(audioRef.current.currentTime));
|
||||
// }
|
||||
// }, 1000);
|
||||
// } else {
|
||||
// if (progressInterval.current) {
|
||||
// clearInterval(progressInterval.current);
|
||||
// }
|
||||
// }
|
||||
|
||||
// return () => {
|
||||
// if (progressInterval.current) {
|
||||
// clearInterval(progressInterval.current);
|
||||
// }
|
||||
// };
|
||||
// }, [isPlaying]);
|
||||
|
||||
// Update duration when song changes (HANYA saat ganti lagu, bukan saat isPlaying berubah)
|
||||
useEffect(() => {
|
||||
if (currentSong && audioRef.current) {
|
||||
// Cek apakah ini benar-benar lagu baru
|
||||
const isNewSong = lastPlayedSongIdRef.current !== currentSong.id;
|
||||
|
||||
if (isNewSong) {
|
||||
// Gunakan durasi dari database sebagai acuan utama
|
||||
const durationParts = currentSong.durasi.split(':');
|
||||
const durationInSeconds = parseInt(durationParts[0]) * 60 + parseInt(durationParts[1]);
|
||||
setDuration(durationInSeconds);
|
||||
|
||||
// Reset audio currentTime ke 0 untuk lagu baru
|
||||
audioRef.current.currentTime = 0;
|
||||
setCurrentTime(0);
|
||||
|
||||
// Update ref
|
||||
lastPlayedSongIdRef.current = currentSong.id;
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current.play().catch(err => {
|
||||
console.error('Error playing audio:', err);
|
||||
setIsPlaying(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Jika bukan lagu baru, jangan reset currentTime (biar seek tidak kembali ke 0)
|
||||
}
|
||||
}, [currentSong?.id]); // Intentional: hanya depend on song ID, bukan isPlaying
|
||||
|
||||
// Sync duration dari audio element jika berbeda signifikan (> 1 detik)
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !currentSong) return;
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
const audioDuration = Math.floor(audio.duration);
|
||||
const durationParts = currentSong.durasi.split(':');
|
||||
const dbDuration = parseInt(durationParts[0]) * 60 + parseInt(durationParts[1]);
|
||||
|
||||
// Jika perbedaan > 2 detik, gunakan audio duration (lebih akurat)
|
||||
if (Math.abs(audioDuration - dbDuration) > 2) {
|
||||
setDuration(audioDuration);
|
||||
}
|
||||
};
|
||||
|
||||
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
return () => audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
}, [currentSong?.id]); // Intentional: hanya depend on song ID
|
||||
|
||||
// Format time helper
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const playSong = (index: number) => {
|
||||
if (index < 0 || index >= filteredMusik.length) return;
|
||||
|
||||
setCurrentSongIndex(index);
|
||||
setIsPlaying(true);
|
||||
const handleVolumeChange = (value: number) => {
|
||||
setVolume(value);
|
||||
};
|
||||
|
||||
const handleSongEnd = () => {
|
||||
const playNext = () => {
|
||||
let nextIndex: number;
|
||||
if (isShuffle) {
|
||||
nextIndex = Math.floor(Math.random() * filteredMusik.length);
|
||||
} else {
|
||||
nextIndex = (currentSongIndex + 1) % filteredMusik.length;
|
||||
}
|
||||
|
||||
if (filteredMusik.length > 1) {
|
||||
playSong(nextIndex);
|
||||
} else {
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
}
|
||||
};
|
||||
|
||||
handleRepeatOrNext(audioRef, isRepeat, playNext);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
toggleMuteUtil(audioRef, isMuted, setIsMuted);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (val: number) => {
|
||||
setAudioVolume(audioRef, val, setVolume, setIsMuted);
|
||||
};
|
||||
|
||||
const skipBack = () => {
|
||||
const prevIndex = getPrevIndex(currentSongIndex, filteredMusik.length, isShuffle);
|
||||
if (prevIndex >= 0) {
|
||||
playSong(prevIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const skipForward = () => {
|
||||
const nextIndex = getNextIndex(currentSongIndex, filteredMusik.length, isShuffle);
|
||||
if (nextIndex >= 0) {
|
||||
playSong(nextIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleShuffleHandler = () => {
|
||||
toggleShuffle(isShuffle, setIsShuffle);
|
||||
const toggleMuteHandler = () => {
|
||||
toggleMute();
|
||||
};
|
||||
|
||||
const togglePlayPauseHandler = () => {
|
||||
if (!currentSong) return;
|
||||
togglePlayPause(audioRef, isPlaying, setIsPlaying);
|
||||
togglePlayPause();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
const skipBack = () => {
|
||||
playPrev();
|
||||
};
|
||||
|
||||
const skipForward = () => {
|
||||
playNext();
|
||||
};
|
||||
|
||||
const toggleShuffleHandler = () => {
|
||||
toggleShuffle();
|
||||
};
|
||||
|
||||
const toggleRepeatHandler = () => {
|
||||
toggleRepeat();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box px={{ base: 'md', md: 100 }} py="xl">
|
||||
<Paper mx="auto" p="xl" radius="lg" shadow="sm" bg="white">
|
||||
@@ -233,16 +93,6 @@ const MusicPlayer = () => {
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'md', md: 100 }} py="xl">
|
||||
{/* Hidden audio element - gunakan key yang stabil untuk mencegah remount */}
|
||||
{currentSong?.audioFile && (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={currentSong?.audioFile?.link}
|
||||
muted={isMuted}
|
||||
onEnded={handleSongEnd}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Paper
|
||||
mx="auto"
|
||||
p="xl"
|
||||
@@ -279,7 +129,7 @@ const MusicPlayer = () => {
|
||||
<Card radius="md" p="xl" shadow="md">
|
||||
<Group align="center" gap="xl">
|
||||
<Avatar
|
||||
src={currentSong.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
|
||||
src={currentSong.coverImage?.link || '/mp3-logo.png'}
|
||||
size={180}
|
||||
radius="md"
|
||||
/>
|
||||
@@ -295,38 +145,14 @@ const MusicPlayer = () => {
|
||||
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text>
|
||||
<Slider
|
||||
value={currentTime}
|
||||
max={duration}
|
||||
onChange={(v) => {
|
||||
isSeekingRef.current = true;
|
||||
setCurrentTime(v);
|
||||
}}
|
||||
onChangeEnd={(v) => {
|
||||
// Validasi: jangan seek melebihi durasi
|
||||
const seekTime = Math.min(Math.max(0, v), duration);
|
||||
|
||||
if (audioRef.current) {
|
||||
// Set audio currentTime
|
||||
audioRef.current.currentTime = seekTime;
|
||||
setCurrentTime(seekTime);
|
||||
lastSeekTimeRef.current = seekTime;
|
||||
|
||||
// Jika audio tidak sedang playing, mainkan
|
||||
if (!audioRef.current.paused && !isPlaying) {
|
||||
audioRef.current.play().catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Set seeking false SETELAH semua operasi selesai
|
||||
setTimeout(() => {
|
||||
isSeekingRef.current = false;
|
||||
}, 0);
|
||||
}}
|
||||
max={duration || 100}
|
||||
onChange={(v) => seek(v)}
|
||||
color="#0B4F78"
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
styles={{ thumb: { borderWidth: 2 } }}
|
||||
/>
|
||||
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration)}</Text>
|
||||
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration || 0)}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
@@ -345,7 +171,7 @@ const MusicPlayer = () => {
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={400}>
|
||||
<Grid gutter="md">
|
||||
{filteredMusik.map((song, index) => (
|
||||
{filteredMusik.map((song) => (
|
||||
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}>
|
||||
<Card
|
||||
radius="md"
|
||||
@@ -356,7 +182,7 @@ const MusicPlayer = () => {
|
||||
border: currentSong?.id === song.id ? '2px solid #0B4F78' : '2px solid transparent',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onClick={() => playSong(index)}
|
||||
onClick={() => playSong(song)}
|
||||
>
|
||||
<Group gap="md" align="center">
|
||||
<Avatar
|
||||
@@ -444,7 +270,7 @@ const MusicPlayer = () => {
|
||||
<ActionIcon
|
||||
variant={isRepeat ? 'filled' : 'subtle'}
|
||||
color="#0B4F78"
|
||||
onClick={() => setIsRepeat(!isRepeat)}
|
||||
onClick={toggleRepeatHandler}
|
||||
radius="xl"
|
||||
>
|
||||
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
||||
@@ -454,42 +280,18 @@ const MusicPlayer = () => {
|
||||
<Text size="xs" c="#5A6C7D" w={40} ta="right">{formatTime(currentTime)}</Text>
|
||||
<Slider
|
||||
value={currentTime}
|
||||
max={duration}
|
||||
onChange={(v) => {
|
||||
isSeekingRef.current = true;
|
||||
setCurrentTime(v); // preview - update UI saja
|
||||
}}
|
||||
onChangeEnd={(v) => {
|
||||
// Validasi: jangan seek melebihi durasi
|
||||
const seekTime = Math.min(Math.max(0, v), duration);
|
||||
|
||||
if (audioRef.current) {
|
||||
// Set audio currentTime
|
||||
audioRef.current.currentTime = seekTime;
|
||||
setCurrentTime(seekTime);
|
||||
lastSeekTimeRef.current = seekTime;
|
||||
|
||||
// Jika audio tidak sedang playing, mainkan
|
||||
if (!audioRef.current.paused && !isPlaying) {
|
||||
audioRef.current.play().catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Set seeking false SETELAH semua operasi selesai
|
||||
setTimeout(() => {
|
||||
isSeekingRef.current = false;
|
||||
}, 0);
|
||||
}}
|
||||
max={duration || 100}
|
||||
onChange={(v) => seek(v)}
|
||||
color="#0B4F78"
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Text size="xs" c="#5A6C7D" w={40}>{formatTime(duration)}</Text>
|
||||
<Text size="xs" c="#5A6C7D" w={40}>{formatTime(duration || 0)}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs" style={{ flex: 1 }} justify="flex-end">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={toggleMute}>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={toggleMuteHandler}>
|
||||
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
|
||||
</ActionIcon>
|
||||
<Slider
|
||||
@@ -507,86 +309,4 @@ const MusicPlayer = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default MusicPlayer;
|
||||
|
||||
// 'use client'
|
||||
// import {
|
||||
// Box, Paper, Group, Stack, Text, Slider, ActionIcon
|
||||
// } from '@mantine/core';
|
||||
// import {
|
||||
// IconPlayerPlayFilled,
|
||||
// IconPlayerPauseFilled
|
||||
// } from '@tabler/icons-react';
|
||||
// import { useEffect, useState } from 'react';
|
||||
// import { useAudioEngine } from '../lib/useAudioProgress';
|
||||
|
||||
// interface Musik {
|
||||
// id: string;
|
||||
// judul: string;
|
||||
// artis: string;
|
||||
// audioFile: { link: string };
|
||||
// }
|
||||
|
||||
// export default function MusicPlayer() {
|
||||
// const {
|
||||
// audioRef,
|
||||
// isPlaying,
|
||||
// currentTime,
|
||||
// duration,
|
||||
// load,
|
||||
// toggle,
|
||||
// seek,
|
||||
// } = useAudioEngine();
|
||||
|
||||
// const [songs, setSongs] = useState<Musik[]>([]);
|
||||
// const [index, setIndex] = useState(0);
|
||||
|
||||
// useEffect(() => {
|
||||
// fetch('/api/desa/musik/find-many?page=1&limit=50')
|
||||
// .then(r => r.json())
|
||||
// .then(r => setSongs(r.data ?? []));
|
||||
// }, []);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (!songs[index]) return;
|
||||
// load(songs[index].audioFile.link);
|
||||
// }, [songs, index, load]);
|
||||
|
||||
// const format = (n: number) => {
|
||||
// const m = Math.floor(n / 60);
|
||||
// const s = Math.floor(n % 60);
|
||||
// return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <Box p="xl">
|
||||
// <audio ref={audioRef} />
|
||||
|
||||
// <Paper p="lg">
|
||||
// <Stack>
|
||||
// <Text fw={700}>{songs[index]?.judul}</Text>
|
||||
// <Text size="sm">{songs[index]?.artis}</Text>
|
||||
|
||||
// <Group>
|
||||
// <Text size="xs">{format(currentTime)}</Text>
|
||||
|
||||
// <Slider
|
||||
// value={currentTime}
|
||||
// max={duration}
|
||||
// onChange={seek}
|
||||
// style={{ flex: 1 }}
|
||||
// />
|
||||
|
||||
// <Text size="xs">{format(duration)}</Text>
|
||||
// </Group>
|
||||
|
||||
// <ActionIcon size={56} radius="xl" onClick={toggle}>
|
||||
// {isPlaying
|
||||
// ? <IconPlayerPauseFilled />
|
||||
// : <IconPlayerPlayFilled />}
|
||||
// </ActionIcon>
|
||||
// </Stack>
|
||||
// </Paper>
|
||||
// </Box>
|
||||
// );
|
||||
// }
|
||||
export default MusicPlayer;
|
||||
@@ -78,7 +78,8 @@ function APBDesProgress({ apbdesData }: APBDesProgressProps) {
|
||||
// Hitung total per kategori
|
||||
const calcTotal = (items: { anggaran: number; realisasi: number }[]) => {
|
||||
const anggaran = items.reduce((sum, item) => sum + item.anggaran, 0);
|
||||
const realisasi = items.reduce((sum, item) => sum + item.realisasi, 0);
|
||||
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
|
||||
const realisasi = items.reduce((sum, item) => sum + (item.realisasi || 0), 0);
|
||||
const persen = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
|
||||
return { anggaran, realisasi, persen };
|
||||
};
|
||||
|
||||
@@ -68,6 +68,7 @@ function APBDesTable({ apbdesData }: APBDesTableProps) {
|
||||
|
||||
// Calculate totals
|
||||
const totalAnggaran = items.reduce((sum, item) => sum + (item.anggaran || 0), 0);
|
||||
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
|
||||
const totalRealisasi = items.reduce((sum, item) => sum + (item.realisasi || 0), 0);
|
||||
const totalSelisih = totalAnggaran - totalRealisasi;
|
||||
const totalPersentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
|
||||
|
||||
@@ -51,7 +51,8 @@ export function transformAPBDesData(data: any): APBDesData {
|
||||
kode: item.kode || '',
|
||||
uraian: item.uraian || '',
|
||||
anggaran: typeof item.anggaran === 'number' ? item.anggaran : 0,
|
||||
realisasi: typeof item.realisasi === 'number' ? item.realisasi : 0,
|
||||
// Map totalRealisasi from backend to realisasi field
|
||||
realisasi: typeof item.totalRealisasi === 'number' ? item.totalRealisasi : (typeof item.realisasi === 'number' ? item.realisasi : 0),
|
||||
selisih: typeof item.selisih === 'number' ? item.selisih : 0,
|
||||
persentase: typeof item.persentase === 'number' ? item.persentase : 0,
|
||||
level: typeof item.level === 'number' ? item.level : 1,
|
||||
|
||||
317
src/app/darmasaba/_com/FixedPlayerBar.tsx
Normal file
317
src/app/darmasaba/_com/FixedPlayerBar.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import { useMusic } from '@/app/context/MusicContext';
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
Paper,
|
||||
Slider,
|
||||
Text,
|
||||
Transition
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowsShuffle,
|
||||
IconMusic,
|
||||
IconPlayerPauseFilled,
|
||||
IconPlayerPlayFilled,
|
||||
IconPlayerSkipBackFilled,
|
||||
IconPlayerSkipForwardFilled,
|
||||
IconRepeat,
|
||||
IconRepeatOff,
|
||||
IconVolume,
|
||||
IconVolumeOff,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function FixedPlayerBar() {
|
||||
const {
|
||||
isPlaying,
|
||||
currentSong,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
isMuted,
|
||||
isRepeat,
|
||||
isShuffle,
|
||||
togglePlayPause,
|
||||
playNext,
|
||||
playPrev,
|
||||
seek,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
toggleRepeat,
|
||||
toggleShuffle,
|
||||
} = useMusic();
|
||||
|
||||
const [showVolume, setShowVolume] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
|
||||
// Format time
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Handle seek
|
||||
const handleSeek = (value: number) => {
|
||||
seek(value);
|
||||
};
|
||||
|
||||
// Handle volume change
|
||||
const handleVolumeChange = (value: number) => {
|
||||
setVolume(value);
|
||||
};
|
||||
|
||||
// Handle shuffle toggle
|
||||
const handleToggleShuffle = () => {
|
||||
toggleShuffle();
|
||||
};
|
||||
|
||||
// Handle minimize player (show floating icon)
|
||||
const handleMinimizePlayer = () => {
|
||||
setIsMinimized(true);
|
||||
};
|
||||
|
||||
// Handle restore player from floating icon
|
||||
const handleRestorePlayer = () => {
|
||||
setIsMinimized(false);
|
||||
};
|
||||
|
||||
// If minimized, show floating icon instead of player bar
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<>
|
||||
{/* Floating Music Icon - Shows when player is minimized */}
|
||||
<Button
|
||||
color="#0B4F78"
|
||||
variant="filled"
|
||||
size="md"
|
||||
mt="md"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '50%', // Menempatkan titik atas ikon di tengah layar
|
||||
left: '0px',
|
||||
transform: 'translateY(-50%)', // Menggeser ikon ke atas sebesar setengah tingginya sendiri agar benar-benar di tengah
|
||||
borderBottomRightRadius: '20px',
|
||||
borderTopRightRadius: '20px',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
zIndex: 1
|
||||
}}
|
||||
onClick={handleRestorePlayer}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-50%) scale(1.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-50%)';
|
||||
}}
|
||||
>
|
||||
<IconMusic size={28} color="white" />
|
||||
</Button>
|
||||
|
||||
{/* Spacer to prevent content from being hidden behind player */}
|
||||
<Box h={20} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentSong) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mini Player Bar - Always visible when song is playing */}
|
||||
<Paper
|
||||
pos="fixed"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="sm"
|
||||
shadow="lg"
|
||||
style={{
|
||||
zIndex: 1,
|
||||
borderTop: '1px solid rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<Flex align="center" gap="md" justify="space-between">
|
||||
{/* Song Info - Left */}
|
||||
<Group gap="sm" flex={1} style={{ minWidth: 0 }}>
|
||||
<Avatar
|
||||
src={currentSong.coverImage?.link || ''}
|
||||
alt={currentSong.judul}
|
||||
size={40}
|
||||
radius="sm"
|
||||
imageProps={{ loading: 'lazy' }}
|
||||
/>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz="sm" fw={600} truncate>
|
||||
{currentSong.judul}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed" truncate>
|
||||
{currentSong.artis}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{/* Controls + Progress - Center */}
|
||||
<Group gap="xs" flex={2} justify="center">
|
||||
{/* Control Buttons */}
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant={isShuffle ? 'filled' : 'subtle'}
|
||||
color={isShuffle ? 'blue' : 'gray'}
|
||||
size="lg"
|
||||
onClick={handleToggleShuffle}
|
||||
title="Shuffle"
|
||||
>
|
||||
<IconArrowsShuffle size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
onClick={playPrev}
|
||||
title="Previous"
|
||||
>
|
||||
<IconPlayerSkipBackFilled size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color={isPlaying ? 'blue' : 'gray'}
|
||||
size="xl"
|
||||
radius="xl"
|
||||
onClick={togglePlayPause}
|
||||
title={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<IconPlayerPauseFilled size={24} />
|
||||
) : (
|
||||
<IconPlayerPlayFilled size={24} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
onClick={playNext}
|
||||
title="Next"
|
||||
>
|
||||
<IconPlayerSkipForwardFilled size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={isRepeat ? 'blue' : 'gray'}
|
||||
size="lg"
|
||||
onClick={toggleRepeat}
|
||||
title={isRepeat ? 'Repeat On' : 'Repeat Off'}
|
||||
>
|
||||
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{/* Progress Bar - Desktop */}
|
||||
<Box w={200} display={{ base: 'none', md: 'block' }}>
|
||||
<Slider
|
||||
value={currentTime}
|
||||
max={duration || 100}
|
||||
onChange={handleSeek}
|
||||
size="sm"
|
||||
color="blue"
|
||||
label={(value) => formatTime(value)}
|
||||
/>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{/* Right Controls - Volume + Close */}
|
||||
<Group gap="xs" flex={1} justify="flex-end">
|
||||
<Box
|
||||
onMouseEnter={() => setShowVolume(true)}
|
||||
onMouseLeave={() => setShowVolume(false)}
|
||||
pos="relative"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={isMuted ? 'red' : 'gray'}
|
||||
size="lg"
|
||||
onClick={toggleMute}
|
||||
title={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{isMuted ? (
|
||||
<IconVolumeOff size={18} />
|
||||
) : (
|
||||
<IconVolume size={18} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
|
||||
<Transition
|
||||
mounted={showVolume}
|
||||
transition="scale-y"
|
||||
duration={200}
|
||||
timingFunction="ease"
|
||||
>
|
||||
{(style) => (
|
||||
<Paper
|
||||
style={{
|
||||
...style,
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
right: 0,
|
||||
mb: 'xs',
|
||||
p: 'sm',
|
||||
zIndex: 1001,
|
||||
}}
|
||||
shadow="md"
|
||||
withBorder
|
||||
>
|
||||
<Slider
|
||||
value={isMuted ? 0 : volume}
|
||||
max={100}
|
||||
onChange={handleVolumeChange}
|
||||
h={100}
|
||||
color="blue"
|
||||
size="sm"
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
</Transition>
|
||||
</Box>
|
||||
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
onClick={handleMinimizePlayer}
|
||||
title="Minimize player"
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Flex>
|
||||
|
||||
{/* Progress Bar - Mobile */}
|
||||
<Box mt="xs" display={{ base: 'block', md: 'none' }}>
|
||||
<Slider
|
||||
value={currentTime}
|
||||
max={duration || 100}
|
||||
onChange={handleSeek}
|
||||
size="sm"
|
||||
color="blue"
|
||||
label={(value) => formatTime(value)}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Spacer to prevent content from being hidden behind player */}
|
||||
<Box h={80} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import { Button } from '@mantine/core';
|
||||
import { IconMusic, IconMusicOff } from '@tabler/icons-react';
|
||||
import { IconDisabled, IconDisabledOff } from '@tabler/icons-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const NewsReaderLanding = () => {
|
||||
@@ -95,15 +95,17 @@ const NewsReaderLanding = () => {
|
||||
mt="md"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '350px',
|
||||
top: '50%', // Menempatkan titik atas ikon di tengah layar
|
||||
left: '0px',
|
||||
transform: 'translateY(80%)', // Menggeser ikon ke atas sebesar setengah tingginya sendiri agar benar-benar di tengah
|
||||
borderBottomRightRadius: '20px',
|
||||
borderTopRightRadius: '20px',
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
{isPointerMode ? <IconMusicOff /> : <IconMusic />}
|
||||
{isPointerMode ? <IconDisabledOff /> : <IconDisabled />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,48 +1,55 @@
|
||||
/* 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 APBDesProgress from '@/app/darmasaba/(tambahan)/apbdes/lib/apbDesaProgress'
|
||||
import { transformAPBDesData } from '@/app/darmasaba/(tambahan)/apbdes/lib/types'
|
||||
|
||||
import apbdesState from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
|
||||
import colors from '@/con/colors'
|
||||
import {
|
||||
ActionIcon,
|
||||
BackgroundImage,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
LoadingOverlay,
|
||||
Transition,
|
||||
} from '@mantine/core'
|
||||
import { IconDownload } from '@tabler/icons-react'
|
||||
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 [loading, setLoading] = useState(false)
|
||||
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 {
|
||||
setLoading(true)
|
||||
setIsLoading(true)
|
||||
await state.findMany.load()
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
@@ -51,171 +58,204 @@ function Apbdes() {
|
||||
const dataAPBDes = state.findMany.data || []
|
||||
|
||||
const years = Array.from(
|
||||
new Set(
|
||||
dataAPBDes
|
||||
.map((item: any) => item?.tahun)
|
||||
.filter((tahun): tahun is number => typeof tahun === 'number')
|
||||
new Set(
|
||||
dataAPBDes
|
||||
.map((item: any) => item?.tahun)
|
||||
.filter((tahun): tahun is number => typeof tahun === 'number')
|
||||
)
|
||||
)
|
||||
)
|
||||
.sort((a, b) => b - a)
|
||||
.map(year => ({
|
||||
value: year.toString(),
|
||||
label: `Tahun ${year}`,
|
||||
}))
|
||||
|
||||
.sort((a, b) => b - a)
|
||||
.map((year) => ({
|
||||
value: year.toString(),
|
||||
label: `Tahun ${year}`,
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
if (years.length > 0 && !selectedYear) {
|
||||
setSelectedYear(years[0].value)
|
||||
}
|
||||
}, [years, selectedYear])
|
||||
}, [years])
|
||||
|
||||
const currentApbdes = dataAPBDes.length > 0
|
||||
? transformAPBDesData(dataAPBDes.find(item => item?.tahun?.toString() === selectedYear) || dataAPBDes[0])
|
||||
? (dataAPBDes.find((item: any) => item?.tahun?.toString() === selectedYear) || dataAPBDes[0])
|
||||
: null
|
||||
|
||||
const data = (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}>
|
||||
{/* 📌 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: 100 }}>
|
||||
<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>
|
||||
|
||||
{/* Progress */}
|
||||
{currentApbdes ? (
|
||||
<APBDesProgress apbdesData={currentApbdes} />
|
||||
) : (
|
||||
<Box px={{ base: 'md', md: 100 }} py="md">
|
||||
<Text fz="sm" c="dimmed" ta="center" lh={1.5}>
|
||||
Tidak ada data APBDes untuk tahun yang dipilih.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* GRID */}
|
||||
{loading ? (
|
||||
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
|
||||
<Loader size="lg" color="blue" />
|
||||
</Center>
|
||||
) : data.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"
|
||||
>
|
||||
{data.map((v, k) => (
|
||||
<BackgroundImage
|
||||
key={k}
|
||||
src={v.image?.link || ''}
|
||||
h={360}
|
||||
radius="xl"
|
||||
pos="relative"
|
||||
style={{ 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}
|
||||
</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>
|
||||
</BackgroundImage>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
224
src/app/darmasaba/_com/main-page/apbdes/lib/grafikRealisasi.tsx
Normal file
224
src/app/darmasaba/_com/main-page/apbdes/lib/grafikRealisasi.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Paper, Title, Progress, Stack, Text, Group, Box } from '@mantine/core'
|
||||
import { IconArrowUpRight, IconArrowDownRight } from '@tabler/icons-react'
|
||||
import { APBDes, APBDesItem } from '../types/apbdes'
|
||||
|
||||
interface SummaryProps {
|
||||
title: string
|
||||
data: APBDesItem[]
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
function Summary({ title, data, icon }: SummaryProps) {
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
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 persentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0
|
||||
|
||||
const formatRupiah = (angka: number) => {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(angka)
|
||||
}
|
||||
|
||||
const getProgressColor = (persen: number) => {
|
||||
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">
|
||||
<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="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={persentase}
|
||||
size="xl"
|
||||
radius="xl"
|
||||
color={getProgressColor(persentase)}
|
||||
striped={persentase < 100}
|
||||
animated={persentase < 100}
|
||||
mb="xs"
|
||||
/>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
interface GrafikRealisasiProps {
|
||||
apbdesData: APBDes
|
||||
}
|
||||
|
||||
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="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="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>
|
||||
)
|
||||
}
|
||||
179
src/app/darmasaba/_com/main-page/apbdes/lib/paguTable.tsx
Normal file
179
src/app/darmasaba/_com/main-page/apbdes/lib/paguTable.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Paper, Table, Title, Box, ScrollArea, Badge } from '@mantine/core'
|
||||
import { APBDes, APBDesItem } from '../types/apbdes'
|
||||
|
||||
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 bg="gray.0">
|
||||
<Table.Td colSpan={2}>
|
||||
<Badge color={badgeColor} variant="light" size="lg" fw={600}>
|
||||
{title}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
{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"
|
||||
style={{
|
||||
borderBottom: '1px solid #e5e7eb',
|
||||
fontWeight: 600,
|
||||
color: 'var(--mantine-color-blue-7)',
|
||||
}}
|
||||
>
|
||||
Rp {item.anggaran.toLocaleString('id-ID')}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface PaguTableProps {
|
||||
apbdesData: APBDes
|
||||
}
|
||||
|
||||
export default function PaguTable({ apbdesData }: PaguTableProps) {
|
||||
const items = apbdesData.items || []
|
||||
|
||||
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="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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
211
src/app/darmasaba/_com/main-page/apbdes/lib/realisasiTable.tsx
Normal file
211
src/app/darmasaba/_com/main-page/apbdes/lib/realisasiTable.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { Paper, Table, Title, Badge, Text, Box, ScrollArea } from '@mantine/core'
|
||||
import { APBDes, APBDesItem, RealisasiItem } from '../types/apbdes'
|
||||
|
||||
interface RealisasiRowProps {
|
||||
realisasi: RealisasiItem
|
||||
parentItem: APBDesItem
|
||||
}
|
||||
|
||||
function RealisasiRow({ realisasi, parentItem }: RealisasiRowProps) {
|
||||
const persentase = parentItem.anggaran > 0
|
||||
? (realisasi.jumlah / parentItem.anggaran) * 100
|
||||
: 0
|
||||
|
||||
const getBadgeColor = (percentage: number) => {
|
||||
if (percentage >= 100) return 'teal'
|
||||
if (percentage >= 80) return 'blue'
|
||||
if (percentage >= 60) return 'yellow'
|
||||
return 'red'
|
||||
}
|
||||
|
||||
const getBadgeVariant = (percentage: number) => {
|
||||
if (percentage >= 100) return 'filled'
|
||||
return 'light'
|
||||
}
|
||||
|
||||
return (
|
||||
<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 ? (
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
<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.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;
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { Box, Space, Stack } from "@mantine/core";
|
||||
|
||||
import { Navbar } from "@/app/darmasaba/_com/Navbar";
|
||||
import Footer from "./_com/Footer";
|
||||
import FixedPlayerBar from "./_com/FixedPlayerBar";
|
||||
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
@@ -21,6 +22,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
{children}
|
||||
</Box>
|
||||
<Footer />
|
||||
<FixedPlayerBar />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -150,13 +150,13 @@ export default function Page() {
|
||||
<Box id="page-root">
|
||||
<Stack bg={colors.grey[1]} gap={0}>
|
||||
<LandingPage />
|
||||
<Apbdes />
|
||||
<Penghargaan />
|
||||
<Layanan />
|
||||
<Potensi />
|
||||
<DesaAntiKorupsi />
|
||||
<Kepuasan />
|
||||
<SDGS />
|
||||
<Apbdes />
|
||||
<Prestasi />
|
||||
<ScrollToTopButton />
|
||||
<NewsReaderLanding />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import "@mantine/core/styles.css";
|
||||
import "./globals.css";
|
||||
import "./globals.css"; // Sisanya import di globals.css
|
||||
|
||||
import LoadDataFirstClient from "@/app/darmasaba/_com/LoadDataFirstClient";
|
||||
import { MusicProvider } from "@/app/context/MusicContext";
|
||||
import {
|
||||
ColorSchemeScript,
|
||||
MantineProvider,
|
||||
createTheme,
|
||||
mantineHtmlProps,
|
||||
// mantineHtmlProps,
|
||||
} from "@mantine/core";
|
||||
import { Metadata, Viewport } from "next";
|
||||
import { ViewTransitions } from "next-view-transitions";
|
||||
@@ -97,24 +99,26 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ViewTransitions>
|
||||
<html lang="id" {...mantineHtmlProps}>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<ColorSchemeScript defaultColorScheme="light" />
|
||||
</head>
|
||||
<body>
|
||||
<MantineProvider theme={theme} defaultColorScheme="light">
|
||||
{children}
|
||||
<LoadDataFirstClient />
|
||||
<ToastContainer
|
||||
position="bottom-center"
|
||||
hideProgressBar
|
||||
style={{ zIndex: 9999 }}
|
||||
/>
|
||||
</MantineProvider>
|
||||
</body>
|
||||
</html>
|
||||
</ViewTransitions>
|
||||
<html lang="id" {...mantineHtmlProps}>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<ColorSchemeScript defaultColorScheme="light" />
|
||||
</head>
|
||||
<body>
|
||||
<ViewTransitions>
|
||||
<MusicProvider>
|
||||
<MantineProvider theme={theme} defaultColorScheme="light">
|
||||
{children}
|
||||
<LoadDataFirstClient />
|
||||
<ToastContainer
|
||||
position="bottom-center"
|
||||
hideProgressBar
|
||||
style={{ zIndex: 9999 }}
|
||||
/>
|
||||
</MantineProvider>
|
||||
</MusicProvider>
|
||||
</ViewTransitions>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ function Page() {
|
||||
dengan ketentuan ini, harap jangan gunakan Website.
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
|
||||
<Box>
|
||||
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
|
||||
|
||||
@@ -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