Compare commits

..

4 Commits

Author SHA1 Message Date
8c0abd7f08 fix Layout 2026-02-25 12:00:41 +08:00
ef7763f01c fix: move defaultColorScheme to MantineProvider
- Remove invalid defaultColorScheme from createTheme()
- Keep defaultColorScheme='light' in MantineProvider props
- Fix TypeScript error in darmasaba/layout.tsx

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-25 11:31:49 +08:00
a319484907 fix: force light mode for darmasaba public pages
- Add MantineProvider with defaultColorScheme='light' to darmasaba/layout.tsx
- Remove useMantineColorScheme from ModuleView component
- Hardcode bg='white' for Paper components in ModuleView
- Prevent system dark mode from affecting public pages

This ensures public pages always use light mode regardless of
OS system preference, while admin panel can still use dark mode toggle.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-25 11:30:42 +08:00
458797ae38 fix: remove preventDefault from wheel event handlers
- Remove e.preventDefault() from handleWheel in layanan component
- Remove e.preventDefault() from handleWheel in penghargaan component
- Fix 'Unable to preventDefault inside passive event listener' error

Browser handles wheel events passively for better scroll performance.
Horizontal scroll with wheel delta works without preventDefault.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-25 11:15:51 +08:00
544 changed files with 5815 additions and 39016 deletions

View File

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

View File

@@ -1,44 +0,0 @@
# Database Configuration
DATABASE_URL="postgresql://username:password@localhost:5432/desa-darmasaba?schema=public"
# Seafile Configuration (File Storage)
SEAFILE_TOKEN=your_seafile_token
SEAFILE_REPO_ID=your_seafile_repo_id
SEAFILE_URL=https://your-seafile-instance.com
SEAFILE_PUBLIC_SHARE_TOKEN=your_seafile_public_share_token
# Upload Configuration
WIBU_UPLOAD_DIR=uploads
WIBU_DOWNLOAD_DIR=./download
# WhatsApp Server Configuration
WA_SERVER_TOKEN=your_whatsapp_server_token
# Application Configuration
# IMPORTANT: For staging/production, set this to your actual domain
# Local development: NEXT_PUBLIC_BASE_URL=http://localhost:3000
# Staging: NEXT_PUBLIC_BASE_URL=https://desa-darmasaba-stg.wibudev.com
# Production: NEXT_PUBLIC_BASE_URL=https://your-production-domain.com
# Or use relative URL '/' for automatic protocol/domain detection (recommended)
NEXT_PUBLIC_BASE_URL=/
# Email Configuration (for notifications/subscriptions)
EMAIL_USER=your_email@gmail.com
EMAIL_PASS=your_email_app_password
# Session Configuration
BASE_SESSION_KEY=your_session_key_generate_secure_random_string
BASE_TOKEN_KEY=your_jwt_secret_key_generate_secure_random_string
# Telegram Bot Configuration (for notifications)
BOT_TOKEN=your_telegram_bot_token
CHAT_ID=your_telegram_chat_id
# Session Password (for iron-session)
SESSION_PASSWORD="your_session_password_min_32_characters_long_secure"
# ElevenLabs API Key (for TTS features - optional)
ELEVENLABS_API_KEY=your_elevenlabs_api_key
# Environment (optional, defaults to development)
# NODE_ENV=development

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

11
.gitignore vendored
View File

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

View File

@@ -1,13 +0,0 @@
{
"mcpServers": {
"playwright-mcp": {
"command": "npx",
"args": [
"-y",
"playwright-mcp@latest"
],
"timeout": 60000
}
},
"$version": 3
}

View File

@@ -1,9 +0,0 @@
{
"mcpServers": {
"playwright-mcp": {
"command": "npx",
"args": ["-y", "playwright-mcp@latest"],
"timeout": 60000
}
}
}

View File

@@ -1,5 +0,0 @@
# Memory Context from Past Sessions
*No context yet. Complete your first session and context will appear here.*
Use claude-mem's MCP search tools for manual memory queries.

View File

@@ -1,73 +0,0 @@
# Engineering Audit Report: Desa Darmasaba
**Status:** Production Readiness Review (Critical)
**Auditor:** Staff Technical Architect
---
## 📊 Executive Summary & Scores
| Category | Score | Status |
| :--- | :---: | :--- |
| **Project Architecture** | 3/10 | 🔴 Critical Failure |
| **Code Quality** | 4/10 | 🟠 Poor |
| **Performance** | 5/10 | 🟡 Mediocre |
| **Security** | 5/10 | 🟠 Risk Detected |
| **Production Readiness** | 2/10 | 🔴 Not Ready |
---
## 🏗️ 1. Project Architecture
The project suffers from a **"Frankenstein Architecture"**. It attempts to run a full Elysia.js instance inside a Next.js Catch-All route.
- **Fractured Backend:** Logic is split between standard Next.js routes (`/api/auth`) and embedded Elysia modules.
- **Stateful Dependency:** Reliance on local filesystem (`WIBU_UPLOAD_DIR`) makes the application impossible to deploy on modern serverless platforms like Vercel.
- **Polluted Namespace:** Routing tree contains "test/coba" folders (`src/app/coba`, `src/app/percobaan`) that would be accessible in production.
## ⚛️ 2. Frontend Engineering (React / Next.js)
- **State Management Chaos:** Simultaneous use of `Valtio`, `Jotai`, `React Context`, and `localStorage`.
- **Tight Coupling:** Public pages (`/darmasaba`) import state directly from Admin internal states (`/admin/(dashboard)/_state`).
- **Heavy Client-Side Logic:** Logic that belongs in Server Actions or Hooks is embedded in presentational components (e.g., `Footer.tsx`).
## 📡 3. Backend / API Design
- **Framework Overhead:** Running Elysia inside Next.js adds unnecessary cold-boot overhead and complexity.
- **Weak Validation:** Widespread use of `as Type` casting in API handlers instead of runtime validation (Zod/Schema).
- **Service Integration:** OTP codes are sent via external `GET` requests with sensitive data in the query string—a major logging risk.
## 🗄️ 4. Database & Data Modeling (Prisma)
- **Schema Over-Normalization:** ~2000 lines of schema. Every minor content type (e.g., `LambangDesa`) is a separate table instead of a unified CMS model.
- **Polymorphic Monolith:** `FileStorage` is a "god table" with optional relations to ~40 other tables, creating a massive bottleneck and data integrity risk.
- **Connection Mismanagement:** Manual `prisma.$disconnect()` in API routes kills connection pooling performance.
## 🚀 5. Performance Engineering
- **Bypassing Optimization:** Custom `/api/utils/img` endpoint bypasses `next/image` optimization, serving uncompressed assets.
- **Aggressive Polling:** Client-side 30s polling for notifications is battery-draining and inefficient compared to SSE or SWR.
## 🔒 6. Security Audit
- **Insecure OTP Delivery:** Credentials passed as URL parameters to the WhatsApp service.
- **File Upload Risks:** Potential for Arbitrary File Upload due to direct local filesystem writes without rigorous sanitization.
## 🧹 7. Code Quality
- **Inconsistency:** Mixed English/Indonesian naming (e.g., `nomor` vs `createdAt`).
- **Artifacts:** Root directory is littered with scratch files: `xcoba.ts`, `xx.ts`, `test.txt`.
---
## 🚩 Top 10 Critical Problems
1. **Architectural Fracture:** Embedding Elysia inside Next.js creates a "split-brain" system.
2. **Serverless Incompatibility:** Dependency on local disk storage for uploads.
3. **Database Bloat:** Over-complicated schema with a fragile `FileStorage` monolith.
4. **State Fragmentation:** Mixed usage of Jotai and Valtio without a clear standard.
5. **Credential Leakage:** OTP codes sent via GET query parameters.
6. **Poor Cleanup:** Trial/Test folders and files committed to the production source.
7. **Asset Performance:** Bypassing Next.js image optimization.
8. **Coupling:** High dependency between public UI and internal Admin state.
9. **Type Safety:** Manual casting in APIs instead of runtime validation.
10. **Connection Pooling:** Inefficient Prisma connection management.
---
## 🛠️ Tech Lead Refactoring Priorities
1. **Unify the API:** Decommission the Elysia wrapper. Port all logic to standard Next.js Route Handlers with Zod validation.
2. **Stateless Storage:** Implement an S3-compatible adapter for all file uploads. Remove `fs` usage.
3. **Schema Consolidation:** Refactor the schema to use generic content models where possible.
4. **Standardize State:** Choose one global state manager and migrate all components.
5. **Project Sanitization:** Delete all `coba`, `percobaan`, and scratch files (`xcoba.ts`, etc.).

137
CLAUDE.md
View File

@@ -1,137 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Desa Darmasaba is a full-stack digital village management platform for a village in Badung, Bali. It serves both a public-facing website (`/darmasaba/*`) and an admin CMS (`/admin/*`).
## Commands
```bash
# Development
bun run dev # Start dev server (port 3000)
bun run build # Production build
bun run tsc --noEmit # Type-check only
# Testing
bun run test # All tests
bun run test:api # Unit tests (Vitest)
bun run test:e2e # E2E tests (Playwright)
# Database
bunx prisma migrate deploy # Apply migrations
bunx prisma migrate dev --name <name> # Create migration
bun run prisma/seed.ts # Seed database
bunx prisma studio # Interactive DB viewer
# Linting
bun eslint . --fix
```
## Architecture
### Tech Stack
- **Framework**: Next.js 15 (App Router) + React 19
- **Runtime/Package manager**: Bun (not npm)
- **API server**: Elysia.js (mounted at `/api/[[...slugs]]`)
- **ORM**: Prisma + PostgreSQL
- **UI**: Mantine UI v7-8
- **State**: Jotai (atoms), Valtio (proxies), SWR (data fetching)
- **Auth**: iron-session + JWT
- **File storage**: Local uploads + Seafile (self-hosted)
### Request Flow
```
Browser → Next.js middleware (src/middleware.ts)
→ Public pages: src/app/darmasaba/
→ Admin pages: src/app/admin/
→ API: src/app/api/[[...slugs]]/route.ts (Elysia.js)
└── _lib/*.ts (domain modules)
```
The Elysia server is a single entry point with domain-specific modules: `desa.ts`, `kesehatan.ts`, `ekonomi.ts`, `keamanan.ts`, `lingkungan.ts`, `pendidikan.ts`, `kependudukan.ts`, `ppid.ts`, `inovasi.ts`, `auth/`, `user/`, `fileStorage/`. Swagger docs are auto-generated at `/api/docs`.
### Domain Modules
Each domain (desa, kesehatan, ekonomi, etc.) has:
- API handler in `src/app/api/[[...slugs]]/_lib/<domain>.ts`
- Admin CMS pages in `src/app/admin/(dashboard)/<domain>/`
- Public pages in `src/app/darmasaba/(pages)/<domain>/`
### Database (Prisma)
- Schema at `prisma/schema.prisma` (~2400 lines, 100+ models)
- Common model conventions: `@default(cuid())` IDs, `createdAt`/`updatedAt` timestamps, `deletedAt DateTime?` (soft delete), `isActive Boolean @default(true)`
- Seeders per-module in `prisma/_seeder_list/`, orchestrated by `prisma/seed.ts`
### Authentication Flow
1. User submits phone → OTP sent (email/SMS)
2. OTP validated → JWT created + iron-session stored
3. `UserSession` model tracks active sessions
4. `src/middleware.ts` validates on each request
5. `src/lib/api-auth.ts` handles JWT/session checks in API routes
### File Handling
All uploaded files reference the `FileStorage` Prisma model. Uploads land in `WIBU_UPLOAD_DIR` (default: `uploads/`). Seafile is the external storage fallback.
## Key Files
| File | Purpose |
|------|---------|
| `src/middleware.ts` | Route guards and auth |
| `src/lib/prisma.ts` | Prisma client singleton |
| `src/lib/api-auth.ts` | JWT/session validation |
| `src/lib/api-fetch.ts` | Typed fetch wrapper used by frontend |
| `src/lib/session.ts` | iron-session config |
| `next.config.ts` | Next.js config (cache headers, allowed origins) |
| `postcss.config.cjs` | Mantine CSS preset and breakpoints |
| `docker-entrypoint.sh` | Runs `prisma migrate deploy` then starts app |
## Environment Variables
Copy `.env.example` to `.env`. Required variables:
```env
DATABASE_URL="postgresql://..."
NEXT_PUBLIC_BASE_URL="/"
BASE_SESSION_KEY="..." # random string
BASE_TOKEN_KEY="..." # random string
SESSION_PASSWORD="..." # min 32 chars
SEAFILE_TOKEN="..."
SEAFILE_REPO_ID="..."
SEAFILE_URL="..."
```
## Docker
Multi-stage build: `oven/bun:1-debian` → builder → runner. The runner creates a `nextjs` user (UID 1001), exposes port 3000, and mounts `/app/uploads` as a volume. Entrypoint runs migrations automatically.
## CI/CD
GitHub Actions workflows in `.github/workflows/`:
- `docker-publish.yml` — triggers on `v*` tags, pushes to GHCR
- `publish.yml` — manual build & push
- `re-pull.yml` — triggers Portainer to redeploy latest image
To release: tag with `git tag -a v0.1.x -m "..."` and push the tag.
### Workflow for Code Changes
1. **Commit** existing changes before starting new work
2. **Create plan** at `MIND/PLAN/[plan-name].md`
3. **Create task** at `MIND/PLAN/[task-name].md`
4. **Execute the task** and update task progress
5. **Create summary** at `MIND/SUMMARY/[summary-name].md` when done
6. **Run build** (`bun run build`) to ensure no compile errors
7. **Fix any build errors** if they occur
8. **Commit** all changes AFTER successful build
9. **Update version** in `package.json` for every change
10. **Push** to new branch with format: `tasks/[task-name]/[what-is-being-done]/[date-time]`
11. **Push ke 2 Remote** - Push ke 2 remote origin dan deploy
12. **Merge ke Branch** - Merge ke branch target (biasanya `stg` untuk staging atau `prod` untuk production) ke 2 remote origin dan deploy
### GitHub Workflows
1. **publish.yml**: Uses branch `main`, stack env and image tag matching version from `package.json`.
2. **re-pull.yml**: **Wait for `publish.yml` to complete successfully before running.** Uses branch `main`, stack env and stack name `desa-darmasaba`.
### After Progress
- Always give option to continue to GitHub workflows or not

View File

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

View File

@@ -1,76 +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/lib ./src/lib
COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json
COPY --from=builder --chown=nextjs:nodejs /app/next.config.* ./
COPY --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh
# Create uploads directory with proper permissions
RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads
USER nextjs
# Persistent storage for uploaded files
VOLUME ["/app/uploads"]
EXPOSE 3000
CMD ["/app/docker-entrypoint.sh"]

274
GEMINI.md
View File

@@ -1,244 +1,62 @@
# Desa Darmasaba - Village Management System
# Project: Desa Darmasaba
## Project Overview
Desa Darmasaba is a comprehensive Next.js 15 application designed for village management services in Darmasaba, Badung, Bali. The application serves as a digital platform for government services, public information, and community engagement. It features multiple sections including PPID (Public Information Disclosure), health services, security, education, environment, economy, innovation, and more.
The `desa-darmasaba` project is a Next.js (version 15+) application developed with TypeScript. It serves as an official platform for Desa Darmasaba (a village in Badung, Bali), offering various public services, news, and detailed village profiles.
### Key Technologies
- **Framework**: Next.js 15 with App Router
- **Language**: TypeScript with strict mode
- **Styling**: Mantine UI components with custom CSS
- **Backend**: Elysia.js API server integrated with Next.js
- **Database**: PostgreSQL with Prisma ORM
- **State Management**: Valtio for global state
- **Authentication**: JWT with iron-session
**Key Technologies:**
### Architecture
The application follows a modular architecture with:
- A main frontend built with Next.js and Mantine UI
- An integrated Elysia.js API server for backend operations
- Prisma ORM for database interactions
- File storage integration with Seafile
- Multiple domain-specific modules (PPID, health, security, education, etc.)
* **Frontend Framework:** Next.js (v15+) with React (v19+)
* **Language:** TypeScript
* **UI Library:** Mantine UI
* **Database ORM:** Prisma (v6+)
* **Database:** PostgreSQL (as configured in `prisma/schema.prisma`)
* **API Framework:** Elysia (used for API routes, as seen in dependencies)
* **State Management:** Potentially Jotai and Valtio (listed in dependencies)
* **Image Processing:** Sharp
* **Package Manager:** Likely Bun, given `bun.lockb` and the `prisma:seed` script.
The application architecture follows the Next.js App Router structure, with comprehensive data models defined in `prisma/schema.prisma` covering various domains like public information, health, security, economy, innovation, environment, and education. It also includes configurations for image handling and caching.
## Building and Running
### Prerequisites
- Node.js (with Bun runtime)
- PostgreSQL database
- Seafile server for file storage
This project uses `bun` as the package manager. Ensure Bun is installed to run these commands.
### Setup Instructions
1. Install dependencies:
```bash
bun install
```
* **Install Dependencies:**
```bash
bun install
```
2. Set up environment variables in `.env.local`:
```
DATABASE_URL=your_postgresql_connection_string
SEAFILE_TOKEN=your_seafile_token
SEAFILE_REPO_ID=your_seafile_repo_id
SEAFILE_BASE_URL=your_seafile_base_url
SEAFILE_PUBLIC_SHARE_TOKEN=your_seafile_public_share_token
SEAFILE_URL=your_seafile_api_url
WIBU_UPLOAD_DIR=your_upload_directory
```
* **Development Server:**
Runs the Next.js development server.
```bash
bun run dev
```
3. Generate Prisma client:
```bash
bunx prisma generate
```
* **Build for Production:**
Builds the Next.js application for production deployment.
```bash
bun run build
```
4. Push database schema:
```bash
bunx prisma db push
```
* **Start Production Server:**
Starts the Next.js application in production mode.
```bash
bun run start
```
5. Seed the database:
```bash
bun run prisma/seed.ts
```
6. Run the development server:
```bash
bun run dev
```
### Available Scripts
- `bun run dev` - Start development server
- `bun run build` - Build for production
- `bun run start` - Start production server
- `bun run prisma/seed.ts` - Run database seeding
- `bunx prisma generate` - Generate Prisma client
- `bunx prisma db push` - Push schema changes to database
- `bunx prisma studio` - Open Prisma Studio GUI
* **Database Seeding:**
Executes the Prisma seeding script to populate the database.
```bash
bun run prisma:seed
```
## Development Conventions
### Code Structure
```
src/
├── app/ # Next.js app router pages
├── admin/ # Admin dashboard pages
├── api/ # API routes with Elysia.js
├── darmasaba/ # Public-facing village pages
│ └── ...
├── con/ # Constants and configuration
├── hooks/ # React hooks
├── lib/ # Utility functions and configurations
├── middlewares/ # Next.js middleware
├── state/ # Global state management
├── store/ # Additional state management
├── types/ # TypeScript type definitions
└── utils/ # Utility functions
```
### Import Conventions
- Use absolute imports with `@/` alias (configured in tsconfig.json)
- Group imports: external libraries first, then internal modules
- Keep import statements organized and remove unused imports
```typescript
// External libraries
import { useState } from 'react'
import { Button, Stack } from '@mantine/core'
// Internal modules
import ApiFetch from '@/lib/api-fetch'
import { MyComponent } from '@/components/my-component'
```
### TypeScript Configuration
- Strict mode enabled (`"strict": true`)
- Target: ES2017
- Module resolution: bundler
- Path alias: `@/*` maps to `./src/*`
### Naming Conventions
- **Components**: PascalCase (e.g., `UploadImage.tsx`)
- **Files**: kebab-case for utilities (e.g., `api-fetch.ts`)
- **Variables/Functions**: camelCase
- **Constants**: UPPER_SNAKE_CASE
- **Database Models**: PascalCase (Prisma convention)
### Error Handling
- Use try-catch blocks for async operations
- Implement proper error boundaries in React components
- Log errors appropriately without exposing sensitive data
- Use Zod for runtime validation and type safety
### API Structure
- Backend uses Elysia.js with TypeScript
- API routes are in `src/app/api/[[...slugs]]/` directory
- Use treaty client for type-safe API calls
- Follow RESTful conventions for endpoints
- Include proper HTTP status codes and error responses
### Database Operations
- Use Prisma client from `@/lib/prisma.ts`
- Database connection includes graceful shutdown handling
- Use transactions for complex operations
- Implement proper error handling for database queries
### Component Guidelines
- Use functional components with hooks
- Implement proper prop types with TypeScript interfaces
- Use Mantine components for UI consistency
- Follow atomic design principles when possible
- Add loading states and error states for async operations
### State Management
- Use Valtio proxies for global state
- Keep local state in components when possible
- Use SWR for server state caching
- Implement optimistic updates for better UX
### Styling
- Primary: Mantine UI components
- Use Mantine theme system for customization
- Custom CSS should be minimal and scoped
- Follow responsive design principles
- Use semantic HTML5 elements
### Security Practices
- Validate all user inputs with Zod schemas
- Use JWT tokens for authentication
- Implement proper CORS configuration
- Never expose database credentials or API keys
- Use HTTPS in production
- Implement rate limiting for sensitive endpoints
### Performance Considerations
- Use Next.js Image optimization
- Implement proper caching strategies
- Use React.memo for expensive components
- Optimize bundle size with dynamic imports
- Use Prisma query optimization
## Domain Modules
The application is organized into several domain modules:
1. **PPID (Public Information Disclosure)**: Profile, structure, information requests, legal basis
2. **Health**: Health facilities, programs, emergency response, disease information
3. **Security**: Community security, emergency contacts, crime prevention
4. **Education**: Schools, scholarships, educational programs
5. **Economy**: Local markets, BUMDes, employment data
6. **Environment**: Environmental data, conservation, waste management
7. **Innovation**: Digital services, innovation programs
8. **Culture**: Village traditions, music, cultural preservation
Each module has its own section in both the admin panel and public-facing areas.
## File Storage Integration
The application integrates with Seafile for file storage, with specific handling for:
- Images and documents
- Public sharing capabilities
- CDN URL generation
- Batch processing of assets
## Testing
Currently no formal test framework is configured. When adding tests:
- Consider Jest or Vitest for unit testing
- Use Playwright for E2E testing
- Update this section with specific test commands
## Deployment
The application includes deployment scripts in the `NOTE.md` file that outline:
- Automated deployment with GitHub API integration
- Environment-specific configurations
- PM2 process management
- Release management with versioning
## Troubleshooting
Common issues and solutions:
- **API endpoints returning 404**: Check that environment variables are properly configured
- **Database connection errors**: Verify DATABASE_URL in environment variables
- **File upload issues**: Ensure Seafile integration is properly configured
- **Build failures**: Run `bunx prisma generate` before building
### Workflow for Code Changes
1. **Commit** existing changes before starting new work
2. **Create plan** at `MIND/PLAN/[plan-name].md`
3. **Create task** at `MIND/PLAN/[task-name].md`
4. **Execute the task** and update task progress
5. **Create summary** at `MIND/SUMMARY/[summary-name].md` when done
6. **Run build** (`bun run build`) to ensure no compile errors
7. **Fix any build errors** if they occur
8. **Commit** all changes AFTER successful build
9. **Update version** in `package.json` for every change
10. **Push** to new branch with format: `tasks/[task-name]/[what-is-being-done]/[date-time]`
11. **Push ke 2 Remote** - Push ke 2 remote origin dan deploy
12. **Merge ke Branch** - Merge ke branch target (biasanya `stg` untuk staging atau `prod` untuk production) ke 2 remote origin dan deploy
### GitHub Workflows
1. **publish.yml**: Uses branch `main`, stack env and image tag matching version from `package.json`.
2. **re-pull.yml**: **Wait for `publish.yml` to complete successfully before running.** Uses branch `main`, stack env and stack name `desa-darmasaba`.
### After Progress
- Always give option to continue to GitHub workflows or not
* **Coding Language:** TypeScript is strictly enforced.
* **Frontend Framework:** Next.js App Router for page and component structuring.
* **UI/UX:** Adherence to Mantine UI component library for consistent styling and user experience.
* **Database Interaction:** Prisma ORM is used for all database operations, with a PostgreSQL database.
* **Linting:** ESLint is configured with `next/core-web-vitals` and `next/typescript` to maintain code quality and adherence to Next.js and TypeScript best practices.
* **Styling:** PostCSS is used, with `postcss-preset-mantine` and `postcss-simple-vars` defining Mantine-specific breakpoints and other CSS variables.
* **Imports:** Absolute imports are configured using `@/*` which resolves to the `src/` directory.

View File

@@ -1,24 +0,0 @@
# Plan - Admin UMKM & Produk Edit Pages
## Problem
Admin UMKM module list pages have "Edit" buttons that are not functional, and there are no edit pages or update state logic implemented.
## Strategy
1. Update Valtio state in `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts` with `update` modules for UMKM and Produk.
2. Delete Valtio state in `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts` with `del` modules for UMKM and Produk.
3. Add `onClick` handlers to "Edit" buttons in list pages.
4. Create new edit pages.
5. Use `ModalKonfirmasiHapus` component for delete actions.
6. Verify changes with a successful build.
7. Follow deployment workflow.
## Progress
- [x] Update Valtio state with update modules
- [x] Update Valtio state with delete modules
- [x] Wire edit and delete buttons in list pages
- [x] Create UMKM edit page
- [x] Create Produk edit page
- [x] Build and fix errors
- [x] Update version in package.json
- [x] Commit and push to branch
- [x] Merge to stg and push to remotes

View File

@@ -1,24 +0,0 @@
# Plan: Fix 3 Bugs in UMKM Module
## 1. TypeError: Cannot set properties of undefined (setting 'loading')
- **File**: `src/app/darmasaba/(pages)/ekonomi/pasar-desa/page.tsx`
- **Root Cause**: `load` method is destructured from Valtio proxy, causing `this` binding to be lost.
- **Fix**: Remove `load` from destructuring and call it directly via `umkmState.produk.findMany.load` or `umkmState.umkm.findMany.load`.
## 2. 404 Not Found - Category Product API
- **File**: `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts`
- **Root Cause**: Incorrect API URL for fetching category products.
- **Fix**: Update URL from `/api/ekonomi/pasar-desa/kategori-produk/find-many-all` to `/api/ekonomi/kategoriproduk/find-many-all`.
## 3. Recharts Warning: width(-1) height(-1)
- **Location**: UMKM Admin Dashboard.
- **Root Cause**: Missing explicit height on chart container.
- **Fix**: Add `style={{ height: 300 }}` to the container and wrap charts with `ResponsiveContainer`.
## Steps:
1. Fix `src/app/darmasaba/(pages)/ekonomi/pasar-desa/page.tsx`.
2. Fix `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts`.
3. Locate and fix chart containers in UMKM admin dashboard.
4. Verify changes locally.
5. Run build to ensure no compile errors.
6. Commit and deploy.

View File

@@ -1,22 +0,0 @@
# Plan - Refactor UMKM Edit Pages Pattern
## Problem
The edit pages for UMKM (Data UMKM and Produk) use an older UI pattern. The user wants to align them with the newer pattern used in the Berita edit page.
## Strategy
1. Analyze the pattern in `src/app/admin/(dashboard)/desa/berita/list-berita/[id]/edit/page.tsx`.
2. Refactor `src/app/admin/(dashboard)/ekonomi/umkm/produk/[id]/edit/page.tsx` to match the pattern.
3. Refactor `src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/[id]/edit/page.tsx` to match the pattern.
4. Add "Batal" (Reset) functionality to both pages.
5. Standardize UI components (Header, Paper, Dropzone, Action buttons).
6. Verify with a production build.
7. Follow the versioning and deployment workflow.
## Progress
- [x] Analyze Berita edit page pattern
- [x] Refactor UMKM Produk edit page (with interfaces)
- [x] Refactor Data UMKM edit page (with interfaces)
- [x] Run build and fix any errors
- [ ] Update version in package.json
- [ ] Commit and push to task branch
- [ ] Merge to stg branch

View File

@@ -1,24 +0,0 @@
# Plan: Refactor UMKM and Pasar Desa (Consolidation)
## Objective
Consolidate "Pasar Desa" into the UMKM module. Pasar Desa is no longer a separate entity; it is now strictly a collection of products belonging to UMKM entities.
## Steps:
1. **Cleanup API**: Remove `PasarDesa` and `KategoriProduk` (from `pasar-desa` folder) imports from `src/app/api/[[...slugs]]/_lib/ekonomi/index.ts`.
2. **Admin UI**:
- Remove "Pasar Desa" menu from `src/app/admin/_com/list_PageAdmin.tsx`.
- Ensure "UMKM" menu handles all product management.
3. **Public UI**:
- Remove "Pasar Desa" from `src/con/navbar-list-menu.ts`.
- Refactor `src/app/darmasaba/(pages)/ekonomi/pasar-desa/page.tsx` to remove the "Produk Pasar Desa" tab.
- Rename the page or adjust its purpose to be the unified UMKM/Product hub.
4. **Prisma Schema**:
- Ensure `umkmId` is mandatory in `PasarDesa` model (already seems to be).
- (Optional) Rename `PasarDesa` to `ProdukUmkm` if requested, but user said it's optional. For now, keep it as `PasarDesa` to minimize breaking changes.
5. **Build & Verify**: Run `bun run build` and check for any broken references.
## Verification:
- No "Pasar Desa" menu in Admin.
- No "Pasar Desa" menu in Public Navbar.
- Public page `/darmasaba/ekonomi/pasar-desa` (or new path) shows UMKM products only.
- Successful build.

View File

@@ -1,26 +0,0 @@
# Plan: Refactor UMKM and Pasar Desa Model
## Objective
Unify `ProdukUmkm` and `PasarDesa` into a single `PasarDesa` model to avoid data redundancy and simplify management.
## Changes:
1. **Schema Refactor**:
- Merge fields from `ProdukUmkm` (`stok`, `umkmId`) into `PasarDesa`.
- Update `PenjualanProduk` to relate directly to `PasarDesa`.
- Remove `ProdukUmkm` model.
- Update `FileStorage` relations.
2. **Backend/API Refactor**:
- Update Pasar Desa `findMany` to only show products where `umkmId` is null.
- Update UMKM Produk APIs (`create`, `updt`, `findMany`, `del`) to use the `PasarDesa` model with `umkmId` filter.
- Update Penjualan logic to adjust `stok` in `PasarDesa`.
- Update UMKM Dashboard analytics to query `PasarDesa`.
3. **Admin UI Refactor**:
- Update `umkmState` to handle `kategoriId` for products.
- Create "Tambah UMKM" form for business profile management.
- Create "Tambah Produk UMKM" form for product management with `umkmId` binding.
- Update list views to link to the new forms.
- Implement logical separation between "Pasar Desa Admin" and "UMKM Admin" contexts.
## Verification:
- Successful build (`bun run build`).
- Verify API responses for both Pasar Desa and UMKM Produk filters.

View File

@@ -1,14 +0,0 @@
# Task - Admin UMKM & Produk Edit functionality
## Description
Implement Edit and Delete functionality for UMKM and Produk modules in the admin dashboard.
## Tasks
- [x] Update Valtio state with update/delete modules for UMKM and Produk
- [x] Wire edit/delete buttons in `src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/page.tsx`
- [x] Wire edit/delete buttons in `src/app/admin/(dashboard)/ekonomi/umkm/produk/page.tsx`
- [x] Create edit page for UMKM at `src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/[id]/edit/page.tsx`
- [x] Create edit page for Produk at `src/app/admin/(dashboard)/ekonomi/umkm/produk/[id]/edit/page.tsx`
- [x] Ensure `ModalKonfirmasiHapus` is used correctly
- [x] Run `bun run build` and fix errors
- [x] Push to task branch and merge to stg

View File

@@ -1,6 +0,0 @@
# Task: Fix UMKM Module Bugs
- [x] Fix TypeError in `src/app/darmasaba/(pages)/ekonomi/pasar-desa/page.tsx` <!-- id: 0 -->
- [x] Fix 404 API URL in `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts` <!-- id: 1 -->
- [x] Fix Recharts warning in UMKM admin dashboard <!-- id: 2 -->
- [x] Run build and verify <!-- id: 3 -->

View File

@@ -1,12 +0,0 @@
# Task - Refactor UMKM Edit Pages Pattern
Refactor Data UMKM and Produk edit pages to match the Berita edit page UI pattern and logic.
## Steps
1. [x] Analyze `berita/list-berita/[id]/edit/page.tsx` for the desired pattern.
2. [x] Implement the pattern in `ekonomi/umkm/produk/[id]/edit/page.tsx` (using interfaces).
3. [x] Implement the pattern in `ekonomi/umkm/data-umkm/[id]/edit/page.tsx` (using interfaces).
4. [x] Run `bun run build` to verify.
5. [x] Update `package.json` version.
6. [x] Commit with message: "feat(admin): refactor UMKM edit pages to match berita pattern with interfaces".
7. [ ] Create summary in `MIND/SUMMARY/refactor-umkm-edit-pages-pattern-summary.md`.

View File

@@ -1,8 +0,0 @@
# Task: Refactor UMKM and Pasar Desa (Consolidation)
- [ ] Cleanup API imports in `src/app/api/[[...slugs]]/_lib/ekonomi/index.ts` <!-- id: 0 -->
- [ ] Remove "Pasar Desa" menu in `src/app/admin/_com/list_PageAdmin.tsx` <!-- id: 1 -->
- [ ] Remove "Pasar Desa" from public navbar in `src/con/navbar-list-menu.ts` <!-- id: 2 -->
- [ ] Refactor public page `src/app/darmasaba/(pages)/ekonomi/pasar-desa/page.tsx` <!-- id: 3 -->
- [ ] Run build and fix errors <!-- id: 4 -->
- [ ] Update version and commit <!-- id: 5 -->

View File

@@ -1,10 +0,0 @@
# Task: Refactor UMKM and Pasar Desa Model
- [x] Refactor `prisma/schema.prisma` and run `db push` <!-- id: 0 -->
- [x] Update Pasar Desa `findMany` API with `umkmId: null` filter <!-- id: 1 -->
- [x] Update UMKM Produk APIs (CRUD) to use `PasarDesa` model <!-- id: 2 -->
- [x] Update UMKM Dashboard analytics and Penjualan logic <!-- id: 3 -->
- [x] Create Admin Form for "Data UMKM" (Business Profile) <!-- id: 4 -->
- [x] Create Admin Form for "Produk UMKM" (Product) <!-- id: 5 -->
- [x] Link list views to new forms and update state <!-- id: 6 -->
- [ ] Run build and verify <!-- id: 7 -->

View File

@@ -1,34 +0,0 @@
# Plan: UMKM Module Implementation
## Goal
Implement UMKM, ProdukUmkm, and PenjualanProduk module with CRUD API and Dashboard analytics.
## Steps
1. Update Prisma Schema (already done in file).
2. Run database migration and seed data.
3. Implement UMKM CRUD API.
4. Implement ProdukUmkm CRUD API.
5. Implement PenjualanProduk CRUD API.
6. Implement Dashboard API (KPI, Summary, Top Produk, Detail Penjualan).
7. Register all routers in the ekonomi module.
8. Verify with type check and build.
## Progress
- [x] Step 1: Update Prisma Schema
- [x] Step 2: Run database migration
- [x] Step 3: Implement UMKM CRUD API
- [x] Step 4: Implement ProdukUmkm CRUD API
- [x] Step 5: Implement PenjualanProduk CRUD API
- [x] Step 6: Implement Dashboard API
- [x] Step 7: Register routers
- [x] Step 8: Verify changes
- [x] Step 9: Implement Admin UI Layout and Tabs
- [x] Step 10: Implement Dashboard UI Page
- [x] Step 11: Implement Data UMKM UI Page
- [x] Step 12: Implement Produk UI Page
- [x] Step 13: Implement Penjualan UI Page
- [x] Step 14: Register UI pages in Admin Menu
- [x] Step 15: Implement Public UMKM Directory Page
- [x] Step 16: Implement Public UMKM Detail Page
- [x] Step 17: Implement Public Product Catalog Page
- [x] Step 18: Register public pages in Navbar

View File

@@ -1,15 +0,0 @@
# Summary - Admin UMKM & Produk Edit functionality
## Changes
- **Valtio State**: Added `update` and `del` methods for UMKM and Produk modules in `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts`.
- **List Pages**: Updated `data-umkm/page.tsx` and `produk/page.tsx` to handle edit (navigation) and delete (confirmation modal + state action).
- **Edit Pages**:
- Created `src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/[id]/edit/page.tsx`
- Created `src/app/admin/(dashboard)/ekonomi/umkm/produk/[id]/edit/page.tsx`
- **Component**: Integrated `ModalKonfirmasiHapus` with named import.
- **Version**: Bumped to `0.1.21`.
## Verification
- Successfully ran `bun run build`.
- Pushed to `tasks/admin-umkm-edit/implement-edit-delete/2026-04-24-11-44`.
- Merged to `stg` and pushed to `origin` and `deploy` remotes.

View File

@@ -1,20 +0,0 @@
# Summary: UMKM Module Bug Fixes
## Changes Made:
1. **Fixed TypeError in UMKM/Pasar Desa Public Page**:
- Modified `src/app/darmasaba/(pages)/ekonomi/pasar-desa/page.tsx` to stop destructuring the `load` method from the Valtio proxy.
- Called `load` directly via `pasarDesaState` or `umkmState` to preserve `this` binding.
- Cleaned up unused imports (`Group`, `IconTag`).
2. **Fixed 404 API URL for Category Products**:
- Corrected the URL in `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts` from `/api/ekonomi/pasar-desa/kategori-produk/find-many-all` to `/api/ekonomi/kategoriproduk/find-many-all`.
- Removed unused `Prisma` import.
3. **Resolved Recharts Warning and Improved Dashboard**:
- Added a `BarChart` to the UMKM Admin Dashboard (`src/app/admin/(dashboard)/ekonomi/umkm/dashboard/page.tsx`) to show sales trends by product.
- Wrapped the chart in a `ResponsiveContainer` and provided an explicit height of 350px on the parent `Box`.
- Fixed a compilation error in `src/app/darmasaba/(pages)/ekonomi/umkm/[id]/page.tsx` by adding the missing `Center` import.
## Verification:
- Ran `bun run build` successfully with no compile errors.
- Verified that all three bugs are addressed based on code analysis and build success.

View File

@@ -1,14 +0,0 @@
# Summary - Refactor UMKM Edit Pages Pattern
## Changes
1. **UMKM Produk Edit Page**: Refactored `src/app/admin/(dashboard)/ekonomi/umkm/produk/[id]/edit/page.tsx` to match the "Berita" edit page pattern. Added explicit `ProdukData` and `ProdukForm` interfaces. Added Reset ("Batal") functionality, standardized header, paper, and dropzone styling, and used `EditEditor`.
2. **Data UMKM Edit Page**: Refactored `src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/[id]/edit/page.tsx` with the same pattern and interfaces (`UmkmData`, `UmkmForm`).
3. **Type Safety**: Improved type safety by using explicit interfaces for data fetching and form state management.
4. **UI Consistency**: Standardized colors and component usage across UMKM edit pages.
5. **UX Improvement**: Added a "Batal" button that resets the form to its original data state.
6. **Build Verification**: Confirmed that the project builds successfully with `bun run build`.
## Verification Results
- `bun run build`: Success.
- Pattern Match: Both pages now follow the consistent layout and logic of the Berita edit page.
- Reset Functionality: Implemented and verified via logic review.

View File

@@ -1,20 +0,0 @@
# Summary: Unified UMKM and Pasar Desa Model
## Changes Made:
1. **Model Unification**:
- `ProdukUmkm` has been removed.
- `PasarDesa` now includes `stok` and an optional `umkmId`.
- `PenjualanProduk` is now directly related to `PasarDesa`.
- Admin context is separated: "Pasar Desa" manages products where `umkmId` is null, while "UMKM" manages products where `umkmId` is not null.
2. **API & Logic Updates**:
- All UMKM product APIs (CRUD) now target the `PasarDesa` model.
- Sales transactions correctly decrement `stok` in the `PasarDesa` table.
- Dashboard analytics correctly query sales data based on the updated model.
3. **UI Enhancements**:
- Added `src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/create/page.tsx` for UMKM business profiles.
- Added `src/app/admin/(dashboard)/ekonomi/umkm/produk/create/page.tsx` for UMKM products with category support.
- Updated list views to separate "Pasar Murni" and "UMKM Produk" logically.
## Verification:
- Database schema synchronized with `prisma db push`.
- API logic updated and tested for consistency.

View File

@@ -1,34 +0,0 @@
# Summary: Refactor UMKM and Pasar Desa (Consolidation)
## Objective
Successfully consolidated "Pasar Desa" into the UMKM module. Pasar Desa is now strictly a part of the UMKM ecosystem, where every product must belong to an UMKM entity.
## Changes Made:
1. **Backend & API**:
- Removed redundant `pasar-desa` API endpoints from `src/app/api/[[...slugs]]/_lib/ekonomi/index.ts`.
- Removed invalid `not: null` filters for `umkmId` in UMKM dashboard and product findMany APIs (since `umkmId` is now mandatory).
- Updated `umkmState` to include `findUnique` for products.
2. **Admin UI**:
- Removed "Pasar Desa" menu items from `src/app/admin/_com/list_PageAdmin.tsx` for all roles.
- Cleaned up unused state management for `pasar-desa`.
3. **Public UI**:
- Replaced "Pasar Desa" with "UMKM" in the public navbar (`src/con/navbar-list-menu.ts`).
- Unified the public hub at `/darmasaba/ekonomi/umkm`.
- Refactored the hub page to remove the "Produk Pasar Desa" tab and rename other tabs to "Katalog Produk" and "Direktori Bisnis".
- Updated product detail routing to `/darmasaba/ekonomi/umkm/produk/[id]`.
- Updated UMKM profile routing to `/darmasaba/ekonomi/umkm/[id]`.
4. **Database & Seeding**:
- Created a new UMKM seeder (`prisma/_seeder_list/ekonomi/seed_umkm.ts`).
- Updated `seedPasarDesa` to link products to UMKM entities, satisfying the mandatory `umkmId` constraint.
- Integrated `seedUmkm` into the main `seed.ts`.
5. **Code Cleanup**:
- Fixed missing imports (e.g., `IconUser`).
- Removed unused imports across several files.
- Fixed copy-pasted toast messages in unrelated modules.
## Verification**:
- Build successful (`bun run build`).
- No "Pasar Desa" menu in Admin.
- "UMKM" menu in Public Navbar points to unified hub.
- Unified hub shows products linked to UMKM.
- Product detail pages correctly show seller information.

View File

@@ -1,34 +0,0 @@
# Summary: UMKM Module Implementation
## Accomplishments
- Successfully migrated the database to include `Umkm`, `ProdukUmkm`, and `PenjualanProduk` tables.
- Implemented a complete set of CRUD API endpoints for UMKM, Products, and Sales.
- Implemented a comprehensive Dashboard API providing KPIs, sales summaries, top products, and detailed stock analytics.
- Integrated the new module into the existing `ekonomi` router.
- Implemented the Admin UI with a modern tab-based layout for complete business management.
- Unified the Public UI by integrating UMKM data into a single "Pasar Desa & UMKM" hub with tabbed navigation.
- Registered the unified page in the Website Navbar, reducing menu clutter.
- Verified the implementation with `tsc` and `bun run build`.
## Files Created/Modified
### Modified
- `prisma/schema.prisma`: Added relations and models.
- `src/app/api/[[...slugs]]/_lib/ekonomi/index.ts`: Registered new routers.
- `src/app/admin/_com/list_PageAdmin.tsx`: Registered new UI pages in menu.
### Created
- `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/`: CRUD for UMKM.
- `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/produk/`: CRUD for Products.
- `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/penjualan/`: CRUD for Sales with stock management.
- `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/dashboard/`: Analytics endpoints.
- `src/app/admin/(dashboard)/ekonomi/umkm/`: Admin UI pages and layouts.
- `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts`: Valtio state for the UMKM module.
## Stock Management Logic
- Creating a sale decrements product stock.
- Updating a sale adjusts stock based on the difference in quantity.
- Deleting a sale increments stock back.
## Next Steps
- Implement frontend UI for the UMKM module.
- Add more comprehensive tests for the stock management logic.

View File

@@ -1,173 +0,0 @@
# Musik Desa - Create Feature Analysis
## Error Summary
**Error**: `ERR_BLOCKED_BY_CLIENT` saat create musik di staging environment
## Root Cause Analysis
### 1. **CORS Configuration Issue** (Primary)
File: `src/app/api/[[...slugs]]/route.ts`
The CORS configuration has specific origins listed:
```typescript
const corsConfig = {
origin: [
"http://localhost:3000",
"http://localhost:3001",
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
"*",
],
// ...
}
```
**Problem**: The wildcard `*` is at the end, but some browsers don't respect it when `credentials: true` is set.
### 2. **API Fetch Base URL** (Secondary)
File: `src/lib/api-fetch.ts`
```typescript
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
```
**Problem**:
- In staging, this might still default to `http://localhost:3000`
- Mixed content (HTTPS frontend → HTTP API) gets blocked by browsers
- The `NEXT_PUBLIC_BASE_URL` environment variable might not be set in staging
### 3. **File Storage Upload Path** (Tertiary)
File: `src/app/api/[[...slugs]]/_lib/fileStorage/_lib/create.ts`
```typescript
const UPLOAD_DIR = process.env.WIBU_UPLOAD_DIR;
```
**Problem**: If `WIBU_UPLOAD_DIR` is not set or points to a non-writable location, uploads will fail silently.
## Solution
### Fix 1: Update CORS Configuration
**File**: `src/app/api/[[...slugs]]/route.ts`
```typescript
// Move wildcard to first position and ensure it works with credentials
const corsConfig = {
origin: [
"*", // Allow all origins (for staging flexibility)
"http://localhost:3000",
"http://localhost:3001",
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
"https://desa-darmasaba-stg.wibudev.com"
],
methods: ["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] as HTTPMethod[],
allowedHeaders: ["Content-Type", "Authorization", "Accept"],
exposedHeaders: ["Content-Range", "X-Content-Range"],
maxAge: 86400, // 24 hours
credentials: true,
};
```
### Fix 2: Add Environment Variable Validation
**File**: `.env.example` (update)
```bash
# Application Configuration
NEXT_PUBLIC_BASE_URL=http://localhost:3000
# For staging/production, set this to your actual domain
# NEXT_PUBLIC_BASE_URL=https://cld-dkr-desa-darmasaba-stg.wibudev.com
```
### Fix 3: Update API Fetch to Handle Relative URLs
**File**: `src/lib/api-fetch.ts`
```typescript
import { AppServer } from '@/app/api/[[...slugs]]/route'
import { treaty } from '@elysiajs/eden'
// Use relative URL for better deployment flexibility
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || '/'
const ApiFetch = treaty<AppServer>(BASE_URL)
export default ApiFetch
```
### Fix 4: Add Error Handling in Create Page
**File**: `src/app/admin/(dashboard)/musik/create/page.tsx`
Add better error logging to diagnose issues:
```typescript
const handleSubmit = async () => {
// ... validation ...
try {
setIsSubmitting(true);
// Upload cover image
const coverRes = await ApiFetch.api.fileStorage.create.post({
file: coverFile,
name: coverFile.name,
});
if (!coverRes.data?.data?.id) {
console.error('Cover upload failed:', coverRes);
return toast.error('Gagal mengunggah cover, silakan coba lagi');
}
// ... rest of the code ...
} catch (error) {
console.error('Error creating musik:', {
error,
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
});
toast.error('Terjadi kesalahan saat membuat musik');
} finally {
setIsSubmitting(false);
}
};
```
## Testing Checklist
### Local Development
- [ ] Test create musik with cover image and audio file
- [ ] Verify CORS headers in browser DevTools Network tab
- [ ] Check that file uploads are saved to correct directory
### Staging Environment
- [ ] Set `NEXT_PUBLIC_BASE_URL` to staging domain
- [ ] Verify HTTPS is used for all API calls
- [ ] Check browser console for mixed content warnings
- [ ] Verify `WIBU_UPLOAD_DIR` is set and writable
- [ ] Test create musik end-to-end
## Additional Notes
### ERR_BLOCKED_BY_CLIENT Common Causes:
1. **CORS policy blocking** - Most likely cause
2. **Ad blockers** - Can block certain API endpoints
3. **Mixed content** - HTTPS page making HTTP requests
4. **Content Security Policy (CSP)** - Restrictive CSP headers
5. **Browser extensions** - Privacy/security extensions blocking requests
### Debugging Steps:
1. Open browser DevTools → Network tab
2. Try to create musik
3. Look for failed requests (red status)
4. Check the "Headers" tab for:
- Request URL (should be correct domain)
- Response headers (should have `Access-Control-Allow-Origin`)
- Status code (4xx/5xx indicates server-side issue)
5. Check browser console for CORS errors
## Recommended Next Steps
1. **Immediate**: Update CORS configuration to allow staging domain
2. **Short-term**: Add proper environment variable validation
3. **Long-term**: Implement proper error boundaries and logging

View File

@@ -1,347 +0,0 @@
# Fix Summary - Berita Desa High Priority Issues
**Tanggal:** 25 Februari 2026
**Status:****ALL COMPLETED**
---
## ✅ COMPLETED FIXES
### 1. API - Delete Kategori dengan Relation Check ✅ FIXED
**File:** `src/app/api/[[...slugs]]/_lib/desa/berita/kategori-berita/del.ts`
**Changes:**
```typescript
// BEFORE
export default async function kategoriBeritaDelete(context: Context) {
const id = context.params.id as string;
// ❌ Langsung delete tanpa cek relasi
await prisma.kategoriBerita.delete({
where: { id },
});
return {
status: 200,
success: true,
message: "Sukses Menghapus kategori berita",
};
}
// AFTER
export default async function kategoriBeritaDelete(context: Context) {
try {
const id = context.params?.id as string;
if (!id) {
return Response.json({
success: false,
message: "ID tidak boleh kosong",
}, { status: 400 });
}
// ✅ Cek apakah kategori masih digunakan oleh berita
const beritaCount = await prisma.berita.count({
where: {
kategoriBeritaId: id,
isActive: true,
deletedAt: null,
},
});
if (beritaCount > 0) {
return Response.json({
success: false,
message: `Kategori tidak dapat dihapus karena masih digunakan oleh ${beritaCount} berita`,
}, { status: 400 });
}
// ✅ Soft delete (bukan hard delete)
await prisma.kategoriBerita.update({
where: { id },
data: {
deletedAt: new Date(),
isActive: false,
},
});
return {
success: true,
message: "Kategori berita berhasil dihapus",
};
} catch (error) {
console.error("Delete kategori error:", error);
return Response.json({
success: false,
message: "Gagal menghapus kategori: " + (error instanceof Error ? error.message : 'Unknown error'),
}, { status: 500 });
}
}
```
**Impact:**
- ✅ Tidak ada foreign key constraint error
- ✅ Data integrity terjaga - berita tidak kehilangan referensi kategori
- ✅ User feedback lebih baik (error message jelas dengan jumlah berita)
- ✅ Soft delete pattern konsisten (bukan hard delete)
- ✅ Error handling lebih robust dengan try-catch
**Testing:**
```bash
# Test 1: Delete kategori yang masih digunakan (should fail)
DELETE /api/desa/berita/kategoriberita/del/{id}
# Expected: 400 Bad Request
# Response: { success: false, message: "Kategori tidak dapat dihapus karena masih digunakan oleh X berita" }
# Test 2: Delete kategori yang tidak digunakan (should succeed)
DELETE /api/desa/berita/kategoriberita/del/{id}
# Expected: 200 OK
# Response: { success: true, message: "Kategori berita berhasil dihapus" }
```
---
### 2. UI - Search Parameter Hilang Saat Pagination ✅ FIXED
**File:** `src/app/admin/(dashboard)/desa/berita/list-berita/page.tsx`
**Changes:**
```typescript
// BEFORE (Line 189)
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10); // ❌ Missing search parameter
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
// AFTER (Line 189)
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, debouncedSearch); // ✅ Include search parameter
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
```
**Impact:**
- ✅ Search query tidak hilang saat ganti halaman
- ✅ UX significantly improved - user tidak perlu ketik ulang search
- ✅ Pagination dan search bekerja bersamaan dengan baik
- ✅ Consistent dengan best practices
**Testing:**
```
1. Buka halaman List Berita
2. Ketik search query (misal: "desa")
3. Tunggu hasil search muncul
4. Klik pagination halaman 2
5. ✅ Verify: search query "desa" masih ada di search box
6. ✅ Verify: hasil di halaman 2 masih ter-filter dengan "desa"
7. ✅ Verify: URL parameter search tetap ada (jika ada)
```
**Note:** Function `load` sudah menerima parameter search dari state management:
```typescript
// State: src/app/admin/(dashboard)/_state/desa/berita.ts
async load(page = 1, limit = 10, search = '') {
// ... implementation sudah support search
}
```
---
### 3. UI - colSpan Tidak Sesuai Jumlah Kolom ✅ FIXED
**File:** `src/app/admin/(dashboard)/desa/berita/kategori-berita/page.tsx`
**Changes:**
```typescript
// BEFORE (Line 163)
<TableTr>
<TableTd colSpan={4}> {/* ❌ colSpan 4, seharusnya 3 */}
<Center py={24}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori berita yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
// AFTER (Line 163)
<TableTr>
<TableTd colSpan={3}> {/* ✅ Match column count (3 columns) */}
<Center py={24}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori berita yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
```
**Table Structure:**
```typescript
<TableThead>
<TableTr>
<TableTh w="60%">Nama</TableTh> {/* Column 1 */}
<TableTh w="20%">Edit</TableTh> {/* Column 2 */}
<TableTh w="20%">Hapus</TableTh> {/* Column 3 */}
</TableTr>
</TableThead>
```
**Impact:**
- ✅ Layout table rapi dan proporsional
- ✅ Empty state tidak terlalu lebar atau terlalu sempit
- ✅ Visual consistency maintained
- ✅ Professional appearance
**Testing:**
```
1. Buka halaman Kategori Berita
2. Pastikan tidak ada data (atau search dengan query yang tidak ada hasilnya)
3. ✅ Verify: Empty state message centered dengan baik
4. ✅ Verify: Empty state tidak terlalu lebar atau sempit
5. ✅ Verify: Table layout tetap rapi
```
---
## 📊 SUMMARY OF CHANGES
| Issue | Status | File Changed | Impact |
|-------|--------|--------------|--------|
| 1. Delete Relation Check | ✅ Fixed | del.ts | Prevents data integrity issues |
| 2. Search in Pagination | ✅ Fixed | list-berita/page.tsx | UX significantly improved |
| 3. colSpan Mismatch | ✅ Fixed | kategori-berita/page.tsx | UI polish, consistency |
**Total Files Modified:** 3
- `src/app/api/[[...slugs]]/_lib/desa/berita/kategori-berita/del.ts`
- `src/app/admin/(dashboard)/desa/berita/list-berita/page.tsx`
- `src/app/admin/(dashboard)/desa/berita/kategori-berita/page.tsx`
---
## 🧪 TESTING CHECKLIST
### API Changes (Issue #1):
- [ ] Test delete kategori yang masih digunakan oleh 1 berita (should fail with message "masih digunakan oleh 1 berita")
- [ ] Test delete kategori yang masih digunakan oleh 5 berita (should fail with message "masih digunakan oleh 5 berita")
- [ ] Test delete kategori yang tidak digunakan sama sekali (should succeed)
- [ ] Test delete dengan ID kosong (should return 400)
- [ ] Test delete dengan ID yang tidak ada (should return error)
- [ ] Verify soft delete: cek `deletedAt` dan `isActive` di database
### UI Changes (Issue #2):
- [ ] Test search dengan 1 karakter
- [ ] Test search dengan 10 karakter
- [ ] Test pagination page 1 → page 2 (search query harus tetap ada)
- [ ] Test pagination page 2 → page 3 (search query harus tetap ada)
- [ ] Test pagination page 3 → page 1 (search query harus tetap ada)
- [ ] Test clear search (pagination harus reset ke page 1)
- [ ] Test scroll to top saat ganti halaman
### UI Changes (Issue #3):
- [ ] Test dengan data kosong (empty state)
- [ ] Test dengan search tidak ada hasil (empty state)
- [ ] Verify colSpan = 3 (tidak terlalu lebar/sempit)
- [ ] Verify table layout tetap rapi
---
## 📝 ADDITIONAL IMPROVEMENTS
### Code Quality Improvements:
**1. Better Error Handling (del.ts):**
```typescript
try {
// ... validation and logic
} catch (error) {
console.error("Delete kategori error:", error);
return Response.json({
success: false,
message: "Gagal menghapus kategori: " + (error instanceof Error ? error.message : 'Unknown error'),
}, { status: 500 });
}
```
**2. Soft Delete Pattern (del.ts):**
```typescript
// Changed from hard delete to soft delete
await prisma.kategoriBerita.update({
where: { id },
data: {
deletedAt: new Date(),
isActive: false,
},
});
```
**3. Consistent Response Format (del.ts):**
```typescript
return {
success: true,
message: "Kategori berita berhasil dihapus",
};
```
---
## 🚀 MIGRATION NOTES
### No Database Changes Required:
- ✅ Tidak ada perubahan schema
- ✅ Tidak perlu migration
- ✅ Tidak perlu db push
### Backward Compatibility:
- ✅ API response format tetap sama (`{ success, message }`)
- ✅ Frontend pagination API tetap sama
- ✅ Table structure tidak berubah
---
## ✅ VERIFICATION
**All High Priority Issues from QC Report:**
- [x] Issue #1: API - Delete kategori relation check ✅ FIXED
- [x] Issue #2: UI - Search parameter pagination ✅ FIXED
- [x] Issue #3: UI - colSpan mismatch ✅ FIXED
**Status: 3/3 High Priority Issues FIXED (100% Complete)**
---
## 📈 IMPACT SUMMARY
### Before Fix:
- ❌ Kategori bisa dihapus meski masih digunakan (data integrity issue)
- ❌ Search hilang saat pagination (UX issue)
- ❌ Table layout tidak rapi (UI polish issue)
### After Fix:
- ✅ Kategori tidak bisa dihapus jika masih digunakan (data integrity protected)
- ✅ Search tetap ada saat pagination (UX improved)
- ✅ Table layout rapi (UI polished)
---
**Last Updated:** 25 Februari 2026
**Completed By:** QC Automation
**Review Status:** ✅ Ready for Testing
**Total Time to Fix:** ~30 minutes

View File

@@ -1,442 +0,0 @@
# Fix Summary - Potensi Desa High Priority Issues
**Tanggal:** 25 Februari 2026
**Status:****ALL COMPLETED**
---
## ✅ COMPLETED FIXES
### 1. Schema - Unique Constraints ✅ FIXED
**File:** `prisma/schema.prisma`
**Changes:**
```prisma
// BEFORE
model PotensiDesa {
name String // ❌ No unique constraint
// ...
}
model KategoriPotensi {
nama String // ❌ No unique constraint
// ...
}
// AFTER
model PotensiDesa {
name String @unique @db.VarChar(255) // ✅ Unique + length limit
// ...
}
model KategoriPotensi {
nama String @unique @db.VarChar(100) // ✅ Unique + length limit
// ...
}
```
**Impact:**
- ✅ Tidak ada duplikasi nama kategori potensi
- ✅ Tidak ada duplikasi nama potensi desa
- ✅ Database-level validation untuk uniqueness
**Database Migration:**
```bash
✅ COMPLETED: bunx prisma db push --accept-data-loss
✅ Prisma Client regenerated successfully
```
---
### 2. Schema - kategoriId Required ✅ FIXED
**File:** `prisma/schema.prisma`
**Changes:**
```prisma
// BEFORE
model PotensiDesa {
kategoriId String? // ❌ Nullable
// ...
}
// AFTER
model PotensiDesa {
kategoriId String @db.VarChar(36) // ✅ Required + length limit
// ...
}
```
**Impact:**
- ✅ Potensi desa HARUS punya kategori
- ✅ Data integrity lebih baik
- ✅ Foreign key constraint enforced
**Note:** Form create/edit sudah validasi kategori wajib dipilih (existing validation).
---
### 3. Schema - Length Constraints ✅ FIXED
**File:** `prisma/schema.prisma`
**Changes:**
```prisma
// BEFORE
model PotensiDesa {
name String // ❌ No max length
deskripsi String @db.Text
// ...
}
model KategoriPotensi {
nama String // ❌ No max length
// ...
}
// AFTER
model PotensiDesa {
name String @unique @db.VarChar(255) // ✅ Max 255 chars
deskripsi String @db.Text
kategoriId String @db.VarChar(36) // ✅ Max 36 chars (CUID)
// ...
}
model KategoriPotensi {
nama String @unique @db.VarChar(100) // ✅ Max 100 chars
// ...
}
```
**Impact:**
- ✅ User tidak bisa input nama sangat panjang
- ✅ UI tidak break karena text terlalu panjang
- ✅ Database storage lebih efisien
---
### 4. API - Delete Kategori dengan Relation Check ✅ FIXED
**File:** `src/app/api/[[...slugs]]/_lib/desa/potensi/kategori-potensi/del.ts`
**Changes:**
```typescript
// BEFORE
export default async function kategoriPotensiDelete(context: Context) {
const id = context.params.id as string;
// ❌ Langsung delete tanpa cek relasi
await prisma.kategoriPotensi.delete({
where: { id },
});
return {
status: 200,
success: true,
message: "Sukses Menghapus kategori potensi",
};
}
// AFTER
export default async function kategoriPotensiDelete(context: Context) {
try {
const id = context.params?.id as string;
if (!id) {
return Response.json({
success: false,
message: "ID tidak boleh kosong",
}, { status: 400 });
}
// ✅ Cek apakah kategori masih digunakan oleh potensi desa
const existingPotensi = await prisma.potensiDesa.findFirst({
where: {
kategoriId: id,
isActive: true,
deletedAt: null,
},
});
if (existingPotensi) {
return Response.json({
success: false,
message: "Kategori masih digunakan oleh potensi desa. Tidak dapat dihapus.",
}, { status: 400 });
}
// ✅ Soft delete (bukan hard delete)
await prisma.kategoriPotensi.update({
where: { id },
data: {
deletedAt: new Date(),
isActive: false,
},
});
return {
success: true,
message: "Kategori potensi berhasil dihapus",
};
} catch (error) {
console.error("Delete kategori error:", error);
return Response.json({
success: false,
message: "Gagal menghapus kategori: " + (error instanceof Error ? error.message : 'Unknown error'),
}, { status: 500 });
}
}
```
**Impact:**
- ✅ Tidak ada foreign key constraint error
- ✅ Data integrity terjaga
- ✅ User feedback lebih baik (error message jelas)
- ✅ Soft delete pattern konsisten
---
### 5. API - Find Unique dengan isActive Filter ✅ FIXED
**File:** `src/app/api/[[...slugs]]/_lib/desa/potensi/find-unique.ts`
**Changes:**
```typescript
// BEFORE
const data = await prisma.potensiDesa.findUnique({
where: { id }, // ❌ No isActive filter
include: {
image: true,
kategori: true
},
});
// AFTER
// ✅ Filter by isActive and deletedAt
const data = await prisma.potensiDesa.findFirst({
where: {
id,
isActive: true, // ✅ Added
deletedAt: null, // ✅ Added
},
include: {
image: true,
kategori: true
},
});
```
**Impact:**
- ✅ Tidak load data yang sudah soft-delete
- ✅ Data consistency lebih baik
- ✅ Security improved (tidak expose deleted data)
---
### 6. UI - XSS Sanitization dengan DOMPurify ✅ FIXED
**Files Modified:**
-`src/app/admin/(dashboard)/desa/potensi/list-potensi/page.tsx`
-`src/app/admin/(dashboard)/desa/potensi/list-potensi/[id]/page.tsx`
**Changes:**
**Import DOMPurify:**
```typescript
import DOMPurify from 'dompurify';
```
**Sanitize HTML (Desktop Table - line 140):**
```typescript
// BEFORE
<Text
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
style={{ wordBreak: 'break-word' }}
/>
// AFTER
<Text
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(item.deskripsi, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
ALLOWED_ATTR: []
})
}}
style={{ wordBreak: 'break-word' }}
/>
```
**Sanitize HTML (Mobile Cards - line 202):**
```typescript
// BEFORE
<Text
fz="sm"
lh={1.5}
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
style={{ wordBreak: 'break-word' }}
/>
// AFTER
<Text
fz="sm"
lh={1.5}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(item.deskripsi, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
ALLOWED_ATTR: []
})
}}
style={{ wordBreak: 'break-word' }}
/>
```
**Sanitize HTML (Detail Page - deskripsi & content):**
```typescript
// BEFORE
<Text
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
/>
<Text
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
/>
// AFTER
<Text
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(data.deskripsi || '-', {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
ALLOWED_ATTR: []
})
}}
/>
<Text
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(data.content || '-', {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
ALLOWED_ATTR: []
})
}}
/>
```
**Impact:**
- ✅ XSS attack prevented
- ✅ User tidak bisa inject malicious scripts
- ✅ Security significantly improved
- ✅ Data integrity terjaga
**Allowed HTML Tags:**
- `p` - Paragraph
- `br` - Line break
- `strong` - Bold
- `em` - Italic
- `u` - Underline
- `ul`, `ol`, `li` - Lists
**Disallowed:**
- `script`, `iframe`, `object`, `embed`, dll (berbahaya)
- Semua attributes (untuk security maksimal)
---
## 📊 SUMMARY OF CHANGES
| Issue | Status | Files Changed | Impact |
|-------|--------|---------------|--------|
| 1. Unique Constraints | ✅ Fixed | schema.prisma | Prevents duplicates |
| 2. Required kategoriId | ✅ Fixed | schema.prisma | Data integrity |
| 3. Length Constraints | ✅ Fixed | schema.prisma | UI/DB protection |
| 4. Delete Relation Check | ✅ Fixed | del.ts | Prevents data loss |
| 5. isActive Filter | ✅ Fixed | find-unique.ts | Data consistency |
| 6. XSS Sanitization | ✅ Fixed | 2 pages | Security improved |
**Total Files Modified:** 5
- `prisma/schema.prisma`
- `src/app/api/[[...slugs]]/_lib/desa/potensi/kategori-potensi/del.ts`
- `src/app/api/[[...slugs]]/_lib/desa/potensi/find-unique.ts`
- `src/app/admin/(dashboard)/desa/potensi/list-potensi/page.tsx`
- `src/app/admin/(dashboard)/desa/potensi/list-potensi/[id]/page.tsx`
---
## 🧪 TESTING CHECKLIST
### Database Changes:
- [ ] Verify unique constraint works (try insert duplicate name)
- [ ] Verify length constraint works (try insert >255 chars)
- [ ] Verify kategoriId required (try insert without kategori)
- [ ] Check existing data still accessible
### API Changes:
- [ ] Test delete kategori yang masih digunakan (should fail)
- [ ] Test delete kategori yang tidak digunakan (should succeed)
- [ ] Test find-unique untuk data yang sudah deleted (should return 404)
- [ ] Test find-unique untuk data aktif (should work)
### UI Changes:
- [ ] Test XSS attempt dengan script tags (should be sanitized)
- [ ] Test HTML content masih render dengan benar
- [ ] Test allowed tags (p, br, strong, em, u, lists) masih work
- [ ] Test disallowed tags (script, iframe) di-strip
---
## 🚀 MIGRATION NOTES
### Database Migration Applied:
```bash
bunx prisma db push --accept-data-loss
```
**Warnings Accepted:**
- Column `nama` cast from `Text` to `VarChar(100)` (3 rows)
- Column `name` cast from `Text` to `VarChar(255)` (11 rows)
- Column `kategoriId` cast from `Text` to `VarChar(36)` (11 rows)
- Unique constraint added to `nama`
- Unique constraint added to `name`
**Data Loss Considerations:**
- Jika ada data dengan nama >100 chars (kategori) atau >255 chars (potensi), akan ter-truncate
- Jika ada duplicate names, migration akan fail (perlu manual cleanup dulu)
### Existing Data:
- **KategoriPotensi:** 3 rows (should be fine)
- **PotensiDesa:** 11 rows (should be fine)
---
## 📝 RECOMMENDATIONS
### Immediate Actions:
1.**Test di staging environment** dulu sebelum production
2.**Backup database** sebelum deploy ke production
3.**Check existing data** untuk duplicate names
4.**Test semua CRUD operations** untuk potensi dan kategori
### Future Improvements:
1. **Add authentication** ke semua API endpoints (belum ada di scope QC ini)
2. **Add backend validation** untuk duplicate check di create/update
3. **Add pagination** di find-many API (sudah ada)
4. **Add search** di semua fields (sudah ada)
5. **Add sorting** options (belum ada)
---
## ✅ VERIFICATION
**All High Priority Issues from QC Report:**
- [x] Issue #1: Schema - Unique constraints ✅ FIXED
- [x] Issue #2: Schema - kategoriId required ✅ FIXED
- [x] Issue #3: Schema - Length constraints ✅ FIXED
- [x] Issue #4: API - Delete relation check ✅ FIXED
- [x] Issue #5: API - isActive filter ✅ FIXED
- [x] Issue #6: UI - XSS sanitization ✅ FIXED
**Status: 6/6 High Priority Issues FIXED (100% Complete)**
---
**Last Updated:** 25 Februari 2026
**Completed By:** QC Automation
**Review Status:** ✅ Ready for Testing

View File

@@ -1,363 +0,0 @@
# Fix Summary - Profil Desa High Priority Issues
**Tanggal:** 25 Februari 2026
**Status:****Partially Completed**
---
## ✅ COMPLETED FIXES
### 1. Schema - deletedAt @default(now()) Bug ✅ FIXED
**File:** `prisma/schema.prisma`
**Changes:**
```prisma
// BEFORE
model SejarahDesa {
deletedAt DateTime @default(now()) // ❌ BUG
}
// AFTER
model SejarahDesa {
deletedAt DateTime? // ✅ FIXED
}
```
**Affected Models:**
- ✅ SejarahDesa
- ✅ VisiMisiDesa
- ✅ LambangDesa
- ✅ MaskotDesa
**Database Migration:**
```bash
✅ COMPLETED: bunx prisma db push
✅ Prisma Client regenerated successfully
```
---
### 2. Hardcoded Nama Perbekel di UI ✅ FIXED
**File:** `src/app/admin/(dashboard)/desa/profil/profil-perbekel/page.tsx`
**Changes:**
```tsx
// BEFORE (Line 95-102)
<Text>I.B. Surya Prabhawa Manuaba, S.H., M.H.</Text>
// AFTER
<Text>{perbekel.nama || "I.B. Surya Prabhawa Manuaba, S.H., M.H."}</Text>
```
**Impact:**
- ✅ Nama perbekel sekarang dinamis dari database
- ✅ Fallback ke nama lama jika data kosong (backward compatible)
---
### 3. Magic String "edit" - Created /first Endpoint ✅ FIXED
**New Files Created:**
-`src/app/api/[[...slugs]]/_lib/desa/profile/profile_desa/sejarah/find-first.ts`
- ✅ Updated `src/app/api/[[...slugs]]/_lib/desa/profile/profile_desa/sejarah/index.ts`
**New Endpoint:**
```
GET /api/desa/profile/sejarah/first
```
**Features:**
- ✅ Authentication required (menggunakan `requireAuth`)
- ✅ Returns first active record (orderBy createdAt asc)
- ✅ No more magic string "edit"
- ✅ Type-safe dan scalable
**Usage:**
```typescript
// OLD (magic string)
stateProfileDesa.sejarahDesa.findUnique.load("edit");
// NEW (type-safe)
const response = await ApiFetch.api.desa.profile.sejarah.first.get();
```
---
### 4. Authentication Helper Libraries ✅ CREATED
**New Files:**
-`src/lib/api-auth.ts` - Authentication helper dengan `requireAuth` dan `optionalAuth`
-`src/lib/session.ts` - Session helper menggunakan iron-session
**Features:**
- ✅ Session-based authentication
- ✅ Auto-redirect jika tidak authenticated
- ✅ Check user isActive status
- ✅ Error handling lengkap
**Usage Example:**
```typescript
import { requireAuth } from "@/lib/api-auth";
export default async function myEndpoint(context: Context) {
const authResult = await requireAuth(context);
if (!authResult.authenticated) {
return authResult.response; // 401 Unauthorized
}
// Lanjut proses dengan authResult.user
console.log("User:", authResult.user);
}
```
---
### 5. Authentication Added to Update Endpoint ✅ FIXED
**File:** `src/app/api/[[...slugs]]/_lib/desa/profile/profile_desa/sejarah/update.ts`
**Changes:**
```typescript
// BEFORE
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function sejarahDesaUpdate(context: Context) {
// ❌ No authentication
const id = context.params?.id as string;
// ...
}
// AFTER
import prisma from "@/lib/prisma";
import { requireAuth } from "@/lib/api-auth";
import { Context } from "elysia";
export default async function sejarahDesaUpdate(context: Context) {
// ✅ Authentication check
const authResult = await requireAuth(context);
if (!authResult.authenticated) {
return authResult.response;
}
const id = context.params?.id as string;
// ...
}
```
---
## ⚠️ REMAINING FIXES (Manual Required)
### 1. Add Authentication to ALL Profile API Endpoints
**Files that need authentication:**
#### Profile Desa (Sejarah, Visi Misi, Lambang, Maskot):
- [ ] `src/app/api/[[...slugs]]/_lib/desa/profile/profile_desa/sejarah/find-by-id.ts`
- [ ] `src/app/api/[[...slugs]]/_lib/desa/profile/profile_desa/visi-misi/find-by-id.ts`
- [ ] `src/app/api/[[...slugs]]/_lib/desa/profile/profile_desa/visi-misi/update.ts`
- [ ] `src/app/api/[[...slugs]]/_lib/desa/profile/profile_desa/lambang-desa/find-by-id.ts`
- [ ] `src/app/api/[[...slugs]]/_lib/desa/profile/profile_desa/lambang-desa/update.ts`
- [ ] `src/app/api/[[...slugs]]/_lib/desa/profile/profile_desa/maskot-desa/find-by-id.ts`
- [ ] `src/app/api/[[...slugs]]/_lib/desa/profile/profile_desa/maskot-desa/update.ts`
#### Profile Perbekel:
- [ ] `src/app/api/[[...slugs]]/_lib/desa/profile/profilePerbekel/find-by-id.ts`
- [ ] `src/app/api/[[...slugs]]/_lib/desa/profile/profilePerbekel/update.ts`
#### Profile Mantan Perbekel:
- [ ] `src/app/api/[[...slugs]]/_lib/desa/profile/profile-mantan-perbekel/create.ts`
- [ ] `src/app/api/[[...slugs]]/_lib/desa/profile/profile-mantan-perbekel/findMany.ts`
- [ ] `src/app/api/[[...slugs]]/_lib/desa/profile/profile-mantan-perbekel/findUnique.ts`
- [ ] `src/app/api/[[...slugs]]/_lib/desa/profile/profile-mantan-perbekel/updt.ts`
- [ ] `src/app/api/[[...slugs]]/_lib/desa/profile/profile-mantan-perbekel/del.ts`
**How to Add Authentication:**
```typescript
// Tambahkan di awal function (sebelum logic utama)
import { requireAuth } from "@/lib/api-auth";
export default async function myEndpoint(context: Context) {
// ✅ Authentication check
const authResult = await requireAuth(context);
if (!authResult.authenticated) {
return authResult.response;
}
// ... existing code
}
```
---
### 2. Fix Maskot Image Delete Logic
**File:** `src/app/api/[[...slugs]]/_lib/desa/profile/profile_desa/maskot-desa/update.ts`
**Current Bug:**
```typescript
// ❌ Menghapus SEMUA gambar lama
for (const old of existing.images) {
await prisma.fileStorage.delete({ where: { id: old.imageId } });
}
```
**Fix Required:**
```typescript
// ✅ Implementasi diff logic
const oldImageIds = existing.images.map(img => img.imageId);
const newImageIds = body.images?.filter(img => img.imageId).map(img => img.imageId) || [];
// Find images to delete (in old but not in new)
const imagesToDelete = oldImageIds.filter(id => !newImageIds.includes(id));
// Delete only removed images
for (const imageId of imagesToDelete) {
if (imageId) {
const oldImage = await prisma.fileStorage.findUnique({ where: { id: imageId } });
if (oldImage) {
try {
const filePath = path.join(oldImage.path, oldImage.name);
await fs.unlink(filePath);
await prisma.fileStorage.delete({ where: { id: imageId } });
} catch (error) {
console.error('Failed to delete old image:', error);
}
}
}
}
```
---
### 3. Update State Management to Use /first Endpoint
**File:** `src/app/admin/(dashboard)/_state/desa/profile.ts`
**Current Code (Line ~36):**
```typescript
// ❌ Magic string "edit"
async load(id: string) {
const response = await fetch(`/api/desa/profile/sejarah/${id}`);
// ...
}
// Usage di page:
stateProfileDesa.sejarahDesa.findUnique.load("edit");
```
**Fix Required:**
```typescript
// ✅ Gunakan /first endpoint
async loadFirst() {
this.loading = true;
this.error = null;
try {
const response = await ApiFetch.api.desa.profile.sejarah.first.get();
if (response.success) {
this.data = response.data;
return response.data;
} else {
throw new Error(response.message || "Gagal mengambil data");
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
console.error("Load sejarah desa error:", msg);
toast.error("Terjadi kesalahan");
return null;
} finally {
this.loading = false;
}
}
// Usage di page:
stateProfileDesa.sejarahDesa.findUnique.loadFirst();
```
---
### 4. Add XSS Sanitization
**Files that use dangerouslySetInnerHTML:**
- [ ] `src/app/admin/(dashboard)/desa/profil/profil-perbekel/page.tsx` (multiple places)
- [ ] `src/app/admin/(dashboard)/desa/profil/profil-perbekel/[id]/page.tsx`
**Fix Required:**
```typescript
// Install: bun add dompurify
import DOMPurify from 'dompurify';
// Usage
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(perbekel.biodata, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
ALLOWED_ATTR: []
})
}}
/>
```
---
## 📋 TESTING CHECKLIST
### Database Changes:
- [ ] Verify schema changes applied: `bunx prisma db push`
- [ ] Check Prisma Client regenerated
- [ ] Test create new data (should not auto-delete)
### API Authentication:
- [ ] Test endpoint tanpa login (should return 401)
- [ ] Test endpoint dengan login (should work)
- [ ] Test dengan user inactive (should return 403)
### /first Endpoint:
- [ ] Test GET /api/desa/profile/sejarah/first
- [ ] Verify returns first active record
- [ ] Test tanpa authentication (should fail)
### UI Changes:
- [ ] Check perbekel name dynamic (not hardcoded)
- [ ] Test with different perbekel data
- [ ] Verify fallback to old name if data empty
---
## 🚀 NEXT STEPS
1. **Add authentication ke semua API endpoints** (15 files)
2. **Fix maskot image delete logic** (1 file)
3. **Update state management** untuk gunakan `/first` endpoint
4. **Add XSS sanitization** di semua page yang pakai `dangerouslySetInnerHTML`
5. **Test semua changes** secara thorough
---
## 📝 NOTES
- ✅ Schema fix sudah di-push ke database
- ✅ Authentication helper sudah dibuat dan bisa di-reuse
- ✅ /first endpoint sudah dibuat sebagai contoh
- ⚠️ Remaining fixes butuh manual update karena banyak file
**Estimated Time to Complete:**
- Add auth to all endpoints: ~2-3 jam
- Fix maskot delete logic: ~30 menit
- Update state management: ~1 jam
- Add XSS sanitization: ~30 menit
- Testing: ~1-2 jam
**Total: ~5-6 jam**
---
**Last Updated:** 25 Februari 2026
**Status:** 3/5 Critical Issues Fixed (60% Complete)

View File

@@ -1,622 +0,0 @@
# Quality Control Report - Berita Desa Admin
**Lokasi:** `/src/app/admin/(dashboard)/desa/berita/`
**Tanggal QC:** 25 Februari 2026
**Status:****Good** (dengan issue critical yang perlu diperbaiki)
---
## 📋 Ringkasan Eksekutif
Halaman Berita Desa memiliki implementasi yang **cukup baik** dengan CRUD lengkap, state management terstruktur, dan UI yang responsive. Ditemukan **14 issue** dengan rincian:
- 🔴 **High Priority:** 3 issue
- 🟡 **Medium Priority:** 7 issue
- 🟢 **Low Priority:** 4 issue
**Overall Score: 7/10** - Good
---
## 📁 Struktur File yang Diperiksa
```
/src/app/admin/(dashboard)/desa/berita/
├── layout.tsx
├── _com/
│ ├── BeritaEditor.tsx # Rich text editor component
│ └── layoutTabs.tsx # Tab navigation
├── kategori-berita/
│ ├── page.tsx # List kategori dengan search & pagination
│ ├── create/
│ │ └── page.tsx # Form create kategori
│ └── [id]/
│ └── page.tsx # Edit kategori
└── list-berita/
├── page.tsx # List berita dengan search & pagination
├── create/
│ └── page.tsx # Form create berita (rich text + image)
└── [id]/
├── page.tsx # Detail berita
└── edit/
└── page.tsx # Edit berita
```
**File Terkait:**
- State: `/src/app/admin/(dashboard)/_state/desa/berita.ts`
- API: `/src/app/api/[[...slugs]]/_lib/desa/berita/` (8 files)
- API: `/src/app/api/[[...slugs]]/_lib/desa/berita/kategori-berita/` (6 files)
- Schema: `/prisma/schema.prisma` (Model `Berita` & `KategoriBerita`)
---
## 🔴 HIGH PRIORITY ISSUES
### 1. API - Kategori Masih Digunakan Bisa Dihapus
**File:** `src/app/api/[[...slugs]]/_lib/desa/berita/kategori-berita/del.ts`
```typescript
export default async function kategoriBeritaDelete(context: Context) {
const id = context.params?.id as string;
// ❌ Tidak cek apakah kategori masih dipakai oleh Berita
await prisma.kategoriBerita.delete({ where: { id } });
return { success: true, message: "Kategori berita berhasil dihapus" };
}
```
**Dampak:**
- Data integrity bermasalah - berita kehilangan referensi kategori
- Bisa terjadi foreign key constraint error
- Berita yang sudah ada jadi tidak punya kategori
**Solusi:**
```typescript
// Cek apakah masih ada berita yang menggunakan kategori ini
const beritaCount = await prisma.berita.count({
where: {
kategoriBeritaId: id,
isActive: true
}
});
if (beritaCount > 0) {
return Response.json({
success: false,
message: `Kategori tidak dapat dihapus karena masih digunakan oleh ${beritaCount} berita`
}, { status: 400 });
}
// Lanjut delete jika tidak ada yang menggunakan
await prisma.kategoriBerita.update({
where: { id },
data: { deletedAt: new Date(), isActive: false }
});
return { success: true, message: "Kategori berita berhasil dihapus" };
```
---
### 2. UI - Search Parameter Hilang Saat Pagination
**File:** `src/app/admin/(dashboard)/desa/berita/list-berita/page.tsx`
```typescript
<Pagination
total={totalPages}
value={page}
onChange={(newPage) => {
load(newPage, 10); // ❌ Missing search parameter
}}
/>
```
**Dampak:**
- Saat user ganti halaman, search query hilang
- User harus ketik ulang search query
- UX sangat buruk untuk pagination dengan search
**Solusi:**
```typescript
<Pagination
total={totalPages}
value={page}
onChange={(newPage) => {
load(newPage, 10, search); // ✅ Include search parameter
}}
/>
```
**Note:** Pastikan function `load` menerima parameter search:
```typescript
const load = async (page: number, limit: number, searchQuery?: string) => {
// ...
};
```
---
### 3. UI - colSpan Tidak Sesuai Jumlah Kolom
**File:** `src/app/admin/(dashboard)/desa/berita/kategori-berita/page.tsx`
```typescript
<TableThead>
<TableTr>
<TableTh>Nama</TableTh>
<TableTh>Dibuat</TableTh>
<TableTh>Aksi</TableTh> {/* 3 kolom total */}
</TableTr>
</TableThead>
<TableTbody>
{loading ? (
<TableTr>
<TableTd colSpan={4}> {/* ❌ colSpan 4, seharusnya 3 */}
<Skeleton height={40} />
</TableTd>
</TableTr>
) : (
// ...
)}
</TableTbody>
```
**Dampak:** Layout table tidak rapi, colSpan terlalu lebar.
**Solusi:**
```typescript
<TableTd colSpan={3}> // ✅ Match column count
```
---
## 🟡 MEDIUM PRIORITY ISSUES
### 4. Schema - `deletedAt` Default `now()` Bermasalah
**File:** `prisma/schema.prisma`
```prisma
model Berita {
deletedAt DateTime @default(now()) // ❌ Problematic default
isActive Boolean @default(true)
}
model KategoriBerita {
deletedAt DateTime @default(now()) // ❌ Problematic default
isActive Boolean @default(true)
}
```
**Dampak:**
- Record baru langsung ter-mark sebagai deleted saat create
- Soft delete logic tidak bekerja dengan benar
- Query dengan filter `deletedAt: null` tidak akan dapat data baru
**Solusi:**
```prisma
model Berita {
deletedAt DateTime? // ✅ Nullable, tanpa default
isActive Boolean @default(true)
}
model KategoriBerita {
deletedAt DateTime? // ✅ Nullable, tanpa default
isActive Boolean @default(true)
}
```
**Migration Required:**
```bash
bunx prisma db push
# atau
bunx prisma migrate dev --name fix_deleted_at_default
```
**Data Cleanup:**
```sql
-- Update record yang ter-affected
UPDATE "Berita" SET "deletedAt" = NULL WHERE "isActive" = true;
UPDATE "KategoriBerita" SET "deletedAt" = NULL WHERE "isActive" = true;
```
---
### 5. API - Create Tidak Return Data dari Database
**File:** `src/app/api/[[...slugs]]/_lib/desa/berita/create.ts`
```typescript
const created = await prisma.berita.create({
data: {
...body,
kategoriBeritaId: kategori?.id
}
});
return {
success: true,
message: "Sukses menambahkan berita",
data: { ...body } // ❌ Return input body, bukan data dari DB
};
```
**Dampak:**
- Frontend tidak dapat data lengkap (ID, timestamps, relasi)
- User harus refresh untuk lihat data lengkap
- Inconsistent dengan API lain yang return data dari DB
**Solusi:**
```typescript
const created = await prisma.berita.create({
data: {
...body,
kategoriBeritaId: kategori?.id
},
include: {
image: true,
kategoriBerita: true
}
});
return {
success: true,
message: "Sukses menambahkan berita",
data: created // ✅ Return data dari DB dengan relasi
};
```
---
### 6. API - Order By `asc` untuk Kategori Tidak Ideal
**File:** `src/app/api/[[...slugs]]/_lib/desa/berita/kategori-berita/findMany.ts`
```typescript
const data = await prisma.kategoriBerita.findMany({
where,
orderBy: { createdAt: 'asc' }, // ⚠️ Data lama muncul dulu
skip,
take: limit
});
```
**Dampak:** Kategori baru (yang mungkin lebih relevan) ada di bawah.
**Solusi:**
```typescript
const data = await prisma.kategoriBerita.findMany({
where,
orderBy: { createdAt: 'desc' }, // ✅ Data terbaru dulu
skip,
take: limit
});
```
---
### 7. UI - Button Label "Batal" untuk Reset Form Membingungkan
**File:** `src/app/admin/(dashboard)/desa/berita/kategori-berita/[id]/page.tsx`
```typescript
<Button
onClick={handleResetForm}
variant="outline"
color="gray"
>
Batal // ❌ Membingungkan - "Batal" biasanya untuk cancel navigation
</Button>
```
**Dampak:** User mungkin bingung apakah button ini akan cancel edit atau reset form.
**Solusi:**
```typescript
<Button
onClick={handleResetForm}
variant="outline"
color="gray"
>
Reset Form // ✅ Lebih jelas
</Button>
```
---
### 8. UI - Dropzone Accept Tidak Spesifik
**File:** `src/app/admin/(dashboard)/desa/berita/list-berita/create/page.tsx` dan `edit/page.tsx`
```typescript
<Dropzone
accept={{ "image/*": [] }} // ❌ Terlalu general
// ...
>
```
**Dampak:** User bisa coba upload format image aneh yang tidak didukung browser.
**Solusi:**
```typescript
<Dropzone
accept={{
'image/*': ['.jpeg', '.jpg', '.png', '.webp'] // ✅ Specify extensions
}}
// ...
>
```
---
### 9. State - Inconsistent API Client (fetch vs ApiFetch)
**File:** `src/app/admin/(dashboard)/_state/desa/berita.ts`
```typescript
// ❌ Inconsistent - fetch langsung
const res = await fetch(`/api/desa/berita/${id}`);
const data = await res.json();
// ✅ Di tempat lain pakai ApiFetch
const data = await ApiFetch.api.desa.berita[':id'].get({ query: { id } });
```
**Dampak:** Code maintainability kurang, tidak konsisten.
**Solusi:**
```typescript
// Gunakan ApiFetch untuk semua
const data = await ApiFetch.api.desa.berita[':id'].get({ query: { id } });
```
---
### 10. Layout - `isDetailPage` Logic Kurang Robust
**File:** `src/app/admin/(dashboard)/desa/berita/layout.tsx`
```typescript
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length >= 5; // ❌ Magic number, bisa false positive
```
**Dampak:** Bisa false positive untuk path lain yang length sama.
**Solusi:**
```typescript
// Option 1: Check for specific segments
const isDetailPage = segments.some(seg =>
['create', 'edit'].includes(seg) || /^\w{20,}$/.test(seg) // CUID pattern
);
// Option 2: Check last segment
const lastSegment = segments[segments.length - 1];
const isDetailPage = ['create', 'edit'].includes(lastSegment) ||
/^[a-zA-Z0-9]{20,}$/.test(lastSegment);
```
---
## 🟢 LOW PRIORITY ISSUES
### 11. Form Validation Hanya Cek `trim()`
**File:** `src/app/admin/(dashboard)/desa/berita/kategori-berita/create/page.tsx`
```typescript
const isFormValid = () => {
return createState.create.form.name?.trim().length > 0; // ⚠️ Hanya cek empty
};
```
**Dampak:** User bisa input nama 1 karakter.
**Solusi:**
```typescript
const isFormValid = () => {
const name = createState.create.form.name?.trim();
return name && name.length >= 3; // ✅ Minimal 3 karakter
};
```
---
### 12. Error Handling Upload Gambar Generic
**File:** `src/app/admin/(dashboard)/desa/berita/list-berita/create/page.tsx`
```typescript
catch (error) {
toast.error('Gagal upload gambar'); // ⚠️ Generic message
}
```
**Solusi:**
```typescript
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
toast.error(`Gagal upload gambar: ${errorMessage}`);
}
```
---
### 13. Unused State - `kategoriBerita.findUnique`
**File:** `src/app/admin/(dashboard)/_state/desa/berita.ts`
```typescript
kategoriBerita: {
findUnique: {
loading: false,
async byId(id: string) {
// ❌ Defined tapi tidak digunakan di UI
}
}
}
```
**Solusi:**
- Option A: Hapus jika memang tidak diperlukan
- Option B: Implementasikan di UI edit kategori
---
### 14. Unused API Endpoints
**File:** `src/app/api/[[...slugs]]/_lib/desa/berita/`
```
find-first.ts // ⚠️ Tidak digunakan di admin
find-recent.ts // ⚠️ Tidak digunakan di admin
```
**Solusi:**
- Option A: Hapus jika memang tidak diperlukan
- Option B: Dokumentasikan untuk future use
- Option C: Implementasikan di UI (misal: recent articles widget)
---
## ✅ YANG SUDAH BAIK
### **Schema:**
- ✅ Relasi yang jelas antara Berita dan KategoriBerita (one-to-many)
- ✅ Soft delete dengan `deletedAt` dan `isActive`
- ✅ Image menggunakan relasi ke FileStorage (reusable)
- ✅ Timestamp lengkap (createdAt, updatedAt)
- ✅ Unique constraint pada `name` di KategoriBerita
### **API:**
- ✅ CRUD lengkap untuk Berita dan Kategori Berita
- ✅ Pagination support dengan `page`, `limit`, `search`
- ✅ Search functionality dengan case-insensitive
- ✅ Include relasi (image, kategori) pada find-many
- ✅ File cleanup (hapus file fisik + database) saat update/delete
- ✅ Filter by kategori di find-many
- ✅ Response format konsisten: `{ success, message, data }`
### **UI/UX:**
- ✅ Konsisten design pattern
- ✅ Responsive untuk mobile dan desktop
- ✅ Loading states dan skeleton
- ✅ Toast notifications untuk feedback
- ✅ Form validation yang comprehensive
- ✅ Rich text editor (BeritaEditor) dengan toolbar lengkap
- ✅ Image upload dengan preview dan delete button
- ✅ Search dengan debounce 1 detik
- ✅ Modal konfirmasi hapus
- ✅ Minimum delay 300ms untuk UX yang smooth
### **State Management:**
- ✅ Valtio proxy untuk global state
- ✅ Zod validation schema
- ✅ Loading state management
- ✅ Error handling di setiap action
---
## 📊 Metrics
| Aspek | Score | Keterangan |
|-------|-------|------------|
| **Schema Design** | 8/10 | Good, unique constraint ada di Kategori |
| **API Design** | 7.5/10 | RESTful, tapi ada unused endpoints |
| **API Security** | 6/10 | Tidak ada authentication |
| **UI/UX** | 8/10 | Responsive, comprehensive validation |
| **State Management** | 8/10 | Valtio works well, ada inconsistency |
| **Code Quality** | 7/10 | Good structure, beberapa bug minor |
**Overall Score: 7/10** - **Good**
---
## 🎯 Action Plan
### Week 1 (Critical Fixes)
- [ ] Fix delete kategori dengan relation check
- [ ] Fix pagination pass search parameter
- [ ] Fix colSpan mismatch
- [ ] Fix `deletedAt @default(now())` di schema
### Week 2 (Medium Priority)
- [ ] API create return data dari DB
- [ ] Fix order by ke `desc` untuk kategori
- [ ] Rename button "Batal" → "Reset Form"
- [ ] Fix dropzone accept extensions
- [ ] Konsisten gunakan ApiFetch
### Week 3 (Polish)
- [ ] Fix isDetailPage logic
- [ ] Improve form validation (min length)
- [ ] Improve error handling messages
- [ ] Cleanup unused state/API
- [ ] Add authentication middleware
---
## 📝 Technical Notes
### **Database Migration:**
Fix deletedAt default:
```bash
# Generate migration
bunx prisma migrate dev --name fix_deleted_at_default
# Atau jika tidak pakai migrate
bunx prisma db push
# Data cleanup
UPDATE "Berita" SET "deletedAt" = NULL WHERE "isActive" = true;
UPDATE "KategoriBerita" SET "deletedAt" = NULL WHERE "isActive" = true;
```
### **API Testing:**
Test delete kategori dengan relasi:
```bash
# 1. Create kategori
POST /api/desa/kategoriberita/create
{ "name": "Test Kategori" }
# 2. Create berita dengan kategori tersebut
POST /api/desa/berita/create
{
"judul": "Test Berita",
"kategoriBeritaId": "<kategori_id>",
...
}
# 3. Try delete kategori (should fail)
DELETE /api/desa/kategoriberita/del/<kategori_id>
# Expected: { success: false, message: "Kategori tidak dapat dihapus..." }
```
### **Frontend Testing:**
Test pagination dengan search:
1. Buka halaman List Berita
2. Ketik search query (misal: "desa")
3. Klik pagination halaman 2
4. Verify search query masih ada dan result sesuai
---
## 📚 References
- [Prisma Soft Delete Pattern](https://www.prisma.io/docs/concepts/components/prisma-client/soft-delete)
- [Mantine Table Documentation](https://mantine.dev/core/table/)
- [React Toastify Documentation](https://fkhadra.github.io/react-toastify/)
- [Zod Documentation](https://zod.dev/)
---
**Dibuat oleh:** QC Automation
**Review Status:** ⏳ Menunggu Review Developer
**Next Review:** Setelah implementasi fixes

File diff suppressed because it is too large Load Diff

View File

@@ -1,882 +0,0 @@
# Quality Control Report - Layanan Desa Admin
**Lokasi:** `/src/app/admin/(dashboard)/desa/layanan/`
**Tanggal QC:** 25 Februari 2026
**Status:** ⚠️ **Needs Improvement** (ada issue critical dan incomplete features)
---
## 📋 Ringkasan Eksekutif
Halaman Layanan Desa memiliki **5 modul** dengan implementasi yang **bervariasi**. Ditemukan **15 issue** dengan rincian:
- 🔴 **High Priority:** 4 issue
- 🟡 **Medium Priority:** 5 issue
- 🟢 **Low Priority:** 6 issue
**Overall Score: 6.5/10** - Needs Improvement
---
## 📁 Struktur File yang Diperiksa
```
/src/app/admin/(dashboard)/desa/layanan/
├── layout.tsx
├── ajukan_permohonan/
│ ├── page.tsx # List permohonan dengan search & pagination
│ └── [id]/
│ ├── page.tsx # Detail permohonan
│ └── edit/
│ └── page.tsx # Edit permohonan
├── pelayanan_penduduk_non_permanent/
│ ├── page.tsx # ⚠️ Preview only (hardcoded ID)
│ └── [id]/
│ └── page.tsx # Edit form
├── pelayanan_perizinan_berusaha/
│ ├── page.tsx # ⚠️ Preview only dengan stepper (hardcoded ID)
│ └── [id]/
│ └── page.tsx # Edit form
├── pelayanan_surat_keterangan/
│ ├── page.tsx # List surat keterangan
│ ├── create/
│ │ └── page.tsx # Create dengan dual image upload
│ └── [id]/
│ ├── page.tsx # Detail
│ └── edit/
│ └── page.tsx # Edit dengan dual image upload
└── pelayanan_telunjuk_sakti_desa/
├── page.tsx # List telunjuk sakti desa
├── create/
│ └── page.tsx # Create form
└── [id]/
├── page.tsx # Detail
└── edit/
└── page.tsx # Edit form
```
**File Terkait:**
- State: `/src/app/admin/(dashboard)/_state/desa/layananDesa.ts` (1050 baris)
- API: `/src/app/api/[[...slugs]]/_lib/desa/layanan/` (5 modul)
- Schema: `/prisma/schema.prisma` (5 models)
---
## 🔴 HIGH PRIORITY ISSUES
### 1. API - Inconsistent Delete Endpoint
**File:** `src/app/api/[[...slugs]]/_lib/desa/layanan/pelayanan_telunjuk_sakti_desa/index.ts`
```typescript
// Line 38-40
.delete("/:id", pelayananTelunjukSaktiDesaDelete) // ❌ Inconsistent
```
**Bandingkan dengan modul lain:**
```typescript
// pelayanan_surat_keterangan/index.ts
.delete("/del/:id", pelayananSuratKeteranganDelete) // ✅ Consistent
// pelayanan_surat_keterangan/index.ts line 34
.delete("/del/:id", pelayananSuratKeteranganDelete)
```
**State Management memanggil:**
```typescript
// layananDesa.ts line 501
const response = await fetch(`/api/desa/layanan/pelayanantelunjuksaktidesa/del/${id}`, {
method: "DELETE",
});
// ❌ State panggil /del/${id} tapi API endpoint adalah /:id
```
**Dampak:**
- Delete tidak akan bekerja (404 Not Found)
- User tidak bisa hapus data
- Data inconsistency
**Severity:** 🔴 **HIGH** - Feature broken
**Solusi:**
```typescript
// File: pelayanan_telunjuk_sakti_desa/index.ts
.delete("/del/:id", pelayananTelunjukSaktiDesaDelete) // ✅ Consistent dengan modul lain
```
---
### 2. API - Missing Endpoints (INCOMPLETE FEATURE)
**File:** `src/app/api/[[...slugs]]/_lib/desa/layanan/pelayanan_perizinan_berusaha/`
```
Current files:
├── findUnique.ts ✅
└── updt.ts ✅
Missing files:
❌ find-many.ts # Tidak ada list dengan pagination
❌ create.ts # Tidak ada create
❌ del.ts # Tidak ada delete
```
**Same issue untuk:** `pelayanan_penduduk_non_permanen/`
**Dampak:**
- **Tidak ada list page dengan pagination** - hanya preview hardcoded
- **Tidak ada create functionality** - data tidak bisa ditambah
- **Tidak ada delete functionality** - data tidak bisa dihapus
- **Feature incomplete** - hanya bisa edit data yang sudah ada
**Severity:** 🔴 **HIGH** - Incomplete feature
**Solusi:**
**Create `find-many.ts`:**
```typescript
import { prisma } from "@/lib/prisma";
import { Context } from "elysia";
export default async function findMany(context: Context) {
try {
const { page = 1, limit = 10, search = "" } = context.query;
const skip = (Number(page) - 1) * Number(limit);
const where: any = { isActive: true };
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ deskripsi: { contains: search, mode: 'insensitive' } }
];
}
const [data, total] = await Promise.all([
prisma.pelayananPerizinanBerusaha.findMany({
where,
skip,
take: Number(limit),
orderBy: { createdAt: 'desc' }
}),
prisma.pelayananPerizinanBerusaha.count({ where })
]);
return {
success: true,
message: "Data retrieved successfully",
data,
pagination: {
page: Number(page),
limit: Number(limit),
total,
totalPages: Math.ceil(total / Number(limit))
}
};
} catch (error) {
console.error("Error fetching data:", error);
return { success: false, message: "Failed to fetch data" };
}
}
```
**Create `create.ts`:**
```typescript
import { prisma } from "@/lib/prisma";
import { Context } from "elysia";
export default async function create(context: Context) {
try {
const body = await context.body;
// Validation
if (!body.name || !body.deskripsi || !body.link) {
return Response.json({
success: false,
message: "All fields are required"
}, { status: 400 });
}
const created = await prisma.pelayananPerizinanBerusaha.create({
data: {
name: body.name,
deskripsi: body.deskripsi,
link: body.link,
}
});
return {
success: true,
message: "Data created successfully",
data: created
};
} catch (error) {
console.error("Error creating data:", error);
return { success: false, message: "Failed to create data" };
}
}
```
**Create `del.ts`:**
```typescript
import { prisma } from "@/lib/prisma";
import { Context } from "elysia";
export default async function del(context: Context) {
try {
const id = context.params?.id as string;
// Soft delete
await prisma.pelayananPerizinanBerusaha.update({
where: { id },
data: {
deletedAt: new Date(),
isActive: false
}
});
return {
success: true,
message: "Data deleted successfully"
};
} catch (error) {
console.error("Error deleting data:", error);
return { success: false, message: "Failed to delete data" };
}
}
```
**Update API route index:**
```typescript
// index.ts
import findMany from "./find-many";
import create from "./create";
import del from "./del";
export const pelayananPerizinanBerusahaRoutes = (app: Elysia) =>
app
.get("/api/desa/layanan/pelayananperizinanberusaha/find-many", findMany)
.post("/api/desa/layanan/pelayananperizinanberusaha/create", create)
.delete("/api/desa/layanan/pelayananperizinanberusaha/del/:id", del);
```
---
### 3. UI - Hardcoded ID 'edit' (CRITICAL)
**File:** `src/app/admin/(dashboard)/desa/layanan/pelayanan_penduduk_non_permanent/page.tsx`
```typescript
// Line 22
const { data, loading } = useSnapshot(pelayananPendudukNonPermanenState.findUnique);
useEffect(() => {
pelayananPendudukNonPermanenState.findUnique.load('edit'); // ❌ HARDCODED ID
}, []);
```
**Same issue di:** `pelayanan_perizinan_berusaha/page.tsx` line 36
```typescript
useEffect(() => {
pelayananPerizinanBerusahaState.findUnique.load("edit"); // ❌ HARDCODED ID
}, []);
```
**Dampak:**
- Data yang di-load selalu ID `'edit'` (data pertama?)
- Tidak dinamis
- Jika tidak ada data dengan ID `'edit'`, page kosong
- **Ini seharusnya list page, bukan preview single data**
**Severity:** 🔴 **HIGH** - Logic error
**Solusi:**
**Option A - Convert ke List Page (Recommended):**
```typescript
// page.tsx should be a list page with pagination
const { data, loading } = useSnapshot(pelayananPendudukNonPermanenState.findMany);
useEffect(() => {
pelayananPendudukNonPermanenState.findMany.load(page, limit, search);
}, [page, limit, search]);
```
**Option B - Remove Hardcoded Page:**
```typescript
// Jika memang hanya ada 1 data, remove page.tsx
// Direct ke edit page atau detail page
```
---
### 4. State Management - Wrong Variable Assignment (BUG)
**File:** `src/app/admin/(dashboard)/_state/desa/layananDesa.ts`
```typescript
// Line 468-470
} catch (error) {
console.error("Error fetching telunjuk sakti desa:", error);
suratKeterangan.findMany.total = 0; // ❌ WRONG VARIABLE!
suratKeterangan.findMany.totalPages = 1; // ❌ WRONG VARIABLE!
}
```
**Should be:**
```typescript
} catch (error) {
console.error("Error fetching telunjuk sakti desa:", error);
pelayananTelunjukSaktiDesa.findMany.total = 0; // ✅ Correct
pelayananTelunjukSaktiDesa.findMany.totalPages = 1; // ✅ Correct
}
```
**Dampak:**
- `pelayananTelunjukSaktiDesa.findMany.total` tidak di-set saat error
- Pagination tidak bekerja dengan benar
- Bisa infinite loading atau wrong pagination display
**Severity:** 🔴 **HIGH** - Bug
**Solusi:** Fix variable names immediately.
---
## 🟡 MEDIUM PRIORITY ISSUES
### 5. State - Missing Validation for `link` Field
**File:** `src/app/admin/(dashboard)/_state/desa/layananDesa.ts`
```typescript
// Line 28-32
const templateTelunjukSaktiDesaForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"),
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
// ❌ Missing link field validation!
});
```
**Dampak:**
- User bisa submit dengan link kosong atau invalid URL
- Data inconsistency
- Broken links di frontend
**Severity:** 🟡 **MEDIUM** - Validation gap
**Solusi:**
```typescript
const templateTelunjukSaktiDesaForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"),
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
link: z.string().url("Link harus URL yang valid"), // ✅ Add validation
});
```
**Same issue untuk:** `pelayananPerizinanBerusahaForm`
---
### 6. UI - Inconsistent Edit Page Structure
**Current structure:**
| Module | Edit Page Location |
|--------|-------------------|
| `ajukan_permohonan` | `[id]/edit/page.tsx` ✅ |
| `pelayanan_surat_keterangan` | `[id]/edit/page.tsx` ✅ |
| `pelayanan_telunjuk_sakti_desa` | `[id]/edit/page.tsx` ✅ |
| `pelayanan_penduduk_non_permanent` | `[id]/page.tsx` ❌ |
| `pelayanan_perizinan_berusaha` | `[id]/page.tsx` ❌ |
**Dampak:**
- Inconsistent user experience
- Confusing navigation
- Harder to maintain
**Severity:** 🟡 **MEDIUM** - UX inconsistency
**Solusi:**
- Move edit logic from `[id]/page.tsx` to `[id]/edit/page.tsx`
- Or convert `[id]/page.tsx` to detail view only
---
### 7. UI - Missing Create Functionality
**Modules without create:**
| Module | Create Page | Create API |
|--------|-------------|------------|
| `pelayanan_penduduk_non_permanent` | ❌ | ❌ |
| `pelayanan_perizinan_berusaha` | ❌ | ❌ |
**Dampak:**
- **Data tidak bisa ditambah** dari admin panel
- Data hanya bisa di-seed dari database atau cara lain
- Feature incomplete
**Severity:** 🟡 **MEDIUM** - Missing feature
**Solusi:**
- Create `create/page.tsx` untuk kedua modul
- Add corresponding API endpoints (lihat Issue #2)
---
### 8. API - Inconsistent Response Format
**Examples:**
```typescript
// pelayanan_surat_keterangan/create.ts
return {
success: true,
message: "Sukses menambahkan data",
data: created
};
// pelayanan_telunjuk_sakti_desa/create.ts
return new Response(
JSON.stringify({
status: 200,
message: "Sukses menambahkan data",
data: created
})
);
// ajukan_permohonan/del.ts
return {
status: 200,
message: "Sukses menghapus data"
};
```
**Dampak:**
- Frontend harus handle multiple response formats
- Confusing untuk developer
- Harder to maintain
**Severity:** 🟡 **MEDIUM** - Code quality
**Solusi:**
```typescript
// Standardize response format
return {
success: boolean,
message: string,
data?: any,
// Optional: status code if needed
};
```
---
### 9. UI - Client-Side Search Instead of Server-Side
**File:** `src/app/admin/(dashboard)/desa/layanan/pelayanan_surat_keterangan/page.tsx`
```typescript
// Line 50-57
const filteredData = useMemo(() => {
if (!search) return data || [];
return (data || []).filter((item) =>
item.name.toLowerCase().includes(search.toLowerCase()) ||
item.deskripsi.toLowerCase().includes(search.toLowerCase())
);
}, [data, search]);
```
**Dampak:**
- Semua data di-load dari server (no server-side filtering)
- Performance issue jika data banyak
- Pagination tidak bekerja dengan benar (filter setelah pagination)
**Severity:** 🟡 **MEDIUM** - Performance issue
**Solusi:**
```typescript
// Pass search to API
const load = async (page: number, limit: number, search: string) => {
pelayananSuratKeteranganState.findMany.loading = true;
try {
const res = await ApiFetch.api.desa.layanan.pelayanansuratketerangan['find-many'].get({
query: { page, limit, search }
});
// ...
}
};
```
---
## 🟢 LOW PRIORITY ISSUES
### 10. UI - Table Fixed Layout Without Column Widths
**File:** Multiple list pages
```typescript
<Table layout="fixed">
<TableThead>
<TableTr>
<TableTh>Nama</TableTh>
<TableTh>Deskripsi</TableTh>
<TableTh>Aksi</TableTh>
</TableTr>
</TableThead>
</Table>
```
**Dampak:** Column widths tidak konsisten, bisa break layout.
**Severity:** 🟢 **LOW** - UI polish
**Solusi:**
```typescript
<Table layout="fixed">
<TableThead>
<TableTr>
<TableTh w="30%">Nama</TableTh>
<TableTh w="50%">Deskripsi</TableTh>
<TableTh w="20%">Aksi</TableTh>
</TableTr>
</TableThead>
</Table>
```
---
### 11. State - Inconsistent Ordering
**File:** Multiple state files
```typescript
// ajukan_permohonan/findMany.ts
orderBy: { createdAt: 'asc' } // ❌ Ascending
// pelayanan_surat_keterangan/find-many.ts
orderBy: { createdAt: 'desc' } // ✅ Descending
```
**Dampak:** Inconsistent data display (oldest first vs newest first).
**Severity:** 🟢 **LOW** - UX consistency
**Solusi:** Standardize to `orderBy: { createdAt: 'desc' }` for all modules.
---
### 12. UI - Missing Loading States (Some Edit Pages)
**File:** Some edit pages
```typescript
useEffect(() => {
state.load(params.id);
}, [params.id]);
// ❌ No loading state check
return (
<form>
{/* Form fields */}
</form>
);
```
**Dampak:** Form bisa render dengan empty data saat loading.
**Severity:** 🟢 **LOW** - UX polish
**Solusi:**
```typescript
const [loading, setLoading] = useState(true);
useEffect(() => {
state.load(params.id).finally(() => setLoading(false));
}, [params.id]);
if (loading) {
return <Skeleton height={400} radius="md" />;
}
return (
<form>
{/* Form fields */}
</form>
);
```
---
### 13. UI - Memory Leak Potential (createObjectURL)
**File:** Multiple create/edit pages with image upload
```typescript
useEffect(() => {
if (file) {
const url = URL.createObjectURL(file);
setPreviewImage(url);
}
}, [file]);
// ❌ No cleanup
```
**Dampak:** Memory leak jika user upload banyak gambar.
**Severity:** 🟢 **LOW** - Performance
**Solusi:**
```typescript
useEffect(() => {
if (file) {
const url = URL.createObjectURL(file);
setPreviewImage(url);
return () => {
URL.revokeObjectURL(url); // ✅ Cleanup
};
}
}, [file]);
```
---
### 14. Schema - `deletedAt @default(now())` (SAME BUG AS OTHER MODULES)
**File:** `prisma/schema.prisma`
```prisma
model PelayananSuratKeterangan {
deletedAt DateTime @default(now()) // ❌ SAME BUG
}
model PelayananTelunjukSaktiDesa {
deletedAt DateTime @default(now()) // ❌ SAME BUG
}
model PelayananPerizinanBerusaha {
deletedAt DateTime @default(now()) // ❌ SAME BUG
}
model PelayananPendudukNonPermanen {
deletedAt DateTime @default(now()) // ❌ SAME BUG
}
model AjukanPermohonan {
deletedAt DateTime @default(now()) // ❌ SAME BUG
}
```
**Dampak:** Record baru langsung ter-mark deleted.
**Severity:** 🟢 **LOW** - (Actually MEDIUM, tapi sudah documented di QC lain)
**Solusi:**
```prisma
deletedAt DateTime? // Remove @default(now())
```
---
### 15. UI - No Error Boundary
**File:** No error boundary found
**Dampak:** Error di component bisa crash entire app.
**Severity:** 🟢 **LOW** - Code quality
**Solusi:**
```typescript
// Add Error Boundary di layout.tsx
'use client'
import { Component, ReactNode } from 'react'
class ErrorBoundary extends Component {
state = { hasError: false }
static getDerivedStateFromError() {
return { hasError: true }
}
render() {
if (this.state.hasError) {
return <ErrorFallback />
}
return this.props.children
}
}
```
---
## ✅ YANG SUDAH BAIK
### **Schema:**
- ✅ Relasi yang jelas antara `AjukanPermohonan` dan `PelayananSuratKeterangan`
- ✅ Soft delete pattern dengan `deletedAt` dan `isActive`
- ✅ Audit trail dengan `createdAt` dan `updatedAt`
- ✅ Dual image support untuk `PelayananSuratKeterangan`
### **API:**
- ✅ CRUD lengkap untuk `pelayanan_surat_keterangan` dan `pelayanan_telunjuk_sakti_desa`
- ✅ Pagination support
- ✅ Search functionality
- ✅ Soft delete di-support via `isActive` flag
- ✅ Response format mostly consistent: `{ success, message, data }`
### **UI/UX:**
- ✅ Responsive design (desktop + mobile)
- ✅ Loading states dan skeleton
- ✅ Toast notifications untuk feedback
- ✅ Form validation comprehensive
- ✅ Dual image upload dengan preview (surat keterangan)
- ✅ Rich text editor untuk deskripsi
- ✅ Search dengan debounce
- ✅ Modal konfirmasi hapus
- ✅ Interactive stepper (perizinan berusaha)
- ✅ Reset form functionality
### **State Management:**
- ✅ Valtio proxy untuk global state
- ✅ Zod validation schema
- ✅ Loading state management
- ✅ Auto-refresh after CRUD operations
---
## 📊 Metrics
| Aspek | Score | Keterangan |
|-------|-------|------------|
| **Schema Design** | 7/10 | Good structure, tapi ada bug deletedAt |
| **API Completeness** | 5/10 | 2 modul incomplete (missing endpoints) |
| **API Security** | 5/10 | Tidak ada authentication |
| **UI/UX** | 7.5/10 | Responsive, good features |
| **State Management** | 6.5/10 | Good structure, ada bug |
| **Code Quality** | 6/10 | Inconsistent patterns, hardcoded values |
**Overall Score: 6.5/10** - **Needs Improvement**
---
## 🎯 Action Plan
### Week 1 (Critical Fixes) 🔴
- [ ] **URGENT:** Fix delete endpoint inconsistency (`pelayanan_telunjuk_sakti_desa`)
- [ ] **URGENT:** Fix state management bug (wrong variable assignment)
- [ ] **URGENT:** Fix hardcoded ID 'edit' di list pages
- [ ] **URGENT:** Create missing API endpoints (`find-many`, `create`, `del`) untuk 2 modul
### Week 2 (Complete Features) 🟡
- [ ] Create `create/page.tsx` untuk 2 modul tanpa create
- [ ] Move edit logic to `[id]/edit/page.tsx` untuk consistency
- [ ] Add validation for `link` field di state
- [ ] Standardize response format di semua API
- [ ] Move client-side search to server-side
### Week 3 (Polish) 🟢
- [ ] Add column widths untuk fixed layout tables
- [ ] Standardize ordering (`createdAt: desc`)
- [ ] Add loading states di semua edit pages
- [ ] Fix memory leak (revoke Object URLs)
- [ ] Add Error Boundary di layout
- [ ] Fix `deletedAt @default(now())` di schema
---
## 📝 Technical Notes
### **Database Migration:**
Fix deletedAt default:
```bash
bunx prisma migrate dev --name fix_layanan_deleted_at
# atau
bunx prisma db push
# Data cleanup
UPDATE "PelayananSuratKeterangan" SET "deletedAt" = NULL WHERE "isActive" = true;
UPDATE "PelayananTelunjukSaktiDesa" SET "deletedAt" = NULL WHERE "isActive" = true;
UPDATE "PelayananPerizinanBerusaha" SET "deletedAt" = NULL WHERE "isActive" = true;
UPDATE "PelayananPendudukNonPermanen" SET "deletedAt" = NULL WHERE "isActive" = true;
UPDATE "AjukanPermohonan" SET "deletedAt" = NULL WHERE "isActive" = true;
```
### **API Endpoint Checklist:**
**pelayanan_perizinan_berusaha:**
- [ ] Create `find-many.ts`
- [ ] Create `create.ts`
- [ ] Create `del.ts`
- [ ] Update `index.ts` dengan routes baru
**pelayanan_penduduk_non_permanen:**
- [ ] Create `find-many.ts`
- [ ] Create `create.ts`
- [ ] Create `del.ts`
- [ ] Update `index.ts` dengan routes baru
### **Frontend Checklist:**
**pelayanan_perizinan_berusaha:**
- [ ] Convert `page.tsx` dari preview ke list page
- [ ] Create `create/page.tsx`
- [ ] Move edit logic ke `[id]/edit/page.tsx`
**pelayanan_penduduk_non_permanen:**
- [ ] Convert `page.tsx` dari preview ke list page
- [ ] Create `create/page.tsx`
- [ ] Move edit logic ke `[id]/edit/page.tsx`
---
## 📚 References
- [Prisma Soft Delete Pattern](https://www.prisma.io/docs/concepts/components/prisma-client/soft-delete)
- [Mantine Table Documentation](https://mantine.dev/core/table/)
- [React Toastify Documentation](https://fkhadra.github.io/react-toastify/)
- [Zod Documentation](https://zod.dev/)
- [URL.createObjectURL() Memory Management](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL#memory_management)
---
## 📈 Comparison dengan QC Sebelumnya
| Aspek | Profil | Potensi | Berita | Pengumuman | Gallery | **Layanan** |
|-------|--------|---------|--------|------------|---------|-------------|
| Schema | 6/10 | 7/10 | 8/10 | 7/10 | 6/10 | **7/10** |
| API Completeness | 7/10 | 8/10 | 7.5/10 | 7/10 | 6/10 | **5/10** 🔴 |
| API Security | 4/10 | 6/10 | 6/10 | 6/10 | 4/10 | **5/10** |
| UI/UX | 8/10 | 8.5/10 | 8/10 | 7.5/10 | 7.5/10 | **7.5/10** |
| State Mgmt | 7/10 | 8/10 | 8/10 | 7/10 | 6.5/10 | **6.5/10** |
| Code Quality | 7/10 | 7.5/10 | 7/10 | 6.5/10 | 6/10 | **6/10** |
| **Overall** | **6.5/10** | **7.5/10** | **7/10** | **6.5/10** | **6/10** | **6.5/10** |
**Layanan** memiliki score sama dengan **Profil Desa** dan **Pengumuman** karena:
**Positif:**
- ✅ Schema design lebih baik (dual image support, relasi yang jelas)
- ✅ UI/UX bagus (responsive, interactive stepper)
- ✅ Most modules complete
**Negatif:**
-**2 modul incomplete** (missing API endpoints & create pages)
-**Hardcoded ID 'edit'** di production code
-**State management bug** (wrong variable assignment)
-**Inconsistent endpoint patterns** (delete endpoint beda)
- ❌ Missing authentication
---
**Dibuat oleh:** QC Automation
**Review Status:** ⏳ Menunggu Review Developer
**Next Review:** Setelah implementasi fixes

View File

@@ -1,774 +0,0 @@
# Quality Control Report - Penghargaan Desa Admin
**Lokasi:** `/src/app/admin/(dashboard)/desa/penghargaan/`
**Tanggal QC:** 25 Februari 2026
**Status:****Good** (dengan beberapa issue security yang perlu diperbaiki)
---
## 📋 Ringkasan Eksekutif
Halaman Penghargaan Desa memiliki implementasi yang **cukup baik** dengan CRUD lengkap, upload gambar, dan state management terstruktur. Ditemukan **11 issue** dengan rincian:
- 🔴 **High Priority:** 2 issue
- 🟡 **Medium Priority:** 5 issue
- 🟢 **Low Priority:** 4 issue
**Overall Score: 7/10** - Good
---
## 📁 Struktur File yang Diperiksa
```
/src/app/admin/(dashboard)/desa/penghargaan/
├── page.tsx # List penghargaan dengan search & pagination
├── create/
│ └── page.tsx # Create penghargaan dengan upload gambar
└── [id]/
├── page.tsx # Detail penghargaan
└── edit/
└── page.tsx # Edit penghargaan dengan replace image
```
**File Terkait:**
- State: `/src/app/admin/(dashboard)/_state/desa/penghargaan.ts`
- API: `/src/app/api/[[...slugs]]/_lib/desa/penghargaan/` (6 files)
- Schema: `/prisma/schema.prisma` (Model `Penghargaan`)
---
## 🔴 HIGH PRIORITY ISSUES
### 1. XSS Vulnerability via `dangerouslySetInnerHTML`
**File:** `src/app/admin/(dashboard)/desa/penghargaan/page.tsx`
```typescript
// Line 79
<TableTd
dangerouslySetInnerHTML={{
__html: item.deskripsi, // ❌ XSS VULNERABILITY
}}
/>
```
**Same issue di:** `src/app/admin/(dashboard)/desa/penghargaan/[id]/page.tsx` line 89
```typescript
<Box
dangerouslySetInnerHTML={{
__html: data.deskripsi, // ❌ XSS VULNERABILITY
}}
/>
```
**Dampak:**
- User bisa inject malicious script melalui rich text editor
- XSS attack bisa mencuri session, cookies, atau data sensitif
- Admin lain yang lihat data bisa terinfeksi
**Severity:** 🔴 **HIGH** - Security vulnerability
**Solusi:**
**Option A - Sanitize HTML (Recommended):**
```typescript
// Install: bun add dompurify
import DOMPurify from 'dompurify';
// Di component
<TableTd
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(item.deskripsi, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
ALLOWED_ATTR: []
})
}}
/>
```
**Option B - Strip HTML Tags:**
```typescript
const stripHtml = (html: string) => {
const tmp = document.createElement('div');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
};
<TableTd>{stripHtml(item.deskripsi)}</TableTd>
```
**Option C - Server-Side Sanitization:**
```typescript
// Di API create.ts dan updt.ts
import sanitizeHtml from 'sanitize-html';
const sanitizedDeskripsi = sanitizeHtml(body.deskripsi, {
allowedTags: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
allowedAttributes: {}
});
```
---
### 2. Inconsistent Fetch Patterns (ApiFetch vs fetch)
**File:** `src/app/admin/(dashboard)/_state/desa/penghargaan.ts`
```typescript
// Line 45-53 (create) - Menggunakan ApiFetch ✅
const res = await ApiFetch.api.desa.penghargaan.create.post(penghargaan.create.form);
// Line 90-93 (findUnique) - Menggunakan fetch langsung ❌
const res = await fetch(`/api/desa/penghargaan/${id}`);
const data = await res.json();
// Line 108-120 (delete) - Menggunakan fetch langsung ❌
const response = await fetch(`/api/desa/penghargaan/del/${id}`, {
method: 'DELETE',
});
const result = await response.json();
// Line 147-165 (edit.load) - Menggunakan fetch langsung ❌
const response = await fetch(`/api/desa/penghargaan/${id}`);
const result = await response.json();
```
**Dampak:**
- Code maintainability kurang
- Tidak type-safe
- Inconsistent error handling
- Sulit refactor
**Severity:** 🔴 **HIGH** - Code quality issue
**Solusi:**
```typescript
// Gunakan ApiFetch untuk semua
// findUnique
const data = await ApiFetch.api.desa.penghargaan[':id'].get({ query: { id } });
// delete
const result = await ApiFetch.api.desa.penghargaan['del/:id'].delete({ params: { id } });
// edit.load
const data = await ApiFetch.api.desa.penghargaan[':id'].get({ query: { id } });
```
---
## 🟡 MEDIUM PRIORITY ISSUES
### 3. Tidak Ada Validasi Duplicate Name
**File:** `src/app/api/[[...slugs]]/_lib/desa/penghargaan/create.ts`
```typescript
// Line 13-23
const penghargaan = await prisma.penghargaan.create({
data: {
name: body.name, // ❌ Tidak cek duplicate
juara: body.juara,
deskripsi: body.deskripsi,
imageId: body.imageId,
},
});
```
**Same issue di:** `updt.ts` (update endpoint)
**Dampak:**
- User bisa buat penghargaan dengan nama sama
- Data redundancy
- Confusing saat search
**Severity:** 🟡 **MEDIUM** - Data integrity
**Solusi:**
```typescript
// Check duplicate sebelum create
const existing = await prisma.penghargaan.findFirst({
where: {
name: body.name,
isActive: true
}
});
if (existing) {
return Response.json({
success: false,
message: "Nama penghargaan sudah digunakan"
}, { status: 400 });
}
// Lanjut create
const penghargaan = await prisma.penghargaan.create({ ... });
```
**Alternative - Schema Level:**
```prisma
model Penghargaan {
name String @unique // Add unique constraint
// ...
}
```
---
### 4. Search Tidak Reset Pagination
**File:** `src/app/admin/(dashboard)/desa/penghargaan/page.tsx`
```typescript
// Line 35-38
useShallowEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
```
**Dampak:**
- User di page 5, search untuk data yang hanya ada di page 1
- Result kosong, user bingung
- UX buruk
**Severity:** 🟡 **MEDIUM** - UX issue
**Solusi:**
```typescript
// Reset page saat search berubah
useShallowEffect(() => {
if (debouncedSearch !== search) {
setPage(1); // Reset to page 1
}
load(page, 10, debouncedSearch);
}, [page, debouncedSearch, search]);
```
**Better Solution:**
```typescript
// Watch search separately
useEffect(() => {
setPage(1); // Reset page saat search berubah
}, [debouncedSearch]);
useEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
```
---
### 5. Image Upload Hanya Saat Submit
**File:** `src/app/admin/(dashboard)/desa/penghargaan/create/page.tsx`
```typescript
// Line 81-95
const handleSubmit = async () => {
// Validasi
// ...
// Upload image BARU saat submit
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar');
}
// Create penghargaan
await statePenghargaan.penghargaan.create.form.imageId = uploaded.id;
await statePenghargaan.penghargaan.create();
};
```
**Dampak:**
- Jika create penghargaan gagal, file sudah ter-upload (orphaned file)
- User tidak bisa preview image yang sudah di-upload sebelumnya
- Tidak ada progress indicator saat upload
**Severity:** 🟡 **MEDIUM** - Data integrity & UX
**Solusi:**
**Option A - Upload Dulu, Baru Create:**
```typescript
// Upload immediately saat file selected
const handleFileChange = async (file: File) => {
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (uploaded?.id) {
setFile(file);
setPreviewImage(URL.createObjectURL(file));
statePenghargaan.penghargaan.create.form.imageId = uploaded.id;
}
};
// Submit hanya create penghargaan
const handleSubmit = async () => {
await statePenghargaan.penghargaan.create();
};
```
**Option B - Transaction dengan Rollback:**
```typescript
const handleSubmit = async () => {
try {
// Upload file
const uploaded = await uploadFile(file);
// Create penghargaan
const result = await createPenghargaan({ imageId: uploaded.id });
if (!result.success) {
// Rollback: delete uploaded file
await deleteFile(uploaded.id);
throw new Error('Create failed');
}
} catch (error) {
toast.error('Gagal membuat penghargaan');
}
};
```
---
### 6. Dropzone Accept Format Typo
**File:** `src/app/admin/(dashboard)/desa/penghargaan/create/page.tsx`
```typescript
// Line 140-143
<Dropzone
accept={{
'image/*': ['.jpeg', '.jpg', '.png', 'webp'] // ❌ Typo: "webp" seharusnya ".webp"
}}
// ...
>
```
**Same issue di:** `edit/page.tsx` line 180-183
**Dampak:**
- File `.webp` tidak akan di-accept oleh dropzone
- User confusion saat coba upload WebP
- Inconsistent dengan validasi lainnya
**Severity:** 🟡 **MEDIUM** - UX issue
**Solusi:**
```typescript
<Dropzone
accept={{
'image/*': ['.jpeg', '.jpg', '.png', '.webp'] // ✅ Fix typo
}}
// ...
>
```
---
### 7. Schema `deletedAt` Default Value (SAME BUG)
**File:** `prisma/schema.prisma`
```prisma
model Penghargaan {
id String @id @default(cuid())
name String
deletedAt DateTime @default(now()) // ❌ SAME BUG AS OTHER MODULES
isActive Boolean @default(true)
}
```
**Dampak:**
- Record baru langsung ter-mark deleted saat dibuat
- Soft delete logic tidak bekerja
- Query dengan `deletedAt: null` tidak dapat data baru
**Severity:** 🟡 **MEDIUM** - Data integrity bug
**Solusi:**
```prisma
model Penghargaan {
id String @id @default(cuid())
name String
deletedAt DateTime? // ✅ Nullable, tanpa default
isActive Boolean @default(true)
}
```
**Migration:**
```bash
bunx prisma db push
# atau
bunx prisma migrate dev --name fix_penghargaan_deleted_at
# Data cleanup
UPDATE "Penghargaan" SET "deletedAt" = NULL WHERE "isActive" = true;
```
---
## 🟢 LOW PRIORITY ISSUES
### 8. `isHtmlEmpty` Tidak Handle Edge Cases
**File:** `src/app/admin/(dashboard)/desa/penghargaan/create/page.tsx`
```typescript
// Line 23-26
const isHtmlEmpty = (html: string) => {
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
```
**Dampak:**
- HTML dengan hanya `&nbsp;` atau `<br>` akan dianggap empty
- User bisa submit content yang sebenarnya kosong
**Severity:** 🟢 **LOW** - Validation edge case
**Solusi:**
```typescript
const isHtmlEmpty = (html: string) => {
// Strip HTML tags
const tmp = document.createElement('div');
tmp.innerHTML = html;
// Get text content
const textContent = tmp.textContent || tmp.innerText || '';
// Check if empty or only whitespace
return textContent.trim().length === 0;
};
```
---
### 9. Duplicate Validation Check
**File:** `src/app/admin/(dashboard)/desa/penghargaan/create/page.tsx`
```typescript
// Line 58-73: Validasi pertama
const handleSubmit = async () => {
if (!statePenghargaan.penghargaan.create.form.name?.trim()) {
toast.error('Nama penghargaan wajib diisi');
return;
}
// ... validasi lainnya
// Line 81-84: Validasi diulang lagi (redundant)
if (
!statePenghargaan.penghargaan.create.form.name?.trim() ||
!statePenghargaan.penghargaan.create.form.juara?.trim() ||
isHtmlEmpty(statePenghargaan.penghargaan.create.form.deskripsi) ||
!file
) {
toast.error('Mohon lengkapi semua data');
return;
}
};
```
**Dampak:** Code redundancy, minor performance overhead.
**Severity:** 🟢 **LOW** - Code quality
**Solusi:**
```typescript
const handleSubmit = async () => {
// Single validation block
if (!statePenghargaan.penghargaan.create.form.name?.trim()) {
toast.error('Nama penghargaan wajib diisi');
return;
}
if (!statePenghargaan.penghargaan.create.form.juara?.trim()) {
toast.error('Juara wajib diisi');
return;
}
if (isHtmlEmpty(statePenghargaan.penghargaan.create.form.deskripsi)) {
toast.error('Deskripsi wajib diisi');
return;
}
if (!file) {
toast.error('Gambar wajib diunggah');
return;
}
// Submit logic
// ...
};
```
---
### 10. Inconsistent Button Labels (Reset vs Batal)
**File:** Create page vs Edit page
```typescript
// create/page.tsx line 109
<Button onClick={resetForm} variant="outline" color="gray">
Reset // ❌ Inconsistent
</Button>
// edit/page.tsx line 100
<Button onClick={handleResetForm} variant="outline" color="gray">
Batal // ❌ Inconsistent
</Button>
```
**Dampak:** Minor UX inconsistency.
**Severity:** 🟢 **LOW** - UX consistency
**Solusi:** Standardize to "Reset Form" untuk kedua page.
---
### 11. Tidak Ada Karakter Counter
**File:** Create & Edit pages
```typescript
<TextInput
label="Nama Penghargaan"
value={statePenghargaan.penghargaan.create.form.name}
onChange={(e) => {
statePenghargaan.penghargaan.create.form.name = e.target.value;
}}
// ❌ Tidak ada maxLength atau character counter
/>
```
**Dampak:** User tidak tahu ada limit atau tidak.
**Severity:** 🟢 **LOW** - UX polish
**Solusi:**
```typescript
<TextInput
label="Nama Penghargaan"
value={statePenghargaan.penghargaan.create.form.name}
onChange={(e) => {
statePenghargaan.penghargaan.create.form.name = e.target.value;
}}
maxLength={255} // Add max length
rightSection={
<Text size="sm" c="dimmed">
{statePenghargaan.penghargaan.create.form.name?.length || 0}/255
</Text>
}
/>
```
---
## ✅ YANG SUDAH BAIK
### **Schema:**
- ✅ Relasi ke FileStorage untuk gambar sudah benar
- ✅ Soft delete pattern dengan `deletedAt` dan `isActive`
- ✅ Audit trail dengan `createdAt` dan `updatedAt`
- ✅ Field yang diperlukan sudah lengkap
### **API:**
- ✅ CRUD lengkap untuk Penghargaan
- ✅ Pagination support dengan `page`, `limit`, `search`
- ✅ Search functionality dengan case-insensitive
- ✅ Include relasi image di response
-**File cleanup saat update** (hapus old image) ✅
-**File cleanup saat delete** (hapus image) ✅
- ✅ Parallel query untuk data & count (optimasi performa)
- ✅ Response format mostly konsisten: `{ success, message, data }`
### **UI/UX:**
- ✅ Responsive design (desktop table + mobile cards)
- ✅ Loading states dan skeleton
- ✅ Toast notifications untuk feedback
- ✅ Form validation comprehensive
- ✅ Image upload dengan dropzone & preview
- ✅ File size limit & format validation
- ✅ Rich text editor untuk deskripsi
- ✅ Search dengan debounce (1000ms)
- ✅ Modal konfirmasi hapus
- ✅ Empty state message
- ✅ Reset form functionality
- ✅ Button disabled saat invalid/submitting
### **State Management:**
- ✅ Valtio proxy untuk global state
- ✅ Zod validation schema
- ✅ Loading state management
- ✅ Auto-refresh after CRUD operations
- ✅ Error handling dengan toast
---
## 📊 Metrics
| Aspek | Score | Keterangan |
|-------|-------|------------|
| **Schema Design** | 7/10 | Good, tapi ada bug deletedAt |
| **API Design** | 7.5/10 | RESTful, file cleanup implemented |
| **API Security** | 5/10 | Tidak ada auth, XSS vulnerability |
| **UI/UX** | 8/10 | Responsive, comprehensive features |
| **State Management** | 7/10 | Valtio works well, inconsistent fetch |
| **Code Quality** | 7/10 | Good structure, minor inconsistencies |
**Overall Score: 7/10** - **Good**
---
## 🎯 Action Plan
### Week 1 (Critical Fixes) 🔴
- [ ] **URGENT:** Sanitize HTML content (DOMPurify) untuk XSS prevention
- [ ] **URGENT:** Konsistensi fetch pattern (gunakan ApiFetch untuk semua)
### Week 2 (Medium Priority) 🟡
- [ ] Tambahkan validasi duplicate name di API create/update
- [ ] Fix search reset pagination logic
- [ ] Fix image upload timing (upload dulu atau transaction)
- [ ] Fix dropzone accept format typo (`.webp`)
- [ ] Fix `deletedAt @default(now())` di schema
### Week 3 (Polish) 🟢
- [ ] Improve `isHtmlEmpty` function
- [ ] Remove duplicate validation
- [ ] Standardize button labels (Reset Form)
- [ ] Add character counter untuk text fields
- [ ] Add loading state saat load data di edit page
---
## 📝 Technical Notes
### **Database Migration:**
Fix deletedAt default:
```bash
bunx prisma migrate dev --name fix_penghargaan_deleted_at
# atau
bunx prisma db push
# Data cleanup
UPDATE "Penghargaan" SET "deletedAt" = NULL WHERE "isActive" = true;
```
### **XSS Prevention:**
Install DOMPurify:
```bash
bun add dompurify
bun add -D @types/dompurify
```
Usage:
```typescript
import DOMPurify from 'dompurify';
// Di component
<Box
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(data.deskripsi, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
ALLOWED_ATTR: []
})
}}
/>
```
### **Duplicate Name Prevention:**
API validation:
```typescript
// Check existing name
const existing = await prisma.penghargaan.findFirst({
where: {
name: body.name,
isActive: true,
id: body.id ? { not: body.id } : undefined // Exclude current for update
}
});
if (existing) {
return Response.json({
success: false,
message: "Nama penghargaan sudah digunakan"
}, { status: 400 });
}
```
### **Search Reset Pagination:**
```typescript
// Watch search separately
useEffect(() => {
setPage(1); // Reset page saat search berubah
}, [debouncedSearch]);
useEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
```
---
## 📚 References
- [Prisma Soft Delete Pattern](https://www.prisma.io/docs/concepts/components/prisma-client/soft-delete)
- [DOMPurify Documentation](https://github.com/cure53/DOMPurify)
- [Mantine Dropzone Documentation](https://mantine.dev/x/dropzone/)
- [React Toastify Documentation](https://fkhadra.github.io/react-toastify/)
- [Zod Documentation](https://zod.dev/)
---
## 📈 Comparison dengan QC Sebelumnya
| Aspek | Profil | Potensi | Berita | Pengumuman | Gallery | Layanan | **Penghargaan** |
|-------|--------|---------|--------|------------|---------|---------|-----------------|
| Schema | 6/10 | 7/10 | 8/10 | 7/10 | 6/10 | 7/10 | **7/10** |
| API Design | 7/10 | 8/10 | 7.5/10 | 7/10 | 6/10 | 5/10 | **7.5/10** ✅ |
| API Security | 4/10 | 6/10 | 6/10 | 6/10 | 4/10 | 5/10 | **5/10** |
| UI/UX | 8/10 | 8.5/10 | 8/10 | 7.5/10 | 7.5/10 | 7.5/10 | **8/10** ✅ |
| State Mgmt | 7/10 | 8/10 | 8/10 | 7/10 | 6.5/10 | 6.5/10 | **7/10** |
| Code Quality | 7/10 | 7.5/10 | 7/10 | 6.5/10 | 6/10 | 6/10 | **7/10** |
| **Overall** | **6.5/10** | **7.5/10** | **7/10** | **6.5/10** | **6/10** | **6.5/10** | **7/10** |
**Penghargaan** memiliki score **tertinggi kedua** (setelah Potensi Desa) karena:
**Positif:**
- ✅ CRUD lengkap & berfungsi dengan baik
- ✅ File cleanup implemented (update & delete) ✅
- ✅ Responsive design bagus
- ✅ Comprehensive validation
- ✅ Parallel query untuk performa
- ✅ Tidak ada incomplete features (seperti Layanan)
- ✅ Tidak ada critical data loss bugs (seperti Gallery)
**Yang Perlu Diperbaiki:**
- ❌ XSS vulnerability (dangerouslySetInnerHTML)
- ❌ Inconsistent fetch patterns
- ❌ Duplicate name validation tidak ada
-`deletedAt @default(now())` bug
- ❌ Search tidak reset pagination
---
**Dibuat oleh:** QC Automation
**Review Status:** ⏳ Menunggu Review Developer
**Next Review:** Setelah implementasi fixes

View File

@@ -1,809 +0,0 @@
# Quality Control Report - Pengumuman Desa Admin
**Lokasi:** `/src/app/admin/(dashboard)/desa/pengumuman/`
**Tanggal QC:** 25 Februari 2026
**Status:** ⚠️ **Needs Improvement** (ada issue critical yang perlu segera diperbaiki)
---
## 📋 Ringkasan Eksekutif
Halaman Pengumuman Desa memiliki implementasi yang **cukup baik** dengan CRUD lengkap dan state management terstruktur. Namun ditemukan **15 issue** dengan rincian:
- 🔴 **High Priority:** 2 issue
- 🟡 **Medium Priority:** 7 issue
- 🟢 **Low Priority:** 6 issue
**Overall Score: 6.5/10** - Needs Improvement
---
## 📁 Struktur File yang Diperiksa
```
/src/app/admin/(dashboard)/desa/pengumuman/
├── layout.tsx
├── _com/
│ └── layoutTabs.tsx # Tab navigation component
├── kategori-pengumuman/
│ ├── page.tsx # List kategori dengan search & pagination
│ ├── create/
│ │ └── page.tsx # Form create kategori
│ └── [id]/
│ └── page.tsx # Edit kategori
└── list-pengumuman/
├── page.tsx # List pengumuman dengan search & pagination
├── create/
│ └── page.tsx # Form create pengumuman (rich text)
└── [id]/
├── page.tsx # Detail pengumuman
└── edit/
└── page.tsx # Edit pengumuman
```
**File Terkait:**
- State: `/src/app/admin/(dashboard)/_state/desa/pengumuman.ts`
- API: `/src/app/api/[[...slugs]]/_lib/desa/pengumuman/` (9 files)
- API: `/src/app/api/[[...slugs]]/_lib/desa/pengumuman/kategori-pengumuman/` (6 files)
- Schema: `/prisma/schema.prisma` (Model `Pengumuman` & `CategoryPengumuman`)
---
## 🔴 HIGH PRIORITY ISSUES
### 1. API - Hard Delete vs Soft Delete Mismatch (DATA LOSS RISK)
**File:** `src/app/api/[[...slugs]]/_lib/desa/pengumuman/del.ts`
```typescript
export default async function pengumumanDelete(context: Context) {
const id = context.params?.id as string;
// ❌ HARD DELETE - Data benar-benar terhapus dari database
await prisma.pengumuman.delete({ where: { id } });
return { success: true, message: "Pengumuman berhasil dihapus" };
}
```
**Schema yang Diharapkan:**
```prisma
model Pengumuman {
deletedAt DateTime? @default(null) // Soft delete field
isActive Boolean @default(true)
}
```
**Dampak:**
- **DATA LOSS** - Data pengumuman terhapus permanen, tidak bisa direcover
- Audit trail hilang (riwayat pengumuman tidak ada lagi)
- Inconsistent dengan schema design yang sudah ada soft delete fields
- Bisa melanggar compliance requirements untuk data retention
**Solusi:**
```typescript
// Ganti hard delete dengan soft delete
export default async function pengumumanDelete(context: Context) {
const id = context.params?.id as string;
// ✅ SOFT DELETE - Update deletedAt dan isActive
await prisma.pengumuman.update({
where: { id },
data: {
deletedAt: new Date(),
isActive: false
}
});
return { success: true, message: "Pengumuman berhasil dihapus" };
}
```
**File yang Perlu Diperbaiki:**
- `src/app/api/[[...slugs]]/_lib/desa/pengumuman/del.ts`
- `src/app/api/[[...slugs]]/_lib/desa/pengumuman/kategori-pengumuman/del.ts`
---
### 2. Schema - `deletedAt` Default Value `now()` Bermasalah
**File:** `prisma/schema.prisma`
```prisma
model Pengumuman {
id String @id @default(cuid())
judul String
deletedAt DateTime @default(now()) // ❌ PROBLEMATIC DEFAULT
isActive Boolean @default(true)
}
model CategoryPengumuman {
id String @id @default(cuid())
name String @unique
deletedAt DateTime @default(now()) // ❌ PROBLEMATIC DEFAULT
isActive Boolean @default(true)
}
```
**Dampak:**
- Setiap record **baru langsung ter-mark sebagai deleted** saat dibuat
- Query dengan filter `deletedAt: null` tidak akan dapat data baru
- Soft delete logic tidak bekerja dengan benar
- Data inconsistency antara `deletedAt` (set) dan `isActive` (true)
**Solusi:**
```prisma
model Pengumuman {
id String @id @default(cuid())
judul String
deletedAt DateTime? // ✅ Nullable, tanpa default
isActive Boolean @default(true)
}
model CategoryPengumuman {
id String @id @default(cuid())
name String @unique
deletedAt DateTime? // ✅ Nullable, tanpa default
isActive Boolean @default(true)
}
```
**Migration Required:**
```bash
# Generate migration
bunx prisma migrate dev --name fix_deleted_at_default
# Atau jika tidak pakai migrate
bunx prisma db push
# Data cleanup untuk record yang sudah ter-affected
UPDATE "Pengumuman" SET "deletedAt" = NULL WHERE "isActive" = true;
UPDATE "CategoryPengumuman" SET "deletedAt" = NULL WHERE "isActive" = true;
```
---
## 🟡 MEDIUM PRIORITY ISSUES
### 3. UI - Search Parameter Hilang Saat Pagination
**File:** `src/app/admin/(dashboard)/desa/pengumuman/list-pengumuman/page.tsx`
```typescript
<Pagination
total={totalPages}
value={page}
onChange={(newPage) => {
load(newPage, 10); // ❌ Missing search parameter
}}
/>
```
**Dampak:**
- Saat user ganti halaman, search query hilang
- User harus ketik ulang search query
- UX sangat buruk untuk pagination dengan search
- Inconsistent dengan page lain (berita, potensi)
**Solusi:**
```typescript
<Pagination
total={totalPages}
value={page}
onChange={(newPage) => {
load(newPage, 10, debouncedSearch); // ✅ Include search parameter
}}
/>
```
**Note:** Pastikan function `load` menerima parameter search:
```typescript
const load = async (page: number, limit: number, searchQuery?: string) => {
// ...
};
```
---
### 4. UI - Duplicate State Management
**File:** `src/app/admin/(dashboard)/desa/pengumuman/list-pengumuman/[id]/edit/page.tsx`
```typescript
// Local state
const [formData, setFormData] = useState({
judul: '',
deskripsi: '',
content: '',
categoryPengumumanId: '',
});
const [originalData, setOriginalData] = useState({...formData});
// Global state (Valtio)
editState.pengumuman.edit.form = {
...editState.pengumuman.edit.form,
...formData, // ❌ Duplicate data
};
```
**Dampak:**
- Data inconsistency antara local state dan global state
- Sulit debug karena data ada di 2 tempat
- Memory overhead
- Potential bugs saat reset form
**Solusi:**
**Option A - Gunakan hanya global state:**
```typescript
// Hapus local state, gunakan langsung global state
const formData = editState.pengumuman.edit.form;
const handleResetForm = () => {
editState.pengumuman.edit.form = { ...originalData };
};
```
**Option B - Sinkronisasi dengan useEffect:**
```typescript
useEffect(() => {
// Sync local state ke global state
editState.pengumuman.edit.form = { ...formData };
}, [formData]);
```
---
### 5. UI - Error Handling Silent Failures
**File:** `src/app/admin/(dashboard)/_state/desa/pengumuman.ts`
```typescript
// Line 266-268
catch (error) {
console.log((error as Error).message);
// ❌ Error tidak ditampilkan ke user, silent failure
}
```
**Dampak:**
- User tidak tahu ada error
- Sulit debug production issues
- User experience buruk (loading forever tanpa feedback)
**Solusi:**
```typescript
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Failed to load pengumuman:', errorMessage);
toast.error(`Gagal memuat data: ${errorMessage}`);
}
```
---
### 6. UI - ColSpan Mismatch
**File:** `src/app/admin/(dashboard)/desa/pengumuman/kategori-pengumuman/page.tsx`
```typescript
<TableThead>
<TableTr>
<TableTh>Nama</TableTh>
<TableTh>Dibuat</TableTh>
<TableTh>Aksi</TableTh> {/* 3 kolom total */}
</TableTr>
</TableThead>
<TableTbody>
{loading ? (
<TableTr>
<TableTd colSpan={4}> {/* ❌ colSpan 4, seharusnya 3 */}
<Skeleton height={40} />
</TableTd>
</TableTr>
) : (
// ...
)}
</TableTbody>
```
**Dampak:** Layout table tidak rapi, colSpan terlalu lebar.
**Solusi:**
```typescript
<TableTd colSpan={3}> // ✅ Match column count
```
---
### 7. State Management - Copy-Paste Error Message
**File:** `src/app/admin/(dashboard)/_state/desa/pengumuman.ts`
```typescript
// Line 68-70
kategoriPengumuman: {
findMany: {
loading: false,
async load(page = 1, limit = 10, search = '') {
try {
// ...
} catch (error) {
console.error("Failed to load potensi desa:", res.data?.message);
// ❌ Copy-paste error dari file potensi! Seharusnya "kategori pengumuman"
}
}
}
}
```
**Dampak:**
- Membingungkan saat debug
- Tidak profesional
- Menunjukkan kurangnya attention to detail
**Solusi:**
```typescript
console.error("Failed to load kategori pengumuman:", res.data?.message);
```
---
### 8. UI - Button Text "Batal" Membingungkan
**File:** `src/app/admin/(dashboard)/desa/pengumuman/kategori-pengumuman/[id]/page.tsx`
```typescript
<Button
onClick={handleResetForm}
variant="outline"
color="gray"
>
Batal // ❌ Membingungkan - "Batal" biasanya untuk cancel navigation
</Button>
```
**Dampak:** User mungkin bingung apakah button ini akan cancel edit atau reset form.
**Solusi:**
```typescript
<Button
onClick={handleResetForm}
variant="outline"
color="gray"
>
Reset Form // ✅ Lebih jelas
</Button>
```
---
### 9. UI - Button Order Tidak Mengikuti UX Best Practice
**File:** `src/app/admin/(dashboard)/desa/pengumuman/list-pengumuman/[id]/page.tsx`
```typescript
<Group gap="sm">
<Button color="red"> {/* Delete button first */}
<Button color="green"> {/* Edit button second */}
</Group>
```
**Dampak:** Destructive action (delete) lebih prominent daripada primary action (edit).
**Solusi:**
```typescript
<Group gap="sm">
<Button color="green"> {/* Edit button first */}
<Button color="red"> {/* Delete button second */}
</Group>
```
**UX Best Practice:** Primary action (edit) seharusnya lebih prominent, destructive action (delete) kurang prominent dan lebih sulit diakses.
---
## 🟢 LOW PRIORITY ISSUES
### 10. UI - Inline Styles yang Panjang
**File:** `src/app/admin/(dashboard)/desa/pengumuman/_com/layoutTabs.tsx`
```typescript
<TabsList
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
border: "1px solid #d1d5db",
padding: "0.5rem",
borderRadius: "12px",
display: "flex",
gap: "0.5rem",
// ... 10+ baris inline styles
}}
>
```
**Dampak:**
- Sulit maintain
- Tidak reusable
- Code readability buruk
**Solusi:**
```typescript
// Option A: CSS module
// layoutTabs.module.css
.tabsList {
background: linear-gradient(135deg, #e7ebf7, #f9faff);
boxShadow: 0 2px 8px rgba(0,0,0,0.08);
// ...
}
// Component
<TabsList className={styles.tabsList}>
```
**Option B: Mantine theme**
```typescript
// theme.ts
const theme = createTheme({
components: {
TabsList: {
styles: {
root: {
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
// ...
}
}
}
}
});
```
---
### 11. UI - Hardcoded Paths
**File:** `src/app/admin/(dashboard)/desa/pengumuman/_com/layoutTabs.tsx`
```typescript
const tabs = [
{ href: "/admin/desa/pengumuman/list-pengumuman" },
{ href: "/admin/desa/pengumuman/kategori-pengumuman" },
];
```
**Dampak:** Sulit refactor, jika ada perubahan struktur URL harus update di banyak tempat.
**Solusi:**
```typescript
// constants/routes.ts
export const ROUTES = {
PENGUMUMAN_LIST: '/admin/desa/pengumuman/list-pengumuman',
PENGUMUMAN_CREATE: '/admin/desa/pengumuman/list-pengumuman/create',
PENGUMUMAN_EDIT: (id: string) => `/admin/desa/pengumuman/list-pengumuman/${id}/edit`,
KATEGORI_PENGUMUMAN_LIST: '/admin/desa/pengumuman/kategori-pengumuman',
KATEGORI_PENGUMUMAN_CREATE: '/admin/desa/pengumuman/kategori-pengumuman/create',
KATEGORI_PENGUMUMAN_EDIT: (id: string) => `/admin/desa/pengumuman/kategori-pengumuman/${id}/edit`,
};
// Usage
const tabs = [
{ href: ROUTES.PENGUMUMAN_LIST },
{ href: ROUTES.KATEGORI_PENGUMUMAN_LIST },
];
```
---
### 12. UI - HTML Validation Function Bisa False Positive
**File:** `src/app/admin/(dashboard)/desa/pengumuman/list-pengumuman/create/page.tsx`
```typescript
const isHtmlEmpty = (html: string) => {
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
```
**Dampak:**
- Konten dengan hanya `<br>` atau `<p> </p>` akan dianggap empty
- User bisa submit content yang sebenarnya kosong
**Solusi:**
```typescript
const isHtmlEmpty = (html: string) => {
// Strip HTML tags
const tmp = document.createElement('div');
tmp.innerHTML = html;
// Get text content and check if empty
const textContent = tmp.textContent || tmp.innerText || '';
return textContent.trim().length === 0;
};
```
---
### 13. State - Inconsistent API Client Usage
**File:** `src/app/admin/(dashboard)/_state/desa/pengumuman.ts`
```typescript
// ❌ Direct fetch
const res = await fetch(`/api/desa/kategoripengumuman/${id}`);
const data = await res.json();
// ✅ Di tempat lain pakai ApiFetch
const data = await ApiFetch.api.desa.kategoripengumuman[':id'].get({ query: { id } });
```
**Dampak:** Code maintainability kurang, tidak konsisten.
**Solusi:**
```typescript
// Gunakan ApiFetch untuk semua
const data = await ApiFetch.api.desa.kategoripengumuman[':id'].get({ query: { id } });
```
---
### 14. Layout - `isDetailPage` Logic Kurang Robust
**File:** `src/app/admin/(dashboard)/desa/pengumuman/layout.tsx`
```typescript
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length >= 5; // ❌ Magic number, bisa false positive
```
**Dampak:** Bisa false positive untuk path lain yang length sama.
**Contoh False Positive:**
```
/admin/desa/pengumuman/list-pengumuman/create // 6 segments, dianggap detail page ❌
```
**Solusi:**
```typescript
// Check last segment
const lastSegment = segments[segments.length - 1];
const isDetailPage = ['create', 'edit'].includes(lastSegment) ||
/^[a-zA-Z0-9]{20,}$/.test(lastSegment); // CUID pattern
```
---
### 15. API - Missing Validation
**File:** `src/app/api/[[...slugs]]/_lib/desa/pengumuman/create.ts`
```typescript
const body = await context.body;
// ❌ Tidak ada validasi uniqueness untuk judul
// ❌ Tidak ada validasi panjang maksimal
await prisma.pengumuman.create({
data: {
judul: body.judul, // Bisa sangat panjang
// ...
}
});
```
**Dampak:**
- User bisa buat pengumuman dengan judul sama
- User bisa input judul/deskripsi sangat panjang
- Database bisa penuh dengan data tidak valid
**Solusi:**
```typescript
// Validasi di API
const body = await context.body;
// Check uniqueness
const existing = await prisma.pengumuman.findFirst({
where: {
judul: body.judul,
isActive: true
}
});
if (existing) {
return new Response(
JSON.stringify({
success: false,
message: "Judul pengumuman sudah digunakan"
}),
{ status: 400 }
);
}
// Validate length
if (body.judul.length > 255) {
return new Response(
JSON.stringify({
success: false,
message: "Judul maksimal 255 karakter"
}),
{ status: 400 }
);
}
```
---
## ✅ YANG SUDAH BAIK
### **Schema:**
- ✅ Relasi yang jelas antara Pengumuman dan CategoryPengumuman (one-to-many)
- ✅ Soft delete pattern dengan `deletedAt` dan `isActive` (tapi ada bug di default value)
- ✅ Audit trail dengan `createdAt` dan `updatedAt`
- ✅ Unique constraint pada `name` di CategoryPengumuman
### **API:**
- ✅ CRUD lengkap untuk Pengumuman dan Kategori Pengumuman
- ✅ Pagination support dengan `page`, `limit`, `search`
- ✅ Search functionality dengan case-insensitive
- ✅ Include relasi (CategoryPengumuman) di response
- ✅ Validation input menggunakan Elysia `t.Object`
- ✅ Filter by kategori di find-many
### **UI/UX:**
- ✅ Konsisten design pattern
- ✅ Responsive untuk mobile dan desktop
- ✅ Loading states dan skeleton
- ✅ Toast notifications untuk feedback
- ✅ Form validation yang comprehensive
- ✅ Rich text editor (TipTap) untuk content
- ✅ Search dengan debounce (500ms-1000ms)
- ✅ Modal konfirmasi hapus
- ✅ Empty state message
### **State Management:**
- ✅ Valtio proxy untuk global state
- ✅ Zod validation schema
- ✅ Loading state management
- ✅ Auto-refresh after CRUD operations
---
## 📊 Metrics
| Aspek | Score | Keterangan |
|-------|-------|------------|
| **Schema Design** | 7/10 | Good, tapi ada bug di deletedAt default |
| **API Design** | 7/10 | RESTful, validation ada, tapi hard delete issue |
| **API Security** | 6/10 | Tidak ada authentication |
| **UI/UX** | 7.5/10 | Responsive, comprehensive validation |
| **State Management** | 7/10 | Valtio works well, ada inconsistency |
| **Code Quality** | 6.5/10 | Good structure, copy-paste errors, inline styles |
**Overall Score: 6.5/10** - **Needs Improvement**
---
## 🎯 Action Plan
### Week 1 (Critical Fixes) 🔴
- [ ] **URGENT:** Fix hard delete → soft delete di API del.ts
- [ ] **URGENT:** Fix `deletedAt @default(now())` di schema
- [ ] Fix pagination pass search parameter
- [ ] Fix colSpan mismatch
### Week 2 (Medium Priority) 🟡
- [ ] Consolidate state management (local vs global)
- [ ] Improve error handling (no silent failures)
- [ ] Fix error message typo ("potensi desa" → "kategori pengumuman")
- [ ] Rename button "Batal" → "Reset Form"
- [ ] Fix button order (edit before delete)
### Week 3 (Polish) 🟢
- [ ] Move inline styles to CSS module/theme
- [ ] Extract hardcoded paths to constants
- [ ] Fix HTML validation function
- [ ] Konsisten gunakan ApiFetch
- [ ] Fix isDetailPage logic
- [ ] Add uniqueness validation di API create
---
## 📝 Technical Notes
### **Database Migration:**
Fix deletedAt default dan cleanup data:
```bash
# Generate migration
bunx prisma migrate dev --name fix_deleted_at_default
# Atau jika tidak pakai migrate
bunx prisma db push
# Data cleanup
UPDATE "Pengumuman" SET "deletedAt" = NULL WHERE "isActive" = true;
UPDATE "CategoryPengumuman" SET "deletedAt" = NULL WHERE "isActive" = true;
```
### **Soft Delete Implementation:**
Update semua delete endpoint:
```typescript
// Before (hard delete)
await prisma.pengumuman.delete({ where: { id } });
// After (soft delete)
await prisma.pengumuman.update({
where: { id },
data: {
deletedAt: new Date(),
isActive: false
}
});
```
### **API Testing:**
Test soft delete:
```bash
# 1. Create pengumuman
POST /api/desa/pengumuman/create
{
"judul": "Test Pengumuman",
"deskripsi": "Test",
"content": "Test content",
"categoryPengumumanId": "<id>"
}
# 2. Delete pengumuman
DELETE /api/desa/pengumuman/del/<id>
# 3. Verify soft delete (data masih ada tapi isActive = false)
GET /api/desa/pengumuman/<id>
# Expected: isActive = false, deletedAt != null
```
Test pagination dengan search:
1. Buka halaman List Pengumuman
2. Ketik search query (misal: "desa")
3. Klik pagination halaman 2
4. Verify search query masih ada dan result sesuai
---
## 📚 References
- [Prisma Soft Delete Pattern](https://www.prisma.io/docs/concepts/components/prisma-client/soft-delete)
- [Mantine Table Documentation](https://mantine.dev/core/table/)
- [React Toastify Documentation](https://fkhadra.github.io/react-toastify/)
- [Zod Documentation](https://zod.dev/)
- [TipTap Documentation](https://tiptap.dev/)
---
## 📈 Comparison dengan QC Sebelumnya
| Aspek | Profil Desa | Potensi Desa | Berita Desa | **Pengumuman** |
|-------|-------------|--------------|-------------|----------------|
| Schema | 6/10 | 7/10 | 8/10 | **7/10** |
| API Security | 4/10 | 6/10 | 6/10 | **6/10** |
| API Design | 7/10 | 8/10 | 7.5/10 | **7/10** |
| UI/UX | 8/10 | 8.5/10 | 8/10 | **7.5/10** |
| State Mgmt | 7/10 | 8/10 | 8/10 | **7/10** |
| Code Quality | 7/10 | 7.5/10 | 7/10 | **6.5/10** |
| **Overall** | **6.5/10** | **7.5/10** | **7/10** | **6.5/10** |
**Pengumuman** memiliki score yang sama dengan **Profil Desa** karena:
- ✅ Unique constraint pada `name` (CategoryPengumuman)
- ✅ Validation input di API
- ❌ Hard delete vs soft delete mismatch (critical)
- ❌ Copy-paste error messages
- ❌ Inline styles yang berlebihan
- ❌ Duplicate state management
---
**Dibuat oleh:** QC Automation
**Review Status:** ⏳ Menunggu Review Developer
**Next Review:** Setelah implementasi fixes

View File

@@ -1,658 +0,0 @@
# Quality Control Report - Potensi Desa Admin
**Lokasi:** `/src/app/admin/(dashboard)/desa/potensi/`
**Tanggal QC:** 25 Februari 2026
**Status:****Good** (dengan area untuk improvement)
---
## 📋 Ringkasan Eksekutif
Halaman Potensi Desa memiliki implementasi yang **cukup baik** dengan CRUD lengkap, UI yang responsive, dan state management yang terstruktur. Ditemukan **15 issue** dengan rincian:
- 🔴 **High Priority:** 6 issue
- 🟡 **Medium Priority:** 6 issue
- 🟢 **Low Priority:** 3 issue
**Overall Score: 7.5/10** - Good
---
## 📁 Struktur File yang Diperiksa
```
/src/app/admin/(dashboard)/desa/potensi/
├── layout.tsx
├── _lib/
│ └── layoutTabs.tsx
├── kategori-potensi/
│ ├── page.tsx # List kategori dengan search & pagination
│ ├── create/
│ │ └── page.tsx # Form create kategori
│ └── [id]/
│ └── page.tsx # Edit kategori
└── list-potensi/
├── page.tsx # List potensi dengan search & pagination
├── create/
│ └── page.tsx # Form create potensi (rich text + image)
└── [id]/
├── page.tsx # Detail potensi
└── edit/
└── page.tsx # Edit potensi
```
**File Terkait:**
- State: `/src/app/admin/(dashboard)/_state/desa/potensi.ts`
- API: `/src/app/api/[[...slugs]]/_lib/desa/potensi/` (10 files)
- API: `/src/app/api/[[...slugs]]/_lib/desa/potensi/kategori-potensi/` (5 files)
- Schema: `/prisma/schema.prisma` (Model `PotensiDesa` & `KategoriPotensi`)
---
## 🔴 HIGH PRIORITY ISSUES
### 1. Schema - Tidak Ada Unique Constraint pada `name` dan `nama`
**File:** `prisma/schema.prisma`
```prisma
model PotensiDesa {
name String // ❌ Tidak ada @unique
deskripsi String
// ...
}
model KategoriPotensi {
nama String // ❌ Tidak ada @unique
// ...
}
```
**Dampak:**
- Bisa ada duplikasi nama kategori potensi (misal: "Pariwisata" muncul 2x)
- Bisa ada duplikasi judul potensi desa
- Menyulitkan user saat mencari data
**Solusi:**
```prisma
model PotensiDesa {
name String @unique // ✅ Add unique constraint
// ...
}
model KategoriPotensi {
nama String @unique // ✅ Add unique constraint
// ...
}
```
**Migration Required:**
```bash
bunx prisma db push
# atau
bunx prisma migrate dev --name add_unique_constraints
```
---
### 2. Schema - `kategoriId` Nullable Seharusnya Required
**File:** `prisma/schema.prisma`
```prisma
model PotensiDesa {
kategoriId String? // ❌ Nullable, seharusnya required
// ...
}
```
**Dampak:** Potensi desa bisa dibuat tanpa kategori, tidak masuk akal secara bisnis.
**Solusi:**
```prisma
model PotensiDesa {
kategoriId String // ✅ Remove ? (required)
// ...
}
```
**Note:** Perlu update form create/edit untuk validasi kategori wajib dipilih.
---
### 3. Schema - Tidak Ada Length Constraints
**File:** `prisma/schema.prisma`
```prisma
model PotensiDesa {
name String // ❌ Tidak ada max length
deskripsi String @db.Text
// ...
}
model KategoriPotensi {
nama String // ❌ Tidak ada max length
// ...
}
```
**Dampak:** User bisa input nama sangat panjang, bisa break UI atau database.
**Solusi:**
```prisma
model PotensiDesa {
name String @db.VarChar(255) // ✅ Max 255 chars
deskripsi String @db.Text
// ...
}
model KategoriPotensi {
nama String @db.VarChar(100) // ✅ Max 100 chars
// ...
}
```
---
### 4. API - Delete Kategori Tanpa Cek Relasi
**File:** `src/app/api/[[...slugs]]/_lib/desa/potensi/kategori-potensi/del.ts`
```typescript
export default async function kategoriPotensiDelete(context: Context) {
const id = context.params?.id as string;
// ❌ Tidak cek apakah kategori masih dipakai oleh PotensiDesa
await prisma.kategoriPotensi.update({
where: { id },
data: { deletedAt: new Date(), isActive: false }
});
return { success: true, message: "Kategori potensi berhasil dihapus" };
}
```
**Dampak:**
- Bisa terjadi foreign key constraint error
- Data inconsistency jika kategori masih dipakai
**Solusi:**
```typescript
// Cek apakah masih ada potensi yang menggunakan kategori ini
const existingPotensi = await prisma.potensiDesa.findFirst({
where: {
kategoriId: id,
isActive: true
}
});
if (existingPotensi) {
return Response.json({
success: false,
message: "Kategori masih digunakan oleh potensi desa. Tidak dapat dihapus."
}, { status: 400 });
}
```
---
### 5. API - `find-unique.ts` Tidak Filter `isActive`
**File:** `src/app/api/[[...slugs]]/_lib/desa/potensi/find-unique.ts`
```typescript
const data = await prisma.potensiDesa.findUnique({
where: { id }, // ❌ Tidak cek isActive
include: {
image: true,
kategori: true
}
});
```
**Dampak:** Bisa load data yang sudah di-soft delete.
**Solusi:**
```typescript
const data = await prisma.potensiDesa.findUnique({
where: {
id,
isActive: true // ✅ Add filter
},
include: {
image: true,
kategori: true
}
});
```
---
### 6. UI - HTML Injection Risk (XSS Vulnerability)
**File:** Multiple pages
**`kategori-potensi/page.tsx`:**
```typescript
<TableTd dangerouslySetInnerHTML={{ __html: item.nama }} />
```
**`list-potensi/page.tsx`:**
```typescript
<TableTd dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
```
**Dampak:**
- User bisa inject malicious script melalui rich text editor
- XSS attack bisa mencuri session atau data sensitif
**Solusi:**
```typescript
// Install: bun add dompurify
import DOMPurify from 'dompurify';
// Sanitize sebelum render
<TableTd
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(item.deskripsi, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
ALLOWED_ATTR: []
})
}}
/>
```
**Alternatif (tanpa library):**
```typescript
// Strip HTML tags completely
const stripHtml = (html: string) => {
const tmp = document.createElement('div');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
};
<TableTd>{stripHtml(item.deskripsi)}</TableTd>
```
---
## 🟡 MEDIUM PRIORITY ISSUES
### 7. API - Inconsistent Naming Convention
**File:** API routes
```
potensi/
├── find-many.ts // ❌ kebab-case
└── kategori-potensi/
└── findMany.ts // ❌ camelCase
```
**Dampak:** Membingungkan developer, tidak konsisten.
**Solusi:** Standardize ke **kebab-case** (konsisten dengan endpoint lain):
```bash
mv findMany.ts find-many.ts
mv findUnique.ts find-unique.ts
mv updt.ts update.ts
mv del.ts delete.ts
```
Update semua import di frontend.
---
### 8. UI - Pagination Tidak Pass Search Parameter
**File:** `src/app/admin/(dashboard)/desa/potensi/list-potensi/page.tsx`
```typescript
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10); // ❌ Tidak ada search parameter
}}
/>
```
**Dampak:** Saat ganti halaman, search query hilang.
**Solusi:**
```typescript
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, search); // ✅ Include search
}}
/>
```
---
### 9. UI - colSpan Mismatch
**File:** `src/app/admin/(dashboard)/desa/potensi/kategori-potensi/page.tsx`
```typescript
<TableThead>
<TableTr>
<TableTh>Nama</TableTh>
<TableTh>Dibuat</TableTh>
<TableTh>Aksi</TableTh> {/* 3 kolom */}
</TableTr>
</TableThead>
<TableTbody>
{loading ? (
<TableTr>
<TableTd colSpan={4}> {/* ❌ colSpan 4, seharusnya 3 */}
<Skeleton height={40} />
</TableTd>
</TableTr>
) : (
// ...
)}
</TableTbody>
```
**Solusi:**
```typescript
<TableTd colSpan={3}> // ✅ Match column count
```
---
### 10. UI - Alert Instead of Toast
**File:** `src/app/admin/(dashboard)/desa/potensi/kategori-potensi/create/page.tsx`
```typescript
if (!nama.trim()) {
alert('Nama kategori potensi wajib diisi'); // ❌ Browser alert
return;
}
```
**Dampak:** Browser alert blocking, UX buruk, tidak konsisten dengan page lain.
**Solusi:**
```typescript
import { toast } from 'react-toastify';
if (!nama.trim()) {
toast.error('Nama kategori potensi wajib diisi'); // ✅ Toast notification
return;
}
```
---
### 11. UI - Missing useEffect Dependencies
**File:** `src/app/admin/(dashboard)/desa/potensi/list-potensi/page.tsx`
```typescript
useEffect(() => {
potensiState.kategoriPotensi.findMany.load();
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]); // ❌ Missing potensiState
```
**Dampak:** ESLint warning, potential stale closure.
**Solusi:**
```typescript
useEffect(() => {
potensiState.kategoriPotensi.findMany.load();
load(page, 10, debouncedSearch);
}, [page, debouncedSearch, potensiState]); // ✅ Add missing dep
```
**Note:** Atau gunakan `useCallback` untuk `load` function.
---
### 12. UI - Dropzone Accept Tidak Specify Extensions
**File:** `src/app/admin/(dashboard)/desa/potensi/list-potensi/create/page.tsx`
```typescript
<Dropzone
accept={{ "image/*": [] }} // ❌ Terlalu general
// ...
>
```
**Dampak:** User bisa upload format image aneh yang tidak didukung browser.
**Solusi:**
```typescript
<Dropzone
accept={{
"image/*": ['.jpeg', '.jpg', '.png', '.webp'] // ✅ Specify extensions
}}
// ...
>
```
---
## 🟢 LOW PRIORITY ISSUES
### 13. UI - Magic Number untuk Detail Page Detection
**File:** `src/app/admin/(dashboard)/desa/potensi/layout.tsx`
```typescript
const isDetailPage = segments.length >= 5; // ❌ Magic number
```
**Dampak:** Tidak jelas maksudnya, brittle jika ada perubahan route structure.
**Solusi:**
```typescript
const isDetailPage = segments.includes('[id]') ||
segments.some(s => !['create', 'edit'].includes(s) && s.match(/^\w+$/));
// Atau lebih baik lagi:
const isDetailPage = segments.some(s => s.match(/^[a-zA-Z0-9]{20,}$/)); // CUID pattern
```
---
### 14. API - Inconsistent Error Handling
**File:** Multiple API handlers
**Contoh inconsistency:**
```typescript
// File A - Return object
return { success: false, message: "Error" };
// File B - Throw error
throw new Error("Something went wrong");
// File C - Return Response
return Response.json({ success: false }, { status: 500 });
```
**Solusi:** Standardize ke satu format:
```typescript
// Always return Response.json dengan format konsisten
return Response.json({
success: false,
message: "Error message",
data: null
}, { status: 500 });
```
---
### 15. State - Inconsistent Loading State
**File:** `src/app/admin/(dashboard)/_state/desa/potensi.ts`
```typescript
delete: {
loading: false,
async byId(id: string) {
try {
// ❌ Loading di-set di dalam async function
potensiDesa.delete.loading = true;
// ...
} finally {
potensiDesa.delete.loading = false;
}
}
}
```
**Solusi:** Konsisten set loading di awal dan reset di finally untuk semua operation.
---
## ✅ YANG SUDAH BAIK
### **Schema:**
- ✅ Soft delete dengan `deletedAt` dan `isActive`
- ✅ Relasi yang jelas antara PotensiDesa dan KategoriPotensi
- ✅ Relasi ke FileStorage untuk gambar
- ✅ Timestamp lengkap (createdAt, updatedAt)
### **API:**
- ✅ CRUD lengkap untuk kedua entitas
- ✅ Pagination support dengan `page`, `limit`, `search`
- ✅ Search functionality dengan case-insensitive
- ✅ Include relasi (image, kategori) pada find-many dan find-unique
- ✅ File cleanup (hapus file fisik + database) saat update/delete
- ✅ Response format konsisten: `{ success, message, data }`
### **UI/UX:**
- ✅ Konsisten design pattern
- ✅ Responsive untuk mobile dan desktop
- ✅ Loading states dan skeleton
- ✅ Toast notifications untuk feedback
- ✅ Form validation yang comprehensive
- ✅ Rich text editor dengan toolbar lengkap
- ✅ Image upload dengan preview dan delete button
- ✅ Search dengan debounce
- ✅ Modal konfirmasi hapus
---
## 📊 Metrics
| Aspek | Score | Keterangan |
|-------|-------|------------|
| **Schema Design** | 7/10 | Good, tapi perlu unique constraints |
| **API Design** | 8/10 | RESTful, konsisten, perlu standardisasi naming |
| **API Security** | 6/10 | Tidak ada auth, XSS vulnerability |
| **UI/UX** | 8.5/10 | Responsive, comprehensive validation |
| **State Management** | 8/10 | Valtio works well, minor inconsistency |
| **Code Quality** | 7.5/10 | Good structure, beberapa bug minor |
**Overall Score: 7.5/10** - **Good**
---
## 🎯 Action Plan
### Week 1 (Critical Fixes)
- [ ] Add unique constraint pada `name` dan `nama` di schema
- [ ] Make `kategoriId` required di schema
- [ ] Add length constraints (@db.VarChar)
- [ ] Fix delete kategori dengan relation check
- [ ] Add `isActive` filter di find-unique API
- [ ] Add HTML sanitization (DOMPurify)
### Week 2 (Medium Priority)
- [ ] Standardize API naming (kebab-case)
- [ ] Fix pagination pass search parameter
- [ ] Fix colSpan mismatch
- [ ] Replace alert dengan toast
- [ ] Fix useEffect dependencies
- [ ] Specify dropzone extensions
### Week 3 (Polish)
- [ ] Remove magic number di layout
- [ ] Standardize error handling di API
- [ ] Fix loading state consistency
- [ ] Add authentication middleware
- [ ] Add unit tests untuk critical functions
---
## 📝 Technical Notes
### **Database Migration:**
Setelah update schema:
```bash
# Generate migration
bunx prisma migrate dev --name add_unique_and_length_constraints
# Atau jika tidak pakai migrate
bunx prisma db push
# Handle duplicate data (jika ada)
# Query manual untuk merge/delete duplicates
```
### **HTML Sanitization:**
Install DOMPurify:
```bash
bun add dompurify
bun add -D @types/dompurify
```
Usage:
```typescript
import DOMPurify from 'dompurify';
// Di component
const sanitizedContent = DOMPurify.sanitize(htmlContent, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li', 'h1', 'h2', 'h3'],
ALLOWED_ATTR: []
});
<div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />
```
### **API Testing:**
Test delete kategori dengan relasi:
```bash
# 1. Create kategori
POST /api/desa/kategoripotensi/create
{ "nama": "Test Kategori" }
# 2. Create potensi dengan kategori tersebut
POST /api/desa/potensi/create
{
"name": "Test Potensi",
"kategoriId": "<kategori_id>",
...
}
# 3. Try delete kategori (should fail)
DELETE /api/desa/kategoripotensi/del/<kategori_id>
# Expected: { success: false, message: "Kategori masih digunakan..." }
```
---
## 📚 References
- [Prisma Schema Reference](https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference)
- [DOMPurify Documentation](https://github.com/cure53/DOMPurify)
- [Mantine Table Documentation](https://mantine.dev/core/table/)
- [React Toastify Documentation](https://fkhadra.github.io/react-toastify/)
---
**Dibuat oleh:** QC Automation
**Review Status:** ⏳ Menunggu Review Developer
**Next Review:** Setelah implementasi fixes

View File

@@ -1,371 +0,0 @@
# Quality Control Report - Profil Desa Admin
**Lokasi:** `/src/app/admin/(dashboard)/desa/profil/`
**Tanggal QC:** 25 Februari 2026
**Status:** ⚠️ **Needs Improvement**
---
## 📋 Ringkasan Eksekutif
Halaman Profil Desa sudah memiliki struktur yang baik dengan separation of concerns yang jelas antara UI, State Management, dan API. Namun ditemukan **16 issue** dengan rincian:
- 🔴 **High Priority:** 5 issue
- 🟡 **Medium Priority:** 5 issue
- 🟢 **Low Priority:** 6 issue
---
## 📁 Struktur File yang Diperiksa
```
/src/app/admin/(dashboard)/desa/profil/
├── layout.tsx
├── _lib/
│ ├── layoutTabsDetail.tsx
│ └── layoutTabsEdit.tsx
├── profil-desa/
│ ├── page.tsx
│ └── [id]/
│ ├── sejarah_desa/page.tsx
│ ├── visi_misi_desa/page.tsx
│ ├── lambang_desa/page.tsx
│ └── maskot_desa/page.tsx
├── profil-perbekel/
│ ├── page.tsx
│ └── [id]/page.tsx
└── profil-perbekel-dari-masa-ke-masa/
├── page.tsx
├── create/page.tsx
└── [id]/
├── page.tsx
└── edit/page.tsx
```
**File Terkait:**
- State: `/src/app/admin/(dashboard)/_state/desa/profile.ts` (1058 baris)
- API: `/src/app/api/[[...slugs]]/_lib/desa/profile/` (15+ files)
- Schema: `/prisma/schema.prisma`
---
## 🔴 HIGH PRIORITY ISSUES
### 1. Schema Bug - `deletedAt` Default Value Salah
**File:** `prisma/schema.prisma`
```prisma
model SejarahDesa {
deletedAt DateTime @default(now()) // ❌ BUG: Record langsung ter-delete!
isActive Boolean @default(true)
}
```
**Dampak:** Setiap record baru langsung ter-mark sebagai deleted karena `deletedAt` di-set ke `now()` saat create.
**Solusi:**
```prisma
deletedAt DateTime? // ✅ Nullable, tanpa default
```
**Affected Models:** `SejarahDesa`, `VisiMisiDesa`, `LambangDesa`, `MaskotDesa`
---
### 2. API Tidak Ada Authentication
**File:** Semua file di `/src/app/api/[[...slugs]]/_lib/desa/profile/`
```typescript
export default async function sejarahDesaUpdate(context: Context) {
// ❌ Tidak ada validasi session/user
const id = context.params?.id as string;
// Langsung proses update...
}
```
**Dampak:** Siapa saja yang tahu endpoint bisa update/delete data tanpa login.
**Solusi:** Tambahkan middleware authentication di route handler atau di setiap endpoint.
---
### 3. Hardcoded Nama Perbekel di UI
**File:** `src/app/admin/(dashboard)/desa/profil/profil-perbekel/page.tsx`
```typescript
<Text>
I.B. Surya Prabhawa Manuaba, S.H., M.H. // ❌ Hardcoded!
</Text>
```
**Dampak:** UI tidak update otomatis jika ada perbekel baru.
**Solusi:** Ambil data dari database `ProfilPerbekel` dengan filter `isActive: true`.
---
### 4. Maskot Image Delete Logic Bug
**File:** `src/app/api/[[...slugs]]/_lib/desa/profile/profile_desa/maskot-desa/update.ts`
```typescript
// Hapus semua gambar lama
for (const old of existing.images) {
await prisma.fileStorage.delete({ where: { id: old.imageId } });
}
```
**Dampak:** Semua gambar lama **selalu dihapus**, bahkan jika user ingin mempertahankan beberapa gambar.
**Solusi:** Implementasi diff logic untuk membandingkan gambar yang dipertahankan vs dihapus.
---
### 5. Magic String "edit" sebagai ID
**File:** Multiple files di state dan API
```typescript
stateProfileDesa.sejarahDesa.findUnique.load("edit"); // ❌ Magic string
```
**Dampak:** Tidak type-safe, rentan typo, tidak scalable.
**Solusi:** Buat endpoint khusus `/first` atau `/active` untuk get record pertama yang aktif.
---
## 🟡 MEDIUM PRIORITY ISSUES
### 6. ProfileDesaImage Tanpa Soft Delete
**File:** `prisma/schema.prisma`
```prisma
model ProfileDesaImage {
// ❌ Tidak ada deletedAt, isActive, createdAt, updatedAt
id String @id @default(cuid())
label String
imageId String?
}
```
**Solusi:** Tambahkan audit fields:
```prisma
deletedAt DateTime?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
```
---
### 7. HTML Validation dengan Regex
**File:** `src/app/admin/(dashboard)/desa/profil/profil-desa/[id]/sejarah_desa/page.tsx`
```typescript
const isHtmlEmpty = (html: string) => {
const textContent = html.replace(/<[^>]*>/g, '').trim(); // ❌ Tidak robust
return textContent === '';
};
```
**Dampak:** Validasi bisa gagal untuk edge cases (nested tags, comments, script tags).
**Solusi:** Gunakan library `sanitize-html` atau DOMParser untuk extract text content.
---
### 8. Image Label Tidak Divvalidasi
**File:** `src/app/admin/(dashboard)/desa/profil/profil-desa/[id]/maskot_desa/page.tsx`
**Dampak:** User bisa submit dengan label kosong atau sangat panjang.
**Solusi:** Tambahkan validation:
```typescript
z.object({
label: z.string().min(1, "Label wajib diisi").max(100, "Maksimal 100 karakter")
})
```
---
### 9. Typo Variable Name
**File:** `src/app/api/[[...slugs]]/_lib/desa/profile/profilePerbekel/update.ts`
```typescript
if (exisitng.imageId !== imageId) { // ❌ Typo: "exisitng"
```
**Solusi:** Fix menjadi `existing`.
---
### 10. Tidak Ada Error Boundary
**Dampak:** Jika ada error di component tree, seluruh halaman bisa crash.
**Solusi:** Tambahkan React Error Boundary di layout.tsx:
```typescript
import { ErrorBoundary } from 'react-error-boundary';
<ErrorBoundary fallback={<ErrorFallback />}>
{children}
</ErrorBoundary>
```
---
## 🟢 LOW PRIORITY ISSUES
### 11. Image Loading Tanpa Skeleton
**File:** `src/app/admin/(dashboard)/desa/profil/profil-desa/page.tsx`
**Dampak:** Layout shift saat image load, UX kurang smooth.
**Solusi:** Tambahkan Skeleton component:
```typescript
{loading ? (
<Skeleton height={200} circle />
) : (
<Image src={imageUrl} alt="..." />
)}
```
---
### 12. Reset Form Tanpa Konfirmasi
**File:** `src/app/admin/(dashboard)/desa/profil/profil-perbekel-dari-masa-ke-masa/[id]/edit/page.tsx`
**Dampak:** User bisa tidak sengaja reset form dan kehilangan perubahan.
**Solusi:** Tambahkan modal konfirmasi sebelum reset.
---
### 13. Sequential API Calls Tanpa Promise.all
**File:** `src/app/admin/(dashboard)/desa/profil/profil-desa/page.tsx`
```typescript
useEffect(() => {
stateProfileDesa.sejarahDesa.findUnique.load("edit");
stateProfileDesa.visiMisiDesa.findUnique.load("edit"); // ❌ Sequential
stateProfileDesa.lambangDesa.findUnique.load("edit");
stateProfileDesa.maskotDesa.findUnique.load("edit");
}, []);
```
**Solusi:** Gunakan `Promise.all` untuk parallel loading.
---
### 14. FileStorage Validation di Server
**Dampak:** User bisa upload file dengan tipe yang tidak diinginkan.
**Solusi:** Tambahkan MIME type check di server-side upload handler.
---
### 15. Mantan Perbekel Create Tidak Return ID
**File:** `src/app/api/[[...slugs]]/_lib/desa/profile/profile-mantan-perbekel/create.ts`
```typescript
return {
success: true,
data: { ...body }, // ❌ Tidak return ID
};
```
**Solusi:** Return ID record yang baru dibuat untuk referensi.
---
### 16. Tidak Ada Unique Constraint
**Dampak:** Bisa ada multiple record aktif untuk model yang seharusnya single-record.
**Solusi:** Tambahkan unique constraint atau validasi di API layer.
---
## ✅ Yang Sudah Baik
1.**Struktur folder terorganisir** dengan separation of concerns
2.**Responsive design** untuk mobile dan desktop
3.**Loading states** dan error handling dasar
4.**Form validation** client-side dengan Valtio
5.**Preview image** sebelum upload
6.**Toast notifications** untuk feedback user
7.**File cleanup** (hapus file fisik + database) di API
8.**Consistent response format** di semua API endpoint
---
## 📊 Metrics
| Aspek | Score | Keterangan |
|-------|-------|------------|
| **Schema Design** | 6/10 | Ada bug critical di deletedAt |
| **API Security** | 4/10 | Tidak ada authentication |
| **API Design** | 7/10 | RESTful, tapi ada magic string |
| **UI/UX** | 8/10 | Responsive, tapi ada hardcoded data |
| **State Management** | 7/10 | Valtio works, tapi tidak type-safe |
| **Code Quality** | 7/10 | Ada typo, tidak ada error boundary |
**Overall Score: 6.5/10** - **Needs Improvement**
---
## 🎯 Action Plan
### Week 1 (Critical Fixes)
- [ ] Fix `deletedAt @default(now())` di schema
- [ ] Tambahkan authentication middleware di API
- [ ] Fix hardcoded nama perbekel
- [ ] Fix maskot image delete logic
### Week 2 (Medium Priority)
- [ ] Tambahkan audit fields di ProfileDesaImage
- [ ] Fix HTML validation dengan library
- [ ] Tambahkan validasi image label
- [ ] Fix typo dan tambahkan error boundary
### Week 3 (Polish)
- [ ] Tambahkan skeleton loading untuk images
- [ ] Tambahkan konfirmasi reset form
- [ ] Optimasi dengan Promise.all
- [ ] Tambahkan server-side file validation
---
## 📝 Notes
1. **Database Migration Required:** Setelah fix schema, jalankan:
```bash
bunx prisma db push
```
2. **Data Migration:** Record yang sudah ter-create dengan `deletedAt` set perlu di-update:
```sql
UPDATE "SejarahDesa" SET "deletedAt" = NULL WHERE "isActive" = true;
```
3. **Testing:** Setelah fix authentication, test semua endpoint dengan:
- User belum login (should redirect)
- User login dengan role berbeda (should respect permissions)
---
**Dibuat oleh:** QC Automation
**Review Status:** ⏳ Menunggu Review Developer

View File

@@ -1,904 +0,0 @@
# Quality Control Report - Posyandu Kesehatan Admin
**Lokasi:** `/src/app/admin/(dashboard)/kesehatan/posyandu/`
**Tanggal QC:** 25 Februari 2026
**Status:** ⚠️ **Needs Improvement** (ada issue critical data loss & validation)
---
## 📋 Ringkasan Eksekutif
Halaman Posyandu Kesehatan memiliki implementasi yang **cukup baik** dengan CRUD lengkap, upload gambar, dan state management terstruktur. Namun ditemukan **15 issue** dengan rincian:
- 🔴 **High Priority:** 5 issue
- 🟡 **Medium Priority:** 5 issue
- 🟢 **Low Priority:** 5 issue
**Overall Score: 6.5/10** - Needs Improvement
---
## 📁 Struktur File yang Diperiksa
```
/src/app/admin/(dashboard)/kesehatan/posyandu/
├── page.tsx # List posyandu dengan search & pagination
├── create/
│ └── page.tsx # Create posyandu dengan upload gambar
└── [id]/
├── page.tsx # Detail posyandu
└── edit/
└── page.tsx # Edit posyandu dengan replace image
```
**File Terkait:**
- State: `/src/app/admin/(dashboard)/_state/kesehatan/posyandu/posyandu.ts`
- API: `/src/app/api/[[...slugs]]/_lib/kesehatan/posyandu/` (6 files)
- Schema: `/prisma/schema.prisma` (Model `Posyandu`)
- UI Components: `/src/app/admin/(dashboard)/_com/` (createEditor, editEditor, modalKonfirmasiHapus)
---
## 🔴 HIGH PRIORITY ISSUES
### 1. Delete Operation Hard Delete (DATA LOSS RISK)
**File:** `src/app/api/[[...slugs]]/_lib/kesehatan/posyandu/del.ts`
```typescript
// Line 28-37
// Hapus file gambar dari filesystem
const filePath = path.join(posyandu.image.path, posyandu.image.name);
await fs.unlink(filePath);
// Hapus dari database FileStorage
await prisma.fileStorage.delete({ where: { id: posyandu.image.id } });
// Hapus posyandu (HARD DELETE!) ❌
await prisma.posyandu.delete({ where: { id } });
```
**Schema yang Diharapkan:**
```prisma
model Posyandu {
deletedAt DateTime? @default(null) // Soft delete field
isActive Boolean @default(true)
}
```
**Dampak:**
- **DATA LOSS** - Data posyandu terhapus permanen, tidak bisa direcover
- Audit trail hilang (riwayat posyandu tidak ada lagi)
- **Inconsistent dengan schema design** yang sudah ada soft delete fields
- Bisa melanggar compliance requirements untuk data retention
**Severity:** 🔴 **HIGH** - Data loss risk
**Solusi:**
```typescript
// Ganti hard delete dengan soft delete
export default async function posyanduDelete(context: Context) {
const id = context.params?.id as string;
try {
// SOFT DELETE - Update deletedAt dan isActive
await prisma.posyandu.update({
where: { id },
data: {
deletedAt: new Date(),
isActive: false
}
});
return {
success: true,
message: "Posyandu berhasil dihapus"
};
} catch (error) {
console.error("Error deleting posyandu:", error);
return { success: false, message: "Gagal menghapus posyandu" };
}
}
```
**Note:** File cleanup sebaiknya tidak dilakukan saat soft delete, atau dipindah ke background job untuk hard delete data yang sudah lama ter-delete.
---
### 2. Tidak Ada Validasi Duplicate Name/Nomor
**File:** `src/app/api/[[...slugs]]/_lib/kesehatan/posyandu/create.ts`
```typescript
// Line 13-23
const posyandu = await prisma.posyandu.create({
data: {
name: body.name, // ❌ Tidak cek duplicate
nomor: body.nomor, // ❌ Tidak cek duplicate
deskripsi: body.deskripsi,
imageId: body.imageId,
jadwalPelayanan: body.jadwalPelayanan,
},
});
```
**Same issue di:** `updt.ts` (update endpoint)
**Dampak:**
- User bisa buat posyandu dengan nama/nomor sama
- Data redundancy
- Confusing saat search dan reporting
- Bisa terjadi data inconsistency
**Severity:** 🔴 **HIGH** - Data integrity
**Solusi:**
```typescript
// Validasi duplicate sebelum create
const existing = await prisma.posyandu.findFirst({
where: {
OR: [
{ name: body.name },
{ nomor: body.nomor }
],
isActive: true
}
});
if (existing) {
return Response.json({
success: false,
message: "Nama atau nomor posyandu sudah digunakan"
}, { status: 400 });
}
// Lanjut create
const posyandu = await prisma.posyandu.create({ ... });
```
**Alternative - Schema Level:**
```prisma
model Posyandu {
name String @unique @db.VarChar(255) // Add unique constraint
nomor String @unique @db.VarChar(50) // Add unique constraint
// ...
}
```
---
### 3. Tidak Ada Validasi imageId Existence
**File:** `src/app/api/[[...slugs]]/_lib/kesehatan/posyandu/create.ts`
```typescript
// Line 13-23
const posyandu = await prisma.posyandu.create({
data: {
imageId: body.imageId, // ❌ Tidak cek apakah FileStorage benar ada
// ...
},
});
```
**Dampak:**
- User bisa create posyandu dengan `imageId` yang tidak valid
- Orphaned records (posyandu dengan gambar yang tidak ada)
- Bisa error saat fetch data dengan include image
**Severity:** 🔴 **HIGH** - Data integrity
**Solusi:**
```typescript
// Validasi imageId existence
if (body.imageId) {
const imageExists = await prisma.fileStorage.findUnique({
where: { id: body.imageId }
});
if (!imageExists) {
return Response.json({
success: false,
message: "Gambar tidak valid atau tidak ditemukan"
}, { status: 404 });
}
}
// Lanjut create
const posyandu = await prisma.posyandu.create({ ... });
```
---
### 4. Race Condition di Edit Page
**File:** `src/app/admin/(dashboard)/kesehatan/posyandu/[id]/edit/page.tsx`
```typescript
// Line 53-59: Local state
const [formData, setFormData] = useState({
name: '',
nomor: '',
deskripsi: '',
jadwalPelayanan: '',
imageId: '',
});
// Line 79-95: Load data ke local state
useEffect(() => {
const loadPosyandu = async () => {
const data = await statePosyandu.edit.load(params?.id as string);
if (data) {
setFormData({
name: data.name || '',
nomor: data.nomor || '',
// ...
});
}
};
loadPosyandu();
}, [params?.id]);
// Line 100-113: Reset form
const handleResetForm = () => {
setFormData({
name: originalData.name,
nomor: originalData.nomor,
// ...
});
// ❌ statePosyandu.edit.form tidak di-reset
};
// Line 133-140: Sync ke global state sebelum submit
useEffect(() => {
statePosyandu.edit.form = {
...statePosyandu.edit.form,
...formData,
};
}, [formData]);
```
**Dampak:**
- **Dual source of truth** - formData lokal dan statePosyandu.edit.form bisa tidak sinkron
- User bisa submit data yang tidak sesuai dengan yang ditampilkan di form
- Sulit debug karena data ada di 2 tempat
**Severity:** 🔴 **HIGH** - Data consistency
**Solusi:**
**Option A - Gunakan hanya global state (Recommended):**
```typescript
// Hapus local state, gunakan langsung global state
const formData = statePosyandu.edit.form;
const handleResetForm = () => {
statePosyandu.edit.form = { ...originalData };
};
// Submit langsung
const handleSubmit = async () => {
// Validasi
await statePosyandu.edit.update();
};
```
**Option B - Sinkronisasi dengan proper effect:**
```typescript
// Sync global state ke local state saat load
useEffect(() => {
const loadPosyandu = async () => {
const data = await statePosyandu.edit.load(params?.id as string);
if (data) {
statePosyandu.edit.form = {
name: data.name || '',
nomor: data.nomor || '',
// ...
};
setFormData(statePosyandu.edit.form);
}
};
loadPosyandu();
}, [params?.id]);
// Update global state saat formData berubah
useEffect(() => {
statePosyandu.edit.form = { ...formData };
}, [formData]);
```
---
### 5. Inconsistent API Client Usage
**File:** `src/app/admin/(dashboard)/_state/kesehatan/posyandu/posyandu.ts`
```typescript
// Line 45-53 (create) - Menggunakan ApiFetch ✅
const res = await ApiFetch.api.kesehatan.posyandu.create.post(posyandu.create.form);
// Line 90-93 (findUnique) - Menggunakan fetch langsung ❌
const res = await fetch(`/api/kesehatan/posyandu/${id}`);
const data = await res.json();
// Line 108-120 (delete) - Menggunakan fetch langsung ❌
const response = await fetch(`/api/kesehatan/posyandu/del/${id}`, {
method: 'DELETE',
});
const result = await response.json();
// Line 147-165 (edit.load) - Menggunakan fetch langsung ❌
const response = await fetch(`/api/kesehatan/posyandu/${id}`);
const result = await response.json();
```
**Dampak:**
- Code maintainability kurang
- Tidak type-safe
- Inconsistent error handling
- Sulit refactor
**Severity:** 🔴 **HIGH** - Code quality
**Solusi:**
```typescript
// Gunakan ApiFetch untuk semua
// findUnique
const data = await ApiFetch.api.kesehatan.posyandu[':id'].get({ query: { id } });
// delete
const result = await ApiFetch.api.kesehatan.posyandu['del/:id'].delete({ params: { id } });
// edit.load
const data = await ApiFetch.api.kesehatan.posyandu[':id'].get({ query: { id } });
```
---
## 🟡 MEDIUM PRIORITY ISSUES
### 6. Search Tidak Reset Pagination
**File:** `src/app/admin/(dashboard)/kesehatan/posyandu/page.tsx`
```typescript
// Line 35-38
useShallowEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
```
**Dampak:**
- User di page 5, search untuk data yang hanya ada di page 1
- Result kosong atau page error
- UX buruk
**Severity:** 🟡 **MEDIUM** - UX issue
**Solusi:**
```typescript
// Watch search separately
useEffect(() => {
setPage(1); // Reset page saat search berubah
}, [debouncedSearch]);
useEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
```
---
### 7. Find By ID Tidak Filter isActive
**File:** `src/app/api/[[...slugs]]/_lib/kesehatan/posyandu/find-by-id.ts`
```typescript
// Line 13-19
const data = await prisma.posyandu.findUnique({
where: { id }, // ❌ Tidak filter isActive
include: { image: true }
});
```
**Dampak:**
- Bisa fetch data yang sudah di-soft delete
- Data inconsistency
- Bisa tampil di UI padahal sudah dihapus
**Severity:** 🟡 **MEDIUM** - Data consistency
**Solusi:**
```typescript
const data = await prisma.posyandu.findFirst({
where: {
id,
isActive: true,
deletedAt: null // ✅ Filter soft-deleted data
},
include: { image: true }
});
```
---
### 8. Error Handling Upload Gambar Hanya console.log
**File:** `src/app/admin/(dashboard)/kesehatan/posyandu/create/page.tsx`
```typescript
// Line 81-95
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
toast.error('Gagal mengunggah gambar'); // ❌ Generic error
console.error('Gagal upload gambar'); // ❌ Hanya console.log
return;
}
```
**Dampak:**
- User tidak tahu penyebab error
- Sulit debug production issues
- Error detail hilang
**Severity:** 🟡 **MEDIUM** - UX & debugging
**Solusi:**
```typescript
const uploaded = res.data?.data;
if (!uploaded?.id) {
const errorMessage = res.data?.message || 'Unknown error';
console.error('Gagal upload gambar:', errorMessage);
toast.error(`Gagal upload gambar: ${errorMessage}`);
return;
}
```
---
### 9. Tidak Ada Progress Indicator Upload
**File:** Create & Edit pages
**Dampak:**
- User tidak tahu upload sedang berjalan
- User bisa klik submit berkali-kali (duplicate upload)
- UX buruk untuk file besar
**Severity:** 🟡 **MEDIUM** - UX
**Solusi:**
```typescript
// Tambah loading state untuk upload
const [uploading, setUploading] = useState(false);
const handleUpload = async (file: File) => {
setUploading(true);
try {
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
// ...
} finally {
setUploading(false);
}
};
// Disable button saat uploading
<Button type="submit" loading={submitting || uploading}>
Simpan
</Button>
```
---
### 10. Validasi Form Hanya di Frontend
**File:** Create & Edit pages
**Dampak:**
- User bisa bypass validation via API call langsung
- Data invalid bisa masuk ke database
- Security risk
**Severity:** 🟡 **MEDIUM** - Security & data integrity
**Solusi:**
```typescript
// Tambah validasi di API create.ts
const { name, nomor, deskripsi, jadwalPelayanan } = await context.body;
// Validasi required fields
if (!name || !nomor || !deskripsi || !jadwalPelayanan) {
return Response.json({
success: false,
message: "Semua field wajib diisi"
}, { status: 400 });
}
// Validasi length
if (name.length > 255) {
return Response.json({
success: false,
message: "Nama maksimal 255 karakter"
}, { status: 400 });
}
// Validasi nomor format (jika perlu)
if (!/^\d+$/.test(nomor)) {
return Response.json({
success: false,
message: "Nomor harus angka"
}, { status: 400 });
}
```
---
## 🟢 LOW PRIORITY ISSUES
### 11. Schema Field `name` Tidak Unique
**File:** `prisma/schema.prisma`
```prisma
model Posyandu {
name String // ❌ Tidak ada @unique (berbeda dengan Berita, KategoriBerita, dll)
nomor String // ❌ Tidak ada @unique
// ...
}
```
**Dampak:** Tidak ada constraint di database level untuk mencegah duplikasi.
**Severity:** 🟢 **LOW** - Schema design
**Solusi:**
```prisma
model Posyandu {
name String @unique @db.VarChar(255)
nomor String @unique @db.VarChar(50)
// ...
}
```
---
### 12. Tidak Ada Constraint Panjang untuk Field Text
**File:** `prisma/schema.prisma`
```prisma
model Posyandu {
name String // ❌ Tidak ada max length
nomor String // ❌ Tidak ada max length
deskripsi String @db.Text
jadwalPelayanan String // ❌ Tidak ada max length
// ...
}
```
**Dampak:** User bisa input text sangat panjang, bisa break UI atau database.
**Severity:** 🟢 **LOW** - Schema design
**Solusi:**
```prisma
model Posyandu {
name String @db.VarChar(255)
nomor String @db.VarChar(50)
deskripsi String @db.Text
jadwalPelayanan String @db.VarChar(500)
// ...
}
```
---
### 13. Empty State Tanpa Illustration
**File:** `src/app/admin/(dashboard)/kesehatan/posyandu/page.tsx`
```typescript
// Line 67-69
{filteredData.length === 0 && (
<Box py="xl" ta="center">
<Text c="dimmed">Tidak ada data posyandu</Text>
</Box>
)}
```
**Dampak:** Empty state kurang informatif dan kurang visually appealing.
**Severity:** 🟢 **LOW** - UX polish
**Solusi:**
```typescript
{filteredData.length === 0 && (
<Box py="xl" ta="center">
<Image
src="/empty-state.svg"
alt="No data"
w={200}
mx="auto"
mb="md"
/>
<Text fw={600} mb="xs">Tidak ada data posyandu</Text>
<Text c="dimmed" size="sm">
{search ? 'Coba kata kunci lain' : 'Mulai dengan menambahkan posyandu baru'}
</Text>
{!search && (
<Button mt="md" onClick={() => router.push('/kesehatan/posyandu/create')}>
Tambah Posyandu
</Button>
)}
</Box>
)}
```
---
### 14. Tidak Ada Sorting Option
**File:** `find-many.ts` dan `page.tsx`
```typescript
// find-many.ts
orderBy: { createdAt: 'desc' } // ❌ Hardcoded, tidak ada option sorting
```
**Dampak:** User tidak bisa sort by name, nomor, atau jadwal.
**Severity:** 🟢 **LOW** - UX
**Solusi:**
```typescript
// API find-many.ts
const { page = 1, limit = 10, search = '', sortBy = 'createdAt', sortOrder = 'desc' } = context.query;
orderBy: {
[sortBy as string]: sortOrder === 'asc' ? 'asc' : 'desc'
}
```
---
### 15. Toast Error Tidak Spesifik
**File:** `posyandu.ts` state
```typescript
// Line 45-53
if (res.status === 200) {
toast.success("Posyandu berhasil disimpan!");
} else {
toast.error("Gagal menyimpan posyandu"); // ❌ Generic error
}
```
**Dampak:** User tidak tahu penyebab error.
**Severity:** 🟢 **LOW** - UX
**Solusi:**
```typescript
if (res.status === 200) {
toast.success("Posyandu berhasil disimpan!");
} else {
const errorMessage = res.data?.message || 'Terjadi kesalahan';
toast.error(`Gagal menyimpan posyandu: ${errorMessage}`);
}
```
---
## ✅ YANG SUDAH BAIK
### **Schema:**
- ✅ Relasi ke FileStorage untuk gambar sudah benar
- ✅ Soft delete pattern dengan `deletedAt` dan `isActive` (tapi tidak dipakai di delete)
- ✅ Audit trail dengan `createdAt` dan `updatedAt`
- ✅ Field yang diperlukan sudah lengkap (name, nomor, deskripsi, jadwal, image)
### **API:**
- ✅ CRUD lengkap untuk Posyandu
- ✅ Pagination support dengan `page`, `limit`, `search`
- ✅ Search functionality dengan case-insensitive (include semua field)
- ✅ Include relasi image di response
- ✅ File cleanup saat delete (hapus file fisik + database)
- ✅ Error handling ada di semua endpoints
- ✅ Response format konsisten: `{ success, message, data }`
### **UI/UX:**
- ✅ Responsive design (desktop table + mobile cards)
- ✅ Loading states dan skeleton
- ✅ Toast notifications untuk feedback
- ✅ Form validation comprehensive (name, nomor, deskripsi, jadwal, image)
- ✅ Image upload dengan dropzone & preview
- ✅ File size limit & format validation
- ✅ Rich text editor untuk deskripsi dan jadwal
- ✅ Search dengan debounce (1000ms)
- ✅ Modal konfirmasi hapus
- ✅ Empty state message
- ✅ Reset form functionality
- ✅ Button disabled saat invalid/submitting
### **State Management:**
- ✅ Valtio proxy untuk global state
- ✅ Zod validation schema
- ✅ Loading state management
- ✅ Auto-refresh after CRUD operations
- ✅ Separate state untuk create, findMany, findUnique, edit, delete
---
## 📊 Metrics
| Aspek | Score | Keterangan |
|-------|-------|------------|
| **Schema Design** | 6.5/10 | Good structure, tapi tidak ada unique constraints |
| **API Design** | 6.5/10 | RESTful, file cleanup implemented, tapi tidak ada validation |
| **API Security** | 5/10 | Tidak ada auth, tidak ada backend validation |
| **UI/UX** | 7.5/10 | Responsive, comprehensive features |
| **State Management** | 6.5/10 | Valtio works well, inconsistent fetch patterns |
| **Code Quality** | 6.5/10 | Good structure, race condition potential |
**Overall Score: 6.5/10** - **Needs Improvement**
---
## 🎯 Action Plan
### Week 1 (Critical Fixes) 🔴
- [ ] **URGENT:** Fix delete operation (hard delete → soft delete)
- [ ] **URGENT:** Tambahkan validasi duplicate name/nomor di API
- [ ] **URGENT:** Tambahkan validasi imageId existence di API
- [ ] **URGENT:** Fix race condition di edit page (dual state)
- [ ] **URGENT:** Konsistensi fetch pattern (gunakan ApiFetch)
### Week 2 (Medium Priority) 🟡
- [ ] Fix search reset pagination logic
- [ ] Tambahkan filter isActive di find-by-id API
- [ ] Improve error handling upload gambar
- [ ] Tambahkan progress indicator untuk upload
- [ ] Tambahkan backend validation untuk semua field
### Week 3 (Polish) 🟢
- [ ] Tambahkan unique constraint di schema
- [ ] Tambahkan length constraints di schema
- [ ] Improve empty state dengan illustration
- [ ] Tambahkan sorting option
- [ ] Improve toast error messages
---
## 📝 Technical Notes
### **Database Migration:**
Fix deletedAt default dan add unique constraints:
```bash
# Generate migration
bunx prisma migrate dev --name fix_posyandu_deleted_at_and_unique
# Atau jika tidak pakai migrate
bunx prisma db push
# Data cleanup
UPDATE "Posyandu" SET "deletedAt" = NULL WHERE "isActive" = true;
```
### **Soft Delete Implementation:**
Update delete endpoint:
```typescript
// del.ts - Before (hard delete)
await prisma.posyandu.delete({ where: { id } });
// After (soft delete)
await prisma.posyandu.update({
where: { id },
data: {
deletedAt: new Date(),
isActive: false
}
});
```
### **Duplicate Validation:**
```typescript
// Check existing name/nomor
const existing = await prisma.posyandu.findFirst({
where: {
OR: [
{ name: body.name },
{ nomor: body.nomor }
],
isActive: true,
id: body.id ? { not: body.id } : undefined // Exclude current for update
}
});
if (existing) {
return Response.json({
success: false,
message: "Nama atau nomor posyandu sudah digunakan"
}, { status: 400 });
}
```
### **Race Condition Fix:**
```typescript
// Option A: Use only global state
const formData = statePosyandu.edit.form;
const handleResetForm = () => {
statePosyandu.edit.form = { ...originalData };
};
// Submit directly
const handleSubmit = async () => {
// Validation
await statePosyandu.edit.update();
};
```
---
## 📚 References
- [Prisma Soft Delete Pattern](https://www.prisma.io/docs/concepts/components/prisma-client/soft-delete)
- [Prisma Unique Constraints](https://www.prisma.io/docs/concepts/components/prisma-schema/relations)
- [Mantine Dropzone Documentation](https://mantine.dev/x/dropzone/)
- [React Toastify Documentation](https://fkhadra.github.io/react-toastify/)
- [Zod Documentation](https://zod.dev/)
- [Valtio Documentation](https://docs.pmnd.rs/valtio)
---
## 📈 Comparison dengan QC Sebelumnya
| Aspek | Profil | Potensi | Berita | Pengumuman | Gallery | Layanan | Penghargaan | **Posyandu** |
|-------|--------|---------|--------|------------|---------|---------|-------------|--------------|
| Schema | 6/10 | 7/10 | 8/10 | 7/10 | 6/10 | 7/10 | 7/10 | **6.5/10** |
| API Design | 7/10 | 8/10 | 7.5/10 | 7/10 | 6/10 | 5/10 | 7.5/10 | **6.5/10** |
| API Security | 4/10 | 6/10 | 6/10 | 6/10 | 4/10 | 5/10 | 5/10 | **5/10** |
| UI/UX | 8/10 | 8.5/10 | 8/10 | 7.5/10 | 7.5/10 | 7.5/10 | 8/10 | **7.5/10** ✅ |
| State Mgmt | 7/10 | 8/10 | 8/10 | 7/10 | 6.5/10 | 6.5/10 | 7/10 | **6.5/10** |
| Code Quality | 7/10 | 7.5/10 | 7/10 | 6.5/10 | 6/10 | 6/10 | 7/10 | **6.5/10** |
| **Overall** | **6.5/10** | **7.5/10** | **7/10** | **6.5/10** | **6/10** | **6.5/10** | **7/10** | **6.5/10** |
**Posyandu** memiliki score sama dengan **Profil Desa** dan **Pengumuman** karena:
**Positif:**
- ✅ CRUD lengkap & berfungsi dengan baik
- ✅ File cleanup implemented (delete) ✅
- ✅ Responsive design bagus
- ✅ Comprehensive validation di frontend
- ✅ Rich text editor untuk 2 field (deskripsi & jadwal)
- ✅ Search include semua field
**Negatif:**
-**Hard delete** vs soft delete mismatch (data loss risk)
-**Tidak ada validasi backend** (duplicate, imageId, required fields)
-**Race condition** di edit page (dual state)
-**Inconsistent fetch patterns** (ApiFetch vs fetch)
-**Tidak ada unique constraints** di schema
-**Tidak ada authentication** di API
---
**Dibuat oleh:** QC Automation
**Review Status:** ⏳ Menunggu Review Developer
**Next Review:** Setelah implementasi fixes

41
QWEN.md
View File

@@ -229,43 +229,4 @@ Common issues and solutions:
3. Test database changes with `bunx prisma db push`
4. Use the integrated Swagger docs at `/api/docs` for API testing
5. Check environment variables are properly configured
6. Verify responsive design on different screen sizes
## Qwen Added Memories
- **GitHub Workflow Execution**: Project ini memiliki 3 workflow GitHub Action:
1. `publish.yml` - Build & push Docker image ke GHCR (manual trigger, butuh input: stack_env + tag)
2. `re-pull.yml` - Re-pull Docker image di Portainer (manual trigger, butuh input: stack_name + stack_env)
3. `docker-publish.yml` - Auto build & push saat ada tag versi v*
Workflow bisa dijalankan via GitHub CLI: `gh workflow run <nama.yml> -f param=value --ref branch`
Setelah commit ke branch deployment (dev/stg/prod), otomatis trigger workflow publish + re-pull untuk deploy ke server.
- **Deployment Workflow Sistematis**:
1. **Version Bump** - Update `version` di `package.json` sebelum deploy (ikuti semver: major.minor.patch)
2. **Commit** - Commit perubahan + version bump dengan pesan yang jelas
3. **Buat Branch dan Push ke Branch yang baru dibuat** - Untuk branchnya buat sesuai dengan apa yang dikerjakan dengan format [apa-yang-dikerjakan]-[date-time]
4. **Push ke 2 Remote** - Push ke 2 remote origin dan deploy
5. **Merge ke Branch** - Merge ke branch target (biasanya `stg` untuk staging atau `prod` untuk production) ke 2 remote origin dan deploy
6. **Trigger publish.yml** - Gunakan GitHub API atau CLI dengan: `ref: main`, `stack_env: stg`, `tag: <versi-dari-package.json>`
7. **Tunggu publish selesai** - Workflow harus completed baru lanjut ke re-pull
8. **Trigger re-pull.yml** - Gunakan GitHub API atau CLI dengan: `ref: main`, `stack_name: desa-darmasaba`, `stack_env: stg`
Branch deployment: `stg` (staging) atau `prod` (production)
Version format di package.json: `"version": "major.minor.patch"`
- **Deployment Workflow HARUS Sequential (Berurutan)**:
Saat deploy ke stg atau prod, workflow TIDAK BOLEH dijalankan bersamaan. Harus menunggu yang pertama SELESAI total baru trigger yang kedua.
**Urutan yang BENAR:**
1. ✅ **publish.yml** - Tunggu sampai SELESAI (status: ✓ success)
2. ✅ **Setelah publish selesai**, baru trigger **re-pull.yml**
**JANGAN trigger keduanya bersamaan!** Ini akan menyebabkan race condition karena re-pull akan menarik image yang belum selesai di-build.
**Cara cek workflow selesai via GitHub CLI:**
```bash
gh run watch <publish_run_id>
# Tunggu sampai ada checkmark ✓
```
6. Verify responsive design on different screen sizes

View File

@@ -1,678 +0,0 @@
# Dokumentasi Struktur Proyek - Desa Darmasaba
## 1. Ringkasan Proyek
**Desa Darmasaba** adalah aplikasi web komprehensif untuk layanan pemerintahan desa di Desa Darmasaba, Kabupaten Badung, Bali. Aplikasi ini berfungsi sebagai platform digital untuk layanan pemerintah, informasi publik, dan keterlibatan masyarakat.
### Tech Stack
| Kategori | Teknologi |
|----------|-----------|
| **Framework Frontend** | Next.js 15 dengan App Router |
| **Bahasa** | TypeScript (strict mode) |
| **Styling** | Mantine UI v7/v8 + Custom CSS |
| **Backend API** | Elysia.js (high-performance TypeScript framework) |
| **Database** | PostgreSQL |
| **ORM** | Prisma 6.3.1 |
| **Runtime** | Bun |
| **State Management** | Jotai + Valtio + SWR |
| **Autentikasi** | iron-session + JWT |
| **File Storage** | Seafile |
| **Rich Text Editor** | TipTap |
| **Charts** | Recharts + Chart.js |
| **Maps** | Leaflet + react-leaflet |
| **UI Components** | Mantine, PrimeReact, Framer Motion |
| **Validasi** | Zod |
| **Testing** | Vitest (unit), Playwright (E2E) |
| **Deployment** | Docker + GitHub Actions + Portainer |
| **Registry** | GitHub Container Registry (GHCR) |
---
## 2. Struktur Direktori
```
desa-darmasaba/
├── .github/workflows/ # GitHub Actions CI/CD
│ ├── docker-publish.yml # Auto build & push saat tag v*
│ ├── publish.yml # Manual build & push ke GHCR
│ ├── re-pull.yml # Manual re-pull image di Portainer
│ └── script/ # Script deployment
├── prisma/
│ ├── schema.prisma # Database schema (2413 baris, 100+ model)
│ ├── seed.ts # Database seeder utama
│ └── _seeder_list/ # Data seed per modul
│ ├── desa/ # Seed berita, gallery, layanan, dll
│ ├── ekonomi/ # Seed APBDes, demografi, dll
│ ├── inovasi/ # Seed ide inovatif, desa digital
│ ├── keamanan/ # Seed keamanan, kontak darurat
│ ├── kesehatan/ # Seed fasilitas kesehatan, posyandu
│ ├── kependudukan/ # Seed data penduduk
│ ├── lingkungan/ # Seed lingkungan desa
│ ├── pendidikan/ # Seed sekolah, beasiswa
│ ├── ppid/ # Seed PPID
│ └── landing-page/ # Seed landing page
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── _com/ # Komponen global (SplashScreen, WebVitals)
│ │ ├── admin/ # Panel administrasi (protected)
│ │ │ ├── _com/ # Komponen admin shared
│ │ │ ├── (dashboard)/ # Dashboard admin dengan route groups
│ │ │ │ ├── _com/ # Komponen dashboard shared
│ │ │ │ ├── _state/ # State khusus dashboard
│ │ │ │ ├── _utils/ # Utilitas dashboard
│ │ │ │ ├── auth/ # Autentikasi admin
│ │ │ │ ├── desa/ # Admin: berita, gallery, profil, layanan
│ │ │ │ ├── ekonomi/ # Admin: APBDes, demografi, BUMDes
│ │ │ │ ├── inovasi/ # Admin: ide inovatif, desa digital
│ │ │ │ ├── keamanan/ # Admin: keamanan, kontak darurat
│ │ │ │ ├── kependudukan/# Admin: banjar, agama, umur, migrasi
│ │ │ │ ├── kesehatan/ # Admin: puskesmas, posyandu, wabah
│ │ │ │ ├── landing-page/# Admin: konten landing page
│ │ │ │ ├── lingkungan/ # Admin: konservasi, sampah, penghijauan
│ │ │ │ ├── musik/ # Admin: musik desa
│ │ │ │ ├── pendidikan/ # Admin: sekolah, beasiswa, perpustakaan
│ │ │ │ ├── ppid/ # Admin: PPID, IKM, permohonan
│ │ │ │ └── user&role/ # Admin: manajemen user & role
│ │ │ ├── auth/ # Halaman login admin
│ │ │ ├── csv/ # Upload/demo CSV
│ │ │ ├── images/ # Manajemen gambar
│ │ │ └── upload-demo/ # Demo upload
│ │ │
│ │ ├── api/ # API routes (Elysia.js)
│ │ │ ├── [[...slugs]]/ # Catch-all route untuk Elysia
│ │ │ │ ├── _lib/ # Modul API per domain
│ │ │ │ │ ├── auth/ # Autentikasi API
│ │ │ │ │ ├── desa/ # API modul desa
│ │ │ │ │ ├── ekonomi/ # API modul ekonomi
│ │ │ │ │ ├── fileStorage/ # API file storage
│ │ │ │ │ ├── inovasi/ # API modul inovasi
│ │ │ │ │ ├── keamanan/# API modul keamanan
│ │ │ │ │ ├── kependudukan/ # API modul kependudukan
│ │ │ │ │ ├── kesehatan/ # API modul kesehatan
│ │ │ │ │ ├── landing_page/ # API landing page
│ │ │ │ │ ├── lingkungan/ # API modul lingkungan
│ │ │ │ │ ├── pendidikan/ # API modul pendidikan
│ │ │ │ │ ├── ppid/ # API modul PPID
│ │ │ │ │ ├── search/ # API pencarian global
│ │ │ │ │ └── user/ # API user management
│ │ │ │ └── route.ts # Entry point Elysia server
│ │ │ ├── admin/ # API khusus admin
│ │ │ ├── auth/ # API autentikasi
│ │ │ ├── health/ # Health check endpoint
│ │ │ ├── layout/ # API layout
│ │ │ ├── news/ # API berita
│ │ │ ├── subscribe/ # API subscription (email)
│ │ │ └── tts/ # Text-to-Speech (ElevenLabs)
│ │ │
│ │ ├── context/ # React contexts
│ │ │ └── MusicContext.tsx # Context untuk pemutar musik
│ │ │
│ │ ├── darmasaba/ # Halaman publik (front-facing)
│ │ │ ├── _com/ # Komponen shared publik
│ │ │ │ ├── main-page/ # Komponen halaman utama
│ │ │ │ ├── Navbar.tsx # Navigasi utama
│ │ │ │ ├── Footer.tsx # Footer
│ │ │ │ ├── FixedPlayerBar.tsx # Music player bar
│ │ │ │ ├── LoadDataFirstClient.tsx # Data prefetching
│ │ │ │ ├── NewsReader.tsx # Component pembaca berita
│ │ │ │ ├── globalSearch.tsx # Pencarian global
│ │ │ │ └── scrollToTopButton.tsx
│ │ │ ├── (pages)/ # Halaman publik utama
│ │ │ │ ├── desa/ # Halaman: profil, berita, gallery, layanan
│ │ │ │ ├── ekonomi/ # Halaman: APBDes, BUMDes, demografi
│ │ │ │ ├── inovasi/ # Halaman: inovasi desa
│ │ │ │ ├── keamanan/ # Halaman: keamanan lingkungan
│ │ │ │ ├── kependudukan/# Halaman: data penduduk
│ │ │ │ ├── kesehatan/ # Halaman: fasilitas kesehatan
│ │ │ │ ├── lingkungan/ # Halaman: lingkungan desa
│ │ │ │ ├── module/ # Halaman modul tambahan
│ │ │ │ ├── musik/ # Halaman: musik desa
│ │ │ │ ├── pendidikan/ # Halaman: pendidikan
│ │ │ │ └── ppid/ # Halaman: PPID publik
│ │ │ ├── (tambahan)/ # Halaman tambahan
│ │ │ ├── layout.tsx # Layout utama publik
│ │ │ └── page.tsx # Landing page utama
│ │ │
│ │ ├── login/ # Halaman login
│ │ ├── registrasi/ # Halaman registrasi
│ │ ├── waiting-room/ # Halaman waiting room
│ │ ├── terms-of-service/ # Halaman syarat layanan
│ │ ├── test-upload/ # Halaman tes upload
│ │ ├── validasi/ # Halaman validasi
│ │ ├── coba/ # Halaman percobaan
│ │ ├── percobaan/ # Halaman percobaan lainnya
│ │ ├── layout.tsx # Root layout (MantineProvider)
│ │ ├── page.tsx # Root page
│ │ ├── error.tsx # Error boundary
│ │ ├── not-found.tsx # 404 page
│ │ ├── globals.css # Global styles
│ │ └── favicon.ico
│ │
│ ├── components/
│ │ └── admin/ # Komponen admin reusable
│ │ ├── AdminThemeProvider.tsx
│ │ ├── DarkModeToggle.tsx
│ │ ├── UnifiedSurface.tsx
│ │ └── UnifiedTypography.tsx
│ │
│ ├── con/ # Constants & konfigurasi
│ │ └── colors.ts # Palet warna
│ │
│ ├── lib/ # Utility functions
│ │ ├── router/ # Router utilities
│ │ ├── api-auth.ts # Autentikasi API
│ │ ├── api-fetch.ts # Helper fetch API
│ │ ├── EnvStringParse.ts # Parser environment variables
│ │ ├── prisma.ts # Prisma client instance
│ │ ├── seafile-auth-service.ts # Integrasi Seafile
│ │ └── session.ts # iron-session helper
│ │
│ ├── middlewares/ # Next.js middleware
│ ├── state/ # Global state (Jotai/Valtio)
│ │ ├── darkModeStore.ts # State dark mode
│ │ ├── state-layanan.ts # State layanan
│ │ ├── state-list-image.ts # State daftar gambar
│ │ └── state-nav.ts # State navigasi
│ │
│ ├── store/ # State management tambahan
│ └── types/ # TypeScript type definitions
├── public/ # Static assets
│ └── assets/ # Gambar, icon, dll
├── uploads/ # Directory upload (runtime)
│ └── image/ # Upload gambar
├── .env.example # Contoh environment variables
├── .gitignore
├── AGENTS.md # Panduan untuk AI coding agents
├── Dockerfile # Docker image definition
├── docker-entrypoint.sh # Entry point container
├── next.config.ts # Next.js configuration
├── package.json # Dependencies & scripts
├── tsconfig.json # TypeScript configuration
├── biome.json # Biome linter config
├── eslint.config.mjs # ESLint config
├── NOTE.md # Catatan deployment
└── QWEN.md # Konteks & memori proyek
```
---
## 3. Arsitektur Aplikasi
### 3.1 Arsitektur Keseluruhan
```
┌─────────────────────────────────────────────────────────┐
│ Client (Browser) │
└────────────┬────────────────────────────┬────────────────┘
│ │
│ Next.js Pages │ API Calls
│ (SSR/CSR) │
▼ ▼
┌────────────────────────┐ ┌────────────────────────────┐
│ Next.js 15 App Router│ │ Elysia.js API Server │
│ - Pages publik │ │ - RESTful endpoints │
│ - Admin dashboard │ │ - File upload │
│ - Server components │ │ - Swagger docs (/api/docs│
│ - Client components │ │ - Static file serving │
└────────────┬───────────┘ └────────────┬───────────────┘
│ │
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ PostgreSQL Database │
│ (via Prisma ORM) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Seafile File Storage │
│ (Images & Documents) │
└─────────────────────────────────────────────────────────┘
```
### 3.2 Next.js App Router
- Menggunakan **App Router** (bukan Pages Router)
- Route groups `(dashboard)`, `(pages)`, `(tambahan)` untuk organisasi tanpa mempengaruhi URL
- Layout bersarang: root layout -> admin/darmasaba layout -> page layouts
- `force-dynamic` digunakan untuk menghindari error prerendering
- View Transitions API diaktifkan via `next-view-transitions`
### 3.3 Elysia.js API Server
- Terintegrasi sebagai **catch-all route** di `/api/[[...slugs]]/route.ts`
- Semua HTTP methods (GET, POST, PATCH, DELETE, PUT) di-handle oleh Elysia
- Plugin yang digunakan:
- `@elysiajs/cors` - CORS configuration
- `@elysiajs/static` - Static file serving dari `/uploads`
- `@elysiajs/swagger` - API documentation di `/api/docs`
- `@elysiajs/jwt` - JWT authentication
- `@elysiajs/cookie` - Cookie handling
- Endpoint file upload: `/api/upl-img`, `/api/upl-img-single`, `/api/upl-csv`
- Image serving: `/api/img/:name` dengan resize support
### 3.4 Rendering Strategy
- **Server Components**: Halaman publik untuk SEO optimal
- **Client Components**: Komponen interaktif (form, state, animasi)
- **Force Dynamic**: Beberapa halaman menggunakan `force-dynamic`
- **ISR**: Caching header untuk assets (1 jam cache)
---
## 4. Modul Domain
### 4.1 Profil Desa (Desa)
**Admin**: `/admin/desa/*` | **Publik**: `/darmasaba/desa/*`
| Sub-modul | Fungsi |
|-----------|--------|
| `berita` | CRUD berita/pengumuman desa |
| `gallery` | Galeri foto dan video |
| `layanan` | Manajemen layanan desa |
| `penghargaan` | Penghargaan yang diraih |
| `pengumuman` | Pengumuman publik |
| `potensi` | Potensi desa (pertanian, pariwisata, dll) |
| `profil` | Profil desa (sejarah, visi misi, lambang, maskot, perangkat) |
### 4.2 PPID (Pejabat Pengelola Informasi dan Dokumentasi)
**Admin**: `/admin/ppid/*` | **Publik**: `/darmasaba/ppid/*`
| Sub-modul | Fungsi |
|-----------|--------|
| `profil-ppid` | Profil pejabat PPID |
| `struktur-ppid` | Struktur organisasi PPID |
| `visi-misi-ppid` | Visi dan misi PPID |
| `daftar-informasi-publik` | Daftar informasi yang tersedia |
| `dasar-hukum` | Dasar hukum PPID |
| `permohonan-informasi-publik` | Form permohonan informasi |
| `permohonan-keberatan-informasi-publik` | Form keberatan |
| `indeks-kepuasan-masyarakat` | Survei kepuasan masyarakat (IKM) |
### 4.3 Kesehatan
**Admin**: `/admin/kesehatan/*` | **Publik**: `/darmasaba/kesehatan/*`
| Sub-modul | Fungsi |
|-----------|--------|
| `fasilitas-kesehatan` | Data puskesmas, klinik, dokter |
| `posyandu` | Manajemen posyandu |
| `program-kesehatan` | Program kesehatan desa |
| `info-wabah-penyakit` | Informasi wabah |
| `penanganan-darurat` | Prosedur penanganan darurat |
| `kontak-darurat` | Kontak darurat kesehatan |
| `data-kesehatan-warga` | Statistik kesehatan warga |
| `artikel-kesehatan` | Artikel kesehatan |
### 4.4 Ekonomi
**Admin**: `/admin/ekonomi/*` | **Publik**: `/darmasaba/ekonomi/*`
| Sub-modul | Fungsi |
|-----------|--------|
| `APBDes` | Anggaran Pendapatan dan Belanja Desa (hierarki items + realisasi) |
| `PADesa-pendapatan-asli-desa` | Pendapatan asli desa |
| `demografi-pekerjaan` | Demografi pekerjaan penduduk |
| `jumlah-penduduk-miskin` | Data penduduk miskin |
| `jumlah-pengangguran` | Data pengangguran |
| `lowongan-kerja-lokal` | Lowongan kerja lokal |
| `pasar-desa` | Data pasar desa |
| `program-kemiskinan` | Program penanganan kemiskinan |
| `sektor-unggulan-desa` | Sektor unggulan ekonomi |
| `Struktur-Organisasi-Dan-Sk-Pengurus-BumDes` | Struktur BUMDes |
### 4.5 Kependudukan
**Admin**: `/admin/kependudukan/*` | **Publik**: `/darmasaba/kependudukan/*`
| Sub-modul | Fungsi |
|-----------|--------|
| `data-banjar` | Data banjar (unit wilayah tradisional Bali) |
| `distribusi-agama` | Distribusi agama penduduk |
| `distribusi-umur` | Distribusi umur penduduk |
| `migrasi-penduduk` | Data migrasi (masuk/keluar) |
### 4.6 Pendidikan
**Admin**: `/admin/pendidikan/*` | **Publik**: `/darmasaba/pendidikan/*`
| Sub-modul | Fungsi |
|-----------|--------|
| `beasiswa-desa` | Program beasiswa |
| `bimbingan-belajar-desa` | Bimbingan belajar |
| `data-pendidikan` | Data statistik pendidikan |
| `info-sekolah` | Informasi sekolah |
| `pendidikan-non-formal` | Pendidikan non-formal |
| `perpustakaan-digital` | Perpustakaan digital |
| `program-pendidikan-anak` | Program pendidikan anak |
### 4.7 Keamanan
**Admin**: `/admin/keamanan/*` | **Publik**: `/darmasaba/keamanan/*`
| Sub-modul | Fungsi |
|-----------|--------|
| `keamanan-lingkungan-pecalang-patwal` | Keamanan lingkungan (pecalang Bali) |
| `kontak-darurat` | Kontak darurat keamanan |
| `laporan-publik` | Laporan publik |
| `pencegahan-kriminalitas` | Pencegahan kriminalitas |
| `polsek-terdekat` | Data polsek terdekat |
| `tips-keamanan` | Tips keamanan |
### 4.8 Lingkungan
**Admin**: `/admin/lingkungan/*` | **Publik**: `/darmasaba/lingkungan/*`
| Sub-modul | Fungsi |
|-----------|--------|
| `data-lingkungan-desa` | Data lingkungan desa |
| `edukasi-lingkungan` | Edukasi lingkungan |
| `gotong-royong` | Kegiatan gotong royong |
| `konservasi-adat-bali` | Konservasi adat Bali |
| `pengelolaan-sampah-bank-sampah` | Bank sampah |
| `program-penghijauan` | Program penghijauan |
### 4.9 Inovasi
**Admin**: `/admin/inovasi/*` | **Publik**: `/darmasaba/inovasi/*`
| Sub-modul | Fungsi |
|-----------|--------|
| `ajukan-ide-inovatif` | Form pengajuan ide inovatif |
| `desa-digital-smart-village` | Program desa digital |
| `info-teknologi-tepat-guna` | Info teknologi tepat guna |
| `kolaborasi-inovasi` | Kolaborasi inovasi |
| `layanan-online-desa` | Layanan online desa |
| `program-kreatif-desa` | Program kreatif desa |
### 4.10 Musik Desa
**Admin**: `/admin/musik/*` | **Publik**: `/darmasaba/musik/*`
- Manajemen audio dan cover musik desa
- Fixed player bar di halaman publik
- Context provider untuk state pemutar musik
### 4.11 Landing Page
**Admin**: `/admin/landing-page/*`
| Sub-modul | Fungsi |
|-----------|--------|
| `desa-anti-korupsi` | Konten anti-korupsi |
| `prestasi-desa` | Prestasi yang diraih |
| `sdgs-desa` | SDGs (Sustainable Development Goals) |
| `profil-landing-page` | Profil dan media sosial |
### 4.12 User & Role
**Admin**: `/admin/user&role/*`
- Manajemen pengguna admin
- Manajemen role dan permission
- Manajemen menu akses
---
## 5. Database Schema
### 5.1 Overview
Database menggunakan **PostgreSQL** dengan **Prisma ORM** (versi 6.3.1).
Schema terdiri dari **2413 baris** dengan **100+ model**.
### 5.2 Model Utama
#### FileStorage
Model sentral untuk semua file (gambar, dokumen, audio):
```prisma
model FileStorage {
id String @id @default(cuid())
name String @unique
realName String
path String
mimeType String
category String // "image" / "document" / "audio" / "other"
link String
isActive Boolean @default(true)
// Relasi ke 50+ model lain (Berita, PotensiDesa, GalleryFoto, dll)
}
```
#### AppMenu & AppMenuChild
Menu navigasi aplikasi:
```prisma
model AppMenu {
id String @id @default(cuid())
name String @unique
link String
isActive Boolean @default(true)
AppMenuChild AppMenuChild[]
}
```
#### User & Role (Autentikasi Admin)
- `User` - Data pengguna admin
- `Role` - Role/peran pengguna
- `Menu` - Menu akses per role
#### Modul Desa
- `Berita` - Berita desa (dengan featured image & gallery)
- `GalleryFoto` / `GalleryVideo` - Galeri media
- `Layanan` - Layanan desa
- `Pengumuman` - Pengumuman
- `PotensiDesa` - Potensi desa
- `ProfileDesaImage` - Gambar profil desa
- `ProfilPerbekel` - Profil perbekel (kepala desa)
- `PejabatDesa` - Pejabat desa
- `Penghargaan` - Penghargaan
- `PrestasiDesa` - Prestasi
- `MediaSosial` - Media sosial desa
#### Modul PPID
- `StrukturPPID` - Struktur organisasi
- `PosisiOrganisasiPPID` - Posisi dengan hierarki
- `PegawaiPPID` - Data pegawai
- `ProfilePPID` - Profil PPID
- `VisiMisiPPID` - Visi misi
- `DasarHukumPPID` - Dasar hukum
- `DaftarInformasiPublik` - Daftar informasi
- `PermohonanInformasiPublik` - Permohonan informasi
- `FormulirPermohonanKeberatan` - Formulir keberatan
- `IndeksKepuasanMasyarakat` - IKM
- `Responden` + lookup tables - Data responden IKM
#### Modul Kesehatan
- `Puskesmas` - Data puskesmas
- `Posyandu` - Data posyandu
- `ProgramKesehatan` - Program kesehatan
- `FasilitasKesehatan` - Fasilitas
- `InfoWabahPenyakit` - Info wabah
- `PenangananDarurat` - Penanganan darurat
- `KontakDarurat` - Kontak darurat
- `ArtikelKesehatan` - Artikel
#### Modul Ekonomi
- `APBDes` & `APBDesItem` - Anggaran desa (hierarki tree structure)
- `RealisasiItem` - Realisasi anggaran (multiple per item)
- `PasarDesa` - Pasar desa
- `PegawaiBumDes` - Pegawai BUMDes
- `StrukturBumDes` - Struktur BUMDes
- `DemografiPekerjaan` - Demografi pekerjaan
- `JumlahPendudukMiskin` - Data kemiskinan
- `JumlahPengangguran` - Data pengangguran
- `LowonganKerjaLokal` - Lowongan kerja
- `ProgramKemiskinan` - Program kemiskinan
- `SektorUnggulanDesa` - Sektor unggulan
- `PendapatanAsli` - Pendapatan asli desa
#### Modul Kependudukan
- `DataBanjar` - Data banjar
- `DistribusiAgama` - Distribusi agama
- `DistribusiUmur` - Distribusi umur
- `MigrasiPenduduk` - Migrasi
#### Modul Pendidikan
- `InfoSekolah` - Data sekolah
- `BeasiswaDesa` - Beasiswa
- `BimbinganBelajar` - Bimbingan belajar
- `PendidikanNonFormal` - Pendidikan non-formal
- `DataPerpustakaan` - Perpustakaan
#### Modul Keamanan
- `KeamananLingkungan` - Keamanan lingkungan
- `MenuTipsKeamanan` - Tips keamanan
- `PencegahanKriminalitas` - Pencegahan kriminalitas
- `PolsekTerdekat` - Polsek terdekat
- `LaporanPublik` - Laporan publik
#### Modul Lingkungan
- `DataLingkunganDesa` - Data lingkungan
- `KonservasiAdatBali` - Konservasi adat
- `BankSampah` - Bank sampah
- `ProgramPenghijauan` - Penghijauan
- `GotongRoyong` - Gotong royong
- `EdukasiLingkungan` - Edukasi
#### Modul Inovasi
- `ProgramInovasi` - Program inovasi
- `DesaDigital` - Desa digital
- `InfoTekno` - Info teknologi
- `KolaborasiInovasi` + `MitraKolaborasi` - Kolaborasi
- `LayananOnlineDesa` - Layanan online
- `ProgramKreatifDesa` - Program kreatif
- `Ajukan` - Pengajuan ide
#### Modul Musik
- `MusikDesa` - Musik desa
- `audioFile` -> FileStorage
- `coverImage` -> FileStorage
#### Landing Page
- `DesaAntiKorupsi` + `KategoriDesaAntiKorupsi`
- `SdgsDesa` - SDGs
- `PrestasiDesa` + `KategoriPrestasiDesa`
- `MediaSosial`
- `LandingPage_Layanan`
#### APBDes (Struktur Hierarki)
```prisma
model APBDesItem {
kode String // "4", "4.1", "4.1.2"
uraian String // Nama item
anggaran Float // Anggaran dalam Rupiah
tipe String? // "pendapatan" | "belanja" | "pembiayaan"
level Int // 1, 2, 3
parentId String? // Self-referencing untuk tree
children APBDesItem[]
totalRealisasi Float @default(0) // Auto-calculated
selisih Float @default(0) // totalRealisasi - anggaran
persentase Float @default(0) // (totalRealisasi / anggaran) * 100
realisasiItems RealisasiItem[]
}
```
### 5.3 Pola Umum Model
Hampir semua model mengikuti pola:
```prisma
model Contoh {
id String @id @default(cuid())
// ... fields
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime? @default(now()) // Soft delete
isActive Boolean @default(true) // Soft delete flag
}
```
---
## 6. API Routes
### 6.1 Struktur API
Semua API routes ditangani oleh **Elysia.js** di `/src/app/api/[[...slugs]]/route.ts`
### 6.2 API Groups
| Prefix | Modul | Contoh Endpoints |
|--------|-------|------------------|
| `/api/layanan` | Layanan | `GET /api/layanan` |
| `/api/potensi` | Potensi | `GET /api/potensi` |
| `/api/desa/*` | Desa | CRUD berita, gallery, profil, dll |
| `/api/ppid/*` | PPID | CRUD struktur, profil, permohonan |
| `/api/kesehatan/*` | Kesehatan | CRUD puskesmas, posyandu, dll |
| `/api/ekonomi/*` | Ekonomi | CRUD APBDes, BUMDes, demografi |
| `/api/kependudukan/*` | Kependudukan | CRUD banjar, demografi |
| `/api/pendidikan/*` | Pendidikan | CRUD sekolah, beasiswa |
| `/api/keamanan/*` | Keamanan | CRUD keamanan, kontak darurat |
| `/api/lingkungan/*` | Lingkungan | CRUD data lingkungan |
| `/api/inovasi/*` | Inovasi | CRUD program inovasi |
| `/api/landing-page/*` | Landing Page | CRUD konten landing page |
| `/api/user/*` | User | CRUD user admin |
| `/api/user/role/*` | Role | CRUD role & permission |
| `/api/search` | Search | Pencarian global |
| `/api/file-storage/*` | File Storage | CRUD file storage |
| `/api/img/:name` | Image | GET gambar dengan resize |
| `/api/upl-img` | Upload | Upload multiple images |
| `/api/upl-img-single` | Upload | Upload single image |
| `/api/upl-csv` | Upload | Upload CSV files |
| `/api/utils/version` | Utils | GET versi aplikasi |
### 6.3 API Documentation
Swagger UI tersedia di: **`/api/docs`**
### 6.4 API Route Lainnya
| Route | Fungsi |
|-------|--------|
| `/api/health` | Health check endpoint |
| `/api/news` | API berita (standalone) |
| `/api/subscribe` | Subscription email |
| `/api/tts` | Text-to-Speech (ElevenLabs) |
| `/api/admin/*` | API khusus admin |
| `/api/auth/*` | API autentikasi |
---
## 7. Halaman Admin
### 7.1 Struktur
Admin dashboard berada di `/admin` dengan route group `(dashboard)`.
| Section | Path | Fungsi |
|---------|------|--------|
| **Dashboard** | `/admin` | Dashboard utama |
| **Autentikasi** | `/admin/auth` | Login admin |
| **Desa** | `/admin/desa/*` | Berita, gallery, profil, layanan, penghargaan, pengumuman, potensi |
| **PPID** | `/admin/ppid/*` | Profil, struktur, visi-misi, daftar informasi, dasar hukum, permohonan, IKM |
| **Kesehatan** | `/admin/kesehatan/*` | Puskesmas, posyandu, program kesehatan, wabah, kontak darurat |
| **Ekonomi** | `/admin/ekonomi/*` | APBDes, PAD, demografi, pengangguran, kemiskinan, BUMDes, pasar desa |
| **Kependudukan** | `/admin/kependudukan/*` | Banjar, distribusi agama, distribusi umur, migrasi |
| **Pendidikan** | `/admin/pendidikan/*` | Sekolah, beasiswa, bimbingan belajar, perpustakaan digital |
| **Keamanan** | `/admin/keamanan/*` | Keamanan lingkungan, kontak darurat, pencegahan kriminalitas, polsek |
| **Lingkungan** | `/admin/lingkungan/*` | Data lingkungan, konservasi, bank sampah, penghijauan, gotong royong |
| **Inovasi** | `/admin/inovasi/*` | Ide inovatif, desa digital, teknologi tepat guna, kolaborasi |
| **Musik** | `/admin/musik/*` | Manajemen musik desa |
| **Landing Page** | `/admin/landing-page/*` | Anti-korupsi, prestasi, SDGs, media sosial |
| **User & Role** | `/admin/user&role/*` | Manajemen user dan role |
| **Images** | `/admin/images/*` | Manajemen gambar |
| **CSV** | `/admin/csv/*` | Upload/import CSV |
### 7.2 Komponen Admin Shared
- `AdminThemeProvider.tsx` - Theme provider untuk dark/light mode
- `DarkModeToggle.tsx` - Toggle dark mode
- `UnifiedSurface.tsx` - Komponen surface/card unified
- `UnifiedTypography.tsx` - Tipografi unified
---
## 8. Halaman Publik
### 8.1 Struktur
Halaman publik berada di `/darmasaba` dengan layout yang mencakup Navbar, Footer, dan Fixed Music Player.
| Halaman | Path | Konten |
|---------|------|--------|
| **Landing Page

View File

@@ -1,842 +0,0 @@
# Dokumentasi Struktur Proyek Desa Darmasaba
## 1. Ringkasan Proyek
**Desa Darmasaba** adalah aplikasi web manajemen desa digital untuk Desa Darmasaba, Kabupaten Badung, Bali. Aplikasi ini berfungsi sebagai platform layanan publik digital yang mencakup informasi pemerintahan, layanan kesehatan, keamanan, pendidikan, ekonomi, lingkungan, dan inovasi desa.
### Tech Stack
| Kategori | Teknologi |
|----------|-----------|
| **Framework** | Next.js 15 (App Router) |
| **Language** | TypeScript (strict mode) |
| **Runtime** | Bun |
| **Backend API** | Elysia.js (high-performance HTTP server) |
| **Database** | PostgreSQL |
| **ORM** | Prisma 6.3.1 |
| **UI Framework** | Mantine UI v7-v8 |
| **State Management** | Jotai + Valtio + SWR |
| **Authentication** | iron-session + JWT (@elysiajs/jwt) |
| **File Storage** | Seafile (self-hosted) |
| **Text Editor** | Tiptap (Rich text editor) |
| **Charts** | Recharts + Chart.js |
| **Maps** | Leaflet + react-leaflet |
| **Testing** | Vitest (unit) + Playwright (E2E) |
| **Styling** | Mantine + PostCSS + Framer Motion |
| **Deployment** | Docker + GHCR + Portainer + GitHub Actions |
| **Version** | 0.1.11 |
---
## 2. Struktur Direktori
```
desa-darmasaba/
├── .github/workflows/ # GitHub Actions CI/CD
│ ├── docker-publish.yml # Auto build & push saat tag v*
│ ├── publish.yml # Manual build & push ke GHCR
│ ├── re-pull.yml # Manual re-pull di Portainer
│ └── script/ # Shell scripts untuk deploy
├── prisma/
│ ├── schema.prisma # Database schema (2413 baris, 100+ model)
│ └── seed.ts # Database seeder (400+ baris)
│ └── _seeder_list/ # Seed data per modul
├── public/ # Static assets
│ └── assets/
│ └── images/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── _com/ # Global components (SplashScreen, WebVitals)
│ │ ├── admin/ # ADMIN DASHBOARD
│ │ │ ├── (dashboard)/ # Route group dashboard
│ │ │ │ ├── desa/ # - Berita, Gallery, Layanan, dll
│ │ │ │ ├── ppid/ # - Informasi publik, struktur, dasar hukum
│ │ │ │ ├── kesehatan/ # - Fasilitas, posyandu, puskesmas, wabah
│ │ │ │ ├── ekonomi/ # - APBDes, pasar desa, BUMDes, dll
│ │ │ │ ├── kependudukan/ # - Banjar, agama, umur, migrasi
│ │ │ │ ├── pendidikan/ # - Sekolah, beasiswa, perpustakaan
│ │ │ │ ├── keamanan/ # - Keamanan lingkungan, polsek, dll
│ │ │ │ ├── lingkungan/ # - Sampah, penghijauan, gotong royong
│ │ │ │ ├── inovasi/ # - Desa digital, kolaborasi, dll
│ │ │ │ ├── landing-page/ # - Profil, prestasi, anti-korupsi
│ │ │ │ ├── musik/ # - Musik desa
│ │ │ │ ├── user&role/ # - Manajemen user & role
│ │ │ │ └── _com/ # - Shared admin components
│ │ │ ├── auth/ # Login OTP untuk admin
│ │ │ ├── csv/ # Demo CSV upload
│ │ │ └── layout.tsx # Admin shell (AppShell Mantine)
│ │ ├── api/ # ELYSIA.JS API SERVER
│ │ │ ├── [[...slugs]]/ # Catch-all route -> Elysia handler
│ │ │ │ ├── route.ts # - Main Elysia server export
│ │ │ │ └── _lib/ # - Domain route modules
│ │ │ │ ├── desa.ts
│ │ │ │ ├── ppid.ts
│ │ │ │ ├── kesehatan.ts
│ │ │ │ ├── ekonomi.ts
│ │ │ │ ├── keamanan.ts
│ │ │ │ ├── inovasi.ts
│ │ │ │ ├── lingkungan.ts
│ │ │ │ ├── pendidikan.ts
│ │ │ │ ├── kependudukan.ts
│ │ │ │ ├── landing_page.ts
│ │ │ │ ├── user/ # - User & Role management
│ │ │ │ ├── fileStorage/
│ │ │ │ ├── search/
│ │ │ │ ├── auth/
│ │ │ │ ├── upl-img.ts, upl-img-single.ts
│ │ │ │ ├── upl-csv.ts, upl-csv-single.ts
│ │ │ │ └── img.ts, img-del.ts, imgs.ts
│ │ │ ├── auth/ # Auth endpoints (login, logout, me)
│ │ │ └── ... # Other API routes
│ │ ├── darmasaba/ # PUBLIC-FACING WEBSITE
│ │ │ ├── _com/ # Shared components (Navbar, Footer, etc)
│ │ │ ├── (pages)/ # Public pages route group
│ │ │ │ ├── desa/ # - Profil, berita, gallery, layanan
│ │ │ │ ├── ppid/ # - PPID public pages
│ │ │ │ ├── kesehatan/ # - Health info pages
│ │ │ │ ├── ekonomi/ # - Economy pages
│ │ │ │ ├── kependudukan/
│ │ │ │ ├── pendidikan/
│ │ │ │ ├── keamanan/
│ │ │ │ ├── lingkungan/
│ │ │ │ ├── inovasi/
│ │ │ │ ├── musik/
│ │ │ │ └── module/ # - External module links
│ │ │ └── (tambahan)/ # Additional pages
│ │ ├── login/ # Login page
│ │ ├── registrasi/ # Registration page
│ │ ├── waiting-room/ # Waiting room (inactive users)
│ │ ├── terms-of-service/
│ │ ├── layout.tsx # Root layout (MantineProvider, ViewTransitions)
│ │ └── page.tsx # Homepage redirect
│ ├── components/
│ │ └── admin/ # Admin shared components
│ │ ├── AdminThemeProvider.tsx
│ │ ├── DarkModeToggle.tsx
│ │ ├── UnifiedSurface.tsx
│ │ └── UnifiedTypography.tsx
│ ├── con/ # Constants & configuration
│ │ ├── colors.ts # Color palette definitions
│ │ ├── images.ts
│ │ ├── navbar-list-menu.ts
│ │ ├── router.ts # Route mapping
│ │ └── sosmed.ts
│ ├── context/ # React contexts
│ │ └── MusicContext.tsx # Music player context
│ ├── hooks/ # Custom React hooks
│ ├── lib/ # Utility libraries
│ │ ├── router/
│ │ ├── api-auth.ts # API authentication helpers
│ │ ├── api-fetch.ts # API fetch wrapper
│ │ ├── EnvStringParse.ts
│ │ ├── prisma.ts # Prisma client singleton
│ │ ├── seafile-auth-service.ts
│ │ └── session.ts # iron-session helper
│ ├── state/ # Global state (Jotai/Valtio)
│ │ ├── darkModeStore.ts
│ │ ├── state-layanan.ts
│ │ ├── state-list-image.ts
│ │ └── state-nav.ts
│ ├── store/ # Additional stores
│ │ └── authStore.ts # Auth state (Jotai)
│ ├── types/ # TypeScript type definitions
│ └── utils/ # Utility functions
│ └── themeTokens.ts # Dark/light theme tokens
├── uploads/ # Local upload directory (images/files)
├── Dockerfile # Multi-stage Docker build (Bun)
├── docker-entrypoint.sh # Entry script (migrate + start)
├── next.config.ts # Next.js configuration
├── package.json # Dependencies & scripts
├── tsconfig.json # TypeScript configuration
├── biome.json # Biome linter config
├── eslint.config.mjs # ESLint config
├── NOTE.md # Deployment notes
├── QWEN.md # Project memory & workflow
└── AGENTS.md # Agent coding guidelines
```
---
## 3. Arsitektur
### Pola Arsitektur: Full-Stack Monolith dengan App Router
```
Browser
|
+-- Next.js 15 (App Router) -- Server Components + Client Components
|
+-- /darmasaba/* -> Public pages (SSR/CSR)
+-- /admin/* -> Admin dashboard (protected)
+-- /api/* -> Elysia.js API server
|
+-- Elysia Server (src/app/api/[[...slugs]]/route.ts)
|
+-- CORS enabled
+-- Swagger docs di /api/docs
+-- Static file serving (/api/uploads)
+-- Domain modules: Desa, PPID, Kesehatan, Ekonomi, dll
+-- Image upload handlers
|
+-- Prisma ORM --> PostgreSQL
+-- Seafile API --> File Storage
```
### Key Architectural Decisions:
1. **Next.js 15 App Router**: Menggunakan React Server Components sebagai default, dengan `"use client"` untuk interaktivitas
2. **Elysia.js di dalam API Routes**: Catch-all route `[[...slugs]]` meneruskan semua request ke Elysia handler
3. **Route Groups**: `(dashboard)` dan `(pages)` untuk organisasi tanpa mempengaruhi URL path
4. **Multi-tenant Ready**: Role-based access control dengan dynamic navbar berdasarkan roleId
5. **File Uploads**: Local uploads + Seafile integration untuk distributed storage
---
## 4. Modul Domain
### A. PPID (Pejabat Pengelola Informasi dan Dokumentasi)
**Lokasi**: `src/app/admin/(dashboard)/ppid/` dan `src/app/darmasaba/(pages)/ppid/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| Profil PPID | Profil pejabat pengelola informasi |
| Struktur PPID | Struktur organisasi PPID dengan hierarki |
| Visi & Misi PPID | Visi dan misi PPID desa |
| Daftar Informasi Publik | Katalog informasi publik yang tersedia |
| Dasar Hukum | Regulasi dan dasar hukum PPID |
| Permohonan Informasi Publik | Form permohonan informasi (NIK, kontak, jenis) |
| Permohonan Keberatan | Formulir keberatan informasi |
| Indeks Kepuasan Masyarakat | Survey kepuasan dengan grafik demografis |
### B. Desa (Landing Page & Umum)
**Lokasi**: `src/app/admin/(dashboard)/desa/` dan `src/app/darmasaba/(pages)/desa/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| Profil Desa | Sejarah, visi-misi, lambang, maskot |
| Profil Perbekel | Biodata, pengalaman, program unggulan perbekel |
| Perbekel dari Masa ke Masa | Historis perbekel per periode |
| Berita | Artikel berita dengan kategori & multi-image |
| Gallery | Foto dan video galeri |
| Pengumuman | Pengumuman desa dengan kategori |
| Potensi Desa | Potensi desa dengan kategori |
| Layanan Desa | Surat keterangan, ajukan permohonan |
| Penghargaan | Prestasi dan penghargaan desa |
| Desa Anti Korupsi | Transparansi anti-korupsi |
| SDGs Desa | Sustainable Development Goals desa |
| APBDes | Anggaran desa dengan hierarki item & realisasi |
| Prestasi Desa | Katalog prestasi |
### C. Kesehatan
**Lokasi**: `src/app/admin/(dashboard)/kesehatan/` dan `src/app/darmasaba/(pages)/kesehatan/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| Fasilitas Kesehatan | Info rumah sakit/klinik (jam, dokter, tarif) |
| Puskesmas | Data puskesmas dengan jam operasional & kontak |
| Posyandu | Jadwal dan informasi posyandu |
| Program Kesehatan | Program-program kesehatan desa |
| Penanganan Darurat | Prosedur penanganan darurat |
| Kontak Darurat | Kontak emergency dengan WhatsApp |
| Info Wabah Penyakit | Informasi wabah penyakit |
| Artikel Kesehatan | Artikel kesehatan lengkap |
| Data Kesehatan Warga | Statistik kesehatan warga |
| Kelahiran & Kematian | Data vital statistik |
| Grafik Kepuasan | Grafik kepuasan layanan kesehatan |
### D. Ekonomi
**Lokasi**: `src/app/admin/(dashboard)/ekonomi/` dan `src/app/darmasaba/(pages)/ekonomi/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| Pasar Desa | Katalog pasar desa dengan produk & rating |
| Struktur BUMDes | Organisasi BUMDes dengan pengurus |
| APBDes (PADesa) | Pendapatan Asli Desa |
| Program Kemiskinan | Program dan statistik kemiskinan |
| Sektor Unggulan | Sektor ekonomi unggulan desa |
| Lowongan Kerja Lokal | Info lowongan pekerjaan |
| Demografi Pekerjaan | Distribusi pekerjaan penduduk |
| Jumlah Pengangguran | Statistik pengangguran |
| Penduduk Usia Kerja Menganggur | Analisis pengangguran by usia & pendidikan |
| Jumlah Penduduk Miskin | Tren kemiskinan tahunan |
### E. Kependudukan
**Lokasi**: `src/app/admin/(dashboard)/kependudukan/` dan `src/app/darmasaba/(pages)/kependudukan/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| Data Banjar | Data penduduk per banjar |
| Distribusi Agama | Statistik agama penduduk |
| Distribusi Umur | Piramida umur penduduk |
| Migrasi Penduduk | Data migrasi masuk/keluar |
| Dinamika Penduduk | Kelahiran, kematian, migrasi per tahun |
### F. Pendidikan
**Lokasi**: `src/app/admin/(dashboard)/pendidikan/` dan `src/app/darmasaba/(pages)/pendidikan/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| Info Sekolah & PAUD | Data sekolah per jenjang (TK, SD, SMP, SMA) |
| Beasiswa Desa | Program beasiswa & pendaftar |
| Program Pendidikan Anak | Program pendidikan anak |
| Bimbingan Belajar | Informasi bimbingan belajar |
| Pendidikan Non Formal | Tempat & program non-formal |
| Perpustakaan Digital | Katalog buku & peminjaman |
| Data Pendidikan | Statistik pendidikan |
### G. Keamanan
**Lokasi**: `src/app/admin/(dashboard)/keamanan/` dan `src/app/darmasaba/(pages)/keamanan/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| Keamanan Lingkungan (Pecalang/Patwal) | Sistem keamanan tradisional Bali |
| Polsek Terdekat | Data polsek dengan layanan & map |
| Kontak Darurat | Kontak darurat keamanan |
| Pencegahan Kriminalitas | Info pencegahan kriminal |
| Laporan Publik | Laporan masyarakat dengan tracking status |
| Tips Keamanan | Tips dan panduan keamanan |
### H. Lingkungan
**Lokasi**: `src/app/admin/(dashboard)/lingkungan/` dan `src/app/darmasaba/(pages)/lingkungan/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| Pengelolaan Sampah | Bank sampah & pengelolaan |
| Program Penghijauan | Program penghijauan desa |
| Data Lingkungan | Data lingkungan desa |
| Gotong Royong | Kegiatan gotong royong |
| Edukasi Lingkungan | Edukasi lingkungan hidup |
| Konservasi Adat Bali | Tri Hita Karana & konservasi adat |
### I. Inovasi
**Lokasi**: `src/app/admin/(dashboard)/inovasi/` dan `src/app/darmasaba/(pages)/inovasi/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| Desa Digital (Smart Village) | Transformasi digital desa |
| Program Kreatif Desa | Program kreatif & inovatif |
| Kolaborasi Inovasi | Kolaborasi dengan mitra |
| Info Teknologi Tepat Guna | Info teknologi untuk desa |
| Ajukan Ide Inovatif | Form pengajuan ide dari warga |
| Layanan Online Desa | Layanan administrasi online |
### J. Musik Desa
**Lokasi**: `src/app/admin/(dashboard)/musik/` dan `src/app/darmasaba/(pages)/musik/`
Model `MusikDesa` dengan audio file, cover image, genre, dan durasi. Dilengkapi dengan `FixedPlayerBar` di layout publik.
### K. User & Role (Admin)
**Lokasi**: `src/app/admin/(dashboard)/user&role/`
- **Role-based Access Control**: Role dengan permission JSON
- **User Session Management**: Multiple sessions per user dengan JWT
- **OTP Authentication**: Login dengan nomor telepon + OTP
- **Menu Access Control**: Dynamic navbar berdasarkan menu akses user
---
## 5. Database Schema (Prisma)
Schema terdiri dari **2413 baris** dengan **100+ model** dan **berbagai enum**. Berikut model-model utama:
### Core Models
| Model | Keterangan |
|-------|-----------|
| `FileStorage` | Central file storage untuk semua uploaded files |
| `AppMenu` / `AppMenuChild` | Menu navigasi aplikasi |
| `User` / `Role` / `UserSession` / `UserMenuAccess` | Sistem autentikasi & otorisasi |
| `KodeOtp` | OTP codes untuk login |
### Landing Page & Desa
| Model | Keterangan |
|-------|-----------|
| `PejabatDesa` | Pejabat desa dengan foto |
| `ProfilPerbekel` | Profil perbekel (biodata, pengalaman, program) |
| `PerbekelDariMasaKeMasa` | Historis perbekel |
| `Berita` / `KategoriBerita` | Berita desa |
| `PotensiDesa` / `KategoriPotensi` | Potensi desa |
| `Pengumuman` / `CategoryPengumuman` | Pengumuman |
| `GalleryFoto` / `GalleryVideo` | Gallery media |
| `Penghargaan` | Penghargaan desa |
| `APBDes` / `APBDesItem` / `RealisasiItem` | Anggaran dengan realisasi |
| `DesaAntiKorupsi` / `KategoriDesaAntiKorupsi` | Transparansi |
| `SdgsDesa` | SDGs desa |
| `PrestasiDesa` / `KategoriPrestasiDesa` | Prestasi |
| `MusikDesa` | Musik desa |
### PPID
| Model | Keterangan |
|-------|-----------|
| `StrukturPPID` / `PosisiOrganisasiPPID` / `PegawaiPPID` | Struktur organisasi |
| `VisiMisiPPID` | Visi misi |
| `ProfilePPID` | Profil pejabat |
| `DasarHukumPPID` | Regulasi |
| `DaftarInformasiPublik` | Katalog informasi |
| `PermohonanInformasiPublik` | Permohonan + lookup tables |
| `FormulirPermohonanKeberatan` | Keberatan |
| `IndeksKepuasanMasyarakat` + grafik breakdown | Survey kepuasan |
### Kesehatan
| Model | Keterangan |
|-------|-----------|
| `FasilitasKesehatan` | Fasilitas lengkap (dokter, tarif, prosedur) |
| `Puskesmas` / `JamOperasional` / `KontakPuskesmas` | Puskesmas |
| `Posyandu` | Pos pelayanan terpadu |
| `ProgramKesehatan` | Program kesehatan |
| `ArtikelKesehatan` | Artikel lengkap (gejala, pencegahan, P3K, dll) |
| `PenangananDarurat` / `KontakDarurat` | Darurat |
| `InfoWabahPenyakit` | Wabah |
| `DataKematian_Kelahiran` / `Kelahiran` / `Kematian` | Vital statistik |
| `GrafikKepuasan` | Kepuasan |
### Ekonomi
| Model | Keterangan |
|-------|-----------|
| `PasarDesa` / `KategoriProduk` / `KategoriToPasar` | Pasar desa |
| `StrukturBumDes` / `PosisiOrganisasiBumDes` / `PegawaiBumDes` | BUMDes |
| `ProgramKemiskinan` / `StatistikKemiskinan` | Kemiskinan |
| `SektorUnggulanDesa` | Sektor unggulan |
| `LowonganPekerjaan` | Lowongan |
| `DataDemografiPekerjaan` | Demografi pekerjaan |
| `DetailDataPengangguran` | Pengangguran |
| `GrafikJumlahPendudukMiskin` | Tren kemiskinan |
### Kependudukan
| Model | Keterangan |
|-------|-----------|
| `DataBanjar` | Data per banjar |
| `DistribusiAgama` | Distribusi agama |
| `DistribusiUmur` | Distribusi umur |
| `MigrasiPenduduk` | Migrasi (MASUK/KELUAR) |
| `DinamikaPenduduk` | Dinamika tahunan |
### Pendidikan
| Model | Keterangan |
|-------|-----------|
| `JenjangPendidikan` / `Lembaga` / `Siswa` / `Pengajar` | Data sekolah |
| `BeasiswaPendaftar` | Beasiswa (dengan enum lengkap) |
| `DataPerpustakaan` / `KategoriBuku` / `PeminjamanBuku` | Perpustakaan |
| `DataPendidikan` | Statistik |
### Keamanan
| Model | Keterangan |
|-------|-----------|
| `KeamananLingkungan` | Keamanan lingkungan |
| `PolsekTerdekat` / `LayananPolsek` / `LayananToPolsek` | Polsek |
| `KontakDaruratKeamanan` / `KontakItem` | Kontak darurat |
| `PencegahanKriminalitas` | Pencegahan |
| `LaporanPublik` / `PenangananLaporanPublik` (enum StatusLaporan) | Laporan |
| `Pelapor` | Pelapor |
| `MenuTipsKeamanan` | Tips |
### Lingkungan
| Model | Keterangan |
|-------|-----------|
| `PengelolaanSampah` | Pengelolaan sampah |
| `KeteranganBankSampahTerdekat` | Bank sampah |
| `ProgramPenghijauan` | Penghijauan |
| `DataLingkunganDesa` | Data lingkungan |
| `KegiatanDesa` / `KategoriKegiatan` | Gotong royong |
| `FilosofiTriHita` / `BentukKonservasiBerdasarkanAdat` | Konservasi Bali |
### Inovasi
| Model | Keterangan |
|-------|-----------|
| `DesaDigital` | Smart village |
| `ProgramKreatif` | Program kreatif |
| `KolaborasiInovasi` / `MitraKolaborasi` | Kolaborasi |
| `InfoTekno` | Teknologi tepat guna |
| `AjukanIdeInovatif` | Ide dari warga |
| `AdministrasiOnline` / `JenisLayanan` | Layanan online |
| `PengaduanMasyarakat` / `JenisPengaduan` | Pengaduan |
---
## 6. API Routes
Semua API ditangani oleh **Elysia.js** di `src/app/api/[[...slugs]]/route.ts`:
| Endpoint Group | Prefix | Deskripsi |
|---------------|--------|-----------|
| **File Storage** | `/api/file-storage` | CRUD file storage |
| **Landing Page** | `/api/landing-page` | Profil, prestasi, anti-korupsi, SDGs, APBDes |
| **Desa** | `/api/desa` | Berita, gallery, potensi, pengumuman, layanan |
| **PPID** | `/api/ppid` | Semua endpoint PPID |
| **Kesehatan** | `/api/kesehatan` | Fasilitas, puskesmas, posyandu, artikel, wabah |
| **Ekonomi** | `/api/ekonomi` | Pasar desa, BUMDes, APBDes, pengangguran |
| **Keamanan** | `/api/keamanan` | Keamanan, polsek, laporan, kriminalitas |
| **Lingkungan** | `/api/lingkungan` | Sampah, penghijauan, gotong royong |
| **Pendidikan** | `/api/pendidikan` | Sekolah, beasiswa, perpustakaan |
| **Kependudukan** | `/api/kependudukan` | Banjar, agama, umur, migrasi |
| **Inovasi** | `/api/inovasi` | Desa digital, kolaborasi, pengaduan |
| **User** | `/api/admin/user` | CRUD user |
| **Role** | `/api/admin/role` | CRUD role |
| **Search** | `/api/search` | Global search |
| **Utils** | `/api/utils/version` | Version info |
### Utility Endpoints
| Endpoint | Method | Deskripsi |
|----------|--------|-----------|
| `/api/img/:name` | GET | Serve image dengan resize |
| `/api/img/:name` | DELETE | Delete image |
| `/api/imgs` | GET | List images dengan pagination |
| `/api/upl-img` | POST | Upload multiple images |
| `/api/upl-img-single` | POST | Upload single image |
| `/api/upl-csv` | POST | Upload CSV multiple |
| `/api/upl-csv-single` | POST | Upload single CSV |
### Auth Endpoints
| Endpoint | Method | Deskripsi |
|----------|--------|-----------|
| `/api/auth/login` | POST | Login dengan OTP |
| `/api/auth/logout` | POST | Logout |
| `/api/auth/me` | GET | Get current user |
**Swagger Documentation**: Tersedia di `/api/docs`
---
## 7. Halaman Admin
Admin dashboard menggunakan **Mantine AppShell** dengan sidebar navigasi dinamis berbasis role.
### Route Group: `/admin`
| Section | Path | Deskripsi |
|---------|------|-----------|
| **Landing Page** | `/admin/landing-page/` | Profil desa, prestasi, anti-korupsi, SDGs, media sosial |
| **Desa** | `/admin/desa/` | Berita, gallery, layanan, penghargaan, pengumuman, potensi, profil |
| **PPID** | `/admin/ppid/` | 8 sub-modul PPID lengkap |
| **Kesehatan** | `/admin/kesehatan/` | 8 sub-modul kesehatan |
| **Ekonomi** | `/admin/ekonomi/` | 10 sub-modul ekonomi |
| **Kependudukan** | `/admin/kependudukan/` | 4 sub-modul kependudukan |
| **Pendidikan** | `/admin/pendidikan/` | 7 sub-modul pendidikan |
| **Keamanan** | `/admin/keamanan/` | 6 sub-modul keamanan |
| **Lingkungan** | `/admin/lingkungan/` | 6 sub-modul lingkungan |
| **Inovasi** | `/admin/inovasi/` | 6 sub-modul inovasi |
| **Musik** | `/admin/musik/` | Manajemen musik desa |
| **User & Role** | `/admin/user&role/` | Manajemen user, role, menu access |
### Fitur Admin:
- **Role-based Dynamic Navbar**: Navbar berubah berdasarkan roleId user
- **Dark Mode Toggle**: Tema gelap/terang
- **OTP Login**: Login dengan nomor telepon + kode OTP
- **Session Management**: Multiple sessions per user dengan JWT tokens
- **CSV Upload**: Import data via CSV
- **Image Upload**: Upload dengan preview dan management
- **Rich Text Editor**: Tiptap untuk konten HTML
### Role-Based Redirect:
| roleId | Role | Default Redirect |
|--------|------|-----------------|
| 0, 1, 2 | Super Admin / Admin Desa | `/admin/landing-page/profil/program-inovasi` |
| 3 | Admin Kesehatan | `/admin/kesehatan/posyandu` |
| 4 | Admin Pendidikan | `/admin/pendidikan/info-sekolah/jenjang-pendidikan` |
---
## 8. Halaman Publik
Public website di `/darmasaba/` dengan layout yang mencakup **Navbar**, **Footer**, dan **Fixed Music Player Bar**.
### Route Group: `/darmasaba`
| Section | Path | Deskripsi |
|---------|------|-----------|
| **Home** | `/darmasaba` | Landing page utama |
| **Desa** | `/darmasaba/desa` | Profil, berita, gallery, layanan, pengumuman, potensi |
| **PPID** | `/darmasaba/ppid` | 7 sub-halaman PPID publik |
| **Kesehatan** | `/darmasaba/kesehatan` | Info kesehatan publik |
| **Ekonomi** | `/darmasaba/ekonomi` | Info ekonomi desa |
| **Kependudukan** | `/darmasaba/kependudukan` | Data kependudukan |
| **Pendidikan** | `/darmasaba/pendidikan` | Info pendidikan |
| **Keamanan** | `/darmasaba/keamanan` | Info keamanan |
| **Lingkungan** | `/darmasaba/lingkungan` | Info lingkungan |
| **Inovasi** | `/darmasaba/inovasi` | Info inovasi |
| **Musik** | `/darmasaba/musik` | Musik desa |
| **Module** | `/darmasaba/module/*` | Link ke modul eksternal (DAVES, MANGAN, Bicara-Darma, BARES, dll) |
### Fitur Publik:
- **Fixed Music Player Bar**: Player musik yang selalu tampil di bottom
- **Global Search**: Pencarian global
- **News Reader**: Notifikasi berita modern
- **View Transitions**: Smooth page transitions
- **Responsive Design**: Mobile-first dengan Mantine breakpoints
---
## 9. Komponen Utama
### Admin Components (`src/components/admin/`)
| Komponen | Deskripsi |
|----------|-----------|
| `AdminThemeProvider.tsx` | Theme provider untuk admin |
| `DarkModeToggle.tsx` | Toggle dark/light mode |
| `UnifiedSurface.tsx` | Consistent surface/card component |
| `UnifiedTypography.tsx` | Consistent typography system |
### Public Shared Components (`src/app/darmasaba/_com/`)
| Komponen | Deskripsi |
|----------|-----------|
| `Navbar.tsx` | Main navigation bar |
| `NavbarMainMenu.tsx` | Main menu dengan kategori |
| `NavbarSubMenu.tsx` | Submenu dropdown |
| `Footer.tsx` | Footer dengan info desa |
| `FixedPlayerBar.tsx` | Music player bar fixed di bottom |
| `LoadDataFirstClient.tsx` | Client-side data preloader |
| `globalSearch.tsx` | Global search component |
| `NewsReader.tsx` | News notification reader |
| `ModernNewsNotification.tsx` | News toast notifications |
### Global Components (`src/app/_com/`)
| Komponen | Deskripsi |
|----------|-----------|
| `SpashScreen.tsx` | Splash screen on load |
| `WebVitals.tsx` | Web Vitals monitoring |
---
## 10. State Management
Proyek menggunakan **multi-layer state management**:
| Library | Penggunaan | Lokasi |
|---------|-----------|--------|
| **Jotai** | Auth state (`authStore`) | `src/store/authStore.ts` |
| **Valtio** | Dark mode, layanan, image list, nav state | `src/state/*.ts` |
| **SWR** | Server state fetching & caching | Digunakan di components |
| **React Context** | Music player context | `src/app/context/MusicContext.tsx` |
| **React useState** | Local component state | Di components |
### State Files:
```
src/state/
darkModeStore.ts -- Valtio proxy untuk dark mode
state-layanan.ts -- State layanan desa
state-list-image.ts -- State list image untuk upload
state-nav.ts -- State navigasi
src/store/
authStore.ts -- Jotai atom untuk auth user state
```
---
## 11. Autentikasi
Sistem autentikasi menggunakan **OTP (One-Time Password)** via WhatsApp/Telepon dengan **iron-session** untuk session management.
### Flow Autentikasi:
1. User memasukkan **nomor telepon** di `/login`
2. Sistem mengirim **kode OTP** via WhatsApp Server
3. OTP disimpan di model `KodeOtp`
4. User memasukkan kode OTP
5. Jika valid, session dibuat dengan **iron-session** + **JWT token**
6. Session disimpan di `UserSession` model dengan expiry
### Session Structure:
```typescript
// src/lib/session.ts
type SessionData = {
user?: {
id: string;
name: string;
roleId: number;
menuIds?: string[] | null;
isActive?: boolean;
};
};
```
### Role-Based Access:
| roleId | Role | Default Redirect |
|--------|------|-----------------|
| 0, 1, 2 | Super Admin / Admin Desa | `/admin/landing-page/profil/program-inovasi` |
| 3 | Admin Kesehatan | `/admin/kesehatan/posyandu` |
| 4 | Admin Pendidikan | `/admin/pendidikan/info-sekolah/jenjang-pendidikan` |
### Authorization:
- **UserMenuAccess**: Mapping user ke menu yang boleh diakses
- **Dynamic Navbar**: Navbar dirender berdasarkan `menuIds` user
- **Inactive Users**: Dialihkan ke `/waiting-room`
---
## 12. Deployment
### Docker Setup
**Dockerfile** menggunakan **multi-stage build** dengan base image `oven/bun:1-debian`:
```
Stage 1: Builder
- Install dependencies (bun install --frozen-lockfile)
- Generate Prisma client
- Build Next.js (bun run build)
Stage 2: Runner
- Copy .next, node_modules, public, prisma, src/lib, tsconfig.json
- Non-root user (nextjs:nodejs)
- Volume /app/uploads untuk file uploads
- Port 3000
```
### Entry Point (`docker-entrypoint.sh`):
```bash
bunx prisma migrate deploy # Run migrations
exec bun start # Start Next.js production server
```
### CI/CD dengan GitHub Actions
Terdapat **3 workflow**:
| Workflow | Trigger | Fungsi |
|----------|---------|--------|
| `docker-publish.yml` | Push tag `v*` | Auto build & push ke GHCR |
| `publish.yml` | Manual (workflow_dispatch) | Build & push ke GHCR dengan input `stack_env` + `tag` |
| `re-pull.yml` | Manual (workflow_dispatch) | Re-pull image di Portainer dengan input `stack_name` + `stack_env` |
### Deployment Workflow (Sequential):
```
1. Update version di package.json (semver)
2. Commit perubahan
3. Push ke branch target (stg/prod)
4. Trigger publish.yml:
gh workflow run publish.yml --ref main -f stack_env=stg -f tag=<version>
5. Tunggu sampai publish selesai (status: completed)
6. Trigger re-pull.yml:
gh workflow run re-pull.yml --ref main -f stack_name=desa-darmasaba -f stack_env=stg
7. Verifikasi di Portainer
```
**PENTING**: `publish.yml` dan `re-pull.yml` TIDAK boleh dijalankan bersamaan (race condition).
### Environments:
- **dev**: Development
- **stg**: Staging (`desa-darmasaba-stg.wibudev.com`)
- **prod**: Production
### Notification:
- Telegram notification via `notify.sh` script setelah setiap workflow
---
## 13. Scripts
| Script | Command | Deskripsi |
|--------|---------|-----------|
| `dev` | `next dev` | Development server |
| `build` | `next build` | Production build |
| `start` | `next start` | Production server |
| `test:api` | `vitest run` | Run API unit tests |
| `test:e2e` | `playwright test` | Run E2E tests |
| `test` | `bun run test:api && bun run test:e2e` | Run all tests |
| `seed` | `bun run prisma/seed.ts` | Seed database |
| `prisma:generate` | `bunx prisma generate` | Generate Prisma client |
| `prisma:push` | `bunx prisma db push` | Push schema to database |
| `prisma:studio` | `bunx prisma studio` | Open Prisma Studio GUI |
| `gen:api` | *(empty)* | Generate API types (placeholder) |
### Prisma Seed Configuration:
```json
// package.json
{
"prisma": {
"seed": "bun run prisma/seed.ts"
}
}
```
---
## 14. Environment Variables
File: `.env.example`
| Variable | Deskripsi | Contoh |
|----------|-----------|--------|
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/desa-darmasaba` |
| `SEAFILE_TOKEN` | Seafile API token | `your_seafile_token` |
| `SEAFILE_REPO_ID` | Seafile repository ID | `your_repo_id` |
| `SEAFILE_URL` | Seafile instance URL | `https://seafile.example.com` |
| `SEAFILE_PUBLIC_SHARE_TOKEN` | Token untuk public share | `your_share_token` |
| `WIBU_UPLOAD_DIR` | Upload directory path | `uploads` |
| `WA_SERVER_TOKEN` | WhatsApp server token | `your_wa_token` |
| `NEXT_PUBLIC_BASE_URL` | Base URL aplikasi | `/` (relative) |
| `EMAIL_USER` | Email untuk notifikasi | `your_email@gmail.com` |
| `EMAIL_PASS` | Email app password | `your_app_password` |
| `BASE_TOKEN_KEY` | JWT secret key | `your_jwt_secret` |
| `BOT_TOKEN` | Telegram bot token | `your_bot_token` |
| `CHAT_ID` | Telegram chat ID | `your_chat_id` |
| `SESSION_PASSWORD` | iron-session password (min 32 chars) | `secure_32_char_password` |
| `ELEVENLABS_API_KEY` | ElevenLabs API (TTS - optional) | `your_elevenlabs_key` |
---
## 15. Layanan Eksternal
### PostgreSQL
- **Provider**: PostgreSQL via Prisma ORM
- **Schema**: `public`
- **Connection**: Via `DATABASE_URL` environment variable
- **Migrations**: `prisma migrate deploy` di docker entrypoint
### Seafile (File Storage)
- **Tipe**: Self-hosted file sync & share
- **Penggunaan**: Storage untuk images, documents, audio files
- **Integrasi**: `src/lib/seafile-auth-service.ts`
- **CDN**: URL generation untuk public sharing
- **Config**: Token, repo ID, base URL
### WhatsApp Server
- **Penggunaan**: Kirim OTP codes saat login
- **Config**: `WA_SERVER_TOKEN`
### Telegram Bot
- **Penggunaan**: Notifikasi deployment & sistem
- **Config**: `BOT_TOKEN` + `CHAT_ID`
- **Integration**: `notify.sh` script di GitHub Actions
### ElevenLabs (Optional)
- **Penggunaan**: Text-to-Speech (TTS) features
- **Config**: `ELEVENLABS_API_KEY`
### Email (Nodemailer)
- **Penggunaan**: Notifikasi email untuk subscription/pengumuman
- **Config**: `EMAIL_USER` + `EMAIL_PASS`
- **Provider**: Gmail (app password)
---
## Ringkasan Cepat
| Aspek | Detail |
|-------|--------|
| **Framework** | Next.js 15 (App Router) + Elysia.js |
| **Database** | PostgreSQL + Prisma (100+ models) |
| **Auth** | OTP + iron-session + JWT |
| **Storage** | Seafile + local uploads |
| **UI** | Mantine UI + Tiptap + Framer Motion |
| **State** | Jotai + Valtio + SWR |
| **Deploy** | Docker + GHCR + Portainer + GitHub Actions |
| **Runtime** | Bun |
| **Testing** | Vitest + Playwright |
| **Version** | 0.1.11 |

3
ai.sh
View File

@@ -1,3 +0,0 @@
export ANTHROPIC_API_KEY=sk-user-nico
export ANTHROPIC_BASE_URL=https://claude-local.wibudev.com
export ANTHROPIC_MODEL=claude-sonnet-4-6

View File

@@ -1,49 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"experimentalScannerIgnores": [
"node_modules",
".next",
"out",
"public"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedVariables": "warn",
"noUnusedImports": "warn"
},
"suspicious": {
"noExplicitAny": "warn"
},
"style": {
"noNonNullAssertion": "warn"
},
"complexity": {
"noForEach": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "all",
"semicolons": "always"
}
}
}

2302
bun.lock

File diff suppressed because it is too large Load Diff

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,169 +0,0 @@
# 🌙 Dark Mode Design Specification
## Admin Darmasaba Dashboard & CMS
Dokumen ini mendefinisikan standar **Dark Mode UI** agar:
- nyaman di mata
- konsisten
- tidak flat
- tetap profesional untuk aplikasi pemerintahan
---
## 🎨 Color Palette (Dark Mode)
### Background Layers
| Layer | Token | Warna | Fungsi |
|------|------|------|------|
| Base | `--bg-base` | `#0B1220` | Background utama aplikasi |
| App | `--bg-app` | `#0F172A` | Area kerja utama |
| Card | `--bg-card` | `#162235` | Card / container |
| Surface | `--bg-surface` | `#1E2A3D` | Table header, tab, input |
---
### Border & Divider
| Token | Warna | Catatan |
|-----|------|--------|
| `--border-default` | `#2A3A52` | Border utama |
| `--border-soft` | `#22314A` | Divider halus |
> ❗ Hindari border terlalu tipis (`opacity < 20%`)
---
### Text Colors
| Jenis | Token | Warna |
|-----|------|------|
| Primary | `--text-primary` | `#E5E7EB` |
| Secondary | `--text-secondary` | `#9CA3AF` |
| Muted | `--text-muted` | `#6B7280` |
| Inverse | `--text-inverse` | `#020617` |
---
### Accent & Action
| Fungsi | Warna |
|------|------|
| Primary Action | `#3B82F6` |
| Hover | `#2563EB` |
| Active | `#1D4ED8` |
| Link | `#60A5FA` |
---
### Status Colors
| Status | Warna |
|------|------|
| Success | `#22C55E` |
| Warning | `#FACC15` |
| Error | `#EF4444` |
| Info | `#38BDF8` |
---
## 🧱 Layout Rules
### Sidebar
- Background: `--bg-app`
- Active menu:
- Background: `rgba(59,130,246,0.15)`
- Text: Primary
- Indicator: kiri (23px accent bar)
- Hover:
- Background: `rgba(255,255,255,0.04)`
---
### Header / Topbar
- Background: `linear-gradient(#0F172A → #0B1220)`
- Border bawah wajib (`--border-soft`)
- Icon:
- Default: muted
- Hover: primary
---
## 📦 Card & Section
### Card
- Background: `--bg-card`
- Border: `--border-default`
- Radius: 1216px
- Jangan pakai shadow hitam
### Section Header
- Font weight lebih besar
- Text: primary
- Spacing jelas dari konten
---
## 📊 Table (Dark Mode Friendly)
### Table Header
- Background: `--bg-surface`
- Text: secondary
- Font weight: medium
### Table Row
- Default: transparent
- Hover:
- Background: `rgba(255,255,255,0.03)`
- Divider antar row wajib terlihat
### Link di Table
- Warna link **lebih terang dari text**
- Hover underline
---
## 🔘 Button Rules
### Primary Button
- Background: Primary Action
- Text: Inverse
- Hover: darker shade
### Secondary Button
- Background: transparent
- Border: `--border-default`
- Text: primary
### Icon Button
- Default: muted
- Hover: primary + bg soft
---
## 🧭 Tab Navigation
- Inactive:
- Text: muted
- Active:
- Background: `rgba(59,130,246,0.15)`
- Text: primary
- Icon ikut berubah
---
## 🌗 Dark vs Light Mode Rule
- Layout, spacing, typography **HARUS SAMA**
- Yang boleh beda:
- warna
- border intensity
- background layer
> ❌ Jangan ganti struktur UI antara dark & light
---
## ✅ Dark Mode Checklist
- [ ] Kontras teks terbaca
- [ ] Active state jelas
- [ ] Hover terasa hidup
- [ ] Tidak flat
- [ ] Tidak silau
---
Dokumen ini adalah **single source of truth** untuk Dark Mode.

View File

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

View File

@@ -1,13 +0,0 @@
#!/bin/bash
set -e
echo "🔄 Running database migrations..."
cd /app
bunx prisma migrate deploy || {
echo "❌ Migration failed!"
exit 1
}
echo "✅ Migrations completed successfully"
echo "🚀 Starting application..."
exec bun start

File diff suppressed because one or more lines are too long

View File

@@ -11,11 +11,6 @@ const compat = new FlatCompat({
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"@typescript-eslint/no-explicit-any": "warn",
},
},
];
export default eslintConfig;

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "desa-darmasaba",
"version": "0.1.23",
"version": "0.1.5",
"private": true,
"scripts": {
"dev": "next dev",
@@ -8,8 +8,7 @@
"start": "next start",
"test:api": "vitest run",
"test:e2e": "playwright test",
"test": "bun run test:api && bun run test:e2e",
"gen:api": ""
"test": "bun run test:api && bun run test:e2e"
},
"prisma": {
"seed": "bun run prisma/seed.ts"
@@ -34,7 +33,7 @@
"@mantine/modals": "^8.3.6",
"@mantine/tiptap": "^7.17.4",
"@paljs/types": "^8.1.0",
"@prisma/client": "6.3.1",
"@prisma/client": "^6.3.1",
"@tabler/icons-react": "^3.30.0",
"@tiptap/extension-highlight": "^2.11.7",
"@tiptap/extension-link": "^2.11.7",
@@ -63,7 +62,6 @@
"colors": "^1.4.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"dompurify": "^3.3.1",
"dotenv": "^17.2.3",
"elysia": "^1.3.5",
"embla-carousel": "^8.6.0",
@@ -71,7 +69,7 @@
"embla-carousel-react": "^8.6.0",
"extract-zip": "^2.0.1",
"form-data": "^4.0.2",
"framer-motion": "^12.38.0",
"framer-motion": "^12.23.5",
"get-port": "^7.1.0",
"iron-session": "^8.0.4",
"jose": "^6.1.0",
@@ -81,7 +79,6 @@
"list": "^2.0.19",
"lodash": "^4.17.21",
"mime-types": "^3.0.2",
"minio": "^8.0.7",
"motion": "^12.4.1",
"nanoid": "^5.1.5",
"next": "^15.5.2",
@@ -91,7 +88,7 @@
"p-limit": "^6.2.0",
"primeicons": "^7.0.0",
"primereact": "^10.9.6",
"prisma": "6.3.1",
"prisma": "^6.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-exif-orientation-img": "^0.1.5",
@@ -102,7 +99,7 @@
"react-transition-group": "^4.4.5",
"react-zoom-pan-pinch": "^3.7.0",
"readdirp": "^4.1.1",
"recharts": "^3.8.0",
"recharts": "^2.15.3",
"sharp": "^0.34.3",
"swr": "^2.3.2",
"uuid": "^11.1.0",
@@ -115,23 +112,20 @@
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.9.1",
"@types/cli-progress": "^3.11.6",
"@types/dompurify": "^3.2.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/ui": "^4.0.18",
"eslint": "^9",
"eslint-config-next": "15.5.12",
"eslint-config-next": "15.1.6",
"jsdom": "^28.0.0",
"msw": "^2.12.9",
"parcel": "^2.6.2",
"playwright-mcp": "^0.0.19",
"postcss": "^8.5.1",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"typescript": "^5",
"vitest": "^4.0.18"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
}

View File

@@ -1,57 +0,0 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const appMenuJson = loadJsonData("core/app-menu.json");
const appMenuChildJson = loadJsonData("core/app-menu-child.json");
export async function seedAppMenu() {
console.log("🔄 Seeding AppMenu...");
for (const item of appMenuJson) {
await prisma.appMenu.upsert({
where: { id: item.id },
update: {
name: item.name,
link: item.link,
isActive: item.isActive,
},
create: {
id: item.id,
name: item.name,
link: item.link,
isActive: item.isActive,
},
});
console.log(`✅ AppMenu seeded: ${item.name}`);
}
console.log("🎉 AppMenu seed selesai");
}
export async function seedAppMenuChild() {
console.log("🔄 Seeding AppMenuChild...");
for (const item of appMenuChildJson) {
await prisma.appMenuChild.upsert({
where: { id: item.id },
update: {
name: item.name,
link: item.link,
isActive: item.isActive,
appMenuId: item.appMenuId,
},
create: {
id: item.id,
name: item.name,
link: item.link,
isActive: item.isActive,
appMenuId: item.appMenuId,
},
});
console.log(`✅ AppMenuChild seeded: ${item.name}`);
}
console.log("🎉 AppMenuChild seed selesai");
}

View File

@@ -1,69 +0,0 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const layananJson = loadJsonData("core/layanan.json");
const potensiJson = loadJsonData("core/potensi.json");
const landingPageLayananJson = loadJsonData("core/landingpage-layanan.json");
export async function seedLayananCore() {
console.log("🔄 Seeding Layanan...");
for (const item of layananJson) {
await prisma.layanan.upsert({
where: { id: item.id },
update: {
name: item.name,
},
create: {
id: item.id,
name: item.name,
},
});
console.log(`✅ Layanan seeded: ${item.name}`);
}
console.log("🎉 Layanan seed selesai");
}
export async function seedPotensiCore() {
console.log("🔄 Seeding Potensi...");
for (const item of potensiJson) {
await prisma.potensi.upsert({
where: { id: item.id },
update: {
name: item.name,
},
create: {
id: item.id,
name: item.name,
},
});
console.log(`✅ Potensi seeded: ${item.name}`);
}
console.log("🎉 Potensi seed selesai");
}
export async function seedLandingPageLayanan() {
console.log("🔄 Seeding LandingPage_Layanan...");
for (const item of landingPageLayananJson) {
await prisma.landingPage_Layanan.upsert({
where: { id: item.id },
update: {
deksripsi: item.deksripsi,
},
create: {
id: item.id,
deksripsi: item.deksripsi,
},
});
console.log(`✅ LandingPage_Layanan seeded: ${item.id}`);
}
console.log("🎉 LandingPage_Layanan seed selesai");
}

View File

@@ -1,8 +1,6 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../../load-json";
const kategoriBerita = loadJsonData("desa/berita/kategori-berita.json");
const beritaJson = loadJsonData("desa/berita/berita.json");
import kategoriBerita from "../../../data/desa/berita/kategori-berita.json";
import beritaJson from "../../../data/desa/berita/berita.json";
export async function seedBerita() {
// ================== SUBMENU BERITA ========================
@@ -28,24 +26,7 @@ export async function seedBerita() {
console.log("🔄 Seeding Berita...");
// Build a map of valid kategori IDs
const validKategoriIds = new Set<string>();
const kategoriList = await prisma.kategoriBerita.findMany({
select: { id: true, name: true },
});
kategoriList.forEach((k) => validKategoriIds.add(k.id));
console.log(`📋 Found ${validKategoriIds.size} valid kategori IDs in database`);
for (const b of beritaJson) {
// Validate kategoriBeritaId exists
if (!b.kategoriBeritaId || !validKategoriIds.has(b.kategoriBeritaId)) {
console.warn(
`⚠️ Skipping berita "${b.judul}": Invalid kategoriBeritaId "${b.kategoriBeritaId}"`,
);
continue;
}
let imageId: string | null = null;
if (b.imageName) {
@@ -63,32 +44,26 @@ export async function seedBerita() {
}
}
try {
await prisma.berita.upsert({
where: { id: b.id },
update: {
judul: b.judul,
deskripsi: b.deskripsi,
content: b.content,
kategoriBeritaId: b.kategoriBeritaId,
imageId,
},
create: {
id: b.id,
judul: b.judul,
deskripsi: b.deskripsi,
content: b.content,
kategoriBeritaId: b.kategoriBeritaId,
imageId,
},
});
await prisma.berita.upsert({
where: { id: b.id },
update: {
judul: b.judul,
deskripsi: b.deskripsi,
content: b.content,
kategoriBeritaId: b.kategoriBeritaId,
imageId,
},
create: {
id: b.id,
judul: b.judul,
deskripsi: b.deskripsi,
content: b.content,
kategoriBeritaId: b.kategoriBeritaId,
imageId,
},
});
console.log(`✅ Berita seeded: ${b.judul}`);
} catch (error: any) {
console.error(
`❌ Failed to seed berita "${b.judul}": ${error.message}`,
);
}
console.log(`✅ Berita seeded: ${b.judul}`);
}
console.log("🎉 Berita seed selesai");

View File

@@ -1,7 +1,5 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../../../load-json";
const foto = loadJsonData("desa/gallery/foto/foto.json");
import foto from "../../../../data/desa/gallery/foto/foto.json";
export async function seedFoto() {
console.log("🔄 Seeding Foto...");

View File

@@ -1,7 +1,5 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../../../load-json";
const galleryVideo = loadJsonData("desa/gallery/video/video.json");
import galleryVideo from "../../../../data/desa/gallery/video/video.json";
export async function seedVideo() {
console.log("🔄 Seeding Gallery Video...");

View File

@@ -1,10 +1,8 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../../load-json";
const pelayananSuratKeterangan = loadJsonData("desa/layanan/pelayananSuratKeterangan.json");
const pelayananTelunjukSaktiDesa = loadJsonData("desa/layanan/pelayananTelunjukSaktiDesa.json");
const pelayananPerizinanBerusaha = loadJsonData("desa/layanan/pelayananPerizinanBerusaha.json");
const pelayananPendudukNonPermanen = loadJsonData("desa/layanan/pelayananPendudukNonPermanen.json");
import pelayananSuratKeterangan from "../../../data/desa/layanan/pelayananSuratKeterangan.json";
import pelayananTelunjukSaktiDesa from "../../../data/desa/layanan/pelayananTelunjukSaktiDesa.json";
import pelayananPerizinanBerusaha from "../../../data/desa/layanan/pelayananPerizinanBerusaha.json";
import pelayananPendudukNonPermanen from "../../../data/desa/layanan/pelayananPendudukNonPermanen.json";
export async function seedLayanan() {
console.log("🔄 Seeding Pelayanan Surat Keterangan...");

View File

@@ -1,57 +0,0 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../../load-json";
const musikJson = loadJsonData("desa/musik-desa/musik-desa.json");
export async function seedMusikDesa() {
console.log("Seeding Musik Desa...");
for (const item of musikJson) {
let audioFileId: string | null = null;
let coverImageId: string | null = null;
if (item.audioFileName) {
const audio = await prisma.fileStorage.findUnique({
where: { name: item.audioFileName },
select: { id: true },
});
if (audio) audioFileId = audio.id;
}
if (item.coverImageName) {
const cover = await prisma.fileStorage.findUnique({
where: { name: item.coverImageName },
select: { id: true },
});
if (cover) coverImageId = cover.id;
}
await prisma.musikDesa.upsert({
where: { id: item.id },
update: {
judul: item.judul,
artis: item.artis,
deskripsi: item.deskripsi,
durasi: item.durasi,
audioFileId,
coverImageId,
genre: item.genre,
tahunRilis: item.tahunRilis,
},
create: {
id: item.id,
judul: item.judul,
artis: item.artis,
deskripsi: item.deskripsi,
durasi: item.durasi,
audioFileId,
coverImageId,
genre: item.genre,
tahunRilis: item.tahunRilis,
},
});
console.log(` Musik: ${item.judul} - ${item.artis}`);
}
console.log("Musik Desa seed selesai");
}

View File

@@ -1,7 +1,5 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../../load-json";
const penghargaan = loadJsonData("desa/penghargaan/penghargaan.json");
import penghargaan from "../../../data/desa/penghargaan/penghargaan.json"
export async function seedPenghargaan() {
console.log("🔄 Seeding Penghargaan...");

View File

@@ -1,9 +1,7 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../../load-json";
import { safeSeedUnique } from "../../../safeseedUnique";
const kategoriPengumuman = loadJsonData("desa/pengumuman/kategori-pengumuman.json");
const pengumuman = loadJsonData("desa/pengumuman/pengumuman.json");
import kategoriPengumuman from "../../../data/desa/pengumuman/kategori-pengumuman.json";
import pengumuman from "../../../data/desa/pengumuman/pengumuman.json";
export async function seedPengumuman() {
console.log("🔄 Seeding Kategori Pengumuman...");

View File

@@ -1,8 +1,6 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../../load-json";
const kategoriPotensi = loadJsonData("desa/potensi/kategori-potensi.json");
const potensiDesa = loadJsonData("desa/potensi/potensi-desa.json");
import kategoriPotensi from "../../../data/desa/potensi/kategori-potensi.json";
import potensiDesa from "../../../data/desa/potensi/potensi-desa.json";
export async function seedPotensi() {
console.log("🔄Seeding Kategori Potensi Desa ...");

View File

@@ -1,12 +1,10 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../../load-json";
const lambangDesa = loadJsonData("desa/profile/lambang_desa.json");
const maskotDesa = loadJsonData("desa/profile/maskot_desa.json");
const profilePerbekel = loadJsonData("desa/profile/profil_perbekel.json");
const profileDesaImage = loadJsonData("desa/profile/profileDesaImage.json");
const sejarahDesa = loadJsonData("desa/profile/sejarah_desa.json");
const visiMisiDesa = loadJsonData("desa/profile/visi_misi_desa.json");
import lambangDesa from "../../../data/desa/profile/lambang_desa.json";
import maskotDesa from "../../../data/desa/profile/maskot_desa.json";
import profilePerbekel from "../../../data/desa/profile/profil_perbekel.json";
import profileDesaImage from "../../../data/desa/profile/profileDesaImage.json";
import sejarahDesa from "../../../data/desa/profile/sejarah_desa.json";
import visiMisiDesa from "../../../data/desa/profile/visi_misi_desa.json";
export async function seedProfileDesa() {
// =========== SEJARAH DESA ===========

View File

@@ -1,7 +1,5 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../../load-json";
const perbekelDariMasaKeMasa = loadJsonData("desa/profile/profile-perbekel-lalu.json");
import perbekelDariMasaKeMasa from "../../../data/desa/profile/profile-perbekel-lalu.json";
export async function seedProfilePerbekel() {
console.log("🔄 Seeding Perbekel Dari Masa Ke Masa...");

View File

@@ -1,45 +0,0 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const apbdesJson = loadJsonData("ekonomi/apbdes/apbdes.json");
export async function seedAPBDes() {
console.log("Seeding APBDes...");
for (const item of apbdesJson) {
let imageId: string | null = null;
let fileId: string | null = null;
if (item.imageName) {
const image = await prisma.fileStorage.findUnique({
where: { name: item.imageName },
select: { id: true },
});
if (image) imageId = image.id;
}
await prisma.aPBDes.upsert({
where: { id: item.id },
update: {
tahun: item.tahun,
name: item.name,
deskripsi: item.deskripsi,
jumlah: item.jumlah,
imageId,
fileId,
},
create: {
id: item.id,
tahun: item.tahun,
name: item.name,
deskripsi: item.deskripsi,
jumlah: item.jumlah,
imageId,
fileId,
},
});
console.log(` APBDes: ${item.name}`);
}
console.log("APBDes seed selesai");
}

View File

@@ -1,63 +0,0 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const itemsJson = loadJsonData("ekonomi/apbdes/apbdes-items.json");
const realisasiJson = loadJsonData("ekonomi/apbdes/realisasi-items.json");
export async function seedAPBDesItem() {
console.log("Seeding APBDes Items...");
// Seed items first (sorted by level to ensure parents exist)
const sortedItems = [...itemsJson].sort((a, b) => a.level - b.level);
for (const item of sortedItems) {
await prisma.aPBDesItem.upsert({
where: { id: item.id },
update: {
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
tipe: item.tipe,
level: item.level,
parentId: item.parentId,
apbdesId: item.apbdesId,
},
create: {
id: item.id,
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
tipe: item.tipe,
level: item.level,
parentId: item.parentId,
apbdesId: item.apbdesId,
},
});
console.log(` APBDes Item: ${item.kode} - ${item.uraian}`);
}
console.log("Seeding Realisasi Items...");
for (const item of realisasiJson) {
await prisma.realisasiItem.upsert({
where: { id: item.id },
update: {
kode: item.kode,
apbdesItemId: item.apbdesItemId,
jumlah: item.jumlah,
tanggal: new Date(item.tanggal),
keterangan: item.keterangan,
},
create: {
id: item.id,
kode: item.kode,
apbdesItemId: item.apbdesItemId,
jumlah: item.jumlah,
tanggal: new Date(item.tanggal),
keterangan: item.keterangan,
},
});
console.log(` Realisasi: ${item.kode} - Rp ${item.jumlah.toLocaleString("id-ID")}`);
}
console.log("APBDes Item & Realisasi seed selesai");
}

View File

@@ -1,7 +1,5 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const demografiPekerjaan = loadJsonData("ekonomi/demografi-pekerjaan/demografi-pekerjaan.json");
import demografiPekerjaan from "../../data/ekonomi/demografi-pekerjaan/demografi-pekerjaan.json";
export async function seedDemografiPekerjaan() {
console.log("🔄 Seeding Demografi Pekerjaan...");

View File

@@ -1,7 +1,5 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const jumlahPendudukMiskin = loadJsonData("ekonomi/jumlah-penduduk-miskin/jumlah-penduduk-miskin.json");
import jumlahPendudukMiskin from "../../data/ekonomi/jumlah-penduduk-miskin/jumlah-penduduk-miskin.json";
export async function seedJumlahPendudukMiskin() {
console.log("🔄 Seeding Jumlah Penduduk Miskin...");

View File

@@ -1,7 +1,5 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const jumlahPengangguran = loadJsonData("ekonomi/jumlah-pengangguran/detail-data-pengangguran.json");
import jumlahPengangguran from "../../data/ekonomi/jumlah-pengangguran/detail-data-pengangguran.json";
export async function seedJumlahPengangguran() {
for (const d of jumlahPengangguran) {

View File

@@ -1,7 +1,5 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const lowonganKerjaLokal = loadJsonData("ekonomi/lowongan-kerja-lokal/lowongan-kerja-lokal.json");
import lowonganKerjaLokal from "../../data/ekonomi/lowongan-kerja-lokal/lowongan-kerja-lokal.json";
export async function seedLowonganKerjaLokal() {
console.log("🔄 Seeding Lowongan Kerja Lokal...");

View File

@@ -1,9 +1,7 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const kategoriProduk = loadJsonData("ekonomi/pasar-desa/kategori-produk.json");
const pasarDesa = loadJsonData("ekonomi/pasar-desa/pasar-desa.json");
const kategoriToPasar = loadJsonData("ekonomi/pasar-desa/kategori-to-pasar.json");
import kategoriProduk from "../../data/ekonomi/pasar-desa/kategori-produk.json";
import pasarDesa from "../../data/ekonomi/pasar-desa/pasar-desa.json";
import kategoriToPasar from "../../data/ekonomi/pasar-desa/kategori-to-pasar.json";
export async function seedPasarDesa() {
console.log("🔄 Seeding Kategori Produk...");
@@ -25,11 +23,8 @@ export async function seedPasarDesa() {
console.log("🔄 Seeding Pasar Desa...");
let i = 1;
for (const p of pasarDesa) {
let imageId: string | null = null;
const umkmId = `umkm-${i}`; // Map to umkm-1, umkm-2, etc.
i = (i % 4) + 1;
if (p.imageName) {
const image = await prisma.fileStorage.findUnique({
@@ -57,7 +52,6 @@ export async function seedPasarDesa() {
kontak: p.kontak,
imageId,
kategoriProdukId: p.kategoriProdukId,
umkmId: umkmId,
},
create: {
id: p.id,
@@ -69,7 +63,6 @@ export async function seedPasarDesa() {
kontak: p.kontak,
imageId,
kategoriProdukId: p.kategoriProdukId,
umkmId: umkmId,
},
});

View File

@@ -1,10 +1,8 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const apbdes = loadJsonData("ekonomi/pendapatan-asli-desa/apbDesa.json");
const pendapatan = loadJsonData("ekonomi/pendapatan-asli-desa/pendapatanDesa.json");
const belanja = loadJsonData("ekonomi/pendapatan-asli-desa/belanjaDesa.json");
const pembiayaan = loadJsonData("ekonomi/pendapatan-asli-desa/pembiayaanDesa.json");
import apbdes from "../../data/ekonomi/pendapatan-asli-desa/apbDesa.json";
import pendapatan from "../../data/ekonomi/pendapatan-asli-desa/pendapatanDesa.json";
import belanja from "../../data/ekonomi/pendapatan-asli-desa/belanjaDesa.json";
import pembiayaan from "../../data/ekonomi/pendapatan-asli-desa/pembiayaanDesa.json";
export async function seedPendapatanAsli() {
console.log("🔄 Seeding Pendapatan Asli...");

View File

@@ -1,8 +1,6 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const grafikMenganggurBerdasarkanUsia = loadJsonData("ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran-berdasarkan-usia.json");
const grafikMenganggurBerdasarkanPendidikan = loadJsonData("ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran-berdasarkan-pendidikan.json");
import grafikMenganggurBerdasarkanUsia from "../../data/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran-berdasarkan-usia.json";
import grafikMenganggurBerdasarkanPendidikan from "../../data/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran-berdasarkan-pendidikan.json";
export async function seedPendudukUsiaKerjaYangMenganggur() {
for (const p of grafikMenganggurBerdasarkanUsia) {

View File

@@ -1,8 +1,6 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const programKemiskinan = loadJsonData("ekonomi/program-kemiskinan/program-kemiskinan.json");
const statistikKemiskinan = loadJsonData("ekonomi/program-kemiskinan/statistik-kemiskinan.json");
import programKemiskinan from "../../data/ekonomi/program-kemiskinan/program-kemiskinan.json";
import statistikKemiskinan from "../../data/ekonomi/program-kemiskinan/statistik-kemiskinan.json";
export async function seedProgramKemiskinan() {
for (const s of statistikKemiskinan) {

View File

@@ -1,7 +1,5 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const sektorUnggulanDesa = loadJsonData("ekonomi/sektor-unggulan/sektor-unggulan.json");
import sektorUnggulanDesa from "../../data/ekonomi/sektor-unggulan/sektor-unggulan.json";
export async function seedSektorUnggulanDesa() {
console.log("🔄 Seeding Sektor Unggulan Desa...");

View File

@@ -1,28 +1,6 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
interface PosisiOrganisasi {
id: string;
nama: string;
deskripsi: string;
hierarki: number;
parentId: string | null;
}
interface PegawaiBumDes {
id: string;
namaLengkap: string;
gelarAkademik: string;
tanggalMasuk: string;
email: string;
telepon: string;
alamat: string;
posisiId: string;
isActive: boolean;
}
const posisiOrganisasiBumDes = loadJsonData<PosisiOrganisasi[][]>("ekonomi/struktur-organisasi/posisi-organisasi-bumdes.json");
const pegawai = loadJsonData<PegawaiBumDes[]>("ekonomi/struktur-organisasi/pegawai-bumdes.json");
import posisiOrganisasiBumDes from "../../data/ekonomi/struktur-organisasi/posisi-organisasi-bumdes.json";
import pegawai from "../../data/ekonomi/struktur-organisasi/pegawai-bumdes.json";
export async function seedStrukturBumdes() {
const flattenedPosisi = posisiOrganisasiBumDes.flat();

View File

@@ -1,67 +0,0 @@
import prisma from "@/lib/prisma";
export const umkmData = [
{
id: "umkm-1",
nama: "Warung Pasar Darmasaba",
pemilik: "Pak Made",
deskripsi: "Warung tradisional kebutuhan pokok",
alamat: "Pasar Desa Darmasaba",
kontak: "081234567890",
kategoriId: "5c06chf7-123f-7igd-0663-5e9h76e55060"
},
{
id: "umkm-2",
nama: "Jajanan Pasar Bu Made",
pemilik: "Bu Made",
deskripsi: "Spesialis jajanan tradisional Bali",
alamat: "Pasar Desa Darmasaba",
kontak: "082145678901",
kategoriId: "4b95bge6-012e-5ged-9552-4d8g65d44959"
},
{
id: "umkm-3",
nama: "Sayur Segar Pak Wayan",
pemilik: "Pak Wayan",
deskripsi: "Sayuran lokal segar setiap hari",
alamat: "Pasar Desa Darmasaba",
kontak: "087865432109",
kategoriId: "5c06chf7-123f-8jhe-0663-5e9h76e55060"
},
{
id: "umkm-4",
nama: "Ayam & Daging Segar Darmasaba",
pemilik: "Pak Ketut",
deskripsi: "Daging ayam dan sapi segar",
alamat: "Pasar Desa Darmasaba",
kontak: "081998877665",
kategoriId: "5c06chf7-123f-9kif-0663-5e9h76e55060"
}
];
export async function seedUmkm() {
console.log("🔄 Seeding UMKM...");
for (const u of umkmData) {
await prisma.umkm.upsert({
where: { id: u.id },
update: {
nama: u.nama,
pemilik: u.pemilik,
deskripsi: u.deskripsi,
alamat: u.alamat,
kontak: u.kontak,
kategoriId: u.kategoriId,
},
create: {
id: u.id,
nama: u.nama,
pemilik: u.pemilik,
deskripsi: u.deskripsi,
alamat: u.alamat,
kontak: u.kontak,
kategoriId: u.kategoriId,
},
});
}
console.log("✅ UMKM seeded successfully");
}

View File

@@ -1,7 +1,5 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const ajukanIde = loadJsonData("inovasi/ajukan-ide/ajukan-ide.json");
import ajukanIde from "../../data/inovasi/ajukan-ide/ajukan-ide.json";
export async function seedAjukan() {
console.log("🔄 Seeding Ajukan Ide Inovatif...");

View File

@@ -1,7 +1,5 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const desaDigital = loadJsonData("inovasi/desa-digital/desa-digital.json");
import desaDigital from "../../data/inovasi/desa-digital/desa-digital.json";
export async function seedDesaDigital() {
console.log("🔄 Seeding Desa Digital...");

View File

@@ -1,7 +1,5 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const infoTeknologi = loadJsonData("inovasi/info-teknologi/info-teknologi.json");
import infoTeknologi from "../../data/inovasi/info-teknologi/info-teknologi.json";
export async function seedInfoTeknologi() {
console.log("🔄 Seeding Info Teknologi...");

View File

@@ -1,8 +1,6 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const kolaborasiInovasi = loadJsonData("inovasi/kolaborasi-inovasi/kolaborasi-inovasi.json");
const mitraKolaborasi = loadJsonData("inovasi/kolaborasi-inovasi/mitra-kolaborasi.json");
import kolaborasiInovasi from "../../data/inovasi/kolaborasi-inovasi/kolaborasi-inovasi.json";
import mitraKolaborasi from "../../data/inovasi/kolaborasi-inovasi/mitra-kolaborasi.json";
export async function seedKolaborasiInovasi() {
console.log("🔄 Seeding Kolaborasi Inovasi...");

View File

@@ -1,10 +1,8 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const jenisLayanan = loadJsonData("inovasi/layanan-online-desa/jenis-layanan.json");
const administrasiOnline = loadJsonData("inovasi/layanan-online-desa/administrasi-online.json");
const jenisPengaduan = loadJsonData("inovasi/layanan-online-desa/jenis-pengaduan.json");
const pengaduanMasyarakat = loadJsonData("inovasi/layanan-online-desa/pengaduan-masyarakat.json");
import jenisLayanan from "../../data/inovasi/layanan-online-desa/jenis-layanan.json";
import administrasiOnline from "../../data/inovasi/layanan-online-desa/administrasi-online.json";
import jenisPengaduan from "../../data/inovasi/layanan-online-desa/jenis-pengaduan.json";
import pengaduanMasyarakat from "../../data/inovasi/layanan-online-desa/pengaduan-masyarakat.json";
export async function seedLayananOnlineDesa() {
console.log("🔄 Seeding Jenis Layanan...");

View File

@@ -1,7 +1,5 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const programKreatif = loadJsonData("inovasi/program-kreatif-desa/program-kreatif-desa.json");
import programKreatif from "../../data/inovasi/program-kreatif-desa/program-kreatif-desa.json";
export async function seedProgramKreatifDesa() {
console.log("🔄 Seeding Program Kreatif...");

View File

@@ -1,7 +1,5 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const keamananLingkunganJson = loadJsonData("keamanan/keamanan-lingkungan/keamanan-lingkungan.json");
import keamananLingkunganJson from "../../data/keamanan/keamanan-lingkungan/keamanan-lingkungan.json";
export async function seedKeamananLingkungan() {
console.log("🔄 Seeding Keamanan Lingkungan...");

View File

@@ -1,9 +1,7 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const kontakDaruratKeamanan = loadJsonData("keamanan/kontak-darurat-keamanan/kontak-darurat-keamanan.json");
const kontakItem = loadJsonData("keamanan/kontak-darurat-keamanan/kontakItem.json");
const kontakDaruratToItem = loadJsonData("keamanan/kontak-darurat-keamanan/kontakDaruratToItem.json");
import kontakDaruratKeamanan from "../../data/keamanan/kontak-darurat-keamanan/kontak-darurat-keamanan.json";
import kontakItem from "../../data/keamanan/kontak-darurat-keamanan/kontakItem.json";
import kontakDaruratToItem from "../../data/keamanan/kontak-darurat-keamanan/kontakDaruratToItem.json";
export async function seedKontakDaruratKeamanan() {
console.log("🔄 Seeding Kontak Item...");

View File

@@ -1,8 +1,6 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const laporanPublik = loadJsonData("keamanan/laporan-publik/laporan-publik.json");
const penangananLaporan = loadJsonData("keamanan/laporan-publik/penanganan-laporan.json");
import laporanPublik from "../../data/keamanan/laporan-publik/laporan-publik.json";
import penangananLaporan from "../../data/keamanan/laporan-publik/penanganan-laporan.json";
export async function seedLaporanPublik() {
console.log("🔄 Seeding Laporan Publik...");

View File

@@ -1,7 +1,5 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const pencegahanKriminalitas = loadJsonData("keamanan/pencegahan-kriminalitas/pencegahan-kriminalitas.json");
import pencegahanKriminalitas from "../../data/keamanan/pencegahan-kriminalitas/pencegahan-kriminalitas.json";
export async function seedPencegahanKriminalitas() {
console.log("🔄 Seeding Pencegahan Kriminalitas...");

View File

@@ -1,9 +1,7 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const layananPolsek = loadJsonData("keamanan/polsek-terdekat/layanan-polsek.json");
const polsekTerdekat = loadJsonData("keamanan/polsek-terdekat/polsek-terdekat.json");
const layananToPolsek = loadJsonData("keamanan/polsek-terdekat/layanan-to-polsek.json");
import layananPolsek from "../../data/keamanan/polsek-terdekat/layanan-polsek.json";
import polsekTerdekat from "../../data/keamanan/polsek-terdekat/polsek-terdekat.json";
import layananToPolsek from "../../data/keamanan/polsek-terdekat/layanan-to-polsek.json";
export async function seedPolsekTerdekat() {
console.log("🔄 Seeding Layanan Polsek...");

View File

@@ -1,7 +1,5 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const tipsKeamananJson = loadJsonData("keamanan/tips-keamanan/tips-keamanan.json");
import tipsKeamananJson from "../../data/keamanan/tips-keamanan/tips-keamanan.json";
export async function seedTipsKeamanan() {
console.log("🔄 Seeding Tips Keamanan...");

View File

@@ -1,32 +0,0 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const dataBanjarJson = loadJsonData("kependudukan/data-banjar/data-banjar.json");
export async function seedDataBanjar() {
console.log("Seeding Data Banjar...");
for (const item of dataBanjarJson) {
await prisma.dataBanjar.upsert({
where: { id: item.id },
update: {
nama: item.nama,
penduduk: item.penduduk,
kk: item.kk,
miskin: item.miskin,
tahun: item.tahun,
},
create: {
id: item.id,
nama: item.nama,
penduduk: item.penduduk,
kk: item.kk,
miskin: item.miskin,
tahun: item.tahun,
},
});
console.log(` Banjar: ${item.nama} (${item.penduduk} penduduk)`);
}
console.log("Data Banjar seed selesai");
}

Some files were not shown because too many files have changed in this diff Show More