Compare commits
1 Commits
tasks/fix-
...
fix-respon
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bc546e985 |
@@ -1,47 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.next
|
|
||||||
.git
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
bun-debug.log*
|
|
||||||
|
|
||||||
# Docker files
|
|
||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
||||||
|
|
||||||
# OS files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Markdown/Documentation
|
|
||||||
README.md
|
|
||||||
GEMINI.md
|
|
||||||
AGENTS.md
|
|
||||||
AUDIT_REPORT.md
|
|
||||||
QWEN.md
|
|
||||||
NOTE.md
|
|
||||||
task-project-apbdes.md
|
|
||||||
MUSIK_CREATE_ANALYSIS.md
|
|
||||||
darkMode.md
|
|
||||||
/test-results
|
|
||||||
/playwright-report
|
|
||||||
/tmp_assets
|
|
||||||
/foldergambar
|
|
||||||
/googleapi
|
|
||||||
/xx
|
|
||||||
/xx.ts
|
|
||||||
/xx.txt
|
|
||||||
/test.txt
|
|
||||||
/x.json
|
|
||||||
/x.sh
|
|
||||||
/xcoba.ts
|
|
||||||
/xcoba2.ts
|
|
||||||
/gambar.ttx
|
|
||||||
/test-berita-state.ts
|
|
||||||
44
.env.example
44
.env.example
@@ -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
|
|
||||||
@@ -1,52 +1,43 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
import { readFileSync, existsSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
// Function to manually load .env from project root if process.env is missing keys
|
// Fungsi untuk mencari string terpanjang dalam objek (biasanya balasan AI)
|
||||||
function loadEnv() {
|
function findLongestString(obj: any): string {
|
||||||
const envPath = join(process.cwd(), ".env");
|
let longest = "";
|
||||||
if (existsSync(envPath)) {
|
const search = (item: any) => {
|
||||||
const envContent = readFileSync(envPath, "utf-8");
|
if (typeof item === "string") {
|
||||||
const lines = envContent.split("\n");
|
if (item.length > longest.length) longest = item;
|
||||||
for (const line of lines) {
|
} else if (Array.isArray(item)) {
|
||||||
if (line && !line.startsWith("#")) {
|
item.forEach(search);
|
||||||
const [key, ...valueParts] = line.split("=");
|
} else if (item && typeof item === "object") {
|
||||||
if (key && valueParts.length > 0) {
|
Object.values(item).forEach(search);
|
||||||
const value = valueParts.join("=").trim().replace(/^["']|["']$/g, "");
|
|
||||||
process.env[key.trim()] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
search(obj);
|
||||||
|
return longest;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
// Ensure environment variables are loaded
|
|
||||||
loadEnv();
|
|
||||||
|
|
||||||
const inputRaw = readFileSync(0, "utf-8");
|
const inputRaw = readFileSync(0, "utf-8");
|
||||||
if (!inputRaw) return;
|
if (!inputRaw) return;
|
||||||
|
const input = JSON.parse(inputRaw);
|
||||||
|
|
||||||
let finalText = "";
|
// DEBUG: Lihat struktur asli di console terminal (stderr)
|
||||||
let sessionId = "web-desa-darmasaba";
|
console.error("DEBUG KEYS:", Object.keys(input));
|
||||||
|
|
||||||
try {
|
|
||||||
// Try parsing as JSON first
|
|
||||||
const input = JSON.parse(inputRaw);
|
|
||||||
sessionId = input.session_id || "web-desa-darmasaba";
|
|
||||||
finalText = typeof input === "string" ? input : (input.response || input.text || JSON.stringify(input));
|
|
||||||
} catch {
|
|
||||||
// If not JSON, use raw text
|
|
||||||
finalText = inputRaw;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BOT_TOKEN = process.env.BOT_TOKEN;
|
const BOT_TOKEN = process.env.BOT_TOKEN;
|
||||||
const CHAT_ID = process.env.CHAT_ID;
|
const CHAT_ID = process.env.CHAT_ID;
|
||||||
|
|
||||||
if (!BOT_TOKEN || !CHAT_ID) {
|
const sessionId = input.session_id || "unknown";
|
||||||
console.error("Missing BOT_TOKEN or CHAT_ID in environment variables");
|
|
||||||
return;
|
// Cari teks secara otomatis di seluruh objek JSON
|
||||||
|
let finalText = findLongestString(input.response || input);
|
||||||
|
|
||||||
|
if (!finalText || finalText.length < 5) {
|
||||||
|
finalText =
|
||||||
|
"Teks masih gagal diekstraksi. Struktur: " +
|
||||||
|
Object.keys(input).join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
@@ -54,7 +45,7 @@ async function run() {
|
|||||||
`🆔 Session: \`${sessionId}\` \n\n` +
|
`🆔 Session: \`${sessionId}\` \n\n` +
|
||||||
`🧠 Output:\n${finalText.substring(0, 3500)}`;
|
`🧠 Output:\n${finalText.substring(0, 3500)}`;
|
||||||
|
|
||||||
const res = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -64,13 +55,6 @@ async function run() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const errorData = await res.json();
|
|
||||||
console.error("Telegram API Error:", errorData);
|
|
||||||
} else {
|
|
||||||
console.log("Notification sent successfully!");
|
|
||||||
}
|
|
||||||
|
|
||||||
process.stdout.write(JSON.stringify({ status: "continue" }));
|
process.stdout.write(JSON.stringify({ status: "continue" }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Hook Error:", err);
|
console.error("Hook Error:", err);
|
||||||
|
|||||||
219
.github/workflows/build.yml
vendored
Normal file
219
.github/workflows/build.yml
vendored
Normal 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
|
||||||
56
.github/workflows/docker-publish.yml
vendored
56
.github/workflows/docker-publish.yml
vendored
@@ -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
|
|
||||||
106
.github/workflows/publish.yml
vendored
106
.github/workflows/publish.yml
vendored
@@ -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 }}"
|
|
||||||
60
.github/workflows/re-pull.yml
vendored
60
.github/workflows/re-pull.yml
vendored
@@ -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 }}"
|
|
||||||
26
.github/workflows/script/notify.sh
vendored
26
.github/workflows/script/notify.sh
vendored
@@ -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"}')"
|
|
||||||
120
.github/workflows/script/re-pull.sh
vendored
120
.github/workflows/script/re-pull.sh
vendored
@@ -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
55
.github/workflows/test.yml
vendored
Normal 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
5
.gitignore
vendored
@@ -29,9 +29,7 @@ yarn-error.log*
|
|||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env
|
# env
|
||||||
# env local files (keep .env.example)
|
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# QC
|
# QC
|
||||||
QC
|
QC
|
||||||
@@ -52,6 +50,9 @@ next-env.d.ts
|
|||||||
# cache
|
# cache
|
||||||
/cache
|
/cache
|
||||||
|
|
||||||
|
.github/
|
||||||
|
|
||||||
|
.env.*
|
||||||
|
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
68
Dockerfile
68
Dockerfile
@@ -1,68 +0,0 @@
|
|||||||
# ==============================
|
|
||||||
# Stage 1: Builder
|
|
||||||
# ==============================
|
|
||||||
FROM oven/bun:1-debian AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
libc6 \
|
|
||||||
git \
|
|
||||||
openssl \
|
|
||||||
ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY package.json bun.lockb* ./
|
|
||||||
|
|
||||||
ENV SHARP_IGNORE_GLOBAL_LIBVIPS=1
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
|
||||||
|
|
||||||
RUN bun install --frozen-lockfile
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN cp .env.example .env || true
|
|
||||||
|
|
||||||
ENV PRISMA_CLI_BINARY_TARGETS=debian-openssl-3.0.x
|
|
||||||
RUN bunx prisma generate
|
|
||||||
|
|
||||||
# Generate API types (opsional)
|
|
||||||
RUN bun run gen:api || echo "tidak ada gen api"
|
|
||||||
|
|
||||||
RUN bun run build
|
|
||||||
|
|
||||||
# ==============================
|
|
||||||
# Stage 2: Runner (Production)
|
|
||||||
# ==============================
|
|
||||||
FROM oven/bun:1-debian AS runner
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
ENV PRISMA_CLI_BINARY_TARGETS=debian-openssl-3.0.x
|
|
||||||
ENV PORT=3000
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
openssl \
|
|
||||||
ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
RUN groupadd --system --gid 1001 nodejs \
|
|
||||||
&& useradd --system --uid 1001 --gid nodejs nextjs
|
|
||||||
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/src/prisma ./src/prisma
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/next.config.* ./
|
|
||||||
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD ["bun", "start"]
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
serverExternalPackages: ['@elysiajs/static', 'elysia'],
|
|
||||||
experimental: {},
|
experimental: {},
|
||||||
allowedDevOrigins: [
|
allowedDevOrigins: [
|
||||||
"http://192.168.1.82:3000", // buat akses dari HP/device lain
|
"http://192.168.1.82:3000", // buat akses dari HP/device lain
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"test:api": "vitest run",
|
"test:api": "vitest run",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test": "bun run test:api && bun run test:e2e",
|
"test": "bun run test:api && bun run test:e2e"
|
||||||
"gen:api": ""
|
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "bun run prisma/seed.ts"
|
"seed": "bun run prisma/seed.ts"
|
||||||
@@ -71,7 +70,7 @@
|
|||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"extract-zip": "^2.0.1",
|
"extract-zip": "^2.0.1",
|
||||||
"form-data": "^4.0.2",
|
"form-data": "^4.0.2",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.23.5",
|
||||||
"get-port": "^7.1.0",
|
"get-port": "^7.1.0",
|
||||||
"iron-session": "^8.0.4",
|
"iron-session": "^8.0.4",
|
||||||
"jose": "^6.1.0",
|
"jose": "^6.1.0",
|
||||||
@@ -101,7 +100,7 @@
|
|||||||
"react-transition-group": "^4.4.5",
|
"react-transition-group": "^4.4.5",
|
||||||
"react-zoom-pan-pinch": "^3.7.0",
|
"react-zoom-pan-pinch": "^3.7.0",
|
||||||
"readdirp": "^4.1.1",
|
"readdirp": "^4.1.1",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^2.15.3",
|
||||||
"sharp": "^0.34.3",
|
"sharp": "^0.34.3",
|
||||||
"swr": "^2.3.2",
|
"swr": "^2.3.2",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
@@ -121,7 +120,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@vitest/ui": "^4.0.18",
|
"@vitest/ui": "^4.0.18",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.12",
|
"eslint-config-next": "15.1.6",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
"msw": "^2.12.9",
|
"msw": "^2.12.9",
|
||||||
"parcel": "^2.6.2",
|
"parcel": "^2.6.2",
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (e.g., Git)
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
|
|||||||
@@ -211,9 +211,6 @@ function ListKategoriPrestasi({ search }: { search: string }) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Modal Konfirmasi Hapus */}
|
{/* Modal Konfirmasi Hapus */}
|
||||||
<ModalKonfirmasiHapus
|
<ModalKonfirmasiHapus
|
||||||
opened={modalHapus}
|
opened={modalHapus}
|
||||||
|
|||||||
@@ -123,51 +123,37 @@ export default function CreateMusik() {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
// Upload cover image
|
// Upload cover image
|
||||||
console.log('Uploading cover image:', coverFile.name);
|
|
||||||
const coverRes = await ApiFetch.api.fileStorage.create.post({
|
const coverRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
file: coverFile,
|
file: coverFile,
|
||||||
name: coverFile.name,
|
name: coverFile.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Cover upload response:', coverRes);
|
|
||||||
const coverUploaded = coverRes.data?.data;
|
const coverUploaded = coverRes.data?.data;
|
||||||
if (!coverUploaded?.id) {
|
if (!coverUploaded?.id) {
|
||||||
console.error('Cover upload failed:', coverRes);
|
return toast.error('Gagal mengunggah cover, silakan coba lagi');
|
||||||
toast.error('Gagal mengunggah cover, silakan coba lagi');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
musikState.musik.create.form.coverImageId = coverUploaded.id;
|
musikState.musik.create.form.coverImageId = coverUploaded.id;
|
||||||
|
|
||||||
// Upload audio file
|
// Upload audio file
|
||||||
console.log('Uploading audio file:', audioFile.name);
|
|
||||||
const audioRes = await ApiFetch.api.fileStorage.create.post({
|
const audioRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
file: audioFile,
|
file: audioFile,
|
||||||
name: audioFile.name,
|
name: audioFile.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Audio upload response:', audioRes);
|
|
||||||
const audioUploaded = audioRes.data?.data;
|
const audioUploaded = audioRes.data?.data;
|
||||||
if (!audioUploaded?.id) {
|
if (!audioUploaded?.id) {
|
||||||
console.error('Audio upload failed:', audioRes);
|
return toast.error('Gagal mengunggah audio, silakan coba lagi');
|
||||||
toast.error('Gagal mengunggah audio, silakan coba lagi');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
musikState.musik.create.form.audioFileId = audioUploaded.id;
|
musikState.musik.create.form.audioFileId = audioUploaded.id;
|
||||||
|
|
||||||
// Create musik entry
|
|
||||||
console.log('Creating musik entry with form:', musikState.musik.create.form);
|
|
||||||
await musikState.musik.create.create();
|
await musikState.musik.create.create();
|
||||||
|
|
||||||
resetForm();
|
resetForm();
|
||||||
router.push('/admin/musik');
|
router.push('/admin/musik');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating musik:', {
|
console.error('Error creating musik:', error);
|
||||||
error,
|
|
||||||
message: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
});
|
|
||||||
toast.error('Terjadi kesalahan saat membuat musik');
|
toast.error('Terjadi kesalahan saat membuat musik');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import AjukanPermohonan from "./layanan/ajukan_permohonan";
|
|||||||
import Musik from "./musik";
|
import Musik from "./musik";
|
||||||
|
|
||||||
|
|
||||||
const Desa = new Elysia({ prefix: "/desa", tags: ["Desa"] })
|
const Desa = new Elysia({ prefix: "/api/desa", tags: ["Desa"] })
|
||||||
.use(Berita)
|
.use(Berita)
|
||||||
.use(Pengumuman)
|
.use(Pengumuman)
|
||||||
.use(ProfileDesa)
|
.use(ProfileDesa)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import PendapatanAsliDesa from "./pendapatan-asli-desa";
|
|||||||
import StrukturOrganisasi from "./struktur-bumdes";
|
import StrukturOrganisasi from "./struktur-bumdes";
|
||||||
|
|
||||||
const Ekonomi = new Elysia({
|
const Ekonomi = new Elysia({
|
||||||
prefix: "/ekonomi",
|
prefix: "/api/ekonomi",
|
||||||
tags: ["Ekonomi"],
|
tags: ["Ekonomi"],
|
||||||
})
|
})
|
||||||
.use(PasarDesa)
|
.use(PasarDesa)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fileStorageFindMany } from "./_lib/findMany";
|
|||||||
import fileStorageDelete from "./_lib/del";
|
import fileStorageDelete from "./_lib/del";
|
||||||
|
|
||||||
const FileStorage = new Elysia({
|
const FileStorage = new Elysia({
|
||||||
prefix: "/fileStorage",
|
prefix: "/api/fileStorage",
|
||||||
tags: ["FileStorage"],
|
tags: ["FileStorage"],
|
||||||
})
|
})
|
||||||
.post("/create", fileStorageCreate, {
|
.post("/create", fileStorageCreate, {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import LayananOnlineDesa from "./layanan-online-desa";
|
|||||||
import MitraKolaborasi from "./kolaborasi-inovasi/mitra-kolaborasi";
|
import MitraKolaborasi from "./kolaborasi-inovasi/mitra-kolaborasi";
|
||||||
|
|
||||||
const Inovasi = new Elysia({
|
const Inovasi = new Elysia({
|
||||||
prefix: "/inovasi",
|
prefix: "/api/inovasi",
|
||||||
tags: ["Inovasi"],
|
tags: ["Inovasi"],
|
||||||
})
|
})
|
||||||
.use(DesaDigital)
|
.use(DesaDigital)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import KontakDaruratKeamanan from "./kontak-darurat-keamanan";
|
|||||||
import KontakItem from "./kontak-darurat-keamanan/kontak-item";
|
import KontakItem from "./kontak-darurat-keamanan/kontak-item";
|
||||||
import LayananPolsek from "./polsek-terdekat/layanan-polsek";
|
import LayananPolsek from "./polsek-terdekat/layanan-polsek";
|
||||||
|
|
||||||
const Keamanan = new Elysia({ prefix: "/keamanan", tags: ["Keamanan"] })
|
const Keamanan = new Elysia({ prefix: "/api/keamanan", tags: ["Keamanan"] })
|
||||||
.use(KeamananLingkungan)
|
.use(KeamananLingkungan)
|
||||||
.use(PolsekTerdekat)
|
.use(PolsekTerdekat)
|
||||||
.use(PencegahanKriminalitas)
|
.use(PencegahanKriminalitas)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import TarifLayanan from "./data_kesehatan_warga/fasilitas_kesehatan/tarif-layan
|
|||||||
|
|
||||||
|
|
||||||
const Kesehatan = new Elysia({
|
const Kesehatan = new Elysia({
|
||||||
prefix: "/kesehatan",
|
prefix: "/api/kesehatan",
|
||||||
tags: ["Kesehatan"],
|
tags: ["Kesehatan"],
|
||||||
})
|
})
|
||||||
.use(PersentaseKelahiranKematian)
|
.use(PersentaseKelahiranKematian)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import UmurResponden from "./indeks_kepuasan/umur-responden";
|
|||||||
import Responden from "./indeks_kepuasan/responden";
|
import Responden from "./indeks_kepuasan/responden";
|
||||||
|
|
||||||
const LandingPage = new Elysia({
|
const LandingPage = new Elysia({
|
||||||
prefix: "/landingpage",
|
prefix: "/api/landingpage",
|
||||||
tags: ["Landing Page/Profile"]
|
tags: ["Landing Page/Profile"]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import KategoriKegiatan from "./gotong-royong/kategori-kegiatan";
|
|||||||
import KeteranganBankSampahTerdekat from "./pengelolaan-sampah/keterangan-bank-sampah";
|
import KeteranganBankSampahTerdekat from "./pengelolaan-sampah/keterangan-bank-sampah";
|
||||||
|
|
||||||
const Lingkungan = new Elysia({
|
const Lingkungan = new Elysia({
|
||||||
prefix: "/lingkungan",
|
prefix: "/api/lingkungan",
|
||||||
tags: ["Lingkungan"],
|
tags: ["Lingkungan"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Beasiswa from "./beasiswa-desa";
|
|||||||
import PerpustakaanDigital from "./perpustakaan-digital";
|
import PerpustakaanDigital from "./perpustakaan-digital";
|
||||||
|
|
||||||
const Pendidikan = new Elysia({
|
const Pendidikan = new Elysia({
|
||||||
prefix: "/pendidikan",
|
prefix: "/api/pendidikan",
|
||||||
tags: ["Pendidikan"]
|
tags: ["Pendidikan"]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import GrafikHasilKepuasanMasyarakat from "./ikm/grafik_hasil_kepuasan_masyaraka
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const PPID = new Elysia({ prefix: "/ppid", tags: ["PPID"] })
|
const PPID = new Elysia({ prefix: "/api/ppid", tags: ["PPID"] })
|
||||||
.use(ProfilePPID)
|
.use(ProfilePPID)
|
||||||
.use(DaftarInformasiPublik)
|
.use(DaftarInformasiPublik)
|
||||||
.use(GrafikHasilKepuasanMasyarakat)
|
.use(GrafikHasilKepuasanMasyarakat)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Elysia from "elysia";
|
|||||||
import searchFindMany from "./findMany";
|
import searchFindMany from "./findMany";
|
||||||
|
|
||||||
const Search = new Elysia({
|
const Search = new Elysia({
|
||||||
prefix: "/search",
|
prefix: "/api/search",
|
||||||
tags: ["Search"],
|
tags: ["Search"],
|
||||||
})
|
})
|
||||||
.get("/findMany", searchFindMany);
|
.get("/findMany", searchFindMany);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import userDelete from "./del"; // `delete` nggak boleh jadi nama file JS langsu
|
|||||||
import userUpdate from "./updt";
|
import userUpdate from "./updt";
|
||||||
import userDeleteAccount from "./delUser";
|
import userDeleteAccount from "./delUser";
|
||||||
|
|
||||||
const User = new Elysia({ prefix: "/user" })
|
const User = new Elysia({ prefix: "/api/user" })
|
||||||
.get("/findMany", userFindMany)
|
.get("/findMany", userFindMany)
|
||||||
.get("/findUnique/:id", userFindUnique)
|
.get("/findUnique/:id", userFindUnique)
|
||||||
.put("/del/:id", userDelete, {
|
.put("/del/:id", userDelete, {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import roleFindUnique from "./findUnique";
|
|||||||
import roleUpdate from "./updt";
|
import roleUpdate from "./updt";
|
||||||
|
|
||||||
const Role = new Elysia({
|
const Role = new Elysia({
|
||||||
prefix: "/role",
|
prefix: "/api/role",
|
||||||
tags: ["User / Role"],
|
tags: ["User / Role"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -47,16 +47,15 @@ fs.mkdir(UPLOAD_DIR_IMAGE, {
|
|||||||
|
|
||||||
const corsConfig = {
|
const corsConfig = {
|
||||||
origin: [
|
origin: [
|
||||||
"*", // Allow all origins - must be first when using credentials: true
|
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://localhost:3001",
|
"http://localhost:3001",
|
||||||
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
|
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
|
||||||
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
|
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
|
||||||
"https://desa-darmasaba-stg.wibudev.com",
|
"*", // Allow all origins in development
|
||||||
],
|
],
|
||||||
methods: ["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] as HTTPMethod[],
|
methods: ["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] as HTTPMethod[],
|
||||||
allowedHeaders: ["Content-Type", "Authorization", "Accept", "*"],
|
allowedHeaders: ["Content-Type", "Authorization", "*"],
|
||||||
exposedHeaders: ["Content-Range", "X-Content-Range", "*"],
|
exposedHeaders: "*",
|
||||||
maxAge: 86400, // 24 hours
|
maxAge: 86400, // 24 hours
|
||||||
credentials: true,
|
credentials: true,
|
||||||
};
|
};
|
||||||
@@ -67,7 +66,7 @@ async function layanan() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Utils = new Elysia({
|
const Utils = new Elysia({
|
||||||
prefix: "/utils",
|
prefix: "/api/utils",
|
||||||
tags: ["Utils"],
|
tags: ["Utils"],
|
||||||
}).get("/version", async () => {
|
}).get("/version", async () => {
|
||||||
const packageJson = await fs.readFile(
|
const packageJson = await fs.readFile(
|
||||||
@@ -81,7 +80,8 @@ const Utils = new Elysia({
|
|||||||
if (!process.env.WIBU_UPLOAD_DIR)
|
if (!process.env.WIBU_UPLOAD_DIR)
|
||||||
throw new Error("WIBU_UPLOAD_DIR is not defined");
|
throw new Error("WIBU_UPLOAD_DIR is not defined");
|
||||||
|
|
||||||
const ApiServer = new Elysia({ prefix: "/api" })
|
const ApiServer = new Elysia()
|
||||||
|
.use(swagger({ path: "/api/docs" }))
|
||||||
.use(
|
.use(
|
||||||
staticPlugin({
|
staticPlugin({
|
||||||
assets: UPLOAD_DIR,
|
assets: UPLOAD_DIR,
|
||||||
@@ -89,25 +89,6 @@ const ApiServer = new Elysia({ prefix: "/api" })
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.use(cors(corsConfig))
|
.use(cors(corsConfig))
|
||||||
.use(
|
|
||||||
swagger({
|
|
||||||
path: "/docs",
|
|
||||||
documentation: {
|
|
||||||
info: {
|
|
||||||
title: "Desa Darmasaba API Documentation",
|
|
||||||
version: "1.0.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.onError(({ code }) => {
|
|
||||||
if (code === "NOT_FOUND") {
|
|
||||||
return {
|
|
||||||
status: 404,
|
|
||||||
body: "Route not found :(",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.use(Utils)
|
.use(Utils)
|
||||||
.use(FileStorage)
|
.use(FileStorage)
|
||||||
.use(LandingPage)
|
.use(LandingPage)
|
||||||
@@ -122,114 +103,126 @@ const ApiServer = new Elysia({ prefix: "/api" })
|
|||||||
.use(User)
|
.use(User)
|
||||||
.use(Role)
|
.use(Role)
|
||||||
.use(Search)
|
.use(Search)
|
||||||
.get("/layanan", layanan)
|
|
||||||
.get("/potensi", getPotensi)
|
.onError(({ code }) => {
|
||||||
.get(
|
if (code === "NOT_FOUND") {
|
||||||
"/img/:name",
|
return {
|
||||||
({ params, query }) => {
|
status: 404,
|
||||||
return img({
|
body: "Route not found :(",
|
||||||
name: params.name,
|
};
|
||||||
UPLOAD_DIR_IMAGE,
|
}
|
||||||
ROOT,
|
})
|
||||||
size: query.size,
|
.group("/api", (app) =>
|
||||||
});
|
app
|
||||||
},
|
.get("/layanan", layanan)
|
||||||
{
|
.get("/potensi", getPotensi)
|
||||||
params: t.Object({
|
.get(
|
||||||
name: t.String(),
|
"/img/:name",
|
||||||
}),
|
({ params, query }) => {
|
||||||
query: t.Optional(
|
return img({
|
||||||
t.Object({
|
name: params.name,
|
||||||
size: t.Optional(t.Number()),
|
UPLOAD_DIR_IMAGE,
|
||||||
}),
|
ROOT,
|
||||||
|
size: query.size,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: t.Object({
|
||||||
|
name: t.String(),
|
||||||
|
}),
|
||||||
|
query: t.Optional(
|
||||||
|
t.Object({
|
||||||
|
size: t.Optional(t.Number()),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.delete(
|
||||||
|
"/img/:name",
|
||||||
|
({ params }) => {
|
||||||
|
return imgDel({
|
||||||
|
name: params.name,
|
||||||
|
UPLOAD_DIR_IMAGE,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: t.Object({
|
||||||
|
name: t.String(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/imgs",
|
||||||
|
({ query }) => {
|
||||||
|
return imgs({
|
||||||
|
search: query.search,
|
||||||
|
page: query.page,
|
||||||
|
count: query.count,
|
||||||
|
UPLOAD_DIR_IMAGE,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: t.Optional(
|
||||||
|
t.Object({
|
||||||
|
page: t.Number({ default: 1 }),
|
||||||
|
count: t.Number({ default: 10 }),
|
||||||
|
search: t.String({ default: "" }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/upl-img",
|
||||||
|
({ body }) => {
|
||||||
|
console.log(body.title);
|
||||||
|
return uplImg({ files: body.files, UPLOAD_DIR_IMAGE });
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
title: t.String(),
|
||||||
|
files: t.Files({ multiple: true }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/upl-img-single",
|
||||||
|
({ body }) => {
|
||||||
|
return uplImgSingle({
|
||||||
|
fileName: body.name,
|
||||||
|
file: body.file,
|
||||||
|
UPLOAD_DIR_IMAGE,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
name: t.String(),
|
||||||
|
file: t.File(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/upl-csv-single",
|
||||||
|
({ body }) => {
|
||||||
|
return uplCsvSingle({ fileName: body.name, file: body.file });
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
name: t.String(),
|
||||||
|
file: t.File(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/upl-csv",
|
||||||
|
({ body }) => {
|
||||||
|
return uplCsv({ files: body.files });
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
files: t.Files(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
},
|
|
||||||
)
|
|
||||||
.delete(
|
|
||||||
"/img/:name",
|
|
||||||
({ params }) => {
|
|
||||||
return imgDel({
|
|
||||||
name: params.name,
|
|
||||||
UPLOAD_DIR_IMAGE,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{
|
|
||||||
params: t.Object({
|
|
||||||
name: t.String(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.get(
|
|
||||||
"/imgs",
|
|
||||||
({ query }) => {
|
|
||||||
return imgs({
|
|
||||||
search: query.search,
|
|
||||||
page: query.page,
|
|
||||||
count: query.count,
|
|
||||||
UPLOAD_DIR_IMAGE,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query: t.Optional(
|
|
||||||
t.Object({
|
|
||||||
page: t.Number({ default: 1 }),
|
|
||||||
count: t.Number({ default: 10 }),
|
|
||||||
search: t.String({ default: "" }),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/upl-img",
|
|
||||||
({ body }) => {
|
|
||||||
console.log(body.title);
|
|
||||||
return uplImg({ files: body.files, UPLOAD_DIR_IMAGE });
|
|
||||||
},
|
|
||||||
{
|
|
||||||
body: t.Object({
|
|
||||||
title: t.String(),
|
|
||||||
files: t.Files({ multiple: true }),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/upl-img-single",
|
|
||||||
({ body }) => {
|
|
||||||
return uplImgSingle({
|
|
||||||
fileName: body.name,
|
|
||||||
file: body.file,
|
|
||||||
UPLOAD_DIR_IMAGE,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{
|
|
||||||
body: t.Object({
|
|
||||||
name: t.String(),
|
|
||||||
file: t.File(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/upl-csv-single",
|
|
||||||
({ body }) => {
|
|
||||||
return uplCsvSingle({ fileName: body.name, file: body.file });
|
|
||||||
},
|
|
||||||
{
|
|
||||||
body: t.Object({
|
|
||||||
name: t.String(),
|
|
||||||
file: t.File(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/upl-csv",
|
|
||||||
({ body }) => {
|
|
||||||
return uplCsv({ files: body.files });
|
|
||||||
},
|
|
||||||
{
|
|
||||||
body: t.Object({
|
|
||||||
files: t.Files(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const GET = ApiServer.handle;
|
export const GET = ApiServer.handle;
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -2,10 +2,16 @@
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { randomOTP } from "../_lib/randomOTP";
|
import { randomOTP } from "../_lib/randomOTP";
|
||||||
import { sendCodeOtp } from "../_lib/sendCodeOtp";
|
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "Method Not Allowed" },
|
||||||
|
{ status: 405 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { nomor } = await req.json();
|
const { nomor } = await req.json();
|
||||||
|
|
||||||
@@ -27,34 +33,32 @@ export async function POST(req: Request) {
|
|||||||
const codeOtp = randomOTP();
|
const codeOtp = randomOTP();
|
||||||
const otpNumber = Number(codeOtp);
|
const otpNumber = Number(codeOtp);
|
||||||
|
|
||||||
console.log(`🔑 DEBUG OTP [${nomor}]: ${codeOtp}`);
|
const waMessage = `Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.\n\n>> Kode OTP anda: ${codeOtp}.`;
|
||||||
|
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
|
||||||
|
|
||||||
|
console.log("🔍 Debug WA URL:", waUrl);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const waResponse = await sendCodeOtp({
|
const res = await fetch(waUrl);
|
||||||
nomor,
|
const sendWa = await res.json();
|
||||||
codeOtp,
|
console.log("📱 WA Response:", sendWa);
|
||||||
});
|
|
||||||
|
|
||||||
if (!waResponse.ok) {
|
if (sendWa.status !== "success") {
|
||||||
console.error(
|
console.error("❌ WA Service Error:", sendWa);
|
||||||
`⚠️ WA Service HTTP Error: ${waResponse.status} ${waResponse.statusText}. Continuing since OTP is logged.`
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: "Gagal mengirim OTP via WhatsApp",
|
||||||
|
debug: sendWa
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
console.log(`💡 Use this OTP to login: ${codeOtp}`);
|
|
||||||
} else {
|
|
||||||
const sendWa = await waResponse.json();
|
|
||||||
console.log("📱 WA Response:", sendWa);
|
|
||||||
|
|
||||||
if (sendWa.status !== "success") {
|
|
||||||
console.error("⚠️ WA Service Logic Error:", sendWa);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (waError: unknown) {
|
} catch (waError) {
|
||||||
const errorMessage =
|
console.error("❌ Fetch WA Error:", waError);
|
||||||
waError instanceof Error ? waError.message : String(waError);
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "Terjadi kesalahan saat mengirim WA" },
|
||||||
console.error(
|
{ status: 500 }
|
||||||
"⚠️ WA Connection Exception. Continuing since OTP is logged.",
|
|
||||||
errorMessage
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,12 +67,12 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set("auth_flow", "login", {
|
cookieStore.set('auth_flow', 'login', {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: "lax",
|
sameSite: 'lax',
|
||||||
maxAge: 60 * 5,
|
maxAge: 60 * 5, // 5 menit
|
||||||
path: "/",
|
path: '/'
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -86,12 +90,11 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Error Login:", error);
|
console.error("❌ Error Login:", error);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, message: "Terjadi kesalahan saat login" },
|
{ success: false, message: "Terjadi kesalahan saat login" },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,22 +22,14 @@ export async function POST(req: Request) {
|
|||||||
// ✅ Generate dan kirim OTP
|
// ✅ Generate dan kirim OTP
|
||||||
const codeOtp = randomOTP();
|
const codeOtp = randomOTP();
|
||||||
const otpNumber = Number(codeOtp);
|
const otpNumber = Number(codeOtp);
|
||||||
console.log(`🔑 DEBUG REGISTER OTP [${nomor}]: ${codeOtp}`);
|
|
||||||
|
|
||||||
const waMessage = `Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
|
const waMessage = `Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
|
||||||
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
|
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
|
||||||
|
const waRes = await fetch(waUrl);
|
||||||
try {
|
const waData = await waRes.json();
|
||||||
const waRes = await fetch(waUrl);
|
|
||||||
if (!waRes.ok) {
|
if (waData.status !== "success") {
|
||||||
console.warn(`⚠️ WA Service HTTP Error (Register): ${waRes.status} ${waRes.statusText}. Continuing since OTP is logged.`);
|
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP via WhatsApp' }, { status: 400 });
|
||||||
} else {
|
|
||||||
const waData = await waRes.json();
|
|
||||||
console.log("📱 WA Response (Register):", waData);
|
|
||||||
}
|
|
||||||
} catch (waError: unknown) {
|
|
||||||
const errorMessage = waError instanceof Error ? waError.message : String(waError);
|
|
||||||
console.warn("⚠️ WA Connection Exception (Register). Continuing since OTP is logged.", errorMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Simpan OTP ke database
|
// ✅ Simpan OTP ke database
|
||||||
|
|||||||
@@ -17,23 +17,18 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const codeOtp = randomOTP();
|
const codeOtp = randomOTP();
|
||||||
const otpNumber = Number(codeOtp);
|
const otpNumber = Number(codeOtp);
|
||||||
console.log(`🔑 DEBUG RESEND OTP [${nomor}]: ${codeOtp}`);
|
|
||||||
|
|
||||||
// Kirim OTP via WhatsApp
|
// Kirim OTP via WhatsApp
|
||||||
const waMessage = `Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.\n\n>> Kode OTP anda: ${codeOtp}.`;
|
const waMessage = `Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.\n\n>> Kode OTP anda: ${codeOtp}.`;
|
||||||
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
|
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
|
||||||
|
const waRes = await fetch(waUrl);
|
||||||
try {
|
const waData = await waRes.json();
|
||||||
const waRes = await fetch(waUrl);
|
|
||||||
if (!waRes.ok) {
|
if (waData.status !== "success") {
|
||||||
console.warn(`⚠️ WA Service HTTP Error (Resend): ${waRes.status} ${waRes.statusText}. Continuing since OTP is logged.`);
|
return NextResponse.json(
|
||||||
} else {
|
{ success: false, message: "Gagal mengirim OTP via WhatsApp" },
|
||||||
const waData = await waRes.json();
|
{ status: 400 }
|
||||||
console.log("📱 WA Response (Resend):", waData);
|
);
|
||||||
}
|
|
||||||
} catch (waError: unknown) {
|
|
||||||
const errorMessage = waError instanceof Error ? waError.message : String(waError);
|
|
||||||
console.warn("⚠️ WA Connection Exception (Resend). Continuing since OTP is logged.", errorMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simpan OTP ke database
|
// Simpan OTP ke database
|
||||||
|
|||||||
@@ -21,22 +21,14 @@ export async function POST(req: Request) {
|
|||||||
// Generate OTP
|
// Generate OTP
|
||||||
const codeOtp = randomOTP();
|
const codeOtp = randomOTP();
|
||||||
const otpNumber = Number(codeOtp);
|
const otpNumber = Number(codeOtp);
|
||||||
console.log(`🔑 DEBUG SEND-OTP-REGISTER [${nomor}]: ${codeOtp}`);
|
|
||||||
|
|
||||||
// Kirim WA
|
// Kirim WA
|
||||||
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
|
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
|
||||||
|
const res = await fetch(waUrl);
|
||||||
try {
|
const sendWa = await res.json();
|
||||||
const res = await fetch(waUrl);
|
|
||||||
if (!res.ok) {
|
if (sendWa.status !== "success") {
|
||||||
console.warn(`⚠️ WA Service HTTP Error (SendOTPRegister): ${res.status} ${res.statusText}. Continuing since OTP is logged.`);
|
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP' }, { status: 400 });
|
||||||
} else {
|
|
||||||
const sendWa = await res.json();
|
|
||||||
console.log("📱 WA Response (SendOTPRegister):", sendWa);
|
|
||||||
}
|
|
||||||
} catch (waError: unknown) {
|
|
||||||
const errorMessage = waError instanceof Error ? waError.message : String(waError);
|
|
||||||
console.warn("⚠️ WA Connection Exception (SendOTPRegister). Continuing since OTP is logged.", errorMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simpan OTP
|
// Simpan OTP
|
||||||
|
|||||||
@@ -92,10 +92,10 @@ const MusicPlayer = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 'md', md: 100 }} py="xl">
|
<Box px={{ base: 'xs', sm: 'md', md: 100 }} py="xl">
|
||||||
<Paper
|
<Paper
|
||||||
mx="auto"
|
mx="auto"
|
||||||
p="xl"
|
p={{ base: 'md', sm: 'xl' }}
|
||||||
radius="lg"
|
radius="lg"
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
bg="white"
|
bg="white"
|
||||||
@@ -105,42 +105,52 @@ const MusicPlayer = () => {
|
|||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<BackButton />
|
<BackButton />
|
||||||
<Group justify="space-between" mb="xl" mt={"md"}>
|
<Flex
|
||||||
|
justify="space-between"
|
||||||
|
align={{ base: 'flex-start', sm: 'center' }}
|
||||||
|
direction={{ base: 'column', sm: 'row' }}
|
||||||
|
gap="md"
|
||||||
|
mb="xl"
|
||||||
|
mt="md"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<Text size="32px" fw={700} c="#0B4F78">Selamat Datang Kembali</Text>
|
<Text fz={{ base: '24px', sm: '32px' }} fw={700} c="#0B4F78" lh={1.2}>Selamat Datang Kembali</Text>
|
||||||
<Text size="md" c="#5A6C7D">Temukan musik favorit Anda hari ini</Text>
|
<Text size="sm" c="#5A6C7D">Temukan musik favorit Anda hari ini</Text>
|
||||||
</div>
|
</div>
|
||||||
<Group gap="md">
|
<TextInput
|
||||||
<TextInput
|
placeholder="Cari lagu..."
|
||||||
placeholder="Cari lagu..."
|
leftSection={<IconSearch size={18} />}
|
||||||
leftSection={<IconSearch size={18} />}
|
radius="xl"
|
||||||
radius="xl"
|
w={{ base: '100%', sm: 280 }}
|
||||||
w={280}
|
value={search}
|
||||||
value={search}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
styles={{ input: { backgroundColor: '#fff' } }}
|
||||||
styles={{ input: { backgroundColor: '#fff' } }}
|
/>
|
||||||
/>
|
</Flex>
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
<div>
|
<div>
|
||||||
<Text size="xl" fw={700} c="#0B4F78" mb="md">Sedang Diputar</Text>
|
<Text size="xl" fw={700} c="#0B4F78" mb="md">Sedang Diputar</Text>
|
||||||
{currentSong ? (
|
{currentSong ? (
|
||||||
<Card radius="md" p="xl" shadow="md">
|
<Card radius="md" p={{ base: 'md', sm: 'xl' }} shadow="md" withBorder>
|
||||||
<Group align="center" gap="xl">
|
<Flex
|
||||||
|
direction={{ base: 'column', sm: 'row' }}
|
||||||
|
align="center"
|
||||||
|
gap={{ base: 'md', sm: 'xl' }}
|
||||||
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={currentSong.coverImage?.link || '/mp3-logo.png'}
|
src={currentSong.coverImage?.link || '/mp3-logo.png'}
|
||||||
size={180}
|
size={120}
|
||||||
radius="md"
|
radius="md"
|
||||||
/>
|
/>
|
||||||
<Stack gap="md" style={{ flex: 1 }}>
|
<Stack gap="md" style={{ flex: 1, width: '100%' }}>
|
||||||
<div>
|
<Box ta={{ base: 'center', sm: 'left' }}>
|
||||||
<Text size="28px" fw={700} c="#0B4F78">{currentSong.judul}</Text>
|
<Text fz={{ base: '20px', sm: '28px' }} fw={700} c="#0B4F78" lineClamp={1}>{currentSong.judul}</Text>
|
||||||
<Text size="lg" c="#5A6C7D">{currentSong.artis}</Text>
|
<Text size="lg" c="#5A6C7D">{currentSong.artis}</Text>
|
||||||
{currentSong.genre && (
|
{currentSong.genre && (
|
||||||
<Badge mt="xs" color="#0B4F78" variant="light">{currentSong.genre}</Badge>
|
<Badge mt="xs" color="#0B4F78" variant="light">{currentSong.genre}</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
<Group gap="xs" align="center">
|
<Group gap="xs" align="center">
|
||||||
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text>
|
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text>
|
||||||
<Slider
|
<Slider
|
||||||
@@ -155,7 +165,7 @@ const MusicPlayer = () => {
|
|||||||
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration || 0)}</Text>
|
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration || 0)}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Flex>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card radius="md" p="xl" shadow="md">
|
<Card radius="md" p="xl" shadow="md">
|
||||||
@@ -175,28 +185,29 @@ const MusicPlayer = () => {
|
|||||||
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}>
|
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}>
|
||||||
<Card
|
<Card
|
||||||
radius="md"
|
radius="md"
|
||||||
p="md"
|
p="sm"
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
|
withBorder
|
||||||
style={{
|
style={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
border: currentSong?.id === song.id ? '2px solid #0B4F78' : '2px solid transparent',
|
borderColor: currentSong?.id === song.id ? '#0B4F78' : 'transparent',
|
||||||
|
backgroundColor: currentSong?.id === song.id ? '#F0F7FA' : 'white',
|
||||||
transition: 'all 0.2s'
|
transition: 'all 0.2s'
|
||||||
}}
|
}}
|
||||||
onClick={() => playSong(song)}
|
onClick={() => playSong(song)}
|
||||||
>
|
>
|
||||||
<Group gap="md" align="center">
|
<Group gap="sm" align="center" wrap="nowrap">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={song.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
|
src={song.coverImage?.link || '/mp3-logo.png'}
|
||||||
size={64}
|
size={50}
|
||||||
radius="md"
|
radius="md"
|
||||||
/>
|
/>
|
||||||
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
|
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text size="sm" fw={600} c="#0B4F78" truncate>{song.judul}</Text>
|
<Text size="sm" fw={600} c="#0B4F78" truncate>{song.judul}</Text>
|
||||||
<Text size="xs" c="#5A6C7D">{song.artis}</Text>
|
<Text size="xs" c="#5A6C7D" truncate>{song.artis}</Text>
|
||||||
<Text size="xs" c="#8A9BA8">{song.durasi}</Text>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
{currentSong?.id === song.id && isPlaying && (
|
{currentSong?.id === song.id && isPlaying && (
|
||||||
<Badge color="#0B4F78" variant="filled">Memutar</Badge>
|
<Badge color="#0B4F78" variant="filled" size="xs">Playing</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -207,34 +218,42 @@ const MusicPlayer = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
{/* Control Player Section */}
|
||||||
<Paper
|
<Paper
|
||||||
mt="xl"
|
mt="xl"
|
||||||
mx="auto"
|
mx="auto"
|
||||||
p="xl"
|
p={{ base: 'md', sm: 'xl' }}
|
||||||
radius="lg"
|
radius="lg"
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
bg="white"
|
bg="white"
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid #eaeaea',
|
border: '1px solid #eaeaea',
|
||||||
|
position: 'sticky',
|
||||||
|
bottom: 20,
|
||||||
|
zIndex: 10
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Flex align="center" justify="space-between" gap="xl" h="100%">
|
<Flex
|
||||||
<Group gap="md" style={{ flex: 1 }}>
|
direction={{ base: 'column', md: 'row' }}
|
||||||
|
align="center"
|
||||||
|
justify="space-between"
|
||||||
|
gap={{ base: 'md', md: 'xl' }}
|
||||||
|
>
|
||||||
|
{/* Song Info */}
|
||||||
|
<Group gap="md" style={{ flex: 1, width: '100%' }} wrap="nowrap">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={currentSong?.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
|
src={currentSong?.coverImage?.link || '/mp3-logo.png'}
|
||||||
size={56}
|
size={48}
|
||||||
radius="md"
|
radius="md"
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
{currentSong ? (
|
{currentSong ? (
|
||||||
<>
|
<>
|
||||||
<Text size="sm" fw={600} c="#0B4F78" truncate>{currentSong.judul}</Text>
|
<Text size="sm" fw={600} c="#0B4F78" truncate>{currentSong.judul}</Text>
|
||||||
<Text size="xs" c="#5A6C7D">{currentSong.artis}</Text>
|
<Text size="xs" c="#5A6C7D" truncate>{currentSong.artis}</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Text size="sm" c="dimmed">Tidak ada lagu</Text>
|
<Text size="sm" c="dimmed">Tidak ada lagu</Text>
|
||||||
@@ -242,29 +261,31 @@ const MusicPlayer = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Stack gap="xs" style={{ flex: 1 }} align="center">
|
{/* Controls + Progress */}
|
||||||
<Group gap="md">
|
<Stack gap="xs" style={{ flex: 2, width: '100%' }} align="center">
|
||||||
|
<Group gap="sm">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={isShuffle ? 'filled' : 'subtle'}
|
variant={isShuffle ? 'filled' : 'subtle'}
|
||||||
color="#0B4F78"
|
color="#0B4F78"
|
||||||
onClick={toggleShuffleHandler}
|
onClick={toggleShuffleHandler}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
|
size={48}
|
||||||
>
|
>
|
||||||
{isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />}
|
{isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl" onClick={skipBack}>
|
<ActionIcon variant="light" color="#0B4F78" size={48} radius="xl" onClick={skipBack}>
|
||||||
<IconPlayerSkipBackFilled size={20} />
|
<IconPlayerSkipBackFilled size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color="#0B4F78"
|
color="#0B4F78"
|
||||||
size={56}
|
size={48}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
onClick={togglePlayPauseHandler}
|
onClick={togglePlayPauseHandler}
|
||||||
>
|
>
|
||||||
{isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />}
|
{isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl" onClick={skipForward}>
|
<ActionIcon variant="light" color="#0B4F78" size={48} radius="xl" onClick={skipForward}>
|
||||||
<IconPlayerSkipForwardFilled size={20} />
|
<IconPlayerSkipForwardFilled size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@@ -272,6 +293,7 @@ const MusicPlayer = () => {
|
|||||||
color="#0B4F78"
|
color="#0B4F78"
|
||||||
onClick={toggleRepeatHandler}
|
onClick={toggleRepeatHandler}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
|
size="md"
|
||||||
>
|
>
|
||||||
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -290,7 +312,8 @@ const MusicPlayer = () => {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Group gap="xs" style={{ flex: 1 }} justify="flex-end">
|
{/* Volume Control - Hidden on mobile, shown on md and up */}
|
||||||
|
<Group gap="xs" style={{ flex: 1 }} justify="flex-end" visibleFrom="md">
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={toggleMuteHandler}>
|
<ActionIcon variant="subtle" color="gray" onClick={toggleMuteHandler}>
|
||||||
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
|
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -93,28 +93,19 @@ export default function FixedPlayerBar() {
|
|||||||
mt="md"
|
mt="md"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: '50%', // Menempatkan titik atas ikon di tengah layar
|
top: '50%',
|
||||||
left: '0px',
|
left: '0px',
|
||||||
transform: 'translateY(-50%)', // Menggeser ikon ke atas sebesar setengah tingginya sendiri agar benar-benar di tengah
|
transform: 'translateY(-50%)',
|
||||||
borderBottomRightRadius: '20px',
|
borderBottomRightRadius: '20px',
|
||||||
borderTopRightRadius: '20px',
|
borderTopRightRadius: '20px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'transform 0.2s ease',
|
transition: 'transform 0.2s ease',
|
||||||
zIndex: 1
|
zIndex: 1000 // Higher z-index
|
||||||
}}
|
}}
|
||||||
onClick={handleRestorePlayer}
|
onClick={handleRestorePlayer}
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'translateY(-50%) scale(1.1)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'translateY(-50%)';
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<IconMusic size={28} color="white" />
|
<IconMusic size={24} color="white" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Spacer to prevent content from being hidden behind player */}
|
|
||||||
<Box h={20} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -131,132 +122,125 @@ export default function FixedPlayerBar() {
|
|||||||
bottom={0}
|
bottom={0}
|
||||||
left={0}
|
left={0}
|
||||||
right={0}
|
right={0}
|
||||||
p="sm"
|
p={{ base: 'xs', sm: 'sm' }}
|
||||||
shadow="lg"
|
shadow="xl"
|
||||||
style={{
|
style={{
|
||||||
zIndex: 1,
|
zIndex: 1000,
|
||||||
borderTop: '1px solid rgba(0,0,0,0.1)',
|
borderTop: '1px solid rgba(0,0,0,0.1)',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Flex align="center" gap="md" justify="space-between">
|
<Flex align="center" gap={{ base: 'xs', sm: 'md' }} justify="space-between">
|
||||||
{/* Song Info - Left */}
|
{/* Song Info - Left */}
|
||||||
<Group gap="sm" flex={1} style={{ minWidth: 0 }}>
|
<Group gap="xs" flex={{ base: 2, sm: 1 }} style={{ minWidth: 0 }} wrap="nowrap">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={currentSong.coverImage?.link || ''}
|
src={currentSong.coverImage?.link || ''}
|
||||||
alt={currentSong.judul}
|
alt={currentSong.judul}
|
||||||
size={40}
|
size={"36"}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
imageProps={{ loading: 'lazy' }}
|
|
||||||
/>
|
/>
|
||||||
<Box style={{ minWidth: 0 }}>
|
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||||
<Text fz="sm" fw={600} truncate>
|
<Text fz={{ base: 'xs', sm: 'sm' }} fw={600} truncate>
|
||||||
{currentSong.judul}
|
{currentSong.judul}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz="xs" c="dimmed" truncate>
|
<Text fz="10px" c="dimmed" truncate>
|
||||||
{currentSong.artis}
|
{currentSong.artis}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Controls + Progress - Center */}
|
{/* Controls - Center */}
|
||||||
<Group gap="xs" flex={2} justify="center">
|
<Group gap={"xs"} flex={{ base: 1, sm: 2 }} justify="center" wrap="nowrap">
|
||||||
{/* Control Buttons */}
|
{/* Shuffle - Desktop Only */}
|
||||||
<Group gap="xs">
|
<ActionIcon
|
||||||
<ActionIcon
|
variant={isShuffle ? 'filled' : 'subtle'}
|
||||||
variant={isShuffle ? 'filled' : 'subtle'}
|
color={isShuffle ? '#0B4F78' : 'gray'}
|
||||||
color={isShuffle ? 'blue' : 'gray'}
|
size={"md"}
|
||||||
size="lg"
|
onClick={handleToggleShuffle}
|
||||||
onClick={handleToggleShuffle}
|
visibleFrom="sm"
|
||||||
title="Shuffle"
|
>
|
||||||
>
|
<IconArrowsShuffle size={18} />
|
||||||
<IconArrowsShuffle size={18} />
|
</ActionIcon>
|
||||||
</ActionIcon>
|
|
||||||
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
size="lg"
|
size={"md"}
|
||||||
onClick={playPrev}
|
onClick={playPrev}
|
||||||
title="Previous"
|
>
|
||||||
>
|
<IconPlayerSkipBackFilled size={20} />
|
||||||
<IconPlayerSkipBackFilled size={20} />
|
</ActionIcon>
|
||||||
</ActionIcon>
|
|
||||||
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color={isPlaying ? 'blue' : 'gray'}
|
color="#0B4F78"
|
||||||
size="xl"
|
size={"lg"}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
onClick={togglePlayPause}
|
onClick={togglePlayPause}
|
||||||
title={isPlaying ? 'Pause' : 'Play'}
|
>
|
||||||
>
|
{isPlaying ? (
|
||||||
{isPlaying ? (
|
<IconPlayerPauseFilled size={24} />
|
||||||
<IconPlayerPauseFilled size={24} />
|
) : (
|
||||||
) : (
|
<IconPlayerPlayFilled size={24} />
|
||||||
<IconPlayerPlayFilled size={24} />
|
)}
|
||||||
)}
|
</ActionIcon>
|
||||||
</ActionIcon>
|
|
||||||
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
size="lg"
|
size={"md"}
|
||||||
onClick={playNext}
|
onClick={playNext}
|
||||||
title="Next"
|
>
|
||||||
>
|
<IconPlayerSkipForwardFilled size={20} />
|
||||||
<IconPlayerSkipForwardFilled size={20} />
|
</ActionIcon>
|
||||||
</ActionIcon>
|
|
||||||
|
|
||||||
<ActionIcon
|
{/* Repeat - Desktop Only */}
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
color={isRepeat ? 'blue' : 'gray'}
|
variant={isRepeat ? 'filled' : 'subtle'}
|
||||||
size="lg"
|
color={isRepeat ? '#0B4F78' : 'gray'}
|
||||||
onClick={toggleRepeat}
|
size={"md"}
|
||||||
title={isRepeat ? 'Repeat On' : 'Repeat Off'}
|
onClick={toggleRepeat}
|
||||||
>
|
visibleFrom="sm"
|
||||||
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
>
|
||||||
</ActionIcon>
|
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
||||||
</Group>
|
</ActionIcon>
|
||||||
|
|
||||||
{/* Progress Bar - Desktop */}
|
{/* Progress Bar - Desktop Only */}
|
||||||
<Box w={200} display={{ base: 'none', md: 'block' }}>
|
<Box w={150} ml="md" visibleFrom="md">
|
||||||
<Slider
|
<Slider
|
||||||
value={currentTime}
|
value={currentTime}
|
||||||
max={duration || 100}
|
max={duration || 100}
|
||||||
onChange={handleSeek}
|
onChange={handleSeek}
|
||||||
size="sm"
|
size="xs"
|
||||||
color="blue"
|
color="#0B4F78"
|
||||||
label={(value) => formatTime(value)}
|
label={(value) => formatTime(value)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Right Controls - Volume + Close */}
|
{/* Right Controls - Volume + Close */}
|
||||||
<Group gap="xs" flex={1} justify="flex-end">
|
<Group gap={4} flex={1} justify="flex-end" wrap="nowrap">
|
||||||
|
{/* Volume Control - Tablet/Desktop */}
|
||||||
<Box
|
<Box
|
||||||
onMouseEnter={() => setShowVolume(true)}
|
onMouseEnter={() => setShowVolume(true)}
|
||||||
onMouseLeave={() => setShowVolume(false)}
|
onMouseLeave={() => setShowVolume(false)}
|
||||||
pos="relative"
|
pos="relative"
|
||||||
|
visibleFrom="sm"
|
||||||
>
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color={isMuted ? 'red' : 'gray'}
|
color={isMuted ? 'red' : 'gray'}
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={toggleMute}
|
onClick={toggleMute}
|
||||||
title={isMuted ? 'Unmute' : 'Mute'}
|
|
||||||
>
|
>
|
||||||
{isMuted ? (
|
{isMuted ? <IconVolumeOff size={18} /> : <IconVolume size={18} />}
|
||||||
<IconVolumeOff size={18} />
|
|
||||||
) : (
|
|
||||||
<IconVolume size={18} />
|
|
||||||
)}
|
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
mounted={showVolume}
|
mounted={showVolume}
|
||||||
transition="scale-y"
|
transition="scale-y"
|
||||||
duration={200}
|
duration={200}
|
||||||
timingFunction="ease"
|
|
||||||
>
|
>
|
||||||
{(style) => (
|
{(style) => (
|
||||||
<Paper
|
<Paper
|
||||||
@@ -265,8 +249,8 @@ export default function FixedPlayerBar() {
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: '100%',
|
bottom: '100%',
|
||||||
right: 0,
|
right: 0,
|
||||||
mb: 'xs',
|
marginBottom: '10px',
|
||||||
p: 'sm',
|
padding: '10px',
|
||||||
zIndex: 1001,
|
zIndex: 1001,
|
||||||
}}
|
}}
|
||||||
shadow="md"
|
shadow="md"
|
||||||
@@ -276,8 +260,8 @@ export default function FixedPlayerBar() {
|
|||||||
value={isMuted ? 0 : volume}
|
value={isMuted ? 0 : volume}
|
||||||
max={100}
|
max={100}
|
||||||
onChange={handleVolumeChange}
|
onChange={handleVolumeChange}
|
||||||
h={100}
|
h={80}
|
||||||
color="blue"
|
color="#0B4F78"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -288,30 +272,29 @@ export default function FixedPlayerBar() {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
size="lg"
|
size={"md"}
|
||||||
onClick={handleMinimizePlayer}
|
onClick={handleMinimizePlayer}
|
||||||
title="Minimize player"
|
|
||||||
>
|
>
|
||||||
<IconX size={18} />
|
<IconX size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* Progress Bar - Mobile */}
|
{/* Progress Bar - Mobile (Base) */}
|
||||||
<Box mt="xs" display={{ base: 'block', md: 'none' }}>
|
<Box px="xs" mt={4} hiddenFrom="md">
|
||||||
<Slider
|
<Slider
|
||||||
value={currentTime}
|
value={currentTime}
|
||||||
max={duration || 100}
|
max={duration || 100}
|
||||||
onChange={handleSeek}
|
onChange={handleSeek}
|
||||||
size="sm"
|
size="xs"
|
||||||
color="blue"
|
color="#0B4F78"
|
||||||
label={(value) => formatTime(value)}
|
label={(value) => formatTime(value)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Spacer to prevent content from being hidden behind player */}
|
{/* Spacer to prevent content from being hidden behind player */}
|
||||||
<Box h={80} />
|
<Box h={{ base: 70, sm: 80 }} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
'use client'
|
'use client'
|
||||||
|
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
|
||||||
import apbdesState from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
|
|
||||||
import colors from '@/con/colors'
|
import colors from '@/con/colors'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -13,43 +12,30 @@ import {
|
|||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title
|
||||||
LoadingOverlay,
|
|
||||||
Transition,
|
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { motion } from 'framer-motion'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useProxy } from 'valtio/utils'
|
import { useProxy } from 'valtio/utils'
|
||||||
|
|
||||||
import { ApbdesMainSkeleton } from './components/apbdesSkeleton'
|
|
||||||
import ComparisonChart from './lib/comparisonChart'
|
|
||||||
import GrafikRealisasi from './lib/grafikRealisasi'
|
import GrafikRealisasi from './lib/grafikRealisasi'
|
||||||
import PaguTable from './lib/paguTable'
|
import PaguTable from './lib/paguTable'
|
||||||
import RealisasiTable from './lib/realisasiTable'
|
import RealisasiTable from './lib/realisasiTable'
|
||||||
|
|
||||||
const MotionStack = motion.create(Stack)
|
|
||||||
|
|
||||||
function Apbdes() {
|
function Apbdes() {
|
||||||
const state = useProxy(apbdesState)
|
const state = useProxy(apbdes)
|
||||||
const [selectedYear, setSelectedYear] = useState<string | null>(null)
|
const [selectedYear, setSelectedYear] = useState<string | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
const [isChangingYear, setIsChangingYear] = useState(false)
|
|
||||||
|
|
||||||
const textHeading = {
|
const textHeading = {
|
||||||
title: 'APBDes',
|
title: 'APBDes',
|
||||||
des: 'Transparansi APBDes Darmasaba adalah langkah nyata menuju tata kelola desa yang bersih, terbuka, dan bertanggung jawab.',
|
des: 'Transparansi APBDes Darmasaba adalah langkah nyata menuju tata kelola desa yang bersih, terbuka, dan bertanggung jawab.'
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true)
|
|
||||||
await state.findMany.load()
|
await state.findMany.load()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading data:', error)
|
console.error('Error loading data:', error)
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadData()
|
loadData()
|
||||||
@@ -65,7 +51,7 @@ function Apbdes() {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.sort((a, b) => b - a)
|
.sort((a, b) => b - a)
|
||||||
.map((year) => ({
|
.map(year => ({
|
||||||
value: year.toString(),
|
value: year.toString(),
|
||||||
label: `Tahun ${year}`,
|
label: `Tahun ${year}`,
|
||||||
}))
|
}))
|
||||||
@@ -74,190 +60,168 @@ function Apbdes() {
|
|||||||
if (years.length > 0 && !selectedYear) {
|
if (years.length > 0 && !selectedYear) {
|
||||||
setSelectedYear(years[0].value)
|
setSelectedYear(years[0].value)
|
||||||
}
|
}
|
||||||
}, [years])
|
}, [years, selectedYear])
|
||||||
|
|
||||||
const currentApbdes = dataAPBDes.length > 0
|
const currentApbdes = dataAPBDes.length > 0
|
||||||
? (dataAPBDes.find((item: any) => item?.tahun?.toString() === selectedYear) || dataAPBDes[0])
|
? dataAPBDes.find((item: any) => item?.tahun?.toString() === selectedYear) || dataAPBDes[0]
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const handleYearChange = (value: string | null) => {
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
if (value !== selectedYear) {
|
const previewData = (state.findMany.data || []).slice(0, 3)
|
||||||
setIsChangingYear(true)
|
|
||||||
setSelectedYear(value)
|
|
||||||
setTimeout(() => setIsChangingYear(false), 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack p="sm" gap="xl" bg={colors.Bg} pos="relative">
|
<Stack p="sm" gap="xl" bg={colors.Bg}>
|
||||||
<LoadingOverlay
|
<Divider c="gray.3" size="sm" />
|
||||||
visible={isLoading}
|
{/* 📌 HEADING */}
|
||||||
zIndex={1000}
|
<Box mt="xl">
|
||||||
overlayProps={{ radius: 'sm', blur: 2 }}
|
<Stack gap="sm">
|
||||||
loaderProps={{ color: colors['blue-button'], type: 'dots' }}
|
<Title
|
||||||
/>
|
order={1}
|
||||||
|
ta="center"
|
||||||
<Transition mounted={!isLoading} transition="fade" duration={600}>
|
c={colors['blue-button']}
|
||||||
{(styles) => (
|
fz={{ base: '2rem', md: '3.6rem' }}
|
||||||
<MotionStack
|
lh={{ base: 1.2, md: 1.1 }}
|
||||||
style={styles}
|
|
||||||
gap="xl"
|
|
||||||
>
|
>
|
||||||
<Divider c="gray.3" size="sm" />
|
{textHeading.title}
|
||||||
|
</Title>
|
||||||
{/* 📌 HEADING */}
|
|
||||||
<Box mt="xl">
|
<Text
|
||||||
<Stack gap="sm">
|
ta="center"
|
||||||
<Title
|
fz={{ base: '1rem', md: '1.25rem' }}
|
||||||
order={1}
|
lh={{ base: 1.5, md: 1.55 }}
|
||||||
|
c="black"
|
||||||
|
>
|
||||||
|
{textHeading.des}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Button Lihat Semua */}
|
||||||
|
<Group justify="center">
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
href="/darmasaba/apbdes"
|
||||||
|
radius="xl"
|
||||||
|
size="lg"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: "#26667F", to: "#124170" }}
|
||||||
|
>
|
||||||
|
Lihat Semua Data
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* COMBOBOX */}
|
||||||
|
<Box px={{ base: 'md', md: "sm" }}>
|
||||||
|
<Select
|
||||||
|
label={<Text fw={600} fz="sm">Pilih Tahun APBDes</Text>}
|
||||||
|
placeholder="Pilih tahun"
|
||||||
|
value={selectedYear}
|
||||||
|
onChange={setSelectedYear}
|
||||||
|
data={years}
|
||||||
|
w={{ base: '100%', sm: 220 }}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
nothingFoundMessage="Tidak ada tahun tersedia"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Tabel & Grafik - Hanya tampilkan jika ada data */}
|
||||||
|
{currentApbdes && currentApbdes.items?.length > 0 ? (
|
||||||
|
<Box px={{ base: 'md', md: 'sm' }} mb="xl">
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
|
<PaguTable apbdesData={currentApbdes} />
|
||||||
|
<RealisasiTable apbdesData={currentApbdes} />
|
||||||
|
<GrafikRealisasi apbdesData={currentApbdes} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
) : currentApbdes ? (
|
||||||
|
<Box px={{ base: 'md', md: 100 }} py="md" mb="xl">
|
||||||
|
<Text fz="sm" c="dimmed" ta="center" lh={1.5}>
|
||||||
|
Tidak ada data item untuk tahun yang dipilih.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* GRID - Card Preview
|
||||||
|
{state.findMany.loading ? (
|
||||||
|
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
|
||||||
|
<Loader size="lg" color="blue" />
|
||||||
|
</Center>
|
||||||
|
) : previewData.length === 0 ? (
|
||||||
|
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
|
||||||
|
<Stack align="center" gap="xs">
|
||||||
|
<Text fz="lg" c="dimmed" lh={1.4}>
|
||||||
|
Belum ada data APBDes yang tersedia
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" c="dimmed" lh={1.4}>
|
||||||
|
Data akan ditampilkan di sini setelah diunggah
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<SimpleGrid
|
||||||
|
mx={{ base: 'md', md: 100 }}
|
||||||
|
cols={{ base: 1, sm: 3 }}
|
||||||
|
spacing="lg"
|
||||||
|
pb="xl"
|
||||||
|
>
|
||||||
|
{previewData.map((v, k) => (
|
||||||
|
<Box
|
||||||
|
key={k}
|
||||||
|
pos="relative"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${v.image?.link || ''})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
borderRadius: 16,
|
||||||
|
height: 360,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box pos="absolute" inset={0} bg="rgba(0,0,0,0.45)" style={{ borderRadius: 16 }} />
|
||||||
|
|
||||||
|
<Stack gap="xs" justify="space-between" h="100%" p="xl" pos="relative">
|
||||||
|
<Text
|
||||||
|
c="white"
|
||||||
|
fw={600}
|
||||||
|
fz={{ base: 'lg', md: 'xl' }}
|
||||||
ta="center"
|
ta="center"
|
||||||
c={colors['blue-button']}
|
lh={1.35}
|
||||||
fz={{ base: '2rem', md: '3.6rem' }}
|
lineClamp={2}
|
||||||
lh={{ base: 1.2, md: 1.1 }}
|
|
||||||
>
|
>
|
||||||
{textHeading.title}
|
{v.name || `APBDes Tahun ${v.tahun}`}
|
||||||
</Title>
|
</Text>
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
|
fw={700}
|
||||||
|
c="white"
|
||||||
|
fz={{ base: '2.4rem', md: '3.2rem' }}
|
||||||
ta="center"
|
ta="center"
|
||||||
fz={{ base: '1rem', md: '1.25rem' }}
|
lh={1}
|
||||||
lh={{ base: 1.5, md: 1.55 }}
|
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
|
||||||
c="black"
|
|
||||||
maw={800}
|
|
||||||
mx="auto"
|
|
||||||
>
|
>
|
||||||
{textHeading.des}
|
{v.jumlah || '-'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
<Center>
|
||||||
|
<ActionIcon
|
||||||
|
component={Link}
|
||||||
|
href={v.file?.link || ''}
|
||||||
|
radius="xl"
|
||||||
|
size="xl"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: '#1C6EA4', to: '#1C6EA4' }}
|
||||||
|
>
|
||||||
|
<IconDownload size={20} color="white" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Center>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
))}
|
||||||
{/* Button Lihat Semua */}
|
</SimpleGrid>
|
||||||
<Group justify="center">
|
)} */}
|
||||||
<Button
|
|
||||||
component={Link}
|
|
||||||
href="/darmasaba/apbdes"
|
|
||||||
radius="xl"
|
|
||||||
size="lg"
|
|
||||||
variant="gradient"
|
|
||||||
gradient={{ from: '#26667F', to: '#124170' }}
|
|
||||||
style={{
|
|
||||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
|
||||||
':hover': {
|
|
||||||
transform: 'translateY(-2px)',
|
|
||||||
boxShadow: '0 4px 12px rgba(38, 102, 127, 0.4)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Lihat Semua Data
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{/* COMBOBOX */}
|
|
||||||
<Box px={{ base: 'md', md: 'sm' }}>
|
|
||||||
<Select
|
|
||||||
label={<Text fw={600} fz="sm">Pilih Tahun APBDes</Text>}
|
|
||||||
placeholder="Pilih tahun"
|
|
||||||
value={selectedYear}
|
|
||||||
onChange={handleYearChange}
|
|
||||||
data={years}
|
|
||||||
w={{ base: '100%', sm: 220 }}
|
|
||||||
searchable
|
|
||||||
clearable
|
|
||||||
nothingFoundMessage="Tidak ada tahun tersedia"
|
|
||||||
disabled={isChangingYear}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Tables & Charts */}
|
|
||||||
{currentApbdes && currentApbdes.items && currentApbdes.items.length > 0 ? (
|
|
||||||
<Box px={{ base: 'md', md: 'sm' }} mb="xl">
|
|
||||||
<Transition
|
|
||||||
mounted={!isChangingYear}
|
|
||||||
transition="slide-up"
|
|
||||||
duration={400}
|
|
||||||
timingFunction="ease"
|
|
||||||
>
|
|
||||||
{(styles) => (
|
|
||||||
<SimpleGrid
|
|
||||||
cols={{ base: 1, sm: 3 }}
|
|
||||||
style={styles}
|
|
||||||
>
|
|
||||||
<MotionStack
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.1 }}
|
|
||||||
>
|
|
||||||
<PaguTable apbdesData={currentApbdes as any} />
|
|
||||||
</MotionStack>
|
|
||||||
|
|
||||||
<MotionStack
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.2 }}
|
|
||||||
>
|
|
||||||
<RealisasiTable apbdesData={currentApbdes as any} />
|
|
||||||
</MotionStack>
|
|
||||||
|
|
||||||
<MotionStack
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.3 }}
|
|
||||||
>
|
|
||||||
<GrafikRealisasi apbdesData={currentApbdes as any} />
|
|
||||||
</MotionStack>
|
|
||||||
</SimpleGrid>
|
|
||||||
)}
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
{/* Comparison Chart */}
|
|
||||||
<Box mt="lg">
|
|
||||||
<Transition
|
|
||||||
mounted={!isChangingYear}
|
|
||||||
transition="slide-up"
|
|
||||||
duration={400}
|
|
||||||
timingFunction="ease"
|
|
||||||
>
|
|
||||||
{(styles) => (
|
|
||||||
<MotionStack
|
|
||||||
style={styles}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.4 }}
|
|
||||||
>
|
|
||||||
<ComparisonChart apbdesData={currentApbdes as any} />
|
|
||||||
</MotionStack>
|
|
||||||
)}
|
|
||||||
</Transition>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
) : currentApbdes ? (
|
|
||||||
<Box px={{ base: 'md', md: 100 }} py="xl" mb="xl">
|
|
||||||
<Stack align="center" gap="sm">
|
|
||||||
<Text fz="2rem">📊</Text>
|
|
||||||
<Text fz="sm" c="dimmed" ta="center" lh={1.5}>
|
|
||||||
Tidak ada data item untuk tahun yang dipilih.
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Loading State for Year Change */}
|
|
||||||
<Transition mounted={isChangingYear} transition="fade" duration={200}>
|
|
||||||
{(styles) => (
|
|
||||||
<Box
|
|
||||||
px={{ base: 'md', md: 'sm' }}
|
|
||||||
mb="xl"
|
|
||||||
style={styles}
|
|
||||||
>
|
|
||||||
<ApbdesMainSkeleton />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Transition>
|
|
||||||
</MotionStack>
|
|
||||||
)}
|
|
||||||
</Transition>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Apbdes
|
export default Apbdes
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,223 +1,125 @@
|
|||||||
import { Paper, Title, Progress, Stack, Text, Group, Box, type MantineColor } from '@mantine/core'
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { IconArrowUpRight, IconArrowDownRight } from '@tabler/icons-react'
|
import { Paper, Title, Progress, Stack, Text, Group, Box } from '@mantine/core';
|
||||||
import { APBDes, APBDesItem } from '../types/apbdes'
|
|
||||||
|
|
||||||
interface SummaryProps {
|
interface APBDesItem {
|
||||||
title: string
|
tipe: string | null;
|
||||||
data: APBDesItem[]
|
anggaran: number;
|
||||||
icon?: React.ReactNode
|
realisasi?: number;
|
||||||
|
totalRealisasi?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Summary({ title, data, icon }: SummaryProps) {
|
interface SummaryProps {
|
||||||
if (!data || data.length === 0) return null
|
title: string;
|
||||||
|
data: APBDesItem[];
|
||||||
|
}
|
||||||
|
|
||||||
const totalAnggaran = data.reduce((sum, i) => sum + i.anggaran, 0)
|
function Summary({ title, data }: SummaryProps) {
|
||||||
|
if (!data || data.length === 0) return null;
|
||||||
// Hitung total realisasi dari realisasiItems (konsisten dengan RealisasiTable)
|
|
||||||
const totalRealisasi = data.reduce((sum, i) => {
|
|
||||||
if (i.realisasiItems && i.realisasiItems.length > 0) {
|
|
||||||
return sum + i.realisasiItems.reduce((sumReal, real) => sumReal + (real.jumlah || 0), 0)
|
|
||||||
}
|
|
||||||
return sum
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
const persentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0
|
const totalAnggaran = data.reduce((s: number, i: APBDesItem) => s + i.anggaran, 0);
|
||||||
|
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
|
||||||
|
const totalRealisasi = data.reduce(
|
||||||
|
(s: number, i: APBDesItem) => s + (i.realisasi || i.totalRealisasi || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const persen =
|
||||||
|
totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
|
||||||
|
|
||||||
|
// Format angka ke dalam format Rupiah
|
||||||
const formatRupiah = (angka: number) => {
|
const formatRupiah = (angka: number) => {
|
||||||
return new Intl.NumberFormat('id-ID', {
|
return new Intl.NumberFormat('id-ID', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'IDR',
|
currency: 'IDR',
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(angka)
|
}).format(angka);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// Tentukan warna berdasarkan persentase
|
||||||
const getProgressColor = (persen: number) => {
|
const getProgressColor = (persen: number) => {
|
||||||
if (persen >= 100) return 'teal'
|
if (persen >= 100) return 'teal';
|
||||||
if (persen >= 80) return 'blue'
|
if (persen >= 80) return 'blue';
|
||||||
if (persen >= 60) return 'yellow'
|
if (persen >= 60) return 'yellow';
|
||||||
return 'red'
|
return 'red';
|
||||||
}
|
};
|
||||||
|
|
||||||
const getStatusMessage = (persen: number) => {
|
|
||||||
if (persen >= 100) {
|
|
||||||
return { text: 'Realisasi mencapai 100% dari anggaran', color: 'teal' }
|
|
||||||
}
|
|
||||||
if (persen >= 80) {
|
|
||||||
return { text: 'Realisasi baik, mendekati target', color: 'blue' }
|
|
||||||
}
|
|
||||||
if (persen >= 60) {
|
|
||||||
return { text: 'Realisasi cukup, perlu ditingkatkan', color: 'yellow' }
|
|
||||||
}
|
|
||||||
return { text: 'Realisasi rendah, perlu perhatian khusus', color: 'red' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusMessage = getStatusMessage(persentase)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Group justify="space-between" mb="xs">
|
<Group justify="space-between" mb="xs">
|
||||||
<Group gap="xs">
|
<Text fw={600} fz="md">{title}</Text>
|
||||||
{icon}
|
<Text fw={700} fz="lg" c={getProgressColor(persen)}>
|
||||||
<Text fw={700} fz="md" c="gray.8">{title}</Text>
|
{persen.toFixed(2)}%
|
||||||
</Group>
|
</Text>
|
||||||
<Group gap="xs">
|
|
||||||
{persentase >= 100 ? (
|
|
||||||
<IconArrowUpRight
|
|
||||||
size={18}
|
|
||||||
color="var(--mantine-color-teal-7)"
|
|
||||||
stroke={2.5}
|
|
||||||
/>
|
|
||||||
) : persentase < 60 ? (
|
|
||||||
<IconArrowDownRight
|
|
||||||
size={18}
|
|
||||||
color="var(--mantine-color-red-7)"
|
|
||||||
stroke={2.5}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Text
|
|
||||||
fw={700}
|
|
||||||
fz="lg"
|
|
||||||
c={getProgressColor(persentase)}
|
|
||||||
style={{
|
|
||||||
minWidth: 60,
|
|
||||||
textAlign: 'right',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{persentase.toFixed(1)}%
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Text fz="xs" c="gray.6" mb="sm" lh={1.5}>
|
<Text fz="sm" c="dimmed" mb="xs">
|
||||||
Realisasi: <Text component="span" fw={700} c="blue.9">{formatRupiah(totalRealisasi)}</Text>
|
Realisasi: {formatRupiah(totalRealisasi)} / Anggaran: {formatRupiah(totalAnggaran)}
|
||||||
{' '}/ Anggaran: <Text component="span" fw={700} c="gray.7">{formatRupiah(totalAnggaran)}</Text>
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Progress
|
<Progress
|
||||||
value={persentase}
|
value={persen}
|
||||||
size="xl"
|
size="xl"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
color={getProgressColor(persentase)}
|
color={getProgressColor(persen)}
|
||||||
striped={persentase < 100}
|
striped={persen < 100}
|
||||||
animated={persentase < 100}
|
animated={persen < 100}
|
||||||
mb="xs"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text
|
{persen >= 100 && (
|
||||||
fz="xs"
|
<Text fz="xs" c="teal" mt="xs" fw={500}>
|
||||||
c={statusMessage.color as MantineColor}
|
✓ Realisasi mencapai 100% dari anggaran
|
||||||
fw={600}
|
</Text>
|
||||||
style={{
|
)}
|
||||||
backgroundColor: `var(--mantine-color-${statusMessage.color}-0)`,
|
|
||||||
padding: '6px 10px',
|
{persen < 100 && persen >= 80 && (
|
||||||
borderRadius: 6,
|
<Text fz="xs" c="blue" mt="xs" fw={500}>
|
||||||
display: 'inline-block',
|
⚡ Realisasi baik, mendekati target
|
||||||
}}
|
</Text>
|
||||||
>
|
)}
|
||||||
{persentase >= 100 && '✓ '}{statusMessage.text}
|
|
||||||
</Text>
|
{persen < 80 && persen >= 60 && (
|
||||||
|
<Text fz="xs" c="yellow" mt="xs" fw={500}>
|
||||||
|
⚠️ Realisasi cukup, perlu ditingkatkan
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{persen < 60 && (
|
||||||
|
<Text fz="xs" c="red" mt="xs" fw={500}>
|
||||||
|
⚠️ Realisasi rendah, perlu perhatian khusus
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GrafikRealisasiProps {
|
export default function GrafikRealisasi({
|
||||||
apbdesData: APBDes
|
apbdesData,
|
||||||
}
|
}: {
|
||||||
|
apbdesData: {
|
||||||
|
tahun?: number | null;
|
||||||
|
items?: APBDesItem[] | null;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const items = apbdesData?.items || [];
|
||||||
|
const tahun = apbdesData?.tahun || new Date().getFullYear();
|
||||||
|
|
||||||
export default function GrafikRealisasi({ apbdesData }: GrafikRealisasiProps) {
|
const pendapatan = items.filter((i: APBDesItem) => i.tipe === 'pendapatan');
|
||||||
const items = apbdesData?.items || []
|
const belanja = items.filter((i: APBDesItem) => i.tipe === 'belanja');
|
||||||
const tahun = apbdesData?.tahun || new Date().getFullYear()
|
const pembiayaan = items.filter((i: APBDesItem) => i.tipe === 'pembiayaan');
|
||||||
|
|
||||||
const pendapatan = items.filter((i: APBDesItem) => i.tipe === 'pendapatan')
|
|
||||||
const belanja = items.filter((i: APBDesItem) => i.tipe === 'belanja')
|
|
||||||
const pembiayaan = items.filter((i: APBDesItem) => i.tipe === 'pembiayaan')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper withBorder p="md" radius="md">
|
||||||
withBorder
|
<Title order={5} mb="md">
|
||||||
p="lg"
|
|
||||||
radius="lg"
|
|
||||||
shadow="sm"
|
|
||||||
style={{
|
|
||||||
transition: 'box-shadow 0.3s ease',
|
|
||||||
':hover': {
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
h={"100%"}
|
|
||||||
>
|
|
||||||
<Title
|
|
||||||
order={5}
|
|
||||||
mb="lg"
|
|
||||||
c="blue.9"
|
|
||||||
fz={{ base: '1rem', md: '1.1rem' }}
|
|
||||||
fw={700}
|
|
||||||
>
|
|
||||||
GRAFIK REALISASI APBDes {tahun}
|
GRAFIK REALISASI APBDes {tahun}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<Stack gap="xl">
|
<Stack gap="lg" mb="lg">
|
||||||
<Summary
|
<Summary title="💰 Pendapatan" data={pendapatan} />
|
||||||
title="Pendapatan"
|
<Summary title="💸 Belanja" data={belanja} />
|
||||||
data={pendapatan}
|
<Summary title="📊 Pembiayaan" data={pembiayaan} />
|
||||||
icon={
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: 'var(--mantine-color-green-0)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text fz="lg">💰</Text>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Summary
|
|
||||||
title="Belanja"
|
|
||||||
data={belanja}
|
|
||||||
icon={
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: 'var(--mantine-color-red-0)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text fz="lg">💸</Text>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Summary
|
|
||||||
title="Pembiayaan"
|
|
||||||
data={pembiayaan}
|
|
||||||
icon={
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: 'var(--mantine-color-orange-0)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text fz="lg">📊</Text>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,179 +1,66 @@
|
|||||||
import { Paper, Table, Title, Box, ScrollArea, Badge } from '@mantine/core'
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { APBDes, APBDesItem } from '../types/apbdes'
|
import { Paper, Table, Title, Text } from '@mantine/core';
|
||||||
|
|
||||||
interface SectionProps {
|
function Section({ title, data }: any) {
|
||||||
title: string
|
if (!data || data.length === 0) return null;
|
||||||
data: APBDesItem[]
|
|
||||||
badgeColor?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function Section({ title, data, badgeColor = 'blue' }: SectionProps) {
|
|
||||||
if (!data || data.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Table.Tr bg="gray.0">
|
<Table.Tr bg="gray.0">
|
||||||
<Table.Td colSpan={2}>
|
<Table.Td colSpan={2}>
|
||||||
<Badge color={badgeColor} variant="light" size="lg" fw={600}>
|
<Text fw={700} fz={{ base: 'xs', sm: 'sm' }}>{title}</Text>
|
||||||
{title}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|
||||||
{data.map((item, index) => (
|
{data.map((item: any) => (
|
||||||
<Table.Tr
|
<Table.Tr key={item.id}>
|
||||||
key={item.id}
|
<Table.Td>
|
||||||
bg={index % 2 === 1 ? 'gray.50' : 'white'}
|
<Text fz={{ base: 'xs', sm: 'sm' }} lineClamp={2}>
|
||||||
style={{
|
{item.kode} - {item.uraian}
|
||||||
transition: 'background-color 0.2s ease',
|
</Text>
|
||||||
':hover': {
|
|
||||||
backgroundColor: 'var(--mantine-color-blue-0)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Table.Td style={{ borderBottom: '1px solid #e5e7eb' }}>
|
|
||||||
<Box style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
||||||
<span style={{
|
|
||||||
fontWeight: 500,
|
|
||||||
color: 'var(--mantine-color-gray-7)',
|
|
||||||
minWidth: 80,
|
|
||||||
}}>
|
|
||||||
{item.kode}
|
|
||||||
</span>
|
|
||||||
<span style={{
|
|
||||||
color: 'var(--mantine-color-gray-6)',
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
}}>
|
|
||||||
{item.uraian}
|
|
||||||
</span>
|
|
||||||
</Box>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td
|
<Table.Td ta="right">
|
||||||
ta="right"
|
<Text fz={{ base: 'xs', sm: 'sm' }} fw={500} style={{ whiteSpace: 'nowrap' }}>
|
||||||
style={{
|
Rp {item.anggaran.toLocaleString('id-ID')}
|
||||||
borderBottom: '1px solid #e5e7eb',
|
</Text>
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--mantine-color-blue-7)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Rp {item.anggaran.toLocaleString('id-ID')}
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaguTableProps {
|
export default function PaguTable({ apbdesData }: any) {
|
||||||
apbdesData: APBDes
|
const items = apbdesData.items || [];
|
||||||
}
|
|
||||||
|
|
||||||
export default function PaguTable({ apbdesData }: PaguTableProps) {
|
const title =
|
||||||
const items = apbdesData.items || []
|
apbdesData.tahun
|
||||||
|
? `PAGU APBDes Tahun ${apbdesData.tahun}`
|
||||||
|
: 'PAGU APBDes';
|
||||||
|
|
||||||
const title = apbdesData.tahun
|
const pendapatan = items.filter((i: any) => i.tipe === 'pendapatan');
|
||||||
? `PAGU APBDes Tahun ${apbdesData.tahun}`
|
const belanja = items.filter((i: any) => i.tipe === 'belanja');
|
||||||
: 'PAGU APBDes'
|
const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan');
|
||||||
|
|
||||||
const pendapatan = items.filter((i: APBDesItem) => i.tipe === 'pendapatan')
|
|
||||||
const belanja = items.filter((i: APBDesItem) => i.tipe === 'belanja')
|
|
||||||
const pembiayaan = items.filter((i: APBDesItem) => i.tipe === 'pembiayaan')
|
|
||||||
|
|
||||||
// Calculate totals
|
|
||||||
const totalPendapatan = pendapatan.reduce((sum, i) => sum + i.anggaran, 0)
|
|
||||||
const totalBelanja = belanja.reduce((sum, i) => sum + i.anggaran, 0)
|
|
||||||
const totalPembiayaan = pembiayaan.reduce((sum, i) => sum + i.anggaran, 0)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper withBorder p={{ base: 'sm', sm: 'md' }} radius="md">
|
||||||
withBorder
|
<Title order={5} mb="md" fz={{ base: 'sm', sm: 'md' }}>{title}</Title>
|
||||||
p="md"
|
|
||||||
radius="lg"
|
|
||||||
shadow="sm"
|
|
||||||
style={{
|
|
||||||
transition: 'box-shadow 0.3s ease',
|
|
||||||
':hover': {
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
h={"100%"}
|
|
||||||
>
|
|
||||||
<Title
|
|
||||||
order={5}
|
|
||||||
mb="md"
|
|
||||||
c="blue.9"
|
|
||||||
fz={{ base: '1rem', md: '1.1rem' }}
|
|
||||||
fw={700}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Title>
|
|
||||||
|
|
||||||
<ScrollArea offsetScrollbars type="hover">
|
<Table.ScrollContainer minWidth={280}>
|
||||||
<Table
|
<Table verticalSpacing="xs">
|
||||||
horizontalSpacing="md"
|
|
||||||
verticalSpacing="xs"
|
|
||||||
layout="fixed"
|
|
||||||
>
|
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr bg="blue.9">
|
<Table.Tr>
|
||||||
<Table.Th c="white" fw={600} style={{ minWidth: '60%' }}>
|
<Table.Th fz={{ base: 'xs', sm: 'sm' }}>Uraian</Table.Th>
|
||||||
Uraian
|
<Table.Th ta="right" fz={{ base: 'xs', sm: 'sm' }}>Anggaran (Rp)</Table.Th>
|
||||||
</Table.Th>
|
|
||||||
<Table.Th
|
|
||||||
c="white"
|
|
||||||
fw={600}
|
|
||||||
ta="right"
|
|
||||||
style={{ minWidth: '40%' }}
|
|
||||||
>
|
|
||||||
Anggaran (Rp)
|
|
||||||
</Table.Th>
|
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
<Section
|
<Section title="1) PENDAPATAN" data={pendapatan} />
|
||||||
title="1) PENDAPATAN"
|
<Section title="2) BELANJA" data={belanja} />
|
||||||
data={pendapatan}
|
<Section title="3) PEMBIAYAAN" data={pembiayaan} />
|
||||||
badgeColor="green"
|
|
||||||
/>
|
|
||||||
{totalPendapatan > 0 && (
|
|
||||||
<Table.Tr bg="green.0" fw={700}>
|
|
||||||
<Table.Td>Total Pendapatan</Table.Td>
|
|
||||||
<Table.Td ta="right">
|
|
||||||
Rp {totalPendapatan.toLocaleString('id-ID')}
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Section
|
|
||||||
title="2) BELANJA"
|
|
||||||
data={belanja}
|
|
||||||
badgeColor="red"
|
|
||||||
/>
|
|
||||||
{totalBelanja > 0 && (
|
|
||||||
<Table.Tr bg="red.0" fw={700}>
|
|
||||||
<Table.Td>Total Belanja</Table.Td>
|
|
||||||
<Table.Td ta="right">
|
|
||||||
Rp {totalBelanja.toLocaleString('id-ID')}
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Section
|
|
||||||
title="3) PEMBIAYAAN"
|
|
||||||
data={pembiayaan}
|
|
||||||
badgeColor="orange"
|
|
||||||
/>
|
|
||||||
{totalPembiayaan > 0 && (
|
|
||||||
<Table.Tr bg="orange.0" fw={700}>
|
|
||||||
<Table.Td>Total Pembiayaan</Table.Td>
|
|
||||||
<Table.Td ta="right">
|
|
||||||
Rp {totalPembiayaan.toLocaleString('id-ID')}
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
)}
|
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</ScrollArea>
|
</Table.ScrollContainer>
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,211 +1,92 @@
|
|||||||
import { Paper, Table, Title, Badge, Text, Box, ScrollArea } from '@mantine/core'
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { APBDes, APBDesItem, RealisasiItem } from '../types/apbdes'
|
import { Paper, Table, Title, Badge, Text } from '@mantine/core';
|
||||||
|
|
||||||
interface RealisasiRowProps {
|
export default function RealisasiTable({ apbdesData }: any) {
|
||||||
realisasi: RealisasiItem
|
const items = apbdesData.items || [];
|
||||||
parentItem: APBDesItem
|
|
||||||
}
|
|
||||||
|
|
||||||
function RealisasiRow({ realisasi, parentItem }: RealisasiRowProps) {
|
const title =
|
||||||
const persentase = parentItem.anggaran > 0
|
apbdesData.tahun
|
||||||
? (realisasi.jumlah / parentItem.anggaran) * 100
|
? `REALISASI APBDes Tahun ${apbdesData.tahun}`
|
||||||
: 0
|
: 'REALISASI APBDes';
|
||||||
|
|
||||||
const getBadgeColor = (percentage: number) => {
|
|
||||||
if (percentage >= 100) return 'teal'
|
|
||||||
if (percentage >= 80) return 'blue'
|
|
||||||
if (percentage >= 60) return 'yellow'
|
|
||||||
return 'red'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getBadgeVariant = (percentage: number) => {
|
|
||||||
if (percentage >= 100) return 'filled'
|
|
||||||
return 'light'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table.Tr
|
|
||||||
style={{
|
|
||||||
transition: 'background-color 0.2s ease',
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: 'var(--mantine-color-blue-0)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Table.Td style={{ borderBottom: '1px solid #e5e7eb' }}>
|
|
||||||
<Box style={{ gap: 8, alignItems: 'center' }}>
|
|
||||||
<span style={{
|
|
||||||
fontWeight: 500,
|
|
||||||
color: 'var(--mantine-color-gray-7)',
|
|
||||||
}}>
|
|
||||||
{realisasi.kode || '-'}
|
|
||||||
</span>
|
|
||||||
<Text
|
|
||||||
size="sm"
|
|
||||||
c="gray.7"
|
|
||||||
title={realisasi.keterangan || '-'}
|
|
||||||
>
|
|
||||||
{realisasi.keterangan || '-'}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td
|
|
||||||
ta="right"
|
|
||||||
style={{
|
|
||||||
borderBottom: '1px solid #e5e7eb',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'var(--mantine-color-blue-7)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{new Intl.NumberFormat('id-ID', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'IDR',
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(realisasi.jumlah || 0)}
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td
|
|
||||||
ta="center"
|
|
||||||
style={{ borderBottom: '1px solid #e5e7eb' }}
|
|
||||||
>
|
|
||||||
<Badge
|
|
||||||
color={getBadgeColor(persentase)}
|
|
||||||
variant={getBadgeVariant(persentase)}
|
|
||||||
size="sm"
|
|
||||||
radius="xl"
|
|
||||||
fw={600}
|
|
||||||
style={{
|
|
||||||
minWidth: 65,
|
|
||||||
transition: 'transform 0.2s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{persentase.toFixed(1)}%
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RealisasiTableProps {
|
|
||||||
apbdesData: APBDes
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RealisasiTable({ apbdesData }: RealisasiTableProps) {
|
|
||||||
const items = apbdesData.items || []
|
|
||||||
|
|
||||||
const title = apbdesData.tahun
|
|
||||||
? `REALISASI APBDes Tahun ${apbdesData.tahun}`
|
|
||||||
: 'REALISASI APBDes'
|
|
||||||
|
|
||||||
// Flatten: kumpulkan semua realisasi items
|
// Flatten: kumpulkan semua realisasi items
|
||||||
const allRealisasiRows: Array<{ realisasi: RealisasiItem; parentItem: APBDesItem }> = []
|
const allRealisasiRows: Array<{ realisasi: any; parentItem: any }> = [];
|
||||||
|
|
||||||
items.forEach((item: APBDesItem) => {
|
items.forEach((item: any) => {
|
||||||
if (item.realisasiItems && item.realisasiItems.length > 0) {
|
if (item.realisasiItems && item.realisasiItems.length > 0) {
|
||||||
item.realisasiItems.forEach((realisasi: RealisasiItem) => {
|
item.realisasiItems.forEach((realisasi: any) => {
|
||||||
allRealisasiRows.push({ realisasi, parentItem: item })
|
allRealisasiRows.push({ realisasi, parentItem: item });
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// Calculate total realisasi
|
const formatRupiah = (amount: number) => {
|
||||||
const totalRealisasi = allRealisasiRows.reduce(
|
return new Intl.NumberFormat('id-ID', {
|
||||||
(sum, { realisasi }) => sum + (realisasi.jumlah || 0),
|
style: 'currency',
|
||||||
0
|
currency: 'IDR',
|
||||||
)
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper withBorder p={{ base: 'sm', sm: 'md' }} radius="md">
|
||||||
withBorder
|
<Title order={5} mb="md" fz={{ base: 'sm', sm: 'md' }}>{title}</Title>
|
||||||
p="md"
|
|
||||||
radius="lg"
|
|
||||||
shadow="sm"
|
|
||||||
style={{
|
|
||||||
transition: 'box-shadow 0.3s ease',
|
|
||||||
':hover': {
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
h={"100%"}
|
|
||||||
>
|
|
||||||
<Title
|
|
||||||
order={5}
|
|
||||||
mb="md"
|
|
||||||
c="blue.9"
|
|
||||||
fz={{ base: '1rem', md: '1.1rem' }}
|
|
||||||
fw={700}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Title>
|
|
||||||
|
|
||||||
{allRealisasiRows.length === 0 ? (
|
{allRealisasiRows.length === 0 ? (
|
||||||
<Box
|
<Text fz="sm" c="dimmed" ta="center" py="md">
|
||||||
py="xl"
|
Belum ada data realisasi
|
||||||
px="md"
|
</Text>
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--mantine-color-gray-0)',
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
fz="sm"
|
|
||||||
c="dimmed"
|
|
||||||
ta="center"
|
|
||||||
lh={1.6}
|
|
||||||
>
|
|
||||||
Belum ada data realisasi untuk tahun ini
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Table.ScrollContainer minWidth={300}>
|
||||||
<ScrollArea offsetScrollbars type="hover">
|
<Table verticalSpacing="xs">
|
||||||
<Table
|
<Table.Thead>
|
||||||
horizontalSpacing="md"
|
<Table.Tr>
|
||||||
verticalSpacing="xs"
|
<Table.Th fz={{ base: 'xs', sm: 'sm' }}>Uraian</Table.Th>
|
||||||
layout="fixed"
|
<Table.Th ta="right" fz={{ base: 'xs', sm: 'sm' }}>Realisasi (Rp)</Table.Th>
|
||||||
>
|
<Table.Th ta="center" fz={{ base: 'xs', sm: 'sm' }}>%</Table.Th>
|
||||||
<Table.Thead>
|
</Table.Tr>
|
||||||
<Table.Tr bg="blue.9">
|
</Table.Thead>
|
||||||
<Table.Th c="white" fw={600}>Uraian</Table.Th>
|
<Table.Tbody>
|
||||||
<Table.Th c="white" fw={600} ta="right">Realisasi (Rp)</Table.Th>
|
{allRealisasiRows.map(({ realisasi, parentItem }) => {
|
||||||
<Table.Th c="white" fw={600} ta="center">%</Table.Th>
|
const persentase = parentItem.anggaran > 0
|
||||||
</Table.Tr>
|
? (realisasi.jumlah / parentItem.anggaran) * 100
|
||||||
</Table.Thead>
|
: 0;
|
||||||
<Table.Tbody>
|
|
||||||
{allRealisasiRows.map(({ realisasi, parentItem }) => (
|
return (
|
||||||
<RealisasiRow
|
<Table.Tr key={realisasi.id}>
|
||||||
key={realisasi.id}
|
<Table.Td>
|
||||||
realisasi={realisasi}
|
<Text fz={{ base: 'xs', sm: 'sm' }} lineClamp={2}>
|
||||||
parentItem={parentItem}
|
{realisasi.kode || '-'} - {realisasi.keterangan || '-'}
|
||||||
/>
|
</Text>
|
||||||
))}
|
</Table.Td>
|
||||||
</Table.Tbody>
|
<Table.Td ta="right">
|
||||||
</Table>
|
<Text fw={600} c="blue" fz={{ base: 'xs', sm: 'sm' }} style={{ whiteSpace: 'nowrap' }}>
|
||||||
</ScrollArea>
|
{formatRupiah(realisasi.jumlah || 0)}
|
||||||
<Box mb="md" px="sm">
|
</Text>
|
||||||
<Text
|
</Table.Td>
|
||||||
size="sm"
|
<Table.Td ta="center">
|
||||||
c="gray.6"
|
<Badge
|
||||||
fw={500}
|
size="sm"
|
||||||
>
|
variant="light"
|
||||||
Total Realisasi:{' '}
|
color={
|
||||||
<Text
|
persentase >= 100
|
||||||
component="span"
|
? 'teal'
|
||||||
c="blue.9"
|
: persentase >= 60
|
||||||
fw={700}
|
? 'yellow'
|
||||||
fz="md"
|
: 'red'
|
||||||
>
|
}
|
||||||
{new Intl.NumberFormat('id-ID', {
|
>
|
||||||
style: 'currency',
|
{persentase.toFixed(1)}%
|
||||||
currency: 'IDR',
|
</Badge>
|
||||||
minimumFractionDigits: 0,
|
</Table.Td>
|
||||||
maximumFractionDigits: 0,
|
</Table.Tr>
|
||||||
}).format(totalRealisasi)}
|
);
|
||||||
</Text>
|
})}
|
||||||
</Text>
|
</Table.Tbody>
|
||||||
</Box>
|
</Table>
|
||||||
</>
|
</Table.ScrollContainer>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -99,13 +99,13 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="id" {...mantineHtmlProps}>
|
<ViewTransitions>
|
||||||
<head>
|
<html lang="id" {...mantineHtmlProps}>
|
||||||
<meta charSet="utf-8" />
|
<head>
|
||||||
<ColorSchemeScript defaultColorScheme="light" />
|
<meta charSet="utf-8" />
|
||||||
</head>
|
<ColorSchemeScript defaultColorScheme="light" />
|
||||||
<body>
|
</head>
|
||||||
<ViewTransitions>
|
<body>
|
||||||
<MusicProvider>
|
<MusicProvider>
|
||||||
<MantineProvider theme={theme} defaultColorScheme="light">
|
<MantineProvider theme={theme} defaultColorScheme="light">
|
||||||
{children}
|
{children}
|
||||||
@@ -117,8 +117,8 @@ export default function RootLayout({
|
|||||||
/>
|
/>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</MusicProvider>
|
</MusicProvider>
|
||||||
</ViewTransitions>
|
</body>
|
||||||
</body>
|
</html>
|
||||||
</html>
|
</ViewTransitions>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,9 @@
|
|||||||
import { AppServer } from '@/app/api/[[...slugs]]/route'
|
import { AppServer } from '@/app/api/[[...slugs]]/route'
|
||||||
import { treaty } from '@elysiajs/eden'
|
import { treaty } from '@elysiajs/eden'
|
||||||
|
|
||||||
// Determine the base URL based on environment
|
// const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'localhost:3000'
|
||||||
// treaty requires a full URL, cannot use relative paths like '/'
|
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
|
||||||
const getBaseUrl = () => {
|
|
||||||
// Development (server-side)
|
|
||||||
if (process.env.NODE_ENV === 'development' && typeof window === 'undefined') {
|
|
||||||
return process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client-side (browser) - use current window origin
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
return window.location.origin
|
|
||||||
}
|
|
||||||
|
|
||||||
// Production/Staging server-side - use environment variable or default
|
|
||||||
return process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
|
|
||||||
}
|
|
||||||
|
|
||||||
const BASE_URL = getBaseUrl()
|
|
||||||
|
|
||||||
const ApiFetch = treaty<AppServer>(BASE_URL)
|
const ApiFetch = treaty<AppServer>(BASE_URL)
|
||||||
|
|
||||||
export default ApiFetch
|
export default ApiFetch
|
||||||
@@ -29,18 +29,16 @@ process.on('unhandledRejection', async (error) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle graceful shutdown
|
// Handle graceful shutdown
|
||||||
if (process.env.NODE_ENV === 'production' && !process.env.NEXT_PHASE) {
|
process.on('SIGINT', async () => {
|
||||||
process.on('SIGINT', async () => {
|
console.log('Received SIGINT signal. Closing database connections...');
|
||||||
console.log('Received SIGINT signal. Closing database connections...');
|
await prisma.$disconnect();
|
||||||
await prisma.$disconnect();
|
process.exit(0);
|
||||||
// Allow natural exit
|
});
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGTERM', async () => {
|
process.on('SIGTERM', async () => {
|
||||||
console.log('Received SIGTERM signal. Closing database connections...');
|
console.log('Received SIGTERM signal. Closing database connections...');
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
// Allow natural exit
|
process.exit(0);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
export default prisma;
|
export default prisma;
|
||||||
@@ -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
|
|
||||||
|
|
||||||
---
|
|
||||||
Reference in New Issue
Block a user