Compare commits
160 Commits
nico/27-fe
...
tasks/impl
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 0160fa636d | |||
| 3684e83187 | |||
| 77c54b5c8a | |||
| bb80b0ecc1 | |||
| 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
|
#!/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 to manually load .env from project root if process.env is missing keys
|
||||||
function findLongestString(obj: any): string {
|
function loadEnv() {
|
||||||
let longest = "";
|
const envPath = join(process.cwd(), ".env");
|
||||||
const search = (item: any) => {
|
if (existsSync(envPath)) {
|
||||||
if (typeof item === "string") {
|
const envContent = readFileSync(envPath, "utf-8");
|
||||||
if (item.length > longest.length) longest = item;
|
const lines = envContent.split("\n");
|
||||||
} else if (Array.isArray(item)) {
|
for (const line of lines) {
|
||||||
item.forEach(search);
|
if (line && !line.startsWith("#")) {
|
||||||
} else if (item && typeof item === "object") {
|
const [key, ...valueParts] = line.split("=");
|
||||||
Object.values(item).forEach(search);
|
if (key && valueParts.length > 0) {
|
||||||
|
const value = valueParts.join("=").trim().replace(/^["']|["']$/g, "");
|
||||||
|
process.env[key.trim()] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
search(obj);
|
|
||||||
return longest;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
|
// Ensure environment variables are loaded
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
const inputRaw = readFileSync(0, "utf-8");
|
const inputRaw = readFileSync(0, "utf-8");
|
||||||
if (!inputRaw) return;
|
if (!inputRaw) return;
|
||||||
const input = JSON.parse(inputRaw);
|
|
||||||
|
|
||||||
// DEBUG: Lihat struktur asli di console terminal (stderr)
|
let finalText = "";
|
||||||
console.error("DEBUG KEYS:", Object.keys(input));
|
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 BOT_TOKEN = process.env.BOT_TOKEN;
|
||||||
const CHAT_ID = process.env.CHAT_ID;
|
const CHAT_ID = process.env.CHAT_ID;
|
||||||
|
|
||||||
const sessionId = input.session_id || "unknown";
|
if (!BOT_TOKEN || !CHAT_ID) {
|
||||||
|
console.error("Missing BOT_TOKEN or CHAT_ID in environment variables");
|
||||||
// Cari teks secara otomatis di seluruh objek JSON
|
return;
|
||||||
let finalText = findLongestString(input.response || input);
|
|
||||||
|
|
||||||
if (!finalText || finalText.length < 5) {
|
|
||||||
finalText =
|
|
||||||
"Teks masih gagal diekstraksi. Struktur: " +
|
|
||||||
Object.keys(input).join(", ");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
@@ -45,7 +54,7 @@ async function run() {
|
|||||||
`🆔 Session: \`${sessionId}\` \n\n` +
|
`🆔 Session: \`${sessionId}\` \n\n` +
|
||||||
`🧠 Output:\n${finalText.substring(0, 3500)}`;
|
`🧠 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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
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" }));
|
process.stdout.write(JSON.stringify({ status: "continue" }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Hook Error:", err);
|
console.error("Hook Error:", err);
|
||||||
|
|||||||
219
.github/workflows/build.yml
vendored
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
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -29,7 +29,9 @@ yarn-error.log*
|
|||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env
|
# env
|
||||||
|
# env local files (keep .env.example)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# QC
|
# QC
|
||||||
QC
|
QC
|
||||||
@@ -50,9 +52,6 @@ next-env.d.ts
|
|||||||
# cache
|
# cache
|
||||||
/cache
|
/cache
|
||||||
|
|
||||||
.github/
|
|
||||||
|
|
||||||
.env.*
|
|
||||||
|
|
||||||
*.tar.gz
|
*.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)
|
||||||
67
Dockerfile
Normal file
67
Dockerfile
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# ==============================
|
||||||
|
# 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.* ./
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["bun", "start"]
|
||||||
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
|
||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
serverExternalPackages: ['@elysiajs/static', 'elysia'],
|
||||||
experimental: {},
|
experimental: {},
|
||||||
allowedDevOrigins: [
|
allowedDevOrigins: [
|
||||||
"http://192.168.1.82:3000", // buat akses dari HP/device lain
|
"http://192.168.1.82:3000", // buat akses dari HP/device lain
|
||||||
@@ -19,7 +20,6 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -8,7 +8,8 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"test:api": "vitest run",
|
"test:api": "vitest run",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test": "bun run test:api && bun run test:e2e"
|
"test": "bun run test:api && bun run test:e2e",
|
||||||
|
"gen:api": ""
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "bun run prisma/seed.ts"
|
"seed": "bun run prisma/seed.ts"
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
"@mantine/modals": "^8.3.6",
|
"@mantine/modals": "^8.3.6",
|
||||||
"@mantine/tiptap": "^7.17.4",
|
"@mantine/tiptap": "^7.17.4",
|
||||||
"@paljs/types": "^8.1.0",
|
"@paljs/types": "^8.1.0",
|
||||||
"@prisma/client": "^6.3.1",
|
"@prisma/client": "6.3.1",
|
||||||
"@tabler/icons-react": "^3.30.0",
|
"@tabler/icons-react": "^3.30.0",
|
||||||
"@tiptap/extension-highlight": "^2.11.7",
|
"@tiptap/extension-highlight": "^2.11.7",
|
||||||
"@tiptap/extension-link": "^2.11.7",
|
"@tiptap/extension-link": "^2.11.7",
|
||||||
@@ -70,7 +71,7 @@
|
|||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"extract-zip": "^2.0.1",
|
"extract-zip": "^2.0.1",
|
||||||
"form-data": "^4.0.2",
|
"form-data": "^4.0.2",
|
||||||
"framer-motion": "^12.23.5",
|
"framer-motion": "^12.38.0",
|
||||||
"get-port": "^7.1.0",
|
"get-port": "^7.1.0",
|
||||||
"iron-session": "^8.0.4",
|
"iron-session": "^8.0.4",
|
||||||
"jose": "^6.1.0",
|
"jose": "^6.1.0",
|
||||||
@@ -89,7 +90,7 @@
|
|||||||
"p-limit": "^6.2.0",
|
"p-limit": "^6.2.0",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.6",
|
"primereact": "^10.9.6",
|
||||||
"prisma": "^6.3.1",
|
"prisma": "6.3.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-exif-orientation-img": "^0.1.5",
|
"react-exif-orientation-img": "^0.1.5",
|
||||||
@@ -100,7 +101,7 @@
|
|||||||
"react-transition-group": "^4.4.5",
|
"react-transition-group": "^4.4.5",
|
||||||
"react-zoom-pan-pinch": "^3.7.0",
|
"react-zoom-pan-pinch": "^3.7.0",
|
||||||
"readdirp": "^4.1.1",
|
"readdirp": "^4.1.1",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^3.8.0",
|
||||||
"sharp": "^0.34.3",
|
"sharp": "^0.34.3",
|
||||||
"swr": "^2.3.2",
|
"swr": "^2.3.2",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
@@ -120,10 +121,11 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@vitest/ui": "^4.0.18",
|
"@vitest/ui": "^4.0.18",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.1.6",
|
"eslint-config-next": "15.5.12",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
"msw": "^2.12.9",
|
"msw": "^2.12.9",
|
||||||
"parcel": "^2.6.2",
|
"parcel": "^2.6.2",
|
||||||
|
"playwright-mcp": "^0.0.19",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
|
|||||||
94
prisma/migrations/20260406032433_init/migration.sql
Normal file
94
prisma/migrations/20260406032433_init/migration.sql
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `realisasi` on the `APBDesItem` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "APBDesItem" DROP COLUMN "realisasi",
|
||||||
|
ADD COLUMN "totalRealisasi" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
ALTER COLUMN "selisih" SET DEFAULT 0,
|
||||||
|
ALTER COLUMN "persentase" SET DEFAULT 0;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Berita" ADD COLUMN "linkVideo" VARCHAR(500);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RealisasiItem" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kode" TEXT,
|
||||||
|
"apbdesItemId" TEXT NOT NULL,
|
||||||
|
"jumlah" DOUBLE PRECISION NOT NULL,
|
||||||
|
"tanggal" DATE NOT NULL,
|
||||||
|
"keterangan" TEXT,
|
||||||
|
"buktiFileId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"deletedAt" TIMESTAMP(3),
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
CONSTRAINT "RealisasiItem_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MusikDesa" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"judul" VARCHAR(255) NOT NULL,
|
||||||
|
"artis" VARCHAR(255) NOT NULL,
|
||||||
|
"deskripsi" TEXT,
|
||||||
|
"durasi" VARCHAR(20) NOT NULL,
|
||||||
|
"audioFileId" TEXT,
|
||||||
|
"coverImageId" TEXT,
|
||||||
|
"genre" VARCHAR(100),
|
||||||
|
"tahunRilis" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"deletedAt" TIMESTAMP(3),
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
CONSTRAINT "MusikDesa_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_BeritaImages" (
|
||||||
|
"A" TEXT NOT NULL,
|
||||||
|
"B" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "_BeritaImages_AB_pkey" PRIMARY KEY ("A","B")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RealisasiItem_kode_idx" ON "RealisasiItem"("kode");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RealisasiItem_apbdesItemId_idx" ON "RealisasiItem"("apbdesItemId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RealisasiItem_tanggal_idx" ON "RealisasiItem"("tanggal");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MusikDesa_judul_idx" ON "MusikDesa"("judul");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MusikDesa_artis_idx" ON "MusikDesa"("artis");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_BeritaImages_B_index" ON "_BeritaImages"("B");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Berita_kategoriBeritaId_idx" ON "Berita"("kategoriBeritaId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RealisasiItem" ADD CONSTRAINT "RealisasiItem_apbdesItemId_fkey" FOREIGN KEY ("apbdesItemId") REFERENCES "APBDesItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MusikDesa" ADD CONSTRAINT "MusikDesa_audioFileId_fkey" FOREIGN KEY ("audioFileId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MusikDesa" ADD CONSTRAINT "MusikDesa_coverImageId_fkey" FOREIGN KEY ("coverImageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_BeritaImages" ADD CONSTRAINT "_BeritaImages_A_fkey" FOREIGN KEY ("A") REFERENCES "Berita"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_BeritaImages" ADD CONSTRAINT "_BeritaImages_B_fkey" FOREIGN KEY ("B") REFERENCES "FileStorage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (e.g., Git)
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
@@ -61,7 +61,8 @@ model FileStorage {
|
|||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
link String
|
link String
|
||||||
category String // "image" / "document" / "audio" / "other"
|
category String // "image" / "document" / "audio" / "other"
|
||||||
Berita Berita[]
|
Berita Berita[] @relation("BeritaFeaturedImage")
|
||||||
|
BeritaImages Berita[] @relation("BeritaImages")
|
||||||
PotensiDesa PotensiDesa[]
|
PotensiDesa PotensiDesa[]
|
||||||
Posyandu Posyandu[]
|
Posyandu Posyandu[]
|
||||||
StrukturPPID StrukturPPID[]
|
StrukturPPID StrukturPPID[]
|
||||||
@@ -208,16 +209,22 @@ model APBDesItem {
|
|||||||
kode String // contoh: "4", "4.1", "4.1.2"
|
kode String // contoh: "4", "4.1", "4.1.2"
|
||||||
uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha"
|
uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha"
|
||||||
anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS)
|
anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS)
|
||||||
realisasi Float
|
tipe String? // "pendapatan" | "belanja" | "pembiayaan" | null
|
||||||
selisih Float // realisasi - anggaran
|
|
||||||
persentase Float
|
|
||||||
tipe String? // (realisasi / anggaran) * 100
|
|
||||||
level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail
|
level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail
|
||||||
parentId String? // untuk relasi hierarki
|
parentId String? // untuk relasi hierarki
|
||||||
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
|
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
|
||||||
children APBDesItem[] @relation("APBDesItemParent")
|
children APBDesItem[] @relation("APBDesItemParent")
|
||||||
apbdesId String
|
apbdesId String
|
||||||
apbdes APBDes @relation(fields: [apbdesId], references: [id])
|
apbdes APBDes @relation(fields: [apbdesId], references: [id])
|
||||||
|
|
||||||
|
// Field kalkulasi (auto-calculated dari realisasi items)
|
||||||
|
totalRealisasi Float @default(0) // Sum dari semua realisasi
|
||||||
|
selisih Float @default(0) // totalRealisasi - anggaran
|
||||||
|
persentase Float @default(0) // (totalRealisasi / anggaran) * 100
|
||||||
|
|
||||||
|
// Relasi ke realisasi items
|
||||||
|
realisasiItems RealisasiItem[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
@@ -228,6 +235,28 @@ model APBDesItem {
|
|||||||
@@index([apbdesId])
|
@@index([apbdesId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Model baru untuk multiple realisasi per item
|
||||||
|
model RealisasiItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
kode String? // Kode realisasi, mirip dengan APBDesItem
|
||||||
|
apbdesItemId String
|
||||||
|
apbdesItem APBDesItem @relation(fields: [apbdesItemId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
jumlah Float // Jumlah realisasi dalam Rupiah
|
||||||
|
tanggal DateTime @db.Date // Tanggal realisasi
|
||||||
|
keterangan String? @db.Text // Keterangan tambahan (opsional)
|
||||||
|
buktiFileId String? // FileStorage ID untuk bukti/foto (opsional)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
@@index([kode])
|
||||||
|
@@index([apbdesItemId])
|
||||||
|
@@index([tanggal])
|
||||||
|
}
|
||||||
|
|
||||||
//========================================= PRESTASI DESA ========================================= //
|
//========================================= PRESTASI DESA ========================================= //
|
||||||
model PrestasiDesa {
|
model PrestasiDesa {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -612,15 +641,19 @@ model Berita {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
judul String
|
judul String
|
||||||
deskripsi String
|
deskripsi String
|
||||||
image FileStorage? @relation(fields: [imageId], references: [id])
|
image FileStorage? @relation("BeritaFeaturedImage", fields: [imageId], references: [id])
|
||||||
imageId String?
|
imageId String?
|
||||||
|
images FileStorage[] @relation("BeritaImages")
|
||||||
content String @db.Text
|
content String @db.Text
|
||||||
|
linkVideo String? @db.VarChar(500)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime @default(now())
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
kategoriBerita KategoriBerita? @relation(fields: [kategoriBeritaId], references: [id])
|
kategoriBerita KategoriBerita? @relation(fields: [kategoriBeritaId], references: [id])
|
||||||
kategoriBeritaId String?
|
kategoriBeritaId String?
|
||||||
|
|
||||||
|
@@index([kategoriBeritaId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model KategoriBerita {
|
model KategoriBerita {
|
||||||
|
|||||||
BIN
public/mp3-logo.png
Normal file
BIN
public/mp3-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
@@ -12,6 +12,8 @@ const templateForm = z.object({
|
|||||||
content: z.string().min(3, "Content minimal 3 karakter"),
|
content: z.string().min(3, "Content minimal 3 karakter"),
|
||||||
kategoriBeritaId: z.string().nonempty(),
|
kategoriBeritaId: z.string().nonempty(),
|
||||||
imageId: z.string().nonempty(),
|
imageId: z.string().nonempty(),
|
||||||
|
imageIds: z.array(z.string()),
|
||||||
|
linkVideo: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Default value form berita (hindari uncontrolled input)
|
// 2. Default value form berita (hindari uncontrolled input)
|
||||||
@@ -21,6 +23,8 @@ const defaultForm = {
|
|||||||
imageId: "",
|
imageId: "",
|
||||||
content: "",
|
content: "",
|
||||||
kategoriBeritaId: "",
|
kategoriBeritaId: "",
|
||||||
|
imageIds: [] as string[],
|
||||||
|
linkVideo: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4. Berita proxy
|
// 4. Berita proxy
|
||||||
@@ -62,14 +66,7 @@ const berita = proxy({
|
|||||||
// State untuk berita utama (hanya 1)
|
// State untuk berita utama (hanya 1)
|
||||||
|
|
||||||
findMany: {
|
findMany: {
|
||||||
data: null as
|
data: null as any[] | null,
|
||||||
| Prisma.BeritaGetPayload<{
|
|
||||||
include: {
|
|
||||||
image: true;
|
|
||||||
kategoriBerita: true;
|
|
||||||
};
|
|
||||||
}>[]
|
|
||||||
| null,
|
|
||||||
page: 1,
|
page: 1,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -79,14 +76,14 @@ const berita = proxy({
|
|||||||
berita.findMany.loading = true;
|
berita.findMany.loading = true;
|
||||||
berita.findMany.page = page;
|
berita.findMany.page = page;
|
||||||
berita.findMany.search = search;
|
berita.findMany.search = search;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const query: any = { page, limit };
|
const query: any = { page, limit };
|
||||||
if (search) query.search = search;
|
if (search) query.search = search;
|
||||||
if (kategori) query.kategori = kategori;
|
if (kategori) query.kategori = kategori;
|
||||||
|
|
||||||
const res = await ApiFetch.api.desa.berita["find-many"].get({ query });
|
const res = await ApiFetch.api.desa.berita["find-many"].get({ query });
|
||||||
|
|
||||||
if (res.status === 200 && res.data?.success) {
|
if (res.status === 200 && res.data?.success) {
|
||||||
berita.findMany.data = res.data.data ?? [];
|
berita.findMany.data = res.data.data ?? [];
|
||||||
berita.findMany.totalPages = res.data.totalPages ?? 1;
|
berita.findMany.totalPages = res.data.totalPages ?? 1;
|
||||||
@@ -103,18 +100,19 @@ const berita = proxy({
|
|||||||
const elapsed = Date.now() - startTime;
|
const elapsed = Date.now() - startTime;
|
||||||
const minDelay = 300;
|
const minDelay = 300;
|
||||||
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
|
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
berita.findMany.loading = false;
|
berita.findMany.loading = false;
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
findUnique: {
|
findUnique: {
|
||||||
data: null as Prisma.BeritaGetPayload<{
|
data: null as Prisma.BeritaGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
image: true;
|
image: true;
|
||||||
|
images: true;
|
||||||
kategoriBerita: true;
|
kategoriBerita: true;
|
||||||
};
|
};
|
||||||
}> | null,
|
}> | null,
|
||||||
@@ -199,6 +197,8 @@ const berita = proxy({
|
|||||||
content: data.content,
|
content: data.content,
|
||||||
kategoriBeritaId: data.kategoriBeritaId || "",
|
kategoriBeritaId: data.kategoriBeritaId || "",
|
||||||
imageId: data.imageId || "",
|
imageId: data.imageId || "",
|
||||||
|
imageIds: data.images?.map((img: any) => img.id) || [],
|
||||||
|
linkVideo: data.linkVideo || "",
|
||||||
};
|
};
|
||||||
return data; // Return the loaded data
|
return data; // Return the loaded data
|
||||||
} else {
|
} else {
|
||||||
@@ -237,6 +237,8 @@ const berita = proxy({
|
|||||||
content: this.form.content,
|
content: this.form.content,
|
||||||
kategoriBeritaId: this.form.kategoriBeritaId || null,
|
kategoriBeritaId: this.form.kategoriBeritaId || null,
|
||||||
imageId: this.form.imageId,
|
imageId: this.form.imageId,
|
||||||
|
imageIds: this.form.imageIds,
|
||||||
|
linkVideo: this.form.linkVideo,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
27
src/app/admin/(dashboard)/_state/kependudukan/dashboard.ts
Normal file
27
src/app/admin/(dashboard)/_state/kependudukan/dashboard.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import ApiFetch from "@/lib/api-fetch";
|
||||||
|
import { proxy } from "valtio";
|
||||||
|
|
||||||
|
const kependudukanDashboard = proxy({
|
||||||
|
summary: {
|
||||||
|
data: null as any,
|
||||||
|
loading: false,
|
||||||
|
async load() {
|
||||||
|
kependudukanDashboard.summary.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await ApiFetch.api.kependudukan.dashboard.summary.get();
|
||||||
|
if (res.status === 200 && res.data?.success) {
|
||||||
|
kependudukanDashboard.summary.data = res.data.data;
|
||||||
|
} else {
|
||||||
|
kependudukanDashboard.summary.data = null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal fetch dashboard summary:", err);
|
||||||
|
kependudukanDashboard.summary.data = null;
|
||||||
|
} finally {
|
||||||
|
kependudukanDashboard.summary.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default kependudukanDashboard;
|
||||||
205
src/app/admin/(dashboard)/_state/kependudukan/data-banjar.ts
Normal file
205
src/app/admin/(dashboard)/_state/kependudukan/data-banjar.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import ApiFetch from "@/lib/api-fetch";
|
||||||
|
import { proxy } from "valtio";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const templateDataBanjar = z.object({
|
||||||
|
nama: z.string().min(1, "Nama banjar harus diisi"),
|
||||||
|
penduduk: z.number().min(0, "Jumlah penduduk harus diisi"),
|
||||||
|
kk: z.number().min(0, "Jumlah KK harus diisi"),
|
||||||
|
miskin: z.number().min(0, "Jumlah penduduk miskin harus diisi"),
|
||||||
|
tahun: z.number().min(2000, "Tahun harus diisi"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataBanjar = proxy({
|
||||||
|
create: {
|
||||||
|
form: {
|
||||||
|
nama: "",
|
||||||
|
penduduk: 0,
|
||||||
|
kk: 0,
|
||||||
|
miskin: 0,
|
||||||
|
tahun: new Date().getFullYear(),
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
async create() {
|
||||||
|
const cek = templateDataBanjar.safeParse(dataBanjar.create.form);
|
||||||
|
if (!cek.success) {
|
||||||
|
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
|
||||||
|
toast.error(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
dataBanjar.create.loading = true;
|
||||||
|
const res = await ApiFetch.api.kependudukan.databanjar["create"].post(dataBanjar.create.form);
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
const id = res.data?.data?.id;
|
||||||
|
if (id) {
|
||||||
|
toast.success("Sukses menambahkan data banjar");
|
||||||
|
dataBanjar.create.form = { nama: "", penduduk: 0, kk: 0, miskin: 0, tahun: new Date().getFullYear() };
|
||||||
|
dataBanjar.findMany.load();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.error("Gagal menambahkan data");
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.log((error as Error).message);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
dataBanjar.create.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
findMany: {
|
||||||
|
data: null as any[] | null,
|
||||||
|
page: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
loading: false,
|
||||||
|
search: "",
|
||||||
|
load: async (page = 1, limit = 10, search = "", tahun = new Date().getFullYear()) => {
|
||||||
|
dataBanjar.findMany.loading = true;
|
||||||
|
dataBanjar.findMany.page = page;
|
||||||
|
dataBanjar.findMany.search = search;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query: any = { page, limit, tahun };
|
||||||
|
if (search) query.search = search;
|
||||||
|
|
||||||
|
const res = await ApiFetch.api.kependudukan.databanjar["find-many"].get({ query });
|
||||||
|
|
||||||
|
if (res.status === 200 && res.data?.success) {
|
||||||
|
dataBanjar.findMany.data = res.data.data ?? [];
|
||||||
|
dataBanjar.findMany.totalPages = res.data.totalPages ?? 1;
|
||||||
|
} else {
|
||||||
|
dataBanjar.findMany.data = [];
|
||||||
|
dataBanjar.findMany.totalPages = 1;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal fetch data banjar paginated:", err);
|
||||||
|
dataBanjar.findMany.data = [];
|
||||||
|
dataBanjar.findMany.totalPages = 1;
|
||||||
|
} finally {
|
||||||
|
dataBanjar.findMany.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
findUnique: {
|
||||||
|
data: null as any | null,
|
||||||
|
async load(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/kependudukan/databanjar/${id}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
dataBanjar.findUnique.data = data.data ?? null;
|
||||||
|
} else {
|
||||||
|
console.error("Failed to fetch data banjar:", res.statusText);
|
||||||
|
dataBanjar.findUnique.data = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching data banjar:", error);
|
||||||
|
dataBanjar.findUnique.data = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
update: {
|
||||||
|
id: "",
|
||||||
|
form: {
|
||||||
|
nama: "",
|
||||||
|
penduduk: 0,
|
||||||
|
kk: 0,
|
||||||
|
miskin: 0,
|
||||||
|
tahun: new Date().getFullYear(),
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
async submit() {
|
||||||
|
const id = this.id;
|
||||||
|
if (!id) {
|
||||||
|
toast.warn("ID tidak valid");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
nama: this.form.nama,
|
||||||
|
penduduk: this.form.penduduk,
|
||||||
|
kk: this.form.kk,
|
||||||
|
miskin: this.form.miskin,
|
||||||
|
tahun: this.form.tahun,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cek = templateDataBanjar.safeParse(formData);
|
||||||
|
if (!cek.success) {
|
||||||
|
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
|
||||||
|
toast.error(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
const res = await fetch(`/api/kependudukan/databanjar/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || !result?.success) {
|
||||||
|
throw new Error(result?.message || "Gagal update data");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Berhasil update data!");
|
||||||
|
await dataBanjar.findMany.load();
|
||||||
|
return result.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update error:", error);
|
||||||
|
toast.error("Gagal update data banjar");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: {
|
||||||
|
loading: false,
|
||||||
|
async byId(id: string) {
|
||||||
|
if (!id) return toast.warn("ID tidak valid");
|
||||||
|
|
||||||
|
try {
|
||||||
|
dataBanjar.delete.loading = true;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/kependudukan/databanjar/del/${id}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result?.success) {
|
||||||
|
toast.success(result.message || "Data banjar berhasil dihapus");
|
||||||
|
await dataBanjar.findMany.load();
|
||||||
|
} else {
|
||||||
|
toast.error(result?.message || "Gagal menghapus data banjar");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gagal delete:", error);
|
||||||
|
toast.error("Terjadi kesalahan saat menghapus data banjar");
|
||||||
|
} finally {
|
||||||
|
dataBanjar.delete.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default dataBanjar;
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import ApiFetch from "@/lib/api-fetch";
|
||||||
|
import { proxy } from "valtio";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const templateDistribusiAgama = z.object({
|
||||||
|
agama: z.string().min(1, "Agama harus diisi"),
|
||||||
|
jumlah: z.number().min(0, "Jumlah harus diisi"),
|
||||||
|
tahun: z.number().min(2000, "Tahun harus diisi"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const distribusiAgama = proxy({
|
||||||
|
create: {
|
||||||
|
form: {
|
||||||
|
agama: "",
|
||||||
|
jumlah: 0,
|
||||||
|
tahun: new Date().getFullYear(),
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
async create() {
|
||||||
|
const cek = templateDistribusiAgama.safeParse(distribusiAgama.create.form);
|
||||||
|
if (!cek.success) {
|
||||||
|
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
|
||||||
|
toast.error(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
distribusiAgama.create.loading = true;
|
||||||
|
const res = await ApiFetch.api.kependudukan.distribusiagama["create"].post(distribusiAgama.create.form);
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
const id = res.data?.data?.id;
|
||||||
|
if (id) {
|
||||||
|
toast.success("Sukses menambahkan distribusi agama");
|
||||||
|
distribusiAgama.create.form = { agama: "", jumlah: 0, tahun: new Date().getFullYear() };
|
||||||
|
distribusiAgama.findMany.load();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.error("Gagal menambahkan data");
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.log((error as Error).message);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
distribusiAgama.create.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
findMany: {
|
||||||
|
data: null as any[] | null,
|
||||||
|
page: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
loading: false,
|
||||||
|
search: "",
|
||||||
|
load: async (page = 1, limit = 10, search = "", tahun = new Date().getFullYear()) => {
|
||||||
|
distribusiAgama.findMany.loading = true;
|
||||||
|
distribusiAgama.findMany.page = page;
|
||||||
|
distribusiAgama.findMany.search = search;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query: any = { page, limit, tahun };
|
||||||
|
if (search) query.search = search;
|
||||||
|
|
||||||
|
const res = await ApiFetch.api.kependudukan.distribusiagama["find-many"].get({ query });
|
||||||
|
|
||||||
|
if (res.status === 200 && res.data?.success) {
|
||||||
|
distribusiAgama.findMany.data = res.data.data ?? [];
|
||||||
|
distribusiAgama.findMany.totalPages = res.data.totalPages ?? 1;
|
||||||
|
} else {
|
||||||
|
distribusiAgama.findMany.data = [];
|
||||||
|
distribusiAgama.findMany.totalPages = 1;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal fetch distribusi agama paginated:", err);
|
||||||
|
distribusiAgama.findMany.data = [];
|
||||||
|
distribusiAgama.findMany.totalPages = 1;
|
||||||
|
} finally {
|
||||||
|
distribusiAgama.findMany.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
findUnique: {
|
||||||
|
data: null as any | null,
|
||||||
|
async load(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/kependudukan/distribusiagama/${id}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
distribusiAgama.findUnique.data = data.data ?? null;
|
||||||
|
} else {
|
||||||
|
console.error("Failed to fetch distribusiAgama:", res.statusText);
|
||||||
|
distribusiAgama.findUnique.data = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching distribusiAgama:", error);
|
||||||
|
distribusiAgama.findUnique.data = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
update: {
|
||||||
|
id: "",
|
||||||
|
form: {
|
||||||
|
agama: "",
|
||||||
|
jumlah: 0,
|
||||||
|
tahun: new Date().getFullYear(),
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
async submit() {
|
||||||
|
const id = this.id;
|
||||||
|
if (!id) {
|
||||||
|
toast.warn("ID tidak valid");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
agama: this.form.agama,
|
||||||
|
jumlah: this.form.jumlah,
|
||||||
|
tahun: this.form.tahun,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cek = templateDistribusiAgama.safeParse(formData);
|
||||||
|
if (!cek.success) {
|
||||||
|
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
|
||||||
|
toast.error(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
const res = await fetch(`/api/kependudukan/distribusiagama/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || !result?.success) {
|
||||||
|
throw new Error(result?.message || "Gagal update data");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Berhasil update data!");
|
||||||
|
await distribusiAgama.findMany.load();
|
||||||
|
return result.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update error:", error);
|
||||||
|
toast.error("Gagal update data distribusi agama");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: {
|
||||||
|
loading: false,
|
||||||
|
async byId(id: string) {
|
||||||
|
if (!id) return toast.warn("ID tidak valid");
|
||||||
|
|
||||||
|
try {
|
||||||
|
distribusiAgama.delete.loading = true;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/kependudukan/distribusiagama/del/${id}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result?.success) {
|
||||||
|
toast.success(result.message || "Distribusi agama berhasil dihapus");
|
||||||
|
await distribusiAgama.findMany.load();
|
||||||
|
} else {
|
||||||
|
toast.error(result?.message || "Gagal menghapus distribusi agama");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gagal delete:", error);
|
||||||
|
toast.error("Terjadi kesalahan saat menghapus distribusi agama");
|
||||||
|
} finally {
|
||||||
|
distribusiAgama.delete.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default distribusiAgama;
|
||||||
197
src/app/admin/(dashboard)/_state/kependudukan/distribusi-umur.ts
Normal file
197
src/app/admin/(dashboard)/_state/kependudukan/distribusi-umur.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import ApiFetch from "@/lib/api-fetch";
|
||||||
|
import { proxy } from "valtio";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const templateDistribusiUmur = z.object({
|
||||||
|
rentangUmur: z.string().min(1, "Rentang umur harus diisi"),
|
||||||
|
jumlah: z.number().min(0, "Jumlah harus diisi"),
|
||||||
|
tahun: z.number().min(2000, "Tahun harus diisi"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const distribusiUmur = proxy({
|
||||||
|
create: {
|
||||||
|
form: {
|
||||||
|
rentangUmur: "",
|
||||||
|
jumlah: 0,
|
||||||
|
tahun: new Date().getFullYear(),
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
async create() {
|
||||||
|
const cek = templateDistribusiUmur.safeParse(distribusiUmur.create.form);
|
||||||
|
if (!cek.success) {
|
||||||
|
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
|
||||||
|
toast.error(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
distribusiUmur.create.loading = true;
|
||||||
|
const res = await ApiFetch.api.kependudukan.distribusiumur["create"].post(distribusiUmur.create.form);
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
const id = res.data?.data?.id;
|
||||||
|
if (id) {
|
||||||
|
toast.success("Sukses menambahkan distribusi umur");
|
||||||
|
distribusiUmur.create.form = { rentangUmur: "", jumlah: 0, tahun: new Date().getFullYear() };
|
||||||
|
distribusiUmur.findMany.load();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.error("Gagal menambahkan data");
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.log((error as Error).message);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
distribusiUmur.create.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
findMany: {
|
||||||
|
data: null as any[] | null,
|
||||||
|
page: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
loading: false,
|
||||||
|
search: "",
|
||||||
|
load: async (page = 1, limit = 10, search = "", tahun = new Date().getFullYear()) => {
|
||||||
|
distribusiUmur.findMany.loading = true;
|
||||||
|
distribusiUmur.findMany.page = page;
|
||||||
|
distribusiUmur.findMany.search = search;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query: any = { page, limit, tahun };
|
||||||
|
if (search) query.search = search;
|
||||||
|
|
||||||
|
const res = await ApiFetch.api.kependudukan.distribusiumur["find-many"].get({ query });
|
||||||
|
|
||||||
|
if (res.status === 200 && res.data?.success) {
|
||||||
|
distribusiUmur.findMany.data = res.data.data ?? [];
|
||||||
|
distribusiUmur.findMany.totalPages = res.data.totalPages ?? 1;
|
||||||
|
} else {
|
||||||
|
distribusiUmur.findMany.data = [];
|
||||||
|
distribusiUmur.findMany.totalPages = 1;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal fetch distribusi umur paginated:", err);
|
||||||
|
distribusiUmur.findMany.data = [];
|
||||||
|
distribusiUmur.findMany.totalPages = 1;
|
||||||
|
} finally {
|
||||||
|
distribusiUmur.findMany.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
findUnique: {
|
||||||
|
data: null as any | null,
|
||||||
|
async load(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/kependudukan/distribusiumur/${id}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
distribusiUmur.findUnique.data = data.data ?? null;
|
||||||
|
} else {
|
||||||
|
console.error("Failed to fetch distribusi umur:", res.statusText);
|
||||||
|
distribusiUmur.findUnique.data = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching distribusi umur:", error);
|
||||||
|
distribusiUmur.findUnique.data = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
update: {
|
||||||
|
id: "",
|
||||||
|
form: {
|
||||||
|
rentangUmur: "",
|
||||||
|
jumlah: 0,
|
||||||
|
tahun: new Date().getFullYear(),
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
async submit() {
|
||||||
|
const id = this.id;
|
||||||
|
if (!id) {
|
||||||
|
toast.warn("ID tidak valid");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
rentangUmur: this.form.rentangUmur,
|
||||||
|
jumlah: this.form.jumlah,
|
||||||
|
tahun: this.form.tahun,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cek = templateDistribusiUmur.safeParse(formData);
|
||||||
|
if (!cek.success) {
|
||||||
|
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
|
||||||
|
toast.error(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
const res = await fetch(`/api/kependudukan/distribusiumur/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || !result?.success) {
|
||||||
|
throw new Error(result?.message || "Gagal update data");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Berhasil update data!");
|
||||||
|
await distribusiUmur.findMany.load();
|
||||||
|
return result.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update error:", error);
|
||||||
|
toast.error("Gagal update data distribusi umur");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: {
|
||||||
|
loading: false,
|
||||||
|
async byId(id: string) {
|
||||||
|
if (!id) return toast.warn("ID tidak valid");
|
||||||
|
|
||||||
|
try {
|
||||||
|
distribusiUmur.delete.loading = true;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/kependudukan/distribusiumur/del/${id}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result?.success) {
|
||||||
|
toast.success(result.message || "Distribusi umur berhasil dihapus");
|
||||||
|
await distribusiUmur.findMany.load();
|
||||||
|
} else {
|
||||||
|
toast.error(result?.message || "Gagal menghapus distribusi umur");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gagal delete:", error);
|
||||||
|
toast.error("Terjadi kesalahan saat menghapus distribusi umur");
|
||||||
|
} finally {
|
||||||
|
distribusiUmur.delete.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default distribusiUmur;
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import ApiFetch from "@/lib/api-fetch";
|
||||||
|
import { proxy } from "valtio";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const templateMigrasiPenduduk = z.object({
|
||||||
|
jenis: z.string().min(1, "Jenis migrasi harus diisi"),
|
||||||
|
nama: z.string().min(1, "Nama harus diisi"),
|
||||||
|
tanggal: z.string().min(1, "Tanggal harus diisi"),
|
||||||
|
asalTujuan: z.string().min(1, "Asal/Tujuan harus diisi"),
|
||||||
|
alasan: z.string().optional(),
|
||||||
|
jenisKelamin: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const migrasiPenduduk = proxy({
|
||||||
|
create: {
|
||||||
|
form: {
|
||||||
|
jenis: "",
|
||||||
|
nama: "",
|
||||||
|
tanggal: "",
|
||||||
|
asalTujuan: "",
|
||||||
|
alasan: "",
|
||||||
|
jenisKelamin: "",
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
async create() {
|
||||||
|
const cek = templateMigrasiPenduduk.safeParse(migrasiPenduduk.create.form);
|
||||||
|
if (!cek.success) {
|
||||||
|
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
|
||||||
|
toast.error(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
migrasiPenduduk.create.loading = true;
|
||||||
|
const res = await ApiFetch.api.kependudukan.migrasipenduduk["create"].post(migrasiPenduduk.create.form);
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
const id = res.data?.data?.id;
|
||||||
|
if (id) {
|
||||||
|
toast.success("Sukses menambahkan data migrasi penduduk");
|
||||||
|
migrasiPenduduk.create.form = { jenis: "", nama: "", tanggal: "", asalTujuan: "", alasan: "", jenisKelamin: "" };
|
||||||
|
migrasiPenduduk.findMany.load();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.error("Gagal menambahkan data");
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.log((error as Error).message);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
migrasiPenduduk.create.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
findMany: {
|
||||||
|
data: null as any[] | null,
|
||||||
|
page: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
loading: false,
|
||||||
|
search: "",
|
||||||
|
load: async (page = 1, limit = 10, search = "", tahun = new Date().getFullYear()) => {
|
||||||
|
migrasiPenduduk.findMany.loading = true;
|
||||||
|
migrasiPenduduk.findMany.page = page;
|
||||||
|
migrasiPenduduk.findMany.search = search;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query: any = { page, limit, tahun };
|
||||||
|
if (search) query.search = search;
|
||||||
|
|
||||||
|
const res = await ApiFetch.api.kependudukan.migrasipenduduk["find-many"].get({ query });
|
||||||
|
|
||||||
|
if (res.status === 200 && res.data?.success) {
|
||||||
|
migrasiPenduduk.findMany.data = res.data.data ?? [];
|
||||||
|
migrasiPenduduk.findMany.totalPages = res.data.totalPages ?? 1;
|
||||||
|
} else {
|
||||||
|
migrasiPenduduk.findMany.data = [];
|
||||||
|
migrasiPenduduk.findMany.totalPages = 1;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal fetch migrasi penduduk paginated:", err);
|
||||||
|
migrasiPenduduk.findMany.data = [];
|
||||||
|
migrasiPenduduk.findMany.totalPages = 1;
|
||||||
|
} finally {
|
||||||
|
migrasiPenduduk.findMany.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
findUnique: {
|
||||||
|
data: null as any | null,
|
||||||
|
async load(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/kependudukan/migrasipenduduk/${id}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
migrasiPenduduk.findUnique.data = data.data ?? null;
|
||||||
|
} else {
|
||||||
|
console.error("Failed to fetch migrasi penduduk:", res.statusText);
|
||||||
|
migrasiPenduduk.findUnique.data = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching migrasi penduduk:", error);
|
||||||
|
migrasiPenduduk.findUnique.data = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
update: {
|
||||||
|
id: "",
|
||||||
|
form: {
|
||||||
|
jenis: "",
|
||||||
|
nama: "",
|
||||||
|
tanggal: "",
|
||||||
|
asalTujuan: "",
|
||||||
|
alasan: "",
|
||||||
|
jenisKelamin: "",
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
async submit() {
|
||||||
|
const id = this.id;
|
||||||
|
if (!id) {
|
||||||
|
toast.warn("ID tidak valid");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
jenis: this.form.jenis,
|
||||||
|
nama: this.form.nama,
|
||||||
|
tanggal: this.form.tanggal,
|
||||||
|
asalTujuan: this.form.asalTujuan,
|
||||||
|
alasan: this.form.alasan,
|
||||||
|
jenisKelamin: this.form.jenisKelamin,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cek = templateMigrasiPenduduk.safeParse(formData);
|
||||||
|
if (!cek.success) {
|
||||||
|
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
|
||||||
|
toast.error(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
const res = await fetch(`/api/kependudukan/migrasipenduduk/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || !result?.success) {
|
||||||
|
throw new Error(result?.message || "Gagal update data");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Berhasil update data!");
|
||||||
|
await migrasiPenduduk.findMany.load();
|
||||||
|
return result.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update error:", error);
|
||||||
|
toast.error("Gagal update data migrasi penduduk");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: {
|
||||||
|
loading: false,
|
||||||
|
async byId(id: string) {
|
||||||
|
if (!id) return toast.warn("ID tidak valid");
|
||||||
|
|
||||||
|
try {
|
||||||
|
migrasiPenduduk.delete.loading = true;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/kependudukan/migrasipenduduk/del/${id}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result?.success) {
|
||||||
|
toast.success(result.message || "Data migrasi penduduk berhasil dihapus");
|
||||||
|
await migrasiPenduduk.findMany.load();
|
||||||
|
} else {
|
||||||
|
toast.error(result?.message || "Gagal menghapus data migrasi penduduk");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gagal delete:", error);
|
||||||
|
toast.error("Terjadi kesalahan saat menghapus data migrasi penduduk");
|
||||||
|
} finally {
|
||||||
|
migrasiPenduduk.delete.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default migrasiPenduduk;
|
||||||
@@ -5,53 +5,52 @@ import { toast } from "react-toastify";
|
|||||||
import { proxy } from "valtio";
|
import { proxy } from "valtio";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
// --- Zod Schema ---
|
// --- Zod Schema untuk APBDes Item (dengan field kalkulasi) ---
|
||||||
const ApbdesItemSchema = z.object({
|
const ApbdesItemSchema = z.object({
|
||||||
kode: z.string().min(1, "Kode wajib diisi"),
|
kode: z.string().min(1, "Kode wajib diisi"),
|
||||||
uraian: z.string().min(1, "Uraian wajib diisi"),
|
uraian: z.string().min(1, "Uraian wajib diisi"),
|
||||||
anggaran: z.number().min(0),
|
anggaran: z.number().min(0, "Anggaran tidak boleh negatif"),
|
||||||
realisasi: z.number().min(0),
|
|
||||||
selisih: z.number(),
|
|
||||||
persentase: z.number(),
|
|
||||||
level: z.number().int().min(1).max(3),
|
level: z.number().int().min(1).max(3),
|
||||||
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
|
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
|
||||||
|
// Field kalkulasi dari realisasiItems (auto-calculated di backend)
|
||||||
|
realisasi: z.number().min(0).default(0),
|
||||||
|
selisih: z.number().default(0),
|
||||||
|
persentase: z.number().default(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ApbdesFormSchema = z.object({
|
const ApbdesFormSchema = z.object({
|
||||||
tahun: z.number().int().min(2000, "Tahun tidak valid"),
|
tahun: z.number().int().min(2000, "Tahun tidak valid"),
|
||||||
imageId: z.string().min(1, "Gambar wajib diunggah"),
|
name: z.string().optional(),
|
||||||
fileId: z.string().min(1, "File wajib diunggah"),
|
deskripsi: z.string().optional(),
|
||||||
|
jumlah: z.string().optional(),
|
||||||
|
// Image dan file opsional (bisa kosong)
|
||||||
|
imageId: z.string().optional(),
|
||||||
|
fileId: z.string().optional(),
|
||||||
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
|
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Default Form ---
|
// --- Default Form ---
|
||||||
const defaultApbdesForm = {
|
const defaultApbdesForm = {
|
||||||
tahun: new Date().getFullYear(),
|
tahun: new Date().getFullYear(),
|
||||||
|
name: "",
|
||||||
|
deskripsi: "",
|
||||||
|
jumlah: "",
|
||||||
imageId: "",
|
imageId: "",
|
||||||
fileId: "",
|
fileId: "",
|
||||||
items: [] as z.infer<typeof ApbdesItemSchema>[],
|
items: [] as z.infer<typeof ApbdesItemSchema>[],
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
|
// --- Helper: Normalize item (dengan field kalkulasi) ---
|
||||||
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
|
|
||||||
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
|
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
|
||||||
const anggaran = item.anggaran ?? 0;
|
|
||||||
const realisasi = item.realisasi ?? 0;
|
|
||||||
|
|
||||||
|
|
||||||
// ✅ Formula yang benar
|
|
||||||
const selisih = realisasi - anggaran; // positif = sisa anggaran, negatif = over budget
|
|
||||||
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kode: item.kode || "",
|
kode: item.kode || "",
|
||||||
uraian: item.uraian || "",
|
uraian: item.uraian || "",
|
||||||
anggaran,
|
anggaran: item.anggaran ?? 0,
|
||||||
realisasi,
|
|
||||||
selisih,
|
|
||||||
persentase,
|
|
||||||
level: item.level || 1,
|
level: item.level || 1,
|
||||||
tipe: item.tipe, // biarkan null jika memang null
|
tipe: item.tipe ?? null,
|
||||||
|
realisasi: item.realisasi ?? 0,
|
||||||
|
selisih: item.selisih ?? 0,
|
||||||
|
persentase: item.persentase ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +112,7 @@ const apbdes = proxy({
|
|||||||
findMany: {
|
findMany: {
|
||||||
data: null as
|
data: null as
|
||||||
| Prisma.APBDesGetPayload<{
|
| Prisma.APBDesGetPayload<{
|
||||||
include: { image: true; file: true; items: true };
|
include: { image: true; file: true; items: { include: { realisasiItems: true } } };
|
||||||
}>[]
|
}>[]
|
||||||
| null,
|
| null,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -158,33 +157,37 @@ const apbdes = proxy({
|
|||||||
findUnique: {
|
findUnique: {
|
||||||
data: null as
|
data: null as
|
||||||
| Prisma.APBDesGetPayload<{
|
| Prisma.APBDesGetPayload<{
|
||||||
include: { image: true; file: true; items: true };
|
include: { image: true; file: true; items: { include: { realisasiItems: true } } };
|
||||||
}>
|
}>
|
||||||
| null,
|
| null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null as string | null,
|
error: null as string | null,
|
||||||
|
|
||||||
async load(id: string) {
|
async load(id: string) {
|
||||||
if (!id || id.trim() === '') {
|
if (!id || id.trim() === '') {
|
||||||
this.data = null;
|
this.data = null;
|
||||||
this.error = "ID tidak valid";
|
this.error = "ID tidak valid";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent multiple simultaneous loads
|
||||||
|
if (this.loading) {
|
||||||
|
console.log("⚠️ Already loading, skipping...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Pastikan URL-nya benar
|
|
||||||
const url = `/api/landingpage/apbdes/${id}`;
|
const url = `/api/landingpage/apbdes/${id}`;
|
||||||
console.log("🌐 Fetching:", url);
|
console.log("🌐 Fetching:", url);
|
||||||
|
|
||||||
// Gunakan fetch biasa atau ApiFetch dengan cara yang benar
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const res = await response.json();
|
const res = await response.json();
|
||||||
|
|
||||||
console.log("📦 Response:", res);
|
console.log("📦 Response:", res);
|
||||||
|
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
this.data = res.data;
|
this.data = res.data;
|
||||||
} else {
|
} else {
|
||||||
@@ -244,15 +247,18 @@ const apbdes = proxy({
|
|||||||
this.id = data.id;
|
this.id = data.id;
|
||||||
this.form = {
|
this.form = {
|
||||||
tahun: data.tahun || new Date().getFullYear(),
|
tahun: data.tahun || new Date().getFullYear(),
|
||||||
|
name: data.name || "",
|
||||||
|
deskripsi: data.deskripsi || "",
|
||||||
|
jumlah: data.jumlah || "",
|
||||||
imageId: data.imageId || "",
|
imageId: data.imageId || "",
|
||||||
fileId: data.fileId || "",
|
fileId: data.fileId || "",
|
||||||
items: (data.items || []).map((item: any) => ({
|
items: (data.items || []).map((item: any) => ({
|
||||||
kode: item.kode,
|
kode: item.kode,
|
||||||
uraian: item.uraian,
|
uraian: item.uraian,
|
||||||
anggaran: item.anggaran,
|
anggaran: item.anggaran,
|
||||||
realisasi: item.realisasi,
|
realisasi: item.totalRealisasi || 0,
|
||||||
selisih: item.selisih,
|
selisih: item.selisih || 0,
|
||||||
persentase: item.persentase,
|
persentase: item.persentase || 0,
|
||||||
level: item.level,
|
level: item.level,
|
||||||
tipe: item.tipe || 'pendapatan',
|
tipe: item.tipe || 'pendapatan',
|
||||||
})),
|
})),
|
||||||
@@ -280,11 +286,24 @@ const apbdes = proxy({
|
|||||||
try {
|
try {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
// Include the ID in the request body
|
// Include the ID in the request body
|
||||||
|
// Omit realisasi, selisih, persentase karena itu calculated fields di backend
|
||||||
const requestData = {
|
const requestData = {
|
||||||
...parsed.data,
|
tahun: parsed.data.tahun,
|
||||||
id: this.id, // Add the ID to the request body
|
name: parsed.data.name,
|
||||||
|
deskripsi: parsed.data.deskripsi,
|
||||||
|
jumlah: parsed.data.jumlah,
|
||||||
|
imageId: parsed.data.imageId,
|
||||||
|
fileId: parsed.data.fileId,
|
||||||
|
id: this.id,
|
||||||
|
items: parsed.data.items.map(item => ({
|
||||||
|
kode: item.kode,
|
||||||
|
uraian: item.uraian,
|
||||||
|
anggaran: item.anggaran,
|
||||||
|
level: item.level,
|
||||||
|
tipe: item.tipe ?? null,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
|
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
|
||||||
|
|
||||||
if (res.data?.success) {
|
if (res.data?.success) {
|
||||||
@@ -317,6 +336,82 @@ const apbdes = proxy({
|
|||||||
this.form = { ...defaultApbdesForm };
|
this.form = { ...defaultApbdesForm };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// REALISASI STATE MANAGEMENT
|
||||||
|
// =========================================
|
||||||
|
realisasi: {
|
||||||
|
// Create realisasi
|
||||||
|
async create(itemId: string, data: { kode: string; jumlah: number; tanggal: string; keterangan?: string; buktiFileId?: string }) {
|
||||||
|
try {
|
||||||
|
const res = await (ApiFetch.api.landingpage.apbdes as any)[itemId].realisasi.post(data);
|
||||||
|
|
||||||
|
if (res.data?.success) {
|
||||||
|
toast.success("Realisasi berhasil ditambahkan");
|
||||||
|
// Reload findUnique untuk update data
|
||||||
|
const currentId = apbdes.findUnique.data?.id;
|
||||||
|
if (currentId) {
|
||||||
|
await apbdes.findUnique.load(currentId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast.error(res.data?.message || "Gagal menambahkan realisasi");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Create realisasi error:", error);
|
||||||
|
toast.error(error?.message || "Terjadi kesalahan saat menambahkan realisasi");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update realisasi
|
||||||
|
async update(realisasiId: string, data: { kode?: string; jumlah?: number; tanggal?: string; keterangan?: string; buktiFileId?: string }) {
|
||||||
|
try {
|
||||||
|
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].put(data);
|
||||||
|
|
||||||
|
if (res.data?.success) {
|
||||||
|
toast.success("Realisasi berhasil diperbarui");
|
||||||
|
// Reload findUnique untuk update data
|
||||||
|
const currentId = apbdes.findUnique.data?.id;
|
||||||
|
if (currentId) {
|
||||||
|
await apbdes.findUnique.load(currentId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast.error(res.data?.message || "Gagal memperbarui realisasi");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Update realisasi error:", error);
|
||||||
|
toast.error(error?.message || "Terjadi kesalahan saat memperbarui realisasi");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete realisasi
|
||||||
|
async delete(realisasiId: string) {
|
||||||
|
try {
|
||||||
|
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].delete();
|
||||||
|
|
||||||
|
if (res.data?.success) {
|
||||||
|
toast.success("Realisasi berhasil dihapus");
|
||||||
|
// Reload findUnique untuk update data
|
||||||
|
if (apbdes.findUnique.data) {
|
||||||
|
await apbdes.findUnique.load(apbdes.findUnique.data.id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast.error(res.data?.message || "Gagal menghapus realisasi");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Delete realisasi error:", error);
|
||||||
|
toast.error(error?.message || "Terjadi kesalahan saat menghapus realisasi");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apbdes;
|
export default apbdes;
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
ActionIcon,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Card,
|
||||||
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Image,
|
Image,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -17,7 +19,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
Loader
|
Loader,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Dropzone } from "@mantine/dropzone";
|
import { Dropzone } from "@mantine/dropzone";
|
||||||
import {
|
import {
|
||||||
@@ -25,19 +27,51 @@ import {
|
|||||||
IconPhoto,
|
IconPhoto,
|
||||||
IconUpload,
|
IconUpload,
|
||||||
IconX,
|
IconX,
|
||||||
|
IconVideo,
|
||||||
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { useProxy } from "valtio/utils";
|
import { useProxy } from "valtio/utils";
|
||||||
|
import { convertYoutubeUrlToEmbed } from '@/app/admin/(dashboard)/desa/gallery/lib/youtube-utils';
|
||||||
|
|
||||||
|
interface ExistingImage {
|
||||||
|
id: string;
|
||||||
|
link: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BeritaData {
|
||||||
|
id: string;
|
||||||
|
judul: string;
|
||||||
|
deskripsi: string;
|
||||||
|
content: string;
|
||||||
|
kategoriBeritaId: string | null;
|
||||||
|
imageId: string | null;
|
||||||
|
image?: { link: string } | null;
|
||||||
|
images?: ExistingImage[];
|
||||||
|
linkVideo?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
function EditBerita() {
|
function EditBerita() {
|
||||||
const beritaState = useProxy(stateDashboardBerita);
|
const beritaState = useProxy(stateDashboardBerita);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
|
// Featured image state
|
||||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
// Gallery images state
|
||||||
|
const [existingGalleryImages, setExistingGalleryImages] = useState<ExistingImage[]>([]);
|
||||||
|
const [galleryFiles, setGalleryFiles] = useState<File[]>([]);
|
||||||
|
const [galleryPreviews, setGalleryPreviews] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// YouTube link state
|
||||||
|
const [youtubeLink, setYoutubeLink] = useState('');
|
||||||
|
const [originalYoutubeLink, setOriginalYoutubeLink] = useState('');
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
judul: "",
|
judul: "",
|
||||||
deskripsi: "",
|
deskripsi: "",
|
||||||
@@ -48,9 +82,17 @@ function EditBerita() {
|
|||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [originalData, setOriginalData] = useState({
|
||||||
|
judul: "",
|
||||||
|
deskripsi: "",
|
||||||
|
kategoriBeritaId: "",
|
||||||
|
content: "",
|
||||||
|
imageId: "",
|
||||||
|
imageUrl: ""
|
||||||
|
});
|
||||||
|
|
||||||
// Helper function to check if HTML content is empty
|
// Helper function to check if HTML content is empty
|
||||||
const isHtmlEmpty = (html: string) => {
|
const isHtmlEmpty = (html: string) => {
|
||||||
// Remove all HTML tags and check if there's any text content
|
|
||||||
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
return textContent === '';
|
return textContent === '';
|
||||||
};
|
};
|
||||||
@@ -61,21 +103,12 @@ function EditBerita() {
|
|||||||
formData.judul?.trim() !== '' &&
|
formData.judul?.trim() !== '' &&
|
||||||
formData.kategoriBeritaId !== '' &&
|
formData.kategoriBeritaId !== '' &&
|
||||||
!isHtmlEmpty(formData.deskripsi) &&
|
!isHtmlEmpty(formData.deskripsi) &&
|
||||||
(file !== null || originalData.imageId !== '') && // Either a new file is selected or an existing image exists
|
(file !== null || originalData.imageId !== '') &&
|
||||||
!isHtmlEmpty(formData.content)
|
!isHtmlEmpty(formData.content)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [originalData, setOriginalData] = useState({
|
// Load data
|
||||||
judul: "",
|
|
||||||
deskripsi: "",
|
|
||||||
kategoriBeritaId: "",
|
|
||||||
content: "",
|
|
||||||
imageId: "",
|
|
||||||
imageUrl: ""
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load kategori + berita
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
beritaState.kategoriBerita.findMany.load();
|
beritaState.kategoriBerita.findMany.load();
|
||||||
|
|
||||||
@@ -84,7 +117,7 @@ function EditBerita() {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await stateDashboardBerita.berita.edit.load(id);
|
const data = await stateDashboardBerita.berita.edit.load(id) as BeritaData | null;
|
||||||
if (data) {
|
if (data) {
|
||||||
setFormData({
|
setFormData({
|
||||||
judul: data.judul || "",
|
judul: data.judul || "",
|
||||||
@@ -106,6 +139,17 @@ function EditBerita() {
|
|||||||
if (data?.image?.link) {
|
if (data?.image?.link) {
|
||||||
setPreviewImage(data.image.link);
|
setPreviewImage(data.image.link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load gallery images
|
||||||
|
if (data?.images && data.images.length > 0) {
|
||||||
|
setExistingGalleryImages(data.images);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load YouTube link
|
||||||
|
if (data?.linkVideo) {
|
||||||
|
setYoutubeLink(data.linkVideo);
|
||||||
|
setOriginalYoutubeLink(data.linkVideo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading berita:", error);
|
console.error("Error loading berita:", error);
|
||||||
@@ -120,27 +164,59 @@ function EditBerita() {
|
|||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGalleryDrop = (files: File[]) => {
|
||||||
|
const maxImages = 10;
|
||||||
|
const currentCount = existingGalleryImages.length + galleryFiles.length;
|
||||||
|
const availableSlots = maxImages - currentCount;
|
||||||
|
|
||||||
|
if (availableSlots <= 0) {
|
||||||
|
toast.warn('Maksimal 10 gambar untuk galeri');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFiles = files.slice(0, availableSlots);
|
||||||
|
|
||||||
|
if (newFiles.length === 0) {
|
||||||
|
toast.warn('Tidak ada slot tersisa untuk gambar galeri');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGalleryFiles([...galleryFiles, ...newFiles]);
|
||||||
|
|
||||||
|
const newPreviews = newFiles.map((f) => URL.createObjectURL(f));
|
||||||
|
setGalleryPreviews([...galleryPreviews, ...newPreviews]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeGalleryImage = (index: number, isExisting: boolean = false) => {
|
||||||
|
if (isExisting) {
|
||||||
|
setExistingGalleryImages(existingGalleryImages.filter((_, i) => i !== index));
|
||||||
|
} else {
|
||||||
|
setGalleryFiles(galleryFiles.filter((_, i) => i !== index));
|
||||||
|
setGalleryPreviews(galleryPreviews.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formData.judul?.trim()) {
|
if (!formData.judul?.trim()) {
|
||||||
toast.error('Judul wajib diisi');
|
toast.error('Judul wajib diisi');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.kategoriBeritaId) {
|
if (!formData.kategoriBeritaId) {
|
||||||
toast.error('Kategori wajib dipilih');
|
toast.error('Kategori wajib dipilih');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHtmlEmpty(formData.deskripsi)) {
|
if (isHtmlEmpty(formData.deskripsi)) {
|
||||||
toast.error('Deskripsi singkat wajib diisi');
|
toast.error('Deskripsi singkat wajib diisi');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file && !originalData.imageId) {
|
if (!file && !originalData.imageId) {
|
||||||
toast.error('Gambar wajib dipilih');
|
toast.error('Gambar utama wajib dipilih');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHtmlEmpty(formData.content)) {
|
if (isHtmlEmpty(formData.content)) {
|
||||||
toast.error('Konten wajib diisi');
|
toast.error('Konten wajib diisi');
|
||||||
return;
|
return;
|
||||||
@@ -148,12 +224,14 @@ function EditBerita() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
// Update global state hanya sekali di sini
|
|
||||||
|
// Update global state
|
||||||
beritaState.berita.edit.form = {
|
beritaState.berita.edit.form = {
|
||||||
...beritaState.berita.edit.form,
|
...beritaState.berita.edit.form,
|
||||||
...formData,
|
...formData,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Upload new featured image if changed
|
||||||
if (file) {
|
if (file) {
|
||||||
const res = await ApiFetch.api.fileStorage.create.post({
|
const res = await ApiFetch.api.fileStorage.create.post({
|
||||||
file,
|
file,
|
||||||
@@ -162,12 +240,33 @@ function EditBerita() {
|
|||||||
const uploaded = res.data?.data;
|
const uploaded = res.data?.data;
|
||||||
|
|
||||||
if (!uploaded?.id) {
|
if (!uploaded?.id) {
|
||||||
return toast.error("Gagal upload gambar");
|
return toast.error("Gagal upload gambar utama");
|
||||||
}
|
}
|
||||||
|
|
||||||
beritaState.berita.edit.form.imageId = uploaded.id;
|
beritaState.berita.edit.form.imageId = uploaded.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upload new gallery images
|
||||||
|
const newGalleryIds: string[] = [];
|
||||||
|
for (const galleryFile of galleryFiles) {
|
||||||
|
const galleryRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
|
file: galleryFile,
|
||||||
|
name: galleryFile.name,
|
||||||
|
});
|
||||||
|
const galleryUploaded = galleryRes.data?.data;
|
||||||
|
if (galleryUploaded?.id) {
|
||||||
|
newGalleryIds.push(galleryUploaded.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine existing (not removed) and new gallery images
|
||||||
|
const remainingExistingIds = existingGalleryImages.map(img => img.id);
|
||||||
|
beritaState.berita.edit.form.imageIds = [...remainingExistingIds, ...newGalleryIds];
|
||||||
|
|
||||||
|
// Set YouTube link
|
||||||
|
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||||
|
beritaState.berita.edit.form.linkVideo = embedLink || '';
|
||||||
|
|
||||||
await beritaState.berita.edit.update();
|
await beritaState.berita.edit.update();
|
||||||
toast.success("Berita berhasil diperbarui!");
|
toast.success("Berita berhasil diperbarui!");
|
||||||
router.push("/admin/desa/berita/list-berita");
|
router.push("/admin/desa/berita/list-berita");
|
||||||
@@ -189,9 +288,12 @@ function EditBerita() {
|
|||||||
});
|
});
|
||||||
setPreviewImage(originalData.imageUrl || null);
|
setPreviewImage(originalData.imageUrl || null);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
|
setYoutubeLink(originalYoutubeLink);
|
||||||
toast.info("Form dikembalikan ke data awal");
|
toast.info("Form dikembalikan ke data awal");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -219,6 +321,7 @@ function EditBerita() {
|
|||||||
style={{ border: "1px solid #e0e0e0" }}
|
style={{ border: "1px solid #e0e0e0" }}
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
{/* Judul */}
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Judul"
|
label="Judul"
|
||||||
placeholder="Masukkan judul"
|
placeholder="Masukkan judul"
|
||||||
@@ -227,6 +330,7 @@ function EditBerita() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Kategori */}
|
||||||
<Select
|
<Select
|
||||||
value={formData.kategoriBeritaId}
|
value={formData.kategoriBeritaId}
|
||||||
onChange={(val) => handleChange("kategoriBeritaId", val || "")}
|
onChange={(val) => handleChange("kategoriBeritaId", val || "")}
|
||||||
@@ -241,9 +345,9 @@ function EditBerita() {
|
|||||||
clearable
|
clearable
|
||||||
searchable
|
searchable
|
||||||
required
|
required
|
||||||
error={!formData.kategoriBeritaId ? "Pilih kategori" : undefined}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Deskripsi */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="sm" fw="bold">
|
<Text fz="sm" fw="bold">
|
||||||
Deskripsi Singkat
|
Deskripsi Singkat
|
||||||
@@ -256,11 +360,10 @@ function EditBerita() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Featured Image */}
|
||||||
{/* Upload Gambar */}
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Gambar Berita
|
Gambar Utama (Featured)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={(files) => {
|
onDrop={(files) => {
|
||||||
@@ -274,17 +377,13 @@ function EditBerita() {
|
|||||||
toast.error("File tidak valid, gunakan format gambar")
|
toast.error("File tidak valid, gunakan format gambar")
|
||||||
}
|
}
|
||||||
maxSize={5 * 1024 ** 2}
|
maxSize={5 * 1024 ** 2}
|
||||||
accept={{ "image/*": [] }}
|
accept={{ "image/*": ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||||
radius="md"
|
radius="md"
|
||||||
p="xl"
|
p="xl"
|
||||||
>
|
>
|
||||||
<Group justify="center" gap="xl" mih={180}>
|
<Group justify="center" gap="xl" mih={180}>
|
||||||
<Dropzone.Accept>
|
<Dropzone.Accept>
|
||||||
<IconUpload
|
<IconUpload size={48} color={colors["blue-button"]} stroke={1.5} />
|
||||||
size={48}
|
|
||||||
color={colors["blue-button"]}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</Dropzone.Accept>
|
</Dropzone.Accept>
|
||||||
<Dropzone.Reject>
|
<Dropzone.Reject>
|
||||||
<IconX size={48} color="red" stroke={1.5} />
|
<IconX size={48} color="red" stroke={1.5} />
|
||||||
@@ -292,14 +391,6 @@ function EditBerita() {
|
|||||||
<Dropzone.Idle>
|
<Dropzone.Idle>
|
||||||
<IconPhoto size={48} color="#868e96" stroke={1.5} />
|
<IconPhoto size={48} color="#868e96" stroke={1.5} />
|
||||||
</Dropzone.Idle>
|
</Dropzone.Idle>
|
||||||
<Stack gap="xs" align="center">
|
|
||||||
<Text size="md" fw={500}>
|
|
||||||
Seret gambar atau klik untuk memilih file
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Dropzone>
|
</Dropzone>
|
||||||
|
|
||||||
@@ -328,9 +419,7 @@ function EditBerita() {
|
|||||||
setPreviewImage(null);
|
setPreviewImage(null);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -338,6 +427,138 @@ function EditBerita() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Gallery Images */}
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
Galeri Gambar (Opsional - Maksimal 10)
|
||||||
|
</Text>
|
||||||
|
<Dropzone
|
||||||
|
onDrop={handleGalleryDrop}
|
||||||
|
onReject={() => toast.error("File tidak valid, gunakan format gambar")}
|
||||||
|
maxSize={5 * 1024 ** 2}
|
||||||
|
accept={{ "image/*": ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||||
|
radius="md"
|
||||||
|
p="md"
|
||||||
|
multiple
|
||||||
|
>
|
||||||
|
<Group justify="center" gap="xl" mih={120}>
|
||||||
|
<Dropzone.Accept>
|
||||||
|
<IconUpload size={40} color={colors["blue-button"]} stroke={1.5} />
|
||||||
|
</Dropzone.Accept>
|
||||||
|
<Dropzone.Reject>
|
||||||
|
<IconX size={40} color="red" stroke={1.5} />
|
||||||
|
</Dropzone.Reject>
|
||||||
|
<Dropzone.Idle>
|
||||||
|
<IconPhoto size={40} color="#868e96" stroke={1.5} />
|
||||||
|
</Dropzone.Idle>
|
||||||
|
</Group>
|
||||||
|
<Text ta="center" mt="sm" size="xs" color="dimmed">
|
||||||
|
Seret gambar untuk menambahkan ke galeri
|
||||||
|
</Text>
|
||||||
|
</Dropzone>
|
||||||
|
|
||||||
|
{/* Existing Gallery Images */}
|
||||||
|
{existingGalleryImages.length > 0 && (
|
||||||
|
<Box mt="sm">
|
||||||
|
<Text fz="xs" fw="bold" mb={6} c="dimmed">
|
||||||
|
Gambar Existing ({existingGalleryImages.length})
|
||||||
|
</Text>
|
||||||
|
<Grid gutter="sm">
|
||||||
|
{existingGalleryImages.map((img, index) => (
|
||||||
|
<Grid.Col span={4} key={img.id}>
|
||||||
|
<Card p="xs" radius="md" withBorder>
|
||||||
|
<Image src={img.link} alt={img.name} radius="sm" height={100} fit="cover" />
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
pos="absolute"
|
||||||
|
top={5}
|
||||||
|
right={5}
|
||||||
|
onClick={() => removeGalleryImage(index, true)}
|
||||||
|
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New Gallery Images */}
|
||||||
|
{galleryPreviews.length > 0 && (
|
||||||
|
<Box mt="sm">
|
||||||
|
<Text fz="xs" fw="bold" mb={6} c="dimmed">
|
||||||
|
Gambar Baru ({galleryPreviews.length})
|
||||||
|
</Text>
|
||||||
|
<Grid gutter="sm">
|
||||||
|
{galleryPreviews.map((preview, index) => (
|
||||||
|
<Grid.Col span={4} key={index}>
|
||||||
|
<Card p="xs" radius="md" withBorder>
|
||||||
|
<Image src={preview} alt={`New ${index}`} radius="sm" height={100} fit="cover" />
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
pos="absolute"
|
||||||
|
top={5}
|
||||||
|
right={5}
|
||||||
|
onClick={() => removeGalleryImage(index, false)}
|
||||||
|
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* YouTube Video */}
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
Link Video YouTube (Opsional)
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
placeholder="https://www.youtube.com/watch?v=..."
|
||||||
|
value={youtubeLink}
|
||||||
|
onChange={(e) => setYoutubeLink(e.currentTarget.value)}
|
||||||
|
leftSection={<IconVideo size={18} />}
|
||||||
|
rightSection={
|
||||||
|
youtubeLink && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => setYoutubeLink('')}
|
||||||
|
>
|
||||||
|
<IconX size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{embedLink && (
|
||||||
|
<Box mt="sm" pos="relative">
|
||||||
|
<iframe
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
width: '100%',
|
||||||
|
height: 250,
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
}}
|
||||||
|
src={embedLink}
|
||||||
|
title="Preview Video"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Konten */}
|
{/* Konten */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="sm" fw="bold">
|
<Text fz="sm" fw="bold">
|
||||||
@@ -351,9 +572,8 @@ function EditBerita() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Action */}
|
{/* Action Buttons */}
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
{/* Tombol Batal */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -363,8 +583,6 @@ function EditBerita() {
|
|||||||
>
|
>
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Tombol Simpan */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
|
import { Box, Button, Card, Grid, Group, Image, Paper, Skeleton, Stack, Text, Badge, AspectRatio } from '@mantine/core';
|
||||||
import { useShallowEffect } from '@mantine/hooks';
|
import { useShallowEffect } from '@mantine/hooks';
|
||||||
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
import { IconArrowBack, IconEdit, IconTrash, IconVideo } from '@tabler/icons-react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useProxy } from 'valtio/utils';
|
import { useProxy } from 'valtio/utils';
|
||||||
@@ -10,6 +10,23 @@ import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirma
|
|||||||
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
|
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
|
||||||
import colors from '@/con/colors';
|
import colors from '@/con/colors';
|
||||||
|
|
||||||
|
interface ExistingImage {
|
||||||
|
id: string;
|
||||||
|
link: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BeritaDetail {
|
||||||
|
id: string;
|
||||||
|
judul: string;
|
||||||
|
deskripsi: string;
|
||||||
|
content: string;
|
||||||
|
image?: { link: string } | null;
|
||||||
|
images?: ExistingImage[];
|
||||||
|
linkVideo?: string | null;
|
||||||
|
kategoriBerita?: { name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
function DetailBerita() {
|
function DetailBerita() {
|
||||||
const beritaState = useProxy(stateDashboardBerita);
|
const beritaState = useProxy(stateDashboardBerita);
|
||||||
const [modalHapus, setModalHapus] = useState(false);
|
const [modalHapus, setModalHapus] = useState(false);
|
||||||
@@ -38,7 +55,7 @@ function DetailBerita() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = beritaState.berita.findUnique.data;
|
const data = beritaState.berita.findUnique.data as unknown as BeritaDetail;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||||
@@ -68,71 +85,131 @@ function DetailBerita() {
|
|||||||
|
|
||||||
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
|
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
|
{/* Kategori */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Kategori</Text>
|
<Text fz="lg" fw="bold">Kategori</Text>
|
||||||
<Text fz="md" c="dimmed">{data.kategoriBerita?.name || '-'}</Text>
|
<Text fz="md" c="dimmed">{data.kategoriBerita?.name || '-'}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Judul */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Judul</Text>
|
<Text fz="lg" fw="bold">Judul</Text>
|
||||||
<Text fz="md" c="dimmed">{data.judul || '-'}</Text>
|
<Text fz="md" c="dimmed">{data.judul || '-'}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Deskripsi */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Deskripsi</Text>
|
<Text fz="lg" fw="bold">Deskripsi</Text>
|
||||||
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }} />
|
<Text
|
||||||
|
fz="md"
|
||||||
|
c="dimmed"
|
||||||
|
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Gambar Utama (Featured) */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Gambar</Text>
|
<Text fz="lg" fw="bold">Gambar Utama</Text>
|
||||||
{data.image?.link ? (
|
{data.image?.link ? (
|
||||||
<Image
|
<Image
|
||||||
src={data.image.link}
|
src={data.image.link}
|
||||||
alt={data.judul || 'Gambar Berita'}
|
alt={data.judul || 'Gambar Berita'}
|
||||||
w={200}
|
w={{ base: '100%', md: 400 }}
|
||||||
h={200}
|
h={300}
|
||||||
radius="md"
|
radius="md"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
loading='lazy'
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Text fz="sm" c="dimmed">Tidak ada gambar</Text>
|
<Text fz="sm" c="dimmed">Tidak ada gambar utama</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Gallery Images */}
|
||||||
|
{data.images && data.images.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Group gap="xs" mb="sm">
|
||||||
|
<Text fz="lg" fw="bold">Galeri Gambar</Text>
|
||||||
|
<Badge color="blue" variant="light">
|
||||||
|
{data.images.length}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Grid gutter="md">
|
||||||
|
{data.images.map((img, index) => (
|
||||||
|
<Grid.Col span={{ base: 6, md: 4 }} key={img.id}>
|
||||||
|
<Card p="xs" radius="md" withBorder>
|
||||||
|
<Image
|
||||||
|
src={img.link}
|
||||||
|
alt={img.name || `Gallery ${index + 1}`}
|
||||||
|
h={150}
|
||||||
|
radius="sm"
|
||||||
|
fit="cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* YouTube Video */}
|
||||||
|
{data.linkVideo && (
|
||||||
|
<Box>
|
||||||
|
<Group gap="xs" mb="sm">
|
||||||
|
<Text fz="lg" fw="bold">Video YouTube</Text>
|
||||||
|
<IconVideo size={20} color={colors['blue-button']} />
|
||||||
|
</Group>
|
||||||
|
<AspectRatio ratio={16 / 9} mah={400}>
|
||||||
|
<iframe
|
||||||
|
src={data.linkVideo}
|
||||||
|
title="YouTube Video"
|
||||||
|
allowFullScreen
|
||||||
|
style={{ borderRadius: 10, border: '1px solid #ddd' }}
|
||||||
|
/>
|
||||||
|
</AspectRatio>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Konten */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Konten</Text>
|
<Text fz="lg" fw="bold">Konten</Text>
|
||||||
<Text
|
<Paper bg="white" p="md" radius="md" mt="xs">
|
||||||
fz="md"
|
<Text
|
||||||
c="dimmed"
|
fz="md"
|
||||||
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
|
c="dimmed"
|
||||||
/>
|
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Action Button */}
|
{/* Action Buttons */}
|
||||||
<Group gap="sm">
|
<Group gap="sm" mt="md">
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color="red"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedId(data.id);
|
setSelectedId(data.id);
|
||||||
setModalHapus(true);
|
setModalHapus(true);
|
||||||
}}
|
}}
|
||||||
variant="light"
|
variant="light"
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
>
|
leftSection={<IconTrash size={20} />}
|
||||||
<IconTrash size={20} />
|
>
|
||||||
</Button>
|
Hapus
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="green"
|
color="green"
|
||||||
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
|
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
|
||||||
variant="light"
|
variant="light"
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
>
|
leftSection={<IconEdit size={20} />}
|
||||||
<IconEdit size={20} />
|
>
|
||||||
</Button>
|
Edit
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -15,26 +15,38 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
Loader,
|
Loader,
|
||||||
ActionIcon
|
ActionIcon,
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import { useShallowEffect } from '@mantine/hooks';
|
import { useShallowEffect } from '@mantine/hooks';
|
||||||
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
import { IconArrowBack, IconPhoto, IconUpload, IconX, IconVideo, IconTrash } from '@tabler/icons-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useProxy } from 'valtio/utils';
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import { convertYoutubeUrlToEmbed } from '@/app/admin/(dashboard)/desa/gallery/lib/youtube-utils';
|
||||||
|
|
||||||
export default function CreateBerita() {
|
export default function CreateBerita() {
|
||||||
const beritaState = useProxy(stateDashboardBerita);
|
const beritaState = useProxy(stateDashboardBerita);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Featured image state
|
||||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const router = useRouter();
|
|
||||||
|
// Gallery images state
|
||||||
|
const [galleryFiles, setGalleryFiles] = useState<File[]>([]);
|
||||||
|
const [galleryPreviews, setGalleryPreviews] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// YouTube link state
|
||||||
|
const [youtubeLink, setYoutubeLink] = useState('');
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
// Helper function to check if HTML content is empty
|
// Helper function to check if HTML content is empty
|
||||||
const isHtmlEmpty = (html: string) => {
|
const isHtmlEmpty = (html: string) => {
|
||||||
// Remove all HTML tags and check if there's any text content
|
|
||||||
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
return textContent === '';
|
return textContent === '';
|
||||||
};
|
};
|
||||||
@@ -61,9 +73,35 @@ export default function CreateBerita() {
|
|||||||
kategoriBeritaId: '',
|
kategoriBeritaId: '',
|
||||||
imageId: '',
|
imageId: '',
|
||||||
content: '',
|
content: '',
|
||||||
|
imageIds: [],
|
||||||
|
linkVideo: '',
|
||||||
};
|
};
|
||||||
setPreviewImage(null);
|
setPreviewImage(null);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
|
setGalleryFiles([]);
|
||||||
|
setGalleryPreviews([]);
|
||||||
|
setYoutubeLink('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGalleryDrop = (files: File[]) => {
|
||||||
|
const newFiles = files.filter(
|
||||||
|
(_, index) => galleryFiles.length + index < 10 // Max 10 images
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newFiles.length === 0) {
|
||||||
|
toast.warn('Maksimal 10 gambar untuk galeri');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGalleryFiles([...galleryFiles, ...newFiles]);
|
||||||
|
|
||||||
|
const newPreviews = newFiles.map((f) => URL.createObjectURL(f));
|
||||||
|
setGalleryPreviews([...galleryPreviews, ...newPreviews]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeGalleryImage = (index: number) => {
|
||||||
|
setGalleryFiles(galleryFiles.filter((_, i) => i !== index));
|
||||||
|
setGalleryPreviews(galleryPreviews.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@@ -71,22 +109,22 @@ export default function CreateBerita() {
|
|||||||
toast.error('Judul wajib diisi');
|
toast.error('Judul wajib diisi');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!beritaState.berita.create.form.kategoriBeritaId) {
|
if (!beritaState.berita.create.form.kategoriBeritaId) {
|
||||||
toast.error('Kategori wajib dipilih');
|
toast.error('Kategori wajib dipilih');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHtmlEmpty(beritaState.berita.create.form.deskripsi)) {
|
if (isHtmlEmpty(beritaState.berita.create.form.deskripsi)) {
|
||||||
toast.error('Deskripsi singkat wajib diisi');
|
toast.error('Deskripsi singkat wajib diisi');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
toast.error('Gambar wajib dipilih');
|
toast.error('Gambar utama wajib dipilih');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHtmlEmpty(beritaState.berita.create.form.content)) {
|
if (isHtmlEmpty(beritaState.berita.create.form.content)) {
|
||||||
toast.error('Konten wajib diisi');
|
toast.error('Konten wajib diisi');
|
||||||
return;
|
return;
|
||||||
@@ -94,21 +132,37 @@ export default function CreateBerita() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
if (!file) {
|
|
||||||
return toast.warn('Silakan pilih file gambar terlebih dahulu');
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await ApiFetch.api.fileStorage.create.post({
|
// Upload featured image
|
||||||
|
const featuredRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
file,
|
file,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
});
|
});
|
||||||
|
const featuredUploaded = featuredRes.data?.data;
|
||||||
const uploaded = res.data?.data;
|
if (!featuredUploaded?.id) {
|
||||||
if (!uploaded?.id) {
|
return toast.error('Gagal mengunggah gambar utama');
|
||||||
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
|
|
||||||
}
|
}
|
||||||
|
beritaState.berita.create.form.imageId = featuredUploaded.id;
|
||||||
|
|
||||||
beritaState.berita.create.form.imageId = uploaded.id;
|
// Upload gallery images
|
||||||
|
const galleryIds: string[] = [];
|
||||||
|
for (const galleryFile of galleryFiles) {
|
||||||
|
const galleryRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
|
file: galleryFile,
|
||||||
|
name: galleryFile.name,
|
||||||
|
});
|
||||||
|
const galleryUploaded = galleryRes.data?.data;
|
||||||
|
if (galleryUploaded?.id) {
|
||||||
|
galleryIds.push(galleryUploaded.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
beritaState.berita.create.form.imageIds = galleryIds;
|
||||||
|
|
||||||
|
// Set YouTube link if provided
|
||||||
|
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||||
|
if (embedLink) {
|
||||||
|
beritaState.berita.create.form.linkVideo = embedLink;
|
||||||
|
}
|
||||||
|
|
||||||
await beritaState.berita.create.create();
|
await beritaState.berita.create.create();
|
||||||
|
|
||||||
@@ -122,16 +176,13 @@ export default function CreateBerita() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
{/* Header dengan tombol kembali */}
|
{/* Header */}
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<Button
|
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||||
variant="subtle"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
p="xs"
|
|
||||||
radius="md"
|
|
||||||
>
|
|
||||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
</Button>
|
</Button>
|
||||||
<Title order={4} ml="sm" c="dark">
|
<Title order={4} ml="sm" c="dark">
|
||||||
@@ -148,6 +199,7 @@ export default function CreateBerita() {
|
|||||||
style={{ border: '1px solid #e0e0e0' }}
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
{/* Judul */}
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Judul"
|
label="Judul"
|
||||||
placeholder="Masukkan judul berita"
|
placeholder="Masukkan judul berita"
|
||||||
@@ -156,6 +208,7 @@ export default function CreateBerita() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Kategori */}
|
||||||
<Select
|
<Select
|
||||||
label="Kategori"
|
label="Kategori"
|
||||||
placeholder="Pilih kategori"
|
placeholder="Pilih kategori"
|
||||||
@@ -182,6 +235,7 @@ export default function CreateBerita() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Deskripsi */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="sm" fw="bold" mb={6}>
|
<Text fz="sm" fw="bold" mb={6}>
|
||||||
Deskripsi Singkat
|
Deskripsi Singkat
|
||||||
@@ -194,9 +248,10 @@ export default function CreateBerita() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Featured Image */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Gambar Berita
|
Gambar Utama (Featured)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={(files) => {
|
onDrop={(files) => {
|
||||||
@@ -232,17 +287,11 @@ export default function CreateBerita() {
|
|||||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||||
<Image
|
<Image
|
||||||
src={previewImage}
|
src={previewImage}
|
||||||
alt="Preview Gambar"
|
alt="Preview Gambar Utama"
|
||||||
radius="md"
|
radius="md"
|
||||||
style={{
|
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
|
||||||
maxHeight: 200,
|
|
||||||
objectFit: 'contain',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
}}
|
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Tombol hapus (pojok kanan atas) */}
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color="red"
|
color="red"
|
||||||
@@ -255,9 +304,7 @@ export default function CreateBerita() {
|
|||||||
setPreviewImage(null);
|
setPreviewImage(null);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -265,6 +312,102 @@ export default function CreateBerita() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Gallery Images */}
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
Galeri Gambar (Opsional - Maksimal 10)
|
||||||
|
</Text>
|
||||||
|
<Dropzone
|
||||||
|
onDrop={handleGalleryDrop}
|
||||||
|
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||||
|
maxSize={5 * 1024 ** 2}
|
||||||
|
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||||
|
radius="md"
|
||||||
|
p="md"
|
||||||
|
multiple
|
||||||
|
>
|
||||||
|
<Group justify="center" gap="xl" mih={120}>
|
||||||
|
<Dropzone.Accept>
|
||||||
|
<IconUpload size={40} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Accept>
|
||||||
|
<Dropzone.Reject>
|
||||||
|
<IconX size={40} color="var(--mantine-color-red-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Reject>
|
||||||
|
<Dropzone.Idle>
|
||||||
|
<IconPhoto size={40} color="var(--mantine-color-dimmed)" stroke={1.5} />
|
||||||
|
</Dropzone.Idle>
|
||||||
|
</Group>
|
||||||
|
<Text ta="center" mt="sm" size="xs" color="dimmed">
|
||||||
|
Seret gambar atau klik untuk menambahkan ke galeri
|
||||||
|
</Text>
|
||||||
|
</Dropzone>
|
||||||
|
|
||||||
|
{galleryPreviews.length > 0 && (
|
||||||
|
<Grid mt="sm" gutter="sm">
|
||||||
|
{galleryPreviews.map((preview, index) => (
|
||||||
|
<Grid.Col span={4} key={index}>
|
||||||
|
<Card p="xs" radius="md" withBorder>
|
||||||
|
<Image src={preview} alt={`Gallery ${index}`} radius="sm" height={100} fit="cover" />
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
pos="absolute"
|
||||||
|
top={5}
|
||||||
|
right={5}
|
||||||
|
onClick={() => removeGalleryImage(index)}
|
||||||
|
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* YouTube Video */}
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
Link Video YouTube (Opsional)
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
placeholder="https://www.youtube.com/watch?v=..."
|
||||||
|
value={youtubeLink}
|
||||||
|
onChange={(e) => setYoutubeLink(e.currentTarget.value)}
|
||||||
|
leftSection={<IconVideo size={18} />}
|
||||||
|
rightSection={
|
||||||
|
youtubeLink && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => setYoutubeLink('')}
|
||||||
|
>
|
||||||
|
<IconX size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{embedLink && (
|
||||||
|
<Box mt="sm" pos="relative">
|
||||||
|
<iframe
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
width: '100%',
|
||||||
|
height: 250,
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
}}
|
||||||
|
src={embedLink}
|
||||||
|
title="Preview Video"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Konten */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="sm" fw="bold" mb={6}>
|
<Text fz="sm" fw="bold" mb={6}>
|
||||||
Konten
|
Konten
|
||||||
@@ -277,6 +420,7 @@ export default function CreateBerita() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -287,8 +431,6 @@ export default function CreateBerita() {
|
|||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Tombol Simpan */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
249
src/app/admin/(dashboard)/kependudukan/data-banjar/[id]/page.tsx
Normal file
249
src/app/admin/(dashboard)/kependudukan/data-banjar/[id]/page.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Title,
|
||||||
|
NumberInput,
|
||||||
|
TextInput
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { IconArrowBack } from '@tabler/icons-react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import dataBanjar from '../../../_state/kependudukan/data-banjar';
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
nama: string;
|
||||||
|
penduduk: number;
|
||||||
|
kk: number;
|
||||||
|
miskin: number;
|
||||||
|
tahun: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditDataBanjar() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = useParams() as { id: string };
|
||||||
|
const stateDataBanjar = useProxy(dataBanjar);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<FormData>({
|
||||||
|
nama: '',
|
||||||
|
penduduk: 0,
|
||||||
|
kk: 0,
|
||||||
|
miskin: 0,
|
||||||
|
tahun: new Date().getFullYear(),
|
||||||
|
});
|
||||||
|
const [originalData, setOriginalData] = useState<FormData>({
|
||||||
|
nama: '',
|
||||||
|
penduduk: 0,
|
||||||
|
kk: 0,
|
||||||
|
miskin: 0,
|
||||||
|
tahun: new Date().getFullYear(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.nama?.trim() !== '' &&
|
||||||
|
formData.penduduk !== null &&
|
||||||
|
formData.penduduk >= 0 &&
|
||||||
|
formData.kk !== null &&
|
||||||
|
formData.kk >= 0 &&
|
||||||
|
formData.miskin !== null &&
|
||||||
|
formData.miskin >= 0 &&
|
||||||
|
formData.tahun !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
stateDataBanjar.update.id = id;
|
||||||
|
await stateDataBanjar.findUnique.load(id);
|
||||||
|
|
||||||
|
const data = stateDataBanjar.findUnique.data;
|
||||||
|
if (data) {
|
||||||
|
setFormData({
|
||||||
|
nama: data.nama ?? '',
|
||||||
|
penduduk: Number(data.penduduk ?? 0),
|
||||||
|
kk: Number(data.kk ?? 0),
|
||||||
|
miskin: Number(data.miskin ?? 0),
|
||||||
|
tahun: Number(data.tahun ?? currentYear),
|
||||||
|
});
|
||||||
|
setOriginalData({
|
||||||
|
nama: data.nama ?? '',
|
||||||
|
penduduk: Number(data.penduduk ?? 0),
|
||||||
|
kk: Number(data.kk ?? 0),
|
||||||
|
miskin: Number(data.miskin ?? 0),
|
||||||
|
tahun: Number(data.tahun ?? currentYear),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading data:', error);
|
||||||
|
toast.error('Gagal memuat data');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof FormData) =>
|
||||||
|
(value: any) => {
|
||||||
|
const val =
|
||||||
|
field === 'penduduk' || field === 'kk' || field === 'miskin' || field === 'tahun'
|
||||||
|
? Number(value || 0)
|
||||||
|
: value;
|
||||||
|
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: val }));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
nama: originalData.nama,
|
||||||
|
penduduk: Number(originalData.penduduk),
|
||||||
|
kk: Number(originalData.kk),
|
||||||
|
miskin: Number(originalData.miskin),
|
||||||
|
tahun: Number(originalData.tahun),
|
||||||
|
});
|
||||||
|
toast.info("Form dikembalikan ke data awal");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
stateDataBanjar.update.id = id;
|
||||||
|
stateDataBanjar.update.form = { ...formData };
|
||||||
|
|
||||||
|
await stateDataBanjar.update.submit();
|
||||||
|
|
||||||
|
toast.success('Data berhasil diperbarui');
|
||||||
|
router.push('/admin/kependudukan/data-banjar');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating data:', error);
|
||||||
|
toast.error('Gagal memperbarui data');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||||
|
{/* Header */}
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
p="xs"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
|
</Button>
|
||||||
|
<Title order={4} ml="sm" c="dark">
|
||||||
|
Edit Data Banjar
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Form Card */}
|
||||||
|
<Paper
|
||||||
|
w={{ base: '100%', md: '50%' }}
|
||||||
|
bg={colors['white-1']}
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
shadow="sm"
|
||||||
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Nama Banjar"
|
||||||
|
placeholder="Masukkan nama banjar"
|
||||||
|
value={formData.nama}
|
||||||
|
onChange={handleChange('nama')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Jumlah Penduduk"
|
||||||
|
placeholder="Masukkan jumlah penduduk"
|
||||||
|
value={formData.penduduk}
|
||||||
|
onChange={handleChange('penduduk')}
|
||||||
|
min={0}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Jumlah KK"
|
||||||
|
placeholder="Masukkan jumlah KK"
|
||||||
|
value={formData.kk}
|
||||||
|
onChange={handleChange('kk')}
|
||||||
|
min={0}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Jumlah Penduduk Miskin"
|
||||||
|
placeholder="Masukkan jumlah penduduk miskin"
|
||||||
|
value={formData.miskin}
|
||||||
|
onChange={handleChange('miskin')}
|
||||||
|
min={0}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Tahun"
|
||||||
|
placeholder="Masukkan tahun"
|
||||||
|
value={formData.tahun}
|
||||||
|
onChange={handleChange('tahun')}
|
||||||
|
min={2000}
|
||||||
|
max={currentYear + 1}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={handleResetForm}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
|
style={{
|
||||||
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `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>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Title,
|
||||||
|
NumberInput,
|
||||||
|
TextInput
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { IconArrowBack } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import dataBanjar from '../../../_state/kependudukan/data-banjar';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
function CreateDataBanjar() {
|
||||||
|
const stateDataBanjar = useProxy(dataBanjar);
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const yearOptions = Array.from({ length: 10 }, (_, i) => ({
|
||||||
|
value: String(currentYear - i),
|
||||||
|
label: String(currentYear - i),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateDataBanjar.create.form.nama?.trim() !== '' &&
|
||||||
|
stateDataBanjar.create.form.penduduk !== null &&
|
||||||
|
stateDataBanjar.create.form.penduduk >= 0 &&
|
||||||
|
stateDataBanjar.create.form.kk !== null &&
|
||||||
|
stateDataBanjar.create.form.kk >= 0 &&
|
||||||
|
stateDataBanjar.create.form.miskin !== null &&
|
||||||
|
stateDataBanjar.create.form.miskin >= 0 &&
|
||||||
|
stateDataBanjar.create.form.tahun !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
stateDataBanjar.create.form = {
|
||||||
|
nama: '',
|
||||||
|
penduduk: 0,
|
||||||
|
kk: 0,
|
||||||
|
miskin: 0,
|
||||||
|
tahun: currentYear,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const id = await stateDataBanjar.create.create();
|
||||||
|
if (id) {
|
||||||
|
resetForm();
|
||||||
|
router.push('/admin/kependudukan/data-banjar');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating data banjar:', error);
|
||||||
|
toast.error('Terjadi kesalahan saat menambah data banjar');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||||
|
{/* Header */}
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
p="xs"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
|
</Button>
|
||||||
|
<Title order={4} ml="sm" c="dark">
|
||||||
|
Tambah Data Banjar
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<Paper
|
||||||
|
w={{ base: '100%', md: '50%' }}
|
||||||
|
bg={colors['white-1']}
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
shadow="sm"
|
||||||
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Nama Banjar"
|
||||||
|
placeholder="Masukkan nama banjar"
|
||||||
|
value={stateDataBanjar.create.form.nama}
|
||||||
|
onChange={(e) => {
|
||||||
|
stateDataBanjar.create.form.nama = e.currentTarget.value;
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Jumlah Penduduk"
|
||||||
|
placeholder="Masukkan jumlah penduduk"
|
||||||
|
value={stateDataBanjar.create.form.penduduk}
|
||||||
|
onChange={(val) => {
|
||||||
|
stateDataBanjar.create.form.penduduk = Number(val || 0);
|
||||||
|
}}
|
||||||
|
min={0}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Jumlah KK"
|
||||||
|
placeholder="Masukkan jumlah KK"
|
||||||
|
value={stateDataBanjar.create.form.kk}
|
||||||
|
onChange={(val) => {
|
||||||
|
stateDataBanjar.create.form.kk = Number(val || 0);
|
||||||
|
}}
|
||||||
|
min={0}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Jumlah Penduduk Miskin"
|
||||||
|
placeholder="Masukkan jumlah penduduk miskin"
|
||||||
|
value={stateDataBanjar.create.form.miskin}
|
||||||
|
onChange={(val) => {
|
||||||
|
stateDataBanjar.create.form.miskin = Number(val || 0);
|
||||||
|
}}
|
||||||
|
min={0}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Tahun"
|
||||||
|
placeholder="Masukkan tahun"
|
||||||
|
value={stateDataBanjar.create.form.tahun}
|
||||||
|
onChange={(val) => {
|
||||||
|
stateDataBanjar.create.form.tahun = Number(val || currentYear);
|
||||||
|
}}
|
||||||
|
min={2000}
|
||||||
|
max={currentYear + 1}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={resetForm}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
|
style={{
|
||||||
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `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>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateDataBanjar;
|
||||||
304
src/app/admin/(dashboard)/kependudukan/data-banjar/page.tsx
Normal file
304
src/app/admin/(dashboard)/kependudukan/data-banjar/page.tsx
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
'use client'
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Group,
|
||||||
|
Pagination,
|
||||||
|
Paper,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableTbody,
|
||||||
|
TableTd,
|
||||||
|
TableTh,
|
||||||
|
TableThead,
|
||||||
|
TableTr,
|
||||||
|
Text,
|
||||||
|
Title
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||||
|
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import HeaderSearch from '../../_com/header';
|
||||||
|
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
|
||||||
|
import dataBanjar from '../../_state/kependudukan/data-banjar';
|
||||||
|
|
||||||
|
function DataBanjarAdmin() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<HeaderSearch
|
||||||
|
title='Data Banjar'
|
||||||
|
placeholder='Cari nama banjar...'
|
||||||
|
searchIcon={<IconSearch size={20} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<ListDataBanjar search={search} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListDataBanjar({ search }: { search: string }) {
|
||||||
|
type DataBanjarType = {
|
||||||
|
id: string;
|
||||||
|
nama: string;
|
||||||
|
penduduk: number;
|
||||||
|
kk: number;
|
||||||
|
miskin: number;
|
||||||
|
tahun: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const stateDataBanjar = useProxy(dataBanjar);
|
||||||
|
const [modalHapus, setModalHapus] = useState(false);
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
loading,
|
||||||
|
load,
|
||||||
|
} = stateDataBanjar.findMany;
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (selectedId) {
|
||||||
|
stateDataBanjar.delete.byId(selectedId);
|
||||||
|
setModalHapus(false);
|
||||||
|
setSelectedId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useShallowEffect(() => {
|
||||||
|
load(page, 10, debouncedSearch);
|
||||||
|
}, [page, debouncedSearch]);
|
||||||
|
|
||||||
|
const filteredData = data || [];
|
||||||
|
|
||||||
|
if (loading || !data) {
|
||||||
|
return (
|
||||||
|
<Stack py={10}>
|
||||||
|
<Skeleton height={600} radius="md" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box py={{ base: 'sm', md: 'md' }}>
|
||||||
|
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
|
||||||
|
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
|
||||||
|
<Title order={4} lh={{ base: 1.2, md: 1.15 }}>
|
||||||
|
List Data Banjar
|
||||||
|
</Title>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPlus size={18} />}
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
onClick={() => router.push('/admin/kependudukan/data-banjar/create')}
|
||||||
|
fz={{ base: 'sm', md: 'md' }}
|
||||||
|
>
|
||||||
|
Tambah Baru
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Desktop Table */}
|
||||||
|
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
|
||||||
|
<Table
|
||||||
|
highlightOnHover
|
||||||
|
miw={0}
|
||||||
|
style={{
|
||||||
|
tableLayout: 'fixed',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableThead>
|
||||||
|
<TableTr>
|
||||||
|
<TableTh style={{ width: '25%' }}>Nama Banjar</TableTh>
|
||||||
|
<TableTh style={{ width: '15%' }}>Penduduk</TableTh>
|
||||||
|
<TableTh style={{ width: '15%' }}>KK</TableTh>
|
||||||
|
<TableTh style={{ width: '15%' }}>Miskin</TableTh>
|
||||||
|
<TableTh style={{ width: '10%' }}>Tahun</TableTh>
|
||||||
|
<TableTh style={{ width: '10%' }}>Edit</TableTh>
|
||||||
|
<TableTh style={{ width: '10%' }}>Hapus</TableTh>
|
||||||
|
</TableTr>
|
||||||
|
</TableThead>
|
||||||
|
<TableTbody>
|
||||||
|
{filteredData.length > 0 ? (
|
||||||
|
filteredData.map((item: DataBanjarType) => (
|
||||||
|
<TableTr key={item.id}>
|
||||||
|
<TableTd>{item.nama}</TableTd>
|
||||||
|
<TableTd>{item.penduduk.toLocaleString('id-ID')}</TableTd>
|
||||||
|
<TableTd>{item.kk.toLocaleString('id-ID')}</TableTd>
|
||||||
|
<TableTd>{item.miskin.toLocaleString('id-ID')}</TableTd>
|
||||||
|
<TableTd>{item.tahun}</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/admin/kependudukan/data-banjar/${item.id}`)
|
||||||
|
}
|
||||||
|
fz="sm"
|
||||||
|
px="xs"
|
||||||
|
py="xs"
|
||||||
|
>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</Button>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
disabled={stateDataBanjar.delete.loading}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(item.id);
|
||||||
|
setModalHapus(true);
|
||||||
|
}}
|
||||||
|
fz="sm"
|
||||||
|
px="xs"
|
||||||
|
py="xs"
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</Button>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableTr>
|
||||||
|
<TableTd colSpan={7}>
|
||||||
|
<Center py={20}>
|
||||||
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
|
Tidak ada data banjar yang cocok
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
)}
|
||||||
|
</TableTbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Mobile Card */}
|
||||||
|
<Box hiddenFrom="md">
|
||||||
|
<Stack gap="xs">
|
||||||
|
{filteredData.length > 0 ? (
|
||||||
|
filteredData.map((item: DataBanjarType) => (
|
||||||
|
<Paper key={item.id} withBorder p="sm" radius="sm">
|
||||||
|
<Stack gap={"xs"}>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
Nama Banjar
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.4}>
|
||||||
|
{item.nama}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
Jumlah Penduduk
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.4}>
|
||||||
|
{item.penduduk.toLocaleString('id-ID')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
KK
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.4}>
|
||||||
|
{item.kk.toLocaleString('id-ID')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
Penduduk Miskin
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.4}>
|
||||||
|
{item.miskin.toLocaleString('id-ID')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
Tahun
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.4}>
|
||||||
|
{item.tahun}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Group justify="flex-end" gap="xs">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/admin/kependudukan/data-banjar/${item.id}`)
|
||||||
|
}
|
||||||
|
fz="xs"
|
||||||
|
px="xs"
|
||||||
|
py="xs"
|
||||||
|
>
|
||||||
|
<IconEdit size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
disabled={stateDataBanjar.delete.loading}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(item.id);
|
||||||
|
setModalHapus(true);
|
||||||
|
}}
|
||||||
|
fz="xs"
|
||||||
|
px="xs"
|
||||||
|
py="xs"
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Center py={20}>
|
||||||
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
|
Tidak ada data banjar yang cocok
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<Center>
|
||||||
|
<Pagination
|
||||||
|
value={page}
|
||||||
|
onChange={(newPage) => {
|
||||||
|
load(newPage, 10, search);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
total={totalPages}
|
||||||
|
mt="md"
|
||||||
|
mb="md"
|
||||||
|
color="blue"
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
|
||||||
|
{/* Modal Konfirmasi Hapus */}
|
||||||
|
<ModalKonfirmasiHapus
|
||||||
|
opened={modalHapus}
|
||||||
|
onClose={() => setModalHapus(false)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
text="Apakah anda yakin ingin menghapus data banjar ini?"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DataBanjarAdmin;
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
NumberInput,
|
||||||
|
Select
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { IconArrowBack } from '@tabler/icons-react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import distribusiAgama from '../../../_state/kependudukan/distribusi-agama';
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
agama: string;
|
||||||
|
jumlah: number;
|
||||||
|
tahun: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditDistribusiAgama() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = useParams() as { id: string };
|
||||||
|
const stateDistribusiAgama = useProxy(distribusiAgama);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<FormData>({
|
||||||
|
agama: '',
|
||||||
|
jumlah: 0,
|
||||||
|
tahun: new Date().getFullYear(),
|
||||||
|
});
|
||||||
|
const [originalData, setOriginalData] = useState<FormData>({
|
||||||
|
agama: '',
|
||||||
|
jumlah: 0,
|
||||||
|
tahun: new Date().getFullYear(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const yearOptions = Array.from({ length: 10 }, (_, i) => ({
|
||||||
|
value: String(currentYear - i),
|
||||||
|
label: String(currentYear - i),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const agamaOptions = [
|
||||||
|
{ value: 'HINDU', label: 'Hindu' },
|
||||||
|
{ value: 'ISLAM', label: 'Islam' },
|
||||||
|
{ value: 'KRISTEN', label: 'Kristen' },
|
||||||
|
{ value: 'KRISTEN_PROTESTAN', label: 'Kristen Protestan' },
|
||||||
|
{ value: 'KRISTEN_KATOLIK', label: 'Kristen Katolik' },
|
||||||
|
{ value: 'BUDDHA', label: 'Buddha' },
|
||||||
|
{ value: 'KONGHUCU', label: 'Konghucu' },
|
||||||
|
{ value: 'LAINNYA', label: 'Lainnya' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.agama?.trim() !== '' &&
|
||||||
|
formData.jumlah !== null &&
|
||||||
|
formData.jumlah >= 0 &&
|
||||||
|
formData.tahun !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
stateDistribusiAgama.update.id = id;
|
||||||
|
await stateDistribusiAgama.findUnique.load(id);
|
||||||
|
|
||||||
|
const data = stateDistribusiAgama.findUnique.data;
|
||||||
|
if (data) {
|
||||||
|
setFormData({
|
||||||
|
agama: data.agama ?? '',
|
||||||
|
jumlah: Number(data.jumlah ?? 0),
|
||||||
|
tahun: Number(data.tahun ?? currentYear),
|
||||||
|
});
|
||||||
|
setOriginalData({
|
||||||
|
agama: data.agama ?? '',
|
||||||
|
jumlah: Number(data.jumlah ?? 0),
|
||||||
|
tahun: Number(data.tahun ?? currentYear),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading data:', error);
|
||||||
|
toast.error('Gagal memuat data');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof FormData) =>
|
||||||
|
(value: any) => {
|
||||||
|
const val =
|
||||||
|
field === 'jumlah' || field === 'tahun'
|
||||||
|
? Number(value || 0)
|
||||||
|
: value;
|
||||||
|
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: val }));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
agama: originalData.agama,
|
||||||
|
jumlah: Number(originalData.jumlah),
|
||||||
|
tahun: Number(originalData.tahun),
|
||||||
|
});
|
||||||
|
toast.info("Form dikembalikan ke data awal");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
stateDistribusiAgama.update.id = id;
|
||||||
|
stateDistribusiAgama.update.form = { ...formData };
|
||||||
|
|
||||||
|
await stateDistribusiAgama.update.submit();
|
||||||
|
|
||||||
|
toast.success('Data berhasil diperbarui');
|
||||||
|
router.push('/admin/kependudukan/distribusi-agama');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating data:', error);
|
||||||
|
toast.error('Gagal memperbarui data');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||||
|
{/* Header */}
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
p="xs"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
|
</Button>
|
||||||
|
<Title order={4} ml="sm" c="dark">
|
||||||
|
Edit Distribusi Agama
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Form Card */}
|
||||||
|
<Paper
|
||||||
|
w={{ base: '100%', md: '50%' }}
|
||||||
|
bg={colors['white-1']}
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
shadow="sm"
|
||||||
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label="Agama"
|
||||||
|
placeholder="Pilih agama"
|
||||||
|
data={agamaOptions}
|
||||||
|
value={formData.agama}
|
||||||
|
onChange={handleChange('agama')}
|
||||||
|
required
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Jumlah"
|
||||||
|
placeholder="Masukkan jumlah"
|
||||||
|
value={formData.jumlah}
|
||||||
|
onChange={handleChange('jumlah')}
|
||||||
|
min={0}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Tahun"
|
||||||
|
placeholder="Pilih tahun"
|
||||||
|
data={yearOptions}
|
||||||
|
value={String(formData.tahun)}
|
||||||
|
onChange={handleChange('tahun')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={handleResetForm}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
|
style={{
|
||||||
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `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>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
NumberInput,
|
||||||
|
Select
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { IconArrowBack } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import distribusiAgama from '../../../_state/kependudukan/distribusi-agama';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
function CreateDistribusiAgama() {
|
||||||
|
const stateDistribusiAgama = useProxy(distribusiAgama);
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const agamaOptions = [
|
||||||
|
{ value: 'HINDU', label: 'Hindu' },
|
||||||
|
{ value: 'ISLAM', label: 'Islam' },
|
||||||
|
{ value: 'KRISTEN', label: 'Kristen' },
|
||||||
|
{ value: 'KRISTEN_PROTESTAN', label: 'Kristen Protestan' },
|
||||||
|
{ value: 'KRISTEN_KATOLIK', label: 'Kristen Katolik' },
|
||||||
|
{ value: 'BUDDHA', label: 'Buddha' },
|
||||||
|
{ value: 'KONGHUCU', label: 'Konghucu' },
|
||||||
|
{ value: 'LAINNYA', label: 'Lainnya' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const yearOptions = Array.from({ length: 10 }, (_, i) => ({
|
||||||
|
value: String(currentYear - i),
|
||||||
|
label: String(currentYear - i),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateDistribusiAgama.create.form.agama?.trim() !== '' &&
|
||||||
|
stateDistribusiAgama.create.form.jumlah !== null &&
|
||||||
|
stateDistribusiAgama.create.form.jumlah >= 0 &&
|
||||||
|
stateDistribusiAgama.create.form.tahun !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
stateDistribusiAgama.create.form = {
|
||||||
|
agama: '',
|
||||||
|
jumlah: 0,
|
||||||
|
tahun: currentYear,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const id = await stateDistribusiAgama.create.create();
|
||||||
|
if (id) {
|
||||||
|
resetForm();
|
||||||
|
router.push('/admin/kependudukan/distribusi-agama');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating distribusi agama:', error);
|
||||||
|
toast.error('Terjadi kesalahan saat menambah distribusi agama');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||||
|
{/* Header */}
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
p="xs"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
|
</Button>
|
||||||
|
<Title order={4} ml="sm" c="dark">
|
||||||
|
Tambah Distribusi Agama
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<Paper
|
||||||
|
w={{ base: '100%', md: '50%' }}
|
||||||
|
bg={colors['white-1']}
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
shadow="sm"
|
||||||
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label="Agama"
|
||||||
|
placeholder="Pilih agama"
|
||||||
|
data={agamaOptions}
|
||||||
|
value={stateDistribusiAgama.create.form.agama}
|
||||||
|
onChange={(val) => {
|
||||||
|
stateDistribusiAgama.create.form.agama = val || '';
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Jumlah"
|
||||||
|
placeholder="Masukkan jumlah"
|
||||||
|
value={stateDistribusiAgama.create.form.jumlah}
|
||||||
|
onChange={(val) => {
|
||||||
|
stateDistribusiAgama.create.form.jumlah = Number(val || 0);
|
||||||
|
}}
|
||||||
|
min={0}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Tahun"
|
||||||
|
placeholder="Pilih tahun"
|
||||||
|
data={yearOptions}
|
||||||
|
value={String(stateDistribusiAgama.create.form.tahun)}
|
||||||
|
onChange={(val) => {
|
||||||
|
stateDistribusiAgama.create.form.tahun = Number(val || currentYear);
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={resetForm}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
|
style={{
|
||||||
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `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>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateDistribusiAgama;
|
||||||
283
src/app/admin/(dashboard)/kependudukan/distribusi-agama/page.tsx
Normal file
283
src/app/admin/(dashboard)/kependudukan/distribusi-agama/page.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
'use client'
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Flex,
|
||||||
|
Group,
|
||||||
|
Pagination,
|
||||||
|
Paper,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableTbody,
|
||||||
|
TableTd,
|
||||||
|
TableTh,
|
||||||
|
TableThead,
|
||||||
|
TableTr,
|
||||||
|
Text,
|
||||||
|
Title
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||||
|
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import HeaderSearch from '../../_com/header';
|
||||||
|
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
|
||||||
|
import distribusiAgama from '../../_state/kependudukan/distribusi-agama';
|
||||||
|
|
||||||
|
function DistribusiAgamaAdmin() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<HeaderSearch
|
||||||
|
title='Distribusi Agama'
|
||||||
|
placeholder='Cari agama...'
|
||||||
|
searchIcon={<IconSearch size={20} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<ListDistribusiAgama search={search} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListDistribusiAgama({ search }: { search: string }) {
|
||||||
|
type DistribusiAgamaType = {
|
||||||
|
id: string;
|
||||||
|
agama: string;
|
||||||
|
jumlah: number;
|
||||||
|
tahun: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const stateDistribusiAgama = useProxy(distribusiAgama);
|
||||||
|
const [modalHapus, setModalHapus] = useState(false);
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
loading,
|
||||||
|
load,
|
||||||
|
} = stateDistribusiAgama.findMany;
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (selectedId) {
|
||||||
|
stateDistribusiAgama.delete.byId(selectedId);
|
||||||
|
setModalHapus(false);
|
||||||
|
setSelectedId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useShallowEffect(() => {
|
||||||
|
load(page, 10, debouncedSearch);
|
||||||
|
}, [page, debouncedSearch]);
|
||||||
|
|
||||||
|
const filteredData = data || [];
|
||||||
|
|
||||||
|
if (loading || !data) {
|
||||||
|
return (
|
||||||
|
<Stack py={10}>
|
||||||
|
<Skeleton height={600} radius="md" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box py={{ base: 'sm', md: 'md' }}>
|
||||||
|
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
|
||||||
|
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
|
||||||
|
<Title order={4} lh={{ base: 1.2, md: 1.15 }}>
|
||||||
|
List Distribusi Agama
|
||||||
|
</Title>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPlus size={18} />}
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
onClick={() => router.push('/admin/kependudukan/distribusi-agama/create')}
|
||||||
|
fz={{ base: 'sm', md: 'md' }}
|
||||||
|
>
|
||||||
|
Tambah Baru
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Desktop Table */}
|
||||||
|
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
|
||||||
|
<Table
|
||||||
|
highlightOnHover
|
||||||
|
miw={0}
|
||||||
|
style={{
|
||||||
|
tableLayout: 'fixed',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableThead>
|
||||||
|
<TableTr>
|
||||||
|
<TableTh style={{ width: '40%' }}>Agama</TableTh>
|
||||||
|
<TableTh style={{ width: '20%' }}>Jumlah</TableTh>
|
||||||
|
<TableTh style={{ width: '20%' }}>Tahun</TableTh>
|
||||||
|
<TableTh style={{ width: '10%' }}>Edit</TableTh>
|
||||||
|
<TableTh style={{ width: '10%' }}>Hapus</TableTh>
|
||||||
|
</TableTr>
|
||||||
|
</TableThead>
|
||||||
|
<TableTbody>
|
||||||
|
{filteredData.length > 0 ? (
|
||||||
|
filteredData.map((item: DistribusiAgamaType) => (
|
||||||
|
<TableTr key={item.id}>
|
||||||
|
<TableTd>{item.agama}</TableTd>
|
||||||
|
<TableTd>{item.jumlah.toLocaleString('id-ID')}</TableTd>
|
||||||
|
<TableTd>{item.tahun}</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/admin/kependudukan/distribusi-agama/${item.id}`)
|
||||||
|
}
|
||||||
|
fz="sm"
|
||||||
|
px="xs"
|
||||||
|
py="xs"
|
||||||
|
>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</Button>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
disabled={stateDistribusiAgama.delete.loading}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(item.id);
|
||||||
|
setModalHapus(true);
|
||||||
|
}}
|
||||||
|
fz="sm"
|
||||||
|
px="xs"
|
||||||
|
py="xs"
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</Button>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableTr>
|
||||||
|
<TableTd colSpan={5}>
|
||||||
|
<Center py={20}>
|
||||||
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
|
Tidak ada data distribusi agama yang cocok
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
)}
|
||||||
|
</TableTbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Mobile Card */}
|
||||||
|
<Box hiddenFrom="md">
|
||||||
|
<Stack gap="xs">
|
||||||
|
{filteredData.length > 0 ? (
|
||||||
|
filteredData.map((item: DistribusiAgamaType) => (
|
||||||
|
<Paper key={item.id} withBorder p="sm" radius="sm">
|
||||||
|
<Stack gap={"xs"}>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
Agama
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.4}>
|
||||||
|
{item.agama}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
Jumlah
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.4}>
|
||||||
|
{item.jumlah.toLocaleString('id-ID')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
Tahun
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.4}>
|
||||||
|
{item.tahun}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Group justify="flex-end" gap="xs">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/admin/kependudukan/distribusi-agama/${item.id}`)
|
||||||
|
}
|
||||||
|
fz="xs"
|
||||||
|
px="xs"
|
||||||
|
py="xs"
|
||||||
|
>
|
||||||
|
<IconEdit size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
disabled={stateDistribusiAgama.delete.loading}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(item.id);
|
||||||
|
setModalHapus(true);
|
||||||
|
}}
|
||||||
|
fz="xs"
|
||||||
|
px="xs"
|
||||||
|
py="xs"
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Center py={20}>
|
||||||
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
|
Tidak ada data distribusi agama yang cocok
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<Center>
|
||||||
|
<Pagination
|
||||||
|
value={page}
|
||||||
|
onChange={(newPage) => {
|
||||||
|
load(newPage, 10, search);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
total={totalPages}
|
||||||
|
mt="md"
|
||||||
|
mb="md"
|
||||||
|
color="blue"
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
|
||||||
|
{/* Modal Konfirmasi Hapus */}
|
||||||
|
<ModalKonfirmasiHapus
|
||||||
|
opened={modalHapus}
|
||||||
|
onClose={() => setModalHapus(false)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
text="Apakah anda yakin ingin menghapus data distribusi agama ini?"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DistribusiAgamaAdmin;
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Title,
|
||||||
|
NumberInput,
|
||||||
|
Select
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { IconArrowBack } from '@tabler/icons-react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import distribusiUmur from '../../../_state/kependudukan/distribusi-umur';
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
rentangUmur: string;
|
||||||
|
jumlah: number;
|
||||||
|
tahun: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditDistribusiUmur() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = useParams() as { id: string };
|
||||||
|
const stateDistribusiUmur = useProxy(distribusiUmur);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<FormData>({
|
||||||
|
rentangUmur: '',
|
||||||
|
jumlah: 0,
|
||||||
|
tahun: new Date().getFullYear(),
|
||||||
|
});
|
||||||
|
const [originalData, setOriginalData] = useState<FormData>({
|
||||||
|
rentangUmur: '',
|
||||||
|
jumlah: 0,
|
||||||
|
tahun: new Date().getFullYear(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const yearOptions = Array.from({ length: 10 }, (_, i) => ({
|
||||||
|
value: String(currentYear - i),
|
||||||
|
label: String(currentYear - i),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rentangUmurOptions = [
|
||||||
|
{ value: '0-5', label: '0-5 Tahun' },
|
||||||
|
{ value: '6-12', label: '6-12 Tahun' },
|
||||||
|
{ value: '13-17', label: '13-17 Tahun' },
|
||||||
|
{ value: '18-25', label: '18-25 Tahun' },
|
||||||
|
{ value: '26-35', label: '26-35 Tahun' },
|
||||||
|
{ value: '36-45', label: '36-45 Tahun' },
|
||||||
|
{ value: '46-55', label: '46-55 Tahun' },
|
||||||
|
{ value: '56-65', label: '56-65 Tahun' },
|
||||||
|
{ value: '65+', label: '65+ Tahun' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.rentangUmur?.trim() !== '' &&
|
||||||
|
formData.jumlah !== null &&
|
||||||
|
formData.jumlah >= 0 &&
|
||||||
|
formData.tahun !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
stateDistribusiUmur.update.id = id;
|
||||||
|
await stateDistribusiUmur.findUnique.load(id);
|
||||||
|
|
||||||
|
const data = stateDistribusiUmur.findUnique.data;
|
||||||
|
if (data) {
|
||||||
|
setFormData({
|
||||||
|
rentangUmur: data.rentangUmur ?? '',
|
||||||
|
jumlah: Number(data.jumlah ?? 0),
|
||||||
|
tahun: Number(data.tahun ?? currentYear),
|
||||||
|
});
|
||||||
|
setOriginalData({
|
||||||
|
rentangUmur: data.rentangUmur ?? '',
|
||||||
|
jumlah: Number(data.jumlah ?? 0),
|
||||||
|
tahun: Number(data.tahun ?? currentYear),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading data:', error);
|
||||||
|
toast.error('Gagal memuat data');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof FormData) =>
|
||||||
|
(value: any) => {
|
||||||
|
const val =
|
||||||
|
field === 'jumlah' || field === 'tahun'
|
||||||
|
? Number(value || 0)
|
||||||
|
: value;
|
||||||
|
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: val }));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
rentangUmur: originalData.rentangUmur,
|
||||||
|
jumlah: Number(originalData.jumlah),
|
||||||
|
tahun: Number(originalData.tahun),
|
||||||
|
});
|
||||||
|
toast.info("Form dikembalikan ke data awal");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
stateDistribusiUmur.update.id = id;
|
||||||
|
stateDistribusiUmur.update.form = { ...formData };
|
||||||
|
|
||||||
|
await stateDistribusiUmur.update.submit();
|
||||||
|
|
||||||
|
toast.success('Data berhasil diperbarui');
|
||||||
|
router.push('/admin/kependudukan/distribusi-umur');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating data:', error);
|
||||||
|
toast.error('Gagal memperbarui data');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||||
|
{/* Header */}
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
p="xs"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
|
</Button>
|
||||||
|
<Title order={4} ml="sm" c="dark">
|
||||||
|
Edit Distribusi Umur
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Form Card */}
|
||||||
|
<Paper
|
||||||
|
w={{ base: '100%', md: '50%' }}
|
||||||
|
bg={colors['white-1']}
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
shadow="sm"
|
||||||
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label="Rentang Umur"
|
||||||
|
placeholder="Pilih rentang umur"
|
||||||
|
data={rentangUmurOptions}
|
||||||
|
value={formData.rentangUmur}
|
||||||
|
onChange={handleChange('rentangUmur')}
|
||||||
|
required
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Jumlah"
|
||||||
|
placeholder="Masukkan jumlah"
|
||||||
|
value={formData.jumlah}
|
||||||
|
onChange={handleChange('jumlah')}
|
||||||
|
min={0}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Tahun"
|
||||||
|
placeholder="Pilih tahun"
|
||||||
|
data={yearOptions}
|
||||||
|
value={String(formData.tahun)}
|
||||||
|
onChange={handleChange('tahun')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={handleResetForm}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
|
style={{
|
||||||
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `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>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Title,
|
||||||
|
NumberInput,
|
||||||
|
Select
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { IconArrowBack } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import distribusiUmur from '../../../_state/kependudukan/distribusi-umur';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
function CreateDistribusiUmur() {
|
||||||
|
const stateDistribusiUmur = useProxy(distribusiUmur);
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const rentangUmurOptions = [
|
||||||
|
{ value: '0-5', label: '0-5 Tahun' },
|
||||||
|
{ value: '6-12', label: '6-12 Tahun' },
|
||||||
|
{ value: '13-17', label: '13-17 Tahun' },
|
||||||
|
{ value: '18-25', label: '18-25 Tahun' },
|
||||||
|
{ value: '26-35', label: '26-35 Tahun' },
|
||||||
|
{ value: '36-45', label: '36-45 Tahun' },
|
||||||
|
{ value: '46-55', label: '46-55 Tahun' },
|
||||||
|
{ value: '56-65', label: '56-65 Tahun' },
|
||||||
|
{ value: '65+', label: '65+ Tahun' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const yearOptions = Array.from({ length: 10 }, (_, i) => ({
|
||||||
|
value: String(currentYear - i),
|
||||||
|
label: String(currentYear - i),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateDistribusiUmur.create.form.rentangUmur?.trim() !== '' &&
|
||||||
|
stateDistribusiUmur.create.form.jumlah !== null &&
|
||||||
|
stateDistribusiUmur.create.form.jumlah >= 0 &&
|
||||||
|
stateDistribusiUmur.create.form.tahun !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
stateDistribusiUmur.create.form = {
|
||||||
|
rentangUmur: '',
|
||||||
|
jumlah: 0,
|
||||||
|
tahun: currentYear,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const id = await stateDistribusiUmur.create.create();
|
||||||
|
if (id) {
|
||||||
|
resetForm();
|
||||||
|
router.push('/admin/kependudukan/distribusi-umur');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating distribusi umur:', error);
|
||||||
|
toast.error('Terjadi kesalahan saat menambah distribusi umur');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||||
|
{/* Header */}
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
p="xs"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
|
</Button>
|
||||||
|
<Title order={4} ml="sm" c="dark">
|
||||||
|
Tambah Distribusi Umur
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<Paper
|
||||||
|
w={{ base: '100%', md: '50%' }}
|
||||||
|
bg={colors['white-1']}
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
shadow="sm"
|
||||||
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label="Rentang Umur"
|
||||||
|
placeholder="Pilih rentang umur"
|
||||||
|
data={rentangUmurOptions}
|
||||||
|
value={stateDistribusiUmur.create.form.rentangUmur}
|
||||||
|
onChange={(val) => {
|
||||||
|
stateDistribusiUmur.create.form.rentangUmur = val || '';
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Jumlah"
|
||||||
|
placeholder="Masukkan jumlah"
|
||||||
|
value={stateDistribusiUmur.create.form.jumlah}
|
||||||
|
onChange={(val) => {
|
||||||
|
stateDistribusiUmur.create.form.jumlah = Number(val || 0);
|
||||||
|
}}
|
||||||
|
min={0}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Tahun"
|
||||||
|
placeholder="Pilih tahun"
|
||||||
|
data={yearOptions}
|
||||||
|
value={String(stateDistribusiUmur.create.form.tahun)}
|
||||||
|
onChange={(val) => {
|
||||||
|
stateDistribusiUmur.create.form.tahun = Number(val || currentYear);
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={resetForm}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
|
style={{
|
||||||
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `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>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateDistribusiUmur;
|
||||||
284
src/app/admin/(dashboard)/kependudukan/distribusi-umur/page.tsx
Normal file
284
src/app/admin/(dashboard)/kependudukan/distribusi-umur/page.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
'use client'
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Flex,
|
||||||
|
Group,
|
||||||
|
Pagination,
|
||||||
|
Paper,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableTbody,
|
||||||
|
TableTd,
|
||||||
|
TableTh,
|
||||||
|
TableThead,
|
||||||
|
TableTr,
|
||||||
|
Text,
|
||||||
|
Title
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||||
|
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import HeaderSearch from '../../_com/header';
|
||||||
|
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
|
||||||
|
import distribusiUmur from '../../_state/kependudukan/distribusi-umur';
|
||||||
|
|
||||||
|
function DistribusiUmurAdmin() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<HeaderSearch
|
||||||
|
title='Distribusi Umur'
|
||||||
|
placeholder='Cari rentang umur...'
|
||||||
|
searchIcon={<IconSearch size={20} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<ListDistribusiUmur search={search} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListDistribusiUmur({ search }: { search: string }) {
|
||||||
|
type DistribusiUmurType = {
|
||||||
|
id: string;
|
||||||
|
rentangUmur: string;
|
||||||
|
jumlah: number;
|
||||||
|
tahun: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const stateDistribusiUmur = useProxy(distribusiUmur);
|
||||||
|
const [modalHapus, setModalHapus] = useState(false);
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
loading,
|
||||||
|
load,
|
||||||
|
} = stateDistribusiUmur.findMany;
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (selectedId) {
|
||||||
|
stateDistribusiUmur.delete.byId(selectedId);
|
||||||
|
setModalHapus(false);
|
||||||
|
setSelectedId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useShallowEffect(() => {
|
||||||
|
load(page, 10, debouncedSearch);
|
||||||
|
}, [page, debouncedSearch]);
|
||||||
|
|
||||||
|
const filteredData = data || [];
|
||||||
|
|
||||||
|
if (loading || !data) {
|
||||||
|
return (
|
||||||
|
<Stack py={10}>
|
||||||
|
<Skeleton height={600} radius="md" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box py={{ base: 'sm', md: 'md' }}>
|
||||||
|
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
|
||||||
|
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
|
||||||
|
<Title order={4} lh={{ base: 1.2, md: 1.15 }}>
|
||||||
|
List Distribusi Umur
|
||||||
|
</Title>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPlus size={18} />}
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
onClick={() => router.push('/admin/kependudukan/distribusi-umur/create')}
|
||||||
|
fz={{ base: 'sm', md: 'md' }}
|
||||||
|
>
|
||||||
|
Tambah Baru
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Desktop Table */}
|
||||||
|
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
|
||||||
|
<Table
|
||||||
|
highlightOnHover
|
||||||
|
miw={0}
|
||||||
|
style={{
|
||||||
|
tableLayout: 'fixed',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableThead>
|
||||||
|
<TableTr>
|
||||||
|
<TableTh style={{ width: '40%' }}>Rentang Umur</TableTh>
|
||||||
|
<TableTh style={{ width: '20%' }}>Jumlah</TableTh>
|
||||||
|
<TableTh style={{ width: '20%' }}>Tahun</TableTh>
|
||||||
|
<TableTh style={{ width: '10%' }}>Edit</TableTh>
|
||||||
|
<TableTh style={{ width: '10%' }}>Hapus</TableTh>
|
||||||
|
</TableTr>
|
||||||
|
</TableThead>
|
||||||
|
<TableTbody>
|
||||||
|
{filteredData.length > 0 ? (
|
||||||
|
filteredData.map((item: DistribusiUmurType) => (
|
||||||
|
<TableTr key={item.id}>
|
||||||
|
<TableTd>{item.rentangUmur}</TableTd>
|
||||||
|
<TableTd>{item.jumlah.toLocaleString('id-ID')}</TableTd>
|
||||||
|
<TableTd>{item.tahun}</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/admin/kependudukan/distribusi-umur/${item.id}`)
|
||||||
|
}
|
||||||
|
fz="sm"
|
||||||
|
px="xs"
|
||||||
|
py="xs"
|
||||||
|
>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</Button>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
disabled={stateDistribusiUmur.delete.loading}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(item.id);
|
||||||
|
setModalHapus(true);
|
||||||
|
}}
|
||||||
|
fz="sm"
|
||||||
|
px="xs"
|
||||||
|
py="xs"
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</Button>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableTr>
|
||||||
|
<TableTd colSpan={5}>
|
||||||
|
<Center py={20}>
|
||||||
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
|
Tidak ada data distribusi umur yang cocok
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
)}
|
||||||
|
</TableTbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Mobile Card */}
|
||||||
|
<Box hiddenFrom="md">
|
||||||
|
<Stack gap="xs">
|
||||||
|
{filteredData.length > 0 ? (
|
||||||
|
filteredData.map((item: DistribusiUmurType) => (
|
||||||
|
<Paper key={item.id} withBorder p="sm" radius="sm">
|
||||||
|
<Stack gap={"xs"}>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
Rentang Umur
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.4}>
|
||||||
|
{item.rentangUmur}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
Jumlah
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.4}>
|
||||||
|
{item.jumlah.toLocaleString('id-ID')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
Tahun
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.4}>
|
||||||
|
{item.tahun}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Group justify="flex-end" gap="xs">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/admin/kependudukan/distribusi-umur/${item.id}`)
|
||||||
|
}
|
||||||
|
fz="xs"
|
||||||
|
px="xs"
|
||||||
|
py="xs"
|
||||||
|
>
|
||||||
|
<IconEdit size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
disabled={stateDistribusiUmur.delete.loading}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(item.id);
|
||||||
|
setModalHapus(true);
|
||||||
|
}}
|
||||||
|
fz="xs"
|
||||||
|
px="xs"
|
||||||
|
py="xs"
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Center py={20}>
|
||||||
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
|
Tidak ada data distribusi umur yang cocok
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<Center>
|
||||||
|
<Pagination
|
||||||
|
value={page}
|
||||||
|
onChange={(newPage) => {
|
||||||
|
load(newPage, 10, search);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
total={totalPages}
|
||||||
|
mt="md"
|
||||||
|
mb="md"
|
||||||
|
color="blue"
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
|
||||||
|
{/* Modal Konfirmasi Hapus */}
|
||||||
|
<ModalKonfirmasiHapus
|
||||||
|
opened={modalHapus}
|
||||||
|
onClose={() => setModalHapus(false)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
text="Apakah anda yakin ingin menghapus data distribusi umur ini?"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DistribusiUmurAdmin;
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Title,
|
||||||
|
TextInput,
|
||||||
|
Select,
|
||||||
|
Textarea
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { DatePickerInput } from '@mantine/dates';
|
||||||
|
import { IconArrowBack } from '@tabler/icons-react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import migrasiPenduduk from '../../../_state/kependudukan/migrasi-penduduk';
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
jenis: string;
|
||||||
|
nama: string;
|
||||||
|
tanggal: string;
|
||||||
|
asalTujuan: string;
|
||||||
|
alasan: string;
|
||||||
|
jenisKelamin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditMigrasiPenduduk() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = useParams() as { id: string };
|
||||||
|
const stateMigrasiPenduduk = useProxy(migrasiPenduduk);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<FormData>({
|
||||||
|
jenis: '',
|
||||||
|
nama: '',
|
||||||
|
tanggal: '',
|
||||||
|
asalTujuan: '',
|
||||||
|
alasan: '',
|
||||||
|
jenisKelamin: '',
|
||||||
|
});
|
||||||
|
const [originalData, setOriginalData] = useState<FormData>({
|
||||||
|
jenis: '',
|
||||||
|
nama: '',
|
||||||
|
tanggal: '',
|
||||||
|
asalTujuan: '',
|
||||||
|
alasan: '',
|
||||||
|
jenisKelamin: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const jenisOptions = [
|
||||||
|
{ value: 'MASUK', label: 'Masuk' },
|
||||||
|
{ value: 'KELUAR', label: 'Keluar' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const jenisKelaminOptions = [
|
||||||
|
{ value: 'L', label: 'Laki-laki' },
|
||||||
|
{ value: 'P', label: 'Perempuan' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.jenis?.trim() !== '' &&
|
||||||
|
formData.nama?.trim() !== '' &&
|
||||||
|
formData.tanggal?.trim() !== '' &&
|
||||||
|
formData.asalTujuan?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
stateMigrasiPenduduk.update.id = id;
|
||||||
|
await stateMigrasiPenduduk.findUnique.load(id);
|
||||||
|
|
||||||
|
const data = stateMigrasiPenduduk.findUnique.data;
|
||||||
|
if (data) {
|
||||||
|
setFormData({
|
||||||
|
jenis: data.jenis ?? '',
|
||||||
|
nama: data.nama ?? '',
|
||||||
|
tanggal: data.tanggal ?? '',
|
||||||
|
asalTujuan: data.asalTujuan ?? '',
|
||||||
|
alasan: data.alasan ?? '',
|
||||||
|
jenisKelamin: data.jenisKelamin ?? '',
|
||||||
|
});
|
||||||
|
setOriginalData({
|
||||||
|
jenis: data.jenis ?? '',
|
||||||
|
nama: data.nama ?? '',
|
||||||
|
tanggal: data.tanggal ?? '',
|
||||||
|
asalTujuan: data.asalTujuan ?? '',
|
||||||
|
alasan: data.alasan ?? '',
|
||||||
|
jenisKelamin: data.jenisKelamin ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading data:', error);
|
||||||
|
toast.error('Gagal memuat data');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof FormData) =>
|
||||||
|
(value: any) => {
|
||||||
|
const val = value || '';
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: val }));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
jenis: originalData.jenis,
|
||||||
|
nama: originalData.nama,
|
||||||
|
tanggal: originalData.tanggal,
|
||||||
|
asalTujuan: originalData.asalTujuan,
|
||||||
|
alasan: originalData.alasan,
|
||||||
|
jenisKelamin: originalData.jenisKelamin,
|
||||||
|
});
|
||||||
|
toast.info("Form dikembalikan ke data awal");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
stateMigrasiPenduduk.update.id = id;
|
||||||
|
stateMigrasiPenduduk.update.form = { ...formData };
|
||||||
|
|
||||||
|
await stateMigrasiPenduduk.update.submit();
|
||||||
|
|
||||||
|
toast.success('Data berhasil diperbarui');
|
||||||
|
router.push('/admin/kependudukan/migrasi-penduduk');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating data:', error);
|
||||||
|
toast.error('Gagal memperbarui data');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||||
|
{/* Header */}
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
p="xs"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
|
</Button>
|
||||||
|
<Title order={4} ml="sm" c="dark">
|
||||||
|
Edit Migrasi Penduduk
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Form Card */}
|
||||||
|
<Paper
|
||||||
|
w={{ base: '100%', md: '50%' }}
|
||||||
|
bg={colors['white-1']}
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
shadow="sm"
|
||||||
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label="Jenis Migrasi"
|
||||||
|
placeholder="Pilih jenis migrasi"
|
||||||
|
data={jenisOptions}
|
||||||
|
value={formData.jenis}
|
||||||
|
onChange={handleChange('jenis')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Nama"
|
||||||
|
placeholder="Masukkan nama lengkap"
|
||||||
|
value={formData.nama}
|
||||||
|
onChange={handleChange('nama')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DatePickerInput
|
||||||
|
label="Tanggal"
|
||||||
|
placeholder="Pilih tanggal"
|
||||||
|
value={formData.tanggal ? new Date(formData.tanggal) : null}
|
||||||
|
onChange={(val: string | null) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
tanggal: val || '',
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label={formData.jenis === 'MASUK' ? 'Asal' : 'Tujuan'}
|
||||||
|
placeholder={formData.jenis === 'MASUK' ? 'Masukkan asal' : 'Masukkan tujuan'}
|
||||||
|
value={formData.asalTujuan}
|
||||||
|
onChange={handleChange('asalTujuan')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Alasan"
|
||||||
|
placeholder="Masukkan alasan (opsional)"
|
||||||
|
value={formData.alasan}
|
||||||
|
onChange={handleChange('alasan')}
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Jenis Kelamin"
|
||||||
|
placeholder="Pilih jenis kelamin"
|
||||||
|
data={jenisKelaminOptions}
|
||||||
|
value={formData.jenisKelamin}
|
||||||
|
onChange={handleChange('jenisKelamin')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={handleResetForm}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
|
style={{
|
||||||
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `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>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Title,
|
||||||
|
TextInput,
|
||||||
|
Select,
|
||||||
|
Textarea
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { DatePickerInput } from '@mantine/dates';
|
||||||
|
import { IconArrowBack } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import migrasiPenduduk from '../../../_state/kependudukan/migrasi-penduduk';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
function CreateMigrasiPenduduk() {
|
||||||
|
const stateMigrasiPenduduk = useProxy(migrasiPenduduk);
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const jenisOptions = [
|
||||||
|
{ value: 'MASUK', label: 'Masuk' },
|
||||||
|
{ value: 'KELUAR', label: 'Keluar' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const jenisKelaminOptions = [
|
||||||
|
{ value: 'L', label: 'Laki-laki' },
|
||||||
|
{ value: 'P', label: 'Perempuan' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateMigrasiPenduduk.create.form.jenis?.trim() !== '' &&
|
||||||
|
stateMigrasiPenduduk.create.form.nama?.trim() !== '' &&
|
||||||
|
stateMigrasiPenduduk.create.form.tanggal?.trim() !== '' &&
|
||||||
|
stateMigrasiPenduduk.create.form.asalTujuan?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
stateMigrasiPenduduk.create.form = {
|
||||||
|
jenis: '',
|
||||||
|
nama: '',
|
||||||
|
tanggal: '',
|
||||||
|
asalTujuan: '',
|
||||||
|
alasan: '',
|
||||||
|
jenisKelamin: '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const id = await stateMigrasiPenduduk.create.create();
|
||||||
|
if (id) {
|
||||||
|
resetForm();
|
||||||
|
router.push('/admin/kependudukan/migrasi-penduduk');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating migrasi penduduk:', error);
|
||||||
|
toast.error('Terjadi kesalahan saat menambah data migrasi penduduk');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||||
|
{/* Header */}
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
p="xs"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
|
</Button>
|
||||||
|
<Title order={4} ml="sm" c="dark">
|
||||||
|
Tambah Migrasi Penduduk
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<Paper
|
||||||
|
w={{ base: '100%', md: '50%' }}
|
||||||
|
bg={colors['white-1']}
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
shadow="sm"
|
||||||
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label="Jenis Migrasi"
|
||||||
|
placeholder="Pilih jenis migrasi"
|
||||||
|
data={jenisOptions}
|
||||||
|
value={stateMigrasiPenduduk.create.form.jenis}
|
||||||
|
onChange={(val) => {
|
||||||
|
stateMigrasiPenduduk.create.form.jenis = val || '';
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Nama"
|
||||||
|
placeholder="Masukkan nama lengkap"
|
||||||
|
value={stateMigrasiPenduduk.create.form.nama}
|
||||||
|
onChange={(e) => {
|
||||||
|
stateMigrasiPenduduk.create.form.nama = e.currentTarget.value;
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DatePickerInput
|
||||||
|
label="Tanggal"
|
||||||
|
placeholder="Pilih tanggal"
|
||||||
|
value={stateMigrasiPenduduk.create.form.tanggal ? new Date(stateMigrasiPenduduk.create.form.tanggal) : null}
|
||||||
|
onChange={(val: string | null) => {
|
||||||
|
stateMigrasiPenduduk.create.form.tanggal = val || '';
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label={stateMigrasiPenduduk.create.form.jenis === 'MASUK' ? 'Asal' : 'Tujuan'}
|
||||||
|
placeholder={stateMigrasiPenduduk.create.form.jenis === 'MASUK' ? 'Masukkan asal' : 'Masukkan tujuan'}
|
||||||
|
value={stateMigrasiPenduduk.create.form.asalTujuan}
|
||||||
|
onChange={(e) => {
|
||||||
|
stateMigrasiPenduduk.create.form.asalTujuan = e.currentTarget.value;
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Alasan"
|
||||||
|
placeholder="Masukkan alasan (opsional)"
|
||||||
|
value={stateMigrasiPenduduk.create.form.alasan}
|
||||||
|
onChange={(e) => {
|
||||||
|
stateMigrasiPenduduk.create.form.alasan = e.currentTarget.value;
|
||||||
|
}}
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Jenis Kelamin"
|
||||||
|
placeholder="Pilih jenis kelamin"
|
||||||
|
data={jenisKelaminOptions}
|
||||||
|
value={stateMigrasiPenduduk.create.form.jenisKelamin}
|
||||||
|
onChange={(val) => {
|
||||||
|
stateMigrasiPenduduk.create.form.jenisKelamin = val || '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={resetForm}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
|
style={{
|
||||||
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `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>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateMigrasiPenduduk;
|
||||||
339
src/app/admin/(dashboard)/kependudukan/migrasi-penduduk/page.tsx
Normal file
339
src/app/admin/(dashboard)/kependudukan/migrasi-penduduk/page.tsx
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
'use client'
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Group,
|
||||||
|
Pagination,
|
||||||
|
Paper,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableTbody,
|
||||||
|
TableTd,
|
||||||
|
TableTh,
|
||||||
|
TableThead,
|
||||||
|
TableTr,
|
||||||
|
Text,
|
||||||
|
Title
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||||
|
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import HeaderSearch from '../../_com/header';
|
||||||
|
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
|
||||||
|
import migrasiPenduduk from '../../_state/kependudukan/migrasi-penduduk';
|
||||||
|
|
||||||
|
function MigrasiPendudukAdmin() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<HeaderSearch
|
||||||
|
title='Migrasi Penduduk'
|
||||||
|
placeholder='Cari nama...'
|
||||||
|
searchIcon={<IconSearch size={20} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<ListMigrasiPenduduk search={search} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListMigrasiPenduduk({ search }: { search: string }) {
|
||||||
|
type MigrasiPendudukType = {
|
||||||
|
id: string;
|
||||||
|
jenis: string;
|
||||||
|
nama: string;
|
||||||
|
tanggal: string;
|
||||||
|
asalTujuan: string;
|
||||||
|
alasan: string | null;
|
||||||
|
jenisKelamin: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const stateMigrasiPenduduk = useProxy(migrasiPenduduk);
|
||||||
|
const [modalHapus, setModalHapus] = useState(false);
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
loading,
|
||||||
|
load,
|
||||||
|
} = stateMigrasiPenduduk.findMany;
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (selectedId) {
|
||||||
|
stateMigrasiPenduduk.delete.byId(selectedId);
|
||||||
|
setModalHapus(false);
|
||||||
|
setSelectedId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useShallowEffect(() => {
|
||||||
|
load(page, 10, debouncedSearch);
|
||||||
|
}, [page, debouncedSearch]);
|
||||||
|
|
||||||
|
const filteredData = data || [];
|
||||||
|
|
||||||
|
if (loading || !data) {
|
||||||
|
return (
|
||||||
|
<Stack py={10}>
|
||||||
|
<Skeleton height={600} radius="md" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTanggal = (tanggal: string) => {
|
||||||
|
try {
|
||||||
|
return new Date(tanggal).toLocaleDateString('id-ID', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return tanggal;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box py={{ base: 'sm', md: 'md' }}>
|
||||||
|
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
|
||||||
|
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
|
||||||
|
<Title order={4} lh={{ base: 1.2, md: 1.15 }}>
|
||||||
|
List Migrasi Penduduk
|
||||||
|
</Title>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPlus size={18} />}
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
onClick={() => router.push('/admin/kependudukan/migrasi-penduduk/create')}
|
||||||
|
fz={{ base: 'sm', md: 'md' }}
|
||||||
|
>
|
||||||
|
Tambah Baru
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Desktop Table */}
|
||||||
|
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
|
||||||
|
<Table
|
||||||
|
highlightOnHover
|
||||||
|
miw={0}
|
||||||
|
style={{
|
||||||
|
tableLayout: 'fixed',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableThead>
|
||||||
|
<TableTr>
|
||||||
|
<TableTh style={{ width: '10%' }}>Jenis</TableTh>
|
||||||
|
<TableTh style={{ width: '20%' }}>Nama</TableTh>
|
||||||
|
<TableTh style={{ width: '12%' }}>Tanggal</TableTh>
|
||||||
|
<TableTh style={{ width: '20%' }}>Asal/Tujuan</TableTh>
|
||||||
|
<TableTh style={{ width: '10%' }}>L/P</TableTh>
|
||||||
|
<TableTh style={{ width: '10%' }}>Edit</TableTh>
|
||||||
|
<TableTh style={{ width: '10%' }}>Hapus</TableTh>
|
||||||
|
</TableTr>
|
||||||
|
</TableThead>
|
||||||
|
<TableTbody>
|
||||||
|
{filteredData.length > 0 ? (
|
||||||
|
filteredData.map((item: MigrasiPendudukType) => (
|
||||||
|
<TableTr key={item.id}>
|
||||||
|
<TableTd>
|
||||||
|
<Text
|
||||||
|
fz="sm"
|
||||||
|
fw={500}
|
||||||
|
c={item.jenis === 'MASUK' ? 'green' : 'red'}
|
||||||
|
>
|
||||||
|
{item.jenis}
|
||||||
|
</Text>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>{item.nama}</TableTd>
|
||||||
|
<TableTd>{formatTanggal(item.tanggal)}</TableTd>
|
||||||
|
<TableTd>{item.asalTujuan}</TableTd>
|
||||||
|
<TableTd>{item.jenisKelamin || '-'}</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/admin/kependudukan/migrasi-penduduk/${item.id}`)
|
||||||
|
}
|
||||||
|
fz="sm"
|
||||||
|
px="xs"
|
||||||
|
py="xs"
|
||||||
|
>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</Button>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
disabled={stateMigrasiPenduduk.delete.loading}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(item.id);
|
||||||
|
setModalHapus(true);
|
||||||
|
}}
|
||||||
|
fz="sm"
|
||||||
|
px="xs"
|
||||||
|
py="xs"
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</Button>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableTr>
|
||||||
|
<TableTd colSpan={7}>
|
||||||
|
<Center py={20}>
|
||||||
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
|
Tidak ada data migrasi penduduk yang cocok
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
)}
|
||||||
|
</TableTbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Mobile Card */}
|
||||||
|
<Box hiddenFrom="md">
|
||||||
|
<Stack gap="xs">
|
||||||
|
{filteredData.length > 0 ? (
|
||||||
|
filteredData.map((item: MigrasiPendudukType) => (
|
||||||
|
<Paper key={item.id} withBorder p="sm" radius="sm">
|
||||||
|
<Stack gap={"xs"}>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
Jenis Migrasi
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
fz="sm"
|
||||||
|
fw={500}
|
||||||
|
c={item.jenis === 'MASUK' ? 'green' : 'red'}
|
||||||
|
>
|
||||||
|
{item.jenis}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
Nama
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.4}>
|
||||||
|
{item.nama}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
Tanggal
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.4}>
|
||||||
|
{formatTanggal(item.tanggal)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
Asal/Tujuan
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.4}>
|
||||||
|
{item.asalTujuan}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{item.alasan && (
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
Alasan
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.4}>
|
||||||
|
{item.alasan}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4}>
|
||||||
|
Jenis Kelamin
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.4}>
|
||||||
|
{item.jenisKelamin || '-'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Group justify="flex-end" gap="xs">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/admin/kependudukan/migrasi-penduduk/${item.id}`)
|
||||||
|
}
|
||||||
|
fz="xs"
|
||||||
|
px="xs"
|
||||||
|
py="xs"
|
||||||
|
>
|
||||||
|
<IconEdit size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
disabled={stateMigrasiPenduduk.delete.loading}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(item.id);
|
||||||
|
setModalHapus(true);
|
||||||
|
}}
|
||||||
|
fz="xs"
|
||||||
|
px="xs"
|
||||||
|
py="xs"
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Center py={20}>
|
||||||
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
|
Tidak ada data migrasi penduduk yang cocok
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<Center>
|
||||||
|
<Pagination
|
||||||
|
value={page}
|
||||||
|
onChange={(newPage) => {
|
||||||
|
load(newPage, 10, search);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
total={totalPages}
|
||||||
|
mt="md"
|
||||||
|
mb="md"
|
||||||
|
color="blue"
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
|
||||||
|
{/* Modal Konfirmasi Hapus */}
|
||||||
|
<ModalKonfirmasiHapus
|
||||||
|
opened={modalHapus}
|
||||||
|
onClose={() => setModalHapus(false)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
text="Apakah anda yakin ingin menghapus data migrasi penduduk ini?"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MigrasiPendudukAdmin;
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
NumberInput,
|
||||||
|
Title,
|
||||||
|
Table,
|
||||||
|
TableThead,
|
||||||
|
TableTbody,
|
||||||
|
TableTr,
|
||||||
|
TableTh,
|
||||||
|
TableTd,
|
||||||
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Modal,
|
||||||
|
Divider,
|
||||||
|
Center,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconPlus,
|
||||||
|
IconEdit,
|
||||||
|
IconTrash,
|
||||||
|
IconCalendar,
|
||||||
|
IconCoin,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
interface RealisasiManagerProps {
|
||||||
|
itemId: string;
|
||||||
|
itemKode: string;
|
||||||
|
itemUraian: string;
|
||||||
|
itemAnggaran: number;
|
||||||
|
itemTotalRealisasi: number;
|
||||||
|
itemPersentase: number;
|
||||||
|
realisasiItems: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RealisasiManager({
|
||||||
|
itemId,
|
||||||
|
itemKode,
|
||||||
|
itemUraian,
|
||||||
|
itemAnggaran,
|
||||||
|
itemTotalRealisasi,
|
||||||
|
itemPersentase,
|
||||||
|
realisasiItems,
|
||||||
|
}: RealisasiManagerProps) {
|
||||||
|
const state = useProxy(apbdes);
|
||||||
|
const [modalOpened, setModalOpened] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
kode: '',
|
||||||
|
jumlah: 0,
|
||||||
|
tanggal: new Date().toISOString().split('T')[0], // YYYY-MM-DD format for input
|
||||||
|
keterangan: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
kode: '',
|
||||||
|
jumlah: 0,
|
||||||
|
tanggal: new Date().toISOString().split('T')[0],
|
||||||
|
keterangan: '',
|
||||||
|
});
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
resetForm();
|
||||||
|
setModalOpened(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = (realisasi: any) => {
|
||||||
|
const tanggal = new Date(realisasi.tanggal);
|
||||||
|
const tanggalStr = tanggal.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
kode: realisasi.kode || '',
|
||||||
|
jumlah: realisasi.jumlah,
|
||||||
|
tanggal: tanggalStr,
|
||||||
|
keterangan: realisasi.keterangan || '',
|
||||||
|
});
|
||||||
|
setEditingId(realisasi.id);
|
||||||
|
setModalOpened(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (formData.jumlah <= 0) {
|
||||||
|
return toast.warn('Jumlah realisasi harus lebih dari 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.kode || formData.kode.trim() === '') {
|
||||||
|
return toast.warn('Kode realisasi wajib diisi');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
// Update existing realisasi
|
||||||
|
const success = await state.realisasi.update(editingId, {
|
||||||
|
kode: formData.kode,
|
||||||
|
jumlah: formData.jumlah,
|
||||||
|
tanggal: new Date(formData.tanggal).toISOString(),
|
||||||
|
keterangan: formData.keterangan,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast.success('Realisasi berhasil diperbarui');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new realisasi
|
||||||
|
const success = await state.realisasi.create(itemId, {
|
||||||
|
kode: formData.kode,
|
||||||
|
jumlah: formData.jumlah,
|
||||||
|
tanggal: new Date(formData.tanggal).toISOString(),
|
||||||
|
keterangan: formData.keterangan,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast.success('Realisasi berhasil ditambahkan');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setModalOpened(false);
|
||||||
|
resetForm();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error saving realisasi:', error);
|
||||||
|
toast.error(error?.message || 'Gagal menyimpan realisasi');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (realisasiId: string) => {
|
||||||
|
if (!confirm('Apakah Anda yakin ingin menghapus realisasi ini?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const success = await state.realisasi.delete(realisasiId);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast.success('Realisasi berhasil dihapus');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error deleting realisasi:', error);
|
||||||
|
toast.error(error?.message || 'Gagal menghapus realisasi');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRupiah = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IDR',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('id-ID', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSisaAnggaran = () => {
|
||||||
|
return itemAnggaran - itemTotalRealisasi;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPersentaseColor = (persen: number) => {
|
||||||
|
if (persen >= 100) return 'teal';
|
||||||
|
if (persen >= 80) return 'blue';
|
||||||
|
if (persen >= 60) return 'yellow';
|
||||||
|
return 'red';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p="md" radius="md" mt="md">
|
||||||
|
{/* Header */}
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Title order={6}>
|
||||||
|
{itemKode} - {itemUraian}
|
||||||
|
</Title>
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
Kelola realisasi untuk item ini
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPlus size={18} />}
|
||||||
|
onClick={handleOpenCreate}
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
Tambah Realisasi
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<Group grow mb="md">
|
||||||
|
<Paper withBorder p="md" radius="md" bg="blue.0">
|
||||||
|
<Text fz="xs" c="blue.9" fw={600}>
|
||||||
|
ANGGARAN
|
||||||
|
</Text>
|
||||||
|
<Text fz="lg" c="blue.9" fw={700}>
|
||||||
|
{formatRupiah(itemAnggaran)}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder p="md" radius="md" bg="teal.0">
|
||||||
|
<Text fz="xs" c="teal.9" fw={600}>
|
||||||
|
TOTAL REALISASI
|
||||||
|
</Text>
|
||||||
|
<Text fz="lg" c="teal.9" fw={700}>
|
||||||
|
{formatRupiah(itemTotalRealisasi)}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder p="md" radius="md" bg={getSisaAnggaran() >= 0 ? 'green.0' : 'red.0'}>
|
||||||
|
<Text fz="xs" c={getSisaAnggaran() >= 0 ? 'green.9' : 'red.9'} fw={600}>
|
||||||
|
SISA ANGGARAN
|
||||||
|
</Text>
|
||||||
|
<Text fz="lg" c={getSisaAnggaran() >= 0 ? 'green.9' : 'red.9'} fw={700}>
|
||||||
|
{formatRupiah(getSisaAnggaran())}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder p="md" radius="md" bg={getPersentaseColor(itemPersentase) + '.0'}>
|
||||||
|
<Text fz="xs" c={getPersentaseColor(itemPersentase) + '.9'} fw={600}>
|
||||||
|
PERSENTASE
|
||||||
|
</Text>
|
||||||
|
<Text fz="lg" c={getPersentaseColor(itemPersentase) + '.9'} fw={700}>
|
||||||
|
{itemPersentase.toFixed(2)}%
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Realisasi List */}
|
||||||
|
{realisasiItems && realisasiItems.length > 0 ? (
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} mb="xs">
|
||||||
|
Daftar Realisasi ({realisasiItems.length})
|
||||||
|
</Text>
|
||||||
|
<Box style={{ overflowX: 'auto' }}>
|
||||||
|
<Table striped highlightOnHover fz="sm">
|
||||||
|
<TableThead>
|
||||||
|
<TableTr>
|
||||||
|
<TableTh>Kode</TableTh>
|
||||||
|
<TableTh>Tanggal</TableTh>
|
||||||
|
<TableTh>Uraian</TableTh>
|
||||||
|
<TableTh ta="right">Jumlah</TableTh>
|
||||||
|
<TableTh ta="center">Aksi</TableTh>
|
||||||
|
</TableTr>
|
||||||
|
</TableThead>
|
||||||
|
<TableTbody>
|
||||||
|
{realisasiItems.map((realisasi) => (
|
||||||
|
<TableTr key={realisasi.id}>
|
||||||
|
<TableTd>
|
||||||
|
<Badge variant="light" color="blue" size="sm">
|
||||||
|
{realisasi.kode || '-'}
|
||||||
|
</Badge>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconCalendar size={16} />
|
||||||
|
<Text fz="sm">{formatDate(realisasi.tanggal)}</Text>
|
||||||
|
</Group>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Text fz="sm">{realisasi.keterangan || '-'}</Text>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd ta="right">
|
||||||
|
<Text fz="sm" fw={600} c="blue">
|
||||||
|
{formatRupiah(realisasi.jumlah)}
|
||||||
|
</Text>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd ta="center">
|
||||||
|
<Group gap="xs" justify="center">
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleOpenEdit(realisasi)}
|
||||||
|
>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(realisasi.id)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
))}
|
||||||
|
</TableTbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Center py="xl">
|
||||||
|
<Stack align="center" gap="xs">
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
Belum ada realisasi untuk item ini
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
Klik tombol "Tambah Realisasi" untuk menambahkan
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal Create/Edit */}
|
||||||
|
<Modal
|
||||||
|
opened={modalOpened}
|
||||||
|
onClose={() => {
|
||||||
|
setModalOpened(false);
|
||||||
|
resetForm();
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
<Text fz="lg" fw={600}>
|
||||||
|
{editingId ? 'Edit Realisasi' : 'Tambah Realisasi Baru'}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
size="md"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{/* Info Item */}
|
||||||
|
<Paper p="sm" bg="gray.0" radius="md">
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
Item: {itemKode} - {itemUraian}
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
Anggaran: {formatRupiah(itemAnggaran)}
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
Sudah terealisasi: {formatRupiah(itemTotalRealisasi)}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Kode Realisasi"
|
||||||
|
placeholder="Contoh: 4.1.1-R1"
|
||||||
|
value={formData.kode}
|
||||||
|
onChange={(e) => setFormData({ ...formData, kode: e.target.value })}
|
||||||
|
description="Kode unik untuk realisasi ini"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Jumlah Realisasi (Rp)"
|
||||||
|
value={formData.jumlah}
|
||||||
|
onChange={(val) => setFormData({ ...formData, jumlah: Number(val) || 0 })}
|
||||||
|
leftSection={<IconCoin size={16} />}
|
||||||
|
thousandSeparator
|
||||||
|
min={0}
|
||||||
|
step={100000}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Tanggal Realisasi"
|
||||||
|
type="date"
|
||||||
|
value={formData.tanggal}
|
||||||
|
onChange={(e) => setFormData({ ...formData, tanggal: e.target.value })}
|
||||||
|
leftSection={<IconCalendar size={18} />}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Keterangan / Uraian"
|
||||||
|
placeholder="Contoh: Penyaluran BLT Tahap 1"
|
||||||
|
value={formData.keterangan}
|
||||||
|
onChange={(e) => setFormData({ ...formData, keterangan: e.target.value })}
|
||||||
|
description="Deskripsi singkat tentang realisasi ini"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider my="xs" />
|
||||||
|
|
||||||
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => {
|
||||||
|
setModalOpened(false);
|
||||||
|
resetForm();
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={loading}
|
||||||
|
color="blue"
|
||||||
|
leftSection={editingId ? <IconEdit size={16} /> : <IconPlus size={16} />}
|
||||||
|
>
|
||||||
|
{editingId ? 'Perbarui' : 'Tambah'} Realisasi
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,9 +42,11 @@ type ItemForm = {
|
|||||||
kode: string;
|
kode: string;
|
||||||
uraian: string;
|
uraian: string;
|
||||||
anggaran: number;
|
anggaran: number;
|
||||||
realisasi: number;
|
|
||||||
level: number;
|
level: number;
|
||||||
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
|
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
|
||||||
|
realisasi?: number;
|
||||||
|
selisih?: number;
|
||||||
|
persentase?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function EditAPBDes() {
|
function EditAPBDes() {
|
||||||
@@ -71,14 +73,19 @@ function EditAPBDes() {
|
|||||||
kode: '',
|
kode: '',
|
||||||
uraian: '',
|
uraian: '',
|
||||||
anggaran: 0,
|
anggaran: 0,
|
||||||
realisasi: 0,
|
|
||||||
level: 1,
|
level: 1,
|
||||||
tipe: 'pendapatan',
|
tipe: 'pendapatan',
|
||||||
|
realisasi: 0,
|
||||||
|
selisih: 0,
|
||||||
|
persentase: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simpan data original untuk reset form
|
// Simpan data original untuk reset form
|
||||||
const [originalData, setOriginalData] = useState({
|
const [originalData, setOriginalData] = useState({
|
||||||
tahun: 0,
|
tahun: 0,
|
||||||
|
name: '',
|
||||||
|
deskripsi: '',
|
||||||
|
jumlah: '',
|
||||||
imageId: '',
|
imageId: '',
|
||||||
fileId: '',
|
fileId: '',
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
@@ -103,6 +110,9 @@ function EditAPBDes() {
|
|||||||
// Simpan data original untuk reset
|
// Simpan data original untuk reset
|
||||||
setOriginalData({
|
setOriginalData({
|
||||||
tahun: data.tahun || new Date().getFullYear(),
|
tahun: data.tahun || new Date().getFullYear(),
|
||||||
|
name: data.name || '',
|
||||||
|
deskripsi: data.deskripsi || '',
|
||||||
|
jumlah: data.jumlah || '',
|
||||||
imageId: data.imageId || '',
|
imageId: data.imageId || '',
|
||||||
fileId: data.fileId || '',
|
fileId: data.fileId || '',
|
||||||
imageUrl: data.image?.link || '',
|
imageUrl: data.image?.link || '',
|
||||||
@@ -112,15 +122,18 @@ function EditAPBDes() {
|
|||||||
// Set form dengan data lama (termasuk imageId dan fileId)
|
// Set form dengan data lama (termasuk imageId dan fileId)
|
||||||
apbdesState.edit.form = {
|
apbdesState.edit.form = {
|
||||||
tahun: data.tahun || new Date().getFullYear(),
|
tahun: data.tahun || new Date().getFullYear(),
|
||||||
|
name: data.name || '',
|
||||||
|
deskripsi: data.deskripsi || '',
|
||||||
|
jumlah: data.jumlah || '',
|
||||||
imageId: data.imageId || '',
|
imageId: data.imageId || '',
|
||||||
fileId: data.fileId || '',
|
fileId: data.fileId || '',
|
||||||
items: (data.items || []).map((item: any) => ({
|
items: (data.items || []).map((item: any) => ({
|
||||||
kode: item.kode,
|
kode: item.kode,
|
||||||
uraian: item.uraian,
|
uraian: item.uraian,
|
||||||
anggaran: item.anggaran,
|
anggaran: item.anggaran,
|
||||||
realisasi: item.realisasi,
|
realisasi: item.totalRealisasi || 0,
|
||||||
selisih: item.selisih,
|
selisih: item.selisih || 0,
|
||||||
persentase: item.persentase,
|
persentase: item.persentase || 0,
|
||||||
level: item.level,
|
level: item.level,
|
||||||
tipe: item.tipe || 'pendapatan',
|
tipe: item.tipe || 'pendapatan',
|
||||||
})),
|
})),
|
||||||
@@ -148,34 +161,33 @@ function EditAPBDes() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddItem = () => {
|
const handleAddItem = () => {
|
||||||
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
|
const { kode, uraian, anggaran, level, tipe, realisasi, selisih, persentase } = newItem;
|
||||||
if (!kode || !uraian) {
|
if (!kode || !uraian) {
|
||||||
return toast.warn('Kode dan uraian wajib diisi');
|
return toast.warn('Kode dan uraian wajib diisi');
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalTipe = level === 1 ? null : tipe;
|
const finalTipe = level === 1 ? null : tipe;
|
||||||
const selisih = realisasi - anggaran;
|
|
||||||
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
|
|
||||||
|
|
||||||
apbdesState.edit.addItem({
|
apbdesState.edit.addItem({
|
||||||
kode,
|
kode,
|
||||||
uraian,
|
uraian,
|
||||||
anggaran,
|
anggaran,
|
||||||
realisasi,
|
realisasi: realisasi || 0,
|
||||||
selisih,
|
selisih: selisih || 0,
|
||||||
persentase,
|
persentase: persentase || 0,
|
||||||
level,
|
level,
|
||||||
tipe: finalTipe, // ✅ Tidak akan undefined
|
tipe: finalTipe,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
setNewItem({
|
setNewItem({
|
||||||
kode: '',
|
kode: '',
|
||||||
uraian: '',
|
uraian: '',
|
||||||
anggaran: 0,
|
anggaran: 0,
|
||||||
realisasi: 0,
|
|
||||||
level: 1,
|
level: 1,
|
||||||
tipe: 'pendapatan',
|
tipe: 'pendapatan',
|
||||||
|
realisasi: 0,
|
||||||
|
selisih: 0,
|
||||||
|
persentase: 0,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -193,7 +205,6 @@ function EditAPBDes() {
|
|||||||
|
|
||||||
// Upload file baru jika ada perubahan
|
// Upload file baru jika ada perubahan
|
||||||
if (imageFile) {
|
if (imageFile) {
|
||||||
// Hapus file lama dari form jika ada file baru
|
|
||||||
const res = await ApiFetch.api.fileStorage.create.post({
|
const res = await ApiFetch.api.fileStorage.create.post({
|
||||||
file: imageFile,
|
file: imageFile,
|
||||||
name: imageFile.name,
|
name: imageFile.name,
|
||||||
@@ -205,7 +216,6 @@ function EditAPBDes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (docFile) {
|
if (docFile) {
|
||||||
// Hapus file lama dari form jika ada file baru
|
|
||||||
const res = await ApiFetch.api.fileStorage.create.post({
|
const res = await ApiFetch.api.fileStorage.create.post({
|
||||||
file: docFile,
|
file: docFile,
|
||||||
name: docFile.name,
|
name: docFile.name,
|
||||||
@@ -216,15 +226,7 @@ function EditAPBDes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jika tidak ada file baru, gunakan ID lama (sudah ada di form)
|
// Image dan file sekarang opsional, tidak perlu validasi
|
||||||
// Pastikan imageId dan fileId tetap ada
|
|
||||||
if (!apbdesState.edit.form.imageId) {
|
|
||||||
return toast.warn('Gambar wajib diunggah');
|
|
||||||
}
|
|
||||||
if (!apbdesState.edit.form.fileId) {
|
|
||||||
return toast.warn('Dokumen wajib diunggah');
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await apbdesState.edit.update();
|
const success = await apbdesState.edit.update();
|
||||||
if (success) {
|
if (success) {
|
||||||
router.push('/admin/landing-page/apbdes');
|
router.push('/admin/landing-page/apbdes');
|
||||||
@@ -238,9 +240,12 @@ function EditAPBDes() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
// Reset ke data original (tahun, imageId, fileId)
|
// Reset ke data original (tahun, name, deskripsi, jumlah, imageId, fileId)
|
||||||
apbdesState.edit.form = {
|
apbdesState.edit.form = {
|
||||||
tahun: originalData.tahun,
|
tahun: originalData.tahun,
|
||||||
|
name: originalData.name,
|
||||||
|
deskripsi: originalData.deskripsi,
|
||||||
|
jumlah: originalData.jumlah,
|
||||||
imageId: originalData.imageId,
|
imageId: originalData.imageId,
|
||||||
fileId: originalData.fileId,
|
fileId: originalData.fileId,
|
||||||
items: [...apbdesState.edit.form.items], // keep existing items
|
items: [...apbdesState.edit.form.items], // keep existing items
|
||||||
@@ -249,21 +254,23 @@ function EditAPBDes() {
|
|||||||
// Reset preview ke data original
|
// Reset preview ke data original
|
||||||
setPreviewImage(originalData.imageUrl || null);
|
setPreviewImage(originalData.imageUrl || null);
|
||||||
setPreviewDoc(originalData.fileUrl || null);
|
setPreviewDoc(originalData.fileUrl || null);
|
||||||
|
|
||||||
// Reset file uploads
|
// Reset file uploads
|
||||||
setImageFile(null);
|
setImageFile(null);
|
||||||
setDocFile(null);
|
setDocFile(null);
|
||||||
|
|
||||||
// Reset new item form
|
// Reset new item form
|
||||||
setNewItem({
|
setNewItem({
|
||||||
kode: '',
|
kode: '',
|
||||||
uraian: '',
|
uraian: '',
|
||||||
anggaran: 0,
|
anggaran: 0,
|
||||||
realisasi: 0,
|
|
||||||
level: 1,
|
level: 1,
|
||||||
tipe: 'pendapatan',
|
tipe: 'pendapatan',
|
||||||
|
realisasi: 0,
|
||||||
|
selisih: 0,
|
||||||
|
persentase: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.info('Form dikembalikan ke data awal');
|
toast.info('Form dikembalikan ke data awal');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -288,6 +295,33 @@ function EditAPBDes() {
|
|||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{/* Header Form */}
|
{/* Header Form */}
|
||||||
|
<TextInput
|
||||||
|
label="Nama APBDes"
|
||||||
|
placeholder="Contoh: APBDes Tahun 2025"
|
||||||
|
value={apbdesState.edit.form.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
(apbdesState.edit.form.name = e.target.value)
|
||||||
|
}
|
||||||
|
description="Opsional - akan diisi otomatis jika kosong"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Deskripsi"
|
||||||
|
placeholder="Deskripsi APBDes (opsional)"
|
||||||
|
value={apbdesState.edit.form.deskripsi}
|
||||||
|
onChange={(e) =>
|
||||||
|
(apbdesState.edit.form.deskripsi = e.target.value)
|
||||||
|
}
|
||||||
|
description="Opsional"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Jumlah Total"
|
||||||
|
placeholder="Contoh: Rp 1.000.000.000"
|
||||||
|
value={apbdesState.edit.form.jumlah}
|
||||||
|
onChange={(e) =>
|
||||||
|
(apbdesState.edit.form.jumlah = e.target.value)
|
||||||
|
}
|
||||||
|
description="Opsional - total keseluruhan anggaran"
|
||||||
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Tahun"
|
label="Tahun"
|
||||||
value={apbdesState.edit.form.tahun || new Date().getFullYear()}
|
value={apbdesState.edit.form.tahun || new Date().getFullYear()}
|
||||||
@@ -299,11 +333,11 @@ function EditAPBDes() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Gambar & Dokumen */}
|
{/* Gambar & Dokumen (Opsional) */}
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Gambar APBDes
|
Gambar APBDes (Opsional)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={handleDrop('image')}
|
onDrop={handleDrop('image')}
|
||||||
@@ -343,6 +377,7 @@ function EditAPBDes() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPreviewImage(null);
|
setPreviewImage(null);
|
||||||
setImageFile(null);
|
setImageFile(null);
|
||||||
|
apbdesState.edit.form.imageId = ''; // Clear imageId from form
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
@@ -353,7 +388,7 @@ function EditAPBDes() {
|
|||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Dokumen APBDes
|
Dokumen APBDes (Opsional)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={handleDrop('doc')}
|
onDrop={handleDrop('doc')}
|
||||||
@@ -402,6 +437,7 @@ function EditAPBDes() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPreviewDoc(null);
|
setPreviewDoc(null);
|
||||||
setDocFile(null);
|
setDocFile(null);
|
||||||
|
apbdesState.edit.form.fileId = ''; // Clear fileId from form
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
@@ -475,13 +511,6 @@ function EditAPBDes() {
|
|||||||
thousandSeparator
|
thousandSeparator
|
||||||
min={0}
|
min={0}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
|
||||||
label="Realisasi (Rp)"
|
|
||||||
value={newItem.realisasi}
|
|
||||||
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
|
|
||||||
thousandSeparator
|
|
||||||
min={0}
|
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconPlus size={16} />}
|
leftSection={<IconPlus size={16} />}
|
||||||
@@ -506,6 +535,8 @@ function EditAPBDes() {
|
|||||||
<th>Uraian</th>
|
<th>Uraian</th>
|
||||||
<th>Anggaran</th>
|
<th>Anggaran</th>
|
||||||
<th>Realisasi</th>
|
<th>Realisasi</th>
|
||||||
|
<th>Selisih</th>
|
||||||
|
<th>%</th>
|
||||||
<th>Level</th>
|
<th>Level</th>
|
||||||
<th>Tipe</th>
|
<th>Tipe</th>
|
||||||
<th style={{ width: '50px' }}>Aksi</th>
|
<th style={{ width: '50px' }}>Aksi</th>
|
||||||
@@ -521,7 +552,11 @@ function EditAPBDes() {
|
|||||||
</td>
|
</td>
|
||||||
<td>{item.uraian}</td>
|
<td>{item.uraian}</td>
|
||||||
<td>{item.anggaran.toLocaleString('id-ID')}</td>
|
<td>{item.anggaran.toLocaleString('id-ID')}</td>
|
||||||
<td>{item.realisasi.toLocaleString('id-ID')}</td>
|
<td>{item.realisasi?.toLocaleString('id-ID') || '0'}</td>
|
||||||
|
<td style={{ color: item.selisih && item.selisih > 0 ? 'red' : 'green' }}>
|
||||||
|
{item.selisih?.toLocaleString('id-ID') || '0'}
|
||||||
|
</td>
|
||||||
|
<td>{item.persentase?.toFixed(2) || '0'}%</td>
|
||||||
<td>
|
<td>
|
||||||
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
|
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
|
||||||
L{item.level}
|
L{item.level}
|
||||||
@@ -533,7 +568,7 @@ function EditAPBDes() {
|
|||||||
{item.tipe}
|
{item.tipe}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
'-'
|
<Text size="sm" c="dimmed">-</Text>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import colors from '@/con/colors';
|
import colors from '@/con/colors';
|
||||||
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
|
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
|
||||||
import apbdes from '../../../_state/landing-page/apbdes';
|
import apbdes from '../../../_state/landing-page/apbdes';
|
||||||
|
import RealisasiManager from './RealisasiManager';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ function DetailAPBDes() {
|
|||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Nama APBDes</Text>
|
<Text fz="lg" fw="bold">Nama APBDes</Text>
|
||||||
<Text fz="md" c="dimmed">
|
<Text fz="md" c="dimmed">
|
||||||
{data.name || '-'}
|
{data.name || `APBDes Tahun ${data.tahun}`}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -105,6 +106,24 @@ function DetailAPBDes() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{data.deskripsi && (
|
||||||
|
<Box>
|
||||||
|
<Text fz="lg" fw="bold">Deskripsi</Text>
|
||||||
|
<Text fz="md" c="dimmed">
|
||||||
|
{data.deskripsi}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.jumlah && (
|
||||||
|
<Box>
|
||||||
|
<Text fz="lg" fw="bold">Jumlah Total</Text>
|
||||||
|
<Text fz="md" c="dimmed">
|
||||||
|
{data.jumlah}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Gambar</Text>
|
<Text fz="lg" fw="bold">Gambar</Text>
|
||||||
{data.image?.link ? (
|
{data.image?.link ? (
|
||||||
@@ -173,48 +192,60 @@ function DetailAPBDes() {
|
|||||||
|
|
||||||
{/* Tabel Items */}
|
{/* Tabel Items */}
|
||||||
{data.items && data.items.length > 0 ? (
|
{data.items && data.items.length > 0 ? (
|
||||||
<Paper withBorder p="md" radius="md">
|
<Stack gap="md">
|
||||||
<Text fz="lg" fw="bold" mb="sm">
|
<Text fz="lg" fw="bold">
|
||||||
Rincian Pendapatan & Belanja ({data.items.length} item)
|
Rincian Pendapatan & Belanja ({data.items.length} item)
|
||||||
</Text>
|
</Text>
|
||||||
<Box style={{ overflowX: 'auto' }}>
|
<Table striped highlightOnHover>
|
||||||
<Table striped highlightOnHover>
|
<TableThead>
|
||||||
<TableThead>
|
<TableTr>
|
||||||
<TableTr>
|
<TableTh>Uraian</TableTh>
|
||||||
<TableTh>Uraian</TableTh>
|
<TableTh>Anggaran (Rp)</TableTh>
|
||||||
<TableTh>Anggaran (Rp)</TableTh>
|
<TableTh>Realisasi (Rp)</TableTh>
|
||||||
<TableTh>Realisasi (Rp)</TableTh>
|
<TableTh>Selisih (Rp)</TableTh>
|
||||||
<TableTh>Selisih (Rp)</TableTh>
|
<TableTh>Persentase (%)</TableTh>
|
||||||
<TableTh>Persentase (%)</TableTh>
|
</TableTr>
|
||||||
</TableTr>
|
</TableThead>
|
||||||
</TableThead>
|
<TableTbody>
|
||||||
<TableTbody>
|
{[...data.items]
|
||||||
{[...data.items] // Create a new array before sorting
|
.sort((a, b) => a.kode.localeCompare(b.kode))
|
||||||
.sort((a, b) => a.kode.localeCompare(b.kode))
|
.map((item) => (
|
||||||
.map((item) => (
|
<TableTr key={item.id}>
|
||||||
<TableTr key={item.id}>
|
<TableTd style={getIndent(item.level)}>
|
||||||
<TableTd style={getIndent(item.level)}>
|
<Group>
|
||||||
<Group>
|
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
|
||||||
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
|
<Text fz="sm" c="dimmed">{item.uraian}</Text>
|
||||||
<Text fz="sm" c="dimmed">{item.uraian}</Text>
|
</Group>
|
||||||
</Group>
|
</TableTd>
|
||||||
</TableTd>
|
<TableTd>{item.anggaran.toLocaleString('id-ID')}</TableTd>
|
||||||
<TableTd>{item.anggaran.toLocaleString('id-ID')}</TableTd>
|
<TableTd>{item.totalRealisasi.toLocaleString('id-ID')}</TableTd>
|
||||||
<TableTd>{item.realisasi.toLocaleString('id-ID')}</TableTd>
|
<TableTd>
|
||||||
<TableTd>
|
<Text c={item.selisih >= 0 ? 'green' : 'red'}>
|
||||||
<Text c={item.selisih >= 0 ? 'green' : 'red'}>
|
{item.selisih.toLocaleString('id-ID')}
|
||||||
{item.selisih.toLocaleString('id-ID')}
|
</Text>
|
||||||
</Text>
|
</TableTd>
|
||||||
</TableTd>
|
<TableTd>
|
||||||
<TableTd>
|
<Text fw={500}>{item.persentase.toFixed(2)}%</Text>
|
||||||
<Text fw={500}>{item.persentase.toFixed(2)}%</Text>
|
</TableTd>
|
||||||
</TableTd>
|
</TableTr>
|
||||||
</TableTr>
|
))}
|
||||||
))}
|
</TableTbody>
|
||||||
</TableTbody>
|
</Table>
|
||||||
</Table>
|
|
||||||
</Box>
|
{/* Realisasi Manager untuk setiap item */}
|
||||||
</Paper>
|
{data.items.map((item) => (
|
||||||
|
<RealisasiManager
|
||||||
|
key={item.id}
|
||||||
|
itemId={item.id}
|
||||||
|
itemKode={item.kode}
|
||||||
|
itemUraian={item.uraian}
|
||||||
|
itemAnggaran={item.anggaran}
|
||||||
|
itemTotalRealisasi={item.totalRealisasi}
|
||||||
|
itemPersentase={item.persentase}
|
||||||
|
realisasiItems={item.realisasiItems || []}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Text>Belum ada data item</Text>
|
<Text>Belum ada data item</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ type ItemForm = {
|
|||||||
kode: string;
|
kode: string;
|
||||||
uraian: string;
|
uraian: string;
|
||||||
anggaran: number;
|
anggaran: number;
|
||||||
realisasi: number;
|
|
||||||
level: number;
|
level: number;
|
||||||
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
|
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
|
||||||
};
|
};
|
||||||
@@ -47,13 +46,9 @@ function CreateAPBDes() {
|
|||||||
const [docFile, setDocFile] = useState<File | null>(null);
|
const [docFile, setDocFile] = useState<File | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
// Check if form is valid
|
// Check if form is valid - hanya cek items, gambar dan file opsional
|
||||||
const isFormValid = () => {
|
const isFormValid = () => {
|
||||||
return (
|
return stateAPBDes.create.form.items.length > 0;
|
||||||
imageFile !== null &&
|
|
||||||
docFile !== null &&
|
|
||||||
stateAPBDes.create.form.items.length > 0
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Form sementara untuk input item baru
|
// Form sementara untuk input item baru
|
||||||
@@ -61,7 +56,6 @@ function CreateAPBDes() {
|
|||||||
kode: '',
|
kode: '',
|
||||||
uraian: '',
|
uraian: '',
|
||||||
anggaran: 0,
|
anggaran: 0,
|
||||||
realisasi: 0,
|
|
||||||
level: 1,
|
level: 1,
|
||||||
tipe: 'pendapatan',
|
tipe: 'pendapatan',
|
||||||
});
|
});
|
||||||
@@ -80,35 +74,40 @@ function CreateAPBDes() {
|
|||||||
kode: '',
|
kode: '',
|
||||||
uraian: '',
|
uraian: '',
|
||||||
anggaran: 0,
|
anggaran: 0,
|
||||||
realisasi: 0,
|
|
||||||
level: 1,
|
level: 1,
|
||||||
tipe: 'pendapatan',
|
tipe: 'pendapatan',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!imageFile || !docFile) {
|
|
||||||
return toast.warn("Pilih gambar dan dokumen terlebih dahulu");
|
|
||||||
}
|
|
||||||
if (stateAPBDes.create.form.items.length === 0) {
|
if (stateAPBDes.create.form.items.length === 0) {
|
||||||
return toast.warn("Minimal tambahkan 1 item APBDes");
|
return toast.warn("Minimal tambahkan 1 item APBDes");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
const [uploadImageRes, uploadDocRes] = await Promise.all([
|
|
||||||
ApiFetch.api.fileStorage.create.post({ file: imageFile, name: imageFile.name }),
|
|
||||||
ApiFetch.api.fileStorage.create.post({ file: docFile, name: docFile.name }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const imageId = uploadImageRes?.data?.data?.id;
|
// Upload files hanya jika ada file yang dipilih
|
||||||
const fileId = uploadDocRes?.data?.data?.id;
|
let imageId = '';
|
||||||
|
let fileId = '';
|
||||||
|
|
||||||
if (!imageId || !fileId) {
|
if (imageFile) {
|
||||||
return toast.error("Gagal mengupload file");
|
const uploadImageRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
|
file: imageFile,
|
||||||
|
name: imageFile.name,
|
||||||
|
});
|
||||||
|
imageId = uploadImageRes?.data?.data?.id || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update form dengan ID file
|
if (docFile) {
|
||||||
|
const uploadDocRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
|
file: docFile,
|
||||||
|
name: docFile.name,
|
||||||
|
});
|
||||||
|
fileId = uploadDocRes?.data?.data?.id || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update form dengan ID file (bisa kosong)
|
||||||
stateAPBDes.create.form.imageId = imageId;
|
stateAPBDes.create.form.imageId = imageId;
|
||||||
stateAPBDes.create.form.fileId = fileId;
|
stateAPBDes.create.form.fileId = fileId;
|
||||||
|
|
||||||
@@ -117,9 +116,9 @@ function CreateAPBDes() {
|
|||||||
toast.success("Berhasil menambahkan APBDes");
|
toast.success("Berhasil menambahkan APBDes");
|
||||||
resetForm();
|
resetForm();
|
||||||
router.push("/admin/landing-page/apbdes");
|
router.push("/admin/landing-page/apbdes");
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Gagal submit:", error);
|
console.error("Gagal submit:", error);
|
||||||
toast.error("Gagal menyimpan data");
|
toast.error(error?.message || "Gagal menyimpan data");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -127,22 +126,17 @@ function CreateAPBDes() {
|
|||||||
|
|
||||||
// Tambahkan item ke state
|
// Tambahkan item ke state
|
||||||
const handleAddItem = () => {
|
const handleAddItem = () => {
|
||||||
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
|
const { kode, uraian, anggaran, level, tipe } = newItem;
|
||||||
if (!kode || !uraian) {
|
if (!kode || !uraian) {
|
||||||
return toast.warn("Kode dan uraian wajib diisi");
|
return toast.warn("Kode dan uraian wajib diisi");
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalTipe = level === 1 ? null : tipe;
|
const finalTipe = level === 1 ? null : tipe;
|
||||||
const selisih = realisasi - anggaran;
|
|
||||||
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
|
|
||||||
|
|
||||||
stateAPBDes.create.addItem({
|
stateAPBDes.create.addItem({
|
||||||
kode,
|
kode,
|
||||||
uraian,
|
uraian,
|
||||||
anggaran,
|
anggaran,
|
||||||
realisasi,
|
|
||||||
selisih,
|
|
||||||
persentase,
|
|
||||||
level,
|
level,
|
||||||
tipe: finalTipe,
|
tipe: finalTipe,
|
||||||
});
|
});
|
||||||
@@ -152,7 +146,6 @@ function CreateAPBDes() {
|
|||||||
kode: '',
|
kode: '',
|
||||||
uraian: '',
|
uraian: '',
|
||||||
anggaran: 0,
|
anggaran: 0,
|
||||||
realisasi: 0,
|
|
||||||
level: 1,
|
level: 1,
|
||||||
tipe: 'pendapatan',
|
tipe: 'pendapatan',
|
||||||
});
|
});
|
||||||
@@ -183,12 +176,16 @@ function CreateAPBDes() {
|
|||||||
style={{ border: '1px solid #e0e0e0' }}
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{/* Gambar & Dokumen (dipendekkan untuk fokus pada items) */}
|
{/* Info: File opsional */}
|
||||||
|
<Text fz="sm" c="dimmed" mb="xs">
|
||||||
|
* Upload gambar dan dokumen bersifat opsional. Bisa dikosongkan jika belum ada.
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Stack gap={"xs"}>
|
<Stack gap={"xs"}>
|
||||||
{/* Gambar APBDes */}
|
{/* Gambar APBDes */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Gambar APBDes
|
Gambar APBDes (Opsional)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={(files) => {
|
onDrop={(files) => {
|
||||||
@@ -258,10 +255,10 @@ function CreateAPBDes() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Dokumen APBDes */}
|
{/* Dokumen APBDes (Opsional) */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Dokumen APBDes
|
Dokumen APBDes (Opsional)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={(files) => {
|
onDrop={(files) => {
|
||||||
@@ -334,6 +331,27 @@ function CreateAPBDes() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Form Header */}
|
{/* Form Header */}
|
||||||
|
<TextInput
|
||||||
|
label="Nama APBDes"
|
||||||
|
placeholder="Contoh: APBDes Tahun 2025"
|
||||||
|
value={stateAPBDes.create.form.name}
|
||||||
|
onChange={(e) => (stateAPBDes.create.form.name = e.target.value)}
|
||||||
|
description="Opsional - akan diisi otomatis jika kosong"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Deskripsi"
|
||||||
|
placeholder="Deskripsi APBDes (opsional)"
|
||||||
|
value={stateAPBDes.create.form.deskripsi}
|
||||||
|
onChange={(e) => (stateAPBDes.create.form.deskripsi = e.target.value)}
|
||||||
|
description="Opsional"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Jumlah Total"
|
||||||
|
placeholder="Contoh: Rp 1.000.000.000"
|
||||||
|
value={stateAPBDes.create.form.jumlah}
|
||||||
|
onChange={(e) => (stateAPBDes.create.form.jumlah = e.target.value)}
|
||||||
|
description="Opsional - total keseluruhan anggaran"
|
||||||
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Tahun"
|
label="Tahun"
|
||||||
value={stateAPBDes.create.form.tahun || new Date().getFullYear()}
|
value={stateAPBDes.create.form.tahun || new Date().getFullYear()}
|
||||||
@@ -406,13 +424,6 @@ function CreateAPBDes() {
|
|||||||
thousandSeparator
|
thousandSeparator
|
||||||
min={0}
|
min={0}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
|
||||||
label="Realisasi (Rp)"
|
|
||||||
value={newItem.realisasi}
|
|
||||||
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
|
|
||||||
thousandSeparator
|
|
||||||
min={0}
|
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconPlus size={16} />}
|
leftSection={<IconPlus size={16} />}
|
||||||
@@ -434,28 +445,30 @@ function CreateAPBDes() {
|
|||||||
<th>Kode</th>
|
<th>Kode</th>
|
||||||
<th>Uraian</th>
|
<th>Uraian</th>
|
||||||
<th>Anggaran</th>
|
<th>Anggaran</th>
|
||||||
<th>Realisasi</th>
|
|
||||||
<th>Level</th>
|
<th>Level</th>
|
||||||
<th>Tipe</th>
|
<th>Tipe</th>
|
||||||
<th style={{ width: 50 }}>Aksi</th>
|
<th style={{ width: 50 }}>Aksi</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{stateAPBDes.create.form.items.map((item, idx) => (
|
{stateAPBDes.create.form.items.map((item: any, idx) => (
|
||||||
<tr key={idx}>
|
<tr key={idx}>
|
||||||
<td><Text size="sm" fw={500}>{item.kode}</Text></td>
|
<td><Text size="sm" fw={500}>{item.kode}</Text></td>
|
||||||
<td>{item.uraian}</td>
|
<td>{item.uraian}</td>
|
||||||
<td>{item.anggaran.toLocaleString('id-ID')}</td>
|
<td>{item.anggaran.toLocaleString('id-ID')}</td>
|
||||||
<td>{item.realisasi.toLocaleString('id-ID')}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
|
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
|
||||||
L{item.level}
|
L{item.level}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
|
{item.tipe ? (
|
||||||
{item.tipe}
|
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
|
||||||
</Badge>
|
{item.tipe}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">-</Text>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>
|
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ function APBDes() {
|
|||||||
function ListAPBDes({ search }: { search: string }) {
|
function ListAPBDes({ search }: { search: string }) {
|
||||||
const listState = useProxy(apbdes);
|
const listState = useProxy(apbdes);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||||
|
|
||||||
const { data, page, totalPages, loading, load } = listState.findMany;
|
const { data, page, totalPages, loading, load } = listState.findMany;
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,9 @@ function ListKategoriPrestasi({ search }: { search: string }) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Modal Konfirmasi Hapus */}
|
{/* Modal Konfirmasi Hapus */}
|
||||||
<ModalKonfirmasiHapus
|
<ModalKonfirmasiHapus
|
||||||
opened={modalHapus}
|
opened={modalHapus}
|
||||||
|
|||||||
@@ -123,37 +123,51 @@ export default function CreateMusik() {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
// Upload cover image
|
// Upload cover image
|
||||||
|
console.log('Uploading cover image:', coverFile.name);
|
||||||
const coverRes = await ApiFetch.api.fileStorage.create.post({
|
const coverRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
file: coverFile,
|
file: coverFile,
|
||||||
name: coverFile.name,
|
name: coverFile.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('Cover upload response:', coverRes);
|
||||||
const coverUploaded = coverRes.data?.data;
|
const coverUploaded = coverRes.data?.data;
|
||||||
if (!coverUploaded?.id) {
|
if (!coverUploaded?.id) {
|
||||||
return toast.error('Gagal mengunggah cover, silakan coba lagi');
|
console.error('Cover upload failed:', coverRes);
|
||||||
|
toast.error('Gagal mengunggah cover, silakan coba lagi');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
musikState.musik.create.form.coverImageId = coverUploaded.id;
|
musikState.musik.create.form.coverImageId = coverUploaded.id;
|
||||||
|
|
||||||
// Upload audio file
|
// Upload audio file
|
||||||
|
console.log('Uploading audio file:', audioFile.name);
|
||||||
const audioRes = await ApiFetch.api.fileStorage.create.post({
|
const audioRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
file: audioFile,
|
file: audioFile,
|
||||||
name: audioFile.name,
|
name: audioFile.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('Audio upload response:', audioRes);
|
||||||
const audioUploaded = audioRes.data?.data;
|
const audioUploaded = audioRes.data?.data;
|
||||||
if (!audioUploaded?.id) {
|
if (!audioUploaded?.id) {
|
||||||
return toast.error('Gagal mengunggah audio, silakan coba lagi');
|
console.error('Audio upload failed:', audioRes);
|
||||||
|
toast.error('Gagal mengunggah audio, silakan coba lagi');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
musikState.musik.create.form.audioFileId = audioUploaded.id;
|
musikState.musik.create.form.audioFileId = audioUploaded.id;
|
||||||
|
|
||||||
|
// Create musik entry
|
||||||
|
console.log('Creating musik entry with form:', musikState.musik.create.form);
|
||||||
await musikState.musik.create.create();
|
await musikState.musik.create.create();
|
||||||
|
|
||||||
resetForm();
|
resetForm();
|
||||||
router.push('/admin/musik');
|
router.push('/admin/musik');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating musik:', 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');
|
toast.error('Terjadi kesalahan saat membuat musik');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|||||||
25
src/app/admin/(dashboard)/user&role/_com/getMenuIdByRole.ts
Normal file
25
src/app/admin/(dashboard)/user&role/_com/getMenuIdByRole.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// src/app/admin/_com/getMenuIdsByRoleId.ts
|
||||||
|
import { navBar, role1, role2, role3 } from '@/app/admin/_com/list_PageAdmin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mengembalikan daftar ID menu (string[]) berdasarkan roleId
|
||||||
|
*/
|
||||||
|
export function getMenuIdsByRoleId(roleId: string | number): string[] {
|
||||||
|
const id = typeof roleId === 'string' ? parseInt(roleId, 10) : roleId;
|
||||||
|
|
||||||
|
switch (id) {
|
||||||
|
case 0:
|
||||||
|
// Asumsikan devBar ada dan punya struktur sama
|
||||||
|
return []; // atau sesuaikan jika ada devBar
|
||||||
|
case 1:
|
||||||
|
return navBar.map(section => section.id);
|
||||||
|
case 2:
|
||||||
|
return role1.map(section => section.id);
|
||||||
|
case 3:
|
||||||
|
return role2.map(section => section.id);
|
||||||
|
case 4:
|
||||||
|
return role3.map(section => section.id);
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -373,6 +373,33 @@ export const devBar = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "Kependudukan",
|
||||||
|
name: "Kependudukan",
|
||||||
|
path: "",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: "Kependudukan_1",
|
||||||
|
name: "Distribusi Agama",
|
||||||
|
path: "/admin/kependudukan/distribusi-agama"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Kependudukan_2",
|
||||||
|
name: "Distribusi Umur",
|
||||||
|
path: "/admin/kependudukan/distribusi-umur"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Kependudukan_3",
|
||||||
|
name: "Data Banjar",
|
||||||
|
path: "/admin/kependudukan/data-banjar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Kependudukan_4",
|
||||||
|
name: "Migrasi Penduduk",
|
||||||
|
path: "/admin/kependudukan/migrasi-penduduk"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "Musik",
|
id: "Musik",
|
||||||
name: "Musik",
|
name: "Musik",
|
||||||
@@ -777,6 +804,33 @@ export const navBar = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "Kependudukan",
|
||||||
|
name: "Kependudukan",
|
||||||
|
path: "",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: "Kependudukan_1",
|
||||||
|
name: "Distribusi Agama",
|
||||||
|
path: "/admin/kependudukan/distribusi-agama"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Kependudukan_2",
|
||||||
|
name: "Distribusi Umur",
|
||||||
|
path: "/admin/kependudukan/distribusi-umur"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Kependudukan_3",
|
||||||
|
name: "Data Banjar",
|
||||||
|
path: "/admin/kependudukan/data-banjar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Kependudukan_4",
|
||||||
|
name: "Migrasi Penduduk",
|
||||||
|
path: "/admin/kependudukan/migrasi-penduduk"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "Musik",
|
id: "Musik",
|
||||||
name: "Musik",
|
name: "Musik",
|
||||||
@@ -1098,6 +1152,33 @@ export const role1 = [
|
|||||||
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
|
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Kependudukan",
|
||||||
|
name: "Kependudukan",
|
||||||
|
path: "",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: "Kependudukan_1",
|
||||||
|
name: "Distribusi Agama",
|
||||||
|
path: "/admin/kependudukan/distribusi-agama"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Kependudukan_2",
|
||||||
|
name: "Distribusi Umur",
|
||||||
|
path: "/admin/kependudukan/distribusi-umur"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Kependudukan_3",
|
||||||
|
name: "Data Banjar",
|
||||||
|
path: "/admin/kependudukan/data-banjar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Kependudukan_4",
|
||||||
|
name: "Migrasi Penduduk",
|
||||||
|
path: "/admin/kependudukan/migrasi-penduduk"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "Musik",
|
id: "Musik",
|
||||||
|
|||||||
@@ -1,26 +1,33 @@
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { Context } from "elysia";
|
import { Context } from "elysia";
|
||||||
|
|
||||||
type FormCreate = Prisma.BeritaGetPayload<{
|
type FormCreate = {
|
||||||
select: {
|
judul: string;
|
||||||
judul: true;
|
deskripsi: string;
|
||||||
deskripsi: true;
|
content: string;
|
||||||
content: true;
|
kategoriBeritaId: string;
|
||||||
kategoriBeritaId: true;
|
imageId: string; // Featured image
|
||||||
imageId: true;
|
imageIds?: string[]; // Multiple images for gallery
|
||||||
};
|
linkVideo?: string; // YouTube link
|
||||||
}>;
|
};
|
||||||
|
|
||||||
async function beritaCreate(context: Context) {
|
async function beritaCreate(context: Context) {
|
||||||
const body = context.body as FormCreate;
|
const body = context.body as FormCreate;
|
||||||
|
|
||||||
await prisma.berita.create({
|
await prisma.berita.create({
|
||||||
data: {
|
data: {
|
||||||
content: body.content,
|
content: body.content,
|
||||||
deskripsi: body.deskripsi,
|
deskripsi: body.deskripsi,
|
||||||
imageId: body.imageId,
|
imageId: body.imageId,
|
||||||
judul: body.judul,
|
judul: body.judul,
|
||||||
kategoriBeritaId: body.kategoriBeritaId,
|
kategoriBeritaId: body.kategoriBeritaId,
|
||||||
|
// Connect multiple images if provided
|
||||||
|
linkVideo: body.linkVideo,
|
||||||
|
images: body.imageIds && body.imageIds.length > 0
|
||||||
|
? {
|
||||||
|
connect: body.imageIds.map((id) => ({ id })),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default async function handler(
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
image: true,
|
image: true,
|
||||||
|
images: true,
|
||||||
kategoriBerita: true,
|
kategoriBerita: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const Berita = new Elysia({ prefix: "/berita", tags: ["Desa/Berita"] })
|
|||||||
imageId: t.String(),
|
imageId: t.String(),
|
||||||
content: t.String(),
|
content: t.String(),
|
||||||
kategoriBeritaId: t.Union([t.String(), t.Null()]),
|
kategoriBeritaId: t.Union([t.String(), t.Null()]),
|
||||||
|
imageIds: t.Array(t.String()),
|
||||||
|
linkVideo: t.Optional(t.String()),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.get("/find-first", beritaFindFirst)
|
.get("/find-first", beritaFindFirst)
|
||||||
@@ -39,6 +41,8 @@ const Berita = new Elysia({ prefix: "/berita", tags: ["Desa/Berita"] })
|
|||||||
imageId: t.String(),
|
imageId: t.String(),
|
||||||
content: t.String(),
|
content: t.String(),
|
||||||
kategoriBeritaId: t.Union([t.String(), t.Null()]),
|
kategoriBeritaId: t.Union([t.String(), t.Null()]),
|
||||||
|
imageIds: t.Array(t.String()),
|
||||||
|
linkVideo: t.Optional(t.String()),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,52 +4,48 @@ import { Prisma } from "@prisma/client";
|
|||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
type FormUpdate = Prisma.BeritaGetPayload<{
|
type FormUpdate = {
|
||||||
select: {
|
id: string;
|
||||||
id: true;
|
judul: string;
|
||||||
judul: true;
|
deskripsi: string;
|
||||||
deskripsi: true;
|
content: string;
|
||||||
content: true;
|
kategoriBeritaId: string;
|
||||||
kategoriBeritaId: true;
|
imageId: string; // Featured image
|
||||||
imageId: true;
|
imageIds?: string[]; // Multiple images for gallery
|
||||||
};
|
linkVideo?: string; // YouTube link
|
||||||
}>;
|
};
|
||||||
|
|
||||||
async function beritaUpdate(context: Context) {
|
async function beritaUpdate(context: Context) {
|
||||||
try {
|
try {
|
||||||
const id = context.params?.id as string; // ambil dari URL
|
const id = context.params?.id as string;
|
||||||
const body = (await context.body) as Omit<FormUpdate, "id">;
|
const body = (await context.body) as Omit<FormUpdate, "id">;
|
||||||
|
|
||||||
const {
|
const { judul, deskripsi, content, kategoriBeritaId, imageId, imageIds, linkVideo } = body;
|
||||||
judul,
|
|
||||||
deskripsi,
|
|
||||||
content,
|
|
||||||
kategoriBeritaId,
|
|
||||||
imageId,
|
|
||||||
} = body;
|
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ success: false, message: "ID tidak boleh kosong" }),
|
JSON.stringify({ success: false, message: "ID tidak boleh kosong" }),
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await prisma.berita.findUnique({
|
const existing = await prisma.berita.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
image: true,
|
image: true,
|
||||||
|
images: true, // Include gallery images
|
||||||
kategoriBerita: true,
|
kategoriBerita: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ success: false, message: "Berita tidak ditemukan" }),
|
JSON.stringify({ success: false, message: "Berita tidak ditemukan" }),
|
||||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
{ status: 404, headers: { "Content-Type": "application/json" } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete old featured image if changed
|
||||||
if (existing.imageId && existing.imageId !== imageId) {
|
if (existing.imageId && existing.imageId !== imageId) {
|
||||||
const oldImage = existing.image;
|
const oldImage = existing.image;
|
||||||
if (oldImage) {
|
if (oldImage) {
|
||||||
@@ -64,35 +60,60 @@ async function beritaUpdate(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build update data
|
||||||
|
const updateData: Prisma.BeritaUpdateInput = {
|
||||||
|
judul,
|
||||||
|
deskripsi,
|
||||||
|
content,
|
||||||
|
kategoriBerita: kategoriBeritaId ? { connect: { id: kategoriBeritaId } } : { disconnect: true },
|
||||||
|
image: imageId ? { connect: { id: imageId } } : { disconnect: true },
|
||||||
|
linkVideo,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle multiple images update
|
||||||
|
if (imageIds !== undefined) {
|
||||||
|
// Disconnect all existing images first
|
||||||
|
updateData.images = {
|
||||||
|
set: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connect new images if provided
|
||||||
|
if (imageIds.length > 0) {
|
||||||
|
updateData.images = {
|
||||||
|
...updateData.images,
|
||||||
|
connect: imageIds.map((id) => ({ id })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await prisma.berita.update({
|
const updated = await prisma.berita.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: updateData,
|
||||||
judul,
|
include: {
|
||||||
deskripsi,
|
image: true,
|
||||||
content,
|
images: true,
|
||||||
kategoriBeritaId: kategoriBeritaId || null,
|
kategoriBerita: true,
|
||||||
imageId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Berita berhasil diupdate",
|
message: "Berita berhasil diupdate",
|
||||||
data: updated,
|
data: updated,
|
||||||
}),
|
}),
|
||||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating berita:", error);
|
console.error("Error updating berita:", error);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: false,
|
success: false,
|
||||||
message: "Terjadi kesalahan saat mengupdate berita",
|
message: "Terjadi kesalahan saat mengupdate berita",
|
||||||
}),
|
}),
|
||||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export default beritaUpdate;
|
export default beritaUpdate;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import AjukanPermohonan from "./layanan/ajukan_permohonan";
|
|||||||
import Musik from "./musik";
|
import Musik from "./musik";
|
||||||
|
|
||||||
|
|
||||||
const Desa = new Elysia({ prefix: "/api/desa", tags: ["Desa"] })
|
const Desa = new Elysia({ prefix: "/desa", tags: ["Desa"] })
|
||||||
.use(Berita)
|
.use(Berita)
|
||||||
.use(Pengumuman)
|
.use(Pengumuman)
|
||||||
.use(ProfileDesa)
|
.use(ProfileDesa)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import PendapatanAsliDesa from "./pendapatan-asli-desa";
|
|||||||
import StrukturOrganisasi from "./struktur-bumdes";
|
import StrukturOrganisasi from "./struktur-bumdes";
|
||||||
|
|
||||||
const Ekonomi = new Elysia({
|
const Ekonomi = new Elysia({
|
||||||
prefix: "/api/ekonomi",
|
prefix: "/ekonomi",
|
||||||
tags: ["Ekonomi"],
|
tags: ["Ekonomi"],
|
||||||
})
|
})
|
||||||
.use(PasarDesa)
|
.use(PasarDesa)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fileStorageFindMany } from "./_lib/findMany";
|
|||||||
import fileStorageDelete from "./_lib/del";
|
import fileStorageDelete from "./_lib/del";
|
||||||
|
|
||||||
const FileStorage = new Elysia({
|
const FileStorage = new Elysia({
|
||||||
prefix: "/api/fileStorage",
|
prefix: "/fileStorage",
|
||||||
tags: ["FileStorage"],
|
tags: ["FileStorage"],
|
||||||
})
|
})
|
||||||
.post("/create", fileStorageCreate, {
|
.post("/create", fileStorageCreate, {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import LayananOnlineDesa from "./layanan-online-desa";
|
|||||||
import MitraKolaborasi from "./kolaborasi-inovasi/mitra-kolaborasi";
|
import MitraKolaborasi from "./kolaborasi-inovasi/mitra-kolaborasi";
|
||||||
|
|
||||||
const Inovasi = new Elysia({
|
const Inovasi = new Elysia({
|
||||||
prefix: "/api/inovasi",
|
prefix: "/inovasi",
|
||||||
tags: ["Inovasi"],
|
tags: ["Inovasi"],
|
||||||
})
|
})
|
||||||
.use(DesaDigital)
|
.use(DesaDigital)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import KontakDaruratKeamanan from "./kontak-darurat-keamanan";
|
|||||||
import KontakItem from "./kontak-darurat-keamanan/kontak-item";
|
import KontakItem from "./kontak-darurat-keamanan/kontak-item";
|
||||||
import LayananPolsek from "./polsek-terdekat/layanan-polsek";
|
import LayananPolsek from "./polsek-terdekat/layanan-polsek";
|
||||||
|
|
||||||
const Keamanan = new Elysia({ prefix: "/api/keamanan", tags: ["Keamanan"] })
|
const Keamanan = new Elysia({ prefix: "/keamanan", tags: ["Keamanan"] })
|
||||||
.use(KeamananLingkungan)
|
.use(KeamananLingkungan)
|
||||||
.use(PolsekTerdekat)
|
.use(PolsekTerdekat)
|
||||||
.use(PencegahanKriminalitas)
|
.use(PencegahanKriminalitas)
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import Elysia from "elysia";
|
||||||
|
import dashboardSummary from "./summary";
|
||||||
|
|
||||||
|
const DashboardKependudukan = new Elysia({
|
||||||
|
prefix: "/dashboard",
|
||||||
|
tags: ["Kependudukan/Dashboard"],
|
||||||
|
})
|
||||||
|
.get("/summary", dashboardSummary)
|
||||||
|
|
||||||
|
export default DashboardKependudukan;
|
||||||
147
src/app/api/[[...slugs]]/_lib/kependudukan/dashboard/summary.ts
Normal file
147
src/app/api/[[...slugs]]/_lib/kependudukan/dashboard/summary.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export default async function dashboardSummary() {
|
||||||
|
try {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
// Get dashboard summary
|
||||||
|
const [
|
||||||
|
totalPenduduk,
|
||||||
|
totalKK,
|
||||||
|
totalKelahiran,
|
||||||
|
totalKemiskinan,
|
||||||
|
kelahiranData,
|
||||||
|
kematianData,
|
||||||
|
pindahMasukData,
|
||||||
|
pindahKeluarData,
|
||||||
|
agamaData,
|
||||||
|
umurData,
|
||||||
|
banjarData
|
||||||
|
] = await Promise.all([
|
||||||
|
// Total penduduk - hitung dari data banjar
|
||||||
|
prisma.dataBanjar.aggregate({
|
||||||
|
where: { isActive: true, tahun: currentYear },
|
||||||
|
_sum: { penduduk: true }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Total KK
|
||||||
|
prisma.dataBanjar.aggregate({
|
||||||
|
where: { isActive: true, tahun: currentYear },
|
||||||
|
_sum: { kk: true }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Total kelahiran tahun ini
|
||||||
|
prisma.kelahiran.count({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
tanggal: {
|
||||||
|
gte: new Date(`${currentYear}-01-01`),
|
||||||
|
lte: new Date(`${currentYear}-12-31`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Total penduduk miskin
|
||||||
|
prisma.dataBanjar.aggregate({
|
||||||
|
where: { isActive: true, tahun: currentYear },
|
||||||
|
_sum: { miskin: true }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Kelahiran data
|
||||||
|
prisma.kelahiran.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
tanggal: {
|
||||||
|
gte: new Date(`${currentYear}-01-01`),
|
||||||
|
lte: new Date(`${currentYear}-12-31`),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { tanggal: 'asc' }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Kematian data
|
||||||
|
prisma.kematian.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
tanggal: {
|
||||||
|
gte: new Date(`${currentYear}-01-01`),
|
||||||
|
lte: new Date(`${currentYear}-12-31`),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { tanggal: 'asc' }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Pindah masuk
|
||||||
|
prisma.migrasiPenduduk.count({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
jenis: 'MASUK',
|
||||||
|
tanggal: {
|
||||||
|
gte: new Date(`${currentYear}-01-01`),
|
||||||
|
lte: new Date(`${currentYear}-12-31`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Pindah keluar
|
||||||
|
prisma.migrasiPenduduk.count({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
jenis: 'KELUAR',
|
||||||
|
tanggal: {
|
||||||
|
gte: new Date(`${currentYear}-01-01`),
|
||||||
|
lte: new Date(`${currentYear}-12-31`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Data agama
|
||||||
|
prisma.distribusiAgama.findMany({
|
||||||
|
where: { isActive: true, tahun: currentYear },
|
||||||
|
orderBy: { jumlah: 'desc' }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Data umur
|
||||||
|
prisma.distribusiUmur.findMany({
|
||||||
|
where: { isActive: true, tahun: currentYear },
|
||||||
|
orderBy: { createdAt: 'asc' }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Data banjar
|
||||||
|
prisma.dataBanjar.findMany({
|
||||||
|
where: { isActive: true, tahun: currentYear },
|
||||||
|
orderBy: { nama: 'asc' }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Dashboard summary berhasil diambil",
|
||||||
|
data: {
|
||||||
|
tahun: currentYear,
|
||||||
|
summary: {
|
||||||
|
totalPenduduk: totalPenduduk._sum.penduduk || 0,
|
||||||
|
totalKK: totalKK._sum.kk || 0,
|
||||||
|
totalKelahiran: totalKelahiran,
|
||||||
|
totalKemiskinan: totalKemiskinan._sum.miskin || 0,
|
||||||
|
},
|
||||||
|
dinamika: {
|
||||||
|
kelahiran: totalKelahiran,
|
||||||
|
kematian: kematianData.length,
|
||||||
|
pindahMasuk: pindahMasukData,
|
||||||
|
pindahKeluar: pindahKeluarData,
|
||||||
|
},
|
||||||
|
agama: agamaData,
|
||||||
|
umur: umurData,
|
||||||
|
banjar: banjarData,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching dashboard summary:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Terjadi kesalahan saat mengambil data dashboard",
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
type FormCreate = Prisma.DataBanjarGetPayload<{
|
||||||
|
select: {
|
||||||
|
nama: true;
|
||||||
|
penduduk: true;
|
||||||
|
kk: true;
|
||||||
|
miskin: true;
|
||||||
|
tahun: true;
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
|
||||||
|
export default async function dataBanjarCreate(context: Context) {
|
||||||
|
const body = context.body as FormCreate;
|
||||||
|
|
||||||
|
const created = await prisma.dataBanjar.create({
|
||||||
|
data: {
|
||||||
|
nama: body.nama,
|
||||||
|
penduduk: body.penduduk,
|
||||||
|
kk: body.kk,
|
||||||
|
miskin: body.miskin,
|
||||||
|
tahun: body.tahun,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
nama: true,
|
||||||
|
penduduk: true,
|
||||||
|
kk: true,
|
||||||
|
miskin: true,
|
||||||
|
tahun: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Sukses menambahkan data banjar",
|
||||||
|
data: created,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
export default async function dataBanjarDelete(context: Context) {
|
||||||
|
const id = context.params?.id;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "ID tidak ditemukan",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.dataBanjar.findUnique({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Data tidak ditemukan",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await prisma.dataBanjar.delete({
|
||||||
|
where: { id },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Data berhasil dihapus",
|
||||||
|
data: deleted,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
export default async function dataBanjarFindMany(context: Context) {
|
||||||
|
const page = Number(context.query.page) || 1;
|
||||||
|
const limit = Number(context.query.limit) || 100;
|
||||||
|
const search = (context.query.search as string) || '';
|
||||||
|
const tahun = Number(context.query.tahun) || new Date().getFullYear();
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: any = { isActive: true, tahun };
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ nama: { contains: search, mode: "insensitive" } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
prisma.dataBanjar.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { nama: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.dataBanjar.count({
|
||||||
|
where,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Success fetch data banjar with pagination",
|
||||||
|
data,
|
||||||
|
page,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch data banjar with pagination",
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export default async function dataBanjarFindUnique(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const pathSegments = url.pathname.split('/');
|
||||||
|
const id = pathSegments[pathSegments.length - 1];
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "ID tidak boleh kosong",
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof id !== 'string') {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "ID tidak valid",
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await prisma.dataBanjar.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "Data tidak ditemukan",
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
message: "Data ditemukan",
|
||||||
|
data: data,
|
||||||
|
}, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching data:", error);
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "Terjadi kesalahan saat mengambil data",
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Elysia, { t } from "elysia";
|
||||||
|
import dataBanjarFindUnique from "./findUnique";
|
||||||
|
import dataBanjarUpdate from "./updt";
|
||||||
|
import dataBanjarFindMany from "./findMany";
|
||||||
|
import dataBanjarCreate from "./create";
|
||||||
|
import dataBanjarDelete from "./del";
|
||||||
|
|
||||||
|
const DataBanjar = new Elysia({
|
||||||
|
prefix: "/databanjar",
|
||||||
|
tags: ["Kependudukan/Data Banjar"],
|
||||||
|
})
|
||||||
|
.get("/:id", async (context) => {
|
||||||
|
const response = await dataBanjarFindUnique(new Request(context.request))
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
.get("/find-many", dataBanjarFindMany)
|
||||||
|
.post("/create", dataBanjarCreate, {
|
||||||
|
body: t.Object({
|
||||||
|
nama: t.String(),
|
||||||
|
penduduk: t.Number(),
|
||||||
|
kk: t.Number(),
|
||||||
|
miskin: t.Number(),
|
||||||
|
tahun: t.Number(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.put("/:id", dataBanjarUpdate, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
body: t.Object({
|
||||||
|
nama: t.String(),
|
||||||
|
penduduk: t.Number(),
|
||||||
|
kk: t.Number(),
|
||||||
|
miskin: t.Number(),
|
||||||
|
tahun: t.Number(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.delete("/del/:id", dataBanjarDelete, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
export default DataBanjar;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
export default async function dataBanjarUpdate(context: Context) {
|
||||||
|
const id = context.params?.id;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "ID tidak ditemukan",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {nama, penduduk, kk, miskin, tahun} = context.body as {
|
||||||
|
nama: string;
|
||||||
|
penduduk: number;
|
||||||
|
kk: number;
|
||||||
|
miskin: number;
|
||||||
|
tahun: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.dataBanjar.findUnique({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Data tidak ditemukan",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.dataBanjar.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
nama,
|
||||||
|
penduduk,
|
||||||
|
kk,
|
||||||
|
miskin,
|
||||||
|
tahun,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Data berhasil diupdate",
|
||||||
|
data: updated,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
type FormCreate = Prisma.DistribusiAgamaGetPayload<{
|
||||||
|
select: {
|
||||||
|
agama: true;
|
||||||
|
jumlah: true;
|
||||||
|
tahun: true;
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
|
||||||
|
export default async function distribusiAgamaCreate(context: Context) {
|
||||||
|
const body = context.body as FormCreate;
|
||||||
|
|
||||||
|
const created = await prisma.distribusiAgama.create({
|
||||||
|
data: {
|
||||||
|
agama: body.agama,
|
||||||
|
jumlah: body.jumlah,
|
||||||
|
tahun: body.tahun,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
agama: true,
|
||||||
|
jumlah: true,
|
||||||
|
tahun: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Sukses menambahkan distribusi agama",
|
||||||
|
data: created,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
export default async function distribusiAgamaDelete(context: Context) {
|
||||||
|
const id = context.params?.id;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "ID tidak ditemukan",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.distribusiAgama.findUnique({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Data tidak ditemukan",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await prisma.distribusiAgama.delete({
|
||||||
|
where: { id },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Data berhasil dihapus",
|
||||||
|
data: deleted,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
export default async function distribusiAgamaFindMany(context: Context) {
|
||||||
|
const page = Number(context.query.page) || 1;
|
||||||
|
const limit = Number(context.query.limit) || 100;
|
||||||
|
const search = (context.query.search as string) || '';
|
||||||
|
const tahun = Number(context.query.tahun) || new Date().getFullYear();
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: any = { isActive: true, tahun };
|
||||||
|
|
||||||
|
// Tambahkan pencarian (jika ada)
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ agama: { contains: search, mode: "insensitive" } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
prisma.distribusiAgama.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { jumlah: "desc" },
|
||||||
|
}),
|
||||||
|
prisma.distribusiAgama.count({
|
||||||
|
where,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Success fetch distribusi agama with pagination",
|
||||||
|
data,
|
||||||
|
page,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch distribusi agama with pagination",
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export default async function distribusiAgamaFindUnique(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const pathSegments = url.pathname.split('/');
|
||||||
|
const id = pathSegments[pathSegments.length - 1];
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "ID tidak boleh kosong",
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof id !== 'string') {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "ID tidak valid",
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await prisma.distribusiAgama.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "Data tidak ditemukan",
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
message: "Data ditemukan",
|
||||||
|
data: data,
|
||||||
|
}, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching data:", error);
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "Terjadi kesalahan saat mengambil data",
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Elysia, { t } from "elysia";
|
||||||
|
import distribusiAgamaFindUnique from "./findUnique";
|
||||||
|
import distribusiAgamaUpdate from "./updt";
|
||||||
|
import distribusiAgamaFindMany from "./findMany";
|
||||||
|
import distribusiAgamaCreate from "./create";
|
||||||
|
import distribusiAgamaDelete from "./del";
|
||||||
|
|
||||||
|
const DistribusiAgama = new Elysia({
|
||||||
|
prefix: "/distribusiagama",
|
||||||
|
tags: ["Kependudukan/Distribusi Agama"],
|
||||||
|
})
|
||||||
|
.get("/:id", async (context) => {
|
||||||
|
const response = await distribusiAgamaFindUnique(new Request(context.request))
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
.get("/find-many", distribusiAgamaFindMany)
|
||||||
|
.post("/create", distribusiAgamaCreate, {
|
||||||
|
body: t.Object({
|
||||||
|
agama: t.String(),
|
||||||
|
jumlah: t.Number(),
|
||||||
|
tahun: t.Number(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.put("/:id", distribusiAgamaUpdate, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
body: t.Object({
|
||||||
|
agama: t.String(),
|
||||||
|
jumlah: t.Number(),
|
||||||
|
tahun: t.Number(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.delete("/del/:id", distribusiAgamaDelete, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
export default DistribusiAgama;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
export default async function distribusiAgamaUpdate(context: Context) {
|
||||||
|
const id = context.params?.id;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "ID tidak ditemukan",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {agama, jumlah, tahun} = context.body as {
|
||||||
|
agama: string;
|
||||||
|
jumlah: number;
|
||||||
|
tahun: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.distribusiAgama.findUnique({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Data tidak ditemukan",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.distribusiAgama.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
agama,
|
||||||
|
jumlah,
|
||||||
|
tahun,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Data berhasil diupdate",
|
||||||
|
data: updated,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
type FormCreate = Prisma.DistribusiUmurGetPayload<{
|
||||||
|
select: {
|
||||||
|
rentangUmur: true;
|
||||||
|
jumlah: true;
|
||||||
|
tahun: true;
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
|
||||||
|
export default async function distribusiUmurCreate(context: Context) {
|
||||||
|
const body = context.body as FormCreate;
|
||||||
|
|
||||||
|
const created = await prisma.distribusiUmur.create({
|
||||||
|
data: {
|
||||||
|
rentangUmur: body.rentangUmur,
|
||||||
|
jumlah: body.jumlah,
|
||||||
|
tahun: body.tahun,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
rentangUmur: true,
|
||||||
|
jumlah: true,
|
||||||
|
tahun: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Sukses menambahkan distribusi umur",
|
||||||
|
data: created,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
export default async function distribusiUmurDelete(context: Context) {
|
||||||
|
const id = context.params?.id;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "ID tidak ditemukan",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.distribusiUmur.findUnique({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Data tidak ditemukan",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await prisma.distribusiUmur.delete({
|
||||||
|
where: { id },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Data berhasil dihapus",
|
||||||
|
data: deleted,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
export default async function distribusiUmurFindMany(context: Context) {
|
||||||
|
const page = Number(context.query.page) || 1;
|
||||||
|
const limit = Number(context.query.limit) || 100;
|
||||||
|
const tahun = Number(context.query.tahun) || new Date().getFullYear();
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: any = { isActive: true, tahun };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
prisma.distribusiUmur.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
}),
|
||||||
|
prisma.distribusiUmur.count({
|
||||||
|
where,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Success fetch distribusi umur with pagination",
|
||||||
|
data,
|
||||||
|
page,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch distribusi umur with pagination",
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export default async function distribusiUmurFindUnique(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const pathSegments = url.pathname.split('/');
|
||||||
|
const id = pathSegments[pathSegments.length - 1];
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "ID tidak boleh kosong",
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof id !== 'string') {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "ID tidak valid",
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await prisma.distribusiUmur.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "Data tidak ditemukan",
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
message: "Data ditemukan",
|
||||||
|
data: data,
|
||||||
|
}, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching data:", error);
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "Terjadi kesalahan saat mengambil data",
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Elysia, { t } from "elysia";
|
||||||
|
import distribusiUmurFindUnique from "./findUnique";
|
||||||
|
import distribusiUmurUpdate from "./updt";
|
||||||
|
import distribusiUmurFindMany from "./findMany";
|
||||||
|
import distribusiUmurCreate from "./create";
|
||||||
|
import distribusiUmurDelete from "./del";
|
||||||
|
|
||||||
|
const DistribusiUmur = new Elysia({
|
||||||
|
prefix: "/distribusiumur",
|
||||||
|
tags: ["Kependudukan/Distribusi Umur"],
|
||||||
|
})
|
||||||
|
.get("/:id", async (context) => {
|
||||||
|
const response = await distribusiUmurFindUnique(new Request(context.request))
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
.get("/find-many", distribusiUmurFindMany)
|
||||||
|
.post("/create", distribusiUmurCreate, {
|
||||||
|
body: t.Object({
|
||||||
|
rentangUmur: t.String(),
|
||||||
|
jumlah: t.Number(),
|
||||||
|
tahun: t.Number(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.put("/:id", distribusiUmurUpdate, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
body: t.Object({
|
||||||
|
rentangUmur: t.String(),
|
||||||
|
jumlah: t.Number(),
|
||||||
|
tahun: t.Number(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.delete("/del/:id", distribusiUmurDelete, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
export default DistribusiUmur;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
export default async function distribusiUmurUpdate(context: Context) {
|
||||||
|
const id = context.params?.id;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "ID tidak ditemukan",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {rentangUmur, jumlah, tahun} = context.body as {
|
||||||
|
rentangUmur: string;
|
||||||
|
jumlah: number;
|
||||||
|
tahun: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.distribusiUmur.findUnique({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Data tidak ditemukan",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.distribusiUmur.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
rentangUmur,
|
||||||
|
jumlah,
|
||||||
|
tahun,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Data berhasil diupdate",
|
||||||
|
data: updated,
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/app/api/[[...slugs]]/_lib/kependudukan/index.ts
Normal file
18
src/app/api/[[...slugs]]/_lib/kependudukan/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import Elysia from "elysia";
|
||||||
|
import DistribusiAgama from "./distribusi-agama";
|
||||||
|
import DistribusiUmur from "./distribusi-umur";
|
||||||
|
import DataBanjar from "./data-banjar";
|
||||||
|
import MigrasiPenduduk from "./migrasi-penduduk";
|
||||||
|
import DashboardKependudukan from "./dashboard";
|
||||||
|
|
||||||
|
const Kependudukan = new Elysia({
|
||||||
|
prefix: "/kependudukan",
|
||||||
|
tags: ["Kependudukan"],
|
||||||
|
})
|
||||||
|
.use(DashboardKependudukan)
|
||||||
|
.use(DistribusiAgama)
|
||||||
|
.use(DistribusiUmur)
|
||||||
|
.use(DataBanjar)
|
||||||
|
.use(MigrasiPenduduk)
|
||||||
|
|
||||||
|
export default Kependudukan;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
type FormCreate = Prisma.MigrasiPendudukGetPayload<{
|
||||||
|
select: {
|
||||||
|
jenis: true;
|
||||||
|
nama: true;
|
||||||
|
tanggal: true;
|
||||||
|
asalTujuan: true;
|
||||||
|
alasan: true;
|
||||||
|
jenisKelamin: true;
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
|
||||||
|
export default async function migrasiPendudukCreate(context: Context) {
|
||||||
|
const body = context.body as FormCreate;
|
||||||
|
|
||||||
|
const created = await prisma.migrasiPenduduk.create({
|
||||||
|
data: {
|
||||||
|
jenis: body.jenis,
|
||||||
|
nama: body.nama,
|
||||||
|
tanggal: new Date(body.tanggal),
|
||||||
|
asalTujuan: body.asalTujuan,
|
||||||
|
alasan: body.alasan,
|
||||||
|
jenisKelamin: body.jenisKelamin,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
jenis: true,
|
||||||
|
nama: true,
|
||||||
|
tanggal: true,
|
||||||
|
asalTujuan: true,
|
||||||
|
alasan: true,
|
||||||
|
jenisKelamin: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Sukses menambahkan migrasi penduduk",
|
||||||
|
data: created,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
export default async function migrasiPendudukDelete(context: Context) {
|
||||||
|
const id = context.params?.id;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "ID tidak ditemukan",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.migrasiPenduduk.findUnique({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Data tidak ditemukan",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await prisma.migrasiPenduduk.delete({
|
||||||
|
where: { id },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Data berhasil dihapus",
|
||||||
|
data: deleted,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
export default async function migrasiPendudukFindMany(context: Context) {
|
||||||
|
const page = Number(context.query.page) || 1;
|
||||||
|
const limit = Number(context.query.limit) || 10;
|
||||||
|
const search = (context.query.search as string) || '';
|
||||||
|
const jenis = (context.query.jenis as string) || '';
|
||||||
|
const tahun = Number(context.query.tahun) || new Date().getFullYear();
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: any = { isActive: true };
|
||||||
|
|
||||||
|
if (jenis) {
|
||||||
|
where.jenis = jenis;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tahun) {
|
||||||
|
where.tanggal = {
|
||||||
|
gte: new Date(`${tahun}-01-01`),
|
||||||
|
lte: new Date(`${tahun}-12-31`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ nama: { contains: search, mode: "insensitive" } },
|
||||||
|
{ asalTujuan: { contains: search, mode: "insensitive" } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
prisma.migrasiPenduduk.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { tanggal: "desc" },
|
||||||
|
}),
|
||||||
|
prisma.migrasiPenduduk.count({
|
||||||
|
where,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Success fetch migrasi penduduk with pagination",
|
||||||
|
data,
|
||||||
|
page,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch migrasi penduduk with pagination",
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export default async function migrasiPendudukFindUnique(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const pathSegments = url.pathname.split('/');
|
||||||
|
const id = pathSegments[pathSegments.length - 1];
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "ID tidak boleh kosong",
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof id !== 'string') {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "ID tidak valid",
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await prisma.migrasiPenduduk.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "Data tidak ditemukan",
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
message: "Data ditemukan",
|
||||||
|
data: data,
|
||||||
|
}, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching data:", error);
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "Terjadi kesalahan saat mengambil data",
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Elysia, { t } from "elysia";
|
||||||
|
import migrasiPendudukFindUnique from "./findUnique";
|
||||||
|
import migrasiPendudukUpdate from "./updt";
|
||||||
|
import migrasiPendudukFindMany from "./findMany";
|
||||||
|
import migrasiPendudukCreate from "./create";
|
||||||
|
import migrasiPendudukDelete from "./del";
|
||||||
|
|
||||||
|
const MigrasiPenduduk = new Elysia({
|
||||||
|
prefix: "/migrasipenduduk",
|
||||||
|
tags: ["Kependudukan/Migrasi Penduduk"],
|
||||||
|
})
|
||||||
|
.get("/:id", async (context) => {
|
||||||
|
const response = await migrasiPendudukFindUnique(new Request(context.request))
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
.get("/find-many", migrasiPendudukFindMany)
|
||||||
|
.post("/create", migrasiPendudukCreate, {
|
||||||
|
body: t.Object({
|
||||||
|
jenis: t.String(),
|
||||||
|
nama: t.String(),
|
||||||
|
tanggal: t.String(),
|
||||||
|
asalTujuan: t.String(),
|
||||||
|
alasan: t.Optional(t.String()),
|
||||||
|
jenisKelamin: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.put("/:id", migrasiPendudukUpdate, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
body: t.Object({
|
||||||
|
jenis: t.String(),
|
||||||
|
nama: t.String(),
|
||||||
|
tanggal: t.String(),
|
||||||
|
asalTujuan: t.String(),
|
||||||
|
alasan: t.Optional(t.String()),
|
||||||
|
jenisKelamin: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.delete("/del/:id", migrasiPendudukDelete, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
export default MigrasiPenduduk;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
export default async function migrasiPendudukUpdate(context: Context) {
|
||||||
|
const id = context.params?.id;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "ID tidak ditemukan",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {jenis, nama, tanggal, asalTujuan, alasan, jenisKelamin} = context.body as {
|
||||||
|
jenis: string;
|
||||||
|
nama: string;
|
||||||
|
tanggal: string;
|
||||||
|
asalTujuan: string;
|
||||||
|
alasan?: string;
|
||||||
|
jenisKelamin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.migrasiPenduduk.findUnique({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Data tidak ditemukan",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.migrasiPenduduk.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
jenis,
|
||||||
|
nama,
|
||||||
|
tanggal: new Date(tanggal),
|
||||||
|
asalTujuan,
|
||||||
|
alasan,
|
||||||
|
jenisKelamin,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Data berhasil diupdate",
|
||||||
|
data: updated,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ import TarifLayanan from "./data_kesehatan_warga/fasilitas_kesehatan/tarif-layan
|
|||||||
|
|
||||||
|
|
||||||
const Kesehatan = new Elysia({
|
const Kesehatan = new Elysia({
|
||||||
prefix: "/api/kesehatan",
|
prefix: "/kesehatan",
|
||||||
tags: ["Kesehatan"],
|
tags: ["Kesehatan"],
|
||||||
})
|
})
|
||||||
.use(PersentaseKelahiranKematian)
|
.use(PersentaseKelahiranKematian)
|
||||||
|
|||||||
@@ -8,24 +8,23 @@ type APBDesItemInput = {
|
|||||||
kode: string;
|
kode: string;
|
||||||
uraian: string;
|
uraian: string;
|
||||||
anggaran: number;
|
anggaran: number;
|
||||||
realisasi: number;
|
|
||||||
selisih: number;
|
|
||||||
persentase: number;
|
|
||||||
level: number;
|
level: number;
|
||||||
tipe?: string | null;
|
tipe?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FormCreate = {
|
type FormCreate = {
|
||||||
tahun: number;
|
tahun: number;
|
||||||
imageId: string;
|
name?: string;
|
||||||
fileId: string;
|
deskripsi?: string;
|
||||||
|
jumlah?: string;
|
||||||
|
imageId?: string | null; // Opsional
|
||||||
|
fileId?: string | null; // Opsional
|
||||||
items: APBDesItemInput[];
|
items: APBDesItemInput[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function apbdesCreate(context: Context) {
|
export default async function apbdesCreate(context: Context) {
|
||||||
const body = context.body as FormCreate;
|
const body = context.body as FormCreate;
|
||||||
|
|
||||||
// Log the incoming request for debugging
|
|
||||||
console.log('Incoming request body:', JSON.stringify(body, null, 2));
|
console.log('Incoming request body:', JSON.stringify(body, null, 2));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -33,43 +32,44 @@ export default async function apbdesCreate(context: Context) {
|
|||||||
if (!body.tahun) {
|
if (!body.tahun) {
|
||||||
throw new Error('Tahun is required');
|
throw new Error('Tahun is required');
|
||||||
}
|
}
|
||||||
if (!body.imageId) {
|
// Image dan file sekarang opsional
|
||||||
throw new Error('Image ID is required');
|
|
||||||
}
|
|
||||||
if (!body.fileId) {
|
|
||||||
throw new Error('File ID is required');
|
|
||||||
}
|
|
||||||
if (!body.items || body.items.length === 0) {
|
if (!body.items || body.items.length === 0) {
|
||||||
throw new Error('At least one item is required');
|
throw new Error('At least one item is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Buat APBDes + items (tanpa parentId dulu)
|
// 1. Buat APBDes + items dengan auto-calculate fields
|
||||||
const created = await prisma.$transaction(async (prisma) => {
|
const created = await prisma.$transaction(async (prisma) => {
|
||||||
const apbdes = await prisma.aPBDes.create({
|
const apbdes = await prisma.aPBDes.create({
|
||||||
data: {
|
data: {
|
||||||
tahun: body.tahun,
|
tahun: body.tahun,
|
||||||
name: `APBDes Tahun ${body.tahun}`,
|
name: body.name || `APBDes Tahun ${body.tahun}`,
|
||||||
imageId: body.imageId,
|
deskripsi: body.deskripsi,
|
||||||
fileId: body.fileId,
|
jumlah: body.jumlah,
|
||||||
|
imageId: body.imageId || null, // null jika tidak ada
|
||||||
|
fileId: body.fileId || null, // null jika tidak ada
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create items in a batch
|
// Create items dengan auto-calculate totalRealisasi=0, selisih, persentase
|
||||||
const items = await Promise.all(
|
const items = await Promise.all(
|
||||||
body.items.map(item => {
|
body.items.map(async item => {
|
||||||
// Create a new object with only the fields that exist in the APBDesItem model
|
const anggaran = item.anggaran;
|
||||||
|
const totalRealisasi = 0; // Belum ada realisasi saat create
|
||||||
|
const selisih = anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
|
||||||
|
const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0;
|
||||||
|
|
||||||
const itemData = {
|
const itemData = {
|
||||||
kode: item.kode,
|
kode: item.kode,
|
||||||
uraian: item.uraian,
|
uraian: item.uraian,
|
||||||
anggaran: item.anggaran,
|
anggaran: anggaran,
|
||||||
realisasi: item.realisasi,
|
|
||||||
selisih: item.selisih,
|
|
||||||
persentase: item.persentase,
|
|
||||||
level: item.level,
|
level: item.level,
|
||||||
tipe: item.tipe, // ✅ sertakan, biar null
|
tipe: item.tipe || null,
|
||||||
|
totalRealisasi,
|
||||||
|
selisih,
|
||||||
|
persentase,
|
||||||
apbdesId: apbdes.id,
|
apbdesId: apbdes.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
return prisma.aPBDesItem.create({
|
return prisma.aPBDesItem.create({
|
||||||
data: itemData,
|
data: itemData,
|
||||||
select: { id: true, kode: true },
|
select: { id: true, kode: true },
|
||||||
@@ -84,20 +84,27 @@ export default async function apbdesCreate(context: Context) {
|
|||||||
// 2. Isi parentId berdasarkan kode
|
// 2. Isi parentId berdasarkan kode
|
||||||
await assignParentIdsToApbdesItems(created.items);
|
await assignParentIdsToApbdesItems(created.items);
|
||||||
|
|
||||||
// 3. Ambil ulang data lengkap untuk response
|
// 3. Ambil ulang data lengkap untuk response (include realisasiItems)
|
||||||
const result = await prisma.aPBDes.findUnique({
|
const result = await prisma.aPBDes.findUnique({
|
||||||
where: { id: created.id },
|
where: { id: created.id },
|
||||||
include: {
|
include: {
|
||||||
image: true,
|
image: true,
|
||||||
file: true,
|
file: true,
|
||||||
items: {
|
items: {
|
||||||
|
where: { isActive: true },
|
||||||
orderBy: { kode: 'asc' },
|
orderBy: { kode: 'asc' },
|
||||||
|
include: {
|
||||||
|
realisasiItems: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { tanggal: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('APBDes created successfully:', JSON.stringify(result, null, 2));
|
console.log('APBDes created successfully:', JSON.stringify(result, null, 2));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Berhasil membuat APBDes",
|
message: "Berhasil membuat APBDes",
|
||||||
@@ -105,7 +112,6 @@ export default async function apbdesCreate(context: Context) {
|
|||||||
};
|
};
|
||||||
} catch (innerError) {
|
} catch (innerError) {
|
||||||
console.error('Error in post-creation steps:', innerError);
|
console.error('Error in post-creation steps:', innerError);
|
||||||
// Even if post-creation steps fail, we still return success since the main record was created
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "APBDes berhasil dibuat, tetapi ada masalah dengan pemrosesan tambahan",
|
message: "APBDes berhasil dibuat, tetapi ada masalah dengan pemrosesan tambahan",
|
||||||
@@ -115,13 +121,12 @@ export default async function apbdesCreate(context: Context) {
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Error creating APBDes:", error);
|
console.error("Error creating APBDes:", error);
|
||||||
|
|
||||||
// Log the full error for debugging
|
|
||||||
if (error.code) console.error('Prisma error code:', error.code);
|
if (error.code) console.error('Prisma error code:', error.code);
|
||||||
if (error.meta) console.error('Prisma error meta:', error.meta);
|
if (error.meta) console.error('Prisma error meta:', error.meta);
|
||||||
|
|
||||||
const errorMessage = error.message || 'Unknown error';
|
const errorMessage = error.message || 'Unknown error';
|
||||||
|
|
||||||
context.set.status = 500;
|
context.set.status = 500;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default async function apbdesFindMany(context: Context) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const where: any = { isActive: true };
|
const where: any = { isActive: true };
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ name: { contains: search, mode: "insensitive" } },
|
{ name: { contains: search, mode: "insensitive" } },
|
||||||
@@ -47,7 +47,16 @@ export default async function apbdesFindMany(context: Context) {
|
|||||||
include: {
|
include: {
|
||||||
image: true,
|
image: true,
|
||||||
file: true,
|
file: true,
|
||||||
items: true,
|
items: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { kode: "asc" },
|
||||||
|
include: {
|
||||||
|
realisasiItems: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { tanggal: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.aPBDes.count({ where }),
|
prisma.aPBDes.count({ where }),
|
||||||
|
|||||||
@@ -2,15 +2,9 @@ import prisma from "@/lib/prisma";
|
|||||||
import { Context } from "elysia";
|
import { Context } from "elysia";
|
||||||
|
|
||||||
export default async function apbdesFindUnique(context: Context) {
|
export default async function apbdesFindUnique(context: Context) {
|
||||||
// ✅ Parse URL secara manual
|
|
||||||
const url = new URL(context.request.url);
|
const url = new URL(context.request.url);
|
||||||
const pathSegments = url.pathname.split('/').filter(Boolean);
|
const pathSegments = url.pathname.split('/').filter(Boolean);
|
||||||
|
|
||||||
console.log("🔍 DEBUG INFO:");
|
|
||||||
console.log("- Full URL:", context.request.url);
|
|
||||||
console.log("- Pathname:", url.pathname);
|
|
||||||
console.log("- Path segments:", pathSegments);
|
|
||||||
|
|
||||||
// Expected: ['api', 'landingpage', 'apbdes', 'ID']
|
// Expected: ['api', 'landingpage', 'apbdes', 'ID']
|
||||||
if (pathSegments.length < 4) {
|
if (pathSegments.length < 4) {
|
||||||
context.set.status = 400;
|
context.set.status = 400;
|
||||||
@@ -20,9 +14,9 @@ export default async function apbdesFindUnique(context: Context) {
|
|||||||
debug: { pathSegments }
|
debug: { pathSegments }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathSegments[0] !== 'api' ||
|
if (pathSegments[0] !== 'api' ||
|
||||||
pathSegments[1] !== 'landingpage' ||
|
pathSegments[1] !== 'landingpage' ||
|
||||||
pathSegments[2] !== 'apbdes') {
|
pathSegments[2] !== 'apbdes') {
|
||||||
context.set.status = 400;
|
context.set.status = 400;
|
||||||
return {
|
return {
|
||||||
@@ -31,9 +25,9 @@ export default async function apbdesFindUnique(context: Context) {
|
|||||||
debug: { pathSegments }
|
debug: { pathSegments }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = pathSegments[3]; // ✅ ID ada di index ke-3
|
const id = pathSegments[3];
|
||||||
|
|
||||||
if (!id || id.trim() === '') {
|
if (!id || id.trim() === '') {
|
||||||
context.set.status = 400;
|
context.set.status = 400;
|
||||||
return {
|
return {
|
||||||
@@ -48,11 +42,17 @@ export default async function apbdesFindUnique(context: Context) {
|
|||||||
include: {
|
include: {
|
||||||
items: {
|
items: {
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
orderBy: { kode: 'asc' }
|
orderBy: { kode: 'asc' },
|
||||||
|
include: {
|
||||||
|
realisasiItems: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { tanggal: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
image: true,
|
image: true,
|
||||||
file: true
|
file: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result || !result.isActive) {
|
if (!result || !result.isActive) {
|
||||||
|
|||||||
@@ -5,17 +5,17 @@ import apbdesDelete from "./del";
|
|||||||
import apbdesFindMany from "./findMany";
|
import apbdesFindMany from "./findMany";
|
||||||
import apbdesFindUnique from "./findUnique";
|
import apbdesFindUnique from "./findUnique";
|
||||||
import apbdesUpdate from "./updt";
|
import apbdesUpdate from "./updt";
|
||||||
|
import realisasiCreate from "./realisasi/create";
|
||||||
|
import realisasiUpdate from "./realisasi/update";
|
||||||
|
import realisasiDelete from "./realisasi/delete";
|
||||||
|
|
||||||
// Definisikan skema untuk item APBDes
|
// Definisikan skema untuk item APBDes (tanpa realisasi field)
|
||||||
const ApbdesItemSchema = t.Object({
|
const ApbdesItemSchema = t.Object({
|
||||||
kode: t.String(),
|
kode: t.String(),
|
||||||
uraian: t.String(),
|
uraian: t.String(),
|
||||||
anggaran: t.Number(),
|
anggaran: t.Number(),
|
||||||
realisasi: t.Number(),
|
|
||||||
selisih: t.Number(),
|
|
||||||
persentase: t.Number(),
|
|
||||||
level: t.Number(),
|
level: t.Number(),
|
||||||
tipe: t.Optional(t.Union([t.String(), t.Null()])) // misal: "pendapatan" atau "belanja"
|
tipe: t.Optional(t.Union([t.String(), t.Null()])), // "pendapatan" | "belanja" | "pembiayaan" | null
|
||||||
});
|
});
|
||||||
|
|
||||||
const APBDes = new Elysia({
|
const APBDes = new Elysia({
|
||||||
@@ -26,33 +26,72 @@ const APBDes = new Elysia({
|
|||||||
// ✅ Find all (dengan query opsional: page, limit, tahun)
|
// ✅ Find all (dengan query opsional: page, limit, tahun)
|
||||||
.get("/findMany", apbdesFindMany)
|
.get("/findMany", apbdesFindMany)
|
||||||
|
|
||||||
// ✅ Find by ID
|
// ✅ Find by ID (include realisasiItems)
|
||||||
.get("/:id", apbdesFindUnique)
|
.get("/:id", apbdesFindUnique)
|
||||||
|
|
||||||
// ✅ Create
|
// ✅ Create APBDes dengan items (tanpa realisasi)
|
||||||
.post("/create", apbdesCreate, {
|
.post("/create", apbdesCreate, {
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
tahun: t.Number(),
|
tahun: t.Number(),
|
||||||
imageId: t.String(),
|
name: t.Optional(t.String()),
|
||||||
fileId: t.String(),
|
deskripsi: t.Optional(t.String()),
|
||||||
|
jumlah: t.Optional(t.String()),
|
||||||
|
imageId: t.Optional(t.String()),
|
||||||
|
fileId: t.Optional(t.String()),
|
||||||
items: t.Array(ApbdesItemSchema),
|
items: t.Array(ApbdesItemSchema),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// ✅ Update
|
// ✅ Update APBDes dengan items (tanpa realisasi)
|
||||||
.put("/:id", apbdesUpdate, {
|
.put("/:id", apbdesUpdate, {
|
||||||
params: t.Object({ id: t.String() }),
|
params: t.Object({ id: t.String() }),
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
tahun: t.Number(),
|
tahun: t.Number(),
|
||||||
imageId: t.String(),
|
name: t.Optional(t.String()),
|
||||||
fileId: t.String(),
|
deskripsi: t.Optional(t.String()),
|
||||||
|
jumlah: t.Optional(t.String()),
|
||||||
|
imageId: t.Optional(t.String()),
|
||||||
|
fileId: t.Optional(t.String()),
|
||||||
items: t.Array(ApbdesItemSchema),
|
items: t.Array(ApbdesItemSchema),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// ✅ Delete
|
// ✅ Delete APBDes
|
||||||
.delete("/del/:id", apbdesDelete, {
|
.delete("/del/:id", apbdesDelete, {
|
||||||
params: t.Object({ id: t.String() }),
|
params: t.Object({ id: t.String() }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// REALISASI ENDPOINTS
|
||||||
|
// =========================================
|
||||||
|
|
||||||
|
// ✅ Create realisasi untuk item tertentu
|
||||||
|
.post("/:itemId/realisasi", realisasiCreate, {
|
||||||
|
params: t.Object({ itemId: t.String() }),
|
||||||
|
body: t.Object({
|
||||||
|
kode: t.String(),
|
||||||
|
jumlah: t.Number(),
|
||||||
|
tanggal: t.String(),
|
||||||
|
keterangan: t.Optional(t.String()),
|
||||||
|
buktiFileId: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ✅ Update realisasi
|
||||||
|
.put("/realisasi/:realisasiId", realisasiUpdate, {
|
||||||
|
params: t.Object({ realisasiId: t.String() }),
|
||||||
|
body: t.Object({
|
||||||
|
kode: t.Optional(t.String()),
|
||||||
|
jumlah: t.Optional(t.Number()),
|
||||||
|
tanggal: t.Optional(t.String()),
|
||||||
|
keterangan: t.Optional(t.String()),
|
||||||
|
buktiFileId: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ✅ Delete realisasi
|
||||||
|
.delete("/realisasi/:realisasiId", realisasiDelete, {
|
||||||
|
params: t.Object({ realisasiId: t.String() }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default APBDes;
|
export default APBDes;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user