Compare commits
189 Commits
nico/18-fe
...
deploy/stg
| Author | SHA1 | Date | |
|---|---|---|---|
| 09c7fd8f3a | |||
| 656ffcc561 | |||
| 76ffa662c5 | |||
| 46423409fd | |||
| 2edf5e9b11 | |||
| af368eeee0 | |||
| e104cd8fcc | |||
| 50801e5c8a | |||
| 80186bf493 | |||
| d84edc44f5 | |||
| 8b14c6ce44 | |||
| 5e822f0b05 | |||
| 34a37dc63b | |||
| 0e6f7e1769 | |||
| feb853d06e | |||
| 3de412afe0 | |||
| 87d234e57f | |||
| fd18a22834 | |||
| 3e8b961e52 | |||
| 82d779e5e0 | |||
| a6517166cb | |||
| 483b6be677 | |||
| f8dad0dbcd | |||
| 74301fe074 | |||
| 8b19abc628 | |||
| e73798a0f2 | |||
| 1a91f3c9ad | |||
| 9b74592101 | |||
| 55f4b94082 | |||
| b403bc754c | |||
| 67b87f145e | |||
| dd09d7c90a | |||
| 59ae8ad039 | |||
| c012d5778c | |||
| af31bd8aef | |||
| 721357adcf | |||
| 39776ec355 | |||
| 50a7356618 | |||
| 4494dd98ef | |||
| 970949a68b | |||
| 8777c45a44 | |||
| 42bcba6c96 | |||
| d1d54e5c25 | |||
| 0a4b85fd82 | |||
| b751f031cd | |||
| a3940321a7 | |||
| 3cd6fcbd81 | |||
| 7d9b7b0c60 | |||
| 0806eb2308 | |||
|
|
6064ef0759 | ||
| 1c00c326c9 | |||
| 51ce823b45 | |||
| 4eba96140d | |||
| f6f0e10935 | |||
| 2108f403aa | |||
| 4dfcf20322 | |||
| c6c3eebadf | |||
|
|
6d26ace8ab | ||
|
|
0dabc204bc | ||
|
|
e8f8b51686 | ||
|
|
a4db3a149d | ||
|
|
fece983ac5 | ||
| 8b7eef5fee | |||
| 8b22d01e0d | |||
| dc13e37a02 | |||
| 2d2cbef29b | |||
| 8c8a96b830 | |||
| dc3eccacbf | |||
| ffe94992e5 | |||
|
|
f5566bca2c | ||
|
|
ba964df32c | ||
|
|
df3f382a97 | ||
| 4fb522f88f | |||
| 85332a8225 | |||
| 3fe2a5ccab | |||
| 363bfa65fb | |||
| dccf590cbf | |||
| f076b81d14 | |||
| b5ea3216e0 | |||
| 64b116588b | |||
| 63161e1a39 | |||
| 8b8c65dd1e | |||
| 159fb3cec6 | |||
| 4821934224 | |||
| ee39b88b00 | |||
| ce46d3b5f7 | |||
| 144ac37e12 | |||
| f90477ed63 | |||
| 4a7811e06f | |||
| f63aaf916d | |||
| 3803c79c95 | |||
| 2d901912ea | |||
| a791efe76c | |||
| e9f7bc2043 | |||
| 6712da9ac2 | |||
| ac11a9367c | |||
| 67e5ceb254 | |||
| 65942ac9d2 | |||
| e0436cc384 | |||
| 63682e47b6 | |||
| f4705690a9 | |||
| 239771a714 | |||
| 03451195c8 | |||
| 597af7e716 | |||
| 0a8a026b94 | |||
| a5bd91b580 | |||
|
|
7368a367f4 | ||
|
|
ed664d5b10 | ||
| ae3187804e | |||
|
|
0ba30aa5b2 | ||
|
|
790d6535e5 | ||
|
|
46ce16ae97 | ||
| 91e32f3f1c | |||
| 4d03908f23 | |||
| 0563f9664f | |||
| 961cc32057 | |||
| fe7672e09f | |||
| 341ff5779f | |||
| 69f7b4c162 | |||
| 409ad4f1a2 | |||
| 55ea3c473a | |||
| 0160fa636d | |||
| a152eaf984 | |||
| 3684e83187 | |||
| 223b85a714 | |||
| 77c54b5c8a | |||
| f1729151b3 | |||
| 8e8c133eea | |||
| 1e7acac193 | |||
| bb80b0ecc1 | |||
| 42dcbcfb22 | |||
| 22de1aa1f3 | |||
| b1d28a8322 | |||
| b86a3a85c3 | |||
| fd63bb0fd4 | |||
| f2c9a922a6 | |||
| 92b24440fe | |||
| f0558aa0d0 | |||
| 8132609ccb | |||
| 1b59d6bf09 | |||
| eb1ad54db6 | |||
| 21ec3ad1c1 | |||
| 3a115908c4 | |||
| 5ff791642c | |||
| b803c7a90c | |||
| fb2fe67c23 | |||
| 51460558d4 | |||
| d105ceeb6b | |||
| c865aee766 | |||
| 273dfdfd09 | |||
| 1d1d8e50dc | |||
| 092afe67d2 | |||
| 2d9170705d | |||
| fdf9a951a4 | |||
| ca74029688 | |||
| 1a8fc1a670 | |||
| 19235f0791 | |||
| 61de7d8d33 | |||
| 8fb85ce56c | |||
| 1f98b6993d | |||
| f3a10d63d1 | |||
| 7a42bec63b | |||
| 44c421129e | |||
| ddff427926 | |||
| 00c8caade4 | |||
| 0209f49449 | |||
| 344c6ada6d | |||
| 11acd04419 | |||
| 8d49213b68 | |||
| 96911e3cf1 | |||
| 9950c28b9b | |||
| fa0f3538d1 | |||
| 2778f53aff | |||
| 37ac91d4f4 | |||
| 217f4a9a3b | |||
| 5d6a7437ed | |||
| 752a6cabee | |||
| 134ddc6154 | |||
| 28979c6b49 | |||
| b2066caa13 | |||
| 023c77d636 | |||
| 9bf3ec72cf | |||
| f359f5b1ce | |||
| 1c1e8fb190 | |||
| 54f83da3b8 | |||
| f8985c550f | |||
| e3d909e760 | |||
| 16a8df50c1 | |||
| d66a952d4c |
47
.dockerignore
Normal file
47
.dockerignore
Normal file
@@ -0,0 +1,47 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
bun-debug.log*
|
||||
|
||||
# Docker files
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Markdown/Documentation
|
||||
README.md
|
||||
GEMINI.md
|
||||
AGENTS.md
|
||||
AUDIT_REPORT.md
|
||||
QWEN.md
|
||||
NOTE.md
|
||||
task-project-apbdes.md
|
||||
MUSIK_CREATE_ANALYSIS.md
|
||||
darkMode.md
|
||||
/test-results
|
||||
/playwright-report
|
||||
/tmp_assets
|
||||
/foldergambar
|
||||
/googleapi
|
||||
/xx
|
||||
/xx.ts
|
||||
/xx.txt
|
||||
/test.txt
|
||||
/x.json
|
||||
/x.sh
|
||||
/xcoba.ts
|
||||
/xcoba2.ts
|
||||
/gambar.ttx
|
||||
/test-berita-state.ts
|
||||
44
.env.example
Normal file
44
.env.example
Normal file
@@ -0,0 +1,44 @@
|
||||
# Database Configuration
|
||||
DATABASE_URL="postgresql://username:password@localhost:5432/desa-darmasaba?schema=public"
|
||||
|
||||
# Seafile Configuration (File Storage)
|
||||
SEAFILE_TOKEN=your_seafile_token
|
||||
SEAFILE_REPO_ID=your_seafile_repo_id
|
||||
SEAFILE_URL=https://your-seafile-instance.com
|
||||
SEAFILE_PUBLIC_SHARE_TOKEN=your_seafile_public_share_token
|
||||
|
||||
# Upload Configuration
|
||||
WIBU_UPLOAD_DIR=uploads
|
||||
WIBU_DOWNLOAD_DIR=./download
|
||||
|
||||
# WhatsApp Server Configuration
|
||||
WA_SERVER_TOKEN=your_whatsapp_server_token
|
||||
|
||||
# Application Configuration
|
||||
# IMPORTANT: For staging/production, set this to your actual domain
|
||||
# Local development: NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
# Staging: NEXT_PUBLIC_BASE_URL=https://desa-darmasaba-stg.wibudev.com
|
||||
# Production: NEXT_PUBLIC_BASE_URL=https://your-production-domain.com
|
||||
# Or use relative URL '/' for automatic protocol/domain detection (recommended)
|
||||
NEXT_PUBLIC_BASE_URL=/
|
||||
|
||||
# Email Configuration (for notifications/subscriptions)
|
||||
EMAIL_USER=your_email@gmail.com
|
||||
EMAIL_PASS=your_email_app_password
|
||||
|
||||
# Session Configuration
|
||||
BASE_SESSION_KEY=your_session_key_generate_secure_random_string
|
||||
BASE_TOKEN_KEY=your_jwt_secret_key_generate_secure_random_string
|
||||
|
||||
# Telegram Bot Configuration (for notifications)
|
||||
BOT_TOKEN=your_telegram_bot_token
|
||||
CHAT_ID=your_telegram_chat_id
|
||||
|
||||
# Session Password (for iron-session)
|
||||
SESSION_PASSWORD="your_session_password_min_32_characters_long_secure"
|
||||
|
||||
# ElevenLabs API Key (for TTS features - optional)
|
||||
ELEVENLABS_API_KEY=your_elevenlabs_api_key
|
||||
|
||||
# Environment (optional, defaults to development)
|
||||
# NODE_ENV=development
|
||||
@@ -1,43 +1,52 @@
|
||||
#!/usr/bin/env bun
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Fungsi untuk mencari string terpanjang dalam objek (biasanya balasan AI)
|
||||
function findLongestString(obj: any): string {
|
||||
let longest = "";
|
||||
const search = (item: any) => {
|
||||
if (typeof item === "string") {
|
||||
if (item.length > longest.length) longest = item;
|
||||
} else if (Array.isArray(item)) {
|
||||
item.forEach(search);
|
||||
} else if (item && typeof item === "object") {
|
||||
Object.values(item).forEach(search);
|
||||
// Function to manually load .env from project root if process.env is missing keys
|
||||
function loadEnv() {
|
||||
const envPath = join(process.cwd(), ".env");
|
||||
if (existsSync(envPath)) {
|
||||
const envContent = readFileSync(envPath, "utf-8");
|
||||
const lines = envContent.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line && !line.startsWith("#")) {
|
||||
const [key, ...valueParts] = line.split("=");
|
||||
if (key && valueParts.length > 0) {
|
||||
const value = valueParts.join("=").trim().replace(/^["']|["']$/g, "");
|
||||
process.env[key.trim()] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
search(obj);
|
||||
return longest;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
// Ensure environment variables are loaded
|
||||
loadEnv();
|
||||
|
||||
const inputRaw = readFileSync(0, "utf-8");
|
||||
if (!inputRaw) return;
|
||||
const input = JSON.parse(inputRaw);
|
||||
|
||||
// DEBUG: Lihat struktur asli di console terminal (stderr)
|
||||
console.error("DEBUG KEYS:", Object.keys(input));
|
||||
let finalText = "";
|
||||
let sessionId = "web-desa-darmasaba";
|
||||
|
||||
try {
|
||||
// Try parsing as JSON first
|
||||
const input = JSON.parse(inputRaw);
|
||||
sessionId = input.session_id || "web-desa-darmasaba";
|
||||
finalText = typeof input === "string" ? input : (input.response || input.text || JSON.stringify(input));
|
||||
} catch {
|
||||
// If not JSON, use raw text
|
||||
finalText = inputRaw;
|
||||
}
|
||||
|
||||
const BOT_TOKEN = process.env.BOT_TOKEN;
|
||||
const CHAT_ID = process.env.CHAT_ID;
|
||||
|
||||
const sessionId = input.session_id || "unknown";
|
||||
|
||||
// Cari teks secara otomatis di seluruh objek JSON
|
||||
let finalText = findLongestString(input.response || input);
|
||||
|
||||
if (!finalText || finalText.length < 5) {
|
||||
finalText =
|
||||
"Teks masih gagal diekstraksi. Struktur: " +
|
||||
Object.keys(input).join(", ");
|
||||
if (!BOT_TOKEN || !CHAT_ID) {
|
||||
console.error("Missing BOT_TOKEN or CHAT_ID in environment variables");
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
@@ -45,7 +54,7 @@ async function run() {
|
||||
`🆔 Session: \`${sessionId}\` \n\n` +
|
||||
`🧠 Output:\n${finalText.substring(0, 3500)}`;
|
||||
|
||||
await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
||||
const res = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -55,6 +64,13 @@ async function run() {
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
console.error("Telegram API Error:", errorData);
|
||||
} else {
|
||||
console.log("Notification sent successfully!");
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify({ status: "continue" }));
|
||||
} catch (err) {
|
||||
console.error("Hook Error:", err);
|
||||
|
||||
219
.github/workflows/build.yml
vendored
219
.github/workflows/build.yml
vendored
@@ -1,219 +0,0 @@
|
||||
name: Build And Save Log
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: "Target environment (e.g., staging, production)"
|
||||
required: true
|
||||
default: "staging"
|
||||
version:
|
||||
description: "Version to deploy"
|
||||
required: false
|
||||
default: "latest"
|
||||
|
||||
env:
|
||||
APP_NAME: desa-darmasaba-action
|
||||
WA_PHONE: "6289697338821,6289697338822"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14
|
||||
env:
|
||||
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd="pg_isready"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
|
||||
steps:
|
||||
# Checkout kode sumber
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Setup Bun
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
# Cache dependencies
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: .bun
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
# Step 1: Set BRANCH_NAME based on event type
|
||||
- name: Set BRANCH_NAME
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
echo "BRANCH_NAME=${{ github.head_ref }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "BRANCH_NAME=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# Step 2: Generate APP_VERSION dynamically
|
||||
- name: Set APP_VERSION
|
||||
run: echo "APP_VERSION=${{ github.sha }}---$(date +%Y%m%d%H%M%S)" >> $GITHUB_ENV
|
||||
|
||||
# Step 3: Kirim notifikasi ke API build Start
|
||||
- name: Notify start build
|
||||
run: |
|
||||
IFS=',' read -ra PHONES <<< "${{ env.WA_PHONE }}"
|
||||
for PHONE in "${PHONES[@]}"; do
|
||||
ENCODED_TEXT=$(bun -e "console.log(encodeURIComponent('Build:start\nApp:${{ env.APP_NAME }}\nBranch:${{ env.BRANCH_NAME }}\nVersion:${{ env.APP_VERSION }}'))")
|
||||
curl -X GET "https://wa.wibudev.com/code?text=$ENCODED_TEXT&nom=$PHONE"
|
||||
done
|
||||
|
||||
# Install dependencies
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
# Konfigurasi environment variable untuk PostgreSQL dan variabel tambahan
|
||||
- name: Set up environment variables
|
||||
run: |
|
||||
echo "DATABASE_URL=postgresql://${{ secrets.POSTGRES_USER }}:${{ secrets.POSTGRES_PASSWORD }}@localhost:5432/${{ secrets.POSTGRES_DB }}?schema=public" >> .env
|
||||
echo "PORT=3000" >> .env
|
||||
echo "NEXT_PUBLIC_WIBU_URL=localhost:3000" >> .env
|
||||
echo "WIBU_UPLOAD_DIR=/uploads" >> .env
|
||||
|
||||
# Create log file
|
||||
- name: Create log file
|
||||
run: touch build.txt
|
||||
|
||||
# Migrasi database menggunakan Prisma
|
||||
- name: Apply Prisma schema to database
|
||||
run: bun prisma db push >> build.txt 2>&1
|
||||
|
||||
# Seed database (opsional)
|
||||
- name: Seed database
|
||||
run: |
|
||||
bun prisma db seed >> build.txt 2>&1 || echo "Seed failed or no seed data found. Continuing without seed." >> build.txt
|
||||
|
||||
# Build project
|
||||
- name: Build project
|
||||
run: bun run build >> build.txt 2>&1
|
||||
|
||||
# Ensure project directory exists
|
||||
- name: Ensure /var/www/projects/${{ env.APP_NAME }} exists
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USERNAME }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
script: |
|
||||
mkdir -p /var/www/projects/${{ env.APP_NAME }}
|
||||
|
||||
# Deploy to a new version directory
|
||||
- name: Deploy to VPS (New Version)
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USERNAME }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
source: "."
|
||||
target: "/var/www/projects/${{ env.APP_NAME }}/releases/${{ env.APP_VERSION }}"
|
||||
|
||||
# Set up environment variables
|
||||
- name: Set up environment variables
|
||||
run: |
|
||||
rm -r .env
|
||||
echo "DATABASE_URL=postgresql://${{ secrets.POSTGRES_USER }}:${{ secrets.POSTGRES_PASSWORD }}@localhost:5433/${{ secrets.POSTGRES_DB }}?schema=public" >> .env
|
||||
echo "NEXT_PUBLIC_WIBU_URL=${{ env.APP_NAME }}" >> .env
|
||||
echo "WIBU_UPLOAD_DIR=/var/www/projects/${{ env.APP_NAME }}/uploads" >> .env
|
||||
|
||||
# Kirim file .env ke server
|
||||
- name: Upload .env to server
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USERNAME }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
source: ".env"
|
||||
target: "/var/www/projects/${{ env.APP_NAME }}/releases/${{ env.APP_VERSION }}/"
|
||||
|
||||
# manage deployment
|
||||
- name: manage deployment
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USERNAME }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
script: |
|
||||
|
||||
# Source ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# Find an available port
|
||||
PORT=$(curl -s -X GET https://wibu-bot.wibudev.com/api/find-port | jq -r '.[0]')
|
||||
if [ -z "$PORT" ] || ! [[ "$PORT" =~ ^[0-9]+$ ]]; then
|
||||
echo "Invalid or missing port from API."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# manage deployment
|
||||
cd /var/www/projects/${{ env.APP_NAME }}/releases/${{ env.APP_VERSION }}
|
||||
|
||||
# Create uploads directory
|
||||
mkdir -p /var/www/projects/${{ env.APP_NAME }}/uploads
|
||||
|
||||
# Install dependencies
|
||||
bun install --production
|
||||
|
||||
# Apply database schema
|
||||
if ! bun prisma db push; then
|
||||
echo "Database migration failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Seed database (optional)
|
||||
bun prisma db seed || echo "tidak membutuhkan seed"
|
||||
|
||||
# Restart the application
|
||||
pm2 reload ${{ env.APP_NAME }} || pm2 start "bun run start --port $PORT" --name "${{ env.APP_NAME }}-$PORT" --namespace "${{ env.APP_NAME }}"
|
||||
|
||||
# Step 4: Set BUILD_STATUS based on success or failure
|
||||
- name: Set BUILD_STATUS
|
||||
if: success()
|
||||
run: echo "BUILD_STATUS=success" >> $GITHUB_ENV
|
||||
|
||||
- name: Set BUILD_STATUS on failure
|
||||
if: failure()
|
||||
run: echo "BUILD_STATUS=failed" >> $GITHUB_ENV
|
||||
|
||||
# Update status log
|
||||
- name: Update status log
|
||||
if: always()
|
||||
run: |
|
||||
echo "=====================" >> build.txt
|
||||
echo "BUILD_STATUS=${{ env.BUILD_STATUS }}" >> build.txt
|
||||
echo "APP_NAME=${{ env.APP_NAME }}" >> build.txt
|
||||
echo "APP_VERSION=${{ env.APP_VERSION }}" >> build.txt
|
||||
echo "=====================" >> build.txt
|
||||
|
||||
# Upload log to 0x0.st
|
||||
- name: Upload log to 0x0.st
|
||||
id: upload_log
|
||||
if: always()
|
||||
run: |
|
||||
LOG_URL=$(curl -F "file=@build.txt" https://wibu-bot.wibudev.com/api/file )
|
||||
echo "LOG_URL=$LOG_URL" >> $GITHUB_ENV
|
||||
|
||||
# Kirim notifikasi ke API
|
||||
- name: Notify build success via API
|
||||
if: always()
|
||||
run: |
|
||||
IFS=',' read -ra PHONES <<< "${{ env.WA_PHONE }}"
|
||||
for PHONE in "${PHONES[@]}"; do
|
||||
ENCODED_TEXT=$(bun -e "console.log(encodeURIComponent('Build:${{ env.BUILD_STATUS }}\nApp:${{ env.APP_NAME }}\nBranch:${{ env.BRANCH_NAME }}\nVersion:${{ env.APP_VERSION }}\nLog:${{ env.LOG_URL }}'))")
|
||||
curl -X GET "https://wa.wibudev.com/code?text=$ENCODED_TEXT&nom=$PHONE"
|
||||
done
|
||||
56
.github/workflows/docker-publish.yml
vendored
Normal file
56
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Publish Docker to GHCR
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
jobs:
|
||||
publish:
|
||||
name: Build & Push to GHCR
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Free disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||
sudo docker image prune --all --force
|
||||
df -h
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract tag name
|
||||
id: meta
|
||||
run: echo "tag=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
106
.github/workflows/publish.yml
vendored
Normal file
106
.github/workflows/publish.yml
vendored
Normal file
@@ -0,0 +1,106 @@
|
||||
name: Publish Docker to GHCR
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stack_env:
|
||||
description: "stack env"
|
||||
required: true
|
||||
type: choice
|
||||
default: "dev"
|
||||
options:
|
||||
- dev
|
||||
- prod
|
||||
- stg
|
||||
tag:
|
||||
description: "Image tag (e.g. 1.0.0)"
|
||||
required: true
|
||||
default: "1.0.0"
|
||||
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build & Push to GHCR ${{ github.repository }}:${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ vars.PORTAINER_ENV || 'portainer' }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Free disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||
sudo docker image prune --all --force
|
||||
df -h
|
||||
|
||||
- name: Checkout branch ${{ github.event.inputs.stack_env }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.stack_env }}
|
||||
|
||||
- name: Checkout scripts from main
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
path: .ci
|
||||
sparse-checkout: .github/workflows/script
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Generate image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }}
|
||||
type=raw,value=${{ github.event.inputs.stack_env }}-latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
|
||||
- name: Notify success
|
||||
if: success()
|
||||
run: bash ./.ci/.github/workflows/script/notify.sh
|
||||
env:
|
||||
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
NOTIFY_STATUS: success
|
||||
NOTIFY_WORKFLOW: "Publish Docker"
|
||||
NOTIFY_DETAIL: "Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }}"
|
||||
|
||||
- name: Notify failure
|
||||
if: failure()
|
||||
run: bash ./.ci/.github/workflows/script/notify.sh
|
||||
env:
|
||||
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
NOTIFY_STATUS: failure
|
||||
NOTIFY_WORKFLOW: "Publish Docker"
|
||||
NOTIFY_DETAIL: "Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }}"
|
||||
60
.github/workflows/re-pull.yml
vendored
Normal file
60
.github/workflows/re-pull.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Re-Pull Docker
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stack_name:
|
||||
description: "stack name"
|
||||
required: true
|
||||
type: string
|
||||
stack_env:
|
||||
description: "stack env"
|
||||
required: true
|
||||
type: choice
|
||||
default: "dev"
|
||||
options:
|
||||
- dev
|
||||
- stg
|
||||
- prod
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Re-Pull Docker ${{ github.event.inputs.stack_name }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ vars.PORTAINER_ENV || 'portainer' }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout scripts from main
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
sparse-checkout: .github/workflows/script
|
||||
|
||||
- name: Deploy ke Portainer
|
||||
run: bash ./.github/workflows/script/re-pull.sh
|
||||
env:
|
||||
PORTAINER_USERNAME: ${{ secrets.PORTAINER_USERNAME }}
|
||||
PORTAINER_PASSWORD: ${{ secrets.PORTAINER_PASSWORD }}
|
||||
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
|
||||
STACK_NAME: ${{ github.event.inputs.stack_name }}-${{ github.event.inputs.stack_env }}
|
||||
|
||||
- name: Notify success
|
||||
if: success()
|
||||
run: bash ./.github/workflows/script/notify.sh
|
||||
env:
|
||||
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
NOTIFY_STATUS: success
|
||||
NOTIFY_WORKFLOW: "Re-Pull Docker"
|
||||
NOTIFY_DETAIL: "Stack: ${{ github.event.inputs.stack_name }}-${{ github.event.inputs.stack_env }}"
|
||||
|
||||
- name: Notify failure
|
||||
if: failure()
|
||||
run: bash ./.github/workflows/script/notify.sh
|
||||
env:
|
||||
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
NOTIFY_STATUS: failure
|
||||
NOTIFY_WORKFLOW: "Re-Pull Docker"
|
||||
NOTIFY_DETAIL: "Stack: ${{ github.event.inputs.stack_name }}-${{ github.event.inputs.stack_env }}"
|
||||
26
.github/workflows/script/notify.sh
vendored
Normal file
26
.github/workflows/script/notify.sh
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
: "${TELEGRAM_TOKEN:?TELEGRAM_TOKEN tidak di-set}"
|
||||
: "${TELEGRAM_CHAT_ID:?TELEGRAM_CHAT_ID tidak di-set}"
|
||||
: "${NOTIFY_STATUS:?NOTIFY_STATUS tidak di-set}"
|
||||
: "${NOTIFY_WORKFLOW:?NOTIFY_WORKFLOW tidak di-set}"
|
||||
|
||||
if [ "$NOTIFY_STATUS" = "success" ]; then
|
||||
ICON="✅"
|
||||
TEXT="${ICON} *${NOTIFY_WORKFLOW}* berhasil!"
|
||||
else
|
||||
ICON="❌"
|
||||
TEXT="${ICON} *${NOTIFY_WORKFLOW}* gagal!"
|
||||
fi
|
||||
|
||||
if [ -n "$NOTIFY_DETAIL" ]; then
|
||||
TEXT="${TEXT}
|
||||
${NOTIFY_DETAIL}"
|
||||
fi
|
||||
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
--arg chat_id "$TELEGRAM_CHAT_ID" \
|
||||
--arg text "$TEXT" \
|
||||
'{chat_id: $chat_id, text: $text, parse_mode: "Markdown"}')"
|
||||
120
.github/workflows/script/re-pull.sh
vendored
Normal file
120
.github/workflows/script/re-pull.sh
vendored
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/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
55
.github/workflows/test.yml
vendored
@@ -1,55 +0,0 @@
|
||||
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
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -29,7 +29,12 @@ yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env
|
||||
# env local files (keep .env.example)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# QC
|
||||
QC
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -47,9 +52,6 @@ next-env.d.ts
|
||||
# cache
|
||||
/cache
|
||||
|
||||
.github/
|
||||
|
||||
.env.*
|
||||
|
||||
*.tar.gz
|
||||
|
||||
|
||||
13
.qwen/settings.json
Normal file
13
.qwen/settings.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright-mcp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"playwright-mcp@latest"
|
||||
],
|
||||
"timeout": 60000
|
||||
}
|
||||
},
|
||||
"$version": 3
|
||||
}
|
||||
9
.qwen/settings.json.orig
Normal file
9
.qwen/settings.json.orig
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "playwright-mcp@latest"],
|
||||
"timeout": 60000
|
||||
}
|
||||
}
|
||||
}
|
||||
73
AUDIT_REPORT.md
Normal file
73
AUDIT_REPORT.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Engineering Audit Report: Desa Darmasaba
|
||||
**Status:** Production Readiness Review (Critical)
|
||||
**Auditor:** Staff Technical Architect
|
||||
|
||||
---
|
||||
|
||||
## 📊 Executive Summary & Scores
|
||||
|
||||
| Category | Score | Status |
|
||||
| :--- | :---: | :--- |
|
||||
| **Project Architecture** | 3/10 | 🔴 Critical Failure |
|
||||
| **Code Quality** | 4/10 | 🟠 Poor |
|
||||
| **Performance** | 5/10 | 🟡 Mediocre |
|
||||
| **Security** | 5/10 | 🟠 Risk Detected |
|
||||
| **Production Readiness** | 2/10 | 🔴 Not Ready |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 1. Project Architecture
|
||||
The project suffers from a **"Frankenstein Architecture"**. It attempts to run a full Elysia.js instance inside a Next.js Catch-All route.
|
||||
- **Fractured Backend:** Logic is split between standard Next.js routes (`/api/auth`) and embedded Elysia modules.
|
||||
- **Stateful Dependency:** Reliance on local filesystem (`WIBU_UPLOAD_DIR`) makes the application impossible to deploy on modern serverless platforms like Vercel.
|
||||
- **Polluted Namespace:** Routing tree contains "test/coba" folders (`src/app/coba`, `src/app/percobaan`) that would be accessible in production.
|
||||
|
||||
## ⚛️ 2. Frontend Engineering (React / Next.js)
|
||||
- **State Management Chaos:** Simultaneous use of `Valtio`, `Jotai`, `React Context`, and `localStorage`.
|
||||
- **Tight Coupling:** Public pages (`/darmasaba`) import state directly from Admin internal states (`/admin/(dashboard)/_state`).
|
||||
- **Heavy Client-Side Logic:** Logic that belongs in Server Actions or Hooks is embedded in presentational components (e.g., `Footer.tsx`).
|
||||
|
||||
## 📡 3. Backend / API Design
|
||||
- **Framework Overhead:** Running Elysia inside Next.js adds unnecessary cold-boot overhead and complexity.
|
||||
- **Weak Validation:** Widespread use of `as Type` casting in API handlers instead of runtime validation (Zod/Schema).
|
||||
- **Service Integration:** OTP codes are sent via external `GET` requests with sensitive data in the query string—a major logging risk.
|
||||
|
||||
## 🗄️ 4. Database & Data Modeling (Prisma)
|
||||
- **Schema Over-Normalization:** ~2000 lines of schema. Every minor content type (e.g., `LambangDesa`) is a separate table instead of a unified CMS model.
|
||||
- **Polymorphic Monolith:** `FileStorage` is a "god table" with optional relations to ~40 other tables, creating a massive bottleneck and data integrity risk.
|
||||
- **Connection Mismanagement:** Manual `prisma.$disconnect()` in API routes kills connection pooling performance.
|
||||
|
||||
## 🚀 5. Performance Engineering
|
||||
- **Bypassing Optimization:** Custom `/api/utils/img` endpoint bypasses `next/image` optimization, serving uncompressed assets.
|
||||
- **Aggressive Polling:** Client-side 30s polling for notifications is battery-draining and inefficient compared to SSE or SWR.
|
||||
|
||||
## 🔒 6. Security Audit
|
||||
- **Insecure OTP Delivery:** Credentials passed as URL parameters to the WhatsApp service.
|
||||
- **File Upload Risks:** Potential for Arbitrary File Upload due to direct local filesystem writes without rigorous sanitization.
|
||||
|
||||
## 🧹 7. Code Quality
|
||||
- **Inconsistency:** Mixed English/Indonesian naming (e.g., `nomor` vs `createdAt`).
|
||||
- **Artifacts:** Root directory is littered with scratch files: `xcoba.ts`, `xx.ts`, `test.txt`.
|
||||
|
||||
---
|
||||
|
||||
## 🚩 Top 10 Critical Problems
|
||||
1. **Architectural Fracture:** Embedding Elysia inside Next.js creates a "split-brain" system.
|
||||
2. **Serverless Incompatibility:** Dependency on local disk storage for uploads.
|
||||
3. **Database Bloat:** Over-complicated schema with a fragile `FileStorage` monolith.
|
||||
4. **State Fragmentation:** Mixed usage of Jotai and Valtio without a clear standard.
|
||||
5. **Credential Leakage:** OTP codes sent via GET query parameters.
|
||||
6. **Poor Cleanup:** Trial/Test folders and files committed to the production source.
|
||||
7. **Asset Performance:** Bypassing Next.js image optimization.
|
||||
8. **Coupling:** High dependency between public UI and internal Admin state.
|
||||
9. **Type Safety:** Manual casting in APIs instead of runtime validation.
|
||||
10. **Connection Pooling:** Inefficient Prisma connection management.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Tech Lead Refactoring Priorities
|
||||
1. **Unify the API:** Decommission the Elysia wrapper. Port all logic to standard Next.js Route Handlers with Zod validation.
|
||||
2. **Stateless Storage:** Implement an S3-compatible adapter for all file uploads. Remove `fs` usage.
|
||||
3. **Schema Consolidation:** Refactor the schema to use generic content models where possible.
|
||||
4. **Standardize State:** Choose one global state manager and migrate all components.
|
||||
5. **Project Sanitization:** Delete all `coba`, `percobaan`, and scratch files (`xcoba.ts`, etc.).
|
||||
191
DEV-INSPECTOR-ANALYSIS.md
Normal file
191
DEV-INSPECTOR-ANALYSIS.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# 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)
|
||||
74
Dockerfile
Normal file
74
Dockerfile
Normal file
@@ -0,0 +1,74 @@
|
||||
# ==============================
|
||||
# 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/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"]
|
||||
173
MUSIK_CREATE_ANALYSIS.md
Normal file
173
MUSIK_CREATE_ANALYSIS.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Musik Desa - Create Feature Analysis
|
||||
|
||||
## Error Summary
|
||||
**Error**: `ERR_BLOCKED_BY_CLIENT` saat create musik di staging environment
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### 1. **CORS Configuration Issue** (Primary)
|
||||
File: `src/app/api/[[...slugs]]/route.ts`
|
||||
|
||||
The CORS configuration has specific origins listed:
|
||||
```typescript
|
||||
const corsConfig = {
|
||||
origin: [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
|
||||
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
|
||||
"*",
|
||||
],
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Problem**: The wildcard `*` is at the end, but some browsers don't respect it when `credentials: true` is set.
|
||||
|
||||
### 2. **API Fetch Base URL** (Secondary)
|
||||
File: `src/lib/api-fetch.ts`
|
||||
|
||||
```typescript
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
|
||||
```
|
||||
|
||||
**Problem**:
|
||||
- In staging, this might still default to `http://localhost:3000`
|
||||
- Mixed content (HTTPS frontend → HTTP API) gets blocked by browsers
|
||||
- The `NEXT_PUBLIC_BASE_URL` environment variable might not be set in staging
|
||||
|
||||
### 3. **File Storage Upload Path** (Tertiary)
|
||||
File: `src/app/api/[[...slugs]]/_lib/fileStorage/_lib/create.ts`
|
||||
|
||||
```typescript
|
||||
const UPLOAD_DIR = process.env.WIBU_UPLOAD_DIR;
|
||||
```
|
||||
|
||||
**Problem**: If `WIBU_UPLOAD_DIR` is not set or points to a non-writable location, uploads will fail silently.
|
||||
|
||||
## Solution
|
||||
|
||||
### Fix 1: Update CORS Configuration
|
||||
**File**: `src/app/api/[[...slugs]]/route.ts`
|
||||
|
||||
```typescript
|
||||
// Move wildcard to first position and ensure it works with credentials
|
||||
const corsConfig = {
|
||||
origin: [
|
||||
"*", // Allow all origins (for staging flexibility)
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
|
||||
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
|
||||
"https://desa-darmasaba-stg.wibudev.com"
|
||||
],
|
||||
methods: ["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] as HTTPMethod[],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Accept"],
|
||||
exposedHeaders: ["Content-Range", "X-Content-Range"],
|
||||
maxAge: 86400, // 24 hours
|
||||
credentials: true,
|
||||
};
|
||||
```
|
||||
|
||||
### Fix 2: Add Environment Variable Validation
|
||||
**File**: `.env.example` (update)
|
||||
|
||||
```bash
|
||||
# Application Configuration
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
|
||||
# For staging/production, set this to your actual domain
|
||||
# NEXT_PUBLIC_BASE_URL=https://cld-dkr-desa-darmasaba-stg.wibudev.com
|
||||
```
|
||||
|
||||
### Fix 3: Update API Fetch to Handle Relative URLs
|
||||
**File**: `src/lib/api-fetch.ts`
|
||||
|
||||
```typescript
|
||||
import { AppServer } from '@/app/api/[[...slugs]]/route'
|
||||
import { treaty } from '@elysiajs/eden'
|
||||
|
||||
// Use relative URL for better deployment flexibility
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || '/'
|
||||
|
||||
const ApiFetch = treaty<AppServer>(BASE_URL)
|
||||
|
||||
export default ApiFetch
|
||||
```
|
||||
|
||||
### Fix 4: Add Error Handling in Create Page
|
||||
**File**: `src/app/admin/(dashboard)/musik/create/page.tsx`
|
||||
|
||||
Add better error logging to diagnose issues:
|
||||
|
||||
```typescript
|
||||
const handleSubmit = async () => {
|
||||
// ... validation ...
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Upload cover image
|
||||
const coverRes = await ApiFetch.api.fileStorage.create.post({
|
||||
file: coverFile,
|
||||
name: coverFile.name,
|
||||
});
|
||||
|
||||
if (!coverRes.data?.data?.id) {
|
||||
console.error('Cover upload failed:', coverRes);
|
||||
return toast.error('Gagal mengunggah cover, silakan coba lagi');
|
||||
}
|
||||
|
||||
// ... rest of the code ...
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating musik:', {
|
||||
error,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
toast.error('Terjadi kesalahan saat membuat musik');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Local Development
|
||||
- [ ] Test create musik with cover image and audio file
|
||||
- [ ] Verify CORS headers in browser DevTools Network tab
|
||||
- [ ] Check that file uploads are saved to correct directory
|
||||
|
||||
### Staging Environment
|
||||
- [ ] Set `NEXT_PUBLIC_BASE_URL` to staging domain
|
||||
- [ ] Verify HTTPS is used for all API calls
|
||||
- [ ] Check browser console for mixed content warnings
|
||||
- [ ] Verify `WIBU_UPLOAD_DIR` is set and writable
|
||||
- [ ] Test create musik end-to-end
|
||||
|
||||
## Additional Notes
|
||||
|
||||
### ERR_BLOCKED_BY_CLIENT Common Causes:
|
||||
1. **CORS policy blocking** - Most likely cause
|
||||
2. **Ad blockers** - Can block certain API endpoints
|
||||
3. **Mixed content** - HTTPS page making HTTP requests
|
||||
4. **Content Security Policy (CSP)** - Restrictive CSP headers
|
||||
5. **Browser extensions** - Privacy/security extensions blocking requests
|
||||
|
||||
### Debugging Steps:
|
||||
1. Open browser DevTools → Network tab
|
||||
2. Try to create musik
|
||||
3. Look for failed requests (red status)
|
||||
4. Check the "Headers" tab for:
|
||||
- Request URL (should be correct domain)
|
||||
- Response headers (should have `Access-Control-Allow-Origin`)
|
||||
- Status code (4xx/5xx indicates server-side issue)
|
||||
5. Check browser console for CORS errors
|
||||
|
||||
## Recommended Next Steps
|
||||
|
||||
1. **Immediate**: Update CORS configuration to allow staging domain
|
||||
2. **Short-term**: Add proper environment variable validation
|
||||
3. **Long-term**: Implement proper error boundaries and logging
|
||||
@@ -1,763 +0,0 @@
|
||||
# QC Summary - APBDes Module
|
||||
|
||||
**Scope:** List APBDes, Create, Edit, Detail
|
||||
**Date:** 2026-02-23
|
||||
**Status:** ✅ Secara umum sudah baik, ada beberapa critical issues yang perlu diperbaiki
|
||||
|
||||
---
|
||||
|
||||
## 📊 OVERVIEW
|
||||
|
||||
| Aspect | Schema | API | UI Admin | State Management | Overall |
|
||||
|--------|--------|-----|----------|-----------------|---------|
|
||||
| APBDes | ✅ Baik | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 Perlu fix |
|
||||
|
||||
---
|
||||
|
||||
## ✅ YANG SUDAH BAIK
|
||||
|
||||
### **1. UI/UX Consistency**
|
||||
- ✅ Responsive design (desktop table + mobile cards)
|
||||
- ✅ Loading states dengan Skeleton
|
||||
- ✅ Search dengan debounce (1000ms)
|
||||
- ✅ Pagination konsisten
|
||||
- ✅ Empty state handling yang informatif
|
||||
- ✅ Modal konfirmasi hapus
|
||||
|
||||
### **2. File Upload Handling**
|
||||
- ✅ Dual upload: Gambar + Dokumen
|
||||
- ✅ Dropzone dengan preview (image + iframe untuk dokumen)
|
||||
- ✅ Validasi format (gambar: JPEG/PNG/WEBP, dokumen: PDF/DOC/DOCX)
|
||||
- ✅ Validasi ukuran file (max 5MB untuk gambar, 10MB untuk dokumen di edit)
|
||||
- ✅ Tombol hapus preview (IconX di pojok kanan atas)
|
||||
- ✅ URL.createObjectURL untuk preview lokal
|
||||
|
||||
### **3. Form Validation**
|
||||
- ✅ Zod schema untuk validasi typed
|
||||
- ✅ isFormValid() check sebelum submit
|
||||
- ✅ Error toast dengan pesan spesifik
|
||||
- ✅ Button disabled saat invalid/loading
|
||||
- ✅ Type number input untuk tahun
|
||||
|
||||
### **4. Complex Feature - APBDes Items**
|
||||
- ✅ Hierarchical items dengan level (1, 2, 3)
|
||||
- ✅ Tipe classification (pendapatan, belanja, pembiayaan)
|
||||
- ✅ Auto-calculation: selisih & persentase
|
||||
- ✅ Add/remove items dynamic
|
||||
- ✅ Table preview dengan badge color coding
|
||||
- ✅ Indentasi visual berdasarkan level
|
||||
|
||||
### **5. Edit Form - Original Data Tracking**
|
||||
- ✅ Original data state untuk reset form
|
||||
- ✅ Load data existing dengan benar
|
||||
- ✅ Preview image & dokumen dari data lama
|
||||
- ✅ Reset form mengembalikan ke data original
|
||||
- ✅ File replacement logic (upload baru jika ada perubahan)
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// Line ~95-130 - Load data & save original
|
||||
const data = await apbdesState.edit.load(id);
|
||||
|
||||
setOriginalData({
|
||||
tahun: data.tahun || new Date().getFullYear(),
|
||||
imageId: data.imageId || '',
|
||||
fileId: data.fileId || '',
|
||||
imageUrl: data.image?.link || '',
|
||||
fileUrl: data.file?.link || '',
|
||||
});
|
||||
|
||||
// Set form dengan data lama (termasuk imageId dan fileId)
|
||||
apbdesState.edit.form = {
|
||||
tahun: data.tahun || new Date().getFullYear(),
|
||||
imageId: data.imageId || '', // ✅ Preserve old ID
|
||||
fileId: data.fileId || '', // ✅ Preserve old ID
|
||||
items: (data.items || []).map(...),
|
||||
};
|
||||
|
||||
// Line ~270 - Handle reset
|
||||
const handleReset = () => {
|
||||
apbdesState.edit.form = {
|
||||
tahun: originalData.tahun,
|
||||
imageId: originalData.imageId, // ✅ Restore old ID
|
||||
fileId: originalData.fileId, // ✅ Restore old ID
|
||||
items: [...apbdesState.edit.form.items],
|
||||
};
|
||||
setPreviewImage(originalData.imageUrl || null);
|
||||
setPreviewDoc(originalData.fileUrl || null);
|
||||
setImageFile(null);
|
||||
setDocFile(null);
|
||||
toast.info('Form dikembalikan ke data awal');
|
||||
};
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - Original data tracking sudah implementasi dengan baik.
|
||||
|
||||
---
|
||||
|
||||
### **6. Schema Design**
|
||||
- ✅ Proper relations: APBDes ↔ FileStorage (image & file)
|
||||
- ✅ Self-relation untuk hierarchical items (parentId → children)
|
||||
- ✅ Indexing untuk performa (kode, level, apbdesId)
|
||||
- ✅ Soft delete support (deletedAt, isActive)
|
||||
- ✅ Nullable deletedAt yang benar (`DateTime? @default(null)`)
|
||||
|
||||
**Schema Example (✅ GOOD):**
|
||||
```prisma
|
||||
model APBDes {
|
||||
id String @id @default(cuid())
|
||||
tahun Int?
|
||||
name String?
|
||||
deskripsi String?
|
||||
jumlah String?
|
||||
items APBDesItem[]
|
||||
image FileStorage? @relation("APBDesImage", fields: [imageId], references: [id])
|
||||
imageId String?
|
||||
file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id])
|
||||
fileId String?
|
||||
deletedAt DateTime? // ✅ Nullable, no default
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model APBDesItem {
|
||||
id String @id @default(cuid())
|
||||
kode String
|
||||
uraian String
|
||||
anggaran Float
|
||||
realisasi Float
|
||||
selisih Float // ✅ Formula di komentar
|
||||
persentase Float
|
||||
tipe String? // ✅ Nullable untuk level 1
|
||||
level Int
|
||||
parentId String?
|
||||
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
|
||||
children APBDesItem[] @relation("APBDesItemParent")
|
||||
apbdesId String
|
||||
apbdes APBDes @relation(fields: [apbdesId], references: [id])
|
||||
|
||||
@@index([kode])
|
||||
@@index([level])
|
||||
@@index([apbdesId])
|
||||
}
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - Schema design sudah solid.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ ISSUES & SARAN PERBAIKAN
|
||||
|
||||
### **🔴 CRITICAL**
|
||||
|
||||
#### **1. Formula Selisih - SALAH di State, BENAR di Schema/API**
|
||||
|
||||
**Lokasi:**
|
||||
- `src/app/admin/(dashboard)/_state/landing-page/apbdes.ts` (line 36)
|
||||
- Schema komentar di `prisma/schema.prisma` (line 210)
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// ❌ SALAH di state (line 36)
|
||||
function normalizeItem(item: Partial<...>): z.infer<typeof ApbdesItemSchema> {
|
||||
const anggaran = item.anggaran ?? 0;
|
||||
const realisasi = item.realisasi ?? 0;
|
||||
|
||||
// ❌ WRONG FORMULA
|
||||
const selisih = anggaran - realisasi; // positif = sisa anggaran
|
||||
|
||||
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
|
||||
|
||||
return { ... };
|
||||
}
|
||||
```
|
||||
|
||||
```prisma
|
||||
// ✅ BENAR di schema komentar (line 210)
|
||||
model APBDesItem {
|
||||
// ...
|
||||
realisasi Float
|
||||
selisih Float // ✅ realisasi - anggaran (komentar benar)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- **Data salah!** Selisih positif/negatif terbalik
|
||||
- Jika realisasi > anggaran (over budget), seharusnya **negatif** tapi jadi **positif**
|
||||
- Jika realisasi < anggaran (under budget/sisa), seharusnya **positif** tapi jadi **negatif**
|
||||
- Color coding di UI (green/red) juga terbalik!
|
||||
|
||||
**Contoh:**
|
||||
```
|
||||
Anggaran: Rp 100.000.000
|
||||
Realisasi: Rp 120.000.000 (over budget!)
|
||||
|
||||
❌ Formula sekarang: selisih = 100M - 120M = -20M (negatif)
|
||||
UI show: merah (over budget) ✅ TAPI karena negatif
|
||||
|
||||
✅ Seharusnya: selisih = 120M - 100M = +20M (positif)
|
||||
UI show: merah (over budget) ✅ Karena positif
|
||||
```
|
||||
|
||||
**Rekomendasi:** Fix formula di state:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT FORMULA
|
||||
const selisih = realisasi - anggaran; // positif = over budget, negatif = under budget
|
||||
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
|
||||
```
|
||||
|
||||
**Priority:** 🔴 **CRITICAL**
|
||||
**Effort:** Low (1 line fix)
|
||||
**Impact:** **HIGH** (data integrity issue)
|
||||
|
||||
---
|
||||
|
||||
#### **2. State Management - Inconsistency Fetch Pattern**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/apbdes.ts`
|
||||
|
||||
**Masalah:** Ada 3 pattern berbeda untuk fetch API:
|
||||
|
||||
```typescript
|
||||
// ❌ Pattern 1: ApiFetch (create, findMany, delete, edit.load, edit.update)
|
||||
const res = await ApiFetch.api.landingpage.apbdes["create"].post(parsed.data);
|
||||
const res = await ApiFetch.api.landingpage.apbdes["findMany"].get({ query });
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any)["del"][id].delete();
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any)[id].get();
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
|
||||
|
||||
// ❌ Pattern 2: fetch manual (findUnique)
|
||||
const response = await fetch(`/api/landingpage/apbdes/${id}`);
|
||||
const res = await response.json();
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- Code consistency buruk
|
||||
- Sulit maintenance
|
||||
- Type safety tidak konsisten
|
||||
- Duplikasi logic error handling
|
||||
- Console.log debugging tertinggal di production
|
||||
|
||||
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
|
||||
|
||||
```typescript
|
||||
// ✅ Unified pattern
|
||||
async load(id: string) {
|
||||
try {
|
||||
this.loading = true;
|
||||
const res = await ApiFetch.api.landingpage.apbdes[id].get();
|
||||
|
||||
if (res.data?.success) {
|
||||
this.data = res.data.data;
|
||||
} else {
|
||||
this.data = null;
|
||||
this.error = res.data?.message || "Gagal memuat detail APBDes";
|
||||
toast.error(this.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("FindUnique error:", error);
|
||||
this.data = null;
|
||||
this.error = "Gagal memuat detail APBDes";
|
||||
toast.error(this.error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 High
|
||||
**Effort:** Medium (refactor di findUnique)
|
||||
|
||||
---
|
||||
|
||||
#### **3. Console.log Debugging di Production**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/apbdes.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~175-177
|
||||
const url = `/api/landingpage/apbdes/${id}`;
|
||||
console.log("🌐 Fetching:", url); // ❌ Debug log
|
||||
|
||||
const response = await fetch(url);
|
||||
const res = await response.json();
|
||||
|
||||
console.log("📦 Response:", res); // ❌ Debug log
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- Performance impact (I/O operation)
|
||||
- Security risk (expose API structure)
|
||||
- Log pollution di production
|
||||
- Unprofessional
|
||||
|
||||
**Rekomendasi:** Remove atau gunakan conditional logging:
|
||||
|
||||
```typescript
|
||||
// ✅ Remove completely (recommended)
|
||||
// Atau gunakan conditional logging
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log("🌐 Fetching:", url);
|
||||
console.log("📦 Response:", res);
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟡 MEDIUM**
|
||||
|
||||
#### **4. Type Safety - Any Usage di Edit Methods**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/apbdes.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~215
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any)[id].get();
|
||||
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
// Line ~245
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
|
||||
// ^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- Type safety hilang
|
||||
- Autocomplete tidak bekerja
|
||||
- Runtime errors tidak terdeteksi di compile time
|
||||
- Refactoring sulit
|
||||
|
||||
**Rekomendasi:** Define typed API client:
|
||||
|
||||
```typescript
|
||||
// Define proper types
|
||||
interface APBDesAPI {
|
||||
[id: string]: {
|
||||
get: () => Promise<ApiResponse<APBDesData>>;
|
||||
put: (data: APBDesForm) => Promise<ApiResponse<APBDesData>>;
|
||||
};
|
||||
del: {
|
||||
[id: string]: {
|
||||
delete: () => Promise<ApiResponse<void>>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Use typed client
|
||||
const res = await ApiFetch.api.landingpage.apbdes[id].get();
|
||||
// No more `as any`
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Medium
|
||||
**Effort:** Medium (perlu setup types)
|
||||
|
||||
---
|
||||
|
||||
#### **5. Edit Form - Items Tidak Di-Restore Saat Reset**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/landing-page/apbdes/[id]/edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~270-285
|
||||
const handleReset = () => {
|
||||
apbdesState.edit.form = {
|
||||
tahun: originalData.tahun,
|
||||
imageId: originalData.imageId,
|
||||
fileId: originalData.fileId,
|
||||
items: [...apbdesState.edit.form.items], // ⚠️ Keep MODIFIED items
|
||||
};
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
**Issue:** Saat reset, items yang sudah di-modified (added/removed) tidak di-restore ke original. User expect reset = kembali ke data awal sepenuhnya.
|
||||
|
||||
**Rekomendasi:** Save original items dan restore saat reset:
|
||||
|
||||
```typescript
|
||||
// Add to originalData state
|
||||
const [originalData, setOriginalData] = useState({
|
||||
tahun: 0,
|
||||
imageId: '',
|
||||
fileId: '',
|
||||
imageUrl: '',
|
||||
fileUrl: '',
|
||||
items: [] as ItemForm[], // ✅ Save original items
|
||||
});
|
||||
|
||||
// Load data
|
||||
setOriginalData({
|
||||
tahun: data.tahun || new Date().getFullYear(),
|
||||
imageId: data.imageId || '',
|
||||
fileId: data.fileId || '',
|
||||
imageUrl: data.image?.link || '',
|
||||
fileUrl: data.file?.link || '',
|
||||
items: (data.items || []).map((item: any) => ({...})), // ✅ Save
|
||||
});
|
||||
|
||||
// Reset
|
||||
const handleReset = () => {
|
||||
apbdesState.edit.form = {
|
||||
tahun: originalData.tahun,
|
||||
imageId: originalData.imageId,
|
||||
fileId: originalData.fileId,
|
||||
items: [...originalData.items], // ✅ Restore original items
|
||||
};
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **6. Zod Schema - Error Message Tidak Akurat**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/apbdes.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~10
|
||||
const ApbdesItemSchema = z.object({
|
||||
kode: z.string().min(1, "Kode wajib diisi"), // ✅ OK
|
||||
uraian: z.string().min(1, "Uraian wajib diisi"), // ✅ OK
|
||||
anggaran: z.number().min(0), // ⚠️ No custom message
|
||||
realisasi: z.number().min(0), // ⚠️ No custom message
|
||||
// ...
|
||||
});
|
||||
|
||||
// Line ~17
|
||||
const ApbdesFormSchema = z.object({
|
||||
tahun: z.number().int().min(2000, "Tahun tidak valid"), // ⚠️ Generic
|
||||
imageId: z.string().min(1, "Gambar wajib diunggah"), // ✅ OK
|
||||
fileId: z.string().min(1, "File wajib diunggah"), // ✅ OK
|
||||
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"), // ✅ OK
|
||||
});
|
||||
```
|
||||
|
||||
**Dampak:** Error messages tidak konsisten, beberapa generic beberapa spesifik.
|
||||
|
||||
**Rekomendasi:** Standardisasi error messages:
|
||||
|
||||
```typescript
|
||||
const ApbdesItemSchema = z.object({
|
||||
kode: z.string().min(1, "Kode wajib diisi"),
|
||||
uraian: z.string().min(1, "Uraian wajib diisi"),
|
||||
anggaran: z.number().min(0, "Anggaran tidak boleh negatif"),
|
||||
realisasi: z.number().min(0, "Realisasi tidak boleh negatif"),
|
||||
selisih: z.number(),
|
||||
persentase: z.number(),
|
||||
level: z.number().int().min(1).max(3, "Level harus antara 1-3"),
|
||||
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
|
||||
});
|
||||
|
||||
const ApbdesFormSchema = z.object({
|
||||
tahun: z.number().int().min(2000, "Tahun minimal 2000").max(2100, "Tahun maksimal 2100"),
|
||||
imageId: z.string().min(1, "Gambar wajib diunggah"),
|
||||
fileId: z.string().min(1, "Dokumen wajib diunggah"),
|
||||
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
|
||||
});
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **7. Console.log di Production (UI Components)**
|
||||
|
||||
**Lokasi:** Multiple UI files
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// edit/page.tsx - Line ~220
|
||||
console.error('Update error:', err);
|
||||
|
||||
// create/page.tsx - Line ~120
|
||||
console.error("Gagal submit:", error);
|
||||
|
||||
// detail/page.tsx - Line ~40
|
||||
console.error('Error loading APBDes:', error);
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan conditional logging:
|
||||
|
||||
```typescript
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Update error:', err);
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟢 LOW (Minor Polish)**
|
||||
|
||||
#### **8. Mobile Layout - Title Order Inconsistency**
|
||||
|
||||
**Lokasi:** `page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~170 (Mobile)
|
||||
<Title order={2} size="lg" lh={1.2}>
|
||||
Daftar APBDes
|
||||
</Title>
|
||||
|
||||
// Line ~70 (Desktop - inside Paper)
|
||||
<Title order={4} size="lg" lh={1.2}>
|
||||
Daftar APBDes
|
||||
</Title>
|
||||
```
|
||||
|
||||
**Issue:** Mobile pakai `order={2}` (heading besar), desktop `order={4}`. Seharusnya konsisten.
|
||||
|
||||
**Rekomendasi:** Samakan:
|
||||
```typescript
|
||||
<Title order={4} size="lg" lh={1.2}>
|
||||
Daftar APBDes
|
||||
</Title>
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **9. Search Placeholder Tidak Spesifik**
|
||||
|
||||
**Lokasi:** `page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~30
|
||||
<HeaderSearch
|
||||
title="APBDes"
|
||||
placeholder="Cari APBDes..." // ⚠️ Generic
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
**Rekomendasi:** Lebih spesifik:
|
||||
```typescript
|
||||
placeholder='Cari nama atau tahun APBDes...'
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **10. Duplicate Comment**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/apbdes.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~28-29
|
||||
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
|
||||
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
|
||||
// ^ Duplicate line
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low (remove duplicate)
|
||||
|
||||
---
|
||||
|
||||
#### **11. Inconsistent Button Label**
|
||||
|
||||
**Lokasi:** Multiple files
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// create/page.tsx - Line ~270
|
||||
<Button ...>Simpan</Button>
|
||||
|
||||
// edit/page.tsx - Line ~340
|
||||
<Button ...>Simpan Perubahan</Button>
|
||||
|
||||
// Should be consistent: "Simpan" atau "Simpan Perubahan"
|
||||
```
|
||||
|
||||
**Rekomendasi:** Standardisasi:
|
||||
```typescript
|
||||
// Create: "Simpan"
|
||||
// Edit: "Simpan Perubahan" (lebih descriptive untuk edit)
|
||||
// OR both: "Simpan"
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **12. Missing Search Feature in Pagination**
|
||||
|
||||
**Lokasi:** `page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~250
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10); // ⚠️ Missing search parameter
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
total={totalPages}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
**Issue:** Saat ganti page, search query hilang.
|
||||
|
||||
**Rekomendasi:** Include search:
|
||||
```typescript
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10, debouncedSearch); // ✅ Include search
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **13. Edit Page - Document Max Size Inconsistency**
|
||||
|
||||
**Lokasi:** `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~230 (Image)
|
||||
maxSize={5 * 1024 ** 2} // 5MB
|
||||
|
||||
// Line ~250 (Document)
|
||||
maxSize={10 * 1024 ** 2} // 10MB
|
||||
```
|
||||
|
||||
**Issue:** Create page maksimal 5MB untuk semua file, edit page 10MB untuk dokumen. Inconsistent.
|
||||
|
||||
**Rekomendasi:** Samakan (prefer 5MB untuk consistency):
|
||||
```typescript
|
||||
maxSize={5 * 1024 ** 2} // 5MB for both
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
## 📋 RINGKASAN ACTION ITEMS
|
||||
|
||||
| Priority | Issue | Module | Impact | Effort | Status |
|
||||
|----------|-------|--------|--------|--------|--------|
|
||||
| 🔴 P0 | **Formula selisih SALAH** | State | **CRITICAL** | Low | **MUST FIX** |
|
||||
| 🔴 P0 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
|
||||
| 🔴 P1 | Console.log debugging in production | State | Medium | Low | Should fix |
|
||||
| 🟡 M | Type safety (any usage) | State | Low | Medium | Optional |
|
||||
| 🟡 M | Items tidak di-restore saat reset | Edit UI | Medium | Low | Should fix |
|
||||
| 🟡 M | Zod schema error messages | State | Low | Low | Optional |
|
||||
| 🟢 L | Console.log in UI components | UI | Low | Low | Optional |
|
||||
| 🟢 L | Mobile title order inconsistency | List UI | Low | Low | Optional |
|
||||
| 🟢 L | Search placeholder tidak spesifik | List UI | Low | Low | Optional |
|
||||
| 🟢 L | Duplicate comment | State | Low | Low | Optional |
|
||||
| 🟢 L | Inconsistent button label | UI | Low | Low | Optional |
|
||||
| 🟢 L | Missing search in pagination | List UI | Low | Low | Should fix |
|
||||
| 🟢 L | Document max size inconsistency | Edit UI | Low | Low | Optional |
|
||||
|
||||
---
|
||||
|
||||
## ✅ KESIMPULAN
|
||||
|
||||
### **Overall Quality: 🟢 BAIK (7/10)**
|
||||
|
||||
**Strengths:**
|
||||
1. ✅ UI/UX konsisten & responsive
|
||||
2. ✅ File upload handling solid (dual upload: image + document)
|
||||
3. ✅ Form validation dengan Zod schema
|
||||
4. ✅ State management terstruktur (Valtio)
|
||||
5. ✅ **Edit form reset sudah benar** (original data tracking untuk files)
|
||||
6. ✅ Complex feature: hierarchical items dengan level & tipe
|
||||
7. ✅ Schema design solid (proper relations, indexing, soft delete)
|
||||
8. ✅ Modal konfirmasi hapus untuk user safety
|
||||
|
||||
**Critical Issues:**
|
||||
1. ⚠️ **FORMULA SELISIH SALAH** - Data integrity issue (CRITICAL)
|
||||
2. ⚠️ Fetch method pattern inconsistency (ApiFetch vs fetch manual)
|
||||
3. ⚠️ Console.log debugging tertinggal di production
|
||||
|
||||
**Areas for Improvement:**
|
||||
1. ⚠️ **Fix formula selisih** (realisasi - anggaran, bukan anggaran - realisasi)
|
||||
2. ⚠️ **Refactor fetch methods** untuk gunakan ApiFetch consistently
|
||||
3. ⚠️ **Remove console.log** debugging dari production code
|
||||
4. ⚠️ **Save & restore original items** saat reset form di edit page
|
||||
5. ⚠️ **Improve type safety** dengan remove `as any` usage
|
||||
6. ⚠️ **Standardisasi error messages** di Zod schema
|
||||
|
||||
**Recommended Next Steps:**
|
||||
1. **🔴 CRITICAL: Fix formula selisih** di state (line 36) - 5 menit fix
|
||||
2. **🔴 HIGH:** Refactor findUnique ke ApiFetch - 30 menit
|
||||
3. **🔴 HIGH:** Remove console.log debugging - 10 menit
|
||||
4. **🟡 MEDIUM:** Save & restore original items - 30 menit
|
||||
5. **🟡 MEDIUM:** Improve type safety - 1-2 jam
|
||||
6. **🟢 LOW:** Polish minor issues - 30 menit
|
||||
|
||||
---
|
||||
|
||||
## 📈 COMPARISON WITH OTHER MODULES
|
||||
|
||||
| Aspect | Profil | Desa Anti Korupsi | SDGs Desa | APBDes | Notes |
|
||||
|--------|--------|-------------------|-----------|--------|-------|
|
||||
| Fetch Pattern | ⚠️ Mixed | ⚠️ Mixed | ⚠️ Mixed | ⚠️ Mixed | All perlu refactor |
|
||||
| Loading State | ⚠️ Some missing | ⚠️ Some missing | ⚠️ Missing | ✅ Good | APBDes paling baik |
|
||||
| Edit Form Reset | ✅ Good | ✅ Good | ✅ Good | ✅ Good | All consistent |
|
||||
| Type Safety | ⚠️ Some `any` | ⚠️ Some `any` | ⚠️ Some `any` | ⚠️ Some `any` | Same issue |
|
||||
| File Upload | ✅ Images | ✅ Documents | ✅ Images | ✅ **Dual** | APBDes paling complex |
|
||||
| Error Handling | ✅ Good | ✅ Good (better) | ✅ Good | ✅ Good | Consistent |
|
||||
| Schema Design | ✅ Good | ⚠️ deletedAt issue | ⚠️ deletedAt issue | ✅ **Best** | APBDes paling solid |
|
||||
| **Data Integrity** | ✅ Good | ✅ Good | ✅ Good | ❌ **Formula WRONG** | **APBDes CRITICAL issue** |
|
||||
| Complexity | Low | Medium | Low | **High** | APBDes items hierarchy |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 UNIQUE FEATURES OF APBDes MODULE
|
||||
|
||||
**Most Complex Module So Far:**
|
||||
1. **Dual file upload** (gambar + dokumen) - unique to APBDes
|
||||
2. **Hierarchical items** dengan 3 level - unique to APBDes
|
||||
3. **Auto-calculation** (selisih & persentase) - unique to APBDes
|
||||
4. **Type classification** (pendapatan, belanja, pembiayaan) - unique to APBDes
|
||||
5. **Dynamic item management** (add/remove) - unique to APBDes
|
||||
|
||||
**Best Practices:**
|
||||
1. ✅ Schema design paling solid (deletedAt nullable, proper indexing)
|
||||
2. ✅ Edit form reset paling comprehensive (preserve files & items)
|
||||
3. ✅ Validation paling thorough (Zod schema untuk items)
|
||||
|
||||
**Biggest Issue:**
|
||||
1. ❌ **Formula selisih SALAH** - critical data integrity issue yang tidak ada di modul lain
|
||||
|
||||
---
|
||||
|
||||
**Catatan:** Secara keseluruhan, modul APBDes adalah **paling complex dan paling solid** dibanding modul lain yang sudah di-QC. Namun, ada **1 CRITICAL BUG** (formula selisih) yang harus **SEGERA DIPERBAIKI** karena menyangkut integritas data. Setelah fix critical issue, module ini production-ready dengan beberapa improvement minor yang bisa dilakukan secara incremental.
|
||||
|
||||
**Priority Action:**
|
||||
```
|
||||
🔴 FIX INI SEKARANG JUGA (5 MENIT):
|
||||
File: src/app/admin/(dashboard)/_state/landing-page/apbdes.ts
|
||||
Line: 36
|
||||
Change: const selisih = anggaran - realisasi;
|
||||
To: const selisih = realisasi - anggaran;
|
||||
```
|
||||
@@ -1,639 +0,0 @@
|
||||
# QC Summary - Desa Anti Korupsi Module
|
||||
|
||||
**Scope:** List Desa Anti Korupsi, Kategori Desa Anti Korupsi
|
||||
**Date:** 2026-02-23
|
||||
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
|
||||
|
||||
---
|
||||
|
||||
## 📊 OVERVIEW
|
||||
|
||||
| Module | Schema | API | UI Admin | State Management | Overall |
|
||||
|--------|--------|-----|----------|-----------------|---------|
|
||||
| List Desa Anti Korupsi | ✅ Baik | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 Perlu fix |
|
||||
| Kategori Desa Anti Korupsi | ✅ Baik | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 Perlu fix |
|
||||
|
||||
---
|
||||
|
||||
## ✅ YANG SUDAH BAIK (COMMON)
|
||||
|
||||
### **1. UI/UX Consistency**
|
||||
- ✅ Responsive design (desktop table + mobile cards)
|
||||
- ✅ Loading states dengan Skeleton
|
||||
- ✅ Search dengan debounce (1000ms)
|
||||
- ✅ Pagination konsisten
|
||||
- ✅ Empty state handling yang informatif
|
||||
- ✅ Modal konfirmasi hapus
|
||||
|
||||
### **2. File Upload Handling** (Desa Anti Korupsi)
|
||||
- ✅ Dropzone dengan preview iframe untuk dokumen
|
||||
- ✅ Validasi format dokumen (PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX)
|
||||
- ✅ Validasi ukuran file (max 5MB)
|
||||
- ✅ Tombol hapus preview (IconX di pojok kanan atas)
|
||||
- ✅ URL.createObjectURL untuk preview lokal
|
||||
|
||||
### **3. Form Validation**
|
||||
- ✅ Zod schema untuk validasi typed
|
||||
- ✅ isFormValid() check sebelum submit
|
||||
- ✅ Error toast dengan pesan spesifik
|
||||
- ✅ Button disabled saat invalid/loading
|
||||
|
||||
### **4. CRUD Operations**
|
||||
- ✅ Create dengan upload file
|
||||
- ✅ FindMany dengan pagination & search
|
||||
- ✅ FindUnique untuk detail
|
||||
- ✅ Delete dengan soft delete
|
||||
- ✅ Update dengan file replacement
|
||||
|
||||
### **5. Error Handling**
|
||||
- ✅ Try-catch di semua async operation
|
||||
- ✅ Toast error dengan pesan user-friendly
|
||||
- ✅ Console.error untuk debugging
|
||||
- ✅ Response cloning untuk error handling yang lebih baik (di kategori update)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ ISSUES & SARAN PERBAIKAN
|
||||
|
||||
### **🔴 CRITICAL**
|
||||
|
||||
#### **1. Edit Form - File Lama Tidak Tersimpan Saat Reset**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/landing-page/desa-anti-korupsi/list-desa-anti-korupsi/[id]/edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~70 - Load data
|
||||
const data = await desaAntiKorupsiState.edit.load(id);
|
||||
|
||||
setFormData({
|
||||
name: data.name,
|
||||
deskripsi: data.deskripsi,
|
||||
kategoriId: data.kategoriId,
|
||||
fileId: data.fileId, // ✅ Sudah benar
|
||||
});
|
||||
|
||||
setOriginalData({
|
||||
name: data.name,
|
||||
deskripsi: data.deskripsi,
|
||||
kategoriId: data.kategoriId,
|
||||
fileId: data.fileId,
|
||||
fileUrl: data.file?.link || "", // ✅ Sudah benar
|
||||
});
|
||||
|
||||
// Line ~130 - Handle reset
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
name: originalData.name,
|
||||
deskripsi: originalData.deskripsi,
|
||||
kategoriId: originalData.kategoriId,
|
||||
fileId: originalData.fileId, // ✅ Sudah benar
|
||||
});
|
||||
setPreviewFile(originalData.fileUrl || null); // ✅ Sudah benar
|
||||
setFile(null); // ✅ Sudah benar
|
||||
};
|
||||
```
|
||||
|
||||
**Status:** ✅ **SUDAH BENAR** - Original data tracking sudah implementasi dengan baik.
|
||||
|
||||
**Verdict:** Tidak ada action needed.
|
||||
|
||||
---
|
||||
|
||||
#### **2. State Management - Inconsistency Fetch Pattern**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/desa-anti-korupsi.ts`
|
||||
|
||||
**Masalah:** Ada 2 pattern berbeda untuk fetch API:
|
||||
|
||||
```typescript
|
||||
// ❌ Pattern 1: ApiFetch (create operations)
|
||||
const res = await ApiFetch.api.landingpage.desaantikorupsi["create"].post({...});
|
||||
|
||||
// ❌ Pattern 2: fetch manual (findUnique, edit, delete)
|
||||
const res = await fetch(`/api/landingpage/desaantikorupsi/${id}`);
|
||||
const response = await fetch(`/api/landingpage/desaantikorupsi/del/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- Code consistency buruk
|
||||
- Sulit maintenance
|
||||
- Type safety tidak konsisten
|
||||
- Duplikasi logic error handling
|
||||
|
||||
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
|
||||
|
||||
```typescript
|
||||
// ✅ Unified pattern
|
||||
const res = await ApiFetch.api.landingpage.desaantikorupsi["create"].post(data);
|
||||
const res = await ApiFetch.api.landingpage.desaantikorupsi[id].get();
|
||||
const res = await ApiFetch.api.landingpage.desaantikorupsi[id].put(data);
|
||||
const res = await ApiFetch.api.landingpage.desaantikorupsi["del"][id].delete();
|
||||
```
|
||||
|
||||
**Priority:** 🔴 High
|
||||
**Effort:** Medium (refactor di semua state methods)
|
||||
|
||||
---
|
||||
|
||||
#### **3. findUnique State - Tidak Ada Loading State Management**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/desa-anti-korupsi.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~97 - desaAntikorupsi.findUnique.load()
|
||||
async load(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/landingpage/desaantikorupsi/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
desaAntikorupsi.findUnique.data = data.data ?? null;
|
||||
} else {
|
||||
console.error("Failed to fetch data", res.status, res.statusText);
|
||||
desaAntikorupsi.findUnique.data = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
desaAntikorupsi.findUnique.data = null;
|
||||
}
|
||||
// ❌ MISSING: finally block untuk stop loading
|
||||
}
|
||||
```
|
||||
|
||||
**Dampak:** UI mungkin stuck di loading state jika ada error.
|
||||
|
||||
**Rekomendasi:** Tambahkan loading state dan finally block:
|
||||
|
||||
```typescript
|
||||
async load(id: string) {
|
||||
try {
|
||||
desaAntikorupsi.findUnique.loading = true; // ✅ Start loading
|
||||
const res = await fetch(`/api/landingpage/desaantikorupsi/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
desaAntikorupsi.findUnique.data = data.data ?? null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
} finally {
|
||||
desaAntikorupsi.findUnique.loading = false; // ✅ Stop loading
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **4. Kategori Edit - Response Cloning Overkill**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/desa-anti-korupsi.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~370 - kategoriDesaAntiKorupsi.edit.update()
|
||||
async update() {
|
||||
// ...
|
||||
const response = await fetch(...);
|
||||
|
||||
// Clone the response to avoid 'body already read' error
|
||||
const responseClone = response.clone();
|
||||
|
||||
try {
|
||||
const result = await response.json();
|
||||
// ...
|
||||
} catch (error) {
|
||||
// If JSON parsing fails, try to get the response text
|
||||
try {
|
||||
const text = await responseClone.text();
|
||||
console.error("Error response text:", text);
|
||||
throw new Error(`Gagal memproses respons dari server: ${text}`);
|
||||
} catch (textError) {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Analysis:**
|
||||
- ✅ **GOOD:** Error handling sangat thorough
|
||||
- ⚠️ **OVERKILL:** Untuk production API yang stable, ini berlebihan
|
||||
- ⚠️ **INCONSISTENT:** Module lain tidak punya error handling se-detail ini
|
||||
|
||||
**Rekomendasi:** Simplify untuk consistency:
|
||||
|
||||
```typescript
|
||||
async update() {
|
||||
try {
|
||||
kategoriDesaAntiKorupsi.edit.loading = true;
|
||||
|
||||
const response = await fetch(`/api/landingpage/kategoridak/${this.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: this.form.name }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message || "Berhasil update");
|
||||
await kategoriDesaAntiKorupsi.findMany.load();
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error(result.message || "Gagal update");
|
||||
} catch (error) {
|
||||
console.error("Error updating:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Gagal update");
|
||||
return false;
|
||||
} finally {
|
||||
kategoriDesaAntiKorupsi.edit.loading = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟡 MEDIUM**
|
||||
|
||||
#### **5. HTML Injection Risk - dangerouslySetInnerHTML**
|
||||
|
||||
**Lokasi:**
|
||||
- `list-desa-anti-korupsi/[id]/page.tsx` (line ~105)
|
||||
- `list-desa-anti-korupsi/create/page.tsx` (CreateEditor component)
|
||||
- `list-desa-anti-korupsi/[id]/edit/page.tsx` (EditEditor component)
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// ❌ Direct HTML render tanpa sanitization
|
||||
<Box
|
||||
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
|
||||
style={{ wordBreak: "break-word", whiteSpace: "normal", lineHeight: 1.6 }}
|
||||
/>
|
||||
```
|
||||
|
||||
**Risk:**
|
||||
- XSS attack jika admin input script malicious
|
||||
- Bisa inject iframe, script tag, dll
|
||||
- Security vulnerability
|
||||
|
||||
**Rekomendasi:** Gunakan DOMPurify atau library sanitization:
|
||||
|
||||
```typescript
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Sanitize sebelum render
|
||||
const sanitizedHtml = DOMPurify.sanitize(data.deskripsi);
|
||||
<Box
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
Atau validasi di backend untuk whitelist tag HTML yang diperbolehkan (hanya `<p>`, `<ul>`, `<li>`, `<strong>`, dll).
|
||||
|
||||
**Priority:** 🟡 Medium (**Security concern**)
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **6. Type Safety - Any Usage**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/desa-anti-korupsi.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~60
|
||||
data: null as any[] | null, // ❌ Using 'any'
|
||||
|
||||
// Line ~280
|
||||
data: null as any[] | null, // ❌ Using 'any'
|
||||
|
||||
// Line ~97
|
||||
data: null as Prisma.DesaAntiKorupsiGetPayload<{...}> | null, // ✅ Typed
|
||||
|
||||
// Line ~310
|
||||
data: null as Prisma.KategoriDesaAntiKorupsiGetPayload<{...}> | null, // ✅ Typed
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan typed data consistently:
|
||||
|
||||
```typescript
|
||||
// desaAntikorupsi.findMany
|
||||
data: null as Prisma.DesaAntiKorupsiGetPayload<{
|
||||
include: { kategori: true; file: true };
|
||||
}>[] | null,
|
||||
|
||||
// kategoriDesaAntiKorupsi.findMany
|
||||
data: null as Prisma.KategoriDesaAntiKorupsiGetPayload<{}>[] | null,
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Medium
|
||||
**Effort:** Medium (perlu update semua reference)
|
||||
|
||||
---
|
||||
|
||||
#### **7. Console.log di Production**
|
||||
|
||||
**Lokasi:** Multiple places di state file
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~50
|
||||
console.log(error);
|
||||
toast.error("Gagal menambahkan data");
|
||||
|
||||
// Line ~85
|
||||
console.error("Failed to load media sosial:", res.data?.message);
|
||||
|
||||
// Line ~91
|
||||
console.error("Error loading media sosial:", error);
|
||||
|
||||
// Line ~110
|
||||
console.error("Failed to fetch data", res.status, res.statusText);
|
||||
|
||||
// Line ~114
|
||||
console.error("Error fetching data:", error);
|
||||
|
||||
// ... dan banyak lagi
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan conditional logging:
|
||||
|
||||
```typescript
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error("Error:", error);
|
||||
}
|
||||
```
|
||||
|
||||
Atau gunakan logging library (winston, pino, dll) dengan levels yang jelas.
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **8. Error Message Tidak Konsisten**
|
||||
|
||||
**Lokasi:** Multiple places
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Create - Line ~40
|
||||
return toast.error("Gagal menambahkan data");
|
||||
|
||||
// Create - Line ~42
|
||||
toast.error("Gagal menambahkan data");
|
||||
|
||||
// Delete - Line ~140
|
||||
toast.error("Terjadi kesalahan saat menghapus desa anti korupsi");
|
||||
|
||||
// Edit - Line ~190
|
||||
toast.error("Gagal memuat data");
|
||||
|
||||
// Edit update - Line ~240
|
||||
toast.error("Gagal mengupdate desa anti korupsi");
|
||||
```
|
||||
|
||||
**Rekomendasi:** Standardisasi error messages:
|
||||
|
||||
```typescript
|
||||
// Pattern: "[Action] [resource] gagal"
|
||||
toast.error("Menambahkan data gagal");
|
||||
toast.error("Menghapus data gagal");
|
||||
toast.error("Memuat data gagal");
|
||||
toast.error("Memperbarui data gagal");
|
||||
|
||||
// Atau lebih spesifik dengan context
|
||||
toast.error("Gagal menambahkan data Desa Anti Korupsi");
|
||||
toast.error("Gagal menghapus Kategori Desa Anti Korupsi");
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟢 LOW (Minor Polish)**
|
||||
|
||||
#### **9. Placeholder Search Tidak Spesifik**
|
||||
|
||||
**Lokasi:**
|
||||
- `list-desa-anti-korupsi/page.tsx`: `placeholder="Cari nama program atau kategori..."` ✅ Spesifik
|
||||
- `kategori-desa-anti-korupsi/page.tsx`: `placeholder='pencarian'` ❌ Terlalu generic
|
||||
|
||||
**Rekomendasi:**
|
||||
```typescript
|
||||
// Kategori page
|
||||
placeholder="Cari nama kategori..."
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **10. Alert vs Toast**
|
||||
|
||||
**Lokasi:** `kategori-desa-anti-korupsi/create/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~37
|
||||
if (!stateKategori.create.form.name) {
|
||||
return alert('Nama kategori harus diisi'); // ❌ Using alert()
|
||||
}
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan toast untuk consistency:
|
||||
```typescript
|
||||
if (!stateKategori.create.form.name) {
|
||||
return toast.warn('Nama kategori harus diisi'); // ✅ Using toast
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **11. Component Name Mismatch**
|
||||
|
||||
**Lokasi:** `list-desa-anti-korupsi/[id]/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~17
|
||||
export default function DetailKegiatanDesa() { // ❌ Wrong name
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Rekomendasi:** Rename ke yang sesuai:
|
||||
```typescript
|
||||
export default function DetailDesaAntiKorupsi() { // ✅ Correct name
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low (hanya rename)
|
||||
|
||||
---
|
||||
|
||||
#### **12. Duplicate Error Logging**
|
||||
|
||||
**Lokasi:** `list-desa-anti-korupsi/[id]/edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~87
|
||||
} catch (err) {
|
||||
console.error(err); // ❌ Duplicate logging
|
||||
toast.error('Gagal memuat data Desa Anti Korupsi');
|
||||
}
|
||||
```
|
||||
|
||||
**Rekomendasi:** Cukup satu logging yang informatif:
|
||||
```typescript
|
||||
} catch (err) {
|
||||
console.error('Failed to load Desa Anti Korupsi:', err);
|
||||
toast.error('Gagal memuat data Desa Anti Korupsi');
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **13. Comment Typo**
|
||||
|
||||
**Lokasi:** `kategori-desa-anti-korupsi/[id]/edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~20
|
||||
// 🧠 Ambil proxy asli (bisa ditulis) & snapshot (buat render)
|
||||
const stateKategori = korupsiState.kategoriDesaAntiKorupsi;
|
||||
const snapshotKategori = useProxy(stateKategori);
|
||||
|
||||
// ❌ snapshotKategori declared but never used
|
||||
```
|
||||
|
||||
**Rekomendasi:** Remove unused variable:
|
||||
```typescript
|
||||
const stateKategori = korupsiState.kategoriDesaAntiKorupsi;
|
||||
// const snapshotKategori = useProxy(stateKategori); // ❌ Remove
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **14. Schema - deletedAt Default Value**
|
||||
|
||||
**Lokasi:** `prisma/schema.prisma`
|
||||
|
||||
**Masalah:**
|
||||
```prisma
|
||||
model DesaAntiKorupsi {
|
||||
// ...
|
||||
deletedAt DateTime @default(now()) // ❌ Always has default value
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `deletedAt @default(now())` berarti setiap record baru langsung punya `deletedAt` value, yang bisa membingungkan untuk soft delete logic.
|
||||
|
||||
**Rekomendasi:**
|
||||
```prisma
|
||||
model DesaAntiKorupsi {
|
||||
// ...
|
||||
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Medium (potential logic issue)
|
||||
**Effort:** Medium (perlu migration)
|
||||
|
||||
---
|
||||
|
||||
## 📋 RINGKASAN ACTION ITEMS
|
||||
|
||||
| Priority | Issue | Module | Impact | Effort | Status |
|
||||
|----------|-------|--------|--------|--------|--------|
|
||||
| 🔴 P0 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
|
||||
| 🔴 P0 | Missing loading state in findUnique | State | Medium | Low | Perlu fix |
|
||||
| 🟡 M | HTML injection risk | UI | **High (Security)** | Low | **Should fix** |
|
||||
| 🟡 M | Type safety (any usage) | State | Low | Medium | Optional |
|
||||
| 🟡 M | Response cloning overkill | State (Kategori) | Low | Low | Optional |
|
||||
| 🟢 L | Console.log in production | State | Low | Low | Optional |
|
||||
| 🟢 L | Error message inconsistency | State | Low | Low | Optional |
|
||||
| 🟢 L | Placeholder tidak spesifik | Kategori UI | Low | Low | Optional |
|
||||
| 🟢 L | Alert vs Toast | Kategori Create | Low | Low | Optional |
|
||||
| 🟢 L | Component name mismatch | Detail page | Low | Low | Optional |
|
||||
| 🟢 L | Duplicate error logging | Edit page | Low | Low | Optional |
|
||||
| 🟢 L | Unused variable | Kategori Edit | Low | Low | Optional |
|
||||
| 🟢 M | deletedAt default value | Schema | Medium | Medium | Should fix |
|
||||
|
||||
---
|
||||
|
||||
## ✅ KESIMPULAN
|
||||
|
||||
### **Overall Quality: 🟢 BAIK (7.5/10)**
|
||||
|
||||
**Strengths:**
|
||||
1. ✅ UI/UX konsisten & responsive
|
||||
2. ✅ File upload handling solid (iframe preview untuk dokumen)
|
||||
3. ✅ Form validation dengan Zod schema
|
||||
4. ✅ State management terstruktur (Valtio)
|
||||
5. ✅ Error handling comprehensive (terutama di kategori update)
|
||||
6. ✅ **Edit form reset sudah benar** (original data tracking)
|
||||
7. ✅ Modal konfirmasi hapus untuk user safety
|
||||
|
||||
**Areas for Improvement:**
|
||||
1. ⚠️ **Security:** HTML injection di deskripsi (prioritas)
|
||||
2. ⚠️ **Consistency:** Fetch method pattern (ApiFetch vs fetch manual)
|
||||
3. ⚠️ **Loading States:** findUnique tidak ada loading state management
|
||||
4. ⚠️ **Type Safety:** Reduce `any` usage, gunakan Prisma types
|
||||
5. ⚠️ **Schema:** deletedAt default value bisa menyebabkan logic issue
|
||||
|
||||
**Recommended Next Steps:**
|
||||
1. **Fix HTML injection** dengan DOMPurify atau backend validation
|
||||
2. **Refactor fetch methods** untuk gunakan ApiFetch consistently
|
||||
3. **Add loading state** di findUnique operations
|
||||
4. **Fix deletedAt schema** untuk soft delete yang benar
|
||||
5. **Optional:** Improve type safety dengan remove `any`
|
||||
|
||||
---
|
||||
|
||||
## 📈 COMPARISON WITH OTHER MODULES
|
||||
|
||||
| Aspect | Profil Module | Desa Anti Korupsi | Notes |
|
||||
|--------|--------------|-------------------|-------|
|
||||
| Fetch Pattern | ⚠️ Mixed | ⚠️ Mixed | Both perlu refactor |
|
||||
| Loading State | ⚠️ Some missing | ⚠️ Some missing | Same issue |
|
||||
| Edit Form Reset | ✅ Good | ✅ Good | Consistent |
|
||||
| Type Safety | ⚠️ Some `any` | ⚠️ Some `any` | Same issue |
|
||||
| HTML Injection | ⚠️ Present | ⚠️ Present | Both need fix |
|
||||
| File Upload | ✅ Images | ✅ Documents | Different use case |
|
||||
| Error Handling | ✅ Good | ✅ Good (better) | DAK more thorough |
|
||||
|
||||
---
|
||||
|
||||
**Catatan:** Secara keseluruhan, modul Desa Anti Korupsi sudah **production-ready** dengan beberapa improvements yang bisa dilakukan secara incremental. Module ini memiliki error handling yang lebih thorough dibanding module Profil, terutama di kategori update operation.
|
||||
@@ -1,875 +0,0 @@
|
||||
# QC Summary - Prestasi Desa Module
|
||||
|
||||
**Scope:** List Prestasi Desa, Kategori Prestasi Desa, Create, Edit, Detail
|
||||
**Date:** 2026-02-23
|
||||
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
|
||||
|
||||
---
|
||||
|
||||
## 📊 OVERVIEW
|
||||
|
||||
| Aspect | Schema | API | UI Admin | State Management | Overall |
|
||||
|--------|--------|-----|----------|-----------------|---------|
|
||||
| Prestasi Desa | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 Perlu fix |
|
||||
| Kategori Prestasi | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 Perlu fix |
|
||||
|
||||
---
|
||||
|
||||
## ✅ YANG SUDAH BAIK
|
||||
|
||||
### **1. UI/UX Consistency**
|
||||
- ✅ Responsive design (desktop table + mobile cards)
|
||||
- ✅ Loading states dengan Skeleton
|
||||
- ✅ Search dengan debounce (1000ms)
|
||||
- ✅ Pagination konsisten
|
||||
- ✅ Empty state handling yang informatif
|
||||
- ✅ Modal konfirmasi hapus
|
||||
|
||||
### **2. File Upload Handling**
|
||||
- ✅ Dropzone dengan preview image
|
||||
- ✅ Validasi format gambar (JPEG, JPG, PNG, WEBP)
|
||||
- ✅ Validasi ukuran file (max 5MB)
|
||||
- ✅ Tombol hapus preview (IconX di pojok kanan atas)
|
||||
- ✅ URL.createObjectURL untuk preview lokal
|
||||
- ✅ Preview dengan max height yang proper
|
||||
|
||||
### **3. Form Validation**
|
||||
- ✅ Zod schema untuk validasi typed
|
||||
- ✅ isFormValid() check sebelum submit
|
||||
- ✅ Error toast dengan pesan spesifik
|
||||
- ✅ Button disabled saat invalid/loading
|
||||
|
||||
### **4. CRUD Operations**
|
||||
- ✅ Create dengan upload file
|
||||
- ✅ FindMany dengan pagination & search
|
||||
- ✅ FindUnique untuk detail
|
||||
- ✅ Delete dengan hard delete (via Prisma)
|
||||
- ✅ Update dengan file replacement
|
||||
|
||||
### **5. Edit Form - Original Data Tracking**
|
||||
- ✅ Original data state untuk reset form
|
||||
- ✅ Load data existing dengan benar
|
||||
- ✅ Preview image dari data lama
|
||||
- ✅ Reset form mengembalikan ke data original
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// edit/page.tsx - Line ~70-95
|
||||
const data = await editState.edit.load(id);
|
||||
|
||||
setOriginalData({
|
||||
name: data.name,
|
||||
deskripsi: data.deskripsi,
|
||||
kategoriId: data.kategoriId,
|
||||
imageId: data.imageId,
|
||||
imageUrl: data.image?.link || "",
|
||||
});
|
||||
|
||||
setFormData({
|
||||
name: data.name,
|
||||
deskripsi: data.deskripsi,
|
||||
kategoriId: data.kategoriId,
|
||||
imageId: data.imageId,
|
||||
});
|
||||
|
||||
if (data.image?.link) setPreviewFile(data.image.link);
|
||||
|
||||
// Line ~105 - Handle reset
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
name: originalData.name,
|
||||
deskripsi: originalData.deskripsi,
|
||||
kategoriId: originalData.kategoriId,
|
||||
imageId: originalData.imageId,
|
||||
});
|
||||
setPreviewFile(originalData.imageUrl || null);
|
||||
setFile(null);
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - Original data tracking sudah implementasi dengan baik.
|
||||
|
||||
---
|
||||
|
||||
### **6. State Management - Good Practices**
|
||||
- ✅ Proper typing dengan Prisma types
|
||||
- ✅ Loading state management dengan finally block
|
||||
- ✅ Error handling yang comprehensive
|
||||
- ✅ Reset function untuk cleanup
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// state file - Line ~70-95
|
||||
load: async (page = 1, limit = 10, search = "") => {
|
||||
prestasiDesa.findMany.loading = true; // ✅ Start loading
|
||||
prestasiDesa.findMany.page = page;
|
||||
prestasiDesa.findMany.search = search;
|
||||
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
if (search) query.search = search;
|
||||
|
||||
const res = await ApiFetch.api.landingpage.prestasidesa["find-many"].get({ query });
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
prestasiDesa.findMany.data = res.data.data ?? [];
|
||||
prestasiDesa.findMany.totalPages = res.data.totalPages ?? 1;
|
||||
} else {
|
||||
prestasiDesa.findMany.data = [];
|
||||
prestasiDesa.findMany.totalPages = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Gagal fetch prestasi desa paginated:", err);
|
||||
prestasiDesa.findMany.data = [];
|
||||
prestasiDesa.findMany.totalPages = 1;
|
||||
} finally {
|
||||
prestasiDesa.findMany.loading = false; // ✅ Stop loading
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - Loading state management sudah proper.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ ISSUES & SARAN PERBAIKAN
|
||||
|
||||
### **🔴 CRITICAL**
|
||||
|
||||
#### **1. Schema - deletedAt Default Value SALAH**
|
||||
|
||||
**Lokasi:** `prisma/schema.prisma` (line 239-240)
|
||||
|
||||
**Masalah:**
|
||||
```prisma
|
||||
model PrestasiDesa {
|
||||
// ...
|
||||
deletedAt DateTime @default(now()) // ❌ SALAH - selalu punya default value
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model KategoriPrestasiDesa {
|
||||
// ...
|
||||
deletedAt DateTime @default(now()) // ❌ SALAH - selalu punya default value
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- **LOGIC ERROR!** Setiap record baru langsung punya `deletedAt` value (timestamp creation)
|
||||
- Soft delete tidak berfungsi dengan benar
|
||||
- Query dengan `where: { deletedAt: null }` tidak akan pernah return data
|
||||
- Data yang "dihapus" vs data "aktif" tidak bisa dibedakan
|
||||
|
||||
**Contoh Issue:**
|
||||
```prisma
|
||||
// Record baru dibuat
|
||||
CREATE PrestasiDesa {
|
||||
name: "Prestasi 1",
|
||||
// deletedAt otomatis ter-set ke now() ❌
|
||||
// isActive: true ✅
|
||||
}
|
||||
|
||||
// Query untuk data aktif (seharusnya return data ini)
|
||||
prisma.prestasiDesa.findMany({
|
||||
where: { deletedAt: null, isActive: true }
|
||||
})
|
||||
// ❌ Return kosong! Karena deletedAt sudah ter-set
|
||||
```
|
||||
|
||||
**Rekomendasi:** Fix schema:
|
||||
```prisma
|
||||
model PrestasiDesa {
|
||||
// ...
|
||||
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model KategoriPrestasiDesa {
|
||||
// ...
|
||||
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 **CRITICAL**
|
||||
**Effort:** Medium (perlu migration)
|
||||
**Impact:** **HIGH** (data integrity & soft delete logic)
|
||||
|
||||
---
|
||||
|
||||
#### **2. State Management - Inconsistency Fetch Pattern**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/prestasi-desa.ts`
|
||||
|
||||
**Masalah:** Ada 2 pattern berbeda untuk fetch API:
|
||||
|
||||
```typescript
|
||||
// ❌ Pattern 1: ApiFetch (create, findMany)
|
||||
const res = await ApiFetch.api.landingpage.prestasidesa["create"].post({...});
|
||||
const res = await ApiFetch.api.landingpage.prestasidesa["find-many"].get({query});
|
||||
|
||||
// ❌ Pattern 2: fetch manual (findUnique, edit, delete)
|
||||
const res = await fetch(`/api/landingpage/prestasidesa/${id}`);
|
||||
const response = await fetch(`/api/landingpage/prestasidesa/del/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- Code consistency buruk
|
||||
- Sulit maintenance
|
||||
- Type safety tidak konsisten
|
||||
- Duplikasi logic error handling
|
||||
|
||||
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
|
||||
|
||||
```typescript
|
||||
// ✅ Unified pattern
|
||||
async load(id: string) {
|
||||
try {
|
||||
prestasiDesa.edit.loading = true;
|
||||
const res = await ApiFetch.api.landingpage.prestasidesa[id].get();
|
||||
|
||||
if (res.data?.success) {
|
||||
const data = res.data.data;
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
name: data.name,
|
||||
deskripsi: data.deskripsi,
|
||||
imageId: data.imageId,
|
||||
kategoriId: data.kategoriId,
|
||||
};
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(res.data?.message || "Gagal memuat data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading prestasi desa:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Gagal memuat data");
|
||||
return null;
|
||||
} finally {
|
||||
prestasiDesa.edit.loading = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 High
|
||||
**Effort:** Medium (refactor di findUnique, edit, delete methods)
|
||||
|
||||
---
|
||||
|
||||
#### **3. findUnique State - Tidak Ada Loading State Management**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/prestasi-desa.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~110 - prestasiDesa.findUnique.load()
|
||||
async load(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/landingpage/prestasidesa/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
prestasiDesa.findUnique.data = data.data ?? null;
|
||||
} else {
|
||||
console.error("Failed to fetch data", res.status, res.statusText);
|
||||
prestasiDesa.findUnique.data = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
prestasiDesa.findUnique.data = null;
|
||||
}
|
||||
// ❌ MISSING: finally block untuk stop loading
|
||||
// ❌ MISSING: loading state initialization
|
||||
}
|
||||
```
|
||||
|
||||
**Dampak:** UI mungkin stuck di loading state jika ada error.
|
||||
|
||||
**Rekomendasi:** Tambahkan loading state dan finally block:
|
||||
|
||||
```typescript
|
||||
async load(id: string) {
|
||||
try {
|
||||
prestasiDesa.findUnique.loading = true; // ✅ Start loading
|
||||
const res = await fetch(`/api/landingpage/prestasidesa/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
prestasiDesa.findUnique.data = data.data ?? null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
} finally {
|
||||
prestasiDesa.findUnique.loading = false; // ✅ Stop loading
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟡 MEDIUM**
|
||||
|
||||
#### **4. HTML Injection Risk - dangerouslySetInnerHTML**
|
||||
|
||||
**Lokasi:**
|
||||
- `list-prestasi-desa/page.tsx` (line ~90, 145)
|
||||
- `list-prestasi-desa/[id]/page.tsx` (line ~85)
|
||||
- `list-prestasi-desa/create/page.tsx` (CreateEditor component)
|
||||
- `list-prestasi-desa/[id]/edit/page.tsx` (EditEditor component)
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// ❌ Direct HTML render tanpa sanitization
|
||||
<Text
|
||||
lineClamp={1}
|
||||
fz="md"
|
||||
c="dimmed"
|
||||
lh={1.5}
|
||||
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
|
||||
/>
|
||||
```
|
||||
|
||||
**Risk:**
|
||||
- XSS attack jika admin input script malicious
|
||||
- Bisa inject iframe, script tag, dll
|
||||
- Security vulnerability
|
||||
|
||||
**Rekomendasi:** Gunakan DOMPurify atau library sanitization:
|
||||
|
||||
```typescript
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Sanitize sebelum render
|
||||
const sanitizedHtml = DOMPurify.sanitize(item.deskripsi);
|
||||
<Text
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
Atau validasi di backend untuk whitelist tag HTML yang diperbolehkan (hanya `<p>`, `<ul>`, `<li>`, `<strong>`, dll).
|
||||
|
||||
**Priority:** 🟡 Medium (**Security concern**)
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **5. Type Safety - Any Usage**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/prestasi-desa.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~73
|
||||
const query: any = { page, limit }; // ❌ Using 'any'
|
||||
if (search) query.search = search;
|
||||
|
||||
// Line ~270
|
||||
const query: any = { page, limit }; // ❌ Using 'any'
|
||||
if (search) query.search = search;
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan typed query:
|
||||
|
||||
```typescript
|
||||
// Define type
|
||||
interface FindManyQuery {
|
||||
page: number | string;
|
||||
limit: number | string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// Use typed query
|
||||
const query: FindManyQuery = { page, limit };
|
||||
if (search) query.search = search;
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **6. Console.log di Production**
|
||||
|
||||
**Lokasi:** Multiple places di state file
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~48
|
||||
console.log(error);
|
||||
toast.error("Gagal menambahkan data");
|
||||
|
||||
// Line ~120
|
||||
console.error("Failed to fetch data", res.status, res.statusText);
|
||||
|
||||
// Line ~124
|
||||
console.error("Error fetching data:", error);
|
||||
|
||||
// Line ~300
|
||||
console.log(error);
|
||||
toast.error("Gagal menambahkan data");
|
||||
|
||||
// ... dan banyak lagi
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan conditional logging:
|
||||
|
||||
```typescript
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error("Error:", error);
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **7. Error Message Tidak Konsisten**
|
||||
|
||||
**Lokasi:** Multiple places
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Create - Line ~46
|
||||
return toast.error("Gagal menambahkan data");
|
||||
|
||||
// Create - Line ~48
|
||||
toast.error("Gagal menambahkan data");
|
||||
|
||||
// Delete - Line ~150
|
||||
toast.error("Terjadi kesalahan saat menghapus prestasi desa");
|
||||
|
||||
// Edit - Line ~200
|
||||
toast.error("Gagal memuat data");
|
||||
|
||||
// Edit update - Line ~240
|
||||
toast.error("Gagal mengupdate prestasi desa");
|
||||
|
||||
// Toast success - Line ~235
|
||||
toast.success("Berhasil update prestasi desa");
|
||||
```
|
||||
|
||||
**Issue:**
|
||||
- Inconsistent capitalization
|
||||
- Mixed patterns ("Gagal menambahkan" vs "Terjadi kesalahan")
|
||||
- Generic messages
|
||||
|
||||
**Rekomendasi:** Standardisasi error messages:
|
||||
|
||||
```typescript
|
||||
// Pattern: "[Action] [resource] gagal" dengan proper casing
|
||||
toast.error("Menambahkan data Prestasi Desa gagal");
|
||||
toast.error("Menghapus data Prestasi Desa gagal");
|
||||
toast.error("Memuat data Prestasi Desa gagal");
|
||||
toast.error("Memperbarui data Prestasi Desa gagal");
|
||||
|
||||
// Atau lebih spesifik dengan context
|
||||
toast.error("Gagal menambahkan data Prestasi Desa");
|
||||
toast.error("Gagal menghapus Prestasi Desa");
|
||||
toast.success("Berhasil memperbarui Prestasi Desa");
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **8. Zod Schema - Error Message Tidak Akurat**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/prestasi-desa.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~8
|
||||
const templateprestasiDesaForm = z.object({
|
||||
name: z.string().min(1, "Judul minimal 1 karakter"), // ⚠️ "Judul" instead of "Nama"
|
||||
deskripsi: z.string().min(1, "Deskripsi minimal 1 karakter"), // ✅ OK
|
||||
imageId: z.string().min(1, "File minimal 1"), // ⚠️ Generic
|
||||
kategoriId: z.string().min(1, "Kategori minimal 1 karakter"), // ⚠️ "Kategori" instead of "Kategori Prestasi"
|
||||
});
|
||||
```
|
||||
|
||||
**Dampak:** User confusion saat validasi error muncul.
|
||||
|
||||
**Rekomendasi:** Fix error messages:
|
||||
|
||||
```typescript
|
||||
const templateprestasiDesaForm = z.object({
|
||||
name: z.string().min(1, "Nama prestasi wajib diisi"),
|
||||
deskripsi: z.string().min(1, "Deskripsi prestasi wajib diisi"),
|
||||
imageId: z.string().min(1, "Gambar prestasi wajib diunggah"),
|
||||
kategoriId: z.string().min(1, "Kategori prestasi wajib dipilih"),
|
||||
});
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟢 LOW (Minor Polish)**
|
||||
|
||||
#### **9. Component Name Mismatch**
|
||||
|
||||
**Lokasi:** `list-prestasi-desa/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~11
|
||||
function ListPrestasiDesa() {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Line ~27
|
||||
function ListPrestasi({ search }: { search: string }) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ⚠️ Function name tidak konsisten dengan file name
|
||||
```
|
||||
|
||||
**Rekomendasi:** Rename ke yang lebih descriptive:
|
||||
```typescript
|
||||
function ListPrestasiDesaPage() {
|
||||
// ...
|
||||
}
|
||||
|
||||
function ListPrestasiDesaTable({ search }: { search: string }) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **10. Pagination onChange Tidak Include Search**
|
||||
|
||||
**Lokasi:** `list-prestasi-desa/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~170
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={load} // ⚠️ Hanya pass page number
|
||||
total={totalPages}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
**Issue:** Saat ganti page, search query hilang karena `load` dipanggil hanya dengan page number.
|
||||
|
||||
**Rekomendasi:** Include search dan limit:
|
||||
```typescript
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => load(newPage, 10, debouncedSearch)} // ✅ Include all params
|
||||
total={totalPages}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **11. Mobile Pagination - load Function Tidak Lengkap**
|
||||
|
||||
**Lokasi:** `kategori-prestasi-desa/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~170 (Desktop)
|
||||
onChange={(newPage) => load(newPage)} // ⚠️ Missing limit & search
|
||||
|
||||
// Line ~200 (Mobile)
|
||||
onChange={(newPage) => load(newPage)} // ⚠️ Missing limit & search
|
||||
```
|
||||
|
||||
**Rekomendasi:** Include all params:
|
||||
```typescript
|
||||
onChange={(newPage) => load(newPage, 10, debouncedSearch)}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **12. Duplicate Error Logging**
|
||||
|
||||
**Lokasi:** Multiple files
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// edit/page.tsx - Line ~100
|
||||
} catch (error) {
|
||||
console.error('Error loading prestasi desa:', error); // ❌ Duplicate
|
||||
toast.error('Gagal memuat data prestasi desa');
|
||||
}
|
||||
|
||||
// edit/page.tsx - Line ~130
|
||||
} catch (error) {
|
||||
console.error('Error updating prestasi desa:', error); // ❌ Duplicate
|
||||
toast.error('Terjadi kesalahan saat memperbarui prestasi desa');
|
||||
}
|
||||
```
|
||||
|
||||
**Rekomendasi:** Cukup satu logging yang informatif:
|
||||
```typescript
|
||||
} catch (error) {
|
||||
console.error('Failed to load Prestasi Desa:', err);
|
||||
toast.error('Gagal memuat data Prestasi Desa');
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **13. Inconsistent Button Label**
|
||||
|
||||
**Lokasi:** Multiple files
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// create/page.tsx - Line ~200
|
||||
<Button ...>Reset</Button>
|
||||
|
||||
// edit/page.tsx - Line ~180
|
||||
<Button ...>Batal</Button>
|
||||
|
||||
// Should be consistent: "Reset" atau "Batal"
|
||||
```
|
||||
|
||||
**Rekomendasi:** Standardisasi:
|
||||
```typescript
|
||||
// Create: "Reset"
|
||||
// Edit: "Batal" (lebih descriptive untuk cancel changes)
|
||||
// OR both: "Reset" / "Batal"
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **14. Search Placeholder Tidak Spesifik**
|
||||
|
||||
**Lokasi:**
|
||||
- `list-prestasi-desa/page.tsx`: `placeholder='Cari nama prestasi...'` ✅ OK
|
||||
- `kategori-prestasi-desa/page.tsx`: `placeholder='Cari kategori prestasi...'` ✅ OK
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - Placeholder sudah spesifik.
|
||||
|
||||
**Priority:** 🟢 None
|
||||
**Effort:** None
|
||||
|
||||
---
|
||||
|
||||
#### **15. Response Clone Overkill di Kategori Edit**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/prestasi-desa.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~370 - kategoriPrestasi.edit.update()
|
||||
const response = await fetch(...);
|
||||
const responseClone = response.clone();
|
||||
|
||||
try {
|
||||
const result = await response.json();
|
||||
// ...
|
||||
} catch (error) {
|
||||
try {
|
||||
const text = await responseClone.text();
|
||||
console.error("Error response text:", text);
|
||||
throw new Error(`Gagal memproses respons dari server: ${text}`);
|
||||
} catch (textError) {
|
||||
console.error("Error parsing response as text:", textError);
|
||||
console.error("Original error:", error);
|
||||
throw new Error("Gagal memproses respons dari server");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Analysis:**
|
||||
- ✅ **GOOD:** Error handling sangat thorough
|
||||
- ⚠️ **OVERKILL:** Untuk production API yang stable, ini berlebihan
|
||||
- ⚠️ **INCONSISTENT:** Module lain tidak punya error handling se-detail ini
|
||||
|
||||
**Rekomendasi:** Simplify untuk consistency:
|
||||
|
||||
```typescript
|
||||
async update() {
|
||||
try {
|
||||
kategoriPrestasi.edit.loading = true;
|
||||
|
||||
const response = await fetch(`/api/landingpage/kategoriprestasi/${this.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: this.form.name }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message || "Berhasil update");
|
||||
await kategoriPrestasi.findMany.load();
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error(result.message || "Gagal update");
|
||||
} catch (error) {
|
||||
console.error("Error updating:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Gagal update");
|
||||
return false;
|
||||
} finally {
|
||||
kategoriPrestasi.edit.loading = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
## 📋 RINGKASAN ACTION ITEMS
|
||||
|
||||
| Priority | Issue | Module | Impact | Effort | Status |
|
||||
|----------|-------|--------|--------|--------|--------|
|
||||
| 🔴 P0 | **Schema deletedAt default SALAH** | Schema | **CRITICAL** | Medium | **MUST FIX** |
|
||||
| 🔴 P0 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
|
||||
| 🔴 P1 | Missing loading state in findUnique | State | Medium | Low | Perlu fix |
|
||||
| 🟡 M | HTML injection risk | UI | **High (Security)** | Low | **Should fix** |
|
||||
| 🟡 M | Type safety (any usage) | State | Low | Low | Optional |
|
||||
| 🟡 M | Console.log in production | State | Low | Low | Optional |
|
||||
| 🟡 M | Error message inconsistency | State/UI | Low | Low | Optional |
|
||||
| 🟡 M | Zod schema error messages | State | Low | Low | Should fix |
|
||||
| 🟢 L | Component name mismatch | List UI | Low | Low | Optional |
|
||||
| 🟢 L | Pagination missing search param | List UI | Low | Low | Should fix |
|
||||
| 🟢 L | Duplicate error logging | UI | Low | Low | Optional |
|
||||
| 🟢 L | Inconsistent button label | UI | Low | Low | Optional |
|
||||
| 🟢 L | Response clone overkill | State (Kategori) | Low | Low | Optional |
|
||||
|
||||
---
|
||||
|
||||
## ✅ KESIMPULAN
|
||||
|
||||
### **Overall Quality: 🟢 BAIK (7/10)**
|
||||
|
||||
**Strengths:**
|
||||
1. ✅ UI/UX konsisten & responsive
|
||||
2. ✅ File upload handling solid
|
||||
3. ✅ Form validation dengan Zod schema
|
||||
4. ✅ State management terstruktur (Valtio)
|
||||
5. ✅ **Edit form reset sudah benar** (original data tracking)
|
||||
6. ✅ Loading state management di findMany (dengan finally block)
|
||||
7. ✅ Modal konfirmasi hapus untuk user safety
|
||||
|
||||
**Critical Issues:**
|
||||
1. ⚠️ **Schema deletedAt default SALAH** - Logic error untuk soft delete (CRITICAL)
|
||||
2. ⚠️ Fetch method pattern inconsistency (ApiFetch vs fetch manual)
|
||||
3. ⚠️ findUnique tidak ada loading state management
|
||||
|
||||
**Areas for Improvement:**
|
||||
1. ⚠️ **Fix schema deletedAt** dari `@default(now())` ke `@default(null)` dengan nullable
|
||||
2. ⚠️ **Refactor fetch methods** untuk gunakan ApiFetch consistently
|
||||
3. ⚠️ **Add loading state** di findUnique operations
|
||||
4. ⚠️ **Fix HTML injection** dengan DOMPurify atau backend validation
|
||||
5. ⚠️ **Improve type safety** dengan remove `any` usage
|
||||
6. ⚠️ **Standardisasi error messages** di Zod schema dan toast
|
||||
|
||||
**Recommended Next Steps:**
|
||||
1. **🔴 CRITICAL: Fix schema deletedAt** - 30 menit (perlu migration)
|
||||
2. **🔴 HIGH:** Refactor findUnique, edit, delete ke ApiFetch - 1 jam
|
||||
3. **🔴 HIGH:** Add loading state di findUnique - 15 menit
|
||||
4. **🟡 MEDIUM:** Fix HTML injection dengan DOMPurify - 30 menit
|
||||
5. **🟡 MEDIUM:** Improve type safety - 30 menit
|
||||
6. **🟢 LOW:** Polish minor issues - 30 menit
|
||||
|
||||
---
|
||||
|
||||
## 📈 COMPARISON WITH OTHER MODULES
|
||||
|
||||
| Aspect | Profil | Desa Anti Korupsi | SDGs Desa | APBDes | Prestasi Desa | Notes |
|
||||
|--------|--------|-------------------|-----------|--------|---------------|-------|
|
||||
| Fetch Pattern | ⚠️ Mixed | ⚠️ Mixed | ⚠️ Mixed | ⚠️ Mixed | ⚠️ Mixed | All perlu refactor |
|
||||
| Loading State | ⚠️ Some missing | ⚠️ Some missing | ⚠️ Missing | ✅ Good | ⚠️ findUnique missing | Similar issue |
|
||||
| Edit Form Reset | ✅ Good | ✅ Good | ✅ Good | ✅ Good | ✅ Good | All consistent |
|
||||
| Type Safety | ⚠️ Some `any` | ⚠️ Some `any` | ⚠️ Some `any` | ⚠️ Some `any` | ⚠️ Some `any` | Same issue |
|
||||
| File Upload | ✅ Images | ✅ Documents | ✅ Images | ✅ Dual | ✅ Images | APBDes paling complex |
|
||||
| Error Handling | ✅ Good | ✅ Good (better) | ✅ Good | ✅ Good | ✅ Good | Consistent |
|
||||
| **Schema deletedAt** | ⚠️ Issue | ⚠️ Issue | ⚠️ Issue | ✅ Good | ❌ **WRONG** | **Prestasi CRITICAL** |
|
||||
| HTML Injection | ⚠️ Present | ⚠️ Present | N/A | N/A | ⚠️ Present | Security concern |
|
||||
| Complexity | Low | Medium | Low | **High** | Medium | APBDes paling complex |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 UNIQUE FEATURES OF PRESTASI DESA MODULE
|
||||
|
||||
**Standard Complexity:**
|
||||
1. **Single file upload** (gambar) - similar to SDGs, Profil
|
||||
2. **Kategori relation** - similar to Desa Anti Korupsi
|
||||
3. **Rich text editor** (deskripsi) - similar to Desa Anti Korupsi
|
||||
|
||||
**Best Practices:**
|
||||
1. ✅ Loading state management di findMany (dengan finally block) - better than SDGs
|
||||
2. ✅ Edit form reset comprehensive (preserve all fields)
|
||||
3. ✅ Proper typing di findMany (Prisma types)
|
||||
|
||||
**Critical Issues:**
|
||||
1. ❌ **Schema deletedAt SALAH** - sama seperti SDGs & Desa Anti Korupsi, tapi APBDes sudah benar
|
||||
|
||||
---
|
||||
|
||||
**Catatan:** Secara keseluruhan, modul Prestasi Desa sudah **production-ready** dengan beberapa improvements yang bisa dilakukan secara incremental. Module ini memiliki struktur yang mirip dengan modul Desa Anti Korupsi (kategori relation, rich text editor, file upload).
|
||||
|
||||
**Unique Issues:**
|
||||
1. Schema deletedAt default value yang salah (sama seperti SDGs & Desa Anti Korupsi)
|
||||
2. HTML injection risk di deskripsi (sama seperti Desa Anti Korupsi)
|
||||
3. Fetch pattern inconsistency (sama seperti semua modul lain)
|
||||
|
||||
**Priority Action:**
|
||||
```diff
|
||||
🔴 FIX INI SEKARANG (30 MENIT + MIGRATION):
|
||||
File: prisma/schema.prisma
|
||||
Line: 239-240, 248-249
|
||||
|
||||
model PrestasiDesa {
|
||||
// ...
|
||||
- deletedAt DateTime @default(now())
|
||||
+ deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model KategoriPrestasiDesa {
|
||||
// ...
|
||||
- deletedAt DateTime @default(now())
|
||||
+ deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
# Lalu jalankan:
|
||||
bunx prisma db push
|
||||
# atau
|
||||
bunx prisma migrate dev --name fix_deletedat_default
|
||||
```
|
||||
|
||||
Setelah fix critical schema issue, module ini production-ready! 🎉
|
||||
@@ -1,488 +0,0 @@
|
||||
# QC Summary - Profil Landing Page Module
|
||||
|
||||
**Scope:** Media Sosial, Pejabat Desa, Program Inovasi
|
||||
**Date:** 2026-02-23
|
||||
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement minor
|
||||
|
||||
---
|
||||
|
||||
## 📊 OVERVIEW
|
||||
|
||||
| Module | Schema | API | UI Admin | Public Page | Overall |
|
||||
|--------|--------|-----|----------|-------------|---------|
|
||||
| Media Sosial | ✅ Baik | ✅ Baik | ✅ Baik | N/A | 🟢 Baik |
|
||||
| Pejabat Desa | ✅ Baik | ⚠️ Ada issue | ✅ Baik | N/A | 🟡 Perlu fix |
|
||||
| Program Inovasi | ✅ Baik | ✅ Baik | ✅ Baik | N/A | 🟢 Baik |
|
||||
|
||||
---
|
||||
|
||||
## ✅ YANG SUDAH BAIK (COMMON)
|
||||
|
||||
### **1. Konsistensi UI/UX**
|
||||
- ✅ Semua halaman menggunakan pattern yang sama (list → detail → edit)
|
||||
- ✅ Responsive design (desktop table + mobile cards)
|
||||
- ✅ Loading states dengan Skeleton
|
||||
- ✅ Empty state handling yang informatif
|
||||
- ✅ Search dengan debounce (1000ms)
|
||||
- ✅ Pagination konsisten di semua modul
|
||||
|
||||
### **2. File Upload Handling**
|
||||
- ✅ Dropzone dengan preview image
|
||||
- ✅ Validasi format & ukuran file (max 5MB)
|
||||
- ✅ Tombol hapus preview (IconX di pojok kanan atas)
|
||||
- ✅ URL.createObjectURL untuk preview lokal
|
||||
- ✅ Cleanup file state saat reset form
|
||||
|
||||
### **3. Form Validation**
|
||||
- ✅ Zod schema untuk validasi typed
|
||||
- ✅ isFormValid() check sebelum submit
|
||||
- ✅ Error toast dengan pesan spesifik
|
||||
- ✅ Button disabled saat invalid/loading
|
||||
|
||||
### **4. State Management (Valtio)**
|
||||
- ✅ Proxy state untuk reaktivitas
|
||||
- ✅ Separate state per modul (programInovasi, pejabatDesa, mediaSosial)
|
||||
- ✅ Reset form function di setiap create/edit
|
||||
- ✅ Original data tracking untuk reset
|
||||
|
||||
### **5. Error Handling**
|
||||
- ✅ Try-catch di semua async operation
|
||||
- ✅ Toast error dengan pesan user-friendly
|
||||
- ✅ Console.error untuk debugging
|
||||
- ✅ Modal konfirmasi hapus
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ ISSUES & SARAN PERBAIKAN
|
||||
|
||||
### **🔴 CRITICAL**
|
||||
|
||||
#### **1. Pejabat Desa - Edit Form Tidak Reset imageId ke Original**
|
||||
**Lokasi:** `src/app/admin/(dashboard)/landing-page/profil/pejabat-desa/[id]/edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~100 - Load data
|
||||
setFormData({
|
||||
name: profileData.name || "",
|
||||
position: profileData.position || "",
|
||||
imageId: profileData.imageId || "", // ✅ Sudah benar
|
||||
});
|
||||
|
||||
// Line ~170 - Handle reset
|
||||
setFormData({
|
||||
name: originalData.name,
|
||||
position: originalData.position,
|
||||
imageId: originalData.imageId, // ✅ Sudah benar
|
||||
});
|
||||
```
|
||||
|
||||
**Status:** ✅ **SUDAH BENAR** - Tidak ada issue di sini
|
||||
|
||||
**Verdict:** Tidak ada action needed.
|
||||
|
||||
---
|
||||
|
||||
#### **2. Media Sosial - Edit Form Sudah Benar**
|
||||
**Lokasi:** `src/app/admin/(dashboard)/landing-page/profil/media-sosial/[id]/edit/page.tsx`
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - Original data tracking sudah implementasi dengan baik:
|
||||
```typescript
|
||||
const [originalData, setOriginalData] = useState({
|
||||
name: '',
|
||||
icon: '',
|
||||
iconUrl: '',
|
||||
imageId: '',
|
||||
imageUrl: '',
|
||||
});
|
||||
|
||||
// Load data
|
||||
setOriginalData({
|
||||
...newForm,
|
||||
imageUrl: data.image?.link || '',
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: originalData.name,
|
||||
icon: originalData.icon,
|
||||
iconUrl: originalData.iconUrl,
|
||||
imageId: originalData.imageId,
|
||||
});
|
||||
```
|
||||
|
||||
**Verdict:** Tidak ada action needed.
|
||||
|
||||
---
|
||||
|
||||
#### **3. Program Inovasi - Edit Form Sudah Benar**
|
||||
**Lokasi:** `src/app/admin/(dashboard)/landing-page/profil/program-inovasi/[id]/edit/page.tsx`
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - Original data tracking sudah implementasi dengan baik.
|
||||
|
||||
**Verdict:** Tidak ada action needed.
|
||||
|
||||
---
|
||||
|
||||
### **🟡 MEDIUM**
|
||||
|
||||
#### **4. Inconsistency: Fetch Method di State**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/profile.ts`
|
||||
|
||||
**Masalah:** Ada 3 pattern berbeda untuk fetch API:
|
||||
|
||||
```typescript
|
||||
// ❌ Pattern 1: ApiFetch (programInovasi.create)
|
||||
const res = await ApiFetch.api.landingpage.programinovasi["create"].post(formData);
|
||||
|
||||
// ❌ Pattern 2: fetch manual (programInovasi.findUnique)
|
||||
const res = await fetch(`/api/landingpage/programinovasi/${id}`);
|
||||
|
||||
// ❌ Pattern 3: fetch dengan headers (programInovasi.update)
|
||||
const response = await fetch(`/api/landingpage/programinovasi/${this.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({...}),
|
||||
});
|
||||
|
||||
// ❌ Pattern 4: fetch dengan delete (programInovasi.delete)
|
||||
const response = await fetch(`/api/landingpage/programinovasi/del/${id}`, {
|
||||
method: "DELETE",
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- Code consistency buruk
|
||||
- Sulit maintenance
|
||||
- Type safety tidak konsisten
|
||||
|
||||
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
|
||||
|
||||
```typescript
|
||||
// ✅统一 pattern
|
||||
const res = await ApiFetch.api.landingpage.programinovasi["create"].post(formData);
|
||||
const res = await ApiFetch.api.landingpage.programinovasi[id].get();
|
||||
const res = await ApiFetch.api.landingpage.programinovasi[id].put(data);
|
||||
const res = await ApiFetch.api.landingpage.programinovasi["del"][id].delete();
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Medium
|
||||
**Effort:** Low (refactor saja, tidak ada logic change)
|
||||
|
||||
---
|
||||
|
||||
#### **5. Media Sosial - Validasi IconUrl Tidak Selalu Relevan**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/landing-page/profil/media-sosial/create/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~67
|
||||
const isFormValid = () => {
|
||||
const isNameValid = stateMediaSosial.create.form.name?.trim() !== '';
|
||||
const isIconUrlValid = stateMediaSosial.create.form.iconUrl?.trim() !== ''; // ❌ Selalu required
|
||||
const isCustomIconValid = selectedSosmed !== 'custom' || file !== null;
|
||||
|
||||
return isNameValid && isIconUrlValid && isCustomIconValid;
|
||||
};
|
||||
```
|
||||
|
||||
**Scenario:**
|
||||
- User pilih icon "telephone" → iconUrl **seharusnya** required (nomor telepon)
|
||||
- User pilih icon "facebook" → iconUrl **seharusnya** required (URL profile)
|
||||
- Tapi jika user hanya mau tampil icon tanpa link → **tidak bisa**
|
||||
|
||||
**Rekomendasi:** Jadikan optional atau berikan default value:
|
||||
|
||||
```typescript
|
||||
const isFormValid = () => {
|
||||
const isNameValid = stateMediaSosial.create.form.name?.trim() !== '';
|
||||
// IconUrl optional, atau validasi berdasarkan selectedSosmed
|
||||
const isIconUrlValid = true; // atau validasi spesifik
|
||||
const isCustomIconValid = selectedSosmed !== 'custom' || file !== null;
|
||||
|
||||
return isNameValid && isCustomIconValid;
|
||||
};
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **6. Pejabat Desa - Hanya Ada 1 Data (Hardcoded ID "edit")**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/landing-page/profil/pejabat-desa/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~17
|
||||
useShallowEffect(() => {
|
||||
allList.findUnique.load("edit"); // ❌ Hardcoded ID
|
||||
}, []);
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- Tidak scalable jika nanti ada multiple pejabat desa
|
||||
- Pattern berbeda dari modul lain (yang pakai findMany)
|
||||
- Confusing untuk developer baru
|
||||
|
||||
**Rekomendasi:**
|
||||
- Jika memang hanya 1 data, tambahkan komentar:
|
||||
```typescript
|
||||
// Note: "edit" adalah special ID untuk single pejabat desa record
|
||||
// Backend akan return data pertama jika ID tidak ditemukan
|
||||
allList.findUnique.load("edit");
|
||||
```
|
||||
|
||||
- Atau gunakan pattern yang lebih clear:
|
||||
```typescript
|
||||
allList.findUnique.load("single"); // atau "default"
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low-Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **7. Program Inovasi - HTML Injection Risk di Deskripsi**
|
||||
|
||||
**Lokasi:**
|
||||
- `src/app/admin/(dashboard)/landing-page/profil/program-inovasi/page.tsx` (line ~107)
|
||||
- `src/app/admin/(dashboard)/landing-page/profil/program-inovasi/[id]/page.tsx` (line ~105)
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// ❌ Direct HTML render tanpa sanitization
|
||||
<Text dangerouslySetInnerHTML={{ __html: item.description || '-' }}></Text>
|
||||
```
|
||||
|
||||
**Risk:**
|
||||
- XSS attack jika admin input script malicious
|
||||
- Bisa inject iframe, script tag, dll
|
||||
|
||||
**Rekomendasi:** Gunakan DOMPurify atau library sanitization:
|
||||
|
||||
```typescript
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Sanitize sebelum render
|
||||
const sanitizedHtml = DOMPurify.sanitize(item.description);
|
||||
<Text dangerouslySetInnerHTML={{ __html: sanitizedHtml }}></Text>
|
||||
```
|
||||
|
||||
Atau validasi di backend untuk whitelist tag HTML yang diperbolehkan (hanya `<p>`, `<ul>`, `<li>`, dll).
|
||||
|
||||
**Priority:** 🟡 Medium (security concern)
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟢 LOW (Minor Polish)**
|
||||
|
||||
#### **8. Inconsistency: Button Size & Styling**
|
||||
|
||||
**Lokasi:** Multiple files
|
||||
|
||||
**Masalah:** Button styling tidak konsisten:
|
||||
|
||||
```typescript
|
||||
// Media Sosial create
|
||||
<Button size="md" ...>Simpan</Button>
|
||||
|
||||
// Program Inovasi create
|
||||
<Button size="md" ...>Simpan</Button>
|
||||
|
||||
// Pejabat Desa edit
|
||||
<Button size="md" ...>Simpan</Button>
|
||||
|
||||
// Media Sosial edit
|
||||
<Button size="md" ...>Simpan</Button>
|
||||
```
|
||||
|
||||
Tapi di detail page:
|
||||
```typescript
|
||||
// Semua detail page
|
||||
<Button size="md" ...> // ✅ Konsisten
|
||||
```
|
||||
|
||||
**Rekomendasi:** Buat konstanta untuk button size:
|
||||
```typescript
|
||||
const BUTTON_SIZE = "md";
|
||||
const BUTTON_VARIANT = "light";
|
||||
const BUTTON_RADIUS = "md";
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **9. Search Placeholder Tidak Spesifik**
|
||||
|
||||
**Lokasi:** Multiple list pages
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Media Sosial
|
||||
placeholder='Cari nama media sosial atau kontak...' // ✅ Spesifik
|
||||
|
||||
// Program Inovasi
|
||||
placeholder="Cari program inovasi..." // ✅ Oke
|
||||
|
||||
// Pejabat Desa
|
||||
// ❌ Tidak ada search feature
|
||||
```
|
||||
|
||||
**Rekomendasi:** Tambahkan search feature ke Pejabat Desa jika memungkinkan, atau berikan komentar kenapa tidak ada (karena hanya 1 data).
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **10. Loading State Tidak Selalu Akurat**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/profile.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~120 - findUnique.load untuk programInovasi
|
||||
async load(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/landingpage/programinovasi/${id}`);
|
||||
// ❌ Tidak ada loading state update di sini
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
programInovasi.findUnique.data = data.data ?? null;
|
||||
}
|
||||
} catch (error) {
|
||||
// ❌ Tidak ada finally block untuk stop loading
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Dampak:** UI mungkin stuck di loading state jika ada error.
|
||||
|
||||
**Rekomendasi:** Tambahkan finally block:
|
||||
```typescript
|
||||
async load(id: string) {
|
||||
try {
|
||||
programInovasi.findUnique.loading = true; // ✅ Start loading
|
||||
const res = await fetch(`/api/landingpage/programinovasi/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
programInovasi.findUnique.data = data.data ?? null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
} finally {
|
||||
programInovasi.findUnique.loading = false; // ✅ Stop loading
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **11. Type Safety - Any Usage**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/profile.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~75
|
||||
data: null as any[] | null, // ❌ Using 'any'
|
||||
|
||||
// Line ~120
|
||||
data: null as Prisma.ProgramInovasiGetPayload<{...}> | null, // ✅ Typed
|
||||
|
||||
// Line ~200
|
||||
data: null as any[] | null, // ❌ Using 'any'
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan typed data:
|
||||
```typescript
|
||||
data: null as Prisma.MediaSosialGetPayload<{ include: { image: true } }>[] | null
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Medium (perlu update semua reference)
|
||||
|
||||
---
|
||||
|
||||
#### **12. Console.log di Production**
|
||||
|
||||
**Lokasi:** Multiple files
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Media Sosial edit page (line ~170)
|
||||
console.log("Data yang akan dikirim ke backend:", stateMediaSosial.update.form);
|
||||
|
||||
// Profile state (multiple places)
|
||||
console.log("Failed to load program inovasi:", res.statusText);
|
||||
console.log((error as Error).message);
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan conditional logging:
|
||||
```typescript
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log("Data:", stateMediaSosial.update.form);
|
||||
}
|
||||
```
|
||||
|
||||
Atau gunakan logging library (winston, pino, dll).
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
## 📋 RINGKASAN ACTION ITEMS
|
||||
|
||||
| Priority | Issue | Module | Impact | Effort | Status |
|
||||
|----------|-------|--------|--------|--------|--------|
|
||||
| 🟡 M | Fetch method inconsistency | All | Medium | Low | Perlu refactor |
|
||||
| 🟡 M | IconUrl validation terlalu strict | Media Sosial | Low | Low | Perlu fix logic |
|
||||
| 🟡 M | HTML injection risk | Program Inovasi | **High (Security)** | Low | **Should fix** |
|
||||
| 🟢 L | Hardcoded ID "edit" | Pejabat Desa | Low | Low | Optional |
|
||||
| 🟢 L | Button styling inconsistency | All | Low | Low | Optional |
|
||||
| 🟢 L | Missing search feature | Pejabat Desa | Low | Low | Optional |
|
||||
| 🟢 L | Loading state inaccurate | All | Low | Low | Perlu fix |
|
||||
| 🟢 L | Type safety (any usage) | All | Low | Medium | Optional |
|
||||
| 🟢 L | Console.log in production | All | Low | Low | Optional |
|
||||
|
||||
---
|
||||
|
||||
## ✅ KESIMPULAN
|
||||
|
||||
### **Overall Quality: 🟢 BAIK (8/10)**
|
||||
|
||||
**Strengths:**
|
||||
1. ✅ UI/UX konsisten & responsive
|
||||
2. ✅ File upload handling sudah solid
|
||||
3. ✅ Form validation dengan Zod
|
||||
4. ✅ State management terstruktur
|
||||
5. ✅ Error handling comprehensive
|
||||
6. ✅ Edit form reset sudah benar di semua modul
|
||||
|
||||
**Areas for Improvement:**
|
||||
1. ⚠️ **Security:** HTML injection di deskripsi Program Inovasi (prioritas)
|
||||
2. ⚠️ **Consistency:** Fetch method pattern (ApiFetch vs fetch manual)
|
||||
3. ⚠️ **Type Safety:** Reduce `any` usage, gunakan Prisma types
|
||||
4. ⚠️ **Loading States:** Pastikan selalu ada finally block
|
||||
|
||||
**Recommended Next Steps:**
|
||||
1. **Fix HTML injection** dengan DOMPurify atau backend validation
|
||||
2. **Refactor fetch methods** untuk gunakan ApiFetch consistently
|
||||
3. **Add loading state cleanup** di semua async operations
|
||||
4. **Optional:** Improve type safety dengan remove `any`
|
||||
|
||||
---
|
||||
|
||||
**Catatan:** Secara keseluruhan, modul Profil sudah **production-ready** dengan minor improvements yang bisa dilakukan secara incremental.
|
||||
@@ -1,651 +0,0 @@
|
||||
# QC Summary - SDGs Desa Module
|
||||
|
||||
**Scope:** List SDGs Desa, Create, Edit, Detail
|
||||
**Date:** 2026-02-23
|
||||
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
|
||||
|
||||
---
|
||||
|
||||
## 📊 OVERVIEW
|
||||
|
||||
| Aspect | Schema | API | UI Admin | State Management | Overall |
|
||||
|--------|--------|-----|----------|-----------------|---------|
|
||||
| SDGs Desa | ✅ Baik | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 Perlu fix |
|
||||
|
||||
---
|
||||
|
||||
## ✅ YANG SUDAH BAIK
|
||||
|
||||
### **1. UI/UX Consistency**
|
||||
- ✅ Responsive design (desktop table + mobile cards)
|
||||
- ✅ Loading states dengan Skeleton
|
||||
- ✅ Search dengan debounce (1000ms)
|
||||
- ✅ Pagination konsisten
|
||||
- ✅ Empty state handling yang informatif
|
||||
- ✅ Modal konfirmasi hapus
|
||||
|
||||
### **2. File Upload Handling**
|
||||
- ✅ Dropzone dengan preview image
|
||||
- ✅ Validasi format gambar (JPEG, JPG, PNG, WEBP)
|
||||
- ✅ Validasi ukuran file (max 5MB)
|
||||
- ✅ Tombol hapus preview (IconX di pojok kanan atas)
|
||||
- ✅ URL.createObjectURL untuk preview lokal
|
||||
|
||||
### **3. Form Validation**
|
||||
- ✅ Zod schema untuk validasi typed
|
||||
- ✅ isFormValid() check sebelum submit
|
||||
- ✅ Error toast dengan pesan spesifik
|
||||
- ✅ Button disabled saat invalid/loading
|
||||
- ✅ Type number input untuk jumlah
|
||||
|
||||
### **4. CRUD Operations**
|
||||
- ✅ Create dengan upload file
|
||||
- ✅ FindMany dengan pagination & search
|
||||
- ✅ FindUnique untuk detail
|
||||
- ✅ Delete dengan hard delete (via Prisma)
|
||||
- ✅ Update dengan file replacement
|
||||
|
||||
### **5. Edit Form - Original Data Tracking**
|
||||
- ✅ Original data state untuk reset form
|
||||
- ✅ Load data existing dengan benar
|
||||
- ✅ Preview image dari data lama
|
||||
- ✅ Reset form mengembalikan ke data original
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// Line ~60-80 - Load data
|
||||
const data = await sdgsState.edit.load(id);
|
||||
|
||||
setFormData({
|
||||
name: data.name || "",
|
||||
jumlah: data.jumlah || "",
|
||||
imageId: data.imageId || "",
|
||||
});
|
||||
|
||||
setOriginalData({
|
||||
...newForm,
|
||||
imageUrl: data.image?.link || "",
|
||||
});
|
||||
|
||||
setPreviewImage(data.image?.link || null);
|
||||
|
||||
// Line ~90 - Handle reset
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
name: originalData.name,
|
||||
jumlah: originalData.jumlah,
|
||||
imageId: originalData.imageId,
|
||||
});
|
||||
setPreviewImage(originalData.imageUrl || null);
|
||||
setFile(null);
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - Original data tracking sudah implementasi dengan baik.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ ISSUES & SARAN PERBAIKAN
|
||||
|
||||
### **🔴 CRITICAL**
|
||||
|
||||
#### **1. State Management - Inconsistency Fetch Pattern**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/sdgs-desa.ts`
|
||||
|
||||
**Masalah:** Ada 2 pattern berbeda untuk fetch API:
|
||||
|
||||
```typescript
|
||||
// ❌ Pattern 1: ApiFetch (create, findMany)
|
||||
const res = await ApiFetch.api.landingpage.sdgsdesa["create"].post({...});
|
||||
const res = await ApiFetch.api.landingpage.sdgsdesa["findMany"].get({query});
|
||||
|
||||
// ❌ Pattern 2: fetch manual (findUnique, edit, delete)
|
||||
const res = await fetch(`/api/landingpage/sdgsdesa/${id}`);
|
||||
const response = await fetch(`/api/landingpage/sdgsdesa/del/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- Code consistency buruk
|
||||
- Sulit maintenance
|
||||
- Type safety tidak konsisten
|
||||
- Duplikasi logic error handling
|
||||
|
||||
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
|
||||
|
||||
```typescript
|
||||
// ✅ Unified pattern
|
||||
const res = await ApiFetch.api.landingpage.sdgsdesa["create"].post(data);
|
||||
const res = await ApiFetch.api.landingpage.sdgsdesa[id].get();
|
||||
const res = await ApiFetch.api.landingpage.sdgsdesa[id].put(data);
|
||||
const res = await ApiFetch.api.landingpage.sdgsdesa["del"][id].delete();
|
||||
```
|
||||
|
||||
**Priority:** 🔴 High
|
||||
**Effort:** Medium (refactor di semua state methods)
|
||||
|
||||
---
|
||||
|
||||
#### **2. findUnique State - Tidak Ada Loading State Management**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/sdgs-desa.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~125 - sdgsDesa.findUnique.load()
|
||||
async load(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/landingpage/sdgsdesa/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
sdgsDesa.findUnique.data = data.data ?? null;
|
||||
} else {
|
||||
console.error("Failed to fetch data", res.status, res.statusText);
|
||||
sdgsDesa.findUnique.data = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
sdgsDesa.findUnique.data = null;
|
||||
}
|
||||
// ❌ MISSING: finally block untuk stop loading
|
||||
}
|
||||
```
|
||||
|
||||
**Dampak:** UI mungkin stuck di loading state jika ada error.
|
||||
|
||||
**Rekomendasi:** Tambahkan loading state dan finally block:
|
||||
|
||||
```typescript
|
||||
async load(id: string) {
|
||||
try {
|
||||
sdgsDesa.findUnique.loading = true; // ✅ Start loading
|
||||
const res = await fetch(`/api/landingpage/sdgsdesa/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
sdgsDesa.findUnique.data = data.data ?? null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
} finally {
|
||||
sdgsDesa.findUnique.loading = false; // ✅ Stop loading
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **3. findManyAll - Tidak Digunakan di UI**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/sdgs-desa.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~95 - findManyAll state
|
||||
findManyAll: {
|
||||
data: null as any[] | null,
|
||||
loading: false,
|
||||
load: async () => {
|
||||
// ... fetch all data tanpa pagination
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Analysis:**
|
||||
- ⚠️ **UNUSED:** Tidak ada component yang menggunakan `findManyAll`
|
||||
- ⚠️ **DEAD CODE:** Menambah bundle size tanpa manfaat
|
||||
- ⚠️ **CONFUSING:** Developer baru bisa bingung kapan pakai findMany vs findManyAll
|
||||
|
||||
**Rekomendasi:** Remove jika tidak digunakan:
|
||||
```typescript
|
||||
// ❌ Remove entire findManyAll block
|
||||
```
|
||||
|
||||
Atau jika diperlukan untuk future feature, tambahkan comment:
|
||||
```typescript
|
||||
// Reserved for future use - dropdown select without pagination
|
||||
findManyAll: { ... }
|
||||
```
|
||||
|
||||
**Priority:** 🔴 Low-Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟡 MEDIUM**
|
||||
|
||||
#### **4. Type Safety - Any Usage**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/sdgs-desa.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~58
|
||||
data: null as any[] | null, // ❌ Using 'any'
|
||||
|
||||
// Line ~96
|
||||
data: null as any[] | null, // ❌ Using 'any'
|
||||
|
||||
// Line ~118
|
||||
data: null as Prisma.SdgsDesaGetPayload<{...}> | null, // ✅ Typed
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan typed data consistently:
|
||||
|
||||
```typescript
|
||||
// findMany
|
||||
data: null as Prisma.SdgsDesaGetPayload<{
|
||||
include: { image: true };
|
||||
}>[] | null,
|
||||
|
||||
// findManyAll (jika tidak dihapus)
|
||||
data: null as Prisma.SdgsDesaGetPayload<{
|
||||
include: { image: true };
|
||||
}>[] | null,
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Medium
|
||||
**Effort:** Medium (perlu update semua reference)
|
||||
|
||||
---
|
||||
|
||||
#### **5. Console.log di Production**
|
||||
|
||||
**Lokasi:** Multiple places di state file
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~48
|
||||
console.log(error);
|
||||
toast.error("Gagal menambahkan data");
|
||||
|
||||
// Line ~80
|
||||
console.error("Failed to load media sosial:", res.data?.message);
|
||||
|
||||
// Line ~85
|
||||
console.error("Error loading media sosial:", error);
|
||||
|
||||
// Line ~132
|
||||
console.error("Failed to fetch data", res.status, res.statusText);
|
||||
|
||||
// Line ~136
|
||||
console.error("Error fetching data:", error);
|
||||
|
||||
// ... dan banyak lagi
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan conditional logging:
|
||||
|
||||
```typescript
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error("Error:", error);
|
||||
}
|
||||
```
|
||||
|
||||
Atau gunakan logging library (winston, pino, dll) dengan levels yang jelas.
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **6. Error Message Tidak Konsisten**
|
||||
|
||||
**Lokasi:** Multiple places
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Create - Line ~44
|
||||
return toast.error("Gagal menambahkan data");
|
||||
|
||||
// Create - Line ~46
|
||||
toast.error("Gagal menambahkan data");
|
||||
|
||||
// Delete - Line ~165
|
||||
toast.error("Terjadi kesalahan saat menghapus sdgs desa");
|
||||
|
||||
// Edit - Line ~210
|
||||
toast.error("Gagal memuat data");
|
||||
|
||||
// Edit update - Line ~250
|
||||
toast.error("Gagal mengupdate sdgs desa");
|
||||
|
||||
// Toast success - Line ~240
|
||||
toast.success("Berhasil update sdgs desa");
|
||||
```
|
||||
|
||||
**Issue:**
|
||||
- Inconsistent capitalization ("sdgs desa" vs "Sdgs Desa")
|
||||
- Mixed patterns ("Gagal menambahkan" vs "Terjadi kesalahan")
|
||||
- Typo: "sdgs" seharusnya "SDGs" (acronym)
|
||||
|
||||
**Rekomendasi:** Standardisasi error messages:
|
||||
|
||||
```typescript
|
||||
// Pattern: "[Action] [resource] gagal" dengan proper casing
|
||||
toast.error("Menambahkan data SDGs Desa gagal");
|
||||
toast.error("Menghapus data SDGs Desa gagal");
|
||||
toast.error("Memuat data SDGs Desa gagal");
|
||||
toast.error("Memperbarui data SDGs Desa gagal");
|
||||
|
||||
// Atau lebih spesifik dengan context
|
||||
toast.error("Gagal menambahkan data SDGs Desa");
|
||||
toast.error("Gagal menghapus SDGs Desa");
|
||||
toast.success("Berhasil memperbarui SDGs Desa");
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **7. Zod Schema - Error Message Tidak Akurat**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/sdgs-desa.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~8
|
||||
const templatesdgsDesaForm = z.object({
|
||||
name: z.string().min(1, "Judul minimal 1 karakter"), // ❌ "Judul" instead of "Nama"
|
||||
jumlah: z.string().min(1, "Deskripsi minimal 1 karakter"), // ❌ "Deskripsi" instead of "Jumlah"
|
||||
imageId: z.string().min(1, "File minimal 1"),
|
||||
});
|
||||
```
|
||||
|
||||
**Dampak:** User confusion saat validasi error muncul:
|
||||
```
|
||||
Error: "Judul minimal 1 karakter" // User: "Lho, ini field nama bukan judul?"
|
||||
Error: "Deskripsi minimal 1 karakter" // User: "Ini field jumlah bukan deskripsi?"
|
||||
```
|
||||
|
||||
**Rekomendasi:** Fix error messages:
|
||||
|
||||
```typescript
|
||||
const templatesdgsDesaForm = z.object({
|
||||
name: z.string().min(1, "Nama SDGs Desa minimal 1 karakter"),
|
||||
jumlah: z.string().min(1, "Jumlah minimal 1 karakter"),
|
||||
imageId: z.string().min(1, "Gambar wajib dipilih"),
|
||||
});
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟢 LOW (Minor Polish)**
|
||||
|
||||
#### **8. Component Name Mismatch**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/landing-page/SDGs/[id]/edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~30
|
||||
export default function EditKolaborasiInovasi() { // ❌ Wrong name
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Dampak:** Confusing untuk developer lain, sulit untuk search/reference.
|
||||
|
||||
**Rekomendasi:** Rename ke yang sesuai:
|
||||
```typescript
|
||||
export default function EditSDGsDesa() { // ✅ Correct name
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low (hanya rename)
|
||||
|
||||
---
|
||||
|
||||
#### **9. Text Label Tidak Konsisten**
|
||||
|
||||
**Lokasi:** Multiple files
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Create page - Line ~100
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar Program Inovasi // ❌ Wrong label
|
||||
</Text>
|
||||
|
||||
// Edit page - Line ~170
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar Program Inovasi // ❌ Wrong label (copy-paste?)
|
||||
</Text>
|
||||
```
|
||||
|
||||
**Rekomendasi:** Fix label:
|
||||
```typescript
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar SDGs Desa // ✅ Correct label
|
||||
</Text>
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **10. Placeholder Search Tidak Spesifik**
|
||||
|
||||
**Lokasi:** `page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~17
|
||||
<HeaderSearch
|
||||
title='Sdgs Desa'
|
||||
placeholder='Cari Sdgs Desa...' // ⚠️ Generic
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
**Rekomendasi:** Lebih spesifik:
|
||||
```typescript
|
||||
placeholder='Cari nama SDGs Desa...'
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **11. Capitalization Inconsistency**
|
||||
|
||||
**Lokasi:** Multiple files
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// page.tsx - Line ~17
|
||||
title='Sdgs Desa' // ❌ Mixed case
|
||||
|
||||
// create/page.tsx - Line ~90
|
||||
<Title>Tambah Sdgs Desa</Title> // ❌ Mixed case
|
||||
|
||||
// edit/page.tsx - Line ~160
|
||||
<Title>Edit Sdgs Desa</Title> // ❌ Mixed case
|
||||
|
||||
// Should be:
|
||||
// "SDGs Desa" (all caps for acronym)
|
||||
```
|
||||
|
||||
**Rekomendasi:** Standardisasi:
|
||||
```typescript
|
||||
title='SDGs Desa'
|
||||
<Title>Tambah SDGs Desa</Title>
|
||||
<Title>Edit SDGs Desa</Title>
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **12. Schema - deletedAt Default Value**
|
||||
|
||||
**Lokasi:** `prisma/schema.prisma`
|
||||
|
||||
**Masalah:**
|
||||
```prisma
|
||||
model SdgsDesa {
|
||||
// ...
|
||||
deletedAt DateTime @default(now()) // ❌ Always has default value
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `deletedAt @default(now())` berarti setiap record baru langsung punya `deletedAt` value, yang bisa membingungkan untuk soft delete logic.
|
||||
|
||||
**Rekomendasi:**
|
||||
```prisma
|
||||
model SdgsDesa {
|
||||
// ...
|
||||
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Medium (potential logic issue)
|
||||
**Effort:** Medium (perlu migration)
|
||||
|
||||
---
|
||||
|
||||
#### **13. Duplicate Error Logging**
|
||||
|
||||
**Lokasi:** `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~80
|
||||
} catch (error) {
|
||||
console.error("Error loading sdgs desa:", error); // ❌ Duplicate
|
||||
toast.error("Gagal memuat data sdgs desa");
|
||||
}
|
||||
|
||||
// Line ~120
|
||||
} catch (error) {
|
||||
console.error("Error updating sdgs desa:", error); // ❌ Duplicate
|
||||
toast.error("Terjadi kesalahan saat memperbarui sdgs desa");
|
||||
}
|
||||
```
|
||||
|
||||
**Rekomendasi:** Cukup satu logging yang informatif:
|
||||
```typescript
|
||||
} catch (error) {
|
||||
console.error('Failed to load SDGs Desa:', err);
|
||||
toast.error('Gagal memuat data SDGs Desa');
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **14. API Response Handling - Inconsistent Error Messages**
|
||||
|
||||
**Lokasi:** API endpoints
|
||||
|
||||
**Masalah:** (dari grep search results)
|
||||
```typescript
|
||||
// del.ts - Line ~18
|
||||
message: "Berhasil menghapus SDGS Desa", // ✅ Proper
|
||||
|
||||
// updt.ts - Line ~38
|
||||
message: "SDGS Desa berhasil diperbarui", // ✅ Proper
|
||||
|
||||
// create.ts - (assumed)
|
||||
// Might have inconsistent casing
|
||||
```
|
||||
|
||||
**Rekomendasi:** Ensure all API responses use consistent "SDGs Desa" casing.
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
## 📋 RINGKASAN ACTION ITEMS
|
||||
|
||||
| Priority | Issue | Module | Impact | Effort | Status |
|
||||
|----------|-------|--------|--------|--------|--------|
|
||||
| 🔴 P0 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
|
||||
| 🔴 P0 | Missing loading state in findUnique | State | Medium | Low | Perlu fix |
|
||||
| 🔴 P1 | Unused findManyAll code | State | Low | Low | Should remove |
|
||||
| 🟡 M | Type safety (any usage) | State | Low | Medium | Optional |
|
||||
| 🟡 M | Console.log in production | State | Low | Low | Optional |
|
||||
| 🟡 M | Error message inconsistency | State/UI | Low | Low | Optional |
|
||||
| 🟡 M | Zod schema error messages | State | Low | Low | Should fix |
|
||||
| 🟢 L | Component name mismatch | Edit page | Low | Low | Optional |
|
||||
| 🟢 L | Wrong label text ("Program Inovasi") | Create/Edit | Low | Low | Should fix |
|
||||
| 🟢 L | Placeholder tidak spesifik | List page | Low | Low | Optional |
|
||||
| 🟢 L | Capitalization inconsistency | All UI | Low | Low | Should fix |
|
||||
| 🟢 M | deletedAt default value | Schema | Medium | Medium | Should fix |
|
||||
| 🟢 L | Duplicate error logging | Edit page | Low | Low | Optional |
|
||||
|
||||
---
|
||||
|
||||
## ✅ KESIMPULAN
|
||||
|
||||
### **Overall Quality: 🟢 BAIK (7.5/10)**
|
||||
|
||||
**Strengths:**
|
||||
1. ✅ UI/UX konsisten & responsive
|
||||
2. ✅ File upload handling solid
|
||||
3. ✅ Form validation dengan Zod schema
|
||||
4. ✅ State management terstruktur (Valtio)
|
||||
5. ✅ **Edit form reset sudah benar** (original data tracking)
|
||||
6. ✅ Modal konfirmasi hapus untuk user safety
|
||||
7. ✅ Type number input untuk field jumlah
|
||||
|
||||
**Areas for Improvement:**
|
||||
1. ⚠️ **Consistency:** Fetch method pattern (ApiFetch vs fetch manual)
|
||||
2. ⚠️ **Loading States:** findUnique tidak ada loading state management
|
||||
3. ⚠️ **Dead Code:** findManyAll tidak digunakan
|
||||
4. ⚠️ **Type Safety:** Reduce `any` usage, gunakan Prisma types
|
||||
5. ⚠️ **Schema:** deletedAt default value bisa menyebabkan logic issue
|
||||
6. ⚠️ **Naming:** Component name & label text masih ada yang salah
|
||||
|
||||
**Recommended Next Steps:**
|
||||
1. **Refactor fetch methods** untuk gunakan ApiFetch consistently
|
||||
2. **Add loading state** di findUnique operations
|
||||
3. **Remove findManyAll** jika tidak digunakan
|
||||
4. **Fix component name** (EditKolaborasiInovasi → EditSDGsDesa)
|
||||
5. **Fix label text** ("Gambar Program Inovasi" → "Gambar SDGs Desa")
|
||||
6. **Fix capitalization** (Sdgs → SDGs)
|
||||
7. **Optional:** Improve type safety dengan remove `any`
|
||||
|
||||
---
|
||||
|
||||
## 📈 COMPARISON WITH OTHER MODULES
|
||||
|
||||
| Aspect | Profil | Desa Anti Korupsi | SDGs Desa | Notes |
|
||||
|--------|--------|-------------------|-----------|-------|
|
||||
| Fetch Pattern | ⚠️ Mixed | ⚠️ Mixed | ⚠️ Mixed | All perlu refactor |
|
||||
| Loading State | ⚠️ Some missing | ⚠️ Some missing | ⚠️ Missing | Same issue |
|
||||
| Edit Form Reset | ✅ Good | ✅ Good | ✅ Good | Consistent |
|
||||
| Type Safety | ⚠️ Some `any` | ⚠️ Some `any` | ⚠️ Some `any` | Same issue |
|
||||
| File Upload | ✅ Images | ✅ Documents | ✅ Images | Different use case |
|
||||
| Error Handling | ✅ Good | ✅ Good (better) | ✅ Good | Consistent |
|
||||
| Dead Code | ❌ None | ❌ None | ⚠️ findManyAll | SDGs unique issue |
|
||||
| Naming Issues | ❌ None | ⚠️ Some | ⚠️ Some | Similar level |
|
||||
|
||||
---
|
||||
|
||||
**Catatan:** Secara keseluruhan, modul SDGs Desa sudah **production-ready** dengan beberapa improvements yang bisa dilakukan secara incremental. Module ini memiliki struktur yang mirip dengan modul lain (Profil, Desa Anti Korupsi) sehingga pattern improvement yang sama bisa diterapkan.
|
||||
|
||||
**Unique Issues:**
|
||||
1. findManyAll unused code (tidak ada di modul lain)
|
||||
2. Component name mismatch (EditKolaborasiInovasi)
|
||||
3. Wrong label text ("Gambar Program Inovasi") - kemungkinan copy-paste dari modul Program Inovasi
|
||||
@@ -1,879 +0,0 @@
|
||||
# QC Summary - Daftar Informasi Publik PPID Module
|
||||
|
||||
**Scope:** List Daftar Informasi Publik, Create, Edit, Detail
|
||||
**Date:** 2026-02-23
|
||||
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
|
||||
|
||||
---
|
||||
|
||||
## 📊 OVERVIEW
|
||||
|
||||
| Aspect | Schema | API | UI Admin | State Management | Overall |
|
||||
|--------|--------|-----|----------|-----------------|---------|
|
||||
| Daftar Informasi Publik | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 Perlu fix |
|
||||
|
||||
---
|
||||
|
||||
## ✅ YANG SUDAH BAIK
|
||||
|
||||
### **1. UI/UX Design**
|
||||
- ✅ Preview layout yang clean dengan responsive design
|
||||
- ✅ Loading states dengan Skeleton
|
||||
- ✅ Empty state handling yang informatif dengan icon
|
||||
- ✅ Search functionality dengan debounce (1000ms)
|
||||
- ✅ Pagination yang konsisten
|
||||
- ✅ Desktop table + mobile cards responsive
|
||||
- ✅ Sticky table header untuk better UX
|
||||
- ✅ Responsive button text ("Tambah" vs "Tambah Baru")
|
||||
|
||||
### **2. Table & Card Layout**
|
||||
- ✅ Fixed column widths (25%, 40%, 20%)
|
||||
- ✅ Sticky header table untuk long lists
|
||||
- ✅ Striped rows untuk readability
|
||||
- ✅ Highlight on hover
|
||||
- ✅ HTML tag stripping untuk preview deskripsi
|
||||
- ✅ Text truncation dengan lineClamp dan substring
|
||||
- ✅ Mobile card view dengan proper information hierarchy
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// page.tsx - Line ~95-120
|
||||
<Table
|
||||
highlightOnHover
|
||||
striped
|
||||
stickyHeader // ✅ GOOD - Header tetap visible saat scroll
|
||||
style={{ minWidth: '700px' }} // ✅ GOOD - Minimum width untuk readability
|
||||
>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh w="25%">
|
||||
<Text fw={600} lh={1.4}>Jenis Informasi</Text>
|
||||
</TableTh>
|
||||
<TableTh w="40%">
|
||||
<Text fw={600} lh={1.4}>Deskripsi</Text>
|
||||
</TableTh>
|
||||
<TableTh ta="center" w="20%">
|
||||
<Text fw={600} lh={1.4}>Aksi</Text>
|
||||
</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **BAIK** - Table layout dengan sticky header yang helpful!
|
||||
|
||||
---
|
||||
|
||||
### **3. State Management**
|
||||
- ✅ Proper typing dengan Prisma types
|
||||
- ✅ Loading state management dengan finally block
|
||||
- ✅ Error handling yang comprehensive
|
||||
- ✅ **ApiFetch consistency** untuk create & findMany! ✅
|
||||
- ✅ Zod validation untuk form data
|
||||
- ✅ Proper date formatting untuk update operation
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// state file - Line ~50-85
|
||||
findMany: {
|
||||
data: null as Prisma.DaftarInformasiPublikGetPayload<{ omit: { isActive: true } }>[] | null,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
loading: false,
|
||||
search: "",
|
||||
load: async (page = 1, limit = 10, search = "") => {
|
||||
daftarInformasiPublik.findMany.loading = true; // ✅ Start loading
|
||||
daftarInformasiPublik.findMany.page = page;
|
||||
daftarInformasiPublik.findMany.search = search;
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
if (search) query.search = search;
|
||||
|
||||
const res = await ApiFetch.api.ppid.daftarinformasipublik["find-many"].get({ query });
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
daftarInformasiPublik.findMany.data = res.data.data ?? [];
|
||||
daftarInformasiPublik.findMany.totalPages = res.data.totalPages ?? 1;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Gagal fetch daftar informasi publik:", err);
|
||||
daftarInformasiPublik.findMany.data = [];
|
||||
daftarInformasiPublik.findMany.totalPages = 1;
|
||||
} finally {
|
||||
daftarInformasiPublik.findMany.loading = false; // ✅ Stop loading
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **BAIK** - State management sudah proper dengan ApiFetch!
|
||||
|
||||
---
|
||||
|
||||
### **4. Zod Schema Validation**
|
||||
- ✅ Comprehensive validation untuk semua fields
|
||||
- ✅ Specific error messages untuk setiap field
|
||||
- ✅ Minimum character validation (3 characters)
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// state file - Line ~8-12
|
||||
const templateDaftarInformasi = z.object({
|
||||
jenisInformasi: z.string().min(3, "Jenis Informasi minimal 3 karakter"),
|
||||
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
|
||||
tanggal: z.string().min(3, "Tanggal minimal 3 karakter"),
|
||||
});
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **BAIK** - Validation yang proper!
|
||||
|
||||
---
|
||||
|
||||
### **5. Edit Form - Original Data Tracking**
|
||||
- ✅ Original data state untuk reset form (via useState)
|
||||
- ✅ Load data existing dengan benar
|
||||
- ✅ Reset form mengembalikan ke data original
|
||||
- ✅ Rich text content handling yang proper
|
||||
- ✅ Date formatting untuk input type="date"
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// edit/page.tsx - Line ~30-60
|
||||
const [formData, setFormData] = useState<FormDaftarInformasi>({
|
||||
jenisInformasi: '',
|
||||
deskripsi: '',
|
||||
tanggal: '',
|
||||
});
|
||||
|
||||
const formatDateForInput = (dateString: string) => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toISOString().split('T')[0]; // ✅ Format untuk input date
|
||||
};
|
||||
|
||||
// Load data
|
||||
useEffect(() => {
|
||||
const loadDaftarInformasi = async () => {
|
||||
const data = await daftarInformasi.edit.load(id);
|
||||
if (data) {
|
||||
setFormData({
|
||||
jenisInformasi: data.jenisInformasi || '',
|
||||
deskripsi: data.deskripsi || '',
|
||||
tanggal: data.tanggal || '',
|
||||
});
|
||||
}
|
||||
};
|
||||
loadDaftarInformasi();
|
||||
}, [params?.id]);
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **BAIK** - Original data tracking sudah implementasi dengan baik!
|
||||
|
||||
---
|
||||
|
||||
### **6. Rich Text Editor**
|
||||
- ✅ CreateEditor untuk create page
|
||||
- ✅ EditEditor untuk edit page
|
||||
- ✅ Reusable component pattern
|
||||
- ✅ HTML content handling yang proper
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ ISSUES & SARAN PERBAIKAN
|
||||
|
||||
### **🔴 CRITICAL**
|
||||
|
||||
#### **1. Schema - deletedAt Default Value SALAH**
|
||||
|
||||
**Lokasi:** `prisma/schema.prisma` (line 414)
|
||||
|
||||
**Masalah:**
|
||||
```prisma
|
||||
model DaftarInformasiPublik {
|
||||
id String @id @default(cuid())
|
||||
jenisInformasi String
|
||||
deskripsi String
|
||||
tanggal DateTime @db.Date
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now()) // ❌ SALAH - selalu punya default value
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- **LOGIC ERROR!** Setiap record baru langsung punya `deletedAt` value (timestamp creation)
|
||||
- Soft delete tidak berfungsi dengan benar
|
||||
- Query dengan `where: { deletedAt: null }` tidak akan pernah return data
|
||||
- Data yang "dihapus" vs data "aktif" tidak bisa dibedakan
|
||||
|
||||
**Contoh Issue:**
|
||||
```prisma
|
||||
// Record baru dibuat
|
||||
CREATE DaftarInformasiPublik {
|
||||
jenisInformasi: "Informasi 1",
|
||||
deskripsi: "Deskripsi 1",
|
||||
tanggal: "2024-01-01",
|
||||
// deletedAt otomatis ter-set ke now() ❌
|
||||
// isActive: true ✅
|
||||
}
|
||||
|
||||
// Query untuk data aktif (seharusnya return data ini)
|
||||
prisma.daftarInformasiPublik.findMany({
|
||||
where: { deletedAt: null, isActive: true }
|
||||
})
|
||||
// ❌ Return kosong! Karena deletedAt sudah ter-set
|
||||
```
|
||||
|
||||
**Rekomendasi:** Fix schema:
|
||||
```prisma
|
||||
model DaftarInformasiPublik {
|
||||
id String @id @default(cuid())
|
||||
jenisInformasi String
|
||||
deskripsi String
|
||||
tanggal DateTime @db.Date
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 **CRITICAL**
|
||||
**Effort:** Medium (perlu migration)
|
||||
**Impact:** **HIGH** (data integrity & soft delete logic)
|
||||
|
||||
---
|
||||
|
||||
#### **2. State Management - Fetch Pattern Inconsistency**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/daftar_informasi_publik/daftarInformasiPublik.ts`
|
||||
|
||||
**Masalah:** Ada 2 pattern berbeda untuk fetch API:
|
||||
|
||||
```typescript
|
||||
// ❌ Pattern 1: ApiFetch (create, findMany)
|
||||
const res = await ApiFetch.api.ppid.daftarinformasipublik["create"].post(form);
|
||||
const res = await ApiFetch.api.ppid.daftarinformasipublik["find-many"].get({ query });
|
||||
|
||||
// ❌ Pattern 2: fetch manual (findUnique, edit, delete)
|
||||
const res = await fetch(`/api/ppid/daftarinformasipublik/${id}`);
|
||||
const response = await fetch(`/api/ppid/daftarinformasipublik/del/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- Code consistency buruk
|
||||
- Sulit maintenance
|
||||
- Type safety tidak konsisten
|
||||
- Duplikasi logic error handling
|
||||
|
||||
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
|
||||
|
||||
```typescript
|
||||
// ✅ Unified pattern
|
||||
async load(id: string) {
|
||||
try {
|
||||
const res = await ApiFetch.api.ppid.daftarinformasipublik[id].get();
|
||||
|
||||
if (res.data?.success) {
|
||||
const data = res.data.data;
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
jenisInformasi: data.jenisInformasi,
|
||||
deskripsi: data.deskripsi,
|
||||
tanggal: data.tanggal,
|
||||
};
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(res.data?.message || "Gagal memuat data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
toast.error("Gagal memuat data");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async byId(id: string) {
|
||||
try {
|
||||
const res = await ApiFetch.api.ppid.daftarinformasipublik["del"][id].delete();
|
||||
|
||||
if (res.data?.success) {
|
||||
toast.success(res.data.message || "Berhasil hapus");
|
||||
await daftarInformasiPublik.findMany.load();
|
||||
} else {
|
||||
toast.error(res.data?.message || "Gagal hapus");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Gagal delete:", error);
|
||||
toast.error("Terjadi kesalahan saat menghapus");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 High
|
||||
**Effort:** Medium (refactor di findUnique, edit, delete methods)
|
||||
|
||||
---
|
||||
|
||||
#### **3. Missing Loading State di Edit Button**
|
||||
|
||||
**Lokasi:** `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~130-145
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isFormValid()} // ⚠️ Missing loading check
|
||||
radius="md"
|
||||
size="md"
|
||||
// ...
|
||||
>
|
||||
Simpan Perubahan
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Issue:** Button tidak disabled saat submitting. User bisa click multiple times.
|
||||
|
||||
**Rekomendasi:** Add loading state:
|
||||
```typescript
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// In handleSubmit
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await daftarInformasi.edit.update();
|
||||
router.push('/admin/ppid/daftar-informasi-publik');
|
||||
} catch (error) {
|
||||
// ...
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// In button
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isFormValid() || isSubmitting}
|
||||
// ...
|
||||
>
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan Perubahan'}
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Priority:** 🔴 Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟡 MEDIUM**
|
||||
|
||||
#### **4. Console.log di Production**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/daftar_informasi_publik/daftarInformasiPublik.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~45
|
||||
console.log((error as Error).message);
|
||||
|
||||
// Line ~80
|
||||
console.error("Gagal fetch daftar informasi publik paginated:", err);
|
||||
|
||||
// Line ~100
|
||||
console.error("Failed to fetch daftar informasi publik:", res.statusText);
|
||||
|
||||
// Line ~104
|
||||
console.error("Error fetching daftar informasi publik:", error);
|
||||
|
||||
// Line ~180
|
||||
console.error("Error loading daftar informasi publik:", error);
|
||||
|
||||
// Line ~230
|
||||
console.error("Error updating daftar informasi publik:", error);
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan conditional logging:
|
||||
|
||||
```typescript
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error("Error:", error);
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **5. Type Safety - Any Usage**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/daftar_informasi_publik/daftarInformasiPublik.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~70
|
||||
const query: any = { page, limit }; // ❌ Using 'any'
|
||||
if (search) query.search = search;
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan typed query:
|
||||
|
||||
```typescript
|
||||
// Define type
|
||||
interface FindManyQuery {
|
||||
page: number | string;
|
||||
limit?: number | string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// Use typed query
|
||||
const query: FindManyQuery = { page, limit };
|
||||
if (search) query.search = search;
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **6. Alert() Instead of Toast**
|
||||
|
||||
**Lokasi:** `create/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~30-40
|
||||
const handleSubmit = async () => {
|
||||
if (!daftarInformasi.create.form.jenisInformasi) {
|
||||
return alert('Mohon isi jenis informasi'); // ❌ Using alert()
|
||||
}
|
||||
if (!daftarInformasi.create.form.deskripsi) {
|
||||
return alert('Mohon isi deskripsi'); // ❌ Using alert()
|
||||
}
|
||||
if (!daftarInformasi.create.form.tanggal) {
|
||||
return alert('Mohon pilih tanggal publikasi'); // ❌ Using alert()
|
||||
}
|
||||
|
||||
try {
|
||||
await daftarInformasi.create.create();
|
||||
// ...
|
||||
} catch (error) {
|
||||
console.error('Error creating informasi publik:', error);
|
||||
alert('Terjadi kesalahan saat menyimpan data'); // ❌ Using alert()
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan toast untuk consistency:
|
||||
|
||||
```typescript
|
||||
if (!daftarInformasi.create.form.jenisInformasi) {
|
||||
return toast.warn('Mohon isi jenis informasi'); // ✅ Using toast
|
||||
}
|
||||
// ...
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **7. Missing Reset Form Function**
|
||||
|
||||
**Lokasi:** `create/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~20-25
|
||||
const resetForm = () => {
|
||||
daftarInformasi.create.form = {
|
||||
jenisInformasi: "",
|
||||
deskripsi: "",
|
||||
tanggal: "",
|
||||
};
|
||||
};
|
||||
|
||||
// resetForm dipanggil di handleSubmit tapi tidak ada di form inputs
|
||||
// Form inputs langsung update state tanpa reset setelah submit
|
||||
```
|
||||
|
||||
**Issue:** Form tidak reset setelah successful submit.
|
||||
|
||||
**Rekomendasi:** Ensure reset is called:
|
||||
```typescript
|
||||
const handleSubmit = async () => {
|
||||
// ... validation
|
||||
|
||||
try {
|
||||
await daftarInformasi.create.create();
|
||||
resetForm(); // ✅ Make sure this is called
|
||||
router.push("/admin/ppid/daftar-informasi-publik");
|
||||
} catch (error) {
|
||||
// ...
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - resetForm() sudah dipanggil di handleSubmit!
|
||||
|
||||
**Priority:** 🟢 None
|
||||
**Effort:** None
|
||||
|
||||
---
|
||||
|
||||
### **🟢 LOW (Minor Polish)**
|
||||
|
||||
#### **8. Pagination onChange Tidak Include Search**
|
||||
|
||||
**Lokasi:** `page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~190-200
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10); // ⚠️ Missing search parameter
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
total={totalPages}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
**Issue:** Saat ganti page, search query hilang.
|
||||
|
||||
**Rekomendasi:** Include search:
|
||||
```typescript
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10, debouncedSearch); // ✅ Include search
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **9. Duplicate Error Logging**
|
||||
|
||||
**Lokasi:** Multiple files
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// edit/page.tsx - Line ~60
|
||||
} catch (error) {
|
||||
console.error('Error loading daftar informasi:', error); // ❌ Duplicate
|
||||
toast.error('Gagal memuat data daftar informasi');
|
||||
}
|
||||
|
||||
// edit/page.tsx - Line ~80
|
||||
} catch (error) {
|
||||
console.error('Error updating berita:', error); // ❌ Duplicate + wrong module name
|
||||
toast.error('Terjadi kesalahan saat memperbarui berita'); // ❌ Wrong module name
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** Copy-paste error dari module "berita"!
|
||||
|
||||
**Rekomendasi:** Fix error messages:
|
||||
```typescript
|
||||
} catch (error) {
|
||||
console.error('Failed to load Daftar Informasi Publik:', err);
|
||||
toast.error('Gagal memuat data Daftar Informasi Publik');
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **10. Missing Loading State di Detail Page**
|
||||
|
||||
**Lokasi:** `[id]/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~20-25
|
||||
useShallowEffect(() => {
|
||||
stateDaftarInformasi.findUnique.load(params?.id as string)
|
||||
}, [params?.id])
|
||||
|
||||
if (!stateDaftarInformasi.findUnique.data) {
|
||||
return (
|
||||
<Stack py={10}>
|
||||
<Skeleton height={500} radius="md" />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** Skeleton ditampilkan untuk semua kondisi (loading, error, not found).
|
||||
|
||||
**Rekomendasi:** Add proper loading state:
|
||||
```typescript
|
||||
if (stateDaftarInformasi.findUnique.loading) {
|
||||
return (
|
||||
<Stack py={10}>
|
||||
<Skeleton height={500} radius="md" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stateDaftarInformasi.findUnique.data) {
|
||||
return (
|
||||
<Alert icon={<IconAlertCircle />} color="red">
|
||||
Data tidak ditemukan
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **11. Search Placeholder Tidak Spesifik**
|
||||
|
||||
**Lokasi:** `page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~30-35
|
||||
<HeaderSearch
|
||||
title='Daftar Informasi Publik'
|
||||
placeholder='Cari jenis informasi atau deskripsi...' // ✅ Actually pretty specific!
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - Placeholder sudah spesifik!
|
||||
|
||||
**Priority:** 🟢 None
|
||||
**Effort:** None
|
||||
|
||||
---
|
||||
|
||||
#### **12. Empty State Icon Consistency**
|
||||
|
||||
**Lokasi:** `page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~85-95
|
||||
<Stack align="center" py="xl">
|
||||
<IconDeviceImacCog size={40} stroke={1.5} color={colors['blue-button']} />
|
||||
<Text fz={{ base: 'sm', md: 'md' }} fw={500} c="dimmed" lh={1.5}>
|
||||
Belum ada informasi publik yang tersedia
|
||||
</Text>
|
||||
</Stack>
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - Empty state dengan icon yang proper!
|
||||
|
||||
**Priority:** 🟢 None
|
||||
**Effort:** None
|
||||
|
||||
---
|
||||
|
||||
#### **13. HTML Tag Stripping for Preview**
|
||||
|
||||
**Lokasi:** `page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~125-130
|
||||
<Text fz="sm" lh={1.5} c="dimmed" lineClamp={1}>
|
||||
{item.deskripsi?.replace(/<[^>]*>?/gm, '').substring(0, 80)}...
|
||||
</Text>
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - HTML tag stripping yang proper untuk preview!
|
||||
|
||||
**Priority:** 🟢 None
|
||||
**Effort:** None
|
||||
|
||||
---
|
||||
|
||||
## 📋 RINGKASAN ACTION ITEMS
|
||||
|
||||
| Priority | Issue | Module | Impact | Effort | Status |
|
||||
|----------|-------|--------|--------|--------|--------|
|
||||
| 🔴 P0 | **Schema deletedAt default SALAH** | Schema | **CRITICAL** | Medium | **MUST FIX** |
|
||||
| 🔴 P0 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
|
||||
| 🔴 P1 | Missing loading state di edit button | UI | Medium | Low | Should fix |
|
||||
| 🟡 M | Console.log in production | State | Low | Low | Optional |
|
||||
| 🟡 M | Type safety (any usage) | State | Low | Low | Optional |
|
||||
| 🟡 M | Alert() instead of toast | Create UI | Low | Low | Should fix |
|
||||
| 🟡 M | Copy-paste error messages (berita) | Edit UI | Low | Low | Should fix |
|
||||
| 🟢 L | Pagination missing search param | UI | Low | Low | Optional |
|
||||
| 🟢 L | Missing loading state di detail page | UI | Low | Low | Optional |
|
||||
| 🟢 L | Duplicate error logging | UI/State | Low | Low | Optional |
|
||||
|
||||
---
|
||||
|
||||
## ✅ KESIMPULAN
|
||||
|
||||
### **Overall Quality: 🟢 BAIK (8/10)**
|
||||
|
||||
**Strengths:**
|
||||
1. ✅ UI/UX clean & responsive
|
||||
2. ✅ **Sticky header table** - Better UX untuk long lists
|
||||
3. ✅ **HTML tag stripping** untuk preview deskripsi
|
||||
4. ✅ Search functionality dengan debounce
|
||||
5. ✅ Empty state handling yang informatif
|
||||
6. ✅ **Zod validation** comprehensive
|
||||
7. ✅ State management dengan ApiFetch untuk create & findMany
|
||||
8. ✅ Loading state management dengan finally block
|
||||
9. ✅ Mobile cards responsive
|
||||
10. ✅ **Responsive button text** ("Tambah" vs "Tambah Baru")
|
||||
11. ✅ Edit form dengan original data tracking
|
||||
12. ✅ Date formatting untuk input type="date"
|
||||
|
||||
**Critical Issues:**
|
||||
1. ⚠️ **Schema deletedAt default SALAH** - Logic error untuk soft delete (CRITICAL)
|
||||
2. ⚠️ Fetch method pattern inconsistency (ApiFetch vs fetch manual)
|
||||
3. ⚠️ Missing loading state di edit button
|
||||
|
||||
**Areas for Improvement:**
|
||||
1. ⚠️ **Fix schema deletedAt** dari `@default(now())` ke `@default(null)` dengan nullable
|
||||
2. ⚠️ **Refactor fetch methods** untuk gunakan ApiFetch consistently
|
||||
3. ⚠️ **Add loading state** di edit button
|
||||
4. ⚠️ **Fix alert()** ke toast
|
||||
5. ⚠️ **Fix copy-paste error messages** dari module "berita"
|
||||
|
||||
**Recommended Next Steps:**
|
||||
1. **🔴 CRITICAL: Fix schema deletedAt** - 30 menit (perlu migration)
|
||||
2. **🔴 HIGH: Refactor findUnique, edit, delete** ke ApiFetch - 1 jam
|
||||
3. **🔴 HIGH: Add loading state** di edit button - 15 menit
|
||||
4. **🟡 MEDIUM: Fix alert()** ke toast - 15 menit
|
||||
5. **🟡 MEDIUM: Fix copy-paste error messages** - 10 menit
|
||||
6. **🟢 LOW: Add pagination search param** - 10 menit
|
||||
7. **🟢 LOW: Polish minor issues** - 30 menit
|
||||
|
||||
---
|
||||
|
||||
## 📈 COMPARISON WITH OTHER MODULES
|
||||
|
||||
| Module | Fetch Pattern | State | Validation | Schema | Loading State | Overall |
|
||||
|--------|--------------|-------|------------|--------|---------------|---------|
|
||||
| Profil | ⚠️ Mixed | ⚠️ Good | ✅ Good | ⚠️ deletedAt | ⚠️ Some missing | 🟢 |
|
||||
| Desa Anti Korupsi | ⚠️ Mixed | ⚠️ Good | ✅ Good | ⚠️ deletedAt | ⚠️ Some missing | 🟢 |
|
||||
| SDGs Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ⚠️ deletedAt | ⚠️ Missing | 🟢 |
|
||||
| APBDes | ⚠️ Mixed | ⚠️ Good | ✅ Good | ✅ Good | ✅ Good | 🟢 |
|
||||
| Prestasi Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ WRONG | ⚠️ Some missing | 🟢 |
|
||||
| PPID Profil | ⚠️ Mixed | ✅ **Best** | ✅ Good | ❌ WRONG | ✅ Good | 🟢⭐ |
|
||||
| Struktur PPID | ⚠️ Mixed | ✅ Good | ✅ Good | ⚠️ Inconsistent | ✅ Good | 🟢 |
|
||||
| Visi Misi PPID | ✅ **100% ApiFetch!** | ✅ Best | ✅ Good | ❌ WRONG | ✅ Good | 🟢⭐⭐ |
|
||||
| Dasar Hukum PPID | ✅ **100% ApiFetch!** | ✅ Best | ✅ Good | ❌ WRONG | ✅ Good | 🟢⭐⭐ |
|
||||
| Permohonan Informasi | ⚠️ Mixed | ⚠️ Good | ✅ **Best** | ❌ **4 models WRONG** | ✅ Good | 🟡 |
|
||||
| **Daftar Informasi** | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ WRONG | ⚠️ Some missing | 🟢 |
|
||||
|
||||
**Daftar Informasi PPID Highlights:**
|
||||
- ✅ **Sticky header table** - Unique feature untuk better UX
|
||||
- ✅ **HTML tag stripping** untuk preview - Good practice
|
||||
- ✅ **Responsive button text** - Attention to detail
|
||||
- ⚠️ **Same deletedAt issue** seperti modul PPID lain
|
||||
- ⚠️ **Copy-paste errors** dari module "berita"
|
||||
|
||||
---
|
||||
|
||||
## 🎯 UNIQUE FEATURES OF DAFTAR INFORMASI MODULE
|
||||
|
||||
**Best Table Implementation:**
|
||||
1. ✅ **Sticky header table** - Unique feature!
|
||||
2. ✅ **HTML tag stripping** untuk preview deskripsi
|
||||
3. ✅ **Responsive button text** - "Tambah" vs "Tambah Baru"
|
||||
4. ✅ **Fixed column widths** - 25%, 40%, 20%
|
||||
5. ✅ **Minimum table width** - 700px untuk readability
|
||||
|
||||
**Best Practices:**
|
||||
1. ✅ **Sticky header** - Best practice untuk long lists
|
||||
2. ✅ **HTML stripping** - Good practice untuk rich text preview
|
||||
3. ✅ **Loading state management** - Proper dengan finally block
|
||||
4. ✅ **Original data tracking** - Edit form reset yang proper
|
||||
5. ✅ **Date formatting** - Proper untuk input type="date"
|
||||
|
||||
**Critical Issues:**
|
||||
1. ❌ **Schema deletedAt SALAH** - Same issue seperti modul PPID lain
|
||||
2. ❌ **Fetch pattern inconsistency** - findUnique, edit, delete pakai fetch manual
|
||||
3. ❌ **Copy-paste error messages** - Dari module "berita"
|
||||
|
||||
---
|
||||
|
||||
**Catatan:** **Daftar Informasi PPID adalah MODULE DENGAN TABLE IMPLEMENTATION TERBAIK** dengan sticky header dan HTML tag stripping untuk preview. Module ini juga punya attention to detail dengan responsive button text.
|
||||
|
||||
**Unique Strengths:**
|
||||
1. ✅ **Sticky header table** - Best table UX
|
||||
2. ✅ **HTML tag stripping** - Best practice untuk preview
|
||||
3. ✅ **Responsive button text** - Attention to detail
|
||||
4. ✅ **Fixed column widths** - Consistent layout
|
||||
5. ✅ **Date formatting** - Proper handling
|
||||
|
||||
**Priority Action:**
|
||||
```diff
|
||||
🔴 FIX INI SEKARANG (30 MENIT + MIGRATION):
|
||||
File: prisma/schema.prisma
|
||||
Line: 414
|
||||
|
||||
model DaftarInformasiPublik {
|
||||
id String @id @default(cuid())
|
||||
jenisInformasi String
|
||||
deskripsi String
|
||||
tanggal DateTime @db.Date
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
- deletedAt DateTime @default(now())
|
||||
+ deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
# Lalu jalankan:
|
||||
bunx prisma db push
|
||||
# atau
|
||||
bunx prisma migrate dev --name fix_deletedat_daftar_informasi
|
||||
```
|
||||
|
||||
```diff
|
||||
🔴 FIX COPY-PASTE ERRORS (10 MENIT):
|
||||
File: edit/page.tsx
|
||||
|
||||
// Line ~80
|
||||
- console.error('Error updating berita:', error);
|
||||
+ console.error('Error updating daftar informasi:', error);
|
||||
|
||||
- toast.error('Terjadi kesalahan saat memperbarui berita');
|
||||
+ toast.error('Terjadi kesalahan saat memperbarui daftar informasi');
|
||||
```
|
||||
|
||||
Setelah fix critical issues, module ini **PRODUCTION-READY** dengan **BEST TABLE IMPLEMENTATION**! 🎉
|
||||
|
||||
---
|
||||
|
||||
## 📚 RECOMMENDED AS REFERENCE FOR OTHER MODULES
|
||||
|
||||
**Daftar Informasi PPID Module adalah BEST PRACTICE untuk:**
|
||||
1. ✅ **Sticky header table** - Best practice untuk long lists
|
||||
2. ✅ **HTML tag stripping** - Good practice untuk rich text preview
|
||||
3. ✅ **Responsive button text** - Attention to detail
|
||||
4. ✅ **Fixed column widths** - Consistent layout
|
||||
5. ✅ **Date formatting** - Proper handling untuk date inputs
|
||||
|
||||
**Modules lain bisa belajar dari Daftar Informasi:**
|
||||
- **ALL MODULES WITH TABLES:** Use sticky header untuk better UX
|
||||
- **ALL MODULES WITH RICH TEXT:** Strip HTML tags untuk preview
|
||||
- **ALL MODULES:** Responsive text untuk buttons
|
||||
- **ALL MODULES:** Fixed column widths untuk consistency
|
||||
- **ALL MODULES:** Proper date formatting untuk date inputs
|
||||
|
||||
---
|
||||
|
||||
**File Location:** `QC/PPID/QC-DAFTAR-INFORMASI-PUBLIK-MODULE.md` 📄
|
||||
@@ -1,821 +0,0 @@
|
||||
# QC Summary - Dasar Hukum PPID Module
|
||||
|
||||
**Scope:** Preview Dasar Hukum, Edit Dasar Hukum dengan Rich Text Editor
|
||||
**Date:** 2026-02-23
|
||||
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
|
||||
|
||||
---
|
||||
|
||||
## 📊 OVERVIEW
|
||||
|
||||
| Aspect | Schema | API | UI Admin | State Management | Overall |
|
||||
|--------|--------|-----|----------|-----------------|---------|
|
||||
| Dasar Hukum PPID | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ✅ Baik | 🟡 Perlu fix |
|
||||
|
||||
---
|
||||
|
||||
## ✅ YANG SUDAH BAIK
|
||||
|
||||
### **1. UI/UX Design**
|
||||
- ✅ Preview layout yang clean dengan logo desa
|
||||
- ✅ Responsive design (mobile & desktop)
|
||||
- ✅ Loading states dengan Skeleton
|
||||
- ✅ Empty state handling yang informatif
|
||||
- ✅ Edit button yang prominent
|
||||
- ✅ Divider visual yang jelas antara Judul dan Content
|
||||
|
||||
### **2. Rich Text Editor (Tiptap)**
|
||||
- ✅ Full-featured editor dengan toolbar lengkap (reuse dari PPIDTextEditor)
|
||||
- ✅ Extensions: Bold, Italic, Underline, Highlight, Link, dll
|
||||
- ✅ Text alignment (left, center, justify, right)
|
||||
- ✅ Heading levels (H1-H4)
|
||||
- ✅ Lists (bullet & ordered)
|
||||
- ✅ Blockquote, code, superscript, subscript
|
||||
- ✅ Undo/Redo
|
||||
- ✅ Sticky toolbar untuk UX yang lebih baik
|
||||
- ✅ **Dynamic import dengan `ssr: false`** untuk menghindari hydration issues! ✅
|
||||
|
||||
### **3. Form Component Structure**
|
||||
- ✅ Reusable PPIDTextEditor component (shared dengan Visi Misi)
|
||||
- ✅ Proper TypeScript typing
|
||||
- ✅ Controlled components dengan onChange handler
|
||||
- ✅ SSR handling yang proper dengan dynamic import
|
||||
|
||||
**Code Example (✅ EXCELLENT):**
|
||||
```typescript
|
||||
// edit/page.tsx - Line ~13-17
|
||||
const PPIDTextEditor = dynamic(
|
||||
() => import('../../_com/PPIDTextEditor').then(mod => mod.PPIDTextEditor),
|
||||
{ ssr: false } // ✅ Disable SSR untuk avoid hydration mismatch
|
||||
);
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **EXCELLENT** - Proper SSR handling!
|
||||
|
||||
---
|
||||
|
||||
### **4. State Management**
|
||||
- ✅ Proper typing dengan Prisma types
|
||||
- ✅ Loading state management dengan finally block
|
||||
- ✅ Error handling yang comprehensive
|
||||
- ✅ **ApiFetch consistency** - Semua operasi pakai ApiFetch! ✅
|
||||
- ✅ Zod validation untuk form data
|
||||
|
||||
**Code Example (✅ EXCELLENT):**
|
||||
```typescript
|
||||
// state file - Line ~20-45
|
||||
findById: {
|
||||
data: null as DasarHukumForm | null,
|
||||
loading: false,
|
||||
initialize() {
|
||||
stateDasarHukumPPID.findById.data = {
|
||||
id: '',
|
||||
judul: '',
|
||||
content: '',
|
||||
} as DasarHukumForm;
|
||||
},
|
||||
async load(id: string) {
|
||||
try {
|
||||
stateDasarHukumPPID.findById.loading = true; // ✅ Start loading
|
||||
const res = await ApiFetch.api.ppid.dasarhukumppid["find-by-id"].get({
|
||||
query: { id },
|
||||
});
|
||||
if (res.status === 200) {
|
||||
stateDasarHukumPPID.findById.data = res.data?.data ?? null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error((error as Error).message);
|
||||
toast.error("Terjadi kesalahan saat mengambil data dasar hukum");
|
||||
} finally {
|
||||
stateDasarHukumPPID.findById.loading = false; // ✅ Stop loading
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SANGAT BAIK** - State management sudah konsisten dengan ApiFetch!
|
||||
|
||||
---
|
||||
|
||||
### **5. Edit Form - Original Data Tracking**
|
||||
- ✅ Original data state untuk reset form
|
||||
- ✅ Load data existing dengan benar
|
||||
- ✅ Reset form mengembalikan ke data original
|
||||
- ✅ Rich text content handling yang proper
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// edit/page.tsx - Line ~20-45
|
||||
const [formData, setFormData] = useState({ judul: '', content: '' });
|
||||
const [originalData, setOriginalData] = useState({
|
||||
judul: '',
|
||||
content: '',
|
||||
});
|
||||
|
||||
// Initialize from global state
|
||||
useEffect(() => {
|
||||
if (dasarHukumState.findById.data) {
|
||||
setFormData({
|
||||
judul: dasarHukumState.findById.data.judul ?? '',
|
||||
content: dasarHukumState.findById.data.content ?? '',
|
||||
});
|
||||
setOriginalData({
|
||||
judul: dasarHukumState.findById.data.judul ?? '',
|
||||
content: dasarHukumState.findById.data.content ?? '',
|
||||
});
|
||||
}
|
||||
}, [dasarHukumState.findById.data]);
|
||||
|
||||
// Line ~65 - Handle reset
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
judul: originalData.judul,
|
||||
content: originalData.content,
|
||||
});
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **BAIK** - Original data tracking sudah implementasi dengan baik!
|
||||
|
||||
---
|
||||
|
||||
### **6. Rich Text Validation**
|
||||
- ✅ Custom validation function untuk rich text content
|
||||
- ✅ Check empty content setelah remove HTML tags
|
||||
- ✅ Validation untuk kedua fields (judul & content)
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// edit/page.tsx - Line ~25-35
|
||||
const isRichTextEmpty = (content: string) => {
|
||||
// Remove HTML tags and check if the resulting text is empty
|
||||
const plainText = content.replace(/<[^>]*>/g, '').trim();
|
||||
return plainText === '' || content.trim() === '<p></p>' || content.trim() === '<p><br></p>';
|
||||
};
|
||||
|
||||
const isFormValid = () => {
|
||||
return (
|
||||
!isRichTextEmpty(formData.judul) &&
|
||||
!isRichTextEmpty(formData.content)
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **EXCELLENT** - Rich text validation yang comprehensive!
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ ISSUES & SARAN PERBAIKAN
|
||||
|
||||
### **🔴 CRITICAL**
|
||||
|
||||
#### **1. Schema - deletedAt Default Value SALAH**
|
||||
|
||||
**Lokasi:** `prisma/schema.prisma` (line 385)
|
||||
|
||||
**Masalah:**
|
||||
```prisma
|
||||
model DasarHukumPPID {
|
||||
id String @id @default(cuid())
|
||||
judul String @db.Text
|
||||
content String @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now()) // ❌ SALAH - selalu punya default value
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- **LOGIC ERROR!** Setiap record baru langsung punya `deletedAt` value (timestamp creation)
|
||||
- Soft delete tidak berfungsi dengan benar
|
||||
- Query dengan `where: { deletedAt: null }` tidak akan pernah return data
|
||||
- Data yang "dihapus" vs data "aktif" tidak bisa dibedakan
|
||||
|
||||
**Contoh Issue:**
|
||||
```prisma
|
||||
// Record baru dibuat
|
||||
CREATE DasarHukumPPID {
|
||||
judul: "Judul 1",
|
||||
content: "Content 1",
|
||||
// deletedAt otomatis ter-set ke now() ❌
|
||||
// isActive: true ✅
|
||||
}
|
||||
|
||||
// Query untuk data aktif (seharusnya return data ini)
|
||||
prisma.dasarHukumPPID.findMany({
|
||||
where: { deletedAt: null, isActive: true }
|
||||
})
|
||||
// ❌ Return kosong! Karena deletedAt sudah ter-set
|
||||
```
|
||||
|
||||
**Rekomendasi:** Fix schema:
|
||||
```prisma
|
||||
model DasarHukumPPID {
|
||||
id String @id @default(cuid())
|
||||
judul String @db.Text
|
||||
content String @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 **CRITICAL**
|
||||
**Effort:** Medium (perlu migration)
|
||||
**Impact:** **HIGH** (data integrity & soft delete logic)
|
||||
|
||||
---
|
||||
|
||||
#### **2. HTML Injection Risk - dangerouslySetInnerHTML**
|
||||
|
||||
**Lokasi:** `page.tsx` (preview page)
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~65-75
|
||||
<Title
|
||||
order={3}
|
||||
ta="center"
|
||||
lh={{ base: 1.15, md: 1.1 }}
|
||||
fw="bold"
|
||||
c={colors['blue-button']}
|
||||
dangerouslySetInnerHTML={{ __html: listDasarHukum.findById.data.judul }} // ❌ No sanitization
|
||||
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
|
||||
/>
|
||||
|
||||
// Line ~80-90 (Content)
|
||||
<Text
|
||||
dangerouslySetInnerHTML={{ __html: listDasarHukum.findById.data.content }} // ❌ No sanitization
|
||||
style={{
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
fontSize: '1rem',
|
||||
lineHeight: 1.55,
|
||||
textAlign: 'justify',
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
**Risk:**
|
||||
- XSS attack jika admin input script malicious
|
||||
- Bisa inject iframe, script tag, dll
|
||||
- Security vulnerability
|
||||
|
||||
**Rekomendasi:** Gunakan DOMPurify atau library sanitization:
|
||||
|
||||
```typescript
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Sanitize sebelum render
|
||||
const sanitizedJudul = DOMPurify.sanitize(listDasarHukum.findById.data.judul);
|
||||
const sanitizedContent = DOMPurify.sanitize(listDasarHukum.findById.data.content);
|
||||
|
||||
<Title
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedJudul }}
|
||||
// ...
|
||||
/>
|
||||
|
||||
<Text
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
Atau validasi di backend untuk whitelist tag HTML yang diperbolehkan (hanya `<p>`, `<ul>`, `<li>`, `<strong>`, dll).
|
||||
|
||||
**Priority:** 🔴 **HIGH** (**Security concern**)
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **3. Missing Delete/Hard Delete Protection**
|
||||
|
||||
**Lokasi:** `page.tsx`, `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
- ❌ Tidak ada tombol delete untuk Dasar Hukum (correct - single record)
|
||||
- ✅ **GOOD:** Single record pattern yang benar
|
||||
- ⚠️ **ISSUE:** Tidak ada konfirmasi sebelum update (direct save)
|
||||
|
||||
**Issue:** User bisa accidentally save changes tanpa konfirmasi.
|
||||
|
||||
**Rekomendasi:** Add confirmation dialog sebelum save:
|
||||
```typescript
|
||||
const handleSubmit = () => {
|
||||
// Check if data has changed
|
||||
if (formData.judul === originalData.judul && formData.content === originalData.content) {
|
||||
toast.info('Tidak ada perubahan');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation
|
||||
const confirmed = window.confirm('Apakah Anda yakin ingin mengubah Dasar Hukum PPID?');
|
||||
if (!confirmed) return;
|
||||
|
||||
// Then save...
|
||||
};
|
||||
```
|
||||
|
||||
**Priority:** 🔴 Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟡 MEDIUM**
|
||||
|
||||
#### **4. Console.log di Production**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/dasar_hukum/dasarHukum.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~40
|
||||
console.error((error as Error).message);
|
||||
|
||||
// Line ~65
|
||||
console.error((error as Error).message);
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan conditional logging:
|
||||
|
||||
```typescript
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error("Error:", error);
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **5. Missing Loading State di Submit Button**
|
||||
|
||||
**Lokasi:** `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~130-140
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
size="md"
|
||||
disabled={!isFormValid() || isSubmitting}
|
||||
// ...
|
||||
>
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Issue:** Button tidak check `dasarHukumState.update.loading` dari global state.
|
||||
|
||||
**Rekomendasi:** Check both states:
|
||||
```typescript
|
||||
disabled={!isFormValid() || isSubmitting || dasarHukumState.update.loading}
|
||||
{isSubmitting || dasarHukumState.update.loading ? (
|
||||
<Loader size="sm" color="white" />
|
||||
) : (
|
||||
'Simpan'
|
||||
)}
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **6. Zod Schema - Could Be More Specific**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/dasar_hukum/dasarHukum.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~7
|
||||
const templateForm = z.object({
|
||||
judul: z.string().min(3, "Judul minimal 3 karakter"), // ⚠️ Generic
|
||||
content: z.string().min(3, "Content minimal 3 karakter"), // ⚠️ Generic
|
||||
});
|
||||
```
|
||||
|
||||
**Rekomendasi:** More specific error messages:
|
||||
```typescript
|
||||
const templateForm = z.object({
|
||||
judul: z.string().min(3, "Judul dasar hukum minimal 3 karakter"),
|
||||
content: z.string().min(3, "Konten dasar hukum minimal 3 karakter"),
|
||||
});
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟢 LOW (Minor Polish)**
|
||||
|
||||
#### **7. Missing Change Detection**
|
||||
|
||||
**Lokasi:** `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~75-85
|
||||
const handleSubmit = () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
if (dasarHukumState.findById.data) {
|
||||
// Update global state hanya saat submit
|
||||
const updated = { ...dasarHukumState.findById.data, ...formData };
|
||||
dasarHukumState.update.save(updated);
|
||||
}
|
||||
router.push('/admin/ppid/dasar-hukum');
|
||||
} catch (error) {
|
||||
console.error("Error updating dasar hukum:", error);
|
||||
toast.error("Terjadi kesalahan saat memperbarui dasar hukum");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Issue:** Tidak ada check apakah data sudah berubah. User bisa save tanpa perubahan.
|
||||
|
||||
**Rekomendasi:** Add change detection:
|
||||
```typescript
|
||||
const handleSubmit = () => {
|
||||
// Check if data has changed
|
||||
if (formData.judul === originalData.judul && formData.content === originalData.content) {
|
||||
toast.info('Tidak ada perubahan');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
// ... rest of save logic
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **8. Editor - Duplicate useEffect**
|
||||
|
||||
**Lokasi:** `PPIDTextEditor.tsx` (shared component)
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~30-35 (di PPIDTextEditor.tsx)
|
||||
const editor = useEditor({
|
||||
extensions: [...],
|
||||
immediatelyRender: false,
|
||||
content: initialContent, // ✅ Set content directly
|
||||
onUpdate: ({editor}) => {
|
||||
onChange(editor.getHTML()) // ✅ Handle changes
|
||||
}
|
||||
});
|
||||
|
||||
// Line ~37-42
|
||||
useEffect(() => {
|
||||
if (editor && initialContent !== editor.getHTML()) {
|
||||
editor.commands.setContent(initialContent || '<p></p>');
|
||||
}
|
||||
}, [initialContent, editor]);
|
||||
```
|
||||
|
||||
**Issue:** Ada useEffect tambahan untuk set content, padahal sudah ada di `useEditor`. Bisa menyebabkan double content update.
|
||||
|
||||
**Rekomendasi:** Simplify - remove useEffect:
|
||||
```typescript
|
||||
const editor = useEditor({
|
||||
extensions: [...],
|
||||
immediatelyRender: false,
|
||||
content: initialContent || '<p></p>', // ✅ Set content directly
|
||||
onUpdate: ({editor}) => {
|
||||
onChange(editor.getHTML())
|
||||
},
|
||||
});
|
||||
|
||||
// Remove useEffect completely
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low (perlu update shared component)
|
||||
|
||||
---
|
||||
|
||||
#### **9. Missing Error Boundary**
|
||||
|
||||
**Lokasi:** `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
- Tidak ada error boundary untuk handle unexpected errors
|
||||
- Jika editor gagal load, tidak ada fallback UI
|
||||
|
||||
**Rekomendasi:** Add error boundary:
|
||||
```typescript
|
||||
if (dasarHukumState.findById.error) {
|
||||
return (
|
||||
<Alert icon={<IconAlertCircle />} color="red">
|
||||
<Text fw="bold">Error</Text>
|
||||
<Text>{dasarHukumState.findById.error}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **10. Preview Page - Title Order Inconsistency**
|
||||
|
||||
**Lokasi:** `page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~40
|
||||
<Title order={3} ...>Preview Dasar Hukum PPID</Title>
|
||||
|
||||
// Line ~65
|
||||
<Title order={3} ... dangerouslySetInnerHTML={{ __html: listDasarHukum.findById.data.judul }} />
|
||||
```
|
||||
|
||||
**Issue:** Title hierarchy agak confusing. Page title dan content title sama-sama order 3.
|
||||
|
||||
**Rekomendasi:** Samakan hierarchy:
|
||||
```typescript
|
||||
// Page title: order={2}
|
||||
// Content title (judul): order={3}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **11. Missing Toast Success After Save**
|
||||
|
||||
**Lokasi:** `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~75-90
|
||||
const handleSubmit = () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
if (dasarHukumState.findById.data) {
|
||||
const updated = { ...dasarHukumState.findById.data, ...formData };
|
||||
dasarHukumState.update.save(updated);
|
||||
}
|
||||
router.push('/admin/ppid/dasar-hukum'); // ✅ Redirect tanpa toast
|
||||
} catch (error) {
|
||||
console.error("Error updating dasar hukum:", error);
|
||||
toast.error("Terjadi kesalahan saat memperbarui dasar hukum");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Issue:** Toast success ada di state `update.save()`, tapi user mungkin tidak lihat karena langsung redirect.
|
||||
|
||||
**Rekomendasi:** Add toast before redirect atau wait untuk toast selesai:
|
||||
```typescript
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
if (dasarHukumState.findById.data) {
|
||||
const updated = { ...dasarHukumState.findById.data, ...formData };
|
||||
await dasarHukumState.update.save(updated);
|
||||
toast.success("Dasar Hukum berhasil diperbarui!");
|
||||
setTimeout(() => {
|
||||
router.push('/admin/ppid/dasar-hukum');
|
||||
}, 1000); // Wait 1 second for toast to show
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating dasar hukum:", error);
|
||||
toast.error("Terjadi kesalahan saat memperbarui dasar hukum");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **12. SSR Dynamic Import - Good but Could Add Loading**
|
||||
|
||||
**Lokasi:** `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~13-17
|
||||
const PPIDTextEditor = dynamic(
|
||||
() => import('../../_com/PPIDTextEditor').then(mod => mod.PPIDTextEditor),
|
||||
{ ssr: false } // ✅ Good
|
||||
);
|
||||
```
|
||||
|
||||
**Issue:** Tidak ada loading state untuk dynamic import. Jika editor lambat load, user lihat kosong.
|
||||
|
||||
**Rekomendasi:** Add loading option:
|
||||
```typescript
|
||||
const PPIDTextEditor = dynamic(
|
||||
() => import('../../_com/PPIDTextEditor').then(mod => mod.PPIDTextEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Center py={40}>
|
||||
<Loader size="sm" />
|
||||
<Text ml="md">Loading editor...</Text>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
## 📋 RINGKASAN ACTION ITEMS
|
||||
|
||||
| Priority | Issue | Module | Impact | Effort | Status |
|
||||
|----------|-------|--------|--------|--------|--------|
|
||||
| 🔴 P0 | **Schema deletedAt default SALAH** | Schema | **CRITICAL** | Medium | **MUST FIX** |
|
||||
| 🔴 P0 | **HTML injection risk** | UI | **HIGH (Security)** | Low | **Should fix** |
|
||||
| 🔴 P1 | Missing delete confirmation | UI | Medium | Low | Should fix |
|
||||
| 🟡 M | Console.log in production | State | Low | Low | Optional |
|
||||
| 🟡 M | Missing loading state di submit button | UI | Low | Low | Should fix |
|
||||
| 🟡 M | Zod schema error messages | State | Low | Low | Optional |
|
||||
| 🟢 L | Missing change detection | Edit UI | Low | Low | Optional |
|
||||
| 🟢 L | Duplicate useEffect di editor | Editor | Low | Low | Optional |
|
||||
| 🟢 L | Missing error boundary | UI | Low | Low | Optional |
|
||||
| 🟢 L | Title order inconsistency | UI | Low | Low | Optional |
|
||||
| 🟢 L | Missing toast success timing | UI | Low | Low | Optional |
|
||||
| 🟢 L | SSR loading state | UI | Low | Low | Optional |
|
||||
|
||||
---
|
||||
|
||||
## ✅ KESIMPULAN
|
||||
|
||||
### **Overall Quality: 🟢 BAIK (8.5/10) - CLEAN & SIMPLE!**
|
||||
|
||||
**Strengths:**
|
||||
1. ✅ UI/UX clean & responsive
|
||||
2. ✅ **Rich Text Editor** full-featured (Tiptap, shared component)
|
||||
3. ✅ **Dynamic import dengan `ssr: false`** - Proper SSR handling! ✅
|
||||
4. ✅ **State management BEST PRACTICES** - **100% ApiFetch!** ✅
|
||||
5. ✅ **Edit form reset sudah benar** (original data tracking)
|
||||
6. ✅ **Rich text validation** comprehensive (check empty content)
|
||||
7. ✅ Error handling comprehensive
|
||||
8. ✅ Loading state management dengan finally block
|
||||
9. ✅ **Reusable component** (PPIDTextEditor shared dengan Visi Misi)
|
||||
|
||||
**Critical Issues:**
|
||||
1. ⚠️ **Schema deletedAt default SALAH** - Logic error untuk soft delete (CRITICAL)
|
||||
2. ⚠️ **HTML injection risk** - dangerouslySetInnerHTML tanpa sanitization (HIGH Security)
|
||||
3. ⚠️ Missing confirmation sebelum save (Medium UX)
|
||||
|
||||
**Areas for Improvement:**
|
||||
1. ⚠️ **Fix schema deletedAt** dari `@default(now())` ke `@default(null)` dengan nullable
|
||||
2. ⚠️ **Fix HTML injection** dengan DOMPurify atau backend validation
|
||||
3. ⚠️ **Add confirmation dialog** sebelum save
|
||||
4. ⚠️ **Add change detection** untuk avoid unnecessary saves
|
||||
5. ⚠️ **Fix loading state** di submit button
|
||||
|
||||
**Recommended Next Steps:**
|
||||
1. **🔴 CRITICAL: Fix schema deletedAt** - 30 menit (perlu migration)
|
||||
2. **🔴 HIGH: Fix HTML injection** dengan DOMPurify - 30 menit
|
||||
3. **🟡 MEDIUM: Add confirmation dialog** - 15 menit
|
||||
4. **🟢 LOW: Add change detection** - 15 menit
|
||||
5. **🟢 LOW: Add SSR loading state** - 10 menit
|
||||
6. **🟢 LOW: Polish minor issues** - 30 menit
|
||||
|
||||
---
|
||||
|
||||
## 📈 COMPARISON WITH OTHER MODULES
|
||||
|
||||
| Module | Fetch Pattern | State | Edit Reset | Rich Text | SSR Handling | HTML Injection | deletedAt | Overall |
|
||||
|--------|--------------|-------|------------|-----------|--------------|----------------|-----------|---------|
|
||||
| Profil | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ None | N/A | ⚠️ Present | ⚠️ Issue | 🟢 |
|
||||
| Desa Anti Korupsi | ⚠️ Mixed | ⚠️ Good | ✅ Good | ✅ Present | ⚠️ None | ⚠️ Present | ⚠️ Issue | 🟢 |
|
||||
| SDGs Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ None | N/A | N/A | ⚠️ Issue | 🟢 |
|
||||
| APBDes | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ None | N/A | N/A | ✅ Good | 🟢 |
|
||||
| Prestasi Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ✅ Present | ⚠️ None | ⚠️ Present | ❌ WRONG | 🟢 |
|
||||
| PPID Profil | ⚠️ Mixed | ✅ **Best** | ✅ **Excellent** | ✅ **Best** | ⚠️ None | ⚠️ Present | ❌ WRONG | 🟢⭐ |
|
||||
| Struktur PPID | ⚠️ Mixed | ✅ Good | ✅ Good | ✅ Present | ⚠️ None | ⚠️ Present | ⚠️ Inconsistent | 🟢 |
|
||||
| Visi Misi PPID | ✅ **100% ApiFetch!** | ✅ **Best** | ✅ Good | ✅ Present | ⚠️ None | ⚠️ Present | ❌ WRONG | 🟢⭐⭐ |
|
||||
| **Dasar Hukum PPID** | ✅ **100% ApiFetch!** | ✅ **Best** | ✅ Good | ✅ Present | ✅ **EXCELLENT** | ⚠️ Present | ❌ WRONG | 🟢⭐⭐ |
|
||||
|
||||
**Dasar Hukum PPID Highlights:**
|
||||
- ✅ **100% ApiFetch** - NO fetch manual sama sekali!
|
||||
- ✅ **SSR Handling** - Dynamic import dengan `ssr: false` (UNIQUE!)
|
||||
- ✅ **Reusable component** - Share PPIDTextEditor dengan Visi Misi
|
||||
- ✅ **Simple & clean** - No unnecessary complexity
|
||||
- ⚠️ **Same deletedAt issue** seperti modul PPID lain
|
||||
|
||||
---
|
||||
|
||||
## 🎯 UNIQUE FEATURES OF DASAR HUKUM PPID MODULE
|
||||
|
||||
**Simplest & Cleanest Module:**
|
||||
1. ✅ **100% ApiFetch consistency** - NO fetch manual sama sekali!
|
||||
2. ✅ **SSR Handling** - Dynamic import dengan `ssr: false` (UNIQUE!)
|
||||
3. ✅ **Reusable component** - Share PPIDTextEditor dengan Visi Misi
|
||||
4. ✅ **Simple single record pattern** - Only 2 fields (judul, content)
|
||||
5. ✅ **Rich text validation** - Check empty content
|
||||
|
||||
**Best Practices:**
|
||||
1. ✅ **API consistency** - 100% ApiFetch
|
||||
2. ✅ **SSR handling** - Best practice untuk Next.js
|
||||
3. ✅ **Loading state management** proper (dengan finally block)
|
||||
4. ✅ **Rich text validation** comprehensive
|
||||
5. ✅ **Original data tracking** untuk reset form
|
||||
6. ✅ **Component reusability** - Share editor component
|
||||
|
||||
**Critical Issues:**
|
||||
1. ❌ **Schema deletedAt SALAH** - Same issue seperti modul PPID lain
|
||||
2. ❌ **HTML injection risk** - Same issue seperti modul dengan rich text lain
|
||||
|
||||
---
|
||||
|
||||
**Catatan:** **Dasar Hukum PPID adalah MODULE PALING CLEAN** bersama Visi Misi PPID dengan codebase paling simple dan **100% PAKAI ApiFetch** (no fetch manual sama sekali!). Module ini juga **SATU-SATUNYA MODULE** yang punya proper SSR handling dengan dynamic import!
|
||||
|
||||
**Unique Strengths:**
|
||||
1. ✅ **100% ApiFetch** - Best API consistency
|
||||
2. ✅ **SSR Handling** - Best practice untuk Next.js (UNIQUE!)
|
||||
3. ✅ **Component reusability** - Share editor component
|
||||
4. ✅ **Simple & clean** - No unnecessary complexity
|
||||
5. ✅ **Rich text validation** - Most comprehensive
|
||||
|
||||
**Priority Action:**
|
||||
```diff
|
||||
🔴 FIX INI SEKARANG (30 MENIT + MIGRATION):
|
||||
File: prisma/schema.prisma
|
||||
Line: 385
|
||||
|
||||
model DasarHukumPPID {
|
||||
id String @id @default(cuid())
|
||||
judul String @db.Text
|
||||
content String @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
- deletedAt DateTime @default(now())
|
||||
+ deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
# Lalu jalankan:
|
||||
bunx prisma db push
|
||||
# atau
|
||||
bunx prisma migrate dev --name fix_deletedat_dasarhukum_ppid
|
||||
```
|
||||
|
||||
```diff
|
||||
🔴 FIX HTML INJECTION (30 MENIT):
|
||||
File: page.tsx
|
||||
+ import DOMPurify from 'dompurify';
|
||||
|
||||
// Line ~65
|
||||
- dangerouslySetInnerHTML={{ __html: listDasarHukum.findById.data.judul }}
|
||||
+ dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(listDasarHukum.findById.data.judul) }}
|
||||
|
||||
// Line ~80
|
||||
- dangerouslySetInnerHTML={{ __html: listDasarHukum.findById.data.content }}
|
||||
+ dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(listDasarHukum.findById.data.content) }}
|
||||
```
|
||||
|
||||
Setelah fix critical issues, module ini **PRODUCTION-READY** dan bisa jadi **REFERENCE untuk SSR HANDLING & API CONSISTENCY**! 🎉
|
||||
|
||||
---
|
||||
|
||||
## 📚 RECOMMENDED AS REFERENCE FOR OTHER MODULES
|
||||
|
||||
**Dasar Hukum PPID Module adalah BEST PRACTICE untuk:**
|
||||
1. ✅ **API consistency** - 100% ApiFetch, NO fetch manual!
|
||||
2. ✅ **SSR handling** - Dynamic import dengan `ssr: false`
|
||||
3. ✅ **Simple state management** - Clean, straightforward
|
||||
4. ✅ **Rich text validation** - Check empty content pattern
|
||||
5. ✅ **Component reusability** - Share editor component
|
||||
|
||||
**Modules lain bisa belajar dari Dasar Hukum PPID:**
|
||||
- **ALL MODULES:** Use ApiFetch consistently (NO fetch manual!)
|
||||
- **ALL MODULES WITH RICH TEXT:** Use dynamic import dengan `ssr: false`
|
||||
- **ALL MODULES:** Keep it simple (avoid unnecessary complexity)
|
||||
- **Rich Text Modules:** Implement empty content validation
|
||||
- **ALL MODULES:** Share reusable components
|
||||
|
||||
---
|
||||
|
||||
**File Location:** `QC/PPID/QC-DASAR-HUKUM-PPID-MODULE.md` 📄
|
||||
@@ -1,913 +0,0 @@
|
||||
# QC Summary - Indeks Kepuasan Masyarakat (IKM) PPID Module
|
||||
|
||||
**Scope:** Responden (CRUD), Grafik Kepuasan Masyarakat, Master Data (Jenis Kelamin, Rating, Kelompok Umur)
|
||||
**Date:** 2026-02-23
|
||||
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
|
||||
|
||||
---
|
||||
|
||||
## 📊 OVERVIEW
|
||||
|
||||
| Sub-Module | Schema | API | UI Admin | State Management | Overall |
|
||||
|------------|--------|-----|----------|-----------------|---------|
|
||||
| Responden | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 |
|
||||
| Grafik IKM | ✅ Baik | ✅ Baik | ✅ **Excellent** | ✅ Baik | 🟢 |
|
||||
| Master Data (JK, Rating, Umur) | ⚠️ Ada issue | ✅ Baik | N/A | ⚠️ Ada issue | 🟡 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ YANG SUDAH BAIK
|
||||
|
||||
### **1. UI/UX - Grafik & Charts (UNIQUE FEATURE!)**
|
||||
- ✅ **Mantine Charts** - PieChart & BarChart yang modern
|
||||
- ✅ **3 Distribusi Charts**: Jenis Kelamin, Penilaian, Kelompok Umur
|
||||
- ✅ **Bar Chart Tren** - Monthly respondent trends
|
||||
- ✅ **Responsive design** - SimpleGrid dengan proper breakpoints
|
||||
- ✅ **Empty state handling** - "Tidak ada data" message
|
||||
- ✅ **Loading states** dengan Skeleton
|
||||
- ✅ **Color coding** yang konsisten
|
||||
- ✅ **Legend & Labels** yang informatif
|
||||
- ✅ **Tooltip** untuk interactive charts
|
||||
|
||||
**Code Example (✅ EXCELLENT):**
|
||||
```typescript
|
||||
// grafik-kepuasan-masyarakat/page.tsx - Line ~100-150
|
||||
<Paper withBorder bg={colors['white-1']} p="lg" radius="xl" shadow="sm">
|
||||
<Title order={3} mb="md" ta="center">Tren Jumlah Responden</Title>
|
||||
<Box h={320}>
|
||||
<BarChart
|
||||
h={300}
|
||||
data={barChartData}
|
||||
dataKey="month"
|
||||
series={[{ name: 'count', color: colors['blue-button'] }]}
|
||||
tickLine="y"
|
||||
xAxisLabel="Bulan"
|
||||
yAxisLabel="Jumlah Responden"
|
||||
withTooltip
|
||||
tooltipAnimationDuration={200}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **EXCELLENT** - Best chart implementation di semua modul PPID!
|
||||
|
||||
---
|
||||
|
||||
### **2. Data Processing untuk Charts**
|
||||
- ✅ Automatic calculation dari data responden
|
||||
- ✅ Grouping by gender, rating, age group
|
||||
- ✅ Monthly aggregation untuk bar chart
|
||||
- ✅ Date parsing dari multiple fields (createdAt, tanggal)
|
||||
- ✅ Sorting by month/year
|
||||
- ✅ Empty data handling (all values = 0)
|
||||
|
||||
**Code Example (✅ EXCELLENT):**
|
||||
```typescript
|
||||
// grafik-kepuasan-masyarakat/page.tsx - Line ~45-85
|
||||
// Hitung total berdasarkan jenis kelamin
|
||||
const totalLaki = data.filter((item: any) =>
|
||||
item.jenisKelamin?.name?.toLowerCase() === 'laki-laki'
|
||||
).length;
|
||||
|
||||
const totalPerempuan = data.filter((item: any) =>
|
||||
item.jenisKelamin?.name?.toLowerCase() === 'perempuan'
|
||||
).length;
|
||||
|
||||
// Update gender chart data
|
||||
setDonutDataJenisKelamin([
|
||||
{ name: 'Laki-laki', value: totalLaki, color: colors['blue-button'] },
|
||||
{ name: 'Perempuan', value: totalPerempuan, color: '#10A85AFF' },
|
||||
]);
|
||||
|
||||
// Process data for bar chart (group by month)
|
||||
const monthYearMap = new Map<string, number>();
|
||||
data.forEach((item: any) => {
|
||||
const dateValue = item.tanggal || item.createdAt;
|
||||
const parsedDate = new Date(dateValue);
|
||||
const month = parsedDate.getMonth() + 1;
|
||||
const year = parsedDate.getFullYear();
|
||||
const monthYearKey = `${year}-${String(month).padStart(2, '0')}`;
|
||||
monthYearMap.set(monthYearKey, (monthYearMap.get(monthYearKey) || 0) + 1);
|
||||
});
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **EXCELLENT** - Data processing yang comprehensive!
|
||||
|
||||
---
|
||||
|
||||
### **3. Form Validation**
|
||||
- ✅ Zod schema untuk semua forms
|
||||
- ✅ Required field validation
|
||||
- ✅ Multiple dropdown dependencies (Jenis Kelamin, Rating, Umur)
|
||||
- ✅ Loading state handling untuk dropdown data
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// state file - Line ~10-16
|
||||
const templateResponden = z.object({
|
||||
name: z.string().min(1, "Nama harus diisi"),
|
||||
tanggal: z.string().min(1, "Tanggal harus diisi"),
|
||||
jenisKelaminId: z.string().min(1, "Jenis kelamin harus diisi"),
|
||||
ratingId: z.string().min(1, "Rating harus diisi"),
|
||||
kelompokUmurId: z.string().min(1, "Kelompok umur harus diisi"),
|
||||
});
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **BAIK** - Validation yang proper!
|
||||
|
||||
---
|
||||
|
||||
### **4. State Management**
|
||||
- ✅ Proper typing dengan Prisma types (untuk findUnique)
|
||||
- ✅ Loading state management dengan finally block
|
||||
- ✅ Error handling yang comprehensive
|
||||
- ✅ **ApiFetch consistency** untuk create & findMany! ✅
|
||||
- ✅ Multiple related states (responden, jenisKelamin, rating, umur)
|
||||
- ✅ Reusable Select component di edit page
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// state file - Line ~60-95
|
||||
findMany: {
|
||||
data: null as any[] | null,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
total: 0,
|
||||
loading: false,
|
||||
search: "",
|
||||
load: async (page = 1, limit = 10, search = "") => {
|
||||
responden.findMany.loading = true; // ✅ Start loading
|
||||
responden.findMany.page = page;
|
||||
responden.findMany.search = search;
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
if (search) query.search = search;
|
||||
|
||||
const res = await ApiFetch.api.landingpage.responden["findMany"].get({ query });
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
responden.findMany.data = res.data.data || [];
|
||||
responden.findMany.total = res.data.total || 0;
|
||||
responden.findMany.totalPages = res.data.totalPages || 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading responden:", error);
|
||||
responden.findMany.data = [];
|
||||
responden.findMany.total = 0;
|
||||
responden.findMany.totalPages = 1;
|
||||
} finally {
|
||||
responden.findMany.loading = false; // ✅ Stop loading
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **BAIK** - State management sudah proper dengan ApiFetch!
|
||||
|
||||
---
|
||||
|
||||
### **5. Edit Form - Original Data Tracking**
|
||||
- ✅ Original data state untuk reset form
|
||||
- ✅ Load data existing dengan benar
|
||||
- ✅ Reset form mengembalikan ke data original
|
||||
- ✅ Reusable ControlledSelect component
|
||||
- ✅ Error display untuk setiap field
|
||||
|
||||
**Code Example (✅ EXCELLENT):**
|
||||
```typescript
|
||||
// edit/page.tsx - Line ~40-60
|
||||
const [formData, setFormData] = useState<FormResponden>({
|
||||
name: '',
|
||||
tanggal: '',
|
||||
jenisKelaminId: '',
|
||||
ratingId: '',
|
||||
kelompokUmurId: '',
|
||||
});
|
||||
|
||||
const [originalData, setOriginalData] = useState<FormResponden>({
|
||||
name: '',
|
||||
tanggal: '',
|
||||
jenisKelaminId: '',
|
||||
ratingId: '',
|
||||
kelompokUmurId: '',
|
||||
});
|
||||
|
||||
// Load data
|
||||
const data = await state.update.load(id);
|
||||
setFormData(newForm);
|
||||
setOriginalData(newForm); // ✅ Save original
|
||||
|
||||
// Line ~130 - Handle reset
|
||||
const handleResetForm = () => {
|
||||
setFormData({ ...originalData });
|
||||
toast.info('Form dikembalikan ke data awal');
|
||||
};
|
||||
|
||||
// Line ~150 - Reusable Select component
|
||||
const ControlledSelect = ({
|
||||
label, value, onChange, options, error, loading,
|
||||
}) => (
|
||||
<Select
|
||||
label={<Text fw="bold" fz="sm" mb={4}>{label}</Text>}
|
||||
value={value}
|
||||
onChange={(val) => onChange(val || '')}
|
||||
data={options}
|
||||
disabled={loading}
|
||||
clearable
|
||||
searchable
|
||||
required
|
||||
radius="md"
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **EXCELLENT** - Best edit form implementation dengan reusable component!
|
||||
|
||||
---
|
||||
|
||||
### **6. Master Data Management**
|
||||
- ✅ 3 master data tables: Jenis Kelamin, Rating, Kelompok Umur
|
||||
- ✅ Separate proxy states untuk masing-masing
|
||||
- ✅ Auto-load saat create/edit form
|
||||
- ✅ Proper filtering dan mapping untuk dropdown options
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ ISSUES & SARAN PERBAIKAN
|
||||
|
||||
### **🔴 CRITICAL**
|
||||
|
||||
#### **1. Schema - deletedAt Default Value SALAH (5 MODELS AFFECTED!)**
|
||||
|
||||
**Lokasi:** `prisma/schema.prisma` (line 266-297)
|
||||
|
||||
**Masalah:**
|
||||
```prisma
|
||||
model Responden {
|
||||
// ...
|
||||
deletedAt DateTime @default(now()) // ❌ SALAH
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model JenisKelaminResponden {
|
||||
// ...
|
||||
deletedAt DateTime @default(now()) // ❌ SALAH
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model PilihanRatingResponden {
|
||||
// ...
|
||||
deletedAt DateTime @default(now()) // ❌ SALAH
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model UmurResponden {
|
||||
// ...
|
||||
deletedAt DateTime @default(now()) // ❌ SALAH
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- **LOGIC ERROR!** Setiap record baru langsung punya `deletedAt` value
|
||||
- Soft delete tidak berfungsi dengan benar
|
||||
- Query dengan `where: { deletedAt: null }` tidak akan pernah return data
|
||||
- **5 models affected!** (Responden + 3 master data + StrukturPPID)
|
||||
|
||||
**Rekomendasi:** Fix semua schema:
|
||||
```prisma
|
||||
model Responden {
|
||||
// ...
|
||||
deletedAt DateTime? @default(null) // ✅ Nullable
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model JenisKelaminResponden {
|
||||
// ...
|
||||
deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model PilihanRatingResponden {
|
||||
// ...
|
||||
deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model UmurResponden {
|
||||
// ...
|
||||
deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 **CRITICAL**
|
||||
**Effort:** Medium (perlu migration untuk 5 models)
|
||||
**Impact:** **HIGH** (data integrity & soft delete logic)
|
||||
|
||||
---
|
||||
|
||||
#### **2. State Management - Fetch Pattern Inconsistency**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/indeks-kepuasan.ts`
|
||||
|
||||
**Masalah:** Ada 2 pattern berbeda untuk fetch API:
|
||||
|
||||
```typescript
|
||||
// ❌ Pattern 1: ApiFetch (create, findMany)
|
||||
const res = await ApiFetch.api.landingpage.responden["create"].post(form);
|
||||
const res = await ApiFetch.api.landingpage.responden["findMany"].get({ query });
|
||||
|
||||
// ❌ Pattern 2: fetch manual (findUnique, update)
|
||||
const res = await fetch(`/api/landingpage/responden/${id}`);
|
||||
const response = await fetch(`/api/landingpage/responden/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- Code consistency buruk
|
||||
- Sulit maintenance
|
||||
- Type safety tidak konsisten
|
||||
- Duplikasi logic error handling
|
||||
|
||||
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
|
||||
|
||||
```typescript
|
||||
// ✅ Unified pattern
|
||||
async load(id: string) {
|
||||
try {
|
||||
const res = await ApiFetch.api.landingpage.responden[id].get();
|
||||
|
||||
if (res.data?.success) {
|
||||
responden.findUnique.data = res.data.data;
|
||||
} else {
|
||||
toast.error(res.data?.message || "Gagal memuat data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
toast.error("Gagal memuat data");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 High
|
||||
**Effort:** Medium (refactor di findUnique, update methods)
|
||||
|
||||
---
|
||||
|
||||
#### **3. Type Safety - Any Usage di findMany**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/indeks-kepuasan.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~58
|
||||
data: null as any[] | null, // ❌ Using 'any'
|
||||
|
||||
// Line ~270
|
||||
data: null as any[] | null, // ❌ Using 'any'
|
||||
|
||||
// Line ~370
|
||||
data: null as any[] | null, // ❌ Using 'any'
|
||||
|
||||
// Line ~470
|
||||
data: null as any[] | null, // ❌ Using 'any'
|
||||
```
|
||||
|
||||
**Issue:** findMany data tidak typed dengan Prisma types, hanya findUnique yang typed.
|
||||
|
||||
**Rekomendasi:** Gunakan typed data:
|
||||
|
||||
```typescript
|
||||
// Define type
|
||||
type RespondenWithRelations = Prisma.RespondenGetPayload<{
|
||||
include: {
|
||||
jenisKelamin: true;
|
||||
rating: true;
|
||||
kelompokUmur: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
// Use typed data
|
||||
data: null as RespondenWithRelations[] | null,
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟡 MEDIUM**
|
||||
|
||||
#### **4. Console.log di Production**
|
||||
|
||||
**Lokasi:** Multiple places di state file
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~80
|
||||
console.error("Failed to load responden:", res.data?.message);
|
||||
|
||||
// Line ~85
|
||||
console.error("Error loading responden:", error);
|
||||
|
||||
// Line ~110
|
||||
console.error("Failed to fetch data", res.status, res.statusText);
|
||||
|
||||
// Line ~114
|
||||
console.error("Error loading responden:", error);
|
||||
|
||||
// ... dan banyak lagi di semua master data states
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan conditional logging:
|
||||
|
||||
```typescript
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error("Error:", error);
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **5. Missing Loading State di Submit Button**
|
||||
|
||||
**Lokasi:** `create/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~100-110
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
size="md"
|
||||
disabled={!isFormValid() || isSubmitting}
|
||||
// ...
|
||||
>
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - Loading state sudah ada di create page!
|
||||
|
||||
**Priority:** 🟢 None
|
||||
**Effort:** None
|
||||
|
||||
---
|
||||
|
||||
#### **6. Missing Loading State di Edit Submit Button**
|
||||
|
||||
**Lokasi:** `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~220-230
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
size="md"
|
||||
disabled={!isFormValid() || isSubmitting}
|
||||
// ⚠️ Missing state.update.loading check
|
||||
>
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Issue:** Button tidak check `state.update.loading` dari global state.
|
||||
|
||||
**Rekomendasi:** Check both states:
|
||||
```typescript
|
||||
disabled={!isFormValid() || isSubmitting || state.update.loading}
|
||||
{isSubmitting || state.update.loading ? (
|
||||
<Loader size="sm" color="white" />
|
||||
) : (
|
||||
'Simpan'
|
||||
)}
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **7. Pagination onChange Tidak Include Search**
|
||||
|
||||
**Lokasi:** `responden/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~200-210
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10); // ⚠️ Missing search parameter
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
total={totalPages}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
**Issue:** Saat ganti page, search query hilang.
|
||||
|
||||
**Rekomendasi:** Include search:
|
||||
```typescript
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10, debouncedSearch); // ✅ Include search
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟢 LOW (Minor Polish)**
|
||||
|
||||
#### **8. Missing Delete Function di Master Data**
|
||||
|
||||
**Lokasi:** State file untuk master data
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~270-290 (jenisKelaminResponden)
|
||||
delete: {
|
||||
loading: false,
|
||||
async byId(id: string) {
|
||||
// ✅ Method sudah ada
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - Delete function sudah ada di semua master data!
|
||||
|
||||
**Priority:** 🟢 None
|
||||
**Effort:** None
|
||||
|
||||
---
|
||||
|
||||
#### **9. Duplicate Loading State Assignment**
|
||||
|
||||
**Lokasi:** State file untuk master data
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~290-295 (jenisKelaminResponden.create)
|
||||
async create() {
|
||||
// ...
|
||||
jenisKelaminResponden.create.loading = true; // ✅ First assignment
|
||||
try {
|
||||
jenisKelaminResponden.create.loading = true; // ❌ Duplicate!
|
||||
const res = await ApiFetch.api.landingpage.jeniskelaminresponden["create"].post(form);
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rekomendasi:** Remove duplicate:
|
||||
```typescript
|
||||
async create() {
|
||||
// ...
|
||||
jenisKelaminResponden.create.loading = true; // ✅ Keep only this
|
||||
try {
|
||||
// Remove duplicate line
|
||||
const res = await ApiFetch.api.landingpage.jeniskelaminresponden["create"].post(form);
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low (ada di 3 master data states)
|
||||
|
||||
---
|
||||
|
||||
#### **10. Inconsistent Toast Messages**
|
||||
|
||||
**Lokasi:** State file
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~45 (responden.create)
|
||||
toast.success("Responden berhasil ditambahkan");
|
||||
|
||||
// Line ~295 (jenisKelaminResponden.create)
|
||||
toast.success("Jenis kelamin responden berhasil ditambahkan");
|
||||
|
||||
// Line ~400 (pilihanRatingResponden.create)
|
||||
toast.success("Jenis kelamin responden berhasil ditambahkan"); // ❌ Wrong message!
|
||||
|
||||
// Line ~505 (kelompokUmurResponden.create)
|
||||
toast.success("Kelompok umur responden berhasil ditambahkan");
|
||||
```
|
||||
|
||||
**Issue:** Copy-paste error di pilihanRatingResponden (masih "Jenis kelamin responden").
|
||||
|
||||
**Rekomendasi:** Fix message:
|
||||
```typescript
|
||||
toast.success("Pilihan rating responden berhasil ditambahkan");
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **11. Missing Edit Page untuk Master Data**
|
||||
|
||||
**Lokasi:** Module structure
|
||||
|
||||
**Masalah:**
|
||||
- ✅ Responden: Create, Edit, Detail, Delete
|
||||
- ❌ Jenis Kelamin: Create, Delete (NO EDIT)
|
||||
- ❌ Rating: Create, Delete (NO EDIT)
|
||||
- ❌ Kelompok Umur: Create, Delete (NO EDIT)
|
||||
|
||||
**Issue:** Master data tidak bisa diedit, hanya bisa delete & create ulang.
|
||||
|
||||
**Rekomendasi:** Consider adding edit pages untuk master data jika diperlukan:
|
||||
```typescript
|
||||
// Add edit method di state (sudah ada)
|
||||
// Add edit page di UI
|
||||
/admin/ppid/indeks-kepuasan-masyarakat/jenis-kelamin/[id]/edit
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low (business decision)
|
||||
**Effort:** Medium
|
||||
|
||||
---
|
||||
|
||||
#### **12. Search Placeholder Tidak Spesifik**
|
||||
|
||||
**Lokasi:** `responden/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~30-35
|
||||
<HeaderSearch
|
||||
title="Data Responden"
|
||||
placeholder="Cari nama responden..." // ✅ Actually pretty specific!
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - Placeholder sudah spesifik!
|
||||
|
||||
**Priority:** 🟢 None
|
||||
**Effort:** None
|
||||
|
||||
---
|
||||
|
||||
#### **13. Chart Color Hardcoding**
|
||||
|
||||
**Lokasi:** `grafik-kepuasan-masyarakat/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~55-60
|
||||
setDonutDataJenisKelamin([
|
||||
{ name: 'Laki-laki', value: totalLaki, color: colors['blue-button'] },
|
||||
{ name: 'Perempuan', value: totalPerempuan, color: '#10A85AFF' }, // ❌ Hardcoded
|
||||
]);
|
||||
|
||||
setDonutDataRating([
|
||||
{ name: 'Sangat Baik', value: totalSangatBaik, color: colors['blue-button'] },
|
||||
{ name: 'Baik', value: totalBaik, color: '#10A85AFF' }, // ❌ Hardcoded
|
||||
{ name: 'Kurang Baik', value: totalKurangBaik, color: '#FFA500' }, // ❌ Hardcoded
|
||||
{ name: 'Sangat Kurang Baik', value: totalSangatKurangBaik, color: '#FF4500' }, // ❌ Hardcoded
|
||||
]);
|
||||
```
|
||||
|
||||
**Rekomendasi:** Define color constants:
|
||||
```typescript
|
||||
// con/colors.ts atau file terpisah
|
||||
export const chartColors = {
|
||||
primary: colors['blue-button'],
|
||||
success: '#10A85AFF',
|
||||
warning: '#FFA500',
|
||||
danger: '#FF4500',
|
||||
};
|
||||
|
||||
// Use in chart data
|
||||
{ name: 'Perempuan', value: totalPerempuan, color: chartColors.success },
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **14. Date Parsing di Detail Page**
|
||||
|
||||
**Lokasi:** `responden/[id]/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~65-70
|
||||
<Text fz="md" c="dimmed">{
|
||||
stateDetail.findUnique.data?.tanggal
|
||||
? new Date(stateDetail.findUnique.data.tanggal).toLocaleDateString('id-ID')
|
||||
: '-'
|
||||
}</Text>
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - Date formatting yang proper!
|
||||
|
||||
**Priority:** 🟢 None
|
||||
**Effort:** None
|
||||
|
||||
---
|
||||
|
||||
## 📋 RINGKASAN ACTION ITEMS
|
||||
|
||||
| Priority | Issue | Module | Impact | Effort | Status |
|
||||
|----------|-------|--------|--------|--------|--------|
|
||||
| 🔴 P0 | **Schema deletedAt default SALAH (5 models)** | Schema | **CRITICAL** | Medium | **MUST FIX** |
|
||||
| 🔴 P0 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
|
||||
| 🟡 M | Type safety (any usage) | State | Low | Low | Optional |
|
||||
| 🟡 M | Console.log in production | State | Low | Low | Optional |
|
||||
| 🟡 M | Missing loading state di edit submit | UI | Low | Low | Should fix |
|
||||
| 🟡 M | Pagination missing search param | UI | Low | Low | Should fix |
|
||||
| 🟢 L | Duplicate loading state assignment | State | Low | Low | Optional |
|
||||
| 🟢 L | Inconsistent toast messages | State | Low | Low | Should fix |
|
||||
| 🟢 L | Missing edit page untuk master data | UI | Low | Medium | Optional |
|
||||
| 🟢 L | Chart color hardcoding | UI | Low | Low | Optional |
|
||||
|
||||
---
|
||||
|
||||
## ✅ KESIMPULAN
|
||||
|
||||
### **Overall Quality: 🟢 BAIK (8/10)**
|
||||
|
||||
**Strengths:**
|
||||
1. ✅ **Grafik & Charts EXCELLENT** - Best chart implementation di semua modul PPID!
|
||||
2. ✅ **Data processing comprehensive** - Automatic calculation dari data responden
|
||||
3. ✅ **3 Distribusi Charts** - Jenis Kelamin, Penilaian, Kelompok Umur
|
||||
4. ✅ **Bar Chart Tren** - Monthly respondent trends
|
||||
5. ✅ UI/UX clean & responsive
|
||||
6. ✅ Form validation comprehensive
|
||||
7. ✅ State management dengan ApiFetch untuk create & findMany
|
||||
8. ✅ **Edit form EXCELLENT** - Reusable ControlledSelect component
|
||||
9. ✅ Original data tracking untuk reset form
|
||||
10. ✅ Master data management proper (3 tables)
|
||||
11. ✅ Loading state management dengan finally block
|
||||
12. ✅ Mobile cards responsive
|
||||
|
||||
**Critical Issues:**
|
||||
1. ⚠️ **Schema deletedAt default SALAH** - 5 models affected (CRITICAL)
|
||||
2. ⚠️ Fetch method pattern inconsistency (ApiFetch vs fetch manual)
|
||||
3. ⚠️ Type safety (any usage di findMany)
|
||||
|
||||
**Areas for Improvement:**
|
||||
1. ⚠️ **Fix schema deletedAt** untuk 5 models dari `@default(now())` ke `@default(null)` dengan nullable
|
||||
2. ⚠️ **Refactor fetch methods** untuk gunakan ApiFetch consistently
|
||||
3. ⚠️ **Improve type safety** dengan remove `any` usage
|
||||
4. ⚠️ **Add loading state** di edit submit button
|
||||
5. ⚠️ **Fix duplicate loading state** di master data create methods
|
||||
6. ⚠️ **Fix copy-paste toast message** di pilihanRatingResponden
|
||||
|
||||
**Recommended Next Steps:**
|
||||
1. **🔴 CRITICAL: Fix schema deletedAt** untuk 5 models - 1 jam (perlu migration)
|
||||
2. **🔴 HIGH: Refactor findUnique, update** ke ApiFetch - 1 jam
|
||||
3. **🟡 MEDIUM: Improve type safety** - 30 menit
|
||||
4. **🟡 MEDIUM: Add loading state** di edit submit - 10 menit
|
||||
5. **🟡 MEDIUM: Fix pagination search param** - 10 menit
|
||||
6. **🟢 LOW: Fix duplicate loading state** - 15 menit
|
||||
7. **🟢 LOW: Fix toast message** - 5 menit
|
||||
8. **🟢 LOW: Define chart color constants** - 15 menit
|
||||
|
||||
---
|
||||
|
||||
## 📈 COMPARISON WITH OTHER MODULES
|
||||
|
||||
| Module | Charts | Data Processing | Edit Form | State | Schema | Overall |
|
||||
|--------|--------|----------------|-----------|-------|--------|---------|
|
||||
| Profil | ❌ None | N/A | ✅ Good | ⚠️ Good | ⚠️ deletedAt | 🟢 |
|
||||
| Desa Anti Korupsi | ❌ None | N/A | ✅ Good | ⚠️ Good | ⚠️ deletedAt | 🟢 |
|
||||
| SDGs Desa | ❌ None | N/A | ✅ Good | ⚠️ Good | ⚠️ deletedAt | 🟢 |
|
||||
| APBDes | ❌ None | ✅ Items hierarchy | ✅ Good | ⚠️ Good | ✅ Good | 🟢 |
|
||||
| Prestasi Desa | ❌ None | N/A | ✅ Good | ⚠️ Good | ❌ WRONG | 🟢 |
|
||||
| PPID Profil | ❌ None | N/A | ✅ **Excellent** | ✅ **Best** | ❌ WRONG | 🟢⭐ |
|
||||
| Struktur PPID | ❌ None | N/A | ✅ Good | ✅ Good | ⚠️ Inconsistent | 🟢 |
|
||||
| Visi Misi PPID | ❌ None | N/A | ✅ Good | ✅ **Best** | ❌ WRONG | 🟢⭐⭐ |
|
||||
| Dasar Hukum PPID | ❌ None | N/A | ✅ Good | ✅ **Best** | ❌ WRONG | 🟢⭐⭐ |
|
||||
| Permohonan Informasi | ❌ None | N/A | ❌ Missing | ⚠️ Good | ❌ **4 models WRONG** | 🟡 |
|
||||
| Permohonan Keberatan | ❌ None | N/A | ❌ Missing | ⚠️ Good | ❌ WRONG | 🟡 |
|
||||
| Daftar Informasi | ❌ None | N/A | ✅ Good | ⚠️ Good | ❌ WRONG | 🟢 |
|
||||
| **IKM (Indeks Kepuasan)** | ✅ **EXCELLENT** | ✅ **EXCELLENT** | ✅ **Excellent** | ⚠️ Good | ❌ **5 models WRONG** | 🟢 |
|
||||
|
||||
**IKM Highlights:**
|
||||
- ✅ **BEST CHARTS** - Mantine Charts (PieChart, BarChart)
|
||||
- ✅ **BEST DATA PROCESSING** - Automatic calculation & grouping
|
||||
- ✅ **BEST EDIT FORM** - Reusable ControlledSelect component
|
||||
- ⚠️ **5 models affected** - deletedAt issue (most affected module!)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 UNIQUE FEATURES OF IKM MODULE
|
||||
|
||||
**Most Advanced Data Visualization:**
|
||||
1. ✅ **Mantine Charts** - PieChart & BarChart (UNIQUE!)
|
||||
2. ✅ **3 Distribusi Charts** - Jenis Kelamin, Penilaian, Kelompok Umur
|
||||
3. ✅ **Monthly Trend Chart** - Bar chart dengan grouping
|
||||
4. ✅ **Automatic Calculation** - Filter & count dari data
|
||||
5. ✅ **Reusable Select Component** - ControlledSelect di edit form
|
||||
6. ✅ **3 Master Data Tables** - Jenis Kelamin, Rating, Kelompok Umur
|
||||
|
||||
**Best Practices:**
|
||||
1. ✅ **Chart implementation** - Best practice untuk data visualization
|
||||
2. ✅ **Data processing** - Comprehensive calculation & grouping
|
||||
3. ✅ **Reusable components** - ControlledSelect untuk dropdowns
|
||||
4. ✅ **Loading state management** - Proper dengan finally block
|
||||
5. ✅ **Original data tracking** - Edit form reset yang proper
|
||||
6. ✅ **Master data management** - Separate states untuk masing-masing
|
||||
|
||||
**Critical Issues:**
|
||||
1. ❌ **5 models dengan deletedAt SALAH** - Most affected module!
|
||||
2. ❌ **Fetch pattern inconsistency** - findUnique, update pakai fetch manual
|
||||
3. ❌ **Type safety** - any usage di findMany
|
||||
|
||||
---
|
||||
|
||||
**Catatan:** **IKM adalah MODULE DENGAN CHARTS & DATA VISUALIZATION TERBAIK** dengan Mantine Charts implementation yang excellent. Module ini juga punya **BEST EDIT FORM** dengan reusable ControlledSelect component. Tapi juga **MODULE DENGAN PALING BANYAK MODEL AFFECTED** oleh deletedAt issue (5 models!).
|
||||
|
||||
**Unique Strengths:**
|
||||
1. ✅ **Charts EXCELLENT** - Best data visualization
|
||||
2. ✅ **Data processing** - Automatic calculation & grouping
|
||||
3. ✅ **Edit form EXCELLENT** - Reusable ControlledSelect
|
||||
4. ✅ **Master data management** - 3 separate tables
|
||||
5. ✅ **Monthly trends** - Bar chart dengan grouping
|
||||
|
||||
**Priority Action:**
|
||||
```diff
|
||||
🔴 FIX INI SEKARANG (1 JAM + MIGRATION):
|
||||
File: prisma/schema.prisma
|
||||
Line: 266-297
|
||||
|
||||
# Fix 5 models:
|
||||
|
||||
model Responden {
|
||||
// ...
|
||||
- deletedAt DateTime @default(now())
|
||||
+ deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model JenisKelaminResponden {
|
||||
// ...
|
||||
- deletedAt DateTime @default(now())
|
||||
+ deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model PilihanRatingResponden {
|
||||
// ...
|
||||
- deletedAt DateTime @default(now())
|
||||
+ deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model UmurResponden {
|
||||
// ...
|
||||
- deletedAt DateTime @default(now())
|
||||
+ deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
# Lalu jalankan:
|
||||
bunx prisma db push
|
||||
# atau
|
||||
bunx prisma migrate dev --name fix_deletedat_ikm
|
||||
```
|
||||
|
||||
Setelah fix critical issues, module ini **PRODUCTION-READY** dengan **BEST CHARTS & DATA VISUALIZATION**! 🎉
|
||||
|
||||
---
|
||||
|
||||
## 📚 RECOMMENDED AS REFERENCE FOR OTHER MODULES
|
||||
|
||||
**IKM Module adalah BEST PRACTICE untuk:**
|
||||
1. ✅ **Charts & Data Visualization** - Mantine Charts implementation
|
||||
2. ✅ **Data Processing** - Automatic calculation & grouping
|
||||
3. ✅ **Reusable Components** - ControlledSelect untuk dropdowns
|
||||
4. ✅ **Edit Form** - Original data tracking dengan reusable components
|
||||
5. ✅ **Master Data Management** - Separate states untuk multiple tables
|
||||
|
||||
**Modules lain bisa belajar dari IKM:**
|
||||
- **ALL MODULES WITH CHARTS:** Use Mantine Charts (PieChart, BarChart)
|
||||
- **ALL MODULES WITH DROPDOWNS:** Use reusable ControlledSelect component
|
||||
- **ALL MODULES:** Automatic data calculation untuk charts
|
||||
- **ALL MODULES:** Master data management dengan separate states
|
||||
- **ALL MODULES:** Edit form dengan original data tracking
|
||||
|
||||
---
|
||||
|
||||
**File Location:** `QC/PPID/QC-IKM-MODULE.md` 📄
|
||||
@@ -1,844 +0,0 @@
|
||||
# QC Summary - Permohonan Informasi Publik PPID Module
|
||||
|
||||
**Scope:** List Permohonan Informasi Publik, Detail Permohonan
|
||||
**Date:** 2026-02-23
|
||||
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
|
||||
|
||||
---
|
||||
|
||||
## 📊 OVERVIEW
|
||||
|
||||
| Aspect | Schema | API | UI Admin | State Management | Overall |
|
||||
|--------|--------|-----|----------|-----------------|---------|
|
||||
| Permohonan Informasi Publik | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 Perlu fix |
|
||||
|
||||
---
|
||||
|
||||
## ✅ YANG SUDAH BAIK
|
||||
|
||||
### **1. UI/UX Design**
|
||||
- ✅ Preview layout yang clean dengan responsive design
|
||||
- ✅ Loading states dengan Skeleton
|
||||
- ✅ Empty state handling yang informatif dengan icon
|
||||
- ✅ Search functionality dengan debounce (1000ms)
|
||||
- ✅ Pagination yang konsisten
|
||||
- ✅ Desktop table + mobile cards responsive
|
||||
- ✅ Icon integration (User, ID, Phone, Info) untuk visual clarity
|
||||
|
||||
### **2. Table & Card Layout**
|
||||
- ✅ Fixed layout table untuk consistency
|
||||
- ✅ Column headers dengan icon yang descriptive
|
||||
- ✅ Row numbering otomatis (index + 1)
|
||||
- ✅ Text truncation dengan lineClamp untuk long text
|
||||
- ✅ Mobile card view dengan proper information hierarchy
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// page.tsx - Line ~130-180
|
||||
<Table highlightOnHover
|
||||
layout="fixed" // ✅ PENTING - consistent column widths
|
||||
withColumnBorders={false}>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh fz="sm" fw={600} ta="center" w={60}>No</TableTh>
|
||||
<TableTh fz="sm" fw={600}>
|
||||
<Group gap={5}>
|
||||
<IconUser size={16} />
|
||||
Nama
|
||||
</Group>
|
||||
</TableTh>
|
||||
<TableTh fz="sm" fw={600}>
|
||||
<Group gap={5}>
|
||||
<IconId size={16} />
|
||||
NIK
|
||||
</Group>
|
||||
</TableTh>
|
||||
// ...
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **BAIK** - Table layout dengan icon yang helpful!
|
||||
|
||||
---
|
||||
|
||||
### **3. State Management**
|
||||
- ✅ Proper typing dengan Prisma types
|
||||
- ✅ Loading state management dengan finally block
|
||||
- ✅ Error handling yang comprehensive
|
||||
- ✅ **ApiFetch consistency** untuk create & findMany! ✅
|
||||
- ✅ Zod validation untuk form data dengan specific rules
|
||||
- ✅ Separate proxy states untuk related data (jenisInformasi, caraMemperoleh, dll)
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// state file - Line ~110-150
|
||||
findMany: {
|
||||
data: null as Prisma.PermohonanInformasiPublikGetPayload<{...}>[] | null,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
total: 0,
|
||||
loading: false,
|
||||
search: "",
|
||||
load: async (page = 1, limit = 10, search = "") => {
|
||||
statepermohonanInformasiPublik.findMany.loading = true; // ✅ Start loading
|
||||
statepermohonanInformasiPublik.findMany.page = page;
|
||||
statepermohonanInformasiPublik.findMany.search = search;
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
if (search) query.search = search;
|
||||
|
||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik["find-many"].get({ query });
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
statepermohonanInformasiPublik.findMany.data = res.data.data || [];
|
||||
statepermohonanInformasiPublik.findMany.total = res.data.total || 0;
|
||||
statepermohonanInformasiPublik.findMany.totalPages = res.data.totalPages || 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading permohonan:", error);
|
||||
statepermohonanInformasiPublik.findMany.data = [];
|
||||
// ...
|
||||
} finally {
|
||||
statepermohonanInformasiPublik.findMany.loading = false; // ✅ Stop loading
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **BAIK** - State management sudah proper dengan ApiFetch!
|
||||
|
||||
---
|
||||
|
||||
### **4. Zod Schema Validation**
|
||||
- ✅ Comprehensive validation untuk semua fields
|
||||
- ✅ Specific error messages untuk setiap field
|
||||
- ✅ Phone number length validation (3-15 chars)
|
||||
- ✅ NIK length validation (3-16 chars)
|
||||
- ✅ Email format validation
|
||||
- ✅ Required field validation untuk dropdowns
|
||||
|
||||
**Code Example (✅ EXCELLENT):**
|
||||
```typescript
|
||||
// state file - Line ~8-22
|
||||
const templateForm = z.object({
|
||||
name: z.string().min(3, "Nama minimal 3 karakter"),
|
||||
nik: z
|
||||
.string()
|
||||
.min(3, "NIK minimal 3 karakter")
|
||||
.max(16, "NIK maksimal 16 angka"), // ✅ Specific validation
|
||||
notelp: z
|
||||
.string()
|
||||
.min(3, "Nomor Telepon minimal 3 karakter")
|
||||
.max(15, "Nomor Telepon maksimal 15 angka"), // ✅ Specific validation
|
||||
alamat: z.string().min(3, "Alamat minimal 3 karakter"),
|
||||
email: z.string().min(3, "Email minimal 3 karakter"),
|
||||
jenisInformasiDimintaId: z.string().nonempty(), // ✅ Required dropdown
|
||||
caraMemperolehInformasiId: z.string().nonempty(), // ✅ Required dropdown
|
||||
caraMemperolehSalinanInformasiId: z.string().nonempty(), // ✅ Required dropdown
|
||||
});
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **EXCELLENT** - Validation yang comprehensive!
|
||||
|
||||
---
|
||||
|
||||
### **5. Related Data Management**
|
||||
- ✅ Separate proxy states untuk dropdown data
|
||||
- ✅ JenisInformasiDiminta, CaraMemperolehInformasi, CaraMemperolehSalinanInformasi
|
||||
- ✅ Proper typing dengan Prisma types
|
||||
- ✅ ApiFetch consistency untuk load dropdown data
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// state file - Line ~24-40
|
||||
const jenisInformasiDiminta = proxy({
|
||||
findMany: {
|
||||
data: null as Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[] | null,
|
||||
async load() {
|
||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi["find-many"].get();
|
||||
if (res.status === 200) {
|
||||
jenisInformasiDiminta.findMany.data = res.data?.data ?? [];
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **BAIK** - Related data management yang proper!
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ ISSUES & SARAN PERBAIKAN
|
||||
|
||||
### **🔴 CRITICAL**
|
||||
|
||||
#### **1. Schema - deletedAt Default Value SALAH (MULTIPLE MODELS)**
|
||||
|
||||
**Lokasi:** `prisma/schema.prisma` (line 435-467)
|
||||
|
||||
**Masalah:**
|
||||
```prisma
|
||||
model PermohonanInformasiPublik {
|
||||
// ...
|
||||
deletedAt DateTime @default(now()) // ❌ SALAH - selalu punya default value
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model JenisInformasiDiminta {
|
||||
// ...
|
||||
deletedAt DateTime @default(now()) // ❌ SALAH
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model CaraMemperolehInformasi {
|
||||
// ...
|
||||
deletedAt DateTime @default(now()) // ❌ SALAH
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model CaraMemperolehSalinanInformasi {
|
||||
// ...
|
||||
deletedAt DateTime @default(now()) // ❌ SALAH
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- **LOGIC ERROR!** Setiap record baru langsung punya `deletedAt` value (timestamp creation)
|
||||
- Soft delete tidak berfungsi dengan benar
|
||||
- Query dengan `where: { deletedAt: null }` tidak akan pernah return data
|
||||
- Data yang "dihapus" vs data "aktif" tidak bisa dibedakan
|
||||
- **4 models affected!** (PermohonanInformasiPublik + 3 related models)
|
||||
|
||||
**Rekomendasi:** Fix semua schema:
|
||||
```prisma
|
||||
model PermohonanInformasiPublik {
|
||||
// ...
|
||||
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model JenisInformasiDiminta {
|
||||
// ...
|
||||
deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model CaraMemperolehInformasi {
|
||||
// ...
|
||||
deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model CaraMemperolehSalinanInformasi {
|
||||
// ...
|
||||
deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 **CRITICAL**
|
||||
**Effort:** Medium (perlu migration untuk 4 models)
|
||||
**Impact:** **HIGH** (data integrity & soft delete logic)
|
||||
|
||||
---
|
||||
|
||||
#### **2. State Management - Fetch Pattern Inconsistency**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/permohonan_informasi_publik/permohonanInformasiPublik.ts`
|
||||
|
||||
**Masalah:** Ada 2 pattern berbeda untuk fetch API:
|
||||
|
||||
```typescript
|
||||
// ❌ Pattern 1: ApiFetch (create, findMany, dropdowns)
|
||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik["create"].post(form);
|
||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik["find-many"].get({ query });
|
||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi["find-many"].get();
|
||||
|
||||
// ❌ Pattern 2: fetch manual (findUnique)
|
||||
const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`);
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- Code consistency buruk
|
||||
- Sulit maintenance
|
||||
- Type safety tidak konsisten
|
||||
- Duplikasi logic error handling
|
||||
|
||||
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
|
||||
|
||||
```typescript
|
||||
// ✅ Unified pattern
|
||||
async load(id: string) {
|
||||
try {
|
||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik[id].get();
|
||||
|
||||
if (res.data?.success) {
|
||||
statepermohonanInformasiPublik.findUnique.data = res.data.data;
|
||||
} else {
|
||||
toast.error(res.data?.message || "Gagal memuat data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
toast.error("Gagal memuat data");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 High
|
||||
**Effort:** Medium (refactor di findUnique method)
|
||||
|
||||
---
|
||||
|
||||
#### **3. Console.log di Production**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/permohonan_informasi_publik/permohonanInformasiPublik.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~70
|
||||
console.log(caraMemperolehSalinanInformasi); // ❌ Debug log
|
||||
|
||||
// Line ~160
|
||||
console.error("Failed to load permohonan keberatan informasi:", res.data?.message);
|
||||
|
||||
// Line ~165
|
||||
console.error("Error loading permohonan keberatan informasi:", error);
|
||||
|
||||
// Line ~185
|
||||
console.error("Failed to fetch program inovasi:", res.statusText);
|
||||
|
||||
// Line ~188
|
||||
console.error("Error fetching program inovasi:", error);
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan conditional logging:
|
||||
|
||||
```typescript
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error("Error:", error);
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **4. Missing Delete/Hard Delete Protection**
|
||||
|
||||
**Lokasi:** `page.tsx`, `[id]/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
- ❌ Tidak ada tombol delete untuk Permohonan Informasi (correct - read-only data)
|
||||
- ✅ **GOOD:** Read-only pattern yang benar untuk data permohonan
|
||||
- ⚠️ **ISSUE:** Tidak ada fitur untuk mark sebagai "processed" atau "completed"
|
||||
|
||||
**Issue:** User tidak bisa update status permohonan (pending → processed → completed).
|
||||
|
||||
**Rekomendasi:** Add status management:
|
||||
```prisma
|
||||
// Add to schema
|
||||
model PermohonanInformasiPublik {
|
||||
// ...
|
||||
status String @default("pending") // pending, processed, completed
|
||||
processedAt DateTime?
|
||||
processedBy String?
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Add action buttons di detail page
|
||||
<Group>
|
||||
<Button color="yellow" onClick={() => updateStatus("processed")}>
|
||||
Mark as Processed
|
||||
</Button>
|
||||
<Button color="green" onClick={() => updateStatus("completed")}>
|
||||
Mark as Completed
|
||||
</Button>
|
||||
</Group>
|
||||
```
|
||||
|
||||
**Priority:** 🔴 Medium
|
||||
**Effort:** Medium (perlu schema change + UI update)
|
||||
|
||||
---
|
||||
|
||||
### **🟡 MEDIUM**
|
||||
|
||||
#### **5. Type Safety - Any Usage**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/permohonan_informasi_publik/permohonanInformasiPublik.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~145
|
||||
const query: any = { page, limit }; // ❌ Using 'any'
|
||||
if (search) query.search = search;
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan typed query:
|
||||
|
||||
```typescript
|
||||
// Define type
|
||||
interface FindManyQuery {
|
||||
page: number | string;
|
||||
limit?: number | string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// Use typed query
|
||||
const query: FindManyQuery = { page, limit };
|
||||
if (search) query.search = search;
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **6. Error Message Tidak Konsisten**
|
||||
|
||||
**Lokasi:** Multiple places
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~160
|
||||
console.error("Failed to load permohonan keberatan informasi:", res.data?.message);
|
||||
// ⚠️ Wrong module name - ini "permohonan informasi publik" bukan "keberatan"
|
||||
|
||||
// Line ~165
|
||||
console.error("Error loading permohonan keberatan informasi:", error);
|
||||
// ⚠️ Same issue
|
||||
|
||||
// Line ~185
|
||||
console.error("Failed to fetch program inovasi:", res.statusText);
|
||||
// ⚠️ Wrong module name - ini "permohonan informasi" bukan "program inovasi"
|
||||
|
||||
// Line ~188
|
||||
console.error("Error fetching program inovasi:", error);
|
||||
// ⚠️ Same issue
|
||||
```
|
||||
|
||||
**Issue:** Copy-paste error dari module lain!
|
||||
|
||||
**Rekomendasi:** Fix error messages:
|
||||
```typescript
|
||||
console.error("Failed to load permohonan informasi publik:", res.data?.message);
|
||||
console.error("Error loading permohonan informasi publik:", error);
|
||||
console.error("Failed to fetch permohonan informasi:", res.statusText);
|
||||
console.error("Error fetching permohonan informasi:", error);
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **7. Pagination onChange Tidak Include Search**
|
||||
|
||||
**Lokasi:** `page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~250-260
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10); // ⚠️ Missing search parameter
|
||||
window.scrollTo(0, 0);
|
||||
}}
|
||||
total={totalPages}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
**Issue:** Saat ganti page, search query hilang.
|
||||
|
||||
**Rekomendasi:** Include search:
|
||||
```typescript
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10, debouncedSearch); // ✅ Include search
|
||||
window.scrollTo(0, 0);
|
||||
}}
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟢 LOW (Minor Polish)**
|
||||
|
||||
#### **8. Missing Loading State di Detail Page**
|
||||
|
||||
**Lokasi:** `[id]/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~20-25
|
||||
useShallowEffect(() => {
|
||||
state.findUnique.load(params?.id as string)
|
||||
}, [params?.id])
|
||||
|
||||
if (!state.findUnique.data) {
|
||||
return (
|
||||
<Stack py={10}>
|
||||
<Skeleton height={500} radius="md" />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** Skeleton ditampilkan untuk semua kondisi (loading, error, not found).
|
||||
|
||||
**Rekomendasi:** Add proper loading state:
|
||||
```typescript
|
||||
if (state.findUnique.loading) {
|
||||
return (
|
||||
<Stack py={10}>
|
||||
<Skeleton height={500} radius="md" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.findUnique.data) {
|
||||
return (
|
||||
<Alert icon={<IconAlertCircle />} color="red">
|
||||
Data tidak ditemukan
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **9. Duplicate Error Logging**
|
||||
|
||||
**Lokasi:** `page.tsx`, state file
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// page.tsx - Line ~160-165
|
||||
console.error("Failed to load permohonan keberatan informasi:", res.data?.message);
|
||||
console.error("Error loading permohonan keberatan informasi:", error);
|
||||
|
||||
// state file - Line ~185-188
|
||||
console.error("Failed to fetch program inovasi:", res.statusText);
|
||||
console.error("Error fetching program inovasi:", error);
|
||||
```
|
||||
|
||||
**Rekomendasi:** Cukup satu logging yang informatif:
|
||||
```typescript
|
||||
console.error('Failed to load Permohonan Informasi Publik:', err);
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **10. Search Placeholder Tidak Spesifik**
|
||||
|
||||
**Lokasi:** `page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~70, 110
|
||||
<TextInput
|
||||
placeholder={"Cari nama..."} // ⚠️ Generic
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
**Rekomendasi:** Lebih spesifik:
|
||||
```typescript
|
||||
placeholder={"Cari nama pemohon..."}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **11. Missing Data Relationships di Detail Page**
|
||||
|
||||
**Lokasi:** `[id]/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~60-90
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold" mb={4}>Jenis Informasi</Text>
|
||||
<Text fz="md" c="dimmed">{data.jenisInformasiDiminta?.name || '-'}</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold" mb={4}>Cara Akses Informasi</Text>
|
||||
<Text fz="md" c="dimmed">{data.caraMemperolehInformasi?.name || '-'}</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold" mb={4}>Cara Akses Salinan Informasi</Text>
|
||||
<Text fz="md" c="dimmed">{data.caraMemperolehSalinanInformasi?.name || '-'}</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
**Issue:** Tidak menampilkan data `alamat` yang ada di schema.
|
||||
|
||||
**Rekomendasi:** Add missing field:
|
||||
```typescript
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold" mb={4}>Alamat</Text>
|
||||
<Text fz="md" c="dimmed">{data.alamat || '-'}</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **12. Unused Console.log**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/permohonan_informasi_publik/permohonanInformasiPublik.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~70
|
||||
console.log(caraMemperolehSalinanInformasi); // ❌ Debug log yang tidak terpakai
|
||||
```
|
||||
|
||||
**Rekomendasi:** Remove:
|
||||
```typescript
|
||||
// Remove this line completely
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **13. Missing Empty State Icon di Mobile**
|
||||
|
||||
**Lokasi:** `page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~60-75 (Desktop empty state)
|
||||
<Stack align="center" py="xl" ta="center">
|
||||
<IconInfoCircle size={40} stroke={1.5} color={colors['blue-button']} />
|
||||
<Text fz={{ base: 'sm', md: 'md' }} fw={500} c="dimmed" lh={1.5}>
|
||||
{search
|
||||
? 'Tidak ditemukan data yang sesuai dengan pencarian'
|
||||
: 'Belum ada permohonan yang tercatat'
|
||||
}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
// Line ~120-130 (Mobile - missing icon)
|
||||
<Stack align="center" py={{ base: 'xl', md: 'xl' }}>
|
||||
<IconInfoCircle size={40} stroke={1.5} color={colors['blue-button']} />
|
||||
// ✅ Icon ada di sini juga
|
||||
<Text fz={{ base: 'sm', md: 'md' }} fw={500} c="dimmed" lh={1.5}>
|
||||
Belum ada permohonan informasi yang tercatat
|
||||
</Text>
|
||||
</Stack>
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - Icon ada di kedua empty states!
|
||||
|
||||
**Priority:** 🟢 None
|
||||
**Effort:** None
|
||||
|
||||
---
|
||||
|
||||
## 📋 RINGKASAN ACTION ITEMS
|
||||
|
||||
| Priority | Issue | Module | Impact | Effort | Status |
|
||||
|----------|-------|--------|--------|--------|--------|
|
||||
| 🔴 P0 | **Schema deletedAt default SALAH (4 models)** | Schema | **CRITICAL** | Medium | **MUST FIX** |
|
||||
| 🔴 P0 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
|
||||
| 🔴 P1 | Missing status management | UI/Schema | Medium | Medium | Should add |
|
||||
| 🟡 M | Console.log in production | State | Low | Low | Optional |
|
||||
| 🟡 M | Type safety (any usage) | State | Low | Low | Optional |
|
||||
| 🟡 M | Error message inconsistency (copy-paste) | State | Low | Low | Should fix |
|
||||
| 🟡 M | Pagination missing search param | UI | Low | Low | Should fix |
|
||||
| 🟢 L | Missing loading state di detail page | UI | Low | Low | Optional |
|
||||
| 🟢 L | Duplicate error logging | UI/State | Low | Low | Optional |
|
||||
| 🟢 L | Search placeholder tidak spesifik | UI | Low | Low | Optional |
|
||||
| 🟢 L | Missing alamat field di detail page | UI | Low | Low | Optional |
|
||||
| 🟢 L | Unused console.log | State | Low | Low | Optional |
|
||||
|
||||
---
|
||||
|
||||
## ✅ KESIMPULAN
|
||||
|
||||
### **Overall Quality: 🟢 BAIK (7.5/10)**
|
||||
|
||||
**Strengths:**
|
||||
1. ✅ UI/UX clean & responsive
|
||||
2. ✅ Table layout dengan icon yang helpful
|
||||
3. ✅ Search functionality dengan debounce
|
||||
4. ✅ Empty state handling yang informatif
|
||||
5. ✅ **Zod validation comprehensive** dengan specific rules
|
||||
6. ✅ **Related data management** proper (dropdowns)
|
||||
7. ✅ State management dengan ApiFetch untuk create & findMany
|
||||
8. ✅ Loading state management dengan finally block
|
||||
9. ✅ Mobile cards responsive
|
||||
|
||||
**Critical Issues:**
|
||||
1. ⚠️ **Schema deletedAt default SALAH** - 4 models affected (CRITICAL)
|
||||
2. ⚠️ Fetch method pattern inconsistency (ApiFetch vs fetch manual)
|
||||
3. ⚠️ Missing status management untuk permohonan (pending → processed → completed)
|
||||
|
||||
**Areas for Improvement:**
|
||||
1. ⚠️ **Fix schema deletedAt** untuk 4 models dari `@default(now())` ke `@default(null)` dengan nullable
|
||||
2. ⚠️ **Refactor fetch methods** untuk gunakan ApiFetch consistently
|
||||
3. ⚠️ **Add status management** untuk tracking status permohonan
|
||||
4. ⚠️ **Fix error messages** (copy-paste error dari module lain)
|
||||
5. ⚠️ **Improve type safety** dengan remove `any` usage
|
||||
|
||||
**Recommended Next Steps:**
|
||||
1. **🔴 CRITICAL: Fix schema deletedAt** untuk 4 models - 1 jam (perlu migration)
|
||||
2. **🔴 HIGH: Refactor findUnique** ke ApiFetch - 30 menit
|
||||
3. **🔴 HIGH: Add status management** - 1 jam (schema + UI)
|
||||
4. **🟡 MEDIUM: Fix error messages** (copy-paste) - 10 menit
|
||||
5. **🟢 LOW: Add pagination search param** - 10 menit
|
||||
6. **🟢 LOW: Polish minor issues** - 30 menit
|
||||
|
||||
---
|
||||
|
||||
## 📈 COMPARISON WITH OTHER MODULES
|
||||
|
||||
| Module | Fetch Pattern | State | Validation | Schema | Status Mgmt | Overall |
|
||||
|--------|--------------|-------|------------|--------|-------------|---------|
|
||||
| Profil | ⚠️ Mixed | ⚠️ Good | ✅ Good | ⚠️ deletedAt | N/A | 🟢 |
|
||||
| Desa Anti Korupsi | ⚠️ Mixed | ⚠️ Good | ✅ Good | ⚠️ deletedAt | N/A | 🟢 |
|
||||
| SDGs Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ⚠️ deletedAt | N/A | 🟢 |
|
||||
| APBDes | ⚠️ Mixed | ⚠️ Good | ✅ Good | ✅ Good | N/A | 🟢 |
|
||||
| Prestasi Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ WRONG | N/A | 🟢 |
|
||||
| PPID Profil | ⚠️ Mixed | ✅ Best | ✅ Good | ❌ WRONG | N/A | 🟢⭐ |
|
||||
| Struktur PPID | ⚠️ Mixed | ✅ Good | ✅ Good | ⚠️ Inconsistent | ✅ Active/Non-active | 🟢 |
|
||||
| Visi Misi PPID | ✅ **100% ApiFetch!** | ✅ Best | ✅ Good | ❌ WRONG | N/A | 🟢⭐⭐ |
|
||||
| Dasar Hukum PPID | ✅ **100% ApiFetch!** | ✅ Best | ✅ Good | ❌ WRONG | N/A | 🟢⭐⭐ |
|
||||
| **Permohonan Informasi** | ⚠️ Mixed | ⚠️ Good | ✅ **Best** | ❌ **4 models WRONG** | ❌ Missing | 🟡 |
|
||||
|
||||
**Permohonan Informasi PPID Highlights:**
|
||||
- ✅ **Best validation** - Comprehensive Zod schema dengan specific rules
|
||||
- ✅ **Related data management** - Separate proxy states untuk dropdowns
|
||||
- ✅ **Icon integration** - Table headers dengan icon yang helpful
|
||||
- ⚠️ **4 models affected** - deletedAt issue (most affected module!)
|
||||
- ⚠️ **Missing status management** - No workflow tracking
|
||||
- ⚠️ **Copy-paste errors** - Error messages dari module lain
|
||||
|
||||
---
|
||||
|
||||
## 🎯 UNIQUE FEATURES OF PERMOHONAN INFORMASI MODULE
|
||||
|
||||
**Most Complex Data Structure:**
|
||||
1. ✅ **3 related dropdown models** - JenisInformasi, CaraMemperoleh, CaraMemperolehSalinan
|
||||
2. ✅ **Comprehensive validation** - Phone length, NIK length, email format
|
||||
3. ✅ **Icon integration** - User, ID, Phone, Info icons di table headers
|
||||
4. ✅ **Auto-increment nomor** - Automatic numbering system
|
||||
5. ❌ **Missing status workflow** - Should have pending → processed → completed
|
||||
|
||||
**Best Practices:**
|
||||
1. ✅ **Validation comprehensive** - Best Zod schema dengan specific rules
|
||||
2. ✅ **Related data management** - Separate proxy states
|
||||
3. ✅ **Icon integration** - Visual clarity di table headers
|
||||
4. ✅ **Loading state management** - Proper dengan finally block
|
||||
|
||||
**Critical Issues:**
|
||||
1. ❌ **4 models dengan deletedAt SALAH** - Most affected module!
|
||||
2. ❌ **Fetch pattern inconsistency** - findUnique pakai fetch manual
|
||||
3. ❌ **Missing status workflow** - No tracking untuk permohonan status
|
||||
4. ❌ **Copy-paste error messages** - Dari module lain
|
||||
|
||||
---
|
||||
|
||||
**Catatan:** **Permohonan Informasi PPID adalah MODULE DENGAN VALIDATION TERBAIK** tapi juga **MODULE DENGAN PALING BANYAK MODEL AFFECTED** oleh deletedAt issue (4 models!). Module ini butuh status management workflow untuk tracking status permohonan.
|
||||
|
||||
**Unique Strengths:**
|
||||
1. ✅ **Best validation** - Comprehensive Zod schema
|
||||
2. ✅ **Related data management** - 3 dropdown models handled properly
|
||||
3. ✅ **Icon integration** - Visual clarity
|
||||
4. ✅ **Auto-increment nomor** - Automatic numbering
|
||||
|
||||
**Priority Action:**
|
||||
```diff
|
||||
🔴 FIX INI SEKARANG (1 JAM + MIGRATION):
|
||||
File: prisma/schema.prisma
|
||||
Line: 435-467
|
||||
|
||||
model PermohonanInformasiPublik {
|
||||
// ...
|
||||
- deletedAt DateTime @default(now())
|
||||
+ deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model JenisInformasiDiminta {
|
||||
// ...
|
||||
- deletedAt DateTime @default(now())
|
||||
+ deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model CaraMemperolehInformasi {
|
||||
// ...
|
||||
- deletedAt DateTime @default(now())
|
||||
+ deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model CaraMemperolehSalinanInformasi {
|
||||
// ...
|
||||
- deletedAt DateTime @default(now())
|
||||
+ deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
# Lalu jalankan:
|
||||
bunx prisma db push
|
||||
# atau
|
||||
bunx prisma migrate dev --name fix_deletedat_permohonan_informasi
|
||||
```
|
||||
|
||||
```diff
|
||||
🔴 ADD STATUS MANAGEMENT (1 JAM):
|
||||
File: prisma/schema.prisma
|
||||
|
||||
model PermohonanInformasiPublik {
|
||||
// ...
|
||||
+ status String @default("pending") // pending, processed, completed
|
||||
+ processedAt DateTime?
|
||||
+ processedBy String?
|
||||
}
|
||||
```
|
||||
|
||||
Setelah fix critical issues, module ini **PRODUCTION-READY** dengan **BEST VALIDATION**! 🎉
|
||||
|
||||
---
|
||||
|
||||
## 📚 RECOMMENDED AS REFERENCE FOR OTHER MODULES
|
||||
|
||||
**Permohonan Informasi PPID Module adalah BEST PRACTICE untuk:**
|
||||
1. ✅ **Comprehensive validation** - Zod schema dengan specific rules (phone, NIK length)
|
||||
2. ✅ **Related data management** - Separate proxy states untuk dropdowns
|
||||
3. ✅ **Icon integration** - Visual clarity di table headers
|
||||
4. ✅ **Auto-increment numbering** - Automatic nomor urut
|
||||
|
||||
**Modules lain bisa belajar dari Permohonan Informasi:**
|
||||
- **ALL MODULES:** Use specific validation rules (min/max length)
|
||||
- **MODULES WITH DROPDOWNS:** Separate proxy states untuk related data
|
||||
- **ALL MODULES:** Icon integration untuk visual clarity
|
||||
- **ALL MODULES:** Auto-increment untuk numbering systems
|
||||
|
||||
---
|
||||
|
||||
**File Location:** `QC/PPID/QC-PERMOHONAN-INFORMASI-PUBLIK-MODULE.md` 📄
|
||||
@@ -1,771 +0,0 @@
|
||||
# QC Summary - Permohonan Keberatan Informasi Publik PPID Module
|
||||
|
||||
**Scope:** List Permohonan Keberatan, Detail Permohonan Keberatan
|
||||
**Date:** 2026-02-23
|
||||
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
|
||||
|
||||
---
|
||||
|
||||
## 📊 OVERVIEW
|
||||
|
||||
| Aspect | Schema | API | UI Admin | State Management | Overall |
|
||||
|--------|--------|-----|----------|-----------------|---------|
|
||||
| Permohonan Keberatan | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 Perlu fix |
|
||||
|
||||
---
|
||||
|
||||
## ✅ YANG SUDAH BAIK
|
||||
|
||||
### **1. UI/UX Design**
|
||||
- ✅ Preview layout yang clean dengan responsive design
|
||||
- ✅ Loading states dengan Skeleton
|
||||
- ✅ Empty state handling yang informatif dengan icon
|
||||
- ✅ Search functionality dengan debounce (1000ms)
|
||||
- ✅ Pagination yang konsisten
|
||||
- ✅ Desktop table + mobile cards responsive
|
||||
- ✅ Icon integration (User, Mail, Phone, Info) untuk visual clarity
|
||||
- ✅ Consistent empty state messages
|
||||
|
||||
### **2. Table & Card Layout**
|
||||
- ✅ Fixed layout table untuk consistency
|
||||
- ✅ Column headers dengan icon yang descriptive
|
||||
- ✅ Row numbering otomatis (index + 1)
|
||||
- ✅ Text truncation dengan lineClamp untuk long text
|
||||
- ✅ Mobile card view dengan proper information hierarchy
|
||||
- ✅ Proper spacing dan gap untuk readability
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// page.tsx - Line ~130-180
|
||||
<Table highlightOnHover
|
||||
layout="fixed" // ✅ PENTING - consistent column widths
|
||||
withColumnBorders={false}>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh fz="sm" fw={600} lh={1.4} ta="center">No</TableTh>
|
||||
<TableTh fz="sm" fw={600} lh={1.4}>
|
||||
<Group gap={5}>
|
||||
<IconUser size={16} />
|
||||
Nama
|
||||
</Group>
|
||||
</TableTh>
|
||||
<TableTh fz="sm" fw={600} lh={1.4}>
|
||||
<Group gap={5}>
|
||||
<IconMail size={16} />
|
||||
Email
|
||||
</Group>
|
||||
</TableTh>
|
||||
// ...
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **BAIK** - Table layout dengan icon yang helpful!
|
||||
|
||||
---
|
||||
|
||||
### **3. State Management**
|
||||
- ✅ Proper typing dengan Prisma types
|
||||
- ✅ Loading state management dengan finally block
|
||||
- ✅ Error handling yang comprehensive
|
||||
- ✅ **ApiFetch consistency** untuk create & findMany! ✅
|
||||
- ✅ Zod validation untuk form data dengan specific rules
|
||||
- ✅ Return boolean untuk create operation (success/failure handling)
|
||||
|
||||
**Code Example (✅ EXCELLENT):**
|
||||
```typescript
|
||||
// state file - Line ~30-55
|
||||
create: {
|
||||
form: {} as PermohonanKeberatanInformasiForm,
|
||||
loading: false,
|
||||
async create() {
|
||||
const cek = templateForm.safeParse(permohonanKeberatanInformasi.create.form);
|
||||
if (!cek.success) {
|
||||
toast.error(cek.error.issues.map((i) => i.message).join("\n"));
|
||||
return false; // ✅ GOOD - Return false untuk failure
|
||||
}
|
||||
try {
|
||||
permohonanKeberatanInformasi.create.loading = true;
|
||||
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["create"].post(form);
|
||||
|
||||
if (res.data?.success === false) {
|
||||
toast.error(res.data?.message);
|
||||
return false; // ✅ GOOD - Return false untuk API failure
|
||||
}
|
||||
|
||||
toast.success("Sukses menambahkan");
|
||||
return true; // ✅ GOOD - Return true untuk success
|
||||
} catch {
|
||||
toast.error("Terjadi kesalahan server");
|
||||
return false;
|
||||
} finally {
|
||||
permohonanKeberatanInformasi.create.loading = false;
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **EXCELLENT** - Proper return value handling untuk create operation!
|
||||
|
||||
---
|
||||
|
||||
### **4. Zod Schema Validation**
|
||||
- ✅ Comprehensive validation untuk semua fields
|
||||
- ✅ Specific error messages untuk setiap field
|
||||
- ✅ Phone number length validation (3-15 chars)
|
||||
- ✅ Minimum character validation (3 characters)
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// state file - Line ~8-15
|
||||
const templateForm = z.object({
|
||||
name: z.string().min(3, "Nama minimal 3 karakter"),
|
||||
email: z.string().min(3, "Email minimal 3 karakter"),
|
||||
notelp: z
|
||||
.string()
|
||||
.min(3, "Nomor Telepon minimal 3 karakter")
|
||||
.max(15, "Nomor Telepon maksimal 15 angka"), // ✅ Specific validation
|
||||
alasan: z.string().min(3, "Alasan minimal 3 karakter"),
|
||||
});
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **BAIK** - Validation yang proper dengan specific rules!
|
||||
|
||||
---
|
||||
|
||||
### **5. Empty State Handling**
|
||||
- ✅ Different messages untuk search vs empty data
|
||||
- ✅ Icon integration untuk visual clarity
|
||||
- ✅ Proper text formatting dan centering
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// page.tsx - Line ~70-85
|
||||
<Stack align="center" py="xl" ta="center">
|
||||
<IconInfoCircle size={40} stroke={1.5} color={colors['blue-button']} />
|
||||
<Text fz={{ base: 'sm', md: 'md' }} fw={500} c="dimmed" lh={1.5}>
|
||||
{search
|
||||
? 'Tidak ditemukan data yang sesuai dengan pencarian'
|
||||
: 'Belum ada permohonan keberatan yang tercatat'
|
||||
}
|
||||
</Text>
|
||||
</Stack>
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **BAIK** - Empty state dengan conditional messages yang helpful!
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ ISSUES & SARAN PERBAIKAN
|
||||
|
||||
### **🔴 CRITICAL**
|
||||
|
||||
#### **1. Schema - deletedAt Default Value SALAH**
|
||||
|
||||
**Lokasi:** `prisma/schema.prisma` (line 478)
|
||||
|
||||
**Masalah:**
|
||||
```prisma
|
||||
model FormulirPermohonanKeberatan {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
email String
|
||||
notelp String
|
||||
alasan String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now()) // ❌ SALAH - selalu punya default value
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- **LOGIC ERROR!** Setiap record baru langsung punya `deletedAt` value (timestamp creation)
|
||||
- Soft delete tidak berfungsi dengan benar
|
||||
- Query dengan `where: { deletedAt: null }` tidak akan pernah return data
|
||||
- Data yang "dihapus" vs data "aktif" tidak bisa dibedakan
|
||||
|
||||
**Rekomendasi:** Fix schema:
|
||||
```prisma
|
||||
model FormulirPermohonanKeberatan {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
email String
|
||||
notelp String
|
||||
alasan String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 **CRITICAL**
|
||||
**Effort:** Medium (perlu migration)
|
||||
**Impact:** **HIGH** (data integrity & soft delete logic)
|
||||
|
||||
---
|
||||
|
||||
#### **2. State Management - Fetch Pattern Inconsistency**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/permohonan_keberatan_informasi_publik/permohonanKeberatanInformasi.ts`
|
||||
|
||||
**Masalah:** Ada 2 pattern berbeda untuk fetch API:
|
||||
|
||||
```typescript
|
||||
// ❌ Pattern 1: ApiFetch (create, findMany)
|
||||
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["create"].post(form);
|
||||
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["find-many"].get({ query });
|
||||
|
||||
// ❌ Pattern 2: fetch manual (findUnique)
|
||||
const res = await fetch(`/api/ppid/permohonankeberataninformasipublik/${id}`);
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- Code consistency buruk
|
||||
- Sulit maintenance
|
||||
- Type safety tidak konsisten
|
||||
- Duplikasi logic error handling
|
||||
|
||||
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
|
||||
|
||||
```typescript
|
||||
// ✅ Unified pattern
|
||||
async load(id: string) {
|
||||
try {
|
||||
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[id].get();
|
||||
|
||||
if (res.data?.success) {
|
||||
permohonanKeberatanInformasi.findUnique.data = res.data.data;
|
||||
} else {
|
||||
toast.error(res.data?.message || "Gagal memuat data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
toast.error("Gagal memuat data");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 High
|
||||
**Effort:** Medium (refactor di findUnique method)
|
||||
|
||||
---
|
||||
|
||||
#### **3. Missing Delete Function**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/permohonan_keberatan_informasi_publik/permohonanKeberatanInformasi.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// state file - Line ~100-120
|
||||
// ❌ MISSING: delete method
|
||||
const permohonanKeberatanInformasi = proxy({
|
||||
create: { ... },
|
||||
findMany: { ... },
|
||||
findUnique: { ... },
|
||||
// ❌ NO delete method!
|
||||
});
|
||||
```
|
||||
|
||||
**Issue:** Tidak ada cara untuk menghapus data permohonan keberatan.
|
||||
|
||||
**Rekomendasi:** Add delete method:
|
||||
```typescript
|
||||
delete: {
|
||||
loading: false,
|
||||
async byId(id: string) {
|
||||
if (!id) return toast.warn("ID tidak valid");
|
||||
try {
|
||||
permohonanKeberatanInformasi.delete.loading = true;
|
||||
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["del"][id].delete();
|
||||
|
||||
if (res.data?.success) {
|
||||
toast.success(res.data.message || "Berhasil hapus permohonan keberatan");
|
||||
await permohonanKeberatanInformasi.findMany.load();
|
||||
} else {
|
||||
toast.error(res.data?.message || "Gagal hapus permohonan keberatan");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Gagal delete:", error);
|
||||
toast.error("Terjadi kesalahan saat menghapus");
|
||||
} finally {
|
||||
permohonanKeberatanInformasi.delete.loading = false;
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 Medium
|
||||
**Effort:** Medium (perlu add method + API endpoint)
|
||||
|
||||
---
|
||||
|
||||
### **🟡 MEDIUM**
|
||||
|
||||
#### **4. Console.log di Production**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/permohonan_keberatan_informasi_publik/permohonanKeberatanInformasi.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~85
|
||||
console.error("Failed to load permohonan keberatan informasi:", res.data?.message);
|
||||
|
||||
// Line ~90
|
||||
console.error("Error loading permohonan keberatan informasi:", error);
|
||||
|
||||
// Line ~110
|
||||
console.error("Failed to fetch permohonan keberatan informasi:", res.statusText);
|
||||
|
||||
// Line ~114
|
||||
console.error("Error fetching permohonan keberatan informasi:", error);
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan conditional logging:
|
||||
|
||||
```typescript
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error("Error:", error);
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **5. Type Safety - Any Usage**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/permohonan_keberatan_informasi_publik/permohonanKeberatanInformasi.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~75
|
||||
const query: any = { page, limit }; // ❌ Using 'any'
|
||||
if (search) query.search = search;
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan typed query:
|
||||
|
||||
```typescript
|
||||
// Define type
|
||||
interface FindManyQuery {
|
||||
page: number | string;
|
||||
limit?: number | string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// Use typed query
|
||||
const query: FindManyQuery = { page, limit };
|
||||
if (search) query.search = search;
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **6. Missing Edit Function**
|
||||
|
||||
**Lokasi:** Module structure
|
||||
|
||||
**Masalah:**
|
||||
- ❌ Tidak ada halaman edit untuk permohonan keberatan
|
||||
- ❌ Tidak ada edit method di state
|
||||
- ⚠️ **QUESTION:** Apakah permohonan keberatan harus bisa diedit?
|
||||
|
||||
**Issue:** Jika ada kesalahan input, user tidak bisa mengoreksi data.
|
||||
|
||||
**Rekomendasi:** Consider adding edit functionality jika diperlukan:
|
||||
```typescript
|
||||
// Add edit method di state
|
||||
edit: {
|
||||
id: "",
|
||||
form: { ... },
|
||||
loading: false,
|
||||
async load(id: string) { ... },
|
||||
async update() { ... },
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low (depends on business requirement)
|
||||
**Effort:** Medium
|
||||
|
||||
---
|
||||
|
||||
#### **7. Pagination onChange Tidak Include Search**
|
||||
|
||||
**Lokasi:** `page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~250-260
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10); // ⚠️ Missing search parameter
|
||||
window.scrollTo(0, 0);
|
||||
}}
|
||||
total={totalPages}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
**Issue:** Saat ganti page, search query hilang.
|
||||
|
||||
**Rekomendasi:** Include search:
|
||||
```typescript
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10, debouncedSearch); // ✅ Include search
|
||||
window.scrollTo(0, 0);
|
||||
}}
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟢 LOW (Minor Polish)**
|
||||
|
||||
#### **8. Missing Loading State di Detail Page**
|
||||
|
||||
**Lokasi:** `[id]/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~20-25
|
||||
useShallowEffect(() => {
|
||||
state.findUnique.load(params?.id as string)
|
||||
}, [params?.id])
|
||||
|
||||
if (!state.findUnique.data) {
|
||||
return (
|
||||
<Stack py={10}>
|
||||
<Skeleton height={500} radius="md" />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** Skeleton ditampilkan untuk semua kondisi (loading, error, not found).
|
||||
|
||||
**Rekomendasi:** Add proper loading state:
|
||||
```typescript
|
||||
if (state.findUnique.loading) {
|
||||
return (
|
||||
<Stack py={10}>
|
||||
<Skeleton height={500} radius="md" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.findUnique.data) {
|
||||
return (
|
||||
<Alert icon={<IconAlertCircle />} color="red">
|
||||
Data tidak ditemukan
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **9. Duplicate Error Logging**
|
||||
|
||||
**Lokasi:** `page.tsx`, state file
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// state file - Line ~85-90
|
||||
console.error("Failed to load permohonan keberatan informasi:", res.data?.message);
|
||||
console.error("Error loading permohonan keberatan informasi:", error);
|
||||
|
||||
// state file - Line ~110-114
|
||||
console.error("Failed to fetch permohonan keberatan informasi:", res.statusText);
|
||||
console.error("Error fetching permohonan keberatan informasi:", error);
|
||||
```
|
||||
|
||||
**Rekomendasi:** Cukup satu logging yang informatif:
|
||||
```typescript
|
||||
console.error('Failed to load Permohonan Keberatan:', err);
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **10. Search Placeholder Tidak Spesifik**
|
||||
|
||||
**Lokasi:** `page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~70, 110
|
||||
<TextInput
|
||||
placeholder={"Cari nama..."} // ⚠️ Generic
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
**Rekomendasi:** Lebih spesifik:
|
||||
```typescript
|
||||
placeholder={"Cari nama pemohon..."}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **11. Missing Data di Detail Page**
|
||||
|
||||
**Lokasi:** `[id]/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~50-80
|
||||
// Menampilkan: name, notelp, email, alasan
|
||||
// ❌ MISSING: createdAt, updatedAt, atau status
|
||||
```
|
||||
|
||||
**Issue:** Tidak menampilkan timestamp atau status permohonan.
|
||||
|
||||
**Rekomendasi:** Add missing fields jika ada di schema:
|
||||
```typescript
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold" mb={4}>Tanggal Pengajuan</Text>
|
||||
<Text fz="md" c="dimmed">
|
||||
{data.createdAt ? new Date(data.createdAt).toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}) : '-'}
|
||||
</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **12. Title Inconsistency di Detail Page**
|
||||
|
||||
**Lokasi:** `[id]/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~40
|
||||
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
|
||||
Detail Informasi Publik // ⚠️ Generic title
|
||||
</Text>
|
||||
```
|
||||
|
||||
**Issue:** Title seharusnya lebih spesifik "Detail Permohonan Keberatan".
|
||||
|
||||
**Rekomendasi:** Fix title:
|
||||
```typescript
|
||||
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
|
||||
Detail Permohonan Keberatan Informasi Publik
|
||||
</Text>
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
## 📋 RINGKASAN ACTION ITEMS
|
||||
|
||||
| Priority | Issue | Module | Impact | Effort | Status |
|
||||
|----------|-------|--------|--------|--------|--------|
|
||||
| 🔴 P0 | **Schema deletedAt default SALAH** | Schema | **CRITICAL** | Medium | **MUST FIX** |
|
||||
| 🔴 P0 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
|
||||
| 🔴 P1 | Missing delete function | State | Medium | Medium | Should add |
|
||||
| 🟡 M | Console.log in production | State | Low | Low | Optional |
|
||||
| 🟡 M | Type safety (any usage) | State | Low | Low | Optional |
|
||||
| 🟡 M | Missing edit function | State/UI | Low | Medium | Optional (business decision) |
|
||||
| 🟡 M | Pagination missing search param | UI | Low | Low | Should fix |
|
||||
| 🟢 L | Missing loading state di detail page | UI | Low | Low | Optional |
|
||||
| 🟢 L | Duplicate error logging | UI/State | Low | Low | Optional |
|
||||
| 🟢 L | Search placeholder tidak spesifik | UI | Low | Low | Optional |
|
||||
| 🟢 L | Missing data di detail page | UI | Low | Low | Optional |
|
||||
| 🟢 L | Title inconsistency di detail page | UI | Low | Low | Should fix |
|
||||
|
||||
---
|
||||
|
||||
## ✅ KESIMPULAN
|
||||
|
||||
### **Overall Quality: 🟢 BAIK (7.5/10)**
|
||||
|
||||
**Strengths:**
|
||||
1. ✅ UI/UX clean & responsive
|
||||
2. ✅ Table layout dengan icon yang helpful
|
||||
3. ✅ Search functionality dengan debounce
|
||||
4. ✅ Empty state handling yang informatif (conditional messages)
|
||||
5. ✅ **Zod validation** comprehensive dengan specific rules
|
||||
6. ✅ **Proper return value handling** untuk create operation (return true/false)
|
||||
7. ✅ State management dengan ApiFetch untuk create & findMany
|
||||
8. ✅ Loading state management dengan finally block
|
||||
9. ✅ Mobile cards responsive
|
||||
10. ✅ Icon integration (User, Mail, Phone, Info)
|
||||
|
||||
**Critical Issues:**
|
||||
1. ⚠️ **Schema deletedAt default SALAH** - Logic error untuk soft delete (CRITICAL)
|
||||
2. ⚠️ Fetch method pattern inconsistency (ApiFetch vs fetch manual)
|
||||
3. ⚠️ Missing delete function untuk hapus data
|
||||
|
||||
**Areas for Improvement:**
|
||||
1. ⚠️ **Fix schema deletedAt** dari `@default(now())` ke `@default(null)` dengan nullable
|
||||
2. ⚠️ **Refactor fetch methods** untuk gunakan ApiFetch consistently
|
||||
3. ⚠️ **Add delete method** untuk hapus data
|
||||
4. ⚠️ **Consider adding edit functionality** (business decision)
|
||||
5. ⚠️ **Improve type safety** dengan remove `any` usage
|
||||
|
||||
**Recommended Next Steps:**
|
||||
1. **🔴 CRITICAL: Fix schema deletedAt** - 30 menit (perlu migration)
|
||||
2. **🔴 HIGH: Refactor findUnique** ke ApiFetch - 30 menit
|
||||
3. **🔴 HIGH: Add delete method** - 45 menit
|
||||
4. **🟡 MEDIUM: Add pagination search param** - 10 menit
|
||||
5. **🟢 LOW: Fix title di detail page** - 5 menit
|
||||
6. **🟢 LOW: Polish minor issues** - 30 menit
|
||||
|
||||
---
|
||||
|
||||
## 📈 COMPARISON WITH OTHER MODULES
|
||||
|
||||
| Module | Fetch Pattern | State | Validation | Schema | Delete | Edit | Overall |
|
||||
|--------|--------------|-------|------------|--------|--------|------|---------|
|
||||
| Profil | ⚠️ Mixed | ⚠️ Good | ✅ Good | ⚠️ deletedAt | ✅ Yes | ✅ Yes | 🟢 |
|
||||
| Desa Anti Korupsi | ⚠️ Mixed | ⚠️ Good | ✅ Good | ⚠️ deletedAt | ✅ Yes | ✅ Yes | 🟢 |
|
||||
| SDGs Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ⚠️ deletedAt | ✅ Yes | ✅ Yes | 🟢 |
|
||||
| APBDes | ⚠️ Mixed | ⚠️ Good | ✅ Good | ✅ Good | ✅ Yes | ✅ Yes | 🟢 |
|
||||
| Prestasi Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ WRONG | ✅ Yes | ✅ Yes | 🟢 |
|
||||
| PPID Profil | ⚠️ Mixed | ✅ **Best** | ✅ Good | ❌ WRONG | N/A | ✅ Yes | 🟢⭐ |
|
||||
| Struktur PPID | ⚠️ Mixed | ✅ Good | ✅ Good | ⚠️ Inconsistent | ✅ Yes | ✅ Yes | 🟢 |
|
||||
| Visi Misi PPID | ✅ **100% ApiFetch!** | ✅ Best | ✅ Good | ❌ WRONG | N/A | ✅ Yes | 🟢⭐⭐ |
|
||||
| Dasar Hukum PPID | ✅ **100% ApiFetch!** | ✅ Best | ✅ Good | ❌ WRONG | N/A | ✅ Yes | 🟢⭐⭐ |
|
||||
| Permohonan Informasi | ⚠️ Mixed | ⚠️ Good | ✅ **Best** | ❌ **4 models WRONG** | ❌ Missing | ❌ Missing | 🟡 |
|
||||
| **Permohonan Keberatan** | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ WRONG | ❌ **MISSING** | ❌ **MISSING** | 🟡 |
|
||||
|
||||
**Permohonan Keberatan PPID Highlights:**
|
||||
- ✅ **Proper return value handling** - Return true/false untuk create operation
|
||||
- ✅ **Icon integration** - User, Mail, Phone, Info icons di table headers
|
||||
- ✅ **Conditional empty state messages** - Different messages untuk search vs empty
|
||||
- ⚠️ **Same deletedAt issue** seperti modul PPID lain
|
||||
- ⚠️ **Missing delete function** - Cannot delete data
|
||||
- ⚠️ **Missing edit function** - Cannot edit data (same as Permohonan Informasi)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 UNIQUE FEATURES OF PERMOHONAN KEBERATAN MODULE
|
||||
|
||||
**Simplest Read-Only Module:**
|
||||
1. ✅ **Proper return value handling** - Return true/false untuk create operation (UNIQUE!)
|
||||
2. ✅ **Conditional empty state messages** - Different messages untuk search vs empty
|
||||
3. ✅ **Icon integration** - User, Mail, Phone, Info icons
|
||||
4. ❌ **Missing delete function** - Cannot delete data
|
||||
5. ❌ **Missing edit function** - Cannot edit data
|
||||
|
||||
**Best Practices:**
|
||||
1. ✅ **Return value handling** - Best practice untuk create operation
|
||||
2. ✅ **Conditional empty state** - Good UX untuk search feedback
|
||||
3. ✅ **Loading state management** - Proper dengan finally block
|
||||
4. ✅ **Icon integration** - Visual clarity di table headers
|
||||
|
||||
**Critical Issues:**
|
||||
1. ❌ **Schema deletedAt SALAH** - Same issue seperti modul PPID lain
|
||||
2. ❌ **Fetch pattern inconsistency** - findUnique pakai fetch manual
|
||||
3. ❌ **Missing delete function** - Cannot delete data
|
||||
4. ❌ **Missing edit function** - Cannot edit data (same as Permohonan Informasi)
|
||||
|
||||
---
|
||||
|
||||
**Catatan:** **Permohonan Keberatan PPID adalah MODULE DENGAN RETURN VALUE HANDLING TERBAIK** tapi juga **MISSING DELETE & EDIT FUNCTIONS**. Module ini mirip dengan Permohonan Informasi (read-only, no delete/edit).
|
||||
|
||||
**Unique Strengths:**
|
||||
1. ✅ **Return value handling** - Best practice (return true/false)
|
||||
2. ✅ **Conditional empty state** - Good UX
|
||||
3. ✅ **Icon integration** - Visual clarity
|
||||
4. ✅ **Validation comprehensive** - Phone length validation
|
||||
|
||||
**Priority Action:**
|
||||
```diff
|
||||
🔴 FIX INI SEKARANG (30 MENIT + MIGRATION):
|
||||
File: prisma/schema.prisma
|
||||
Line: 478
|
||||
|
||||
model FormulirPermohonanKeberatan {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
email String
|
||||
notelp String
|
||||
alasan String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
- deletedAt DateTime @default(now())
|
||||
+ deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
# Lalu jalankan:
|
||||
bunx prisma db push
|
||||
# atau
|
||||
bunx prisma migrate dev --name fix_deletedat_keberatan
|
||||
```
|
||||
|
||||
```diff
|
||||
🔴 ADD DELETE FUNCTION (45 MENIT):
|
||||
File: state file
|
||||
|
||||
delete: {
|
||||
loading: false,
|
||||
async byId(id: string) {
|
||||
if (!id) return toast.warn("ID tidak valid");
|
||||
try {
|
||||
permohonanKeberatanInformasi.delete.loading = true;
|
||||
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["del"][id].delete();
|
||||
|
||||
if (res.data?.success) {
|
||||
toast.success(res.data.message || "Berhasil hapus permohonan keberatan");
|
||||
await permohonanKeberatanInformasi.findMany.load();
|
||||
} else {
|
||||
toast.error(res.data?.message || "Gagal hapus permohonan keberatan");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Gagal delete:", error);
|
||||
toast.error("Terjadi kesalahan saat menghapus");
|
||||
} finally {
|
||||
permohonanKeberatanInformasi.delete.loading = false;
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Setelah fix critical issues, module ini **PRODUCTION-READY** dengan **BEST RETURN VALUE HANDLING**! 🎉
|
||||
|
||||
---
|
||||
|
||||
## 📚 RECOMMENDED AS REFERENCE FOR OTHER MODULES
|
||||
|
||||
**Permohonan Keberatan PPID Module adalah BEST PRACTICE untuk:**
|
||||
1. ✅ **Return value handling** - Return true/false untuk create operation
|
||||
2. ✅ **Conditional empty state** - Different messages untuk search vs empty
|
||||
3. ✅ **Icon integration** - Visual clarity di table headers
|
||||
4. ✅ **Phone validation** - Min/max length validation
|
||||
|
||||
**Modules lain bisa belajar dari Permohonan Keberatan:**
|
||||
- **ALL MODULES:** Use return values untuk handle create success/failure
|
||||
- **ALL MODULES:** Conditional empty state messages untuk better UX
|
||||
- **ALL MODULES:** Icon integration untuk visual clarity
|
||||
- **ALL MODULES:** Specific validation rules (min/max length)
|
||||
|
||||
---
|
||||
|
||||
**File Location:** `QC/PPID/QC-PERMOHONAN-KEBERATAN-INFORMASI-MODULE.md` 📄
|
||||
@@ -1,802 +0,0 @@
|
||||
# QC Summary - PPID Profil Module
|
||||
|
||||
**Scope:** Profil PPID (Preview & Edit), Rich Text Editor Forms
|
||||
**Date:** 2026-02-23
|
||||
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
|
||||
|
||||
---
|
||||
|
||||
## 📊 OVERVIEW
|
||||
|
||||
| Aspect | Schema | API | UI Admin | State Management | Overall |
|
||||
|--------|--------|-----|----------|-----------------|---------|
|
||||
| Profil PPID | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ✅ Baik | 🟡 Perlu fix |
|
||||
|
||||
---
|
||||
|
||||
## ✅ YANG SUDAH BAIK
|
||||
|
||||
### **1. UI/UX Design**
|
||||
- ✅ Preview layout yang clean dengan logo desa
|
||||
- ✅ Responsive design (mobile & desktop)
|
||||
- ✅ Loading states dengan Skeleton
|
||||
- ✅ Error handling dengan Alert component
|
||||
- ✅ Empty state handling yang informatif
|
||||
- ✅ Edit button yang prominent
|
||||
|
||||
### **2. File Upload Handling**
|
||||
- ✅ Dropzone dengan preview image
|
||||
- ✅ Validasi format gambar (JPEG, JPG, PNG, WEBP)
|
||||
- ✅ Validasi ukuran file (max 5MB)
|
||||
- ✅ Tombol hapus preview (IconX di pojok kanan atas)
|
||||
- ✅ URL.createObjectURL untuk preview lokal
|
||||
- ✅ Error handling untuk image load (onError fallback)
|
||||
|
||||
### **3. Rich Text Editor (Tiptap)**
|
||||
- ✅ Full-featured editor dengan toolbar lengkap
|
||||
- ✅ Extensions: Bold, Italic, Underline, Highlight, Link, dll
|
||||
- ✅ Text alignment (left, center, justify, right)
|
||||
- ✅ Heading levels (H1-H4)
|
||||
- ✅ Lists (bullet & ordered)
|
||||
- ✅ Blockquote, code, superscript, subscript
|
||||
- ✅ Undo/Redo
|
||||
- ✅ Sticky toolbar untuk UX yang lebih baik
|
||||
|
||||
### **4. Form Component Structure**
|
||||
- ✅ Modular form components (Biodata, Riwayat, Pengalaman, Unggulan)
|
||||
- ✅ Reusable EditPPIDEditor component
|
||||
- ✅ Proper TypeScript typing
|
||||
- ✅ Error display untuk setiap field
|
||||
- ✅ Controlled components dengan onChange handler
|
||||
|
||||
### **5. State Management - BEST PRACTICES**
|
||||
- ✅ Proper typing dengan Prisma types
|
||||
- ✅ Loading state management dengan finally block
|
||||
- ✅ Error handling yang comprehensive
|
||||
- ✅ Reset function untuk cleanup
|
||||
- ✅ **originalForm tracking** untuk reset ke data awal
|
||||
|
||||
**Code Example (✅ EXCELLENT):**
|
||||
```typescript
|
||||
// state file - Line ~85-105
|
||||
editForm: {
|
||||
id: "",
|
||||
form: { ...defaultForm },
|
||||
originalForm: { ...defaultForm }, // ✅ Track original data
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
|
||||
initialize(profileData: ProfilePPIDForm) {
|
||||
this.id = profileData.id;
|
||||
const data = {
|
||||
name: profileData.name || "",
|
||||
biodata: profileData.biodata || "",
|
||||
riwayat: profileData.riwayat || "",
|
||||
pengalaman: profileData.pengalaman || "",
|
||||
unggulan: profileData.unggulan || "",
|
||||
imageId: profileData.imageId || "",
|
||||
};
|
||||
this.form = { ...data };
|
||||
this.originalForm = { ...data }; // ✅ Save original
|
||||
},
|
||||
|
||||
updateField(field: keyof typeof defaultForm, value: string) {
|
||||
this.form[field] = value;
|
||||
},
|
||||
|
||||
// ✅ Reset to original
|
||||
resetToOriginal() {
|
||||
this.form = { ...this.originalForm };
|
||||
toast.info("Data dikembalikan ke kondisi awal");
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SANGAT BAIK** - State management paling baik dibanding modul lain!
|
||||
|
||||
---
|
||||
|
||||
### **6. Edit Form - Original Data Tracking**
|
||||
- ✅ Original data state untuk reset form
|
||||
- ✅ Load data existing dengan benar
|
||||
- ✅ Preview image dari data lama
|
||||
- ✅ Reset form mengembalikan ke data original
|
||||
- ✅ File replacement logic (upload baru jika ada perubahan)
|
||||
|
||||
**Code Example (✅ EXCELLENT):**
|
||||
```typescript
|
||||
// edit/page.tsx - Line ~100-115
|
||||
const handleResetForm = () => {
|
||||
if (!allState.profile.data) return;
|
||||
|
||||
// Reset form ke data awal yang di-load
|
||||
const original = allState.profile.data;
|
||||
|
||||
stateProfilePPID.editForm.form = {
|
||||
name: original.name || '',
|
||||
imageId: original.imageId || '',
|
||||
biodata: original.biodata || '',
|
||||
riwayat: original.riwayat || '',
|
||||
pengalaman: original.pengalaman || '',
|
||||
unggulan: original.unggulan || '',
|
||||
};
|
||||
|
||||
// Reset preview gambar juga
|
||||
setPreviewImage(original.image?.link || null);
|
||||
setFile(null);
|
||||
|
||||
toast.info('Perubahan dibatalkan');
|
||||
};
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SANGAT BAIK** - Original data tracking sudah implementasi dengan sempurna!
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ ISSUES & SARAN PERBAIKAN
|
||||
|
||||
### **🔴 CRITICAL**
|
||||
|
||||
#### **1. Schema - deletedAt Default Value SALAH**
|
||||
|
||||
**Lokasi:** `prisma/schema.prisma` (line 401)
|
||||
|
||||
**Masalah:**
|
||||
```prisma
|
||||
model ProfilePPID {
|
||||
// ...
|
||||
deletedAt DateTime @default(now()) // ❌ SALAH - selalu punya default value
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- **LOGIC ERROR!** Setiap record baru langsung punya `deletedAt` value (timestamp creation)
|
||||
- Soft delete tidak berfungsi dengan benar
|
||||
- Query dengan `where: { deletedAt: null }` tidak akan pernah return data
|
||||
- Data yang "dihapus" vs data "aktif" tidak bisa dibedakan
|
||||
|
||||
**Contoh Issue:**
|
||||
```prisma
|
||||
// Record baru dibuat
|
||||
CREATE ProfilePPID {
|
||||
name: "PPID 1",
|
||||
// deletedAt otomatis ter-set ke now() ❌
|
||||
// isActive: true ✅
|
||||
}
|
||||
|
||||
// Query untuk data aktif (seharusnya return data ini)
|
||||
prisma.profilePPID.findMany({
|
||||
where: { deletedAt: null, isActive: true }
|
||||
})
|
||||
// ❌ Return kosong! Karena deletedAt sudah ter-set
|
||||
```
|
||||
|
||||
**Rekomendasi:** Fix schema:
|
||||
```prisma
|
||||
model ProfilePPID {
|
||||
// ...
|
||||
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 **CRITICAL**
|
||||
**Effort:** Medium (perlu migration)
|
||||
**Impact:** **HIGH** (data integrity & soft delete logic)
|
||||
|
||||
---
|
||||
|
||||
#### **2. HTML Injection Risk - dangerouslySetInnerHTML**
|
||||
|
||||
**Lokasi:** `page.tsx` (preview page)
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~105-110
|
||||
<Text
|
||||
fz={{ base: 'sm', md: 'md' }}
|
||||
ta="justify"
|
||||
c={colors['blue-button']}
|
||||
lh={1.5}
|
||||
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
||||
dangerouslySetInnerHTML={{ __html: item.biodata }} // ❌ No sanitization
|
||||
/>
|
||||
|
||||
// Line ~115-120 (Riwayat)
|
||||
dangerouslySetInnerHTML={{ __html: item.riwayat }} // ❌ No sanitization
|
||||
|
||||
// Line ~125-130 (Pengalaman)
|
||||
dangerouslySetInnerHTML={{ __html: item.pengalaman }} // ❌ No sanitization
|
||||
|
||||
// Line ~135-140 (Unggulan)
|
||||
dangerouslySetInnerHTML={{ __html: item.unggulan }} // ❌ No sanitization
|
||||
```
|
||||
|
||||
**Risk:**
|
||||
- XSS attack jika admin input script malicious
|
||||
- Bisa inject iframe, script tag, dll
|
||||
- Security vulnerability
|
||||
|
||||
**Rekomendasi:** Gunakan DOMPurify atau library sanitization:
|
||||
|
||||
```typescript
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Sanitize sebelum render
|
||||
const sanitizedBiodata = DOMPurify.sanitize(item.biodata);
|
||||
const sanitizedRiwayat = DOMPurify.sanitize(item.riwayat);
|
||||
const sanitizedPengalaman = DOMPurify.sanitize(item.pengalaman);
|
||||
const sanitizedUnggulan = DOMPurify.sanitize(item.unggulan);
|
||||
|
||||
<Text
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedBiodata }}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
Atau validasi di backend untuk whitelist tag HTML yang diperbolehkan (hanya `<p>`, `<ul>`, `<li>`, `<strong>`, dll).
|
||||
|
||||
**Priority:** 🔴 **HIGH** (**Security concern**)
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **3. State Management - Fetch Pattern Inconsistency**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/profile_ppid/profile_PPID.ts`
|
||||
|
||||
**Masalah:** Ada 2 pattern berbeda untuk fetch API:
|
||||
|
||||
```typescript
|
||||
// ❌ Pattern 1: fetch manual (profile.load)
|
||||
const res = await fetch(`/api/ppid/profileppid/${id}`);
|
||||
|
||||
// ❌ Pattern 2: fetch manual (editForm.submit)
|
||||
const res = await fetch(`/api/ppid/profileppid/${this.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(this.form),
|
||||
});
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- Code consistency buruk
|
||||
- Sulit maintenance
|
||||
- Type safety tidak konsisten
|
||||
- Duplikasi logic error handling
|
||||
- Tidak konsisten dengan modul lain yang sudah migrate ke ApiFetch
|
||||
|
||||
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
|
||||
|
||||
```typescript
|
||||
import ApiFetch from "@/lib/api-fetch";
|
||||
|
||||
// profile.load
|
||||
async load(id: string) {
|
||||
try {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
const res = await ApiFetch.api.ppid.profileppid[id].get();
|
||||
|
||||
if (res.data?.success) {
|
||||
this.data = res.data.data;
|
||||
return res.data.data;
|
||||
} else {
|
||||
if (res.data?.message === "Data tidak ditemukan" ||
|
||||
res.data?.message === "Belum ada data profil PPID yang aktif") {
|
||||
this.error = res.data.message;
|
||||
return null;
|
||||
} else {
|
||||
throw new Error(res.data?.message || "Gagal memuat data profile");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
this.error = msg;
|
||||
console.error("Load profile error:", msg);
|
||||
if (msg !== "Data tidak ditemukan" && msg !== "Belum ada data profil PPID yang aktif") {
|
||||
toast.error("Gagal memuat data profile");
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// editForm.submit
|
||||
async submit() {
|
||||
const check = templateForm.safeParse(this.form);
|
||||
if (!check.success) {
|
||||
toast.error(
|
||||
check.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const res = await ApiFetch.api.ppid.profileppid[this.id].put(this.form);
|
||||
|
||||
if (res.data?.success) {
|
||||
toast.success("Berhasil update profile");
|
||||
this.originalForm = { ...this.form };
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(res.data?.message || "Gagal update profile");
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
this.error = msg;
|
||||
toast.error(msg);
|
||||
return false;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 High
|
||||
**Effort:** Medium (refactor di semua methods)
|
||||
|
||||
---
|
||||
|
||||
### **🟡 MEDIUM**
|
||||
|
||||
#### **4. Console.log di Production**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/profile_ppid/profile_PPID.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~65
|
||||
console.error("Load profile error:", msg);
|
||||
|
||||
// edit/page.tsx - Line ~65
|
||||
console.error("Error updating profile:", error);
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan conditional logging:
|
||||
|
||||
```typescript
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error("Load profile error:", msg);
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **5. Zod Schema - Error Message Tidak Konsisten**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/profile_ppid/profile_PPID.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~6
|
||||
const templateForm = z.object({
|
||||
name: z.string().min(3, "Nama minimal 3 karakter"), // ✅ OK
|
||||
biodata: z.string().min(3, "Biodata minimal 3 karakter"), // ✅ OK
|
||||
riwayat: z.string().min(3, "Riwayat minimal 3 karakter"), // ✅ OK
|
||||
pengalaman: z.string().min(3, "Pengalaman minimal 3 karakter"), // ✅ OK
|
||||
unggulan: z.string().min(3, "Unggulan minimal 3 karakter"), // ✅ OK
|
||||
imageId: z.string().min(1, "Gambar wajib dipilih"), // ✅ OK
|
||||
});
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - Error messages sudah spesifik dan konsisten!
|
||||
|
||||
**Priority:** 🟢 None
|
||||
**Effort:** None
|
||||
|
||||
---
|
||||
|
||||
#### **6. Missing Validation di Submit Button**
|
||||
|
||||
**Lokasi:** `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~270-280
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
size="md"
|
||||
style={{ ... }}
|
||||
>
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Issue:** Button tidak disabled saat submitting atau form invalid. User bisa click multiple times.
|
||||
|
||||
**Rekomendasi:** Add disabled state:
|
||||
|
||||
```typescript
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
size="md"
|
||||
disabled={isSubmitting || allState.editForm.loading}
|
||||
style={{
|
||||
background: isSubmitting || allState.editForm.loading
|
||||
? 'linear-gradient(135deg, #cccccc, #999999)'
|
||||
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟢 LOW (Minor Polish)**
|
||||
|
||||
#### **7. Duplicate useEffect di Editor Component**
|
||||
|
||||
**Lokasi:** `editPPIDEditor.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~25-30
|
||||
useEffect(() => {
|
||||
if (editor && value && value !== editor.getHTML()) {
|
||||
editor.commands.setContent(value);
|
||||
}
|
||||
}, [editor, value]);
|
||||
|
||||
// Line ~32-40
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const updateHandler = () => onChange(editor.getHTML());
|
||||
editor.on('update', updateHandler);
|
||||
|
||||
return () => {
|
||||
editor.off('update', updateHandler);
|
||||
};
|
||||
}, [editor, onChange]);
|
||||
```
|
||||
|
||||
**Issue:** Ada 2 useEffect yang handle editor update. Yang pertama set content, yang kedua handle onChange. Bisa digabung untuk clarity.
|
||||
|
||||
**Rekomendasi:** Simplify:
|
||||
|
||||
```typescript
|
||||
const editor = useEditor({
|
||||
extensions: [...],
|
||||
content: value, // Set content directly
|
||||
onUpdate({ editor }) {
|
||||
onChange(editor.getHTML());
|
||||
},
|
||||
});
|
||||
|
||||
// Remove first useEffect, keep second for cleanup
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **8. Form Label Inconsistency**
|
||||
|
||||
**Lokasi:** `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~170
|
||||
<Text fw="bold">Nama Perbekel</Text>
|
||||
|
||||
// Should be:
|
||||
<Text fw="bold">Nama PPID</Text>
|
||||
```
|
||||
|
||||
**Issue:** Label "Nama Perbekel" tidak sesuai dengan context PPID. Ini profil PPID, bukan perbekel.
|
||||
|
||||
**Rekomendasi:** Fix label:
|
||||
```typescript
|
||||
<Text fw="bold">Nama PPID</Text>
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **9. Image Label Text Size**
|
||||
|
||||
**Lokasi:** `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~180
|
||||
<Text fz={"md"} fw={"bold"}>Gambar</Text>
|
||||
|
||||
// Should be more specific:
|
||||
<Text fz={"md"} fw={"bold"}>Foto Profil PPID</Text>
|
||||
```
|
||||
|
||||
**Rekomendasi:** More descriptive label:
|
||||
```typescript
|
||||
<Text fz={"md"} fw={"bold"}>Foto Profil PPID</Text>
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **10. Dropzone Accept Format**
|
||||
|
||||
**Lokasi:** `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~190
|
||||
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||
|
||||
// Missing mime type specifications
|
||||
```
|
||||
|
||||
**Rekomendasi:** Add full mime types:
|
||||
```typescript
|
||||
accept={{
|
||||
'image/jpeg': ['.jpeg', '.jpg'],
|
||||
'image/png': ['.png'],
|
||||
'image/webp': ['.webp'],
|
||||
}}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **11. Preview Page - Title Order Inconsistency**
|
||||
|
||||
**Lokasi:** `page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~55
|
||||
<Title order={4} ...>
|
||||
PROFIL PIMPINAN BADAN PUBLIK DESA DARMASABA
|
||||
</Title>
|
||||
|
||||
// Line ~90
|
||||
<Title order={3} ...>
|
||||
{item.name}
|
||||
</Title>
|
||||
|
||||
// Line ~100
|
||||
<Title order={3} ...>
|
||||
Biodata
|
||||
</Title>
|
||||
```
|
||||
|
||||
**Issue:** Title hierarchy tidak konsisten. Subtitle (order 4) lebih kecil dari content titles (order 3).
|
||||
|
||||
**Rekomendasi:** Samakan hierarchy:
|
||||
```typescript
|
||||
// Main title: order={2} atau order={3}
|
||||
// Section titles: order={4}
|
||||
// Name: order={3}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **12. Missing Search Feature**
|
||||
|
||||
**Lokasi:** N/A (Single record module)
|
||||
|
||||
**Verdict:** ✅ **NOT APPLICABLE** - Module ini hanya handle single record, search tidak diperlukan.
|
||||
|
||||
**Priority:** 🟢 None
|
||||
**Effort:** None
|
||||
|
||||
---
|
||||
|
||||
#### **13. Button Loading State Tidak Konsisten**
|
||||
|
||||
**Lokasi:** `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~270-280
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
// ...
|
||||
>
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Issue:** Button hanya check `isSubmitting` local state, tidak check `allState.editForm.loading` dari global state.
|
||||
|
||||
**Rekomendasi:** Check both states:
|
||||
```typescript
|
||||
disabled={isSubmitting || allState.editForm.loading}
|
||||
{isSubmitting || allState.editForm.loading ? (
|
||||
<Loader size="sm" color="white" />
|
||||
) : (
|
||||
'Simpan'
|
||||
)}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
## 📋 RINGKASAN ACTION ITEMS
|
||||
|
||||
| Priority | Issue | Module | Impact | Effort | Status |
|
||||
|----------|-------|--------|--------|--------|--------|
|
||||
| 🔴 P0 | **Schema deletedAt default SALAH** | Schema | **CRITICAL** | Medium | **MUST FIX** |
|
||||
| 🔴 P0 | **HTML injection risk** | UI | **HIGH (Security)** | Low | **Should fix** |
|
||||
| 🔴 P1 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
|
||||
| 🟡 M | Console.log in production | State | Low | Low | Optional |
|
||||
| 🟡 M | Missing validation di submit button | UI | Low | Low | Should fix |
|
||||
| 🟢 L | Duplicate useEffect di editor | Editor | Low | Low | Optional |
|
||||
| 🟢 L | Form label inconsistency | UI | Low | Low | Should fix |
|
||||
| 🟢 L | Image label text size | UI | Low | Low | Optional |
|
||||
| 🟢 L | Dropzone accept format | UI | Low | Low | Optional |
|
||||
| 🟢 L | Title order inconsistency | UI | Low | Low | Optional |
|
||||
| 🟢 L | Button loading state inconsistency | UI | Low | Low | Optional |
|
||||
|
||||
---
|
||||
|
||||
## ✅ KESIMPULAN
|
||||
|
||||
### **Overall Quality: 🟢 BAIK (8/10)**
|
||||
|
||||
**Strengths:**
|
||||
1. ✅ UI/UX clean & responsive
|
||||
2. ✅ File upload handling solid
|
||||
3. ✅ **Rich Text Editor** full-featured (Tiptap)
|
||||
4. ✅ **Modular form components** (Biodata, Riwayat, Pengalaman, Unggulan)
|
||||
5. ✅ **State management BEST PRACTICES** (originalForm tracking)
|
||||
6. ✅ **Edit form reset SANGAT BAIK** (original data tracking sempurna)
|
||||
7. ✅ Error handling comprehensive
|
||||
8. ✅ Loading state management dengan finally block
|
||||
9. ✅ Modal konfirmasi hapus untuk user safety
|
||||
|
||||
**Critical Issues:**
|
||||
1. ⚠️ **Schema deletedAt default SALAH** - Logic error untuk soft delete (CRITICAL)
|
||||
2. ⚠️ **HTML injection risk** - dangerouslySetInnerHTML tanpa sanitization (HIGH Security)
|
||||
3. ⚠️ Fetch method pattern inconsistency (ApiFetch vs fetch manual)
|
||||
|
||||
**Areas for Improvement:**
|
||||
1. ⚠️ **Fix schema deletedAt** dari `@default(now())` ke `@default(null)` dengan nullable
|
||||
2. ⚠️ **Fix HTML injection** dengan DOMPurify atau backend validation
|
||||
3. ⚠️ **Refactor fetch methods** untuk gunakan ApiFetch consistently
|
||||
4. ⚠️ **Add disabled state** di submit button
|
||||
5. ⚠️ **Fix form labels** (Nama Perbekel → Nama PPID)
|
||||
|
||||
**Recommended Next Steps:**
|
||||
1. **🔴 CRITICAL: Fix schema deletedAt** - 30 menit (perlu migration)
|
||||
2. **🔴 HIGH: Fix HTML injection** dengan DOMPurify - 30 menit
|
||||
3. **🔴 HIGH: Refactor fetch methods** ke ApiFetch - 1 jam
|
||||
4. **🟡 MEDIUM: Add disabled state** di submit button - 15 menit
|
||||
5. **🟢 LOW: Fix form labels** - 10 menit
|
||||
6. **🟢 LOW: Polish minor issues** - 30 menit
|
||||
|
||||
---
|
||||
|
||||
## 📈 COMPARISON WITH OTHER MODULES
|
||||
|
||||
| Aspect | Profil | Desa Anti Korupsi | SDGs Desa | APBDes | Prestasi Desa | **PPID Profil** | Notes |
|
||||
|--------|--------|-------------------|-----------|--------|---------------|-----------------|-------|
|
||||
| Fetch Pattern | ⚠️ Mixed | ⚠️ Mixed | ⚠️ Mixed | ⚠️ Mixed | ⚠️ Mixed | ⚠️ Mixed | All perlu refactor |
|
||||
| Loading State | ⚠️ Some missing | ⚠️ Some missing | ⚠️ Missing | ✅ Good | ⚠️ findUnique missing | ✅ **Good** | PPID salah satu yang terbaik |
|
||||
| Edit Form Reset | ✅ Good | ✅ Good | ✅ Good | ✅ Good | ✅ Good | ✅ **EXCELLENT** | **PPID paling baik** (originalForm tracking) |
|
||||
| Type Safety | ⚠️ Some `any` | ⚠️ Some `any` | ⚠️ Some `any` | ⚠️ Some `any` | ⚠️ Some `any` | ✅ **Good** | PPID typing lebih baik |
|
||||
| File Upload | ✅ Images | ✅ Documents | ✅ Images | ✅ Dual | ✅ Images | ✅ Images | Similar |
|
||||
| Error Handling | ✅ Good | ✅ Good (better) | ✅ Good | ✅ Good | ✅ Good | ✅ **Good** | Consistent |
|
||||
| **Schema deletedAt** | ⚠️ Issue | ⚠️ Issue | ⚠️ Issue | ✅ Good | ❌ **WRONG** | ❌ **WRONG** | **PPID CRITICAL** |
|
||||
| HTML Injection | ⚠️ Present | ⚠️ Present | N/A | N/A | ⚠️ Present | ⚠️ **Present** | Security concern |
|
||||
| Rich Text Editor | ✅ Present | ✅ Present | N/A | N/A | ✅ Present | ✅ **Best** | **PPID editor paling lengkap** |
|
||||
| Modular Forms | ❌ None | ❌ None | N/A | ❌ None | ❌ None | ✅ **YES** | **PPID unique feature** |
|
||||
| State Management | ⚠️ Good | ⚠️ Good | ⚠️ Good | ⚠️ Good | ⚠️ Good | ✅ **BEST** | **PPID state management terbaik** |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 UNIQUE FEATURES OF PPID PROFIL MODULE
|
||||
|
||||
**Most Advanced Module:**
|
||||
1. ✅ **Rich Text Editor (Tiptap)** - Full-featured dengan toolbar lengkap
|
||||
2. ✅ **Modular Form Components** - Biodata, Riwayat, Pengalaman, Unggulan forms
|
||||
3. ✅ **originalForm Tracking** - State management best practice (unique to PPID)
|
||||
4. ✅ **Single Record Pattern** - Handle "edit" special ID untuk single profile
|
||||
5. ✅ **Comprehensive Error Handling** - Special handling untuk "data not found" cases
|
||||
|
||||
**Best Practices:**
|
||||
1. ✅ **State management PALING BAIK** dibanding semua modul lain
|
||||
2. ✅ **Edit form reset PALING BAIK** (originalForm tracking sempurna)
|
||||
3. ✅ **Type safety LEBIH BAIK** (minimal any usage)
|
||||
4. ✅ **Loading state management PROPER** (dengan finally block)
|
||||
5. ✅ **Modular component design** (reusable forms)
|
||||
|
||||
**Critical Issues:**
|
||||
1. ❌ **Schema deletedAt SALAH** - sama seperti SDGs, Desa Anti Korupsi, Prestasi Desa
|
||||
2. ❌ **HTML injection risk** - sama seperti modul lain yang pakai rich text
|
||||
|
||||
---
|
||||
|
||||
**Catatan:** Secara keseluruhan, modul **PPID Profil adalah YANG PALING BAIK** dibanding semua modul yang sudah di-QC. State management-nya adalah best practice dengan originalForm tracking yang sempurna. Rich Text Editor implementation juga paling advanced.
|
||||
|
||||
**Unique Strengths:**
|
||||
1. ✅ **State management terbaik** - originalForm tracking untuk reset yang sempurna
|
||||
2. ✅ **Rich Text Editor terlengkap** - Tiptap dengan semua extensions
|
||||
3. ✅ **Modular form design** - Reusable components untuk setiap section
|
||||
4. ✅ **Type safety lebih baik** - Minimal any usage
|
||||
|
||||
**Priority Action:**
|
||||
```diff
|
||||
🔴 FIX INI SEKARANG (30 MENIT + MIGRATION):
|
||||
File: prisma/schema.prisma
|
||||
Line: 401
|
||||
|
||||
model ProfilePPID {
|
||||
// ...
|
||||
- deletedAt DateTime @default(now())
|
||||
+ deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
# Lalu jalankan:
|
||||
bunx prisma db push
|
||||
# atau
|
||||
bunx prisma migrate dev --name fix_deletedat_default_ppid
|
||||
```
|
||||
|
||||
```diff
|
||||
🔴 FIX HTML INJECTION (30 MENIT):
|
||||
File: src/app/admin/(dashboard)/ppid/profil-ppid/page.tsx
|
||||
|
||||
+ import DOMPurify from 'dompurify';
|
||||
|
||||
// Line ~105
|
||||
- dangerouslySetInnerHTML={{ __html: item.biodata }}
|
||||
+ dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.biodata) }}
|
||||
|
||||
// Repeat for riwayat, pengalaman, unggulan
|
||||
```
|
||||
|
||||
Setelah fix critical issues, module ini **PRODUCTION-READY** dan bisa jadi **REFERENCE** untuk modul lain! 🎉
|
||||
|
||||
---
|
||||
|
||||
## 📚 RECOMMENDED AS REFERENCE FOR OTHER MODULES
|
||||
|
||||
**PPID Profil Module adalah BEST PRACTICE untuk:**
|
||||
1. ✅ **State management** - originalForm tracking pattern
|
||||
2. ✅ **Edit form reset** - Comprehensive reset logic
|
||||
3. ✅ **Modular form components** - Reusable design pattern
|
||||
4. ✅ **Rich Text Editor** - Tiptap implementation
|
||||
5. ✅ **Type safety** - Proper TypeScript typing
|
||||
|
||||
**Modules lain bisa belajar dari PPID Profil:**
|
||||
- APBDes: Implement originalForm tracking
|
||||
- Prestasi Desa: Implement originalForm tracking
|
||||
- SDGs Desa: Implement originalForm tracking
|
||||
- Desa Anti Korupsi: Implement originalForm tracking
|
||||
- Profil (Media Sosial, Program Inovasi): Implement originalForm tracking
|
||||
|
||||
---
|
||||
|
||||
**File Location:** `QC/PPID/QC-PPID-PROFIL-MODULE.md` 📄
|
||||
@@ -1,936 +0,0 @@
|
||||
# QC Summary - Struktur PPID Module
|
||||
|
||||
**Scope:** Struktur Organisasi (Organization Chart), Pegawai PPID, Posisi Organisasi
|
||||
**Date:** 2026-02-23
|
||||
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
|
||||
|
||||
---
|
||||
|
||||
## 📊 OVERVIEW
|
||||
|
||||
| Sub-Module | Schema | API | UI Admin | State Management | Overall |
|
||||
|------------|--------|-----|----------|-----------------|---------|
|
||||
| Struktur Organisasi | ✅ Baik | ✅ Baik | ✅ **Excellent** | ✅ Baik | 🟢 |
|
||||
| Posisi Organisasi | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 |
|
||||
| Pegawai PPID | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ YANG SUDAH BAIK
|
||||
|
||||
### **1. UI/UX - Organization Chart (UNIQUE FEATURE!)**
|
||||
- ✅ **PrimeReact OrganizationChart** - Visual hierarchy yang excellent
|
||||
- ✅ Interactive tree structure dengan expand/collapse
|
||||
- ✅ Custom node template dengan foto, nama, dan posisi
|
||||
- ✅ Responsive design dengan overflow handling
|
||||
- ✅ Empty state yang informatif
|
||||
- ✅ Loading state dengan spinner
|
||||
|
||||
**Code Example (✅ EXCELLENT):**
|
||||
```typescript
|
||||
// struktur-organisasi/page.tsx - Line ~45-75
|
||||
const posisiMap = new Map<string, any>();
|
||||
|
||||
const aktifPegawai = stateOrganisasi.findManyAll.data?.filter(p => p.isActive);
|
||||
|
||||
for (const pegawai of aktifPegawai) {
|
||||
const posisiId = pegawai.posisi.id;
|
||||
if (!posisiMap.has(posisiId)) {
|
||||
posisiMap.set(posisiId, {
|
||||
...pegawai.posisi,
|
||||
pegawaiList: [],
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
posisiMap.get(posisiId)!.pegawaiList.push(pegawai);
|
||||
}
|
||||
|
||||
// Build tree structure
|
||||
let root: any[] = [];
|
||||
posisiMap.forEach((posisi) => {
|
||||
if (posisi.parentId) {
|
||||
const parent = posisiMap.get(posisi.parentId);
|
||||
if (parent) {
|
||||
parent.children.push(posisi);
|
||||
}
|
||||
} else {
|
||||
root.push(posisi);
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to OrganizationChart format
|
||||
function toOrgChartFormat(node: any): any {
|
||||
return {
|
||||
expanded: true,
|
||||
type: 'person',
|
||||
styleClass: 'p-person',
|
||||
data: {
|
||||
name: node.pegawaiList?.[0]?.namaLengkap || 'Belum ada pegawai',
|
||||
status: node.nama,
|
||||
image: node.pegawaiList?.[0]?.image?.link || '/img/default.png',
|
||||
},
|
||||
children: node.children.map(toOrgChartFormat),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **UNIQUE & EXCELLENT** - Satu-satunya modul dengan organization chart visual!
|
||||
|
||||
---
|
||||
|
||||
### **2. File Upload Handling**
|
||||
- ✅ Dropzone dengan preview image
|
||||
- ✅ Validasi format gambar (JPEG, JPG, PNG, WEBP)
|
||||
- ✅ Validasi ukuran file (max 5MB)
|
||||
- ✅ Tombol hapus preview (IconX di pojok kanan atas)
|
||||
- ✅ URL.createObjectURL untuk preview lokal
|
||||
|
||||
### **3. Form Validation**
|
||||
- ✅ Zod schema untuk validasi typed
|
||||
- ✅ Email validation dengan regex
|
||||
- ✅ Required field validation
|
||||
- ✅ isFormValid() check sebelum submit
|
||||
- ✅ Error toast dengan pesan spesifik
|
||||
- ✅ Button disabled saat invalid/loading
|
||||
|
||||
### **4. CRUD Operations**
|
||||
- ✅ Create dengan upload file
|
||||
- ✅ FindMany dengan pagination & search
|
||||
- ✅ FindUnique untuk detail
|
||||
- ✅ Delete dengan hard delete
|
||||
- ✅ Update dengan file replacement
|
||||
- ✅ **Non-active feature** untuk soft disable pegawai
|
||||
|
||||
### **5. State Management**
|
||||
- ✅ Proper typing dengan Prisma types
|
||||
- ✅ Loading state management dengan finally block
|
||||
- ✅ Error handling yang comprehensive
|
||||
- ✅ Reset function untuk cleanup
|
||||
- ✅ findManyAll untuk organization chart data
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// state file - Line ~270-290
|
||||
findManyAll: {
|
||||
data: null as Prisma.PegawaiPPIDGetPayload<{...}>[] | null,
|
||||
loading: false,
|
||||
search: "",
|
||||
load: async (search = "") => {
|
||||
posisiOrganisasi.findManyAll.loading = true; // ✅ Start loading
|
||||
posisiOrganisasi.findManyAll.search = search;
|
||||
try {
|
||||
const query: any = { search };
|
||||
if (search) query.search = search;
|
||||
|
||||
const res = await ApiFetch.api.ppid.strukturppid.pegawai["find-many-all"].get({ query });
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
posisiOrganisasi.findManyAll.data = res.data.data || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading pegawai:", error);
|
||||
posisiOrganisasi.findManyAll.data = [];
|
||||
} finally {
|
||||
posisiOrganisasi.findManyAll.loading = false; // ✅ Stop loading
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **BAIK** - Loading state management sudah proper!
|
||||
|
||||
---
|
||||
|
||||
### **6. Edit Form - Original Data Tracking**
|
||||
- ✅ Original data state untuk reset form
|
||||
- ✅ Load data existing dengan benar
|
||||
- ✅ Preview image dari data lama
|
||||
- ✅ Reset form mengembalikan ke data original
|
||||
- ✅ File replacement logic (upload baru jika ada perubahan)
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// edit/page.tsx - Line ~80-115
|
||||
const [originalData, setOriginalData] = useState({
|
||||
namaLengkap: "",
|
||||
gelarAkademik: "",
|
||||
imageId: "",
|
||||
tanggalMasuk: "",
|
||||
email: "",
|
||||
telepon: "",
|
||||
alamat: "",
|
||||
posisiId: "",
|
||||
imageUrl: "",
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Load data
|
||||
const data = await stateOrganisasi.edit.load(id);
|
||||
|
||||
setOriginalData({
|
||||
...data,
|
||||
imageUrl: data.image?.link || '',
|
||||
});
|
||||
|
||||
setPreviewImage(data.image?.link || null);
|
||||
|
||||
// Line ~135 - Handle reset
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
namaLengkap: originalData.namaLengkap,
|
||||
gelarAkademik: originalData.gelarAkademik,
|
||||
imageId: originalData.imageId,
|
||||
tanggalMasuk: originalData.tanggalMasuk,
|
||||
email: originalData.email,
|
||||
telepon: originalData.telepon,
|
||||
alamat: originalData.alamat,
|
||||
posisiId: originalData.posisiId,
|
||||
isActive: originalData.isActive,
|
||||
});
|
||||
setPreviewImage(originalData.imageUrl || null);
|
||||
setFile(null);
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **BAIK** - Original data tracking sudah implementasi dengan baik!
|
||||
|
||||
---
|
||||
|
||||
### **7. Unique Features**
|
||||
- ✅ **Organization Chart** - Visual hierarchy tree (UNIQUE!)
|
||||
- ✅ **Hierarchical Positions** - Parent-child relationships
|
||||
- ✅ **Active/Non-active Toggle** - Soft disable untuk pegawai
|
||||
- ✅ **Email Validation** - Regex validation untuk email format
|
||||
- ✅ **Date Input Handling** - Proper date formatting untuk tanggal masuk
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ ISSUES & SARAN PERBAIKAN
|
||||
|
||||
### **🔴 CRITICAL**
|
||||
|
||||
#### **1. Schema - Missing deletedAt for Soft Delete**
|
||||
|
||||
**Lokasi:** `prisma/schema.prisma` (line 327-332, 343-351)
|
||||
|
||||
**Masalah:**
|
||||
```prisma
|
||||
model PosisiOrganisasiPPID {
|
||||
// ...
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
// ❌ MISSING: deletedAt field untuk soft delete
|
||||
}
|
||||
|
||||
model PegawaiPPID {
|
||||
// ...
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
// ❌ MISSING: deletedAt field untuk soft delete
|
||||
}
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- **INCONSISTENT!** Model `StrukturOrganisasiPPID` punya `deletedAt`, tapi Posisi dan Pegawai tidak
|
||||
- Hard delete vs soft delete inconsistency
|
||||
- Data integrity issue saat delete (data hilang permanen)
|
||||
- Tidak bisa restore data yang ter-delete
|
||||
|
||||
**Rekomendasi:** Add deletedAt field:
|
||||
```prisma
|
||||
model PosisiOrganisasiPPID {
|
||||
// ...
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime? @default(null) // ✅ Add for soft delete
|
||||
}
|
||||
|
||||
model PegawaiPPID {
|
||||
// ...
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime? @default(null) // ✅ Add for soft delete
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 **HIGH**
|
||||
**Effort:** Medium (perlu migration)
|
||||
**Impact:** **HIGH** (data integrity & consistency)
|
||||
|
||||
---
|
||||
|
||||
#### **2. State Management - Fetch Pattern Inconsistency**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/struktur_ppid/struktur_PPID.ts`
|
||||
|
||||
**Masalah:** Ada 2 pattern berbeda untuk fetch API:
|
||||
|
||||
```typescript
|
||||
// ❌ Pattern 1: ApiFetch (create, findMany, findManyAll)
|
||||
const res = await ApiFetch.api.ppid.strukturppid.pegawai["create"].post(pegawai.create.form);
|
||||
const res = await ApiFetch.api.ppid.strukturppid.pegawai["find-many"].get({ query });
|
||||
const res = await ApiFetch.api.ppid.strukturppid.pegawai["find-many-all"].get({ query });
|
||||
|
||||
// ❌ Pattern 2: fetch manual (findUnique, edit, delete, nonActive)
|
||||
const res = await fetch(`/api/ppid/strukturppid/pegawai/${id}`);
|
||||
const res = await fetch(`/api/ppid/strukturppid/pegawai/del/${id}`, { method: "DELETE" });
|
||||
const res = await fetch(`/api/ppid/strukturppid/pegawai/non-active/${id}`, { method: "DELETE" });
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- Code consistency buruk
|
||||
- Sulit maintenance
|
||||
- Type safety tidak konsisten
|
||||
- Duplikasi logic error handling
|
||||
|
||||
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
|
||||
|
||||
```typescript
|
||||
// ✅ Unified pattern
|
||||
async load(id: string) {
|
||||
try {
|
||||
const res = await ApiFetch.api.ppid.strukturppid.pegawai[id].get();
|
||||
|
||||
if (res.data?.success) {
|
||||
const data = res.data.data;
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
namaLengkap: data.namaLengkap,
|
||||
gelarAkademik: data.gelarAkademik,
|
||||
imageId: data.imageId,
|
||||
tanggalMasuk: data.tanggalMasuk,
|
||||
email: data.email,
|
||||
telepon: data.telepon,
|
||||
alamat: data.alamat,
|
||||
posisiId: data.posisiId,
|
||||
isActive: data.isActive,
|
||||
};
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(res.data?.message || "Gagal memuat data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading pegawai:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Gagal memuat data");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async byId(id: string) {
|
||||
try {
|
||||
const res = await ApiFetch.api.ppid.strukturppid.pegawai["del"][id].delete();
|
||||
|
||||
if (res.data?.success) {
|
||||
toast.success(res.data.message || "Berhasil hapus pegawai");
|
||||
await pegawai.findMany.load();
|
||||
} else {
|
||||
toast.error(res.data?.message || "Gagal hapus pegawai");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Gagal delete:", error);
|
||||
toast.error("Terjadi kesalahan saat menghapus");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 High
|
||||
**Effort:** Medium (refactor di semua methods)
|
||||
|
||||
---
|
||||
|
||||
#### **3. HTML Injection Risk - dangerouslySetInnerHTML**
|
||||
|
||||
**Lokasi:**
|
||||
- `posisi-organisasi/page.tsx` (line ~95, 155)
|
||||
- `posisi-organisasi/create/page.tsx` (CreateEditor component)
|
||||
- `posisi-organisasi/[id]/edit/page.tsx` (EditEditor component)
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// ❌ Direct HTML render tanpa sanitization
|
||||
<Text
|
||||
fz="sm"
|
||||
lh={1.5}
|
||||
c="dimmed"
|
||||
lineClamp={1}
|
||||
dangerouslySetInnerHTML={{ __html: item.deskripsi || '-' }}
|
||||
/>
|
||||
```
|
||||
|
||||
**Risk:**
|
||||
- XSS attack jika admin input script malicious
|
||||
- Bisa inject iframe, script tag, dll
|
||||
- Security vulnerability
|
||||
|
||||
**Rekomendasi:** Gunakan DOMPurify atau library sanitization:
|
||||
|
||||
```typescript
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Sanitize sebelum render
|
||||
const sanitizedDeskripsi = DOMPurify.sanitize(item.deskripsi);
|
||||
<Text
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedDeskripsi }}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
Atau validasi di backend untuk whitelist tag HTML yang diperbolehkan.
|
||||
|
||||
**Priority:** 🔴 **HIGH** (**Security concern**)
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟡 MEDIUM**
|
||||
|
||||
#### **4. Console.log di Production**
|
||||
|
||||
**Lokasi:** Multiple places di state file
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~65
|
||||
console.error("Load struktur error:", errorMessage);
|
||||
|
||||
// Line ~130
|
||||
console.error("Update struktur error:", errorMessage);
|
||||
|
||||
// Line ~220
|
||||
console.error("Failed to fetch posisiOrganisasi:", res.statusText);
|
||||
|
||||
// Line ~224
|
||||
console.error("Error fetching posisiOrganisasi:", error);
|
||||
|
||||
// Line ~370
|
||||
console.error("Gagal fetch posisi organisasi paginated:", err);
|
||||
|
||||
// Line ~400
|
||||
console.error("Failed to load posisiOrganisasi:", res.data?.message);
|
||||
|
||||
// Line ~404
|
||||
console.error("Error loading posisiOrganisasi:", error);
|
||||
|
||||
// ... dan banyak lagi
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan conditional logging:
|
||||
|
||||
```typescript
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error("Error:", error);
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **5. Type Safety - Any Usage**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/struktur_ppid/struktur_PPID.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~190
|
||||
const query: any = { page, limit: appliedLimit }; // ❌ Using 'any'
|
||||
if (search) query.search = search;
|
||||
|
||||
// Line ~215
|
||||
const query: any = { search }; // ❌ Using 'any'
|
||||
if (search) query.search = search;
|
||||
|
||||
// Line ~365
|
||||
const query: any = { page, limit }; // ❌ Using 'any'
|
||||
if (search) query.search = search;
|
||||
|
||||
// Line ~395
|
||||
const query: any = { search }; // ❌ Using 'any'
|
||||
if (search) query.search = search;
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan typed query:
|
||||
|
||||
```typescript
|
||||
// Define type
|
||||
interface FindManyQuery {
|
||||
page: number | string;
|
||||
limit?: number | string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// Use typed query
|
||||
const query: FindManyQuery = { page, limit: appliedLimit };
|
||||
if (search) query.search = search;
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **6. Error Message Tidak Konsisten**
|
||||
|
||||
**Lokasi:** Multiple places
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Create posisi - Line ~180
|
||||
toast.error("Terjadi kesalahan saat menambahkan posisi");
|
||||
|
||||
// Create pegawai - Line ~280
|
||||
toast.error("Terjadi kesalahan saat menambahkan pegawai");
|
||||
|
||||
// Delete - Line ~430
|
||||
toast.error("Terjadi kesalahan saat menghapus posisi organisasi");
|
||||
|
||||
// Edit - Line ~520
|
||||
toast.error("Gagal memuat data");
|
||||
|
||||
// Update - Line ~560
|
||||
toast.error("Gagal mengupdate posisi organisasi");
|
||||
```
|
||||
|
||||
**Issue:**
|
||||
- Generic error messages
|
||||
- Inconsistent patterns ("Terjadi kesalahan" vs "Gagal")
|
||||
- Tidak spesifik ke resource type
|
||||
|
||||
**Rekomendasi:** Standardisasi error messages:
|
||||
|
||||
```typescript
|
||||
// Pattern: "[Action] [resource] gagal"
|
||||
toast.error("Menambahkan Posisi Organisasi gagal");
|
||||
toast.error("Menghapus Posisi Organisasi gagal");
|
||||
toast.error("Memuat data Posisi Organisasi gagal");
|
||||
toast.error("Memperbarui data Posisi Organisasi gagal");
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **7. Zod Schema - Error Message Tidak Konsisten**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/struktur_ppid/struktur_PPID.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~170
|
||||
const templatePosisiOrganisasi = z.object({
|
||||
nama: z.string().min(1, "Nama harus diisi"), // ✅ OK
|
||||
deskripsi: z.string().optional(), // ⚠️ No min message
|
||||
hierarki: z.number().int().positive("Hierarki harus angka positif"), // ✅ OK
|
||||
});
|
||||
|
||||
// Line ~450
|
||||
const templatePegawai = z.object({
|
||||
namaLengkap: z.string().min(1, "Nama wajib diisi"), // ✅ OK
|
||||
gelarAkademik: z.string().min(1, "Gelar Akademik wajib diisi"), // ✅ OK
|
||||
imageId: z.string().min(1, "Gambar wajib dipilih"), // ✅ OK
|
||||
tanggalMasuk: z.string().min(1, "Tanggal masuk wajib diisi"), // ✅ OK
|
||||
email: z.string().email("Email tidak valid").optional(), // ⚠️ Optional tapi ada validation
|
||||
telepon: z.string().min(1, "Telepom wajib diisi"), // ❌ Typo: "Telepom"
|
||||
alamat: z.string().min(1, "Alamat wajib diisi"), // ✅ OK
|
||||
posisiId: z.string().min(1, "Posisi wajib diisi"), // ✅ OK
|
||||
isActive: z.boolean().default(true), // ✅ OK
|
||||
});
|
||||
```
|
||||
|
||||
**Rekomendasi:** Fix typo dan standardisasi:
|
||||
|
||||
```typescript
|
||||
const templatePegawai = z.object({
|
||||
namaLengkap: z.string().min(1, "Nama lengkap wajib diisi"),
|
||||
gelarAkademik: z.string().min(1, "Gelar akademik wajib diisi"),
|
||||
imageId: z.string().min(1, "Foto profil wajib diunggah"),
|
||||
tanggalMasuk: z.string().min(1, "Tanggal masuk wajib diisi"),
|
||||
email: z.string().email("Format email tidak valid").optional().or(z.literal('')),
|
||||
telepon: z.string().min(1, "Nomor telepon wajib diisi"), // ✅ Fix typo
|
||||
alamat: z.string().min(1, "Alamat wajib diisi"),
|
||||
posisiId: z.string().min(1, "Posisi wajib dipilih"),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟢 LOW (Minor Polish)**
|
||||
|
||||
#### **8. Pagination onChange Tidak Include Search**
|
||||
|
||||
**Lokasi:** `pegawai/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~170
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10); // ⚠️ Missing search parameter
|
||||
window.scrollTo(0, 0);
|
||||
}}
|
||||
total={totalPages}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
**Issue:** Saat ganti page, search query hilang.
|
||||
|
||||
**Rekomendasi:** Include search:
|
||||
```typescript
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10, debouncedSearch); // ✅ Include search
|
||||
window.scrollTo(0, 0);
|
||||
}}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **9. Missing Loading State di Submit Button**
|
||||
|
||||
**Lokasi:** `pegawai/create/page.tsx`, `pegawai/[id]/edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// create/page.tsx - Line ~240
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
size="md"
|
||||
disabled={!isFormValid() || isSubmitting}
|
||||
// ...
|
||||
>
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Issue:** Button tidak check `stateOrganisasi.create.loading` dari global state.
|
||||
|
||||
**Rekomendasi:** Check both states:
|
||||
```typescript
|
||||
disabled={!isFormValid() || isSubmitting || stateOrganisasi.create.loading}
|
||||
{isSubmitting || stateOrganisasi.create.loading ? (
|
||||
<Loader size="sm" color="white" />
|
||||
) : (
|
||||
'Simpan'
|
||||
)}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **10. Duplicate Error Logging**
|
||||
|
||||
**Lokasi:** Multiple files
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// edit/page.tsx - Line ~120
|
||||
} catch (error) {
|
||||
console.error('Error loading pegawai:', error); // ❌ Duplicate
|
||||
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui data pegawai');
|
||||
}
|
||||
|
||||
// edit/page.tsx - Line ~160
|
||||
} catch (error) {
|
||||
console.error('Error updating pegawai:', error); // ❌ Duplicate
|
||||
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui data pegawai');
|
||||
}
|
||||
```
|
||||
|
||||
**Rekomendasi:** Cukup satu logging yang informatif:
|
||||
```typescript
|
||||
} catch (error) {
|
||||
console.error('Failed to load Pegawai:', err);
|
||||
toast.error('Gagal memuat data Pegawai');
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **11. Button Label Inconsistency**
|
||||
|
||||
**Lokasi:** Multiple files
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// create/page.tsx - Line ~230
|
||||
<Button ...>Reset</Button>
|
||||
|
||||
// edit/page.tsx - Line ~140
|
||||
<Button ...>Batal</Button>
|
||||
|
||||
// Should be consistent: "Reset" atau "Batal"
|
||||
```
|
||||
|
||||
**Rekomendasi:** Standardisasi:
|
||||
```typescript
|
||||
// Create: "Reset"
|
||||
// Edit: "Batal" (lebih descriptive untuk cancel changes)
|
||||
// OR both: "Reset" / "Batal"
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **12. Search Placeholder Tidak Spesifik**
|
||||
|
||||
**Lokasi:**
|
||||
- `pegawai/page.tsx`: `placeholder='Cari nama pegawai atau posisi...'` ✅ Spesifik
|
||||
- `posisi-organisasi/page.tsx`: `placeholder='Cari posisi organisasi...'` ✅ OK
|
||||
|
||||
**Verdict:** ✅ **SUDAH BENAR** - Placeholder sudah spesifik.
|
||||
|
||||
**Priority:** 🟢 None
|
||||
**Effort:** None
|
||||
|
||||
---
|
||||
|
||||
#### **13. Non-Active Endpoint Method**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/struktur_ppid/struktur_PPID.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~490
|
||||
nonActive: {
|
||||
loading: false,
|
||||
async byId(id: string) {
|
||||
// ...
|
||||
const res = await fetch(`/api/ppid/strukturppid/pegawai/non-active/${id}`, {
|
||||
method: "DELETE", // ⚠️ Biasanya nonActive pakai PATCH atau PUT
|
||||
});
|
||||
// ...
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** Method "DELETE" untuk non-active agak confusing. Biasanya pakai "PATCH" atau "PUT".
|
||||
|
||||
**Rekomendasi:** Consider using PATCH:
|
||||
```typescript
|
||||
const res = await fetch(`/api/ppid/strukturppid/pegawai/non-active/${id}`, {
|
||||
method: "PATCH", // ✅ More semantic for toggle active/inactive
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isActive: false }),
|
||||
});
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low (perlu update API juga)
|
||||
|
||||
---
|
||||
|
||||
#### **14. OrganizationChart - Missing Expand/Collapse Controls**
|
||||
|
||||
**Lokasi:** `struktur-organisasi/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~80
|
||||
<OrganizationChart value={chartData} nodeTemplate={nodeTemplate} />
|
||||
```
|
||||
|
||||
**Issue:** Tidak ada controls untuk expand/collapse all nodes.
|
||||
|
||||
**Rekomendasi:** Add toggle button:
|
||||
```typescript
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
const toggleAll = () => {
|
||||
const newExpanded = !expanded;
|
||||
setExpanded(newExpanded);
|
||||
// Update chartData dengan expanded: newExpanded untuk semua nodes
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Group justify="flex-end" mb="md">
|
||||
<Button size="xs" onClick={toggleAll}>
|
||||
{expanded ? 'Collapse All' : 'Expand All'}
|
||||
</Button>
|
||||
</Group>
|
||||
<OrganizationChart value={chartData} nodeTemplate={nodeTemplate} />
|
||||
</Box>
|
||||
);
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
## 📋 RINGKASAN ACTION ITEMS
|
||||
|
||||
| Priority | Issue | Module | Impact | Effort | Status |
|
||||
|----------|-------|--------|--------|--------|--------|
|
||||
| 🔴 P0 | **Schema missing deletedAt** | Schema | **HIGH** | Medium | **MUST FIX** |
|
||||
| 🔴 P0 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
|
||||
| 🔴 P1 | **HTML injection risk** | UI | **HIGH (Security)** | Low | **Should fix** |
|
||||
| 🟡 M | Console.log in production | State | Low | Low | Optional |
|
||||
| 🟡 M | Type safety (any usage) | State | Low | Low | Optional |
|
||||
| 🟡 M | Error message inconsistency | State/UI | Low | Low | Optional |
|
||||
| 🟡 M | Zod schema typo ("Telepom") | State | Low | Low | Should fix |
|
||||
| 🟢 L | Pagination missing search param | Pegawai UI | Low | Low | Should fix |
|
||||
| 🟢 L | Missing loading state di submit button | UI | Low | Low | Optional |
|
||||
| 🟢 L | Duplicate error logging | UI | Low | Low | Optional |
|
||||
| 🟢 L | Button label inconsistency | UI | Low | Low | Optional |
|
||||
| 🟢 L | Non-active endpoint method | API | Low | Low | Optional |
|
||||
| 🟢 L | OrganizationChart expand/collapse controls | UI | Low | Low | Nice to have |
|
||||
|
||||
---
|
||||
|
||||
## ✅ KESIMPULAN
|
||||
|
||||
### **Overall Quality: 🟢 BAIK (8/10)**
|
||||
|
||||
**Strengths:**
|
||||
1. ✅ **Organization Chart** - Unique visual hierarchy feature (EXCELLENT!)
|
||||
2. ✅ UI/UX clean & responsive
|
||||
3. ✅ File upload handling solid
|
||||
4. ✅ Form validation comprehensive (email validation, required fields)
|
||||
5. ✅ State management terstruktur (Valtio)
|
||||
6. ✅ **Edit form reset sudah benar** (original data tracking)
|
||||
7. ✅ **Active/Non-active toggle** untuk pegawai
|
||||
8. ✅ Loading state management dengan finally block
|
||||
9. ✅ findManyAll untuk organization chart data
|
||||
|
||||
**Critical Issues:**
|
||||
1. ⚠️ **Schema missing deletedAt** - Inconsistency dengan StrukturOrganisasiPPID (HIGH)
|
||||
2. ⚠️ Fetch method pattern inconsistency (ApiFetch vs fetch manual)
|
||||
3. ⚠️ **HTML injection risk** di deskripsi posisi (HIGH Security)
|
||||
|
||||
**Areas for Improvement:**
|
||||
1. ⚠️ **Add deletedAt field** ke PosisiOrganisasiPPID dan PegawaiPPID
|
||||
2. ⚠️ **Refactor fetch methods** untuk gunakan ApiFetch consistently
|
||||
3. ⚠️ **Fix HTML injection** dengan DOMPurify atau backend validation
|
||||
4. ⚠️ **Fix typo** "Telepom" → "Telepon" di Zod schema
|
||||
5. ⚠️ **Improve type safety** dengan remove `any` usage
|
||||
|
||||
**Recommended Next Steps:**
|
||||
1. **🔴 CRITICAL: Add schema deletedAt** - 30 menit (perlu migration)
|
||||
2. **🔴 HIGH: Fix HTML injection** dengan DOMPurify - 30 menit
|
||||
3. **🔴 HIGH: Refactor fetch methods** ke ApiFetch - 1 jam
|
||||
4. **🟡 MEDIUM: Fix typo** di Zod schema - 5 menit
|
||||
5. **🟢 LOW: Add pagination search param** - 10 menit
|
||||
6. **🟢 LOW: Polish minor issues** - 30 menit
|
||||
|
||||
---
|
||||
|
||||
## 📈 COMPARISON WITH OTHER MODULES
|
||||
|
||||
| Module | Unique Features | Schema | State | Edit Reset | Overall |
|
||||
|--------|----------------|--------|-------|------------|---------|
|
||||
| Profil | ❌ None | ✅ Good | ⚠️ Good | ✅ Good | 🟢 |
|
||||
| Desa Anti Korupsi | ❌ None | ⚠️ deletedAt | ⚠️ Good | ✅ Good | 🟢 |
|
||||
| SDGs Desa | ❌ None | ⚠️ deletedAt | ⚠️ Good | ✅ Good | 🟢 |
|
||||
| APBDes | ✅ Dual upload, Items hierarchy | ✅ **Best** | ⚠️ Good | ✅ Good | 🟢 |
|
||||
| Prestasi Desa | ❌ None | ⚠️ deletedAt | ⚠️ Good | ✅ Good | 🟢 |
|
||||
| PPID Profil | ✅ Rich Text, Modular forms | ⚠️ deletedAt | ✅ **Best** | ✅ **Excellent** | 🟢⭐ |
|
||||
| **Struktur PPID** | ✅ **Org Chart**, Hierarchy, Non-active | ⚠️ Inconsistent | ✅ Good | ✅ Good | 🟢 |
|
||||
|
||||
**Struktur PPID Highlights:**
|
||||
- ✅ **UNIQUE:** Organization Chart visualization (no other module has this!)
|
||||
- ✅ **UNIQUE:** Hierarchical position structure (parent-child)
|
||||
- ✅ **UNIQUE:** Active/Non-active toggle feature
|
||||
- ✅ **GOOD:** Email validation dengan regex
|
||||
- ⚠️ **ISSUE:** Schema inconsistency (deletedAt missing di 2 models)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 UNIQUE FEATURES OF STRUKTUR PPID MODULE
|
||||
|
||||
**Most Unique Module:**
|
||||
1. ✅ **PrimeReact OrganizationChart** - Visual tree hierarchy (UNIQUE!)
|
||||
2. ✅ **Parent-child position relationships** - Hierarchical structure
|
||||
3. ✅ **Active/Non-active toggle** - Soft disable tanpa delete
|
||||
4. ✅ **Email validation** - Regex validation untuk email format
|
||||
5. ✅ **findManyAll pattern** - Load all data untuk organization chart
|
||||
|
||||
**Best Practices:**
|
||||
1. ✅ Organization chart implementation excellent
|
||||
2. ✅ Loading state management proper (dengan finally block)
|
||||
3. ✅ Edit form reset comprehensive (original data tracking)
|
||||
4. ✅ Email validation di form (create & edit)
|
||||
5. ✅ Date input handling untuk tanggal masuk
|
||||
|
||||
**Critical Issues:**
|
||||
1. ❌ **Schema deletedAt missing** - Inconsistency issue
|
||||
2. ❌ **HTML injection risk** - Same issue as modul lain dengan rich text
|
||||
|
||||
---
|
||||
|
||||
**Catatan:** Secara keseluruhan, modul **Struktur PPID adalah YANG PALING UNIQUE** dengan Organization Chart visualization yang excellent. Module ini punya fitur-fitur yang tidak ada di modul lain (hierarchical positions, org chart, active/non-active toggle).
|
||||
|
||||
**Unique Strengths:**
|
||||
1. ✅ **Organization Chart** - Best visual representation
|
||||
2. ✅ **Hierarchical data structure** - Parent-child relationships
|
||||
3. ✅ **Active/Non-active feature** - Soft disable tanpa delete
|
||||
4. ✅ **Email validation** - Comprehensive form validation
|
||||
|
||||
**Priority Action:**
|
||||
```diff
|
||||
🔴 FIX INI SEKARANG (30 MENIT + MIGRATION):
|
||||
File: prisma/schema.prisma
|
||||
Line: 327-332, 343-351
|
||||
|
||||
model PosisiOrganisasiPPID {
|
||||
// ...
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
+ deletedAt DateTime? @default(null) // ✅ Add for soft delete
|
||||
}
|
||||
|
||||
model PegawaiPPID {
|
||||
// ...
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
+ deletedAt DateTime? @default(null) // ✅ Add for soft delete
|
||||
}
|
||||
|
||||
# Lalu jalankan:
|
||||
bunx prisma db push
|
||||
# atau
|
||||
bunx prisma migrate dev --name add_deletedat_struktur_ppid
|
||||
```
|
||||
|
||||
```diff
|
||||
🔴 FIX HTML INJECTION (30 MENIT):
|
||||
File: posisi-organisasi/page.tsx
|
||||
+ import DOMPurify from 'dompurify';
|
||||
|
||||
// Line ~95
|
||||
- dangerouslySetInnerHTML={{ __html: item.deskripsi || '-' }}
|
||||
+ dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.deskripsi) }}
|
||||
|
||||
// Repeat for mobile view line ~155
|
||||
```
|
||||
|
||||
Setelah fix critical issues, module ini **PRODUCTION-READY** dan **ORGANIZATION CHART** adalah fitur yang bisa jadi **SHOWCASE**! 🎉
|
||||
|
||||
---
|
||||
|
||||
**File Location:** `QC/PPID/QC-STRUKTUR-PPID-MODULE.md` 📄
|
||||
@@ -1,797 +0,0 @@
|
||||
# QC Summary - Visi Misi PPID Module
|
||||
|
||||
**Scope:** Preview Visi Misi, Edit Visi Misi dengan Rich Text Editor
|
||||
**Date:** 2026-02-23
|
||||
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
|
||||
|
||||
---
|
||||
|
||||
## 📊 OVERVIEW
|
||||
|
||||
| Aspect | Schema | API | UI Admin | State Management | Overall |
|
||||
|--------|--------|-----|----------|-----------------|---------|
|
||||
| Visi Misi PPID | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ✅ Baik | 🟡 Perlu fix |
|
||||
|
||||
---
|
||||
|
||||
## ✅ YANG SUDAH BAIK
|
||||
|
||||
### **1. UI/UX Design**
|
||||
- ✅ Preview layout yang clean dengan logo desa
|
||||
- ✅ Responsive design (mobile & desktop)
|
||||
- ✅ Loading states dengan Skeleton
|
||||
- ✅ Empty state handling yang informatif
|
||||
- ✅ Edit button yang prominent
|
||||
- ✅ Divider visual yang jelas antara Visi dan Misi
|
||||
|
||||
### **2. Rich Text Editor (Tiptap)**
|
||||
- ✅ Full-featured editor dengan toolbar lengkap
|
||||
- ✅ Extensions: Bold, Italic, Underline, Highlight, Link, dll
|
||||
- ✅ Text alignment (left, center, justify, right)
|
||||
- ✅ Heading levels (H1-H4)
|
||||
- ✅ Lists (bullet & ordered)
|
||||
- ✅ Blockquote, code, superscript, subscript
|
||||
- ✅ Undo/Redo
|
||||
- ✅ Sticky toolbar untuk UX yang lebih baik
|
||||
- ✅ `immediatelyRender: false` untuk menghindari hydration mismatch
|
||||
|
||||
### **3. Form Component Structure**
|
||||
- ✅ Modular form components (VisiPPID, MisiPPID)
|
||||
- ✅ Reusable PPIDTextEditor component
|
||||
- ✅ Proper TypeScript typing
|
||||
- ✅ Controlled components dengan onChange handler
|
||||
|
||||
### **4. State Management**
|
||||
- ✅ Proper typing dengan Prisma types
|
||||
- ✅ Loading state management dengan finally block
|
||||
- ✅ Error handling yang comprehensive
|
||||
- ✅ **ApiFetch consistency** - Semua operasi pakai ApiFetch! ✅
|
||||
- ✅ Zod validation untuk form data
|
||||
|
||||
**Code Example (✅ EXCELLENT):**
|
||||
```typescript
|
||||
// state file - Line ~30-50
|
||||
findById: {
|
||||
data: null as VisiMisiPPIDForm | null,
|
||||
loading: false,
|
||||
initialize() {
|
||||
stateVisiMisiPPID.findById.data = {
|
||||
id: "",
|
||||
misi: "",
|
||||
visi: "",
|
||||
} as VisiMisiPPIDForm;
|
||||
},
|
||||
async load(id: string) {
|
||||
try {
|
||||
stateVisiMisiPPID.findById.loading = true; // ✅ Start loading
|
||||
const res = await ApiFetch.api.ppid.visimisippid["find-by-id"].get({
|
||||
query: { id },
|
||||
});
|
||||
if (res.status === 200) {
|
||||
stateVisiMisiPPID.findById.data = res.data?.data ?? null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error((error as Error).message);
|
||||
toast.error("Terjadi kesalahan saat mengambil data visi misi");
|
||||
} finally {
|
||||
stateVisiMisiPPID.findById.loading = false; // ✅ Stop loading
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **SANGAT BAIK** - State management sudah konsisten dengan ApiFetch!
|
||||
|
||||
---
|
||||
|
||||
### **5. Edit Form - Original Data Tracking**
|
||||
- ✅ Original data state untuk reset form
|
||||
- ✅ Load data existing dengan benar
|
||||
- ✅ Reset form mengembalikan ke data original
|
||||
- ✅ Rich text content handling yang proper
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// edit/page.tsx - Line ~20-45
|
||||
const [formData, setFormData] = useState({ visi: '', misi: '' });
|
||||
const [originalData, setOriginalData] = useState({ visi: '', misi: '' });
|
||||
|
||||
// Initialize from global state
|
||||
useEffect(() => {
|
||||
if (visiMisi.findById.data) {
|
||||
setFormData({
|
||||
visi: visiMisi.findById.data.visi ?? '',
|
||||
misi: visiMisi.findById.data.misi ?? '',
|
||||
});
|
||||
setOriginalData({
|
||||
visi: visiMisi.findById.data.visi ?? '',
|
||||
misi: visiMisi.findById.data.misi ?? '',
|
||||
});
|
||||
}
|
||||
}, [visiMisi.findById.data]);
|
||||
|
||||
// Line ~60 - Handle reset
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
visi: originalData.visi,
|
||||
misi: originalData.misi,
|
||||
});
|
||||
toast.info('Form dikembalikan ke data awal');
|
||||
};
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **BAIK** - Original data tracking sudah implementasi dengan baik!
|
||||
|
||||
---
|
||||
|
||||
### **6. Rich Text Validation**
|
||||
- ✅ Custom validation function untuk rich text content
|
||||
- ✅ Check empty content setelah remove HTML tags
|
||||
|
||||
**Code Example (✅ GOOD):**
|
||||
```typescript
|
||||
// edit/page.tsx - Line ~25-35
|
||||
const isRichTextEmpty = (content: string) => {
|
||||
// Remove HTML tags and check if the resulting text is empty
|
||||
const plainText = content.replace(/<[^>]*>/g, '').trim();
|
||||
return plainText === '' || content.trim() === '<p></p>' || content.trim() === '<p><br></p>';
|
||||
};
|
||||
|
||||
const isFormValid = () => {
|
||||
return (
|
||||
!isRichTextEmpty(formData.visi) &&
|
||||
!isRichTextEmpty(formData.misi)
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**Verdict:** ✅ **EXCELLENT** - Rich text validation yang comprehensive!
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ ISSUES & SARAN PERBAIKAN
|
||||
|
||||
### **🔴 CRITICAL**
|
||||
|
||||
#### **1. Schema - deletedAt Default Value SALAH**
|
||||
|
||||
**Lokasi:** `prisma/schema.prisma` (line 374)
|
||||
|
||||
**Masalah:**
|
||||
```prisma
|
||||
model VisiMisiPPID {
|
||||
id String @id @default(cuid())
|
||||
visi String @db.Text
|
||||
misi String @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now()) // ❌ SALAH - selalu punya default value
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Dampak:**
|
||||
- **LOGIC ERROR!** Setiap record baru langsung punya `deletedAt` value (timestamp creation)
|
||||
- Soft delete tidak berfungsi dengan benar
|
||||
- Query dengan `where: { deletedAt: null }` tidak akan pernah return data
|
||||
- Data yang "dihapus" vs data "aktif" tidak bisa dibedakan
|
||||
|
||||
**Contoh Issue:**
|
||||
```prisma
|
||||
// Record baru dibuat
|
||||
CREATE VisiMisiPPID {
|
||||
visi: "Visi 1",
|
||||
misi: "Misi 1",
|
||||
// deletedAt otomatis ter-set ke now() ❌
|
||||
// isActive: true ✅
|
||||
}
|
||||
|
||||
// Query untuk data aktif (seharusnya return data ini)
|
||||
prisma.visiMisiPPID.findMany({
|
||||
where: { deletedAt: null, isActive: true }
|
||||
})
|
||||
// ❌ Return kosong! Karena deletedAt sudah ter-set
|
||||
```
|
||||
|
||||
**Rekomendasi:** Fix schema:
|
||||
```prisma
|
||||
model VisiMisiPPID {
|
||||
id String @id @default(cuid())
|
||||
visi String @db.Text
|
||||
misi String @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🔴 **CRITICAL**
|
||||
**Effort:** Medium (perlu migration)
|
||||
**Impact:** **HIGH** (data integrity & soft delete logic)
|
||||
|
||||
---
|
||||
|
||||
#### **2. HTML Injection Risk - dangerouslySetInnerHTML**
|
||||
|
||||
**Lokasi:** `page.tsx` (preview page)
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~85-95
|
||||
<Text
|
||||
ta={{ base: "center", md: "justify" }}
|
||||
dangerouslySetInnerHTML={{ __html: listVisiMisi.findById.data.visi }} // ❌ No sanitization
|
||||
style={{ ... }}
|
||||
/>
|
||||
|
||||
// Line ~105-115 (Misi)
|
||||
<Text
|
||||
ta={"justify"}
|
||||
dangerouslySetInnerHTML={{ __html: listVisiMisi.findById.data.misi }} // ❌ No sanitization
|
||||
style={{ ... }}
|
||||
/>
|
||||
```
|
||||
|
||||
**Risk:**
|
||||
- XSS attack jika admin input script malicious
|
||||
- Bisa inject iframe, script tag, dll
|
||||
- Security vulnerability
|
||||
|
||||
**Rekomendasi:** Gunakan DOMPurify atau library sanitization:
|
||||
|
||||
```typescript
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Sanitize sebelum render
|
||||
const sanitizedVisi = DOMPurify.sanitize(listVisiMisi.findById.data.visi);
|
||||
const sanitizedMisi = DOMPurify.sanitize(listVisiMisi.findById.data.misi);
|
||||
|
||||
<Text
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedVisi }}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
Atau validasi di backend untuk whitelist tag HTML yang diperbolehkan (hanya `<p>`, `<ul>`, `<li>`, `<strong>`, dll).
|
||||
|
||||
**Priority:** 🔴 **HIGH** (**Security concern**)
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **3. Missing Delete/Hard Delete Protection**
|
||||
|
||||
**Lokasi:** `page.tsx`, `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
- ❌ Tidak ada tombol delete untuk Visi Misi (correct - single record)
|
||||
- ✅ **GOOD:** Single record pattern yang benar
|
||||
- ⚠️ **ISSUE:** Tidak ada konfirmasi sebelum update (direct save)
|
||||
|
||||
**Issue:** User bisa accidentally save changes tanpa konfirmasi.
|
||||
|
||||
**Rekomendasi:** Add confirmation dialog sebelum save:
|
||||
```typescript
|
||||
const submit = () => {
|
||||
// Check if data has changed
|
||||
if (formData.visi === originalData.visi && formData.misi === originalData.misi) {
|
||||
toast.info('Tidak ada perubahan');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation
|
||||
const confirmed = window.confirm('Apakah Anda yakin ingin mengubah Visi Misi PPID?');
|
||||
if (!confirmed) return;
|
||||
|
||||
// Then save...
|
||||
};
|
||||
```
|
||||
|
||||
**Priority:** 🔴 Medium
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟡 MEDIUM**
|
||||
|
||||
#### **4. Console.log di Production**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/visi_misi_ppid/visimisiPPID.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~40
|
||||
console.error((error as Error).message);
|
||||
|
||||
// Line ~65
|
||||
console.error((error as Error).message);
|
||||
```
|
||||
|
||||
**Rekomendasi:** Gunakan conditional logging:
|
||||
|
||||
```typescript
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error("Error:", error);
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **5. Missing Loading State di Submit Button**
|
||||
|
||||
**Lokasi:** `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~120-130
|
||||
<Button
|
||||
onClick={submit}
|
||||
radius="md"
|
||||
size="md"
|
||||
disabled={!isFormValid() || isSubmitting}
|
||||
// ...
|
||||
>
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Issue:** Button tidak check `visiMisi.update.loading` dari global state.
|
||||
|
||||
**Rekomendasi:** Check both states:
|
||||
```typescript
|
||||
disabled={!isFormValid() || isSubmitting || visiMisi.update.loading}
|
||||
{isSubmitting || visiMisi.update.loading ? (
|
||||
<Loader size="sm" color="white" />
|
||||
) : (
|
||||
'Simpan'
|
||||
)}
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **6. Zod Schema - Could Be More Specific**
|
||||
|
||||
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/visi_misi_ppid/visimisiPPID.ts`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~7
|
||||
const templateForm = z.object({
|
||||
misi: z.string().min(3, "Misi minimal 3 karakter"), // ⚠️ Generic
|
||||
visi: z.string().min(3, "Visi minimal 3 karakter"), // ⚠️ Generic
|
||||
});
|
||||
```
|
||||
|
||||
**Rekomendasi:** More specific error messages:
|
||||
```typescript
|
||||
const templateForm = z.object({
|
||||
misi: z.string().min(3, "Misi PPID minimal 3 karakter"),
|
||||
visi: z.string().min(3, "Visi PPID minimal 3 karakter"),
|
||||
});
|
||||
```
|
||||
|
||||
**Priority:** 🟡 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
### **🟢 LOW (Minor Polish)**
|
||||
|
||||
#### **7. Missing Change Detection**
|
||||
|
||||
**Lokasi:** `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~70-80
|
||||
const submit = () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
if (visiMisi.findById.data) {
|
||||
// update nilai global hanya saat submit
|
||||
visiMisi.findById.data.visi = formData.visi;
|
||||
visiMisi.findById.data.misi = formData.misi;
|
||||
visiMisi.update.save(visiMisi.findById.data);
|
||||
}
|
||||
router.push('/admin/ppid/visi-misi-ppid');
|
||||
} catch (error) {
|
||||
console.error("Error updating visi misi:", error);
|
||||
toast.error("Terjadi kesalahan saat memperbarui visi misi");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Issue:** Tidak ada check apakah data sudah berubah. User bisa save tanpa perubahan.
|
||||
|
||||
**Rekomendasi:** Add change detection:
|
||||
```typescript
|
||||
const submit = () => {
|
||||
// Check if data has changed
|
||||
if (formData.visi === originalData.visi && formData.misi === originalData.misi) {
|
||||
toast.info('Tidak ada perubahan');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
// ... rest of save logic
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **8. Editor - Duplicate useEffect**
|
||||
|
||||
**Lokasi:** `PPIDTextEditor.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~30-35
|
||||
const editor = useEditor({
|
||||
extensions: [...],
|
||||
immediatelyRender: false,
|
||||
content: initialContent, // ✅ Set content directly
|
||||
onUpdate: ({editor}) => {
|
||||
onChange(editor.getHTML()) // ✅ Handle changes
|
||||
}
|
||||
});
|
||||
|
||||
// Line ~37-42
|
||||
useEffect(() => {
|
||||
if (editor && initialContent !== editor.getHTML()) {
|
||||
editor.commands.setContent(initialContent || '<p></p>');
|
||||
}
|
||||
}, [initialContent, editor]);
|
||||
```
|
||||
|
||||
**Issue:** Ada useEffect tambahan untuk set content, padahal sudah ada di `useEditor`. Bisa menyebabkan double content update.
|
||||
|
||||
**Rekomendasi:** Simplify - remove useEffect:
|
||||
```typescript
|
||||
const editor = useEditor({
|
||||
extensions: [...],
|
||||
immediatelyRender: false,
|
||||
content: initialContent || '<p></p>', // ✅ Set content directly
|
||||
onUpdate: ({editor}) => {
|
||||
onChange(editor.getHTML())
|
||||
},
|
||||
editorProps: {
|
||||
// Optional: handle content updates better
|
||||
}
|
||||
});
|
||||
|
||||
// Remove useEffect completely
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **9. Missing Error Boundary**
|
||||
|
||||
**Lokasi:** `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
- Tidak ada error boundary untuk handle unexpected errors
|
||||
- Jika editor gagal load, tidak ada fallback UI
|
||||
|
||||
**Rekomendasi:** Add error boundary:
|
||||
```typescript
|
||||
if (visiMisi.findById.error) {
|
||||
return (
|
||||
<Alert icon={<IconAlertCircle />} color="red">
|
||||
<Text fw="bold">Error</Text>
|
||||
<Text>{visiMisi.findById.error}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **10. Preview Page - Hardcoded Moto PPID**
|
||||
|
||||
**Lokasi:** `page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~60-70
|
||||
<Text
|
||||
ta="center"
|
||||
fz={{ base: 'sm', md: 'md' }}
|
||||
lh={{ base: 1.5, md: 1.5 }}
|
||||
mt="sm"
|
||||
c="black"
|
||||
>
|
||||
MEMBERIKAN INFORMASI YANG CEPAT, MUDAH, TEPAT DAN TRANSPARAN
|
||||
</Text>
|
||||
```
|
||||
|
||||
**Issue:** Moto PPID hardcoded di UI. Seharusnya dari database/config.
|
||||
|
||||
**Rekomendasi:** Move to database or config file:
|
||||
```typescript
|
||||
// Add to schema
|
||||
model VisiMisiPPID {
|
||||
// ...
|
||||
moto String? @db.Text
|
||||
}
|
||||
|
||||
// Or use config
|
||||
const PPID_MOTO = "MEMBERIKAN INFORMASI YANG CEPAT, MUDAH, TEPAT DAN TRANSPARAN";
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Medium (perlu schema change)
|
||||
|
||||
---
|
||||
|
||||
#### **11. Title Order Inconsistency**
|
||||
|
||||
**Lokasi:** `page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~45
|
||||
<Title order={3} ...>Preview Visi Misi PPID</Title>
|
||||
|
||||
// Line ~65
|
||||
<Title order={2} ...>MOTO PPID DESA DARMASABA</Title>
|
||||
|
||||
// Line ~80
|
||||
<Title order={2} ...>VISI PPID</Title>
|
||||
|
||||
// Line ~100
|
||||
<Title order={2} ...>MISI PPID</Title>
|
||||
```
|
||||
|
||||
**Issue:** Title hierarchy agak confusing. Page title (order 3) lebih kecil dari section titles (order 2).
|
||||
|
||||
**Rekomendasi:** Samakan hierarchy:
|
||||
```typescript
|
||||
// Page title: order={2}
|
||||
// Section titles: order={3}
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
#### **12. Missing Toast Success After Save**
|
||||
|
||||
**Lokasi:** `edit/page.tsx`
|
||||
|
||||
**Masalah:**
|
||||
```typescript
|
||||
// Line ~70-85
|
||||
const submit = () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
if (visiMisi.findById.data) {
|
||||
visiMisi.findById.data.visi = formData.visi;
|
||||
visiMisi.findById.data.misi = formData.misi;
|
||||
visiMisi.update.save(visiMisi.findById.data);
|
||||
}
|
||||
router.push('/admin/ppid/visi-misi-ppid'); // ✅ Redirect tanpa toast
|
||||
} catch (error) {
|
||||
console.error("Error updating visi misi:", error);
|
||||
toast.error("Terjadi kesalahan saat memperbarui visi misi");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Issue:** Toast success ada di state `update.save()`, tapi user mungkin tidak lihat karena langsung redirect.
|
||||
|
||||
**Rekomendasi:** Add toast before redirect atau wait untuk toast selesai:
|
||||
```typescript
|
||||
const submit = async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
if (visiMisi.findById.data) {
|
||||
visiMisi.findById.data.visi = formData.visi;
|
||||
visiMisi.findById.data.misi = formData.misi;
|
||||
await visiMisi.update.save(visiMisi.findById.data);
|
||||
toast.success("Visi Misi berhasil diperbarui!");
|
||||
setTimeout(() => {
|
||||
router.push('/admin/ppid/visi-misi-ppid');
|
||||
}, 1000); // Wait 1 second for toast to show
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating visi misi:", error);
|
||||
toast.error("Terjadi kesalahan saat memperbarui visi misi");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Priority:** 🟢 Low
|
||||
**Effort:** Low
|
||||
|
||||
---
|
||||
|
||||
## 📋 RINGKASAN ACTION ITEMS
|
||||
|
||||
| Priority | Issue | Module | Impact | Effort | Status |
|
||||
|----------|-------|--------|--------|--------|--------|
|
||||
| 🔴 P0 | **Schema deletedAt default SALAH** | Schema | **CRITICAL** | Medium | **MUST FIX** |
|
||||
| 🔴 P0 | **HTML injection risk** | UI | **HIGH (Security)** | Low | **Should fix** |
|
||||
| 🔴 P1 | Missing delete confirmation | UI | Medium | Low | Should fix |
|
||||
| 🟡 M | Console.log in production | State | Low | Low | Optional |
|
||||
| 🟡 M | Missing loading state di submit button | UI | Low | Low | Should fix |
|
||||
| 🟡 M | Zod schema error messages | State | Low | Low | Optional |
|
||||
| 🟢 L | Missing change detection | Edit UI | Low | Low | Optional |
|
||||
| 🟢 L | Duplicate useEffect di editor | Editor | Low | Low | Optional |
|
||||
| 🟢 L | Missing error boundary | UI | Low | Low | Optional |
|
||||
| 🟢 L | Hardcoded Moto PPID | UI | Low | Medium | Optional |
|
||||
| 🟢 L | Title order inconsistency | UI | Low | Low | Optional |
|
||||
| 🟢 L | Missing toast success timing | UI | Low | Low | Optional |
|
||||
|
||||
---
|
||||
|
||||
## ✅ KESIMPULAN
|
||||
|
||||
### **Overall Quality: 🟢 BAIK (8.5/10) - CLEANEST MODULE!**
|
||||
|
||||
**Strengths:**
|
||||
1. ✅ UI/UX clean & responsive
|
||||
2. ✅ **Rich Text Editor** full-featured (Tiptap)
|
||||
3. ✅ **Modular form components** (Visi, Misi)
|
||||
4. ✅ **State management BEST PRACTICES** - **ONLY MODULE YANG 100% ApiFetch!** ✅
|
||||
5. ✅ **Edit form reset sudah benar** (original data tracking)
|
||||
6. ✅ **Rich text validation** comprehensive (check empty content)
|
||||
7. ✅ Error handling comprehensive
|
||||
8. ✅ Loading state management dengan finally block
|
||||
9. ✅ `immediatelyRender: false` untuk menghindari hydration mismatch
|
||||
|
||||
**Critical Issues:**
|
||||
1. ⚠️ **Schema deletedAt default SALAH** - Logic error untuk soft delete (CRITICAL)
|
||||
2. ⚠️ **HTML injection risk** - dangerouslySetInnerHTML tanpa sanitization (HIGH Security)
|
||||
3. ⚠️ Missing confirmation sebelum save (Medium UX)
|
||||
|
||||
**Areas for Improvement:**
|
||||
1. ⚠️ **Fix schema deletedAt** dari `@default(now())` ke `@default(null)` dengan nullable
|
||||
2. ⚠️ **Fix HTML injection** dengan DOMPurify atau backend validation
|
||||
3. ⚠️ **Add confirmation dialog** sebelum save
|
||||
4. ⚠️ **Add change detection** untuk avoid unnecessary saves
|
||||
5. ⚠️ **Fix loading state** di submit button
|
||||
|
||||
**Recommended Next Steps:**
|
||||
1. **🔴 CRITICAL: Fix schema deletedAt** - 30 menit (perlu migration)
|
||||
2. **🔴 HIGH: Fix HTML injection** dengan DOMPurify - 30 menit
|
||||
3. **🟡 MEDIUM: Add confirmation dialog** - 15 menit
|
||||
4. **🟢 LOW: Add change detection** - 15 menit
|
||||
5. **🟢 LOW: Polish minor issues** - 30 menit
|
||||
|
||||
---
|
||||
|
||||
## 📈 COMPARISON WITH OTHER MODULES
|
||||
|
||||
| Module | Fetch Pattern | State | Edit Reset | Rich Text | HTML Injection | deletedAt | Overall |
|
||||
|--------|--------------|-------|------------|-----------|----------------|-----------|---------|
|
||||
| Profil | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ None | ⚠️ Present | ⚠️ Issue | 🟢 |
|
||||
| Desa Anti Korupsi | ⚠️ Mixed | ⚠️ Good | ✅ Good | ✅ Present | ⚠️ Present | ⚠️ Issue | 🟢 |
|
||||
| SDGs Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ None | N/A | ⚠️ Issue | 🟢 |
|
||||
| APBDes | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ None | N/A | ✅ Good | 🟢 |
|
||||
| Prestasi Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ✅ Present | ⚠️ Present | ❌ WRONG | 🟢 |
|
||||
| PPID Profil | ⚠️ Mixed | ✅ **Best** | ✅ **Excellent** | ✅ **Best** | ⚠️ Present | ❌ WRONG | 🟢⭐ |
|
||||
| Struktur PPID | ⚠️ Mixed | ✅ Good | ✅ Good | ✅ Present | ⚠️ Present | ⚠️ Inconsistent | 🟢 |
|
||||
| **Visi Misi PPID** | ✅ **100% ApiFetch!** | ✅ **Best** | ✅ Good | ✅ Present | ⚠️ Present | ❌ WRONG | 🟢⭐⭐ |
|
||||
|
||||
**Visi Misi PPID Highlights:**
|
||||
- ✅ **ONLY MODULE** yang 100% konsisten pakai ApiFetch! (NO fetch manual!)
|
||||
- ✅ **CLEANEST CODE** - Simple, straightforward, no complexity
|
||||
- ✅ **Rich text validation** paling comprehensive (check empty content)
|
||||
- ✅ **Best state management** pattern (ApiFetch consistency)
|
||||
- ⚠️ **Same deletedAt issue** seperti modul PPID lain
|
||||
|
||||
---
|
||||
|
||||
## 🎯 UNIQUE FEATURES OF VISI MISI PPID MODULE
|
||||
|
||||
**Simplest & Cleanest Module:**
|
||||
1. ✅ **100% ApiFetch consistency** - NO fetch manual sama sekali! (UNIQUE!)
|
||||
2. ✅ **Simple single record pattern** - Only 2 fields (visi, misi)
|
||||
3. ✅ **Rich text validation** - Check empty content after remove HTML tags
|
||||
4. ✅ **Modular editor components** - VisiPPID, MisiPPID separate
|
||||
5. ✅ **No file upload** - Simplest form (text only)
|
||||
|
||||
**Best Practices:**
|
||||
1. ✅ **ApiFetch 100%** - Best practice untuk API consistency
|
||||
2. ✅ **Loading state management** proper (dengan finally block)
|
||||
3. ✅ **Rich text validation** comprehensive
|
||||
4. ✅ **Original data tracking** untuk reset form
|
||||
5. ✅ **`immediatelyRender: false`** - Avoid hydration mismatch
|
||||
|
||||
**Critical Issues:**
|
||||
1. ❌ **Schema deletedAt SALAH** - Same issue seperti modul PPID lain
|
||||
2. ❌ **HTML injection risk** - Same issue seperti modul dengan rich text lain
|
||||
|
||||
---
|
||||
|
||||
**Catatan:** **Visi Misi PPID adalah MODULE PALING CLEAN** dengan codebase paling simple dan **SATU-SATUNYA MODULE YANG 100% PAKAI ApiFetch** (no fetch manual sama sekali!). Module ini bisa jadi **REFERENCE** untuk API consistency!
|
||||
|
||||
**Unique Strengths:**
|
||||
1. ✅ **100% ApiFetch** - Best API consistency (NO fetch manual!)
|
||||
2. ✅ **Simple & clean** - No unnecessary complexity
|
||||
3. ✅ **Rich text validation** - Most comprehensive
|
||||
4. ✅ **Best state management** pattern
|
||||
|
||||
**Priority Action:**
|
||||
```diff
|
||||
🔴 FIX INI SEKARANG (30 MENIT + MIGRATION):
|
||||
File: prisma/schema.prisma
|
||||
Line: 374
|
||||
|
||||
model VisiMisiPPID {
|
||||
id String @id @default(cuid())
|
||||
visi String @db.Text
|
||||
misi String @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
- deletedAt DateTime @default(now())
|
||||
+ deletedAt DateTime? @default(null)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
# Lalu jalankan:
|
||||
bunx prisma db push
|
||||
# atau
|
||||
bunx prisma migrate dev --name fix_deletedat_visimisi_ppid
|
||||
```
|
||||
|
||||
```diff
|
||||
🔴 FIX HTML INJECTION (30 MENIT):
|
||||
File: page.tsx
|
||||
+ import DOMPurify from 'dompurify';
|
||||
|
||||
// Line ~85
|
||||
- dangerouslySetInnerHTML={{ __html: listVisiMisi.findById.data.visi }}
|
||||
+ dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(listVisiMisi.findById.data.visi) }}
|
||||
|
||||
// Line ~105
|
||||
- dangerouslySetInnerHTML={{ __html: listVisiMisi.findById.data.misi }}
|
||||
+ dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(listVisiMisi.findById.data.misi) }}
|
||||
```
|
||||
|
||||
Setelah fix critical issues, module ini **PRODUCTION-READY** dan bisa jadi **REFERENCE untuk API CONSISTENCY**! 🎉
|
||||
|
||||
---
|
||||
|
||||
## 📚 RECOMMENDED AS REFERENCE FOR OTHER MODULES
|
||||
|
||||
**Visi Misi PPID Module adalah BEST PRACTICE untuk:**
|
||||
1. ✅ **API consistency** - 100% ApiFetch, NO fetch manual!
|
||||
2. ✅ **Simple state management** - Clean, straightforward
|
||||
3. ✅ **Rich text validation** - Check empty content pattern
|
||||
4. ✅ **Modular editor components** - Separate Visi & Misi
|
||||
|
||||
**Modules lain bisa belajar dari Visi Misi PPID:**
|
||||
- **ALL MODULES:** Use ApiFetch consistently (NO fetch manual!)
|
||||
- **ALL MODULES:** Keep it simple (avoid unnecessary complexity)
|
||||
- **Rich Text Modules:** Implement empty content validation
|
||||
- **ALL MODULES:** Proper loading state management
|
||||
|
||||
---
|
||||
|
||||
**File Location:** `QC/PPID/QC-VISI-MISI-PPID-MODULE.md` 📄
|
||||
5
QWEN.md
5
QWEN.md
@@ -229,4 +229,7 @@ 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
|
||||
6. Verify responsive design on different screen sizes
|
||||
|
||||
## Qwen Added Memories
|
||||
- **GitHub Workflows**: Project ini memiliki workflow GitHub Action untuk deployment. User akan menangani workflow secara manual di GitHub.
|
||||
|
||||
49
biome.json
Normal file
49
biome.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
}
|
||||
169
darkMode.md
Normal file
169
darkMode.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# 🌙 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 (2–3px 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: 12–16px
|
||||
- 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.
|
||||
553
dev-inspector-click-to-source.md
Normal file
553
dev-inspector-click-to-source.md
Normal file
@@ -0,0 +1,553 @@
|
||||
# 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
|
||||
13
docker-entrypoint.sh
Normal file
13
docker-entrypoint.sh
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/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
|
||||
@@ -11,6 +11,11 @@ const compat = new FlatCompat({
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ['@elysiajs/static', 'elysia'],
|
||||
experimental: {},
|
||||
allowedDevOrigins: [
|
||||
"http://192.168.1.82:3000", // buat akses dari HP/device lain
|
||||
@@ -19,7 +20,6 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "desa-darmasaba",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -8,7 +8,8 @@
|
||||
"start": "next start",
|
||||
"test:api": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test": "bun run test:api && bun run test:e2e"
|
||||
"test": "bun run test:api && bun run test:e2e",
|
||||
"gen:api": ""
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "bun run prisma/seed.ts"
|
||||
@@ -33,7 +34,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",
|
||||
@@ -62,6 +63,7 @@
|
||||
"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",
|
||||
@@ -69,7 +71,7 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"form-data": "^4.0.2",
|
||||
"framer-motion": "^12.23.5",
|
||||
"framer-motion": "^12.38.0",
|
||||
"get-port": "^7.1.0",
|
||||
"iron-session": "^8.0.4",
|
||||
"jose": "^6.1.0",
|
||||
@@ -88,7 +90,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",
|
||||
@@ -99,7 +101,7 @@
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"readdirp": "^4.1.1",
|
||||
"recharts": "^2.15.3",
|
||||
"recharts": "^3.8.0",
|
||||
"sharp": "^0.34.3",
|
||||
"swr": "^2.3.2",
|
||||
"uuid": "^11.1.0",
|
||||
@@ -112,16 +114,18 @@
|
||||
"@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.1.6",
|
||||
"eslint-config-next": "15.5.12",
|
||||
"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",
|
||||
|
||||
57
prisma/_seeder_list/core/seed_app_menu.ts
Normal file
57
prisma/_seeder_list/core/seed_app_menu.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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");
|
||||
}
|
||||
69
prisma/_seeder_list/core/seed_core.ts
Normal file
69
prisma/_seeder_list/core/seed_core.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
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");
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import kategoriBerita from "../../../data/desa/berita/kategori-berita.json";
|
||||
import beritaJson from "../../../data/desa/berita/berita.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const kategoriBerita = loadJsonData("desa/berita/kategori-berita.json");
|
||||
const beritaJson = loadJsonData("desa/berita/berita.json");
|
||||
|
||||
export async function seedBerita() {
|
||||
// ================== SUBMENU BERITA ========================
|
||||
@@ -26,7 +28,24 @@ 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) {
|
||||
@@ -44,26 +63,32 @@ export async function seedBerita() {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Berita seeded: ${b.judul}`);
|
||||
console.log(`✅ Berita seeded: ${b.judul}`);
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`❌ Failed to seed berita "${b.judul}": ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🎉 Berita seed selesai");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import foto from "../../../../data/desa/gallery/foto/foto.json";
|
||||
import { loadJsonData } from "../../../../load-json";
|
||||
|
||||
const foto = loadJsonData("desa/gallery/foto/foto.json");
|
||||
|
||||
export async function seedFoto() {
|
||||
console.log("🔄 Seeding Foto...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import galleryVideo from "../../../../data/desa/gallery/video/video.json";
|
||||
import { loadJsonData } from "../../../../load-json";
|
||||
|
||||
const galleryVideo = loadJsonData("desa/gallery/video/video.json");
|
||||
|
||||
export async function seedVideo() {
|
||||
console.log("🔄 Seeding Gallery Video...");
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
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";
|
||||
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");
|
||||
|
||||
export async function seedLayanan() {
|
||||
console.log("🔄 Seeding Pelayanan Surat Keterangan...");
|
||||
|
||||
57
prisma/_seeder_list/desa/musik-desa/seed_musik_desa.ts
Normal file
57
prisma/_seeder_list/desa/musik-desa/seed_musik_desa.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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");
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import penghargaan from "../../../data/desa/penghargaan/penghargaan.json"
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const penghargaan = loadJsonData("desa/penghargaan/penghargaan.json");
|
||||
|
||||
export async function seedPenghargaan() {
|
||||
console.log("🔄 Seeding Penghargaan...");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
import { safeSeedUnique } from "../../../safeseedUnique";
|
||||
import kategoriPengumuman from "../../../data/desa/pengumuman/kategori-pengumuman.json";
|
||||
import pengumuman from "../../../data/desa/pengumuman/pengumuman.json";
|
||||
|
||||
const kategoriPengumuman = loadJsonData("desa/pengumuman/kategori-pengumuman.json");
|
||||
const pengumuman = loadJsonData("desa/pengumuman/pengumuman.json");
|
||||
|
||||
export async function seedPengumuman() {
|
||||
console.log("🔄 Seeding Kategori Pengumuman...");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import kategoriPotensi from "../../../data/desa/potensi/kategori-potensi.json";
|
||||
import potensiDesa from "../../../data/desa/potensi/potensi-desa.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const kategoriPotensi = loadJsonData("desa/potensi/kategori-potensi.json");
|
||||
const potensiDesa = loadJsonData("desa/potensi/potensi-desa.json");
|
||||
|
||||
export async function seedPotensi() {
|
||||
console.log("🔄Seeding Kategori Potensi Desa ...");
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
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";
|
||||
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");
|
||||
|
||||
export async function seedProfileDesa() {
|
||||
// =========== SEJARAH DESA ===========
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import perbekelDariMasaKeMasa from "../../../data/desa/profile/profile-perbekel-lalu.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const perbekelDariMasaKeMasa = loadJsonData("desa/profile/profile-perbekel-lalu.json");
|
||||
|
||||
export async function seedProfilePerbekel() {
|
||||
console.log("🔄 Seeding Perbekel Dari Masa Ke Masa...");
|
||||
|
||||
45
prisma/_seeder_list/ekonomi/seed_apbdes.ts
Normal file
45
prisma/_seeder_list/ekonomi/seed_apbdes.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
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");
|
||||
}
|
||||
63
prisma/_seeder_list/ekonomi/seed_apbdes_item.ts
Normal file
63
prisma/_seeder_list/ekonomi/seed_apbdes_item.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
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");
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import demografiPekerjaan from "../../data/ekonomi/demografi-pekerjaan/demografi-pekerjaan.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const demografiPekerjaan = loadJsonData("ekonomi/demografi-pekerjaan/demografi-pekerjaan.json");
|
||||
|
||||
export async function seedDemografiPekerjaan() {
|
||||
console.log("🔄 Seeding Demografi Pekerjaan...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import jumlahPendudukMiskin from "../../data/ekonomi/jumlah-penduduk-miskin/jumlah-penduduk-miskin.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const jumlahPendudukMiskin = loadJsonData("ekonomi/jumlah-penduduk-miskin/jumlah-penduduk-miskin.json");
|
||||
|
||||
export async function seedJumlahPendudukMiskin() {
|
||||
console.log("🔄 Seeding Jumlah Penduduk Miskin...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import jumlahPengangguran from "../../data/ekonomi/jumlah-pengangguran/detail-data-pengangguran.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const jumlahPengangguran = loadJsonData("ekonomi/jumlah-pengangguran/detail-data-pengangguran.json");
|
||||
|
||||
export async function seedJumlahPengangguran() {
|
||||
for (const d of jumlahPengangguran) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import lowonganKerjaLokal from "../../data/ekonomi/lowongan-kerja-lokal/lowongan-kerja-lokal.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const lowonganKerjaLokal = loadJsonData("ekonomi/lowongan-kerja-lokal/lowongan-kerja-lokal.json");
|
||||
|
||||
export async function seedLowonganKerjaLokal() {
|
||||
console.log("🔄 Seeding Lowongan Kerja Lokal...");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
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";
|
||||
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");
|
||||
|
||||
export async function seedPasarDesa() {
|
||||
console.log("🔄 Seeding Kategori Produk...");
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
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";
|
||||
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");
|
||||
|
||||
export async function seedPendapatanAsli() {
|
||||
console.log("🔄 Seeding Pendapatan Asli...");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
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";
|
||||
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");
|
||||
|
||||
export async function seedPendudukUsiaKerjaYangMenganggur() {
|
||||
for (const p of grafikMenganggurBerdasarkanUsia) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import programKemiskinan from "../../data/ekonomi/program-kemiskinan/program-kemiskinan.json";
|
||||
import statistikKemiskinan from "../../data/ekonomi/program-kemiskinan/statistik-kemiskinan.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const programKemiskinan = loadJsonData("ekonomi/program-kemiskinan/program-kemiskinan.json");
|
||||
const statistikKemiskinan = loadJsonData("ekonomi/program-kemiskinan/statistik-kemiskinan.json");
|
||||
|
||||
export async function seedProgramKemiskinan() {
|
||||
for (const s of statistikKemiskinan) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import sektorUnggulanDesa from "../../data/ekonomi/sektor-unggulan/sektor-unggulan.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const sektorUnggulanDesa = loadJsonData("ekonomi/sektor-unggulan/sektor-unggulan.json");
|
||||
|
||||
export async function seedSektorUnggulanDesa() {
|
||||
console.log("🔄 Seeding Sektor Unggulan Desa...");
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import posisiOrganisasiBumDes from "../../data/ekonomi/struktur-organisasi/posisi-organisasi-bumdes.json";
|
||||
import pegawai from "../../data/ekonomi/struktur-organisasi/pegawai-bumdes.json";
|
||||
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");
|
||||
|
||||
export async function seedStrukturBumdes() {
|
||||
const flattenedPosisi = posisiOrganisasiBumDes.flat();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import ajukanIde from "../../data/inovasi/ajukan-ide/ajukan-ide.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const ajukanIde = loadJsonData("inovasi/ajukan-ide/ajukan-ide.json");
|
||||
|
||||
export async function seedAjukan() {
|
||||
console.log("🔄 Seeding Ajukan Ide Inovatif...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import desaDigital from "../../data/inovasi/desa-digital/desa-digital.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const desaDigital = loadJsonData("inovasi/desa-digital/desa-digital.json");
|
||||
|
||||
export async function seedDesaDigital() {
|
||||
console.log("🔄 Seeding Desa Digital...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import infoTeknologi from "../../data/inovasi/info-teknologi/info-teknologi.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const infoTeknologi = loadJsonData("inovasi/info-teknologi/info-teknologi.json");
|
||||
|
||||
export async function seedInfoTeknologi() {
|
||||
console.log("🔄 Seeding Info Teknologi...");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import kolaborasiInovasi from "../../data/inovasi/kolaborasi-inovasi/kolaborasi-inovasi.json";
|
||||
import mitraKolaborasi from "../../data/inovasi/kolaborasi-inovasi/mitra-kolaborasi.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const kolaborasiInovasi = loadJsonData("inovasi/kolaborasi-inovasi/kolaborasi-inovasi.json");
|
||||
const mitraKolaborasi = loadJsonData("inovasi/kolaborasi-inovasi/mitra-kolaborasi.json");
|
||||
|
||||
export async function seedKolaborasiInovasi() {
|
||||
console.log("🔄 Seeding Kolaborasi Inovasi...");
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
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";
|
||||
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");
|
||||
|
||||
export async function seedLayananOnlineDesa() {
|
||||
console.log("🔄 Seeding Jenis Layanan...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import programKreatif from "../../data/inovasi/program-kreatif-desa/program-kreatif-desa.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const programKreatif = loadJsonData("inovasi/program-kreatif-desa/program-kreatif-desa.json");
|
||||
|
||||
export async function seedProgramKreatifDesa() {
|
||||
console.log("🔄 Seeding Program Kreatif...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import keamananLingkunganJson from "../../data/keamanan/keamanan-lingkungan/keamanan-lingkungan.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const keamananLingkunganJson = loadJsonData("keamanan/keamanan-lingkungan/keamanan-lingkungan.json");
|
||||
|
||||
export async function seedKeamananLingkungan() {
|
||||
console.log("🔄 Seeding Keamanan Lingkungan...");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
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";
|
||||
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");
|
||||
|
||||
export async function seedKontakDaruratKeamanan() {
|
||||
console.log("🔄 Seeding Kontak Item...");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import laporanPublik from "../../data/keamanan/laporan-publik/laporan-publik.json";
|
||||
import penangananLaporan from "../../data/keamanan/laporan-publik/penanganan-laporan.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const laporanPublik = loadJsonData("keamanan/laporan-publik/laporan-publik.json");
|
||||
const penangananLaporan = loadJsonData("keamanan/laporan-publik/penanganan-laporan.json");
|
||||
|
||||
export async function seedLaporanPublik() {
|
||||
console.log("🔄 Seeding Laporan Publik...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import pencegahanKriminalitas from "../../data/keamanan/pencegahan-kriminalitas/pencegahan-kriminalitas.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const pencegahanKriminalitas = loadJsonData("keamanan/pencegahan-kriminalitas/pencegahan-kriminalitas.json");
|
||||
|
||||
export async function seedPencegahanKriminalitas() {
|
||||
console.log("🔄 Seeding Pencegahan Kriminalitas...");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
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";
|
||||
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");
|
||||
|
||||
export async function seedPolsekTerdekat() {
|
||||
console.log("🔄 Seeding Layanan Polsek...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import tipsKeamananJson from "../../data/keamanan/tips-keamanan/tips-keamanan.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const tipsKeamananJson = loadJsonData("keamanan/tips-keamanan/tips-keamanan.json");
|
||||
|
||||
export async function seedTipsKeamanan() {
|
||||
console.log("🔄 Seeding Tips Keamanan...");
|
||||
|
||||
32
prisma/_seeder_list/kependudukan/seed_data_banjar.ts
Normal file
32
prisma/_seeder_list/kependudukan/seed_data_banjar.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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");
|
||||
}
|
||||
32
prisma/_seeder_list/kependudukan/seed_dinamika_penduduk.ts
Normal file
32
prisma/_seeder_list/kependudukan/seed_dinamika_penduduk.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const dinamikaPendudukJson = loadJsonData("kependudukan/dinamika-penduduk/dinamika-penduduk.json");
|
||||
|
||||
export async function seedDinamikaPenduduk() {
|
||||
console.log("Seeding Dinamika Penduduk...");
|
||||
|
||||
for (const item of dinamikaPendudukJson) {
|
||||
await prisma.dinamikaPenduduk.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
tahun: item.tahun,
|
||||
kelahiran: item.kelahiran,
|
||||
kematian: item.kematian,
|
||||
masuk: item.masuk,
|
||||
keluar: item.keluar,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
tahun: item.tahun,
|
||||
kelahiran: item.kelahiran,
|
||||
kematian: item.kematian,
|
||||
masuk: item.masuk,
|
||||
keluar: item.keluar,
|
||||
},
|
||||
});
|
||||
console.log(` Tahun ${item.tahun}: ${item.kelahiran} kelahiran, ${item.kematian} kematian`);
|
||||
}
|
||||
|
||||
console.log("Dinamika Penduduk seed selesai");
|
||||
}
|
||||
28
prisma/_seeder_list/kependudukan/seed_distribusi_agama.ts
Normal file
28
prisma/_seeder_list/kependudukan/seed_distribusi_agama.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const distribusiAgamaJson = loadJsonData("kependudukan/distribusi-agama/distribusi-agama.json");
|
||||
|
||||
export async function seedDistribusiAgama() {
|
||||
console.log("Seeding Distribusi Agama...");
|
||||
|
||||
for (const item of distribusiAgamaJson) {
|
||||
await prisma.distribusiAgama.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
agama: item.agama,
|
||||
jumlah: item.jumlah,
|
||||
tahun: item.tahun,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
agama: item.agama,
|
||||
jumlah: item.jumlah,
|
||||
tahun: item.tahun,
|
||||
},
|
||||
});
|
||||
console.log(` ${item.agama}: ${item.jumlah} penganut`);
|
||||
}
|
||||
|
||||
console.log("Distribusi Agama seed selesai");
|
||||
}
|
||||
28
prisma/_seeder_list/kependudukan/seed_distribusi_umur.ts
Normal file
28
prisma/_seeder_list/kependudukan/seed_distribusi_umur.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const distribusiUmurJson = loadJsonData("kependudukan/distribusi-umur/distribusi-umur.json");
|
||||
|
||||
export async function seedDistribusiUmur() {
|
||||
console.log("Seeding Distribusi Umur...");
|
||||
|
||||
for (const item of distribusiUmurJson) {
|
||||
await prisma.distribusiUmur.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
rentangUmur: item.rentangUmur,
|
||||
jumlah: item.jumlah,
|
||||
tahun: item.tahun,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
rentangUmur: item.rentangUmur,
|
||||
jumlah: item.jumlah,
|
||||
tahun: item.tahun,
|
||||
},
|
||||
});
|
||||
console.log(` Rentang ${item.rentangUmur}: ${item.jumlah} jiwa`);
|
||||
}
|
||||
|
||||
console.log("Distribusi Umur seed selesai");
|
||||
}
|
||||
34
prisma/_seeder_list/kependudukan/seed_migrasi_penduduk.ts
Normal file
34
prisma/_seeder_list/kependudukan/seed_migrasi_penduduk.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const migrasiPendudukJson = loadJsonData("kependudukan/migrasi-penduduk/migrasi-penduduk.json");
|
||||
|
||||
export async function seedMigrasiPenduduk() {
|
||||
console.log("Seeding MigrASI PENDUDUK...");
|
||||
|
||||
for (const item of migrasiPendudukJson) {
|
||||
await prisma.migrasiPenduduk.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
nama: item.nama,
|
||||
jenis: item.jenis,
|
||||
tanggal: new Date(item.tanggal),
|
||||
asal: item.asal,
|
||||
tujuan: item.tujuan,
|
||||
alasan: item.alasan,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
nama: item.nama,
|
||||
jenis: item.jenis,
|
||||
tanggal: new Date(item.tanggal),
|
||||
asal: item.asal,
|
||||
tujuan: item.tujuan,
|
||||
alasan: item.alasan,
|
||||
},
|
||||
});
|
||||
console.log(` ${item.nama}: ${item.jenis} (${item.alasan})`);
|
||||
}
|
||||
|
||||
console.log("Migrasi Penduduk seed selesai");
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const artikelJson = loadJsonData("kesehatan/artikel-kesehatan/artikel-kesehatan.json");
|
||||
const introJson = loadJsonData("kesehatan/artikel-kesehatan/introduction.json");
|
||||
const symptomJson = loadJsonData("kesehatan/artikel-kesehatan/symptom.json");
|
||||
const preventionJson = loadJsonData("kesehatan/artikel-kesehatan/prevention.json");
|
||||
const firstAidJson = loadJsonData("kesehatan/artikel-kesehatan/first-aid.json");
|
||||
const mythVsFactJson = loadJsonData("kesehatan/artikel-kesehatan/myth-vs-fact.json");
|
||||
const doctorSignJson = loadJsonData("kesehatan/artikel-kesehatan/doctor-sign.json");
|
||||
|
||||
export async function seedArtikelKesehatan() {
|
||||
console.log("Seeding Introduction...");
|
||||
for (const item of introJson) {
|
||||
await prisma.introduction.upsert({
|
||||
where: { id: item.id },
|
||||
update: { content: item.content },
|
||||
create: { id: item.id, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Symptom...");
|
||||
for (const item of symptomJson) {
|
||||
await prisma.symptom.upsert({
|
||||
where: { id: item.id },
|
||||
update: { title: item.title, content: item.content },
|
||||
create: { id: item.id, title: item.title, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Prevention...");
|
||||
for (const item of preventionJson) {
|
||||
await prisma.prevention.upsert({
|
||||
where: { id: item.id },
|
||||
update: { title: item.title, content: item.content },
|
||||
create: { id: item.id, title: item.title, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding First Aid...");
|
||||
for (const item of firstAidJson) {
|
||||
await prisma.firstAid.upsert({
|
||||
where: { id: item.id },
|
||||
update: { title: item.title, content: item.content },
|
||||
create: { id: item.id, title: item.title, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Myth vs Fact...");
|
||||
for (const item of mythVsFactJson) {
|
||||
await prisma.mythVsFact.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
title: item.title,
|
||||
mitos: item.mitos,
|
||||
fakta: item.fakta,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
mitos: item.mitos,
|
||||
fakta: item.fakta,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Doctor Sign...");
|
||||
for (const item of doctorSignJson) {
|
||||
await prisma.doctorSign.upsert({
|
||||
where: { id: item.id },
|
||||
update: { content: item.content },
|
||||
create: { id: item.id, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Artikel Kesehatan...");
|
||||
for (const item of artikelJson) {
|
||||
await prisma.artikelKesehatan.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
introductionId: item.introductionId,
|
||||
symptomId: item.symptomId,
|
||||
preventionId: item.preventionId,
|
||||
firstAidId: item.firstAidId,
|
||||
mythVsFactId: item.mythVsFactId,
|
||||
doctorSignId: item.doctorSignId,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
introductionId: item.introductionId,
|
||||
symptomId: item.symptomId,
|
||||
preventionId: item.preventionId,
|
||||
firstAidId: item.firstAidId,
|
||||
mythVsFactId: item.mythVsFactId,
|
||||
doctorSignId: item.doctorSignId,
|
||||
},
|
||||
});
|
||||
console.log(` Artikel: ${item.title}`);
|
||||
}
|
||||
|
||||
console.log("Artikel Kesehatan seed selesai");
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const faskesJson = loadJsonData("kesehatan/fasilitas-kesehatan/fasilitas-kesehatan.json");
|
||||
const infoUmumJson = loadJsonData("kesehatan/fasilitas-kesehatan/informasi-umum.json");
|
||||
const layananUnggulanJson = loadJsonData("kesehatan/fasilitas-kesehatan/layanan-unggulan.json");
|
||||
const dokterJson = loadJsonData("kesehatan/fasilitas-kesehatan/dokter-tenaga-medis.json");
|
||||
const fasilitasPendukungJson = loadJsonData("kesehatan/fasilitas-kesehatan/fasilitas-pendukung.json");
|
||||
const prosedurJson = loadJsonData("kesehatan/fasilitas-kesehatan/prosedur-pendaftaran.json");
|
||||
const tarifJson = loadJsonData("kesehatan/fasilitas-kesehatan/tarif-layanan.json");
|
||||
|
||||
export async function seedFasilitasKesehatan() {
|
||||
console.log("Seeding Informasi Umum...");
|
||||
for (const item of infoUmumJson) {
|
||||
await prisma.informasiUmum.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
fasilitas: item.fasilitas,
|
||||
alamat: item.alamat,
|
||||
jamOperasional: item.jamOperasional,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
fasilitas: item.fasilitas,
|
||||
alamat: item.alamat,
|
||||
jamOperasional: item.jamOperasional,
|
||||
},
|
||||
});
|
||||
console.log(` Informasi Umum: ${item.fasilitas}`);
|
||||
}
|
||||
|
||||
console.log("Seeding Layanan Unggulan...");
|
||||
for (const item of layananUnggulanJson) {
|
||||
await prisma.layananUnggulan.upsert({
|
||||
where: { id: item.id },
|
||||
update: { content: item.content },
|
||||
create: { id: item.id, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Fasilitas Pendukung...");
|
||||
for (const item of fasilitasPendukungJson) {
|
||||
await prisma.fasilitasPendukung.upsert({
|
||||
where: { id: item.id },
|
||||
update: { content: item.content },
|
||||
create: { id: item.id, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Prosedur Pendaftaran...");
|
||||
for (const item of prosedurJson) {
|
||||
await prisma.prosedurPendaftaran.upsert({
|
||||
where: { id: item.id },
|
||||
update: { content: item.content },
|
||||
create: { id: item.id, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Tarif dan Layanan...");
|
||||
for (const item of tarifJson) {
|
||||
await prisma.tarifDanLayanan.upsert({
|
||||
where: { id: item.id },
|
||||
update: { layanan: item.layanan, tarif: item.tarif },
|
||||
create: { id: item.id, layanan: item.layanan, tarif: item.tarif },
|
||||
});
|
||||
console.log(` Tarif: ${item.layanan}`);
|
||||
}
|
||||
|
||||
console.log("Seeding Dokter dan Tenaga Medis...");
|
||||
for (const item of dokterJson) {
|
||||
await prisma.dokterdanTenagaMedis.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
name: item.name,
|
||||
specialist: item.specialist,
|
||||
jadwal: item.jadwal,
|
||||
jadwalLibur: item.jadwalLibur,
|
||||
jamBukaOperasional: item.jamBukaOperasional,
|
||||
jamTutupOperasional: item.jamTutupOperasional,
|
||||
jamBukaLibur: item.jamBukaLibur,
|
||||
jamTutupLibur: item.jamTutupLibur,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
specialist: item.specialist,
|
||||
jadwal: item.jadwal,
|
||||
jadwalLibur: item.jadwalLibur,
|
||||
jamBukaOperasional: item.jamBukaOperasional,
|
||||
jamTutupOperasional: item.jamTutupOperasional,
|
||||
jamBukaLibur: item.jamBukaLibur,
|
||||
jamTutupLibur: item.jamTutupLibur,
|
||||
},
|
||||
});
|
||||
console.log(` Dokter: ${item.name}`);
|
||||
}
|
||||
|
||||
console.log("Seeding Fasilitas Kesehatan...");
|
||||
for (const item of faskesJson) {
|
||||
await prisma.fasilitasKesehatan.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
name: item.name,
|
||||
informasiUmumId: item.informasiUmumId,
|
||||
layananUnggulanId: item.layananUnggulanId,
|
||||
fasilitasPendukungId: item.fasilitasPendukungId,
|
||||
prosedurPendaftaranId: item.prosedurPendaftaranId,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
informasiUmumId: item.informasiUmumId,
|
||||
layananUnggulanId: item.layananUnggulanId,
|
||||
fasilitasPendukungId: item.fasilitasPendukungId,
|
||||
prosedurPendaftaranId: item.prosedurPendaftaranId,
|
||||
},
|
||||
});
|
||||
console.log(` Fasilitas Kesehatan: ${item.name}`);
|
||||
}
|
||||
|
||||
console.log("Fasilitas Kesehatan seed selesai");
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import infoWabahPenyakitJson from "../../../data/kesehatan/infowabahpenyakit/infowabahpenyakit.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const infoWabahPenyakitJson = loadJsonData("kesehatan/infowabahpenyakit/infowabahpenyakit.json");
|
||||
|
||||
export async function seedInfoWabahPenyakit() {
|
||||
console.log("🔄 Seeding Info Wabah Penyakit...");
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const jadwalJson = loadJsonData("kesehatan/jadwal-kegiatan/jadwal-kegiatan.json");
|
||||
const infoJadwalJson = loadJsonData("kesehatan/jadwal-kegiatan/informasi-jadwal.json");
|
||||
const deskJadwalJson = loadJsonData("kesehatan/jadwal-kegiatan/deskripsi-jadwal.json");
|
||||
const layananJadwalJson = loadJsonData("kesehatan/jadwal-kegiatan/layanan-jadwal.json");
|
||||
const syaratJadwalJson = loadJsonData("kesehatan/jadwal-kegiatan/syarat-ketentuan.json");
|
||||
const dokumenJadwalJson = loadJsonData("kesehatan/jadwal-kegiatan/dokumen-jadwal.json");
|
||||
const daftarJadwalJson = loadJsonData("kesehatan/jadwal-kegiatan/pendaftaran-jadwal.json");
|
||||
|
||||
export async function seedJadwalKegiatan() {
|
||||
console.log("Seeding Informasi Jadwal Kegiatan...");
|
||||
for (const item of infoJadwalJson) {
|
||||
await prisma.informasiJadwalKegiatan.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
name: item.name,
|
||||
tanggal: item.tanggal,
|
||||
waktu: item.waktu,
|
||||
lokasi: item.lokasi,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
tanggal: item.tanggal,
|
||||
waktu: item.waktu,
|
||||
lokasi: item.lokasi,
|
||||
},
|
||||
});
|
||||
console.log(` Informasi: ${item.name}`);
|
||||
}
|
||||
|
||||
console.log("Seeding Deskripsi Jadwal Kegiatan...");
|
||||
for (const item of deskJadwalJson) {
|
||||
await prisma.deskripsiJadwalKegiatan.upsert({
|
||||
where: { id: item.id },
|
||||
update: { deskripsi: item.deskripsi },
|
||||
create: { id: item.id, deskripsi: item.deskripsi },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Layanan Jadwal Kegiatan...");
|
||||
for (const item of layananJadwalJson) {
|
||||
await prisma.layananJadwalKegiatan.upsert({
|
||||
where: { id: item.id },
|
||||
update: { content: item.content },
|
||||
create: { id: item.id, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Syarat & Ketentuan Jadwal...");
|
||||
for (const item of syaratJadwalJson) {
|
||||
await prisma.syaratKetentuanJadwalKegiatan.upsert({
|
||||
where: { id: item.id },
|
||||
update: { content: item.content },
|
||||
create: { id: item.id, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Dokumen Jadwal Kegiatan...");
|
||||
for (const item of dokumenJadwalJson) {
|
||||
await prisma.dokumenJadwalKegiatan.upsert({
|
||||
where: { id: item.id },
|
||||
update: { content: item.content },
|
||||
create: { id: item.id, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Pendaftaran Jadwal Kegiatan...");
|
||||
for (const item of daftarJadwalJson) {
|
||||
await prisma.pendaftaranJadwalKegiatan.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
name: item.name,
|
||||
tanggal: item.tanggal,
|
||||
namaOrangtua: item.namaOrtu,
|
||||
nomor: item.nomor,
|
||||
alamat: item.alamat,
|
||||
catatan: item.catatan,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
tanggal: item.tanggal,
|
||||
namaOrangtua: item.namaOrtu,
|
||||
nomor: item.nomor,
|
||||
alamat: item.alamat,
|
||||
catatan: item.catatan,
|
||||
},
|
||||
});
|
||||
console.log(` Pendaftaran: ${item.name}`);
|
||||
}
|
||||
|
||||
console.log("Seeding Jadwal Kegiatan...");
|
||||
for (const item of jadwalJson) {
|
||||
await prisma.jadwalKegiatan.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
content: item.content,
|
||||
informasiJadwalKegiatanId: item.informasiJadwalKegiatanId,
|
||||
deskripsiJadwalKegiatanId: item.deskripsiJadwalKegiatanId,
|
||||
layananJadwalKegiatanId: item.layananJadwalKegiatanId,
|
||||
syaratKetentuanJadwalKegiatanId: item.syaratKetentuanJadwalKegiatanId,
|
||||
dokumenJadwalKegiatanId: item.dokumenJadwalKegiatanId,
|
||||
pendaftaranJadwalKegiatanId: item.pendaftaranJadwalKegiatanId,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
informasiJadwalKegiatanId: item.informasiJadwalKegiatanId,
|
||||
deskripsiJadwalKegiatanId: item.deskripsiJadwalKegiatanId,
|
||||
layananJadwalKegiatanId: item.layananJadwalKegiatanId,
|
||||
syaratKetentuanJadwalKegiatanId: item.syaratKetentuanJadwalKegiatanId,
|
||||
dokumenJadwalKegiatanId: item.dokumenJadwalKegiatanId,
|
||||
pendaftaranJadwalKegiatanId: item.pendaftaranJadwalKegiatanId,
|
||||
},
|
||||
});
|
||||
console.log(` Jadwal Kegiatan seeded`);
|
||||
}
|
||||
|
||||
console.log("Jadwal Kegiatan seed selesai");
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import kontakDaruratJson from "../../../data/kesehatan/kontak-darurat/kontak-darurat.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
const kontakDaruratJson = loadJsonData("kesehatan/kontak-darurat/kontak-darurat.json");
|
||||
|
||||
export async function seedKontakDarurat() {
|
||||
console.log("🔄 Seeding Kontak Darurat...");
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import penangananDaruratJson from "../../../data/kesehatan/penanganan-darurat/penganan-darurat.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const penangananDaruratJson = loadJsonData("kesehatan/penanganan-darurat/penganan-darurat.json");
|
||||
|
||||
export async function seedPenangananDarurat() {
|
||||
console.log("🔄 Seeding Penanganan Darurat...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import posyanduJson from "../../../data/kesehatan/posyandu/posyandu.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const posyanduJson = loadJsonData("kesehatan/posyandu/posyandu.json");
|
||||
|
||||
export async function seedPosyandu() {
|
||||
console.log("🔄 Seeding Posyandu...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import programKesehatanJson from "../../../data/kesehatan/program-kesehatan/program-kesehatan.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const programKesehatanJson = loadJsonData("kesehatan/program-kesehatan/program-kesehatan.json");
|
||||
|
||||
export async function seedProgramKesehatan() {
|
||||
for (const p of programKesehatanJson) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import puskesmasJson from "../../../data/kesehatan/puskesmas/puskesmas.json";
|
||||
import kontakPuskesmasJson from "../../../data/kesehatan/puskesmas/kontak-puskesmas/kontak.json";
|
||||
import jamPuskesmasJson from "../../../data/kesehatan/puskesmas/jam-puskesmas/jam.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const puskesmasJson = loadJsonData("kesehatan/puskesmas/puskesmas.json");
|
||||
const kontakPuskesmasJson = loadJsonData("kesehatan/puskesmas/kontak-puskesmas/kontak.json");
|
||||
const jamPuskesmasJson = loadJsonData("kesehatan/puskesmas/jam-puskesmas/jam.json");
|
||||
|
||||
export async function seedPuskesmas() {
|
||||
console.log("🔄 Seeding Kontak Puskesmas...");
|
||||
|
||||
32
prisma/_seeder_list/kesehatan/seed_grafik_kepuasan.ts
Normal file
32
prisma/_seeder_list/kesehatan/seed_grafik_kepuasan.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const grafikKepuasanJson = loadJsonData("kesehatan/grafik-kepuasan/grafik-kepuasan.json");
|
||||
|
||||
export async function seedGrafikKepuasan() {
|
||||
console.log("Seeding Grafik Kepuasan...");
|
||||
|
||||
for (const item of grafikKepuasanJson) {
|
||||
await prisma.grafikKepuasan.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
nama: item.nama,
|
||||
tanggal: new Date(item.tanggal),
|
||||
jenisKelamin: item.jenisKelamin,
|
||||
alamat: item.alamat,
|
||||
penyakit: item.penyakit,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
nama: item.nama,
|
||||
tanggal: new Date(item.tanggal),
|
||||
jenisKelamin: item.jenisKelamin,
|
||||
alamat: item.alamat,
|
||||
penyakit: item.penyakit,
|
||||
},
|
||||
});
|
||||
console.log(` Grafik Kepuasan: ${item.nama}`);
|
||||
}
|
||||
|
||||
console.log("Grafik Kepuasan seed selesai");
|
||||
}
|
||||
70
prisma/_seeder_list/kesehatan/seed_kelahiran_kematian.ts
Normal file
70
prisma/_seeder_list/kesehatan/seed_kelahiran_kematian.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const kelahiranJson = loadJsonData("kesehatan/kelahiran/kelahiran.json");
|
||||
const kematianJson = loadJsonData("kesehatan/kematian/kematian.json");
|
||||
const dataKematianKelahiranJson = loadJsonData("kesehatan/kematian-kelahiran/data-kematian-kelahiran.json");
|
||||
|
||||
export async function seedKelahiranKematian() {
|
||||
console.log("Seeding Kelahiran...");
|
||||
for (const item of kelahiranJson) {
|
||||
await prisma.kelahiran.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
nama: item.nama,
|
||||
tanggal: new Date(item.tanggal),
|
||||
jenisKelamin: item.jenisKelamin,
|
||||
alamat: item.alamat,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
nama: item.nama,
|
||||
tanggal: new Date(item.tanggal),
|
||||
jenisKelamin: item.jenisKelamin,
|
||||
alamat: item.alamat,
|
||||
},
|
||||
});
|
||||
console.log(` Kelahiran: ${item.nama}`);
|
||||
}
|
||||
|
||||
console.log("Seeding Kematian...");
|
||||
for (const item of kematianJson) {
|
||||
await prisma.kematian.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
nama: item.nama,
|
||||
tanggal: new Date(item.tanggal),
|
||||
jenisKelamin: item.jenisKelamin,
|
||||
alamat: item.alamat,
|
||||
penyebab: item.penyebab,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
nama: item.nama,
|
||||
tanggal: new Date(item.tanggal),
|
||||
jenisKelamin: item.jenisKelamin,
|
||||
alamat: item.alamat,
|
||||
penyebab: item.penyebab,
|
||||
},
|
||||
});
|
||||
console.log(` Kematian: ${item.nama}`);
|
||||
}
|
||||
|
||||
console.log("Seeding Data Kematian-Kelahiran...");
|
||||
for (const item of dataKematianKelahiranJson) {
|
||||
await prisma.dataKematian_Kelahiran.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
kematianId: item.kematianId,
|
||||
kelahiranId: item.kelahiranId,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
kematianId: item.kematianId,
|
||||
kelahiranId: item.kelahiranId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Kelahiran & Kematian seed selesai");
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import kategoriDesaAntiKorupsi from "../../../data/landing-page/desa-anti-korupsi/kategoriDesaAntiKorupsi.json"
|
||||
import desaAntiKorupsi from "../../../data/landing-page/desa-anti-korupsi/desaantiKorpusi.json"
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const kategoriDesaAntiKorupsi = loadJsonData("landing-page/desa-anti-korupsi/kategoriDesaAntiKorupsi.json");
|
||||
const desaAntiKorupsi = loadJsonData("landing-page/desa-anti-korupsi/desaantiKorpusi.json");
|
||||
|
||||
export async function seedDesaAntiKorupsi() {
|
||||
for (const k of kategoriDesaAntiKorupsi) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import prestasiDesa from "../../../data/landing-page/prestasi-desa/prestasi-desa.json"
|
||||
import kategoriPrestasiDesa from "../../../data/landing-page/prestasi-desa/kategori-prestasi.json"
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const prestasiDesa = loadJsonData("landing-page/prestasi-desa/prestasi-desa.json");
|
||||
const kategoriPrestasiDesa = loadJsonData("landing-page/prestasi-desa/kategori-prestasi.json");
|
||||
|
||||
export async function seedPrestasiDesa() {
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import mediaSosial from "../../../data/landing-page/profile/mediaSosial.json"
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const mediaSosial = loadJsonData("landing-page/profile/mediaSosial.json");
|
||||
|
||||
export async function seedMediaSosial() {
|
||||
console.log("🔄 Seeding Media Sosial...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import profilePejabatDesa from "../../../data/landing-page/profile/profile.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const profilePejabatDesa = loadJsonData("landing-page/profile/profile.json");
|
||||
|
||||
export async function seedProfileLP() {
|
||||
console.log("🔄 Seeding Pejabat Desa...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import programInovasi from "../../../data/landing-page/profile/programInovasi.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const programInovasi = loadJsonData("landing-page/profile/programInovasi.json");
|
||||
|
||||
export async function seedProgramInovasi() {
|
||||
console.log("🔄 Seeding Program Inovasi...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import sdgsDesa from "../../../data/landing-page/sdgs-desa/sdgs-desa.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const sdgsDesa = loadJsonData("landing-page/sdgs-desa/sdgs-desa.json");
|
||||
|
||||
export async function seedSDGSDesa() {
|
||||
console.log("🔄 Seeding SDGS Desa...");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import kategoriGotongRoyong from "../../data/lingkungan/gotong-royong/kategori-gotong-royong.json";
|
||||
import gotongRoyong from "../../data/lingkungan/gotong-royong/gotong-royong.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const kategoriGotongRoyong = loadJsonData("lingkungan/gotong-royong/kategori-gotong-royong.json");
|
||||
const gotongRoyong = loadJsonData("lingkungan/gotong-royong/gotong-royong.json");
|
||||
|
||||
export async function seedDataGotongRoyong() {
|
||||
console.log("🔄 Seeding Kategori Gotong Royong...");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user