Compare commits

..

2 Commits

Author SHA1 Message Date
40f9284969 Merge pull request 'Fix Responsive' (#80) from fix-responsive into staggingweb
Reviewed-on: #80
2026-03-06 16:39:47 +08:00
7bc546e985 Fix Responsive 2026-03-06 16:19:01 +08:00
53 changed files with 1013 additions and 5724 deletions

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
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { readFileSync } from "node:fs";
// Function to manually load .env from project root if process.env is missing keys
function loadEnv() {
const envPath = join(process.cwd(), ".env");
if (existsSync(envPath)) {
const envContent = readFileSync(envPath, "utf-8");
const lines = envContent.split("\n");
for (const line of lines) {
if (line && !line.startsWith("#")) {
const [key, ...valueParts] = line.split("=");
if (key && valueParts.length > 0) {
const value = valueParts.join("=").trim().replace(/^["']|["']$/g, "");
process.env[key.trim()] = value;
}
}
// Fungsi untuk mencari string terpanjang dalam objek (biasanya balasan AI)
function findLongestString(obj: any): string {
let longest = "";
const search = (item: any) => {
if (typeof item === "string") {
if (item.length > longest.length) longest = item;
} else if (Array.isArray(item)) {
item.forEach(search);
} else if (item && typeof item === "object") {
Object.values(item).forEach(search);
}
}
};
search(obj);
return longest;
}
async function run() {
try {
// Ensure environment variables are loaded
loadEnv();
const inputRaw = readFileSync(0, "utf-8");
if (!inputRaw) return;
const input = JSON.parse(inputRaw);
let finalText = "";
let sessionId = "web-desa-darmasaba";
try {
// Try parsing as JSON first
const input = JSON.parse(inputRaw);
sessionId = input.session_id || "web-desa-darmasaba";
finalText = typeof input === "string" ? input : (input.response || input.text || JSON.stringify(input));
} catch {
// If not JSON, use raw text
finalText = inputRaw;
}
// DEBUG: Lihat struktur asli di console terminal (stderr)
console.error("DEBUG KEYS:", Object.keys(input));
const BOT_TOKEN = process.env.BOT_TOKEN;
const CHAT_ID = process.env.CHAT_ID;
if (!BOT_TOKEN || !CHAT_ID) {
console.error("Missing BOT_TOKEN or CHAT_ID in environment variables");
return;
const sessionId = input.session_id || "unknown";
// Cari teks secara otomatis di seluruh objek JSON
let finalText = findLongestString(input.response || input);
if (!finalText || finalText.length < 5) {
finalText =
"Teks masih gagal diekstraksi. Struktur: " +
Object.keys(input).join(", ");
}
const message =
@@ -54,7 +45,7 @@ async function run() {
`🆔 Session: \`${sessionId}\` \n\n` +
`🧠 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",
headers: { "Content-Type": "application/json" },
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" }));
} catch (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*
# env
# env local files (keep .env.example)
.env*
!.env.example
# QC
QC
@@ -52,6 +50,9 @@ next-env.d.ts
# cache
/cache
.github/
.env.*
*.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
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
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 /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/src ./src
COPY --from=builder /app/next.config.js ./next.config.js
COPY --from=builder /app/tsconfig.json ./tsconfig.json
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
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

View File

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

View File

@@ -8,8 +8,7 @@
"start": "next start",
"test:api": "vitest run",
"test:e2e": "playwright test",
"test": "bun run test:api && bun run test:e2e",
"gen:api": ""
"test": "bun run test:api && bun run test:e2e"
},
"prisma": {
"seed": "bun run prisma/seed.ts"
@@ -71,7 +70,7 @@
"embla-carousel-react": "^8.6.0",
"extract-zip": "^2.0.1",
"form-data": "^4.0.2",
"framer-motion": "^12.38.0",
"framer-motion": "^12.23.5",
"get-port": "^7.1.0",
"iron-session": "^8.0.4",
"jose": "^6.1.0",
@@ -101,7 +100,7 @@
"react-transition-group": "^4.4.5",
"react-zoom-pan-pinch": "^3.7.0",
"readdirp": "^4.1.1",
"recharts": "^3.8.0",
"recharts": "^2.15.3",
"sharp": "^0.34.3",
"swr": "^2.3.2",
"uuid": "^11.1.0",

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
# 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>
</Box>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import Beasiswa from "./beasiswa-desa";
import PerpustakaanDigital from "./perpustakaan-digital";
const Pendidikan = new Elysia({
prefix: "/pendidikan",
prefix: "/api/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(DaftarInformasiPublik)
.use(GrafikHasilKepuasanMasyarakat)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,7 +2,6 @@
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { randomOTP } from "../_lib/randomOTP";
import { sendCodeOtp } from "../_lib/sendCodeOtp";
import { cookies } from "next/headers";
export async function POST(req: Request) {
@@ -34,27 +33,33 @@ export async function POST(req: Request) {
const codeOtp = randomOTP();
const otpNumber = Number(codeOtp);
console.log(`🔑 DEBUG OTP [${nomor}]: ${codeOtp}`);
const waMessage = `Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.\n\n>> Kode OTP anda: ${codeOtp}.`;
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
console.log("🔍 Debug WA URL:", waUrl);
try {
const waResponse = await sendCodeOtp({
nomor,
codeOtp,
});
const res = await fetch(waUrl);
const sendWa = await res.json();
console.log("📱 WA Response:", sendWa);
if (!waResponse.ok) {
console.error(`⚠️ WA Service HTTP Error: ${waResponse.status} ${waResponse.statusText}. Continuing since OTP is logged.`);
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);
}
if (sendWa.status !== "success") {
console.error("❌ WA Service Error:", sendWa);
return NextResponse.json(
{
success: false,
message: "Gagal mengirim OTP via WhatsApp",
debug: sendWa
},
{ status: 400 }
);
}
} catch (waError: unknown) {
const errorMessage = waError instanceof Error ? waError.message : String(waError);
console.error("⚠️ WA Connection Exception. Continuing since OTP is logged.", errorMessage);
} catch (waError) {
console.error("❌ Fetch WA Error:", waError);
return NextResponse.json(
{ success: false, message: "Terjadi kesalahan saat mengirim WA" },
{ status: 500 }
);
}
const createOtpId = await prisma.kodeOtp.create({
@@ -90,6 +95,6 @@ export async function POST(req: Request) {
{ status: 500 }
);
} finally {
await prisma.$disconnect();
await prisma.$disconnect();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -93,28 +93,19 @@ export default function FixedPlayerBar() {
mt="md"
style={{
position: 'fixed',
top: '50%', // Menempatkan titik atas ikon di tengah layar
top: '50%',
left: '0px',
transform: 'translateY(-50%)', // Menggeser ikon ke atas sebesar setengah tingginya sendiri agar benar-benar di tengah
transform: 'translateY(-50%)',
borderBottomRightRadius: '20px',
borderTopRightRadius: '20px',
cursor: 'pointer',
transition: 'transform 0.2s ease',
zIndex: 1
zIndex: 1000 // Higher z-index
}}
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>
{/* Spacer to prevent content from being hidden behind player */}
<Box h={20} />
</>
);
}
@@ -131,132 +122,125 @@ export default function FixedPlayerBar() {
bottom={0}
left={0}
right={0}
p="sm"
shadow="lg"
p={{ base: 'xs', sm: 'sm' }}
shadow="xl"
style={{
zIndex: 1,
zIndex: 1000,
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 */}
<Group gap="sm" flex={1} style={{ minWidth: 0 }}>
<Group gap="xs" flex={{ base: 2, sm: 1 }} style={{ minWidth: 0 }} wrap="nowrap">
<Avatar
src={currentSong.coverImage?.link || ''}
alt={currentSong.judul}
size={40}
size={"36"}
radius="sm"
imageProps={{ loading: 'lazy' }}
/>
<Box style={{ minWidth: 0 }}>
<Text fz="sm" fw={600} truncate>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fz={{ base: 'xs', sm: 'sm' }} fw={600} truncate>
{currentSong.judul}
</Text>
<Text fz="xs" c="dimmed" truncate>
<Text fz="10px" c="dimmed" truncate>
{currentSong.artis}
</Text>
</Box>
</Group>
{/* Controls + Progress - Center */}
<Group gap="xs" flex={2} justify="center">
{/* Control Buttons */}
<Group gap="xs">
<ActionIcon
variant={isShuffle ? 'filled' : 'subtle'}
color={isShuffle ? 'blue' : 'gray'}
size="lg"
onClick={handleToggleShuffle}
title="Shuffle"
>
<IconArrowsShuffle size={18} />
</ActionIcon>
{/* Controls - Center */}
<Group gap={"xs"} flex={{ base: 1, sm: 2 }} justify="center" wrap="nowrap">
{/* Shuffle - Desktop Only */}
<ActionIcon
variant={isShuffle ? 'filled' : 'subtle'}
color={isShuffle ? '#0B4F78' : 'gray'}
size={"md"}
onClick={handleToggleShuffle}
visibleFrom="sm"
>
<IconArrowsShuffle size={18} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
onClick={playPrev}
title="Previous"
>
<IconPlayerSkipBackFilled size={20} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size={"md"}
onClick={playPrev}
>
<IconPlayerSkipBackFilled size={20} />
</ActionIcon>
<ActionIcon
variant="filled"
color={isPlaying ? 'blue' : 'gray'}
size="xl"
radius="xl"
onClick={togglePlayPause}
title={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? (
<IconPlayerPauseFilled size={24} />
) : (
<IconPlayerPlayFilled size={24} />
)}
</ActionIcon>
<ActionIcon
variant="filled"
color="#0B4F78"
size={"lg"}
radius="xl"
onClick={togglePlayPause}
>
{isPlaying ? (
<IconPlayerPauseFilled size={24} />
) : (
<IconPlayerPlayFilled size={24} />
)}
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
onClick={playNext}
title="Next"
>
<IconPlayerSkipForwardFilled size={20} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size={"md"}
onClick={playNext}
>
<IconPlayerSkipForwardFilled size={20} />
</ActionIcon>
<ActionIcon
variant="subtle"
color={isRepeat ? 'blue' : 'gray'}
size="lg"
onClick={toggleRepeat}
title={isRepeat ? 'Repeat On' : 'Repeat Off'}
>
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
</ActionIcon>
</Group>
{/* Repeat - Desktop Only */}
<ActionIcon
variant={isRepeat ? 'filled' : 'subtle'}
color={isRepeat ? '#0B4F78' : 'gray'}
size={"md"}
onClick={toggleRepeat}
visibleFrom="sm"
>
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
</ActionIcon>
{/* Progress Bar - Desktop */}
<Box w={200} display={{ base: 'none', md: 'block' }}>
{/* Progress Bar - Desktop Only */}
<Box w={150} ml="md" visibleFrom="md">
<Slider
value={currentTime}
max={duration || 100}
onChange={handleSeek}
size="sm"
color="blue"
size="xs"
color="#0B4F78"
label={(value) => formatTime(value)}
/>
</Box>
</Group>
{/* 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
onMouseEnter={() => setShowVolume(true)}
onMouseLeave={() => setShowVolume(false)}
pos="relative"
visibleFrom="sm"
>
<ActionIcon
variant="subtle"
color={isMuted ? 'red' : 'gray'}
size="lg"
onClick={toggleMute}
title={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted ? (
<IconVolumeOff size={18} />
) : (
<IconVolume size={18} />
)}
{isMuted ? <IconVolumeOff size={18} /> : <IconVolume size={18} />}
</ActionIcon>
<Transition
mounted={showVolume}
transition="scale-y"
duration={200}
timingFunction="ease"
>
{(style) => (
<Paper
@@ -265,8 +249,8 @@ export default function FixedPlayerBar() {
position: 'absolute',
bottom: '100%',
right: 0,
mb: 'xs',
p: 'sm',
marginBottom: '10px',
padding: '10px',
zIndex: 1001,
}}
shadow="md"
@@ -276,8 +260,8 @@ export default function FixedPlayerBar() {
value={isMuted ? 0 : volume}
max={100}
onChange={handleVolumeChange}
h={100}
color="blue"
h={80}
color="#0B4F78"
size="sm"
/>
</Paper>
@@ -288,30 +272,29 @@ export default function FixedPlayerBar() {
<ActionIcon
variant="subtle"
color="gray"
size="lg"
size={"md"}
onClick={handleMinimizePlayer}
title="Minimize player"
>
<IconX size={18} />
</ActionIcon>
</Group>
</Flex>
{/* Progress Bar - Mobile */}
<Box mt="xs" display={{ base: 'block', md: 'none' }}>
{/* Progress Bar - Mobile (Base) */}
<Box px="xs" mt={4} hiddenFrom="md">
<Slider
value={currentTime}
max={duration || 100}
onChange={handleSeek}
size="sm"
color="blue"
size="xs"
color="#0B4F78"
label={(value) => formatTime(value)}
/>
</Box>
</Paper>
{/* 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 react-hooks/exhaustive-deps */
'use client'
import apbdesState from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
import colors from '@/con/colors'
import {
Box,
@@ -13,43 +12,30 @@ import {
SimpleGrid,
Stack,
Text,
Title,
LoadingOverlay,
Transition,
Title
} from '@mantine/core'
import { motion } from 'framer-motion'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useProxy } from 'valtio/utils'
import { ApbdesMainSkeleton } from './components/apbdesSkeleton'
import ComparisonChart from './lib/comparisonChart'
import GrafikRealisasi from './lib/grafikRealisasi'
import PaguTable from './lib/paguTable'
import RealisasiTable from './lib/realisasiTable'
const MotionStack = motion.create(Stack)
function Apbdes() {
const state = useProxy(apbdesState)
const state = useProxy(apbdes)
const [selectedYear, setSelectedYear] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isChangingYear, setIsChangingYear] = useState(false)
const textHeading = {
title: 'APBDes',
des: 'Transparansi APBDes Darmasaba adalah langkah nyata menuju tata kelola desa yang bersih, terbuka, dan bertanggung jawab.',
des: 'Transparansi APBDes Darmasaba adalah langkah nyata menuju tata kelola desa yang bersih, terbuka, dan bertanggung jawab.'
}
useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true)
await state.findMany.load()
} catch (error) {
console.error('Error loading data:', error)
} finally {
setIsLoading(false)
}
}
loadData()
@@ -65,7 +51,7 @@ function Apbdes() {
)
)
.sort((a, b) => b - a)
.map((year) => ({
.map(year => ({
value: year.toString(),
label: `Tahun ${year}`,
}))
@@ -74,190 +60,168 @@ function Apbdes() {
if (years.length > 0 && !selectedYear) {
setSelectedYear(years[0].value)
}
}, [years])
}, [years, selectedYear])
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
const handleYearChange = (value: string | null) => {
if (value !== selectedYear) {
setIsChangingYear(true)
setSelectedYear(value)
setTimeout(() => setIsChangingYear(false), 500)
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const previewData = (state.findMany.data || []).slice(0, 3)
return (
<Stack p="sm" gap="xl" bg={colors.Bg} pos="relative">
<LoadingOverlay
visible={isLoading}
zIndex={1000}
overlayProps={{ radius: 'sm', blur: 2 }}
loaderProps={{ color: colors['blue-button'], type: 'dots' }}
/>
<Transition mounted={!isLoading} transition="fade" duration={600}>
{(styles) => (
<MotionStack
style={styles}
gap="xl"
<Stack p="sm" gap="xl" bg={colors.Bg}>
<Divider c="gray.3" size="sm" />
{/* 📌 HEADING */}
<Box mt="xl">
<Stack gap="sm">
<Title
order={1}
ta="center"
c={colors['blue-button']}
fz={{ base: '2rem', md: '3.6rem' }}
lh={{ base: 1.2, md: 1.1 }}
>
<Divider c="gray.3" size="sm" />
{/* 📌 HEADING */}
<Box mt="xl">
<Stack gap="sm">
<Title
order={1}
{textHeading.title}
</Title>
<Text
ta="center"
fz={{ base: '1rem', md: '1.25rem' }}
lh={{ base: 1.5, md: 1.55 }}
c="black"
>
{textHeading.des}
</Text>
</Stack>
</Box>
{/* Button Lihat Semua */}
<Group justify="center">
<Button
component={Link}
href="/darmasaba/apbdes"
radius="xl"
size="lg"
variant="gradient"
gradient={{ from: "#26667F", to: "#124170" }}
>
Lihat Semua Data
</Button>
</Group>
{/* COMBOBOX */}
<Box px={{ base: 'md', md: "sm" }}>
<Select
label={<Text fw={600} fz="sm">Pilih Tahun APBDes</Text>}
placeholder="Pilih tahun"
value={selectedYear}
onChange={setSelectedYear}
data={years}
w={{ base: '100%', sm: 220 }}
searchable
clearable
nothingFoundMessage="Tidak ada tahun tersedia"
/>
</Box>
{/* Tabel & Grafik - Hanya tampilkan jika ada data */}
{currentApbdes && currentApbdes.items?.length > 0 ? (
<Box px={{ base: 'md', md: 'sm' }} mb="xl">
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<PaguTable apbdesData={currentApbdes} />
<RealisasiTable apbdesData={currentApbdes} />
<GrafikRealisasi apbdesData={currentApbdes} />
</SimpleGrid>
</Box>
) : currentApbdes ? (
<Box px={{ base: 'md', md: 100 }} py="md" mb="xl">
<Text fz="sm" c="dimmed" ta="center" lh={1.5}>
Tidak ada data item untuk tahun yang dipilih.
</Text>
</Box>
) : null}
{/* GRID - Card Preview
{state.findMany.loading ? (
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
<Loader size="lg" color="blue" />
</Center>
) : previewData.length === 0 ? (
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
<Stack align="center" gap="xs">
<Text fz="lg" c="dimmed" lh={1.4}>
Belum ada data APBDes yang tersedia
</Text>
<Text fz="sm" c="dimmed" lh={1.4}>
Data akan ditampilkan di sini setelah diunggah
</Text>
</Stack>
</Center>
) : (
<SimpleGrid
mx={{ base: 'md', md: 100 }}
cols={{ base: 1, sm: 3 }}
spacing="lg"
pb="xl"
>
{previewData.map((v, k) => (
<Box
key={k}
pos="relative"
style={{
backgroundImage: `url(${v.image?.link || ''})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
borderRadius: 16,
height: 360,
overflow: 'hidden',
}}
>
<Box pos="absolute" inset={0} bg="rgba(0,0,0,0.45)" style={{ borderRadius: 16 }} />
<Stack gap="xs" justify="space-between" h="100%" p="xl" pos="relative">
<Text
c="white"
fw={600}
fz={{ base: 'lg', md: 'xl' }}
ta="center"
c={colors['blue-button']}
fz={{ base: '2rem', md: '3.6rem' }}
lh={{ base: 1.2, md: 1.1 }}
lh={1.35}
lineClamp={2}
>
{textHeading.title}
</Title>
{v.name || `APBDes Tahun ${v.tahun}`}
</Text>
<Text
fw={700}
c="white"
fz={{ base: '2.4rem', md: '3.2rem' }}
ta="center"
fz={{ base: '1rem', md: '1.25rem' }}
lh={{ base: 1.5, md: 1.55 }}
c="black"
maw={800}
mx="auto"
lh={1}
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
>
{textHeading.des}
{v.jumlah || '-'}
</Text>
<Center>
<ActionIcon
component={Link}
href={v.file?.link || ''}
radius="xl"
size="xl"
variant="gradient"
gradient={{ from: '#1C6EA4', to: '#1C6EA4' }}
>
<IconDownload size={20} color="white" />
</ActionIcon>
</Center>
</Stack>
</Box>
{/* Button Lihat Semua */}
<Group justify="center">
<Button
component={Link}
href="/darmasaba/apbdes"
radius="xl"
size="lg"
variant="gradient"
gradient={{ from: '#26667F', to: '#124170' }}
style={{
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
':hover': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(38, 102, 127, 0.4)',
},
}}
>
Lihat Semua Data
</Button>
</Group>
{/* COMBOBOX */}
<Box px={{ base: 'md', md: 'sm' }}>
<Select
label={<Text fw={600} fz="sm">Pilih Tahun APBDes</Text>}
placeholder="Pilih tahun"
value={selectedYear}
onChange={handleYearChange}
data={years}
w={{ base: '100%', sm: 220 }}
searchable
clearable
nothingFoundMessage="Tidak ada tahun tersedia"
disabled={isChangingYear}
/>
</Box>
{/* Tables & Charts */}
{currentApbdes && currentApbdes.items && currentApbdes.items.length > 0 ? (
<Box px={{ base: 'md', md: 'sm' }} mb="xl">
<Transition
mounted={!isChangingYear}
transition="slide-up"
duration={400}
timingFunction="ease"
>
{(styles) => (
<SimpleGrid
cols={{ base: 1, sm: 3 }}
style={styles}
>
<MotionStack
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<PaguTable apbdesData={currentApbdes as any} />
</MotionStack>
<MotionStack
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<RealisasiTable apbdesData={currentApbdes as any} />
</MotionStack>
<MotionStack
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3 }}
>
<GrafikRealisasi apbdesData={currentApbdes as any} />
</MotionStack>
</SimpleGrid>
)}
</Transition>
{/* Comparison Chart */}
<Box mt="lg">
<Transition
mounted={!isChangingYear}
transition="slide-up"
duration={400}
timingFunction="ease"
>
{(styles) => (
<MotionStack
style={styles}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.4 }}
>
<ComparisonChart apbdesData={currentApbdes as any} />
</MotionStack>
)}
</Transition>
</Box>
</Box>
) : currentApbdes ? (
<Box px={{ base: 'md', md: 100 }} py="xl" mb="xl">
<Stack align="center" gap="sm">
<Text fz="2rem">📊</Text>
<Text fz="sm" c="dimmed" ta="center" lh={1.5}>
Tidak ada data item untuk tahun yang dipilih.
</Text>
</Stack>
</Box>
) : null}
{/* Loading State for Year Change */}
<Transition mounted={isChangingYear} transition="fade" duration={200}>
{(styles) => (
<Box
px={{ base: 'md', md: 'sm' }}
mb="xl"
style={styles}
>
<ApbdesMainSkeleton />
</Box>
)}
</Transition>
</MotionStack>
)}
</Transition>
))}
</SimpleGrid>
)} */}
</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'
import { IconArrowUpRight, IconArrowDownRight } from '@tabler/icons-react'
import { APBDes, APBDesItem } from '../types/apbdes'
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Title, Progress, Stack, Text, Group, Box } from '@mantine/core';
interface SummaryProps {
title: string
data: APBDesItem[]
icon?: React.ReactNode
interface APBDesItem {
tipe: string | null;
anggaran: number;
realisasi?: number;
totalRealisasi?: number;
}
function Summary({ title, data, icon }: SummaryProps) {
if (!data || data.length === 0) return null
interface SummaryProps {
title: string;
data: APBDesItem[];
}
const totalAnggaran = data.reduce((sum, i) => sum + i.anggaran, 0)
// Hitung total realisasi dari realisasiItems (konsisten dengan RealisasiTable)
const totalRealisasi = data.reduce((sum, i) => {
if (i.realisasiItems && i.realisasiItems.length > 0) {
return sum + i.realisasiItems.reduce((sumReal, real) => sumReal + (real.jumlah || 0), 0)
}
return sum
}, 0)
function Summary({ title, data }: SummaryProps) {
if (!data || data.length === 0) return null;
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) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(angka)
}
}).format(angka);
};
// Tentukan warna berdasarkan persentase
const getProgressColor = (persen: number) => {
if (persen >= 100) return 'teal'
if (persen >= 80) return 'blue'
if (persen >= 60) return 'yellow'
return 'red'
}
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)
if (persen >= 100) return 'teal';
if (persen >= 80) return 'blue';
if (persen >= 60) return 'yellow';
return 'red';
};
return (
<Box>
<Group justify="space-between" mb="xs">
<Group gap="xs">
{icon}
<Text fw={700} fz="md" c="gray.8">{title}</Text>
</Group>
<Group gap="xs">
{persentase >= 100 ? (
<IconArrowUpRight
size={18}
color="var(--mantine-color-teal-7)"
stroke={2.5}
/>
) : persentase < 60 ? (
<IconArrowDownRight
size={18}
color="var(--mantine-color-red-7)"
stroke={2.5}
/>
) : null}
<Text
fw={700}
fz="lg"
c={getProgressColor(persentase)}
style={{
minWidth: 60,
textAlign: 'right',
}}
>
{persentase.toFixed(1)}%
</Text>
</Group>
<Text fw={600} fz="md">{title}</Text>
<Text fw={700} fz="lg" c={getProgressColor(persen)}>
{persen.toFixed(2)}%
</Text>
</Group>
<Text fz="xs" c="gray.6" mb="sm" lh={1.5}>
Realisasi: <Text component="span" fw={700} c="blue.9">{formatRupiah(totalRealisasi)}</Text>
{' '}/ Anggaran: <Text component="span" fw={700} c="gray.7">{formatRupiah(totalAnggaran)}</Text>
<Text fz="sm" c="dimmed" mb="xs">
Realisasi: {formatRupiah(totalRealisasi)} / Anggaran: {formatRupiah(totalAnggaran)}
</Text>
<Progress
value={persentase}
value={persen}
size="xl"
radius="xl"
color={getProgressColor(persentase)}
striped={persentase < 100}
animated={persentase < 100}
mb="xs"
color={getProgressColor(persen)}
striped={persen < 100}
animated={persen < 100}
/>
<Text
fz="xs"
c={statusMessage.color as MantineColor}
fw={600}
style={{
backgroundColor: `var(--mantine-color-${statusMessage.color}-0)`,
padding: '6px 10px',
borderRadius: 6,
display: 'inline-block',
}}
>
{persentase >= 100 && '✓ '}{statusMessage.text}
</Text>
{persen >= 100 && (
<Text fz="xs" c="teal" mt="xs" fw={500}>
Realisasi mencapai 100% dari anggaran
</Text>
)}
{persen < 100 && persen >= 80 && (
<Text fz="xs" c="blue" mt="xs" fw={500}>
Realisasi baik, mendekati target
</Text>
)}
{persen < 80 && persen >= 60 && (
<Text fz="xs" c="yellow" mt="xs" fw={500}>
Realisasi cukup, perlu ditingkatkan
</Text>
)}
{persen < 60 && (
<Text fz="xs" c="red" mt="xs" fw={500}>
Realisasi rendah, perlu perhatian khusus
</Text>
)}
</Box>
)
);
}
interface GrafikRealisasiProps {
apbdesData: APBDes
}
export default function GrafikRealisasi({
apbdesData,
}: {
apbdesData: {
tahun?: number | null;
items?: APBDesItem[] | null;
[key: string]: any;
};
}) {
const items = apbdesData?.items || [];
const tahun = apbdesData?.tahun || new Date().getFullYear();
export default function GrafikRealisasi({ apbdesData }: GrafikRealisasiProps) {
const items = apbdesData?.items || []
const tahun = apbdesData?.tahun || new Date().getFullYear()
const pendapatan = items.filter((i: APBDesItem) => i.tipe === 'pendapatan')
const belanja = items.filter((i: APBDesItem) => i.tipe === 'belanja')
const pembiayaan = items.filter((i: APBDesItem) => i.tipe === 'pembiayaan')
const pendapatan = items.filter((i: APBDesItem) => i.tipe === 'pendapatan');
const belanja = items.filter((i: APBDesItem) => i.tipe === 'belanja');
const pembiayaan = items.filter((i: APBDesItem) => i.tipe === 'pembiayaan');
return (
<Paper
withBorder
p="lg"
radius="lg"
shadow="sm"
style={{
transition: 'box-shadow 0.3s ease',
':hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
},
}}
h={"100%"}
>
<Title
order={5}
mb="lg"
c="blue.9"
fz={{ base: '1rem', md: '1.1rem' }}
fw={700}
>
<Paper withBorder p="md" radius="md">
<Title order={5} mb="md">
GRAFIK REALISASI APBDes {tahun}
</Title>
<Stack gap="xl">
<Summary
title="Pendapatan"
data={pendapatan}
icon={
<Box
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: 'var(--mantine-color-green-0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text fz="lg">💰</Text>
</Box>
}
/>
<Summary
title="Belanja"
data={belanja}
icon={
<Box
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: 'var(--mantine-color-red-0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text fz="lg">💸</Text>
</Box>
}
/>
<Summary
title="Pembiayaan"
data={pembiayaan}
icon={
<Box
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: 'var(--mantine-color-orange-0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text fz="lg">📊</Text>
</Box>
}
/>
<Stack gap="lg" mb="lg">
<Summary title="💰 Pendapatan" data={pendapatan} />
<Summary title="💸 Belanja" data={belanja} />
<Summary title="📊 Pembiayaan" data={pembiayaan} />
</Stack>
</Paper>
)
}
);
}

View File

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

View File

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

View File

@@ -1,25 +1,9 @@
import { AppServer } from '@/app/api/[[...slugs]]/route'
import { treaty } from '@elysiajs/eden'
// Determine the base URL based on environment
// treaty requires a full URL, cannot use relative paths like '/'
const getBaseUrl = () => {
// Development (server-side)
if (process.env.NODE_ENV === 'development' && typeof window === 'undefined') {
return process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
}
// Client-side (browser) - use current window origin
if (typeof window !== 'undefined') {
return window.location.origin
}
// Production/Staging server-side - use environment variable or default
return process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
}
const BASE_URL = getBaseUrl()
// const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'localhost:3000'
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
const ApiFetch = treaty<AppServer>(BASE_URL)
export default ApiFetch
export default ApiFetch

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
---