Compare commits

..

1 Commits

Author SHA1 Message Date
7bc546e985 Fix Responsive 2026-03-06 16:19:01 +08:00
56 changed files with 1034 additions and 5797 deletions

View File

@@ -1,47 +0,0 @@
node_modules
.next
.git
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
bun-debug.log*
# Docker files
Dockerfile
.dockerignore
# OS files
.DS_Store
Thumbs.db
# Markdown/Documentation
README.md
GEMINI.md
AGENTS.md
AUDIT_REPORT.md
QWEN.md
NOTE.md
task-project-apbdes.md
MUSIK_CREATE_ANALYSIS.md
darkMode.md
/test-results
/playwright-report
/tmp_assets
/foldergambar
/googleapi
/xx
/xx.ts
/xx.txt
/test.txt
/x.json
/x.sh
/xcoba.ts
/xcoba2.ts
/gambar.ttx
/test-berita-state.ts

View File

@@ -1,44 +0,0 @@
# 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
# WhatsApp Server Configuration
WA_SERVER_TOKEN=your_whatsapp_server_token
# 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

View File

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

219
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,219 @@
name: Build And Save Log
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment (e.g., staging, production)"
required: true
default: "staging"
version:
description: "Version to deploy"
required: false
default: "latest"
env:
APP_NAME: desa-darmasaba-action
WA_PHONE: "6289697338821,6289697338822"
jobs:
build:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14
env:
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
# Checkout kode sumber
- name: Checkout code
uses: actions/checkout@v3
# Setup Bun
- name: Setup Bun
uses: oven-sh/setup-bun@v2
# Cache dependencies
- name: Cache dependencies
uses: actions/cache@v3
with:
path: .bun
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
# Step 1: Set BRANCH_NAME based on event type
- name: Set BRANCH_NAME
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "BRANCH_NAME=${{ github.head_ref }}" >> $GITHUB_ENV
else
echo "BRANCH_NAME=${{ github.ref_name }}" >> $GITHUB_ENV
fi
# Step 2: Generate APP_VERSION dynamically
- name: Set APP_VERSION
run: echo "APP_VERSION=${{ github.sha }}---$(date +%Y%m%d%H%M%S)" >> $GITHUB_ENV
# Step 3: Kirim notifikasi ke API build Start
- name: Notify start build
run: |
IFS=',' read -ra PHONES <<< "${{ env.WA_PHONE }}"
for PHONE in "${PHONES[@]}"; do
ENCODED_TEXT=$(bun -e "console.log(encodeURIComponent('Build:start\nApp:${{ env.APP_NAME }}\nBranch:${{ env.BRANCH_NAME }}\nVersion:${{ env.APP_VERSION }}'))")
curl -X GET "https://wa.wibudev.com/code?text=$ENCODED_TEXT&nom=$PHONE"
done
# Install dependencies
- name: Install dependencies
run: bun install
# Konfigurasi environment variable untuk PostgreSQL dan variabel tambahan
- name: Set up environment variables
run: |
echo "DATABASE_URL=postgresql://${{ secrets.POSTGRES_USER }}:${{ secrets.POSTGRES_PASSWORD }}@localhost:5432/${{ secrets.POSTGRES_DB }}?schema=public" >> .env
echo "PORT=3000" >> .env
echo "NEXT_PUBLIC_WIBU_URL=localhost:3000" >> .env
echo "WIBU_UPLOAD_DIR=/uploads" >> .env
# Create log file
- name: Create log file
run: touch build.txt
# Migrasi database menggunakan Prisma
- name: Apply Prisma schema to database
run: bun prisma db push >> build.txt 2>&1
# Seed database (opsional)
- name: Seed database
run: |
bun prisma db seed >> build.txt 2>&1 || echo "Seed failed or no seed data found. Continuing without seed." >> build.txt
# Build project
- name: Build project
run: bun run build >> build.txt 2>&1
# Ensure project directory exists
- name: Ensure /var/www/projects/${{ env.APP_NAME }} exists
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USERNAME }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
mkdir -p /var/www/projects/${{ env.APP_NAME }}
# Deploy to a new version directory
- name: Deploy to VPS (New Version)
uses: appleboy/scp-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USERNAME }}
key: ${{ secrets.VPS_SSH_KEY }}
source: "."
target: "/var/www/projects/${{ env.APP_NAME }}/releases/${{ env.APP_VERSION }}"
# Set up environment variables
- name: Set up environment variables
run: |
rm -r .env
echo "DATABASE_URL=postgresql://${{ secrets.POSTGRES_USER }}:${{ secrets.POSTGRES_PASSWORD }}@localhost:5433/${{ secrets.POSTGRES_DB }}?schema=public" >> .env
echo "NEXT_PUBLIC_WIBU_URL=${{ env.APP_NAME }}" >> .env
echo "WIBU_UPLOAD_DIR=/var/www/projects/${{ env.APP_NAME }}/uploads" >> .env
# Kirim file .env ke server
- name: Upload .env to server
uses: appleboy/scp-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USERNAME }}
key: ${{ secrets.VPS_SSH_KEY }}
source: ".env"
target: "/var/www/projects/${{ env.APP_NAME }}/releases/${{ env.APP_VERSION }}/"
# manage deployment
- name: manage deployment
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USERNAME }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
# Source ~/.bashrc
source ~/.bashrc
# Find an available port
PORT=$(curl -s -X GET https://wibu-bot.wibudev.com/api/find-port | jq -r '.[0]')
if [ -z "$PORT" ] || ! [[ "$PORT" =~ ^[0-9]+$ ]]; then
echo "Invalid or missing port from API."
exit 1
fi
# manage deployment
cd /var/www/projects/${{ env.APP_NAME }}/releases/${{ env.APP_VERSION }}
# Create uploads directory
mkdir -p /var/www/projects/${{ env.APP_NAME }}/uploads
# Install dependencies
bun install --production
# Apply database schema
if ! bun prisma db push; then
echo "Database migration failed."
exit 1
fi
# Seed database (optional)
bun prisma db seed || echo "tidak membutuhkan seed"
# Restart the application
pm2 reload ${{ env.APP_NAME }} || pm2 start "bun run start --port $PORT" --name "${{ env.APP_NAME }}-$PORT" --namespace "${{ env.APP_NAME }}"
# Step 4: Set BUILD_STATUS based on success or failure
- name: Set BUILD_STATUS
if: success()
run: echo "BUILD_STATUS=success" >> $GITHUB_ENV
- name: Set BUILD_STATUS on failure
if: failure()
run: echo "BUILD_STATUS=failed" >> $GITHUB_ENV
# Update status log
- name: Update status log
if: always()
run: |
echo "=====================" >> build.txt
echo "BUILD_STATUS=${{ env.BUILD_STATUS }}" >> build.txt
echo "APP_NAME=${{ env.APP_NAME }}" >> build.txt
echo "APP_VERSION=${{ env.APP_VERSION }}" >> build.txt
echo "=====================" >> build.txt
# Upload log to 0x0.st
- name: Upload log to 0x0.st
id: upload_log
if: always()
run: |
LOG_URL=$(curl -F "file=@build.txt" https://wibu-bot.wibudev.com/api/file )
echo "LOG_URL=$LOG_URL" >> $GITHUB_ENV
# Kirim notifikasi ke API
- name: Notify build success via API
if: always()
run: |
IFS=',' read -ra PHONES <<< "${{ env.WA_PHONE }}"
for PHONE in "${PHONES[@]}"; do
ENCODED_TEXT=$(bun -e "console.log(encodeURIComponent('Build:${{ env.BUILD_STATUS }}\nApp:${{ env.APP_NAME }}\nBranch:${{ env.BRANCH_NAME }}\nVersion:${{ env.APP_VERSION }}\nLog:${{ env.LOG_URL }}'))")
curl -X GET "https://wa.wibudev.com/code?text=$ENCODED_TEXT&nom=$PHONE"
done

View File

@@ -1,56 +0,0 @@
name: Publish Docker to GHCR
on:
push:
tags:
- "v*"
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
publish:
name: Build & Push to GHCR
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
sudo docker image prune --all --force
df -h
- name: Checkout repository
uses: actions/checkout@v4
- name: Extract tag name
id: meta
run: echo "tag=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.tag }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,106 +0,0 @@
name: Publish Docker to GHCR
on:
workflow_dispatch:
inputs:
stack_env:
description: "stack env"
required: true
type: choice
default: "dev"
options:
- dev
- prod
- stg
tag:
description: "Image tag (e.g. 1.0.0)"
required: true
default: "1.0.0"
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
publish:
name: Build & Push to GHCR ${{ github.repository }}:${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }}
runs-on: ubuntu-latest
environment: ${{ vars.PORTAINER_ENV || 'portainer' }}
permissions:
contents: read
packages: write
steps:
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
sudo docker image prune --all --force
df -h
- name: Checkout branch ${{ github.event.inputs.stack_env }}
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.stack_env }}
- name: Checkout scripts from main
uses: actions/checkout@v4
with:
ref: main
path: .ci
sparse-checkout: .github/workflows/script
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }}
type=raw,value=${{ github.event.inputs.stack_env }}-latest
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
- name: Notify success
if: success()
run: bash ./.ci/.github/workflows/script/notify.sh
env:
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
NOTIFY_STATUS: success
NOTIFY_WORKFLOW: "Publish Docker"
NOTIFY_DETAIL: "Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }}"
- name: Notify failure
if: failure()
run: bash ./.ci/.github/workflows/script/notify.sh
env:
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
NOTIFY_STATUS: failure
NOTIFY_WORKFLOW: "Publish Docker"
NOTIFY_DETAIL: "Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }}"

View File

@@ -1,60 +0,0 @@
name: Re-Pull Docker
on:
workflow_dispatch:
inputs:
stack_name:
description: "stack name"
required: true
type: string
stack_env:
description: "stack env"
required: true
type: choice
default: "dev"
options:
- dev
- stg
- prod
jobs:
publish:
name: Re-Pull Docker ${{ github.event.inputs.stack_name }}
runs-on: ubuntu-latest
environment: ${{ vars.PORTAINER_ENV || 'portainer' }}
permissions:
contents: read
packages: write
steps:
- name: Checkout scripts from main
uses: actions/checkout@v4
with:
ref: main
sparse-checkout: .github/workflows/script
- name: Deploy ke Portainer
run: bash ./.github/workflows/script/re-pull.sh
env:
PORTAINER_USERNAME: ${{ secrets.PORTAINER_USERNAME }}
PORTAINER_PASSWORD: ${{ secrets.PORTAINER_PASSWORD }}
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
STACK_NAME: ${{ github.event.inputs.stack_name }}-${{ github.event.inputs.stack_env }}
- name: Notify success
if: success()
run: bash ./.github/workflows/script/notify.sh
env:
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
NOTIFY_STATUS: success
NOTIFY_WORKFLOW: "Re-Pull Docker"
NOTIFY_DETAIL: "Stack: ${{ github.event.inputs.stack_name }}-${{ github.event.inputs.stack_env }}"
- name: Notify failure
if: failure()
run: bash ./.github/workflows/script/notify.sh
env:
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
NOTIFY_STATUS: failure
NOTIFY_WORKFLOW: "Re-Pull Docker"
NOTIFY_DETAIL: "Stack: ${{ github.event.inputs.stack_name }}-${{ github.event.inputs.stack_env }}"

View File

@@ -1,26 +0,0 @@
#!/bin/bash
: "${TELEGRAM_TOKEN:?TELEGRAM_TOKEN tidak di-set}"
: "${TELEGRAM_CHAT_ID:?TELEGRAM_CHAT_ID tidak di-set}"
: "${NOTIFY_STATUS:?NOTIFY_STATUS tidak di-set}"
: "${NOTIFY_WORKFLOW:?NOTIFY_WORKFLOW tidak di-set}"
if [ "$NOTIFY_STATUS" = "success" ]; then
ICON="✅"
TEXT="${ICON} *${NOTIFY_WORKFLOW}* berhasil!"
else
ICON="❌"
TEXT="${ICON} *${NOTIFY_WORKFLOW}* gagal!"
fi
if [ -n "$NOTIFY_DETAIL" ]; then
TEXT="${TEXT}
${NOTIFY_DETAIL}"
fi
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$TEXT" \
'{chat_id: $chat_id, text: $text, parse_mode: "Markdown"}')"

View File

@@ -1,120 +0,0 @@
#!/bin/bash
: "${PORTAINER_URL:?PORTAINER_URL tidak di-set}"
: "${PORTAINER_USERNAME:?PORTAINER_USERNAME tidak di-set}"
: "${PORTAINER_PASSWORD:?PORTAINER_PASSWORD tidak di-set}"
: "${STACK_NAME:?STACK_NAME tidak di-set}"
# Timeout total: MAX_RETRY * SLEEP_INTERVAL detik
MAX_RETRY=60 # 60 × 10s = 10 menit
SLEEP_INTERVAL=10
echo "🔐 Autentikasi ke Portainer..."
TOKEN=$(curl -s -X POST "https://${PORTAINER_URL}/api/auth" \
-H "Content-Type: application/json" \
-d "{\"username\": \"${PORTAINER_USERNAME}\", \"password\": \"${PORTAINER_PASSWORD}\"}" \
| jq -r .jwt)
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "❌ Autentikasi gagal! Cek PORTAINER_URL, USERNAME, dan PASSWORD."
exit 1
fi
echo "🔍 Mencari stack: $STACK_NAME..."
STACK=$(curl -s -X GET "https://${PORTAINER_URL}/api/stacks" \
-H "Authorization: Bearer ${TOKEN}" \
| jq ".[] | select(.Name == \"$STACK_NAME\")")
if [ -z "$STACK" ]; then
echo "❌ Stack '$STACK_NAME' tidak ditemukan di Portainer!"
exit 1
fi
STACK_ID=$(echo "$STACK" | jq -r .Id)
ENDPOINT_ID=$(echo "$STACK" | jq -r .EndpointId)
ENV=$(echo "$STACK" | jq '.Env // []')
# ── Catat container ID lama sebelum redeploy ──────────────────────────────────
echo "📸 Mencatat container aktif sebelum redeploy..."
CONTAINERS_BEFORE=$(curl -s -X GET \
"https://${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/json?all=true&filters=%7B%22label%22%3A%5B%22com.docker.compose.project%3D${STACK_NAME}%22%5D%7D" \
-H "Authorization: Bearer ${TOKEN}")
OLD_IDS=$(echo "$CONTAINERS_BEFORE" | jq -r '[.[] | .Id] | join(",")')
echo " Container lama: $(echo "$CONTAINERS_BEFORE" | jq -r '[.[] | .Names[0]] | join(", ")')"
# ── Ambil compose file lalu trigger redeploy ─────────────────────────────────
echo "📄 Mengambil compose file..."
STACK_FILE=$(curl -s -X GET "https://${PORTAINER_URL}/api/stacks/${STACK_ID}/file" \
-H "Authorization: Bearer ${TOKEN}" \
| jq -r .StackFileContent)
PAYLOAD=$(jq -n \
--arg content "$STACK_FILE" \
--argjson env "$ENV" \
'{stackFileContent: $content, env: $env, pullImage: true}')
echo "🚀 Triggering redeploy $STACK_NAME (pull latest image)..."
HTTP_STATUS=$(curl -s -o /tmp/portainer_response.json -w "%{http_code}" \
-X PUT "https://${PORTAINER_URL}/api/stacks/${STACK_ID}?endpointId=${ENDPOINT_ID}" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
if [ "$HTTP_STATUS" != "200" ]; then
echo "❌ Redeploy gagal! HTTP Status: $HTTP_STATUS"
cat /tmp/portainer_response.json | jq .
exit 1
fi
echo "⏳ Menunggu image selesai di-pull dan container baru running..."
echo " (Timeout: $((MAX_RETRY * SLEEP_INTERVAL)) detik)"
COUNT=0
while [ $COUNT -lt $MAX_RETRY ]; do
sleep $SLEEP_INTERVAL
COUNT=$((COUNT + 1))
CONTAINERS=$(curl -s -X GET \
"https://${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/json?all=true&filters=%7B%22label%22%3A%5B%22com.docker.compose.project%3D${STACK_NAME}%22%5D%7D" \
-H "Authorization: Bearer ${TOKEN}")
# Container baru = ID tidak ada di daftar container lama
NEW_RUNNING=$(echo "$CONTAINERS" | jq \
--arg old "$OLD_IDS" \
'[.[] | select(.State == "running" and ((.Id) as $id | ($old | split(",") | index($id)) == null))] | length')
FAILED=$(echo "$CONTAINERS" | jq \
'[.[] | select(.State == "exited" and (.Status | test("Exited \\(0\\)") | not) and (.Names[0] | test("seed") | not))] | length')
echo "🔄 [$((COUNT * SLEEP_INTERVAL))s / $((MAX_RETRY * SLEEP_INTERVAL))s] Container baru running: ${NEW_RUNNING} | Gagal: ${FAILED}"
echo "$CONTAINERS" | jq -r '.[] | " → \(.Names[0]) | \(.State) | \(.Status) | id: \(.Id[:12])"'
if [ "$FAILED" -gt "0" ]; then
echo ""
echo "❌ Ada container yang crash!"
echo "$CONTAINERS" | jq -r '.[] | select(.State == "exited" and (.Status | test("Exited \\(0\\)") | not) and (.Names[0] | test("seed") | not)) | " → \(.Names[0]) | \(.Status)"'
exit 1
fi
if [ "$NEW_RUNNING" -gt "0" ]; then
# Cleanup dangling images setelah redeploy sukses
echo "🧹 Membersihkan dangling images..."
curl -s -X POST "https://${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/images/prune" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"filters":{"dangling":["true"]}}' | jq -r '" Reclaimed: \(.SpaceReclaimed // 0 | . / 1073741824 | tostring | .[0:5]) GB"'
echo "✅ Cleanup selesai!"
echo ""
echo "✅ Stack $STACK_NAME berhasil di-redeploy dengan image baru dan running!"
exit 0
fi
done
echo ""
echo "❌ Timeout $((MAX_RETRY * SLEEP_INTERVAL))s! Container baru tidak kunjung running."
echo " Kemungkinan image masih dalam proses pull atau ada error di server."
exit 1

55
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: test workflows
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment (e.g., staging, production)"
required: true
default: "staging"
version:
description: "Version to deploy"
required: false
default: "latest"
env:
APP_NAME: desa-darmasaba-action
WA_PHONE: "6289697338821,6289697338822"
jobs:
build:
runs-on: ubuntu-latest
steps:
# Checkout kode sumber
- name: Checkout code
uses: actions/checkout@v3
# Setup Bun
- name: Setup Bun
uses: oven-sh/setup-bun@v2
# Create log file
- name: Create log file
run: touch build.txt
# Step 1: Set BRANCH_NAME based on event type
- name: Set BRANCH_NAME
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "BRANCH_NAME=${{ github.head_ref }}" >> $GITHUB_ENV
else
echo "BRANCH_NAME=${{ github.ref_name }}" >> $GITHUB_ENV
fi
# Step 2: Generate APP_VERSION dynamically
- name: Set APP_VERSION
run: echo "APP_VERSION=${{ github.sha }}---$(date +%Y%m%d%H%M%S)" >> $GITHUB_ENV
# Step 3: Kirim notifikasi ke API build Start
- name: Notify start build
run: |
IFS=',' read -ra PHONES <<< "${{ env.WA_PHONE }}"
for PHONE in "${PHONES[@]}"; do
ENCODED_TEXT=$(bun -e "console.log(encodeURIComponent('Build:start\nApp:${{ env.APP_NAME }}\nenv:${{ inputs.environment }}\nBranch:${{ env.BRANCH_NAME }}\nVersion:${{ env.APP_VERSION }}'))")
curl -X GET "https://wa.wibudev.com/code?text=$ENCODED_TEXT&nom=$PHONE"
done

5
.gitignore vendored
View File

@@ -29,9 +29,7 @@ yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# env # env
# env local files (keep .env.example)
.env* .env*
!.env.example
# QC # QC
QC QC
@@ -52,6 +50,9 @@ next-env.d.ts
# cache # cache
/cache /cache
.github/
.env.*
*.tar.gz *.tar.gz

View File

@@ -1,191 +0,0 @@
# Dev Inspector - Analisis & Rekomendasi untuk Project Desa Darmasaba
## 📋 Ringkasan Analisis
Dokumen `dev-inspector-click-to-source.md` **TIDAK dapat diterapkan langsung** ke project ini karena perbedaan arsitektur fundamental.
## 🔍 Perbedaan Arsitektur
| Syarat di Dokumen | Project Desa Darmasaba | Status |
|-------------------|------------------------|--------|
| **Vite sebagai bundler** | Next.js 15 (Webpack/Turbopack) | ❌ Tidak kompatibel |
| **Elysia + Vite middlewareMode** | Next.js App Router + Elysia sebagai API handler | ❌ Berbeda |
| **React** | ✅ React 19 | ✅ Kompatibel |
| **Bun runtime** | ✅ Bun | ✅ Kompatibel |
## ✅ Solusi: Next.js Sudah Punya Built-in Click-to-Source
Next.js memiliki fitur **click-to-source bawaan** yang bekerja tanpa setup tambahan:
### Cara Menggunakan
1. **Pastikan dalam development mode:**
```bash
bun run dev
```
2. **Klik elemen dengan modifier key:**
- **macOS**: `Option` + `Click` (atau `` + `Click`)
- **Windows/Linux**: `Alt` + `Click`
3. **File akan terbuka di editor** pada baris dan kolom yang tepat
### Syarat Agar Berfungsi
1. **Editor harus ada di PATH**
VS Code biasanya sudah terdaftar. Jika menggunakan editor lain, set:
```bash
# Untuk Cursor
export EDITOR=cursor
# Untuk Windsurf
export EDITOR=windsurf
# Untuk Sublime Text
export EDITOR=subl
```
2. **Hanya berfungsi di development mode**
- Fitur ini otomatis tree-shaken di production
- Zero overhead di production build
3. **Browser DevTools harus terbuka** (beberapa browser memerlukan ini)
## 🎯 Rekomendasi untuk Project Ini
### Opsi 1: Gunakan Built-in Next.js (DIREKOMENDASIKAN)
**Kelebihan:**
- ✅ Zero setup
- ✅ Maintain oleh Vercel
- ✅ Otomatis compatible dengan Next.js updates
- ✅ Zero production overhead
**Kekurangan:**
- ⚠️ Hotkey berbeda (`Option+Click` vs `Ctrl+Shift+Cmd+C`)
- ⚠️ Tidak ada visual overlay/tooltip seperti di dokumen
**Cara:**
Tidak perlu melakukan apapun - fitur sudah aktif saat `bun run dev`.
### Opsi 2: Custom Implementation (JIKA DIPERLUKAN)
Jika ingin visual overlay dan tooltip seperti di dokumen, bisa dibuat custom component dengan pendekatan berbeda:
#### Arsitektur Alternatif untuk Next.js
```
BUILD TIME (Next.js/Webpack):
.tsx/.jsx file
→ [Custom Webpack Loader] inject data-inspector-* attributes
→ [Next.js internal transform] JSX to React.createElement
→ Browser menerima elemen dengan attributes
RUNTIME (Browser):
[SAMA seperti dokumen - DevInspector component]
BACKEND (Next.js API Route):
/__open-in-editor → Bun.spawn([editor, '--goto', 'file:line:col'])
```
#### Komponen yang Dibutuhkan:
1. **Custom Webpack Loader** (bukan Vite Plugin)
- Inject attributes via webpack transform
- Taruh di `next.config.ts` webpack config
2. **DevInspector Component** (sama seperti dokumen)
- Browser runtime untuk handle hotkey & klik
3. **API Route `/__open-in-editor`**
- Buat sebagai Next.js API route: `src/app/api/__open-in-editor/route.ts`
- HARUS bypass auth middleware
4. **Conditional Import** (sama seperti dokumen)
```tsx
const InspectorWrapper = process.env.NODE_ENV === 'development'
? (await import('./DevInspector')).DevInspector
: ({ children }) => <>{children}</>
```
#### Implementasi Steps:
Jika Anda ingin melanjutkan dengan custom implementation, berikut steps:
1. ✅ Buat `src/components/DevInspector.tsx` (copy dari dokumen)
2. ⚠️ Buat webpack loader untuk inject attributes (perlu research)
3. ✅ Buat API route `src/app/api/__open-in-editor/route.ts`
4. ✅ Wrap root layout dengan DevInspector
5. ✅ Set `REACT_EDITOR` di `.env`
**Peringatan:**
- Webpack loader lebih kompleks daripada Vite plugin
- Mungkin ada edge cases dengan Next.js internals
- Perlu maintenance ekstra saat Next.js update
## 📊 Perbandingan
| Fitur | Built-in Next.js | Custom Implementation |
|-------|------------------|----------------------|
| Setup | ✅ Zero | ⚠️ Medium |
| Visual Overlay | ❌ Tidak ada | ✅ Ada |
| Tooltip | ❌ Tidak ada | ✅ Ada |
| Hotkey | `Option+Click` | Custom (bisa disesuaikan) |
| Maintenance | ✅ Vercel | ⚠️ Manual |
| Compatibility | ✅ Guaranteed | ⚠️ Perlu testing |
| Production Impact | ✅ Zero | ✅ Zero (dengan conditional import) |
## 🎯 Kesimpulan
**Rekomendasi: Gunakan Built-in Next.js**
Alasan:
1. ✅ Sudah tersedia - tidak perlu setup
2. ✅ Lebih stabil - maintain oleh Vercel
3. ✅ Lebih simple - tidak ada custom code
4. ✅ Future-proof - otomatis update dengan Next.js
**Custom implementation hanya diperlukan jika:**
- Anda sangat membutuhkan visual overlay & tooltip
- Anda ingin hotkey yang sama persis (`Ctrl+Shift+Cmd+C`)
- Anda punya waktu untuk maintenance
## 🚀 Quick Start - Built-in Feature
Untuk menggunakan click-to-source bawaan Next.js:
1. Jalankan development server:
```bash
bun run dev
```
2. Buka browser ke `http://localhost:3000`
3. Tahan `Option` (macOS) atau `Alt` (Windows/Linux)
4. Cursor akan berubah menjadi crosshair
5. Klik elemen mana pun - file akan terbuka di editor
6. **Opsional**: Set editor di `.env`:
```env
# .env.local
EDITOR=code # atau cursor, windsurf, subl
```
## 📝 Notes
- Fitur ini hanya aktif di development mode (`NODE_ENV=development`)
- Production build (`bun run build`) otomatis menghilangkan fitur ini
- Next.js menggunakan mekanisme yang mirip (source mapping) untuk menentukan lokasi component
- Jika editor tidak terbuka, pastikan:
- Editor sudah terinstall dan ada di PATH
- Browser DevTools terbuka (beberapa browser require ini)
- Anda menggunakan development server, bukan production
## 🔗 Referensi
- [Next.js Documentation - Launching Editor](https://nextjs.org/docs/app/api-reference/config/next-config-js/reactStrictMode)
- [React DevTools - Component Inspection](https://react.dev/learn/react-developer-tools)
- [Original Dev Inspector Document](./dev-inspector-click-to-source.md)

View File

@@ -1,68 +0,0 @@
# ==============================
# Stage 1: Builder
# ==============================
FROM oven/bun:1-debian AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libc6 \
git \
openssl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package.json bun.lockb* ./
ENV SHARP_IGNORE_GLOBAL_LIBVIPS=1
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_OPTIONS="--max-old-space-size=4096"
RUN bun install --frozen-lockfile
COPY . .
RUN cp .env.example .env || true
ENV PRISMA_CLI_BINARY_TARGETS=debian-openssl-3.0.x
RUN bunx prisma generate
# Generate API types (opsional)
RUN bun run gen:api || echo "tidak ada gen api"
RUN bun run build
# ==============================
# Stage 2: Runner (Production)
# ==============================
FROM oven/bun:1-debian AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PRISMA_CLI_BINARY_TARGETS=debian-openssl-3.0.x
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
RUN apt-get update && apt-get install -y --no-install-recommends \
openssl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs nextjs
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/src/prisma ./src/prisma
COPY --from=builder --chown=nextjs:nodejs /app/next.config.* ./
USER nextjs
EXPOSE 3000
CMD ["bun", "start"]

View File

@@ -1,173 +0,0 @@
# 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

2175
bun.lock

File diff suppressed because it is too large Load Diff

BIN
bun.lockb Executable file

Binary file not shown.

View File

@@ -1,553 +0,0 @@
# Skill: Dev Inspector — Click-to-Source untuk Bun + Elysia + Vite + React
## Ringkasan
Fitur development: klik elemen UI di browser → langsung buka source code di editor (VS Code, Cursor, dll) pada baris dan kolom yang tepat. Zero overhead di production.
**Hotkey**: `Ctrl+Shift+Cmd+C` (macOS) / `Ctrl+Shift+Alt+C` → aktifkan mode inspect → klik elemen → file terbuka.
## Kenapa Tidak Pakai Library
`react-dev-inspector` crash di React 19 karena:
- `fiber.return.child.sibling` bisa null di React 19
- `_debugSource` dihapus dari React 19
- Walking fiber tree tidak stabil antar versi React
Solusi ini **regex-based + multi-fallback**, tidak bergantung pada React internals.
## Syarat Arsitektur
Fitur ini bekerja karena 4 syarat struktural terpenuhi. Jika salah satu tidak ada, fitur tidak bisa diimplementasi atau perlu adaptasi signifikan.
### 1. Vite sebagai Bundler (Wajib)
Seluruh mekanisme bergantung pada **Vite plugin transform pipeline**:
- `inspectorPlugin()` inject attributes ke JSX saat build/HMR
- `enforce: 'pre'` memastikan plugin jalan sebelum OXC/Babel transform JSX
- `import.meta.env?.DEV` sebagai compile-time constant untuk tree-shaking
**Tidak bisa diganti dengan**: esbuild standalone, webpack (perlu loader berbeda), SWC standalone.
**Bisa diganti dengan**: framework yang pakai Vite di dalamnya (Remix Vite, TanStack Start, Astro).
### 2. Server dan Frontend dalam Satu Proses (Wajib)
Endpoint `/__open-in-editor` harus **satu proses dengan dev server** yang melayani frontend:
- Browser POST ke origin yang sama (no CORS)
- Server punya akses ke filesystem lokal untuk `Bun.spawn(editor)`
- Endpoint harus bisa ditangani **sebelum routing & middleware** (auth, tenant, dll)
**Pola yang memenuhi syarat:**
- Elysia + Vite middlewareMode (project ini) — `onRequest` intercept sebelum route matching
- Express/Fastify + Vite middlewareMode — middleware biasa sebelum auth
- Vite dev server standalone (`vite dev`) — pakai `configureServer` hook
**Tidak memenuhi syarat:**
- Frontend dan backend di proses/port terpisah (misal: CRA + separate API server) — perlu proxy atau CORS config tambahan
- Serverless/edge deployment — tidak bisa `spawn` editor
### 3. React sebagai UI Framework (Wajib untuk Multi-Fallback)
Strategi extraction source info bergantung pada React internals:
1. `__reactProps$*` — React menyimpan props di DOM element
2. `__reactFiber$*` — React fiber tree untuk walk-up
3. DOM attribute — fallback universal
**Jika pakai framework lain** (Vue, Svelte, Solid):
- Hanya strategi 3 (DOM attribute) yang berfungsi — tetap cukup
- Hapus strategi 1 & 2 dari `getCodeInfoFromElement()`
- Inject attributes tetap via Vite plugin (framework-agnostic)
### 4. Bun sebagai Runtime (Direkomendasikan, Bukan Wajib)
Bun memberikan API yang lebih clean:
- `Bun.spawn()` — fire-and-forget tanpa import
- `Bun.which()` — cek executable ada di PATH (mencegah uncatchable error)
**Jika pakai Node.js:**
- `Bun.spawn()``child_process.spawn(editor, args, { detached: true, stdio: 'ignore' }).unref()`
- `Bun.which()``const which = require('which'); which.sync(editor, { nothrow: true })`
### Ringkasan Syarat
| Syarat | Wajib? | Alternatif |
|-------------------------------|----------|------------------------------------------------------|
| Vite sebagai bundler | Ya | Framework berbasis Vite (Remix, Astro, dll) |
| Server + frontend satu proses | Ya | Bisa diakali dengan proxy, tapi tambah kompleksitas |
| React | Sebagian | Framework lain bisa, hanya fallback ke DOM attribute |
| Bun runtime | Tidak | Node.js dengan `child_process` + `which` package |
## Arsitektur
```
BUILD TIME (Vite Plugin):
.tsx/.jsx file
→ [inspectorPlugin enforce:'pre'] inject data-inspector-* attributes ke JSX
→ [react() OXC] transform JSX ke createElement
→ Browser menerima elemen dengan attributes
RUNTIME (Browser):
Hotkey → aktifkan mode → hover elemen → baca attributes → klik
→ POST /__open-in-editor {relativePath, line, column}
BACKEND (Elysia onRequest):
/__open-in-editor → Bun.spawn([editor, '--goto', 'file:line:col'])
→ Editor terbuka di lokasi tepat
```
## Komponen yang Dibutuhkan
### 1. Vite Plugin — `inspectorPlugin()` (enforce: 'pre')
Inject `data-inspector-*` ke setiap JSX opening tag via regex.
**HARUS `enforce: 'pre'`** — kalau tidak, OXC transform JSX duluan dan regex tidak bisa menemukan `<Component`.
```typescript
// Taruh di file vite config (misal: src/vite.ts atau vite.config.ts)
import path from 'node:path'
import type { Plugin } from 'vite'
function inspectorPlugin(): Plugin {
const rootDir = process.cwd()
return {
name: 'inspector-inject',
enforce: 'pre',
transform(code, id) {
// Hanya .tsx/.jsx, skip node_modules
if (!/\.[jt]sx(\?|$)/.test(id) || id.includes('node_modules')) return null
if (!code.includes('<')) return null
const relativePath = path.relative(rootDir, id)
let modified = false
const lines = code.split('\n')
const result: string[] = []
for (let i = 0; i < lines.length; i++) {
let line = lines[i]
// Match JSX opening tags: <Component atau <div
// Skip TypeScript generics (Record<string>) via charBefore check
const jsxPattern = /(<(?:[A-Z][a-zA-Z0-9.]*|[a-z][a-zA-Z0-9-]*))\b/g
let match: RegExpExecArray | null = null
while ((match = jsxPattern.exec(line)) !== null) {
// Skip jika karakter sebelum `<` adalah identifier char (TypeScript generic)
const charBefore = match.index > 0 ? line[match.index - 1] : ''
if (/[a-zA-Z0-9_$.]/.test(charBefore)) continue
const col = match.index + 1
const attr = ` data-inspector-line="${i + 1}" data-inspector-column="${col}" data-inspector-relative-path="${relativePath}"`
const insertPos = match.index + match[0].length
line = line.slice(0, insertPos) + attr + line.slice(insertPos)
modified = true
jsxPattern.lastIndex += attr.length
}
result.push(line)
}
if (!modified) return null
return result.join('\n')
},
}
}
```
**Mengapa regex, bukan Babel?**
- `@vitejs/plugin-react` v6+ pakai OXC (Rust), bukan Babel
- Config `babel: { plugins: [...] }` di plugin-react **DIABAIKAN**
- Regex jalan sebelum OXC via `enforce: 'pre'`
**Gotcha: TypeScript generics**
- `Record<string>` → karakter sebelum `<` adalah `d` (identifier) → SKIP
- `<Button` → karakter sebelum `<` adalah space/newline → MATCH
### 2. Vite Plugin Order (KRITIS)
```typescript
plugins: [
// 1. Route generation (jika pakai TanStack Router)
TanStackRouterVite({ ... }),
// 2. Inspector inject — HARUS sebelum react()
inspectorPlugin(),
// 3. React OXC transform
react(),
// 4. (Opsional) Dedupe React Refresh untuk middlewareMode
dedupeRefreshPlugin(),
]
```
**Jika urutan salah (inspectorPlugin setelah react):**
- OXC transform `<Button>``React.createElement(Button, ...)`
- Regex tidak menemukan `<Button` → attributes TIDAK ter-inject
- Fitur tidak berfungsi, tanpa error
### 3. DevInspector Component (Browser Runtime)
Komponen React yang handle hotkey, overlay, dan klik.
```tsx
// src/frontend/DevInspector.tsx
import { useCallback, useEffect, useRef, useState } from 'react'
interface CodeInfo {
relativePath: string
line: string
column: string
}
/** Baca data-inspector-* dari fiber props atau DOM attributes */
function getCodeInfoFromElement(element: HTMLElement): CodeInfo | null {
// Strategi 1: React internal props __reactProps$ (paling akurat)
for (const key of Object.keys(element)) {
if (key.startsWith('__reactProps$')) {
const props = (element as any)[key]
if (props?.['data-inspector-relative-path']) {
return {
relativePath: props['data-inspector-relative-path'],
line: props['data-inspector-line'] || '1',
column: props['data-inspector-column'] || '1',
}
}
}
// Strategi 2: Walk fiber tree __reactFiber$
if (key.startsWith('__reactFiber$')) {
const fiber = (element as any)[key]
let f = fiber
while (f) {
const p = f.pendingProps || f.memoizedProps
if (p?.['data-inspector-relative-path']) {
return {
relativePath: p['data-inspector-relative-path'],
line: p['data-inspector-line'] || '1',
column: p['data-inspector-column'] || '1',
}
}
// Fallback: _debugSource (React < 19)
const src = f._debugSource ?? f._debugOwner?._debugSource
if (src?.fileName && src?.lineNumber) {
return {
relativePath: src.fileName,
line: String(src.lineNumber),
column: String(src.columnNumber ?? 1),
}
}
f = f.return
}
}
}
// Strategi 3: Fallback DOM attribute langsung
const rp = element.getAttribute('data-inspector-relative-path')
if (rp) {
return {
relativePath: rp,
line: element.getAttribute('data-inspector-line') || '1',
column: element.getAttribute('data-inspector-column') || '1',
}
}
return null
}
/** Walk up DOM tree sampai ketemu elemen yang punya source info */
function findCodeInfo(target: HTMLElement): CodeInfo | null {
let el: HTMLElement | null = target
while (el) {
const info = getCodeInfoFromElement(el)
if (info) return info
el = el.parentElement
}
return null
}
function openInEditor(info: CodeInfo) {
fetch('/__open-in-editor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
relativePath: info.relativePath,
lineNumber: info.line,
columnNumber: info.column,
}),
})
}
export function DevInspector({ children }: { children: React.ReactNode }) {
const [active, setActive] = useState(false)
const overlayRef = useRef<HTMLDivElement | null>(null)
const tooltipRef = useRef<HTMLDivElement | null>(null)
const lastInfoRef = useRef<CodeInfo | null>(null)
const updateOverlay = useCallback((target: HTMLElement | null) => {
const ov = overlayRef.current
const tt = tooltipRef.current
if (!ov || !tt) return
if (!target) {
ov.style.display = 'none'
tt.style.display = 'none'
lastInfoRef.current = null
return
}
const info = findCodeInfo(target)
if (!info) {
ov.style.display = 'none'
tt.style.display = 'none'
lastInfoRef.current = null
return
}
lastInfoRef.current = info
const rect = target.getBoundingClientRect()
ov.style.display = 'block'
ov.style.top = `${rect.top + window.scrollY}px`
ov.style.left = `${rect.left + window.scrollX}px`
ov.style.width = `${rect.width}px`
ov.style.height = `${rect.height}px`
tt.style.display = 'block'
tt.textContent = `${info.relativePath}:${info.line}`
const ttTop = rect.top + window.scrollY - 24
tt.style.top = `${ttTop > 0 ? ttTop : rect.bottom + window.scrollY + 4}px`
tt.style.left = `${rect.left + window.scrollX}px`
}, [])
// Activate/deactivate event listeners
useEffect(() => {
if (!active) return
const onMouseOver = (e: MouseEvent) => updateOverlay(e.target as HTMLElement)
const onClick = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
const info = lastInfoRef.current ?? findCodeInfo(e.target as HTMLElement)
if (info) {
const loc = `${info.relativePath}:${info.line}:${info.column}`
console.log('[DevInspector] Open:', loc)
navigator.clipboard.writeText(loc)
openInEditor(info)
}
setActive(false)
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setActive(false)
}
document.addEventListener('mouseover', onMouseOver, true)
document.addEventListener('click', onClick, true)
document.addEventListener('keydown', onKeyDown)
document.body.style.cursor = 'crosshair'
return () => {
document.removeEventListener('mouseover', onMouseOver, true)
document.removeEventListener('click', onClick, true)
document.removeEventListener('keydown', onKeyDown)
document.body.style.cursor = ''
if (overlayRef.current) overlayRef.current.style.display = 'none'
if (tooltipRef.current) tooltipRef.current.style.display = 'none'
}
}, [active, updateOverlay])
// Hotkey: Ctrl+Shift+Cmd+C (macOS) / Ctrl+Shift+Alt+C
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === 'c' && e.ctrlKey && e.shiftKey && (e.metaKey || e.altKey)) {
e.preventDefault()
setActive((prev) => !prev)
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [])
return (
<>
{children}
<div
ref={overlayRef}
style={{
display: 'none',
position: 'absolute',
pointerEvents: 'none',
border: '2px solid #3b82f6',
backgroundColor: 'rgba(59,130,246,0.1)',
zIndex: 99999,
transition: 'all 0.05s ease',
}}
/>
<div
ref={tooltipRef}
style={{
display: 'none',
position: 'absolute',
pointerEvents: 'none',
backgroundColor: '#1e293b',
color: '#e2e8f0',
fontSize: '12px',
fontFamily: 'monospace',
padding: '2px 6px',
borderRadius: '3px',
zIndex: 100000,
whiteSpace: 'nowrap',
}}
/>
</>
)
}
```
### 4. Backend Endpoint — `/__open-in-editor`
**HARUS ditangani di `onRequest` / sebelum middleware**, bukan sebagai route biasa. Kalau jadi route, akan kena auth middleware dan gagal.
```typescript
// Di entry point server (src/index.tsx), dalam onRequest handler:
if (!isProduction && pathname === '/__open-in-editor' && request.method === 'POST') {
const { relativePath, lineNumber, columnNumber } = (await request.json()) as {
relativePath: string
lineNumber: string
columnNumber: string
}
const file = `${process.cwd()}/${relativePath}`
const editor = process.env.REACT_EDITOR || 'code'
const loc = `${file}:${lineNumber}:${columnNumber}`
const args = editor === 'subl' ? [loc] : ['--goto', loc]
const editorPath = Bun.which(editor)
console.log(`[inspector] ${editor}${editorPath ?? 'NOT FOUND'}${loc}`)
if (editorPath) {
Bun.spawn([editor, ...args], { stdio: ['ignore', 'ignore', 'ignore'] })
} else {
console.error(`[inspector] Editor "${editor}" not found in PATH. Set REACT_EDITOR in .env`)
}
return new Response('ok')
}
```
**Penting — `Bun.which()` sebelum `Bun.spawn()`:**
- `Bun.spawn()` throw native error yang TIDAK bisa di-catch jika executable tidak ada
- `Bun.which()` return null dengan aman → cek dulu sebelum spawn
**Editor yang didukung:**
| REACT_EDITOR | Editor | Args |
|------------------|--------------|--------------------------------|
| `code` (default) | VS Code | `--goto file:line:col` |
| `cursor` | Cursor | `--goto file:line:col` |
| `windsurf` | Windsurf | `--goto file:line:col` |
| `subl` | Sublime Text | `file:line:col` (tanpa --goto) |
### 5. Frontend Entry — Conditional Import (Zero Production Overhead)
```tsx
// src/frontend.tsx (atau entry point React)
import type { ReactNode } from 'react'
const InspectorWrapper = import.meta.env?.DEV
? (await import('./frontend/DevInspector')).DevInspector
: ({ children }: { children: ReactNode }) => <>{children}</>
const app = (
<InspectorWrapper>
<App />
</InspectorWrapper>
)
```
**Bagaimana zero overhead tercapai:**
- `import.meta.env?.DEV` adalah compile-time constant
- Production build: `false` → dynamic import TIDAK dieksekusi
- Tree-shaking menghapus seluruh `DevInspector.tsx` dari bundle
- Tidak ada runtime check, tidak ada dead code di bundle
### 6. (Opsional) Dedupe React Refresh — Workaround Vite middlewareMode
Jika pakai Vite dalam `middlewareMode` (seperti di Elysia/Express), `@vitejs/plugin-react` v6 bisa inject React Refresh footer dua kali → error "already declared".
```typescript
function dedupeRefreshPlugin(): Plugin {
return {
name: 'dedupe-react-refresh',
enforce: 'post',
transform(code, id) {
if (!/\.[jt]sx(\?|$)/.test(id) || id.includes('node_modules')) return null
const marker = 'import * as RefreshRuntime from "/@react-refresh"'
const firstIdx = code.indexOf(marker)
if (firstIdx === -1) return null
const secondIdx = code.indexOf(marker, firstIdx + marker.length)
if (secondIdx === -1) return null
const sourcemapIdx = code.indexOf('\n//# sourceMappingURL=', secondIdx)
const endIdx = sourcemapIdx !== -1 ? sourcemapIdx : code.length
const cleaned = code.slice(0, secondIdx) + code.slice(endIdx)
return { code: cleaned, map: null }
},
}
}
```
## Langkah Implementasi di Project Baru
### Prasyarat
- Runtime: Bun
- Server: Elysia (atau framework lain dengan onRequest/beforeHandle)
- Frontend: React + Vite
- `@vitejs/plugin-react` (OXC)
### Step-by-step
1. **Buat `DevInspector.tsx`** — copy komponen dari Bagian 3 ke folder frontend
2. **Tambah `inspectorPlugin()`** — copy fungsi dari Bagian 1 ke file vite config
3. **Atur plugin order**`inspectorPlugin()` SEBELUM `react()` (Bagian 2)
4. **Tambah endpoint `/__open-in-editor`** — di `onRequest` handler (Bagian 4)
5. **Wrap root app** — conditional import di entry point (Bagian 5)
6. **Set env**`REACT_EDITOR=code` (atau cursor/windsurf/subl) di `.env`
7. **(Opsional)** Tambah `dedupeRefreshPlugin()` jika pakai Vite `middlewareMode`
### Checklist Verifikasi
- [ ] `inspectorPlugin` punya `enforce: 'pre'`
- [ ] Plugin order: inspector → react (bukan sebaliknya)
- [ ] Endpoint `/__open-in-editor` di LUAR middleware auth
- [ ] `Bun.which(editor)` dipanggil SEBELUM `Bun.spawn()`
- [ ] Conditional import pakai `import.meta.env?.DEV`
- [ ] `REACT_EDITOR` di `.env` sesuai editor yang dipakai
- [ ] Hotkey berfungsi: `Ctrl+Shift+Cmd+C` / `Ctrl+Shift+Alt+C`
## Gotcha & Pelajaran
| Masalah | Penyebab | Solusi |
|----------------------------------|---------------------------------------------|-----------------------------------------------|
| Attributes tidak ter-inject | Plugin order salah | `enforce: 'pre'`, taruh sebelum `react()` |
| `Record<string>` ikut ter-inject | Regex match TypeScript generics | Cek `charBefore` — skip jika identifier char |
| `Bun.spawn` crash | Editor tidak ada di PATH | Selalu `Bun.which()` dulu |
| Hotkey tidak response | `e.key` return 'C' (uppercase) karena Shift | Pakai `e.key.toLowerCase()` |
| React Refresh duplicate | Vite middlewareMode bug | `dedupeRefreshPlugin()` enforce: 'post' |
| Endpoint kena auth middleware | Didaftarkan sebagai route biasa | Tangani di `onRequest` sebelum routing |
| `_debugSource` undefined | React 19 menghapusnya | Multi-fallback: reactProps → fiber → DOM attr |
## Adaptasi untuk Framework Lain
### Express/Fastify (bukan Elysia)
- Endpoint `/__open-in-editor`: gunakan middleware biasa SEBELUM auth
- `Bun.spawn``child_process.spawn` jika pakai Node.js
- `Bun.which``which` npm package jika pakai Node.js
### Next.js
- Tidak perlu — Next.js punya built-in click-to-source
- Tapi jika ingin custom: taruh endpoint di `middleware.ts`, plugin di `next.config.js`
### Remix/Tanstack Start (SSR)
- Plugin tetap sama (Vite-based)
- Endpoint perlu di server entry, bukan di route loader

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,6 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
serverExternalPackages: ['@elysiajs/static', 'elysia'],
experimental: {}, experimental: {},
allowedDevOrigins: [ allowedDevOrigins: [
"http://192.168.1.82:3000", // buat akses dari HP/device lain "http://192.168.1.82:3000", // buat akses dari HP/device lain

View File

@@ -8,8 +8,7 @@
"start": "next start", "start": "next start",
"test:api": "vitest run", "test:api": "vitest run",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test": "bun run test:api && bun run test:e2e", "test": "bun run test:api && bun run test:e2e"
"gen:api": ""
}, },
"prisma": { "prisma": {
"seed": "bun run prisma/seed.ts" "seed": "bun run prisma/seed.ts"
@@ -71,7 +70,7 @@
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"extract-zip": "^2.0.1", "extract-zip": "^2.0.1",
"form-data": "^4.0.2", "form-data": "^4.0.2",
"framer-motion": "^12.38.0", "framer-motion": "^12.23.5",
"get-port": "^7.1.0", "get-port": "^7.1.0",
"iron-session": "^8.0.4", "iron-session": "^8.0.4",
"jose": "^6.1.0", "jose": "^6.1.0",
@@ -101,7 +100,7 @@
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"react-zoom-pan-pinch": "^3.7.0", "react-zoom-pan-pinch": "^3.7.0",
"readdirp": "^4.1.1", "readdirp": "^4.1.1",
"recharts": "^3.8.0", "recharts": "^2.15.3",
"sharp": "^0.34.3", "sharp": "^0.34.3",
"swr": "^2.3.2", "swr": "^2.3.2",
"uuid": "^11.1.0", "uuid": "^11.1.0",
@@ -121,7 +120,7 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitest/ui": "^4.0.18", "@vitest/ui": "^4.0.18",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.12", "eslint-config-next": "15.1.6",
"jsdom": "^28.0.0", "jsdom": "^28.0.0",
"msw": "^2.12.9", "msw": "^2.12.9",
"parcel": "^2.6.2", "parcel": "^2.6.2",

View File

@@ -1,94 +0,0 @@
/*
Warnings:
- You are about to drop the column `realisasi` on the `APBDesItem` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "APBDesItem" DROP COLUMN "realisasi",
ADD COLUMN "totalRealisasi" DOUBLE PRECISION NOT NULL DEFAULT 0,
ALTER COLUMN "selisih" SET DEFAULT 0,
ALTER COLUMN "persentase" SET DEFAULT 0;
-- AlterTable
ALTER TABLE "Berita" ADD COLUMN "linkVideo" VARCHAR(500);
-- CreateTable
CREATE TABLE "RealisasiItem" (
"id" TEXT NOT NULL,
"kode" TEXT,
"apbdesItemId" TEXT NOT NULL,
"jumlah" DOUBLE PRECISION NOT NULL,
"tanggal" DATE NOT NULL,
"keterangan" TEXT,
"buktiFileId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "RealisasiItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MusikDesa" (
"id" TEXT NOT NULL,
"judul" VARCHAR(255) NOT NULL,
"artis" VARCHAR(255) NOT NULL,
"deskripsi" TEXT,
"durasi" VARCHAR(20) NOT NULL,
"audioFileId" TEXT,
"coverImageId" TEXT,
"genre" VARCHAR(100),
"tahunRilis" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "MusikDesa_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_BeritaImages" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_BeritaImages_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE INDEX "RealisasiItem_kode_idx" ON "RealisasiItem"("kode");
-- CreateIndex
CREATE INDEX "RealisasiItem_apbdesItemId_idx" ON "RealisasiItem"("apbdesItemId");
-- CreateIndex
CREATE INDEX "RealisasiItem_tanggal_idx" ON "RealisasiItem"("tanggal");
-- CreateIndex
CREATE INDEX "MusikDesa_judul_idx" ON "MusikDesa"("judul");
-- CreateIndex
CREATE INDEX "MusikDesa_artis_idx" ON "MusikDesa"("artis");
-- CreateIndex
CREATE INDEX "_BeritaImages_B_index" ON "_BeritaImages"("B");
-- CreateIndex
CREATE INDEX "Berita_kategoriBeritaId_idx" ON "Berita"("kategoriBeritaId");
-- AddForeignKey
ALTER TABLE "RealisasiItem" ADD CONSTRAINT "RealisasiItem_apbdesItemId_fkey" FOREIGN KEY ("apbdesItemId") REFERENCES "APBDesItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MusikDesa" ADD CONSTRAINT "MusikDesa_audioFileId_fkey" FOREIGN KEY ("audioFileId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MusikDesa" ADD CONSTRAINT "MusikDesa_coverImageId_fkey" FOREIGN KEY ("coverImageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_BeritaImages" ADD CONSTRAINT "_BeritaImages_A_fkey" FOREIGN KEY ("A") REFERENCES "Berita"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_BeritaImages" ADD CONSTRAINT "_BeritaImages_B_fkey" FOREIGN KEY ("B") REFERENCES "FileStorage"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually # Please do not edit this file manually
# It should be added in your version-control system (e.g., Git) # It should be added in your version-control system (e.g., Git)
provider = "postgresql" provider = "postgresql"

View File

@@ -211,9 +211,6 @@ function ListKategoriPrestasi({ search }: { search: string }) {
</Stack> </Stack>
</Box> </Box>
{/* Modal Konfirmasi Hapus */} {/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus <ModalKonfirmasiHapus
opened={modalHapus} opened={modalHapus}

View File

@@ -123,51 +123,37 @@ export default function CreateMusik() {
setIsSubmitting(true); setIsSubmitting(true);
// Upload cover image // Upload cover image
console.log('Uploading cover image:', coverFile.name);
const coverRes = await ApiFetch.api.fileStorage.create.post({ const coverRes = await ApiFetch.api.fileStorage.create.post({
file: coverFile, file: coverFile,
name: coverFile.name, name: coverFile.name,
}); });
console.log('Cover upload response:', coverRes);
const coverUploaded = coverRes.data?.data; const coverUploaded = coverRes.data?.data;
if (!coverUploaded?.id) { if (!coverUploaded?.id) {
console.error('Cover upload failed:', coverRes); return toast.error('Gagal mengunggah cover, silakan coba lagi');
toast.error('Gagal mengunggah cover, silakan coba lagi');
return;
} }
musikState.musik.create.form.coverImageId = coverUploaded.id; musikState.musik.create.form.coverImageId = coverUploaded.id;
// Upload audio file // Upload audio file
console.log('Uploading audio file:', audioFile.name);
const audioRes = await ApiFetch.api.fileStorage.create.post({ const audioRes = await ApiFetch.api.fileStorage.create.post({
file: audioFile, file: audioFile,
name: audioFile.name, name: audioFile.name,
}); });
console.log('Audio upload response:', audioRes);
const audioUploaded = audioRes.data?.data; const audioUploaded = audioRes.data?.data;
if (!audioUploaded?.id) { if (!audioUploaded?.id) {
console.error('Audio upload failed:', audioRes); return toast.error('Gagal mengunggah audio, silakan coba lagi');
toast.error('Gagal mengunggah audio, silakan coba lagi');
return;
} }
musikState.musik.create.form.audioFileId = audioUploaded.id; 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(); await musikState.musik.create.create();
resetForm(); resetForm();
router.push('/admin/musik'); router.push('/admin/musik');
} catch (error) { } catch (error) {
console.error('Error creating musik:', { console.error('Error creating musik:', error);
error,
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
});
toast.error('Terjadi kesalahan saat membuat musik'); toast.error('Terjadi kesalahan saat membuat musik');
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,16 +47,15 @@ fs.mkdir(UPLOAD_DIR_IMAGE, {
const corsConfig = { const corsConfig = {
origin: [ origin: [
"*", // Allow all origins - must be first when using credentials: true
"http://localhost:3000", "http://localhost:3000",
"http://localhost:3001", "http://localhost:3001",
"https://cld-dkr-desa-darmasaba-stg.wibudev.com", "https://cld-dkr-desa-darmasaba-stg.wibudev.com",
"https://cld-dkr-staging-desa-darmasaba.wibudev.com", "https://cld-dkr-staging-desa-darmasaba.wibudev.com",
"https://desa-darmasaba-stg.wibudev.com", "*", // Allow all origins in development
], ],
methods: ["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] as HTTPMethod[], methods: ["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] as HTTPMethod[],
allowedHeaders: ["Content-Type", "Authorization", "Accept", "*"], allowedHeaders: ["Content-Type", "Authorization", "*"],
exposedHeaders: ["Content-Range", "X-Content-Range", "*"], exposedHeaders: "*",
maxAge: 86400, // 24 hours maxAge: 86400, // 24 hours
credentials: true, credentials: true,
}; };
@@ -67,7 +66,7 @@ async function layanan() {
} }
const Utils = new Elysia({ const Utils = new Elysia({
prefix: "/utils", prefix: "/api/utils",
tags: ["Utils"], tags: ["Utils"],
}).get("/version", async () => { }).get("/version", async () => {
const packageJson = await fs.readFile( const packageJson = await fs.readFile(
@@ -81,7 +80,8 @@ const Utils = new Elysia({
if (!process.env.WIBU_UPLOAD_DIR) if (!process.env.WIBU_UPLOAD_DIR)
throw new Error("WIBU_UPLOAD_DIR is not defined"); throw new Error("WIBU_UPLOAD_DIR is not defined");
const ApiServer = new Elysia({ prefix: "/api" }) const ApiServer = new Elysia()
.use(swagger({ path: "/api/docs" }))
.use( .use(
staticPlugin({ staticPlugin({
assets: UPLOAD_DIR, assets: UPLOAD_DIR,
@@ -89,25 +89,6 @@ const ApiServer = new Elysia({ prefix: "/api" })
}), }),
) )
.use(cors(corsConfig)) .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(Utils)
.use(FileStorage) .use(FileStorage)
.use(LandingPage) .use(LandingPage)
@@ -122,114 +103,126 @@ const ApiServer = new Elysia({ prefix: "/api" })
.use(User) .use(User)
.use(Role) .use(Role)
.use(Search) .use(Search)
.get("/layanan", layanan)
.get("/potensi", getPotensi) .onError(({ code }) => {
.get( if (code === "NOT_FOUND") {
"/img/:name", return {
({ params, query }) => { status: 404,
return img({ body: "Route not found :(",
name: params.name, };
UPLOAD_DIR_IMAGE, }
ROOT, })
size: query.size, .group("/api", (app) =>
}); app
}, .get("/layanan", layanan)
{ .get("/potensi", getPotensi)
params: t.Object({ .get(
name: t.String(), "/img/:name",
}), ({ params, query }) => {
query: t.Optional( return img({
t.Object({ name: params.name,
size: t.Optional(t.Number()), 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(),
}),
},
), ),
},
)
.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; export const GET = ApiServer.handle;

View File

@@ -1,32 +0,0 @@
// app/api/auth/_lib/sendCodeOtp.ts
const sendCodeOtp = async ({
nomor,
codeOtp,
newMessage,
}: {
nomor: string;
codeOtp?: string | number;
newMessage?: string;
}) => {
const msg =
newMessage ||
`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 enCode = msg;
const res = await fetch(`https://otp.wibudev.com/api/wa/send-text`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.WA_SERVER_TOKEN}`,
},
body: JSON.stringify({
number: nomor,
text: enCode,
}),
});
return res;
};
export { sendCodeOtp };

View File

@@ -2,10 +2,16 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { randomOTP } from "../_lib/randomOTP"; import { randomOTP } from "../_lib/randomOTP";
import { sendCodeOtp } from "../_lib/sendCodeOtp";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
export async function POST(req: Request) { export async function POST(req: Request) {
if (req.method !== "POST") {
return NextResponse.json(
{ success: false, message: "Method Not Allowed" },
{ status: 405 }
);
}
try { try {
const { nomor } = await req.json(); const { nomor } = await req.json();
@@ -27,34 +33,32 @@ export async function POST(req: Request) {
const codeOtp = randomOTP(); const codeOtp = randomOTP();
const otpNumber = Number(codeOtp); 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)}`;
console.log("🔍 Debug WA URL:", waUrl);
try { try {
const waResponse = await sendCodeOtp({ const res = await fetch(waUrl);
nomor, const sendWa = await res.json();
codeOtp, console.log("📱 WA Response:", sendWa);
});
if (!waResponse.ok) { if (sendWa.status !== "success") {
console.error( console.error("❌ WA Service Error:", sendWa);
`⚠️ WA Service HTTP Error: ${waResponse.status} ${waResponse.statusText}. Continuing since OTP is logged.` return NextResponse.json(
{
success: false,
message: "Gagal mengirim OTP via WhatsApp",
debug: sendWa
},
{ status: 400 }
); );
console.log(`💡 Use this OTP to login: ${codeOtp}`);
} else {
const sendWa = await waResponse.json();
console.log("📱 WA Response:", sendWa);
if (sendWa.status !== "success") {
console.error("⚠️ WA Service Logic Error:", sendWa);
}
} }
} catch (waError: unknown) { } catch (waError) {
const errorMessage = console.error("❌ Fetch WA Error:", waError);
waError instanceof Error ? waError.message : String(waError); return NextResponse.json(
{ success: false, message: "Terjadi kesalahan saat mengirim WA" },
console.error( { status: 500 }
"⚠️ WA Connection Exception. Continuing since OTP is logged.",
errorMessage
); );
} }
@@ -63,12 +67,12 @@ export async function POST(req: Request) {
}); });
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.set("auth_flow", "login", { cookieStore.set('auth_flow', 'login', {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === 'production',
sameSite: "lax", sameSite: 'lax',
maxAge: 60 * 5, maxAge: 60 * 5, // 5 menit
path: "/", path: '/'
}); });
return NextResponse.json({ return NextResponse.json({
@@ -86,12 +90,11 @@ export async function POST(req: Request) {
} }
} catch (error) { } catch (error) {
console.error("❌ Error Login:", error); console.error("❌ Error Login:", error);
return NextResponse.json( return NextResponse.json(
{ success: false, message: "Terjadi kesalahan saat login" }, { success: false, message: "Terjadi kesalahan saat login" },
{ status: 500 } { status: 500 }
); );
} finally { } finally {
await prisma.$disconnect(); await prisma.$disconnect();
} }
} }

View File

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

View File

@@ -17,23 +17,18 @@ export async function POST(req: Request) {
const codeOtp = randomOTP(); const codeOtp = randomOTP();
const otpNumber = Number(codeOtp); const otpNumber = Number(codeOtp);
console.log(`🔑 DEBUG RESEND OTP [${nomor}]: ${codeOtp}`);
// Kirim OTP via WhatsApp // 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 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 waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
const waRes = await fetch(waUrl);
try { const waData = await waRes.json();
const waRes = await fetch(waUrl);
if (!waRes.ok) { if (waData.status !== "success") {
console.warn(`⚠️ WA Service HTTP Error (Resend): ${waRes.status} ${waRes.statusText}. Continuing since OTP is logged.`); return NextResponse.json(
} else { { success: false, message: "Gagal mengirim OTP via WhatsApp" },
const waData = await waRes.json(); { status: 400 }
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 // Simpan OTP ke database

View File

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

View File

@@ -92,10 +92,10 @@ const MusicPlayer = () => {
} }
return ( return (
<Box px={{ base: 'md', md: 100 }} py="xl"> <Box px={{ base: 'xs', sm: 'md', md: 100 }} py="xl">
<Paper <Paper
mx="auto" mx="auto"
p="xl" p={{ base: 'md', sm: 'xl' }}
radius="lg" radius="lg"
shadow="sm" shadow="sm"
bg="white" bg="white"
@@ -105,42 +105,52 @@ const MusicPlayer = () => {
> >
<Stack gap="md"> <Stack gap="md">
<BackButton /> <BackButton />
<Group justify="space-between" mb="xl" mt={"md"}> <Flex
justify="space-between"
align={{ base: 'flex-start', sm: 'center' }}
direction={{ base: 'column', sm: 'row' }}
gap="md"
mb="xl"
mt="md"
>
<div> <div>
<Text size="32px" fw={700} c="#0B4F78">Selamat Datang Kembali</Text> <Text fz={{ base: '24px', sm: '32px' }} fw={700} c="#0B4F78" lh={1.2}>Selamat Datang Kembali</Text>
<Text size="md" c="#5A6C7D">Temukan musik favorit Anda hari ini</Text> <Text size="sm" c="#5A6C7D">Temukan musik favorit Anda hari ini</Text>
</div> </div>
<Group gap="md"> <TextInput
<TextInput placeholder="Cari lagu..."
placeholder="Cari lagu..." leftSection={<IconSearch size={18} />}
leftSection={<IconSearch size={18} />} radius="xl"
radius="xl" w={{ base: '100%', sm: 280 }}
w={280} value={search}
value={search} onChange={(e) => setSearch(e.target.value)}
onChange={(e) => setSearch(e.target.value)} styles={{ input: { backgroundColor: '#fff' } }}
styles={{ input: { backgroundColor: '#fff' } }} />
/> </Flex>
</Group>
</Group>
<Stack gap="xl"> <Stack gap="xl">
<div> <div>
<Text size="xl" fw={700} c="#0B4F78" mb="md">Sedang Diputar</Text> <Text size="xl" fw={700} c="#0B4F78" mb="md">Sedang Diputar</Text>
{currentSong ? ( {currentSong ? (
<Card radius="md" p="xl" shadow="md"> <Card radius="md" p={{ base: 'md', sm: 'xl' }} shadow="md" withBorder>
<Group align="center" gap="xl"> <Flex
direction={{ base: 'column', sm: 'row' }}
align="center"
gap={{ base: 'md', sm: 'xl' }}
>
<Avatar <Avatar
src={currentSong.coverImage?.link || '/mp3-logo.png'} src={currentSong.coverImage?.link || '/mp3-logo.png'}
size={180} size={120}
radius="md" radius="md"
/> />
<Stack gap="md" style={{ flex: 1 }}> <Stack gap="md" style={{ flex: 1, width: '100%' }}>
<div> <Box ta={{ base: 'center', sm: 'left' }}>
<Text size="28px" fw={700} c="#0B4F78">{currentSong.judul}</Text> <Text fz={{ base: '20px', sm: '28px' }} fw={700} c="#0B4F78" lineClamp={1}>{currentSong.judul}</Text>
<Text size="lg" c="#5A6C7D">{currentSong.artis}</Text> <Text size="lg" c="#5A6C7D">{currentSong.artis}</Text>
{currentSong.genre && ( {currentSong.genre && (
<Badge mt="xs" color="#0B4F78" variant="light">{currentSong.genre}</Badge> <Badge mt="xs" color="#0B4F78" variant="light">{currentSong.genre}</Badge>
)} )}
</div> </Box>
<Group gap="xs" align="center"> <Group gap="xs" align="center">
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text> <Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text>
<Slider <Slider
@@ -155,7 +165,7 @@ const MusicPlayer = () => {
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration || 0)}</Text> <Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration || 0)}</Text>
</Group> </Group>
</Stack> </Stack>
</Group> </Flex>
</Card> </Card>
) : ( ) : (
<Card radius="md" p="xl" shadow="md"> <Card radius="md" p="xl" shadow="md">
@@ -175,28 +185,29 @@ const MusicPlayer = () => {
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}> <Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}>
<Card <Card
radius="md" radius="md"
p="md" p="sm"
shadow="sm" shadow="sm"
withBorder
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
border: currentSong?.id === song.id ? '2px solid #0B4F78' : '2px solid transparent', borderColor: currentSong?.id === song.id ? '#0B4F78' : 'transparent',
backgroundColor: currentSong?.id === song.id ? '#F0F7FA' : 'white',
transition: 'all 0.2s' transition: 'all 0.2s'
}} }}
onClick={() => playSong(song)} onClick={() => playSong(song)}
> >
<Group gap="md" align="center"> <Group gap="sm" align="center" wrap="nowrap">
<Avatar <Avatar
src={song.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'} src={song.coverImage?.link || '/mp3-logo.png'}
size={64} size={50}
radius="md" radius="md"
/> />
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}> <Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} c="#0B4F78" truncate>{song.judul}</Text> <Text size="sm" fw={600} c="#0B4F78" truncate>{song.judul}</Text>
<Text size="xs" c="#5A6C7D">{song.artis}</Text> <Text size="xs" c="#5A6C7D" truncate>{song.artis}</Text>
<Text size="xs" c="#8A9BA8">{song.durasi}</Text>
</Stack> </Stack>
{currentSong?.id === song.id && isPlaying && ( {currentSong?.id === song.id && isPlaying && (
<Badge color="#0B4F78" variant="filled">Memutar</Badge> <Badge color="#0B4F78" variant="filled" size="xs">Playing</Badge>
)} )}
</Group> </Group>
</Card> </Card>
@@ -207,34 +218,42 @@ const MusicPlayer = () => {
)} )}
</div> </div>
</Stack> </Stack>
</Stack> </Stack>
</Paper> </Paper>
{/* Control Player Section */}
<Paper <Paper
mt="xl" mt="xl"
mx="auto" mx="auto"
p="xl" p={{ base: 'md', sm: 'xl' }}
radius="lg" radius="lg"
shadow="sm" shadow="sm"
bg="white" bg="white"
style={{ style={{
border: '1px solid #eaeaea', border: '1px solid #eaeaea',
position: 'sticky',
bottom: 20,
zIndex: 10
}} }}
> >
<Flex align="center" justify="space-between" gap="xl" h="100%"> <Flex
<Group gap="md" style={{ flex: 1 }}> direction={{ base: 'column', md: 'row' }}
align="center"
justify="space-between"
gap={{ base: 'md', md: 'xl' }}
>
{/* Song Info */}
<Group gap="md" style={{ flex: 1, width: '100%' }} wrap="nowrap">
<Avatar <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={56} size={48}
radius="md" radius="md"
/> />
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{currentSong ? ( {currentSong ? (
<> <>
<Text size="sm" fw={600} c="#0B4F78" truncate>{currentSong.judul}</Text> <Text size="sm" fw={600} c="#0B4F78" truncate>{currentSong.judul}</Text>
<Text size="xs" c="#5A6C7D">{currentSong.artis}</Text> <Text size="xs" c="#5A6C7D" truncate>{currentSong.artis}</Text>
</> </>
) : ( ) : (
<Text size="sm" c="dimmed">Tidak ada lagu</Text> <Text size="sm" c="dimmed">Tidak ada lagu</Text>
@@ -242,29 +261,31 @@ const MusicPlayer = () => {
</div> </div>
</Group> </Group>
<Stack gap="xs" style={{ flex: 1 }} align="center"> {/* Controls + Progress */}
<Group gap="md"> <Stack gap="xs" style={{ flex: 2, width: '100%' }} align="center">
<Group gap="sm">
<ActionIcon <ActionIcon
variant={isShuffle ? 'filled' : 'subtle'} variant={isShuffle ? 'filled' : 'subtle'}
color="#0B4F78" color="#0B4F78"
onClick={toggleShuffleHandler} onClick={toggleShuffleHandler}
radius="xl" radius="xl"
size={48}
> >
{isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />} {isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />}
</ActionIcon> </ActionIcon>
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl" onClick={skipBack}> <ActionIcon variant="light" color="#0B4F78" size={48} radius="xl" onClick={skipBack}>
<IconPlayerSkipBackFilled size={20} /> <IconPlayerSkipBackFilled size={20} />
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon
variant="filled" variant="filled"
color="#0B4F78" color="#0B4F78"
size={56} size={48}
radius="xl" radius="xl"
onClick={togglePlayPauseHandler} onClick={togglePlayPauseHandler}
> >
{isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />} {isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />}
</ActionIcon> </ActionIcon>
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl" onClick={skipForward}> <ActionIcon variant="light" color="#0B4F78" size={48} radius="xl" onClick={skipForward}>
<IconPlayerSkipForwardFilled size={20} /> <IconPlayerSkipForwardFilled size={20} />
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon
@@ -272,6 +293,7 @@ const MusicPlayer = () => {
color="#0B4F78" color="#0B4F78"
onClick={toggleRepeatHandler} onClick={toggleRepeatHandler}
radius="xl" radius="xl"
size="md"
> >
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />} {isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
</ActionIcon> </ActionIcon>
@@ -290,7 +312,8 @@ const MusicPlayer = () => {
</Group> </Group>
</Stack> </Stack>
<Group gap="xs" style={{ flex: 1 }} justify="flex-end"> {/* Volume Control - Hidden on mobile, shown on md and up */}
<Group gap="xs" style={{ flex: 1 }} justify="flex-end" visibleFrom="md">
<ActionIcon variant="subtle" color="gray" onClick={toggleMuteHandler}> <ActionIcon variant="subtle" color="gray" onClick={toggleMuteHandler}>
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />} {isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
</ActionIcon> </ActionIcon>

View File

@@ -93,28 +93,19 @@ export default function FixedPlayerBar() {
mt="md" mt="md"
style={{ style={{
position: 'fixed', position: 'fixed',
top: '50%', // Menempatkan titik atas ikon di tengah layar top: '50%',
left: '0px', left: '0px',
transform: 'translateY(-50%)', // Menggeser ikon ke atas sebesar setengah tingginya sendiri agar benar-benar di tengah transform: 'translateY(-50%)',
borderBottomRightRadius: '20px', borderBottomRightRadius: '20px',
borderTopRightRadius: '20px', borderTopRightRadius: '20px',
cursor: 'pointer', cursor: 'pointer',
transition: 'transform 0.2s ease', transition: 'transform 0.2s ease',
zIndex: 1 zIndex: 1000 // Higher z-index
}} }}
onClick={handleRestorePlayer} 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" /> <IconMusic size={24} color="white" />
</Button> </Button>
{/* Spacer to prevent content from being hidden behind player */}
<Box h={20} />
</> </>
); );
} }
@@ -131,132 +122,125 @@ export default function FixedPlayerBar() {
bottom={0} bottom={0}
left={0} left={0}
right={0} right={0}
p="sm" p={{ base: 'xs', sm: 'sm' }}
shadow="lg" shadow="xl"
style={{ style={{
zIndex: 1, zIndex: 1000,
borderTop: '1px solid rgba(0,0,0,0.1)', borderTop: '1px solid rgba(0,0,0,0.1)',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
}} }}
> >
<Flex align="center" gap="md" justify="space-between"> <Flex align="center" gap={{ base: 'xs', sm: 'md' }} justify="space-between">
{/* Song Info - Left */} {/* Song Info - Left */}
<Group gap="sm" flex={1} style={{ minWidth: 0 }}> <Group gap="xs" flex={{ base: 2, sm: 1 }} style={{ minWidth: 0 }} wrap="nowrap">
<Avatar <Avatar
src={currentSong.coverImage?.link || ''} src={currentSong.coverImage?.link || ''}
alt={currentSong.judul} alt={currentSong.judul}
size={40} size={"36"}
radius="sm" radius="sm"
imageProps={{ loading: 'lazy' }}
/> />
<Box style={{ minWidth: 0 }}> <Box style={{ minWidth: 0, flex: 1 }}>
<Text fz="sm" fw={600} truncate> <Text fz={{ base: 'xs', sm: 'sm' }} fw={600} truncate>
{currentSong.judul} {currentSong.judul}
</Text> </Text>
<Text fz="xs" c="dimmed" truncate> <Text fz="10px" c="dimmed" truncate>
{currentSong.artis} {currentSong.artis}
</Text> </Text>
</Box> </Box>
</Group> </Group>
{/* Controls + Progress - Center */} {/* Controls - Center */}
<Group gap="xs" flex={2} justify="center"> <Group gap={"xs"} flex={{ base: 1, sm: 2 }} justify="center" wrap="nowrap">
{/* Control Buttons */} {/* Shuffle - Desktop Only */}
<Group gap="xs"> <ActionIcon
<ActionIcon variant={isShuffle ? 'filled' : 'subtle'}
variant={isShuffle ? 'filled' : 'subtle'} color={isShuffle ? '#0B4F78' : 'gray'}
color={isShuffle ? 'blue' : 'gray'} size={"md"}
size="lg" onClick={handleToggleShuffle}
onClick={handleToggleShuffle} visibleFrom="sm"
title="Shuffle" >
> <IconArrowsShuffle size={18} />
<IconArrowsShuffle size={18} /> </ActionIcon>
</ActionIcon>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
size="lg" size={"md"}
onClick={playPrev} onClick={playPrev}
title="Previous" >
> <IconPlayerSkipBackFilled size={20} />
<IconPlayerSkipBackFilled size={20} /> </ActionIcon>
</ActionIcon>
<ActionIcon <ActionIcon
variant="filled" variant="filled"
color={isPlaying ? 'blue' : 'gray'} color="#0B4F78"
size="xl" size={"lg"}
radius="xl" radius="xl"
onClick={togglePlayPause} onClick={togglePlayPause}
title={isPlaying ? 'Pause' : 'Play'} >
> {isPlaying ? (
{isPlaying ? ( <IconPlayerPauseFilled size={24} />
<IconPlayerPauseFilled size={24} /> ) : (
) : ( <IconPlayerPlayFilled size={24} />
<IconPlayerPlayFilled size={24} /> )}
)} </ActionIcon>
</ActionIcon>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
size="lg" size={"md"}
onClick={playNext} onClick={playNext}
title="Next" >
> <IconPlayerSkipForwardFilled size={20} />
<IconPlayerSkipForwardFilled size={20} /> </ActionIcon>
</ActionIcon>
<ActionIcon {/* Repeat - Desktop Only */}
variant="subtle" <ActionIcon
color={isRepeat ? 'blue' : 'gray'} variant={isRepeat ? 'filled' : 'subtle'}
size="lg" color={isRepeat ? '#0B4F78' : 'gray'}
onClick={toggleRepeat} size={"md"}
title={isRepeat ? 'Repeat On' : 'Repeat Off'} onClick={toggleRepeat}
> visibleFrom="sm"
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />} >
</ActionIcon> {isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
</Group> </ActionIcon>
{/* Progress Bar - Desktop */} {/* Progress Bar - Desktop Only */}
<Box w={200} display={{ base: 'none', md: 'block' }}> <Box w={150} ml="md" visibleFrom="md">
<Slider <Slider
value={currentTime} value={currentTime}
max={duration || 100} max={duration || 100}
onChange={handleSeek} onChange={handleSeek}
size="sm" size="xs"
color="blue" color="#0B4F78"
label={(value) => formatTime(value)} label={(value) => formatTime(value)}
/> />
</Box> </Box>
</Group> </Group>
{/* Right Controls - Volume + Close */} {/* Right Controls - Volume + Close */}
<Group gap="xs" flex={1} justify="flex-end"> <Group gap={4} flex={1} justify="flex-end" wrap="nowrap">
{/* Volume Control - Tablet/Desktop */}
<Box <Box
onMouseEnter={() => setShowVolume(true)} onMouseEnter={() => setShowVolume(true)}
onMouseLeave={() => setShowVolume(false)} onMouseLeave={() => setShowVolume(false)}
pos="relative" pos="relative"
visibleFrom="sm"
> >
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color={isMuted ? 'red' : 'gray'} color={isMuted ? 'red' : 'gray'}
size="lg" size="lg"
onClick={toggleMute} onClick={toggleMute}
title={isMuted ? 'Unmute' : 'Mute'}
> >
{isMuted ? ( {isMuted ? <IconVolumeOff size={18} /> : <IconVolume size={18} />}
<IconVolumeOff size={18} />
) : (
<IconVolume size={18} />
)}
</ActionIcon> </ActionIcon>
<Transition <Transition
mounted={showVolume} mounted={showVolume}
transition="scale-y" transition="scale-y"
duration={200} duration={200}
timingFunction="ease"
> >
{(style) => ( {(style) => (
<Paper <Paper
@@ -265,8 +249,8 @@ export default function FixedPlayerBar() {
position: 'absolute', position: 'absolute',
bottom: '100%', bottom: '100%',
right: 0, right: 0,
mb: 'xs', marginBottom: '10px',
p: 'sm', padding: '10px',
zIndex: 1001, zIndex: 1001,
}} }}
shadow="md" shadow="md"
@@ -276,8 +260,8 @@ export default function FixedPlayerBar() {
value={isMuted ? 0 : volume} value={isMuted ? 0 : volume}
max={100} max={100}
onChange={handleVolumeChange} onChange={handleVolumeChange}
h={100} h={80}
color="blue" color="#0B4F78"
size="sm" size="sm"
/> />
</Paper> </Paper>
@@ -288,30 +272,29 @@ export default function FixedPlayerBar() {
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
size="lg" size={"md"}
onClick={handleMinimizePlayer} onClick={handleMinimizePlayer}
title="Minimize player"
> >
<IconX size={18} /> <IconX size={18} />
</ActionIcon> </ActionIcon>
</Group> </Group>
</Flex> </Flex>
{/* Progress Bar - Mobile */} {/* Progress Bar - Mobile (Base) */}
<Box mt="xs" display={{ base: 'block', md: 'none' }}> <Box px="xs" mt={4} hiddenFrom="md">
<Slider <Slider
value={currentTime} value={currentTime}
max={duration || 100} max={duration || 100}
onChange={handleSeek} onChange={handleSeek}
size="sm" size="xs"
color="blue" color="#0B4F78"
label={(value) => formatTime(value)} label={(value) => formatTime(value)}
/> />
</Box> </Box>
</Paper> </Paper>
{/* Spacer to prevent content from being hidden behind player */} {/* Spacer to prevent content from being hidden behind player */}
<Box h={80} /> <Box h={{ base: 70, sm: 80 }} />
</> </>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,179 +1,66 @@
import { Paper, Table, Title, Box, ScrollArea, Badge } from '@mantine/core' /* eslint-disable @typescript-eslint/no-explicit-any */
import { APBDes, APBDesItem } from '../types/apbdes' import { Paper, Table, Title, Text } from '@mantine/core';
interface SectionProps { function Section({ title, data }: any) {
title: string if (!data || data.length === 0) return null;
data: APBDesItem[]
badgeColor?: string
}
function Section({ title, data, badgeColor = 'blue' }: SectionProps) {
if (!data || data.length === 0) return null
return ( return (
<> <>
<Table.Tr bg="gray.0"> <Table.Tr bg="gray.0">
<Table.Td colSpan={2}> <Table.Td colSpan={2}>
<Badge color={badgeColor} variant="light" size="lg" fw={600}> <Text fw={700} fz={{ base: 'xs', sm: 'sm' }}>{title}</Text>
{title}
</Badge>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
{data.map((item, index) => ( {data.map((item: any) => (
<Table.Tr <Table.Tr key={item.id}>
key={item.id} <Table.Td>
bg={index % 2 === 1 ? 'gray.50' : 'white'} <Text fz={{ base: 'xs', sm: 'sm' }} lineClamp={2}>
style={{ {item.kode} - {item.uraian}
transition: 'background-color 0.2s ease', </Text>
':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>
<Table.Td <Table.Td ta="right">
ta="right" <Text fz={{ base: 'xs', sm: 'sm' }} fw={500} style={{ whiteSpace: 'nowrap' }}>
style={{ Rp {item.anggaran.toLocaleString('id-ID')}
borderBottom: '1px solid #e5e7eb', </Text>
fontWeight: 600,
color: 'var(--mantine-color-blue-7)',
}}
>
Rp {item.anggaran.toLocaleString('id-ID')}
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
</> </>
) );
} }
interface PaguTableProps { export default function PaguTable({ apbdesData }: any) {
apbdesData: APBDes const items = apbdesData.items || [];
}
export default function PaguTable({ apbdesData }: PaguTableProps) { const title =
const items = apbdesData.items || [] apbdesData.tahun
? `PAGU APBDes Tahun ${apbdesData.tahun}`
: 'PAGU APBDes';
const title = apbdesData.tahun const pendapatan = items.filter((i: any) => i.tipe === 'pendapatan');
? `PAGU APBDes Tahun ${apbdesData.tahun}` const belanja = items.filter((i: any) => i.tipe === 'belanja');
: 'PAGU APBDes' const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan');
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 ( return (
<Paper <Paper withBorder p={{ base: 'sm', sm: 'md' }} radius="md">
withBorder <Title order={5} mb="md" fz={{ base: 'sm', sm: 'md' }}>{title}</Title>
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.ScrollContainer minWidth={280}>
<Table <Table verticalSpacing="xs">
horizontalSpacing="md"
verticalSpacing="xs"
layout="fixed"
>
<Table.Thead> <Table.Thead>
<Table.Tr bg="blue.9"> <Table.Tr>
<Table.Th c="white" fw={600} style={{ minWidth: '60%' }}> <Table.Th fz={{ base: 'xs', sm: 'sm' }}>Uraian</Table.Th>
Uraian <Table.Th ta="right" fz={{ base: 'xs', sm: 'sm' }}>Anggaran (Rp)</Table.Th>
</Table.Th>
<Table.Th
c="white"
fw={600}
ta="right"
style={{ minWidth: '40%' }}
>
Anggaran (Rp)
</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
<Section <Section title="1) PENDAPATAN" data={pendapatan} />
title="1) PENDAPATAN" <Section title="2) BELANJA" data={belanja} />
data={pendapatan} <Section title="3) PEMBIAYAAN" data={pembiayaan} />
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.Tbody>
</Table> </Table>
</ScrollArea> </Table.ScrollContainer>
</Paper> </Paper>
) );
} }

View File

@@ -1,211 +1,92 @@
import { Paper, Table, Title, Badge, Text, Box, ScrollArea } from '@mantine/core' /* eslint-disable @typescript-eslint/no-explicit-any */
import { APBDes, APBDesItem, RealisasiItem } from '../types/apbdes' import { Paper, Table, Title, Badge, Text } from '@mantine/core';
interface RealisasiRowProps { export default function RealisasiTable({ apbdesData }: any) {
realisasi: RealisasiItem const items = apbdesData.items || [];
parentItem: APBDesItem
}
function RealisasiRow({ realisasi, parentItem }: RealisasiRowProps) { const title =
const persentase = parentItem.anggaran > 0 apbdesData.tahun
? (realisasi.jumlah / parentItem.anggaran) * 100 ? `REALISASI APBDes Tahun ${apbdesData.tahun}`
: 0 : 'REALISASI APBDes';
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 // Flatten: kumpulkan semua realisasi items
const allRealisasiRows: Array<{ realisasi: RealisasiItem; parentItem: APBDesItem }> = [] const allRealisasiRows: Array<{ realisasi: any; parentItem: any }> = [];
items.forEach((item: APBDesItem) => { items.forEach((item: any) => {
if (item.realisasiItems && item.realisasiItems.length > 0) { if (item.realisasiItems && item.realisasiItems.length > 0) {
item.realisasiItems.forEach((realisasi: RealisasiItem) => { item.realisasiItems.forEach((realisasi: any) => {
allRealisasiRows.push({ realisasi, parentItem: item }) allRealisasiRows.push({ realisasi, parentItem: item });
}) });
} }
}) });
// Calculate total realisasi const formatRupiah = (amount: number) => {
const totalRealisasi = allRealisasiRows.reduce( return new Intl.NumberFormat('id-ID', {
(sum, { realisasi }) => sum + (realisasi.jumlah || 0), style: 'currency',
0 currency: 'IDR',
) minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
return ( return (
<Paper <Paper withBorder p={{ base: 'sm', sm: 'md' }} radius="md">
withBorder <Title order={5} mb="md" fz={{ base: 'sm', sm: 'md' }}>{title}</Title>
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 ? ( {allRealisasiRows.length === 0 ? (
<Box <Text fz="sm" c="dimmed" ta="center" py="md">
py="xl" Belum ada data realisasi
px="md" </Text>
style={{
backgroundColor: 'var(--mantine-color-gray-0)',
borderRadius: 8,
}}
>
<Text
fz="sm"
c="dimmed"
ta="center"
lh={1.6}
>
Belum ada data realisasi untuk tahun ini
</Text>
</Box>
) : ( ) : (
<> <Table.ScrollContainer minWidth={300}>
<ScrollArea offsetScrollbars type="hover"> <Table verticalSpacing="xs">
<Table <Table.Thead>
horizontalSpacing="md" <Table.Tr>
verticalSpacing="xs" <Table.Th fz={{ base: 'xs', sm: 'sm' }}>Uraian</Table.Th>
layout="fixed" <Table.Th ta="right" fz={{ base: 'xs', sm: 'sm' }}>Realisasi (Rp)</Table.Th>
> <Table.Th ta="center" fz={{ base: 'xs', sm: 'sm' }}>%</Table.Th>
<Table.Thead> </Table.Tr>
<Table.Tr bg="blue.9"> </Table.Thead>
<Table.Th c="white" fw={600}>Uraian</Table.Th> <Table.Tbody>
<Table.Th c="white" fw={600} ta="right">Realisasi (Rp)</Table.Th> {allRealisasiRows.map(({ realisasi, parentItem }) => {
<Table.Th c="white" fw={600} ta="center">%</Table.Th> const persentase = parentItem.anggaran > 0
</Table.Tr> ? (realisasi.jumlah / parentItem.anggaran) * 100
</Table.Thead> : 0;
<Table.Tbody>
{allRealisasiRows.map(({ realisasi, parentItem }) => ( return (
<RealisasiRow <Table.Tr key={realisasi.id}>
key={realisasi.id} <Table.Td>
realisasi={realisasi} <Text fz={{ base: 'xs', sm: 'sm' }} lineClamp={2}>
parentItem={parentItem} {realisasi.kode || '-'} - {realisasi.keterangan || '-'}
/> </Text>
))} </Table.Td>
</Table.Tbody> <Table.Td ta="right">
</Table> <Text fw={600} c="blue" fz={{ base: 'xs', sm: 'sm' }} style={{ whiteSpace: 'nowrap' }}>
</ScrollArea> {formatRupiah(realisasi.jumlah || 0)}
<Box mb="md" px="sm"> </Text>
<Text </Table.Td>
size="sm" <Table.Td ta="center">
c="gray.6" <Badge
fw={500} size="sm"
> variant="light"
Total Realisasi:{' '} color={
<Text persentase >= 100
component="span" ? 'teal'
c="blue.9" : persentase >= 60
fw={700} ? 'yellow'
fz="md" : 'red'
> }
{new Intl.NumberFormat('id-ID', { >
style: 'currency', {persentase.toFixed(1)}%
currency: 'IDR', </Badge>
minimumFractionDigits: 0, </Table.Td>
maximumFractionDigits: 0, </Table.Tr>
}).format(totalRealisasi)} );
</Text> })}
</Text> </Table.Tbody>
</Box> </Table>
</> </Table.ScrollContainer>
)} )}
</Paper> </Paper>
) );
} }

View File

@@ -1,90 +0,0 @@
// 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;
};

View File

@@ -99,13 +99,13 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<html lang="id" {...mantineHtmlProps}> <ViewTransitions>
<head> <html lang="id" {...mantineHtmlProps}>
<meta charSet="utf-8" /> <head>
<ColorSchemeScript defaultColorScheme="light" /> <meta charSet="utf-8" />
</head> <ColorSchemeScript defaultColorScheme="light" />
<body> </head>
<ViewTransitions> <body>
<MusicProvider> <MusicProvider>
<MantineProvider theme={theme} defaultColorScheme="light"> <MantineProvider theme={theme} defaultColorScheme="light">
{children} {children}
@@ -117,8 +117,8 @@ export default function RootLayout({
/> />
</MantineProvider> </MantineProvider>
</MusicProvider> </MusicProvider>
</ViewTransitions> </body>
</body> </html>
</html> </ViewTransitions>
); );
} }

View File

@@ -1,25 +1,9 @@
import { AppServer } from '@/app/api/[[...slugs]]/route' import { AppServer } from '@/app/api/[[...slugs]]/route'
import { treaty } from '@elysiajs/eden' import { treaty } from '@elysiajs/eden'
// Determine the base URL based on environment // const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'localhost:3000'
// treaty requires a full URL, cannot use relative paths like '/' const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
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) const ApiFetch = treaty<AppServer>(BASE_URL)
export default ApiFetch export default ApiFetch

View File

@@ -29,18 +29,16 @@ process.on('unhandledRejection', async (error) => {
}); });
// Handle graceful shutdown // Handle graceful shutdown
if (process.env.NODE_ENV === 'production' && !process.env.NEXT_PHASE) { process.on('SIGINT', async () => {
process.on('SIGINT', async () => { console.log('Received SIGINT signal. Closing database connections...');
console.log('Received SIGINT signal. Closing database connections...'); await prisma.$disconnect();
await prisma.$disconnect(); process.exit(0);
// Allow natural exit });
});
process.on('SIGTERM', async () => { process.on('SIGTERM', async () => {
console.log('Received SIGTERM signal. Closing database connections...'); console.log('Received SIGTERM signal. Closing database connections...');
await prisma.$disconnect(); await prisma.$disconnect();
// Allow natural exit process.exit(0);
}); });
}
export default prisma; export default prisma;

View File

@@ -1,418 +0,0 @@
# 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
---