Compare commits
67 Commits
main
...
tasks/noc-
| Author | SHA1 | Date | |
|---|---|---|---|
| 11ef320d55 | |||
| 1b1dc71225 | |||
| 2c5fa52608 | |||
| f066defcba | |||
| fd52b0d281 | |||
| 65844bac7e | |||
| 3125bc1002 | |||
| ed93363de1 | |||
| 8e2608a2be | |||
| 0736df8523 | |||
| 097f9f34cc | |||
| 75c7bc249e | |||
| b77822f2dd | |||
| 5058e2cc1c | |||
| 3bed181805 | |||
| c7a986aebc | |||
| c6951dec80 | |||
| 8da53127c7 | |||
| 354e706dc5 | |||
| c216fa074d | |||
| 44b6b158ef | |||
| 34804127c5 | |||
| 0d0dc187a5 | |||
| ec057ef2e5 | |||
| 0900b8f199 | |||
| aeedb17402 | |||
| ebc1242bee | |||
| 0e063cb79e | |||
| 3eb84921a1 | |||
| c6415c5aab | |||
| 519a14adaa | |||
| 366c08fbaa | |||
| 5c09e7a0be | |||
| 7c8012d277 | |||
| 687ce11a81 | |||
| 1ba4643e23 | |||
| 113dd7ba6f | |||
| 71a305cd4b | |||
| 84b96ca3be | |||
| 8159216a2c | |||
| d714c09efc | |||
| 0a97e31416 | |||
| 158a2db435 | |||
| 2d68d4dc06 | |||
| 97e6caa332 | |||
| f0c37272b9 | |||
| 8c35d58b38 | |||
| 952f7ecb16 | |||
| a74e0c02e5 | |||
| 17ecd3feca | |||
| d88cf2b100 | |||
| e0955ed2c4 | |||
| 918399bf62 | |||
| 7ce2eb6ae8 | |||
| 40772859f9 | |||
| c7b34b8c28 | |||
| 9bf73a305c | |||
| 947adc1537 | |||
| 9086e28961 | |||
| 66d207c081 | |||
| b77f6e8fa3 | |||
| 9e6734d1a5 | |||
|
|
1b9ddf0f4b | ||
| a0f440f6b3 | |||
| 1f56dd7660 | |||
| 1a2a213d0a | |||
| 1ec10fe623 |
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# Database
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/dashboard_desa?schema=public"
|
||||
|
||||
# Authentication
|
||||
BETTER_AUTH_SECRET="your-secret-key-here-min-32-characters"
|
||||
ADMIN_EMAIL="admin@example.com"
|
||||
ADMIN_PASSWORD="admin123"
|
||||
|
||||
# GitHub OAuth (Optional)
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
|
||||
# Application
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Public URL
|
||||
VITE_PUBLIC_URL="http://localhost:3000"
|
||||
NOC_API_URL="https://darmasaba.muku.id/api/noc/docs/json"
|
||||
@@ -1,43 +1,52 @@
|
||||
#!/usr/bin/env bun
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Fungsi untuk mencari string terpanjang dalam objek (biasanya balasan AI)
|
||||
function findLongestString(obj: any): string {
|
||||
let longest = "";
|
||||
const search = (item: any) => {
|
||||
if (typeof item === "string") {
|
||||
if (item.length > longest.length) longest = item;
|
||||
} else if (Array.isArray(item)) {
|
||||
item.forEach(search);
|
||||
} else if (item && typeof item === "object") {
|
||||
Object.values(item).forEach(search);
|
||||
// Function to manually load .env from project root if process.env is missing keys
|
||||
function loadEnv() {
|
||||
const envPath = join(process.cwd(), ".env");
|
||||
if (existsSync(envPath)) {
|
||||
const envContent = readFileSync(envPath, "utf-8");
|
||||
const lines = envContent.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line && !line.startsWith("#")) {
|
||||
const [key, ...valueParts] = line.split("=");
|
||||
if (key && valueParts.length > 0) {
|
||||
const value = valueParts.join("=").trim().replace(/^["']|["']$/g, "");
|
||||
process.env[key.trim()] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
search(obj);
|
||||
return longest;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
// Ensure environment variables are loaded
|
||||
loadEnv();
|
||||
|
||||
const inputRaw = readFileSync(0, "utf-8");
|
||||
if (!inputRaw) return;
|
||||
const input = JSON.parse(inputRaw);
|
||||
|
||||
// DEBUG: Lihat struktur asli di console terminal (stderr)
|
||||
console.error("DEBUG KEYS:", Object.keys(input));
|
||||
let finalText = "";
|
||||
let sessionId = "dashboard-desa-plus";
|
||||
|
||||
try {
|
||||
// Try parsing as JSON first
|
||||
const input = JSON.parse(inputRaw);
|
||||
sessionId = input.session_id || "dashboard-desa-plus";
|
||||
finalText = typeof input === "string" ? input : (input.response || input.text || JSON.stringify(input));
|
||||
} catch {
|
||||
// If not JSON, use raw text
|
||||
finalText = inputRaw;
|
||||
}
|
||||
|
||||
const BOT_TOKEN = process.env.BOT_TOKEN;
|
||||
const CHAT_ID = process.env.CHAT_ID;
|
||||
|
||||
const sessionId = input.session_id || "unknown";
|
||||
|
||||
// Cari teks secara otomatis di seluruh objek JSON
|
||||
let finalText = findLongestString(input.response || input);
|
||||
|
||||
if (!finalText || finalText.length < 5) {
|
||||
finalText =
|
||||
"Teks masih gagal diekstraksi. Struktur: " +
|
||||
Object.keys(input).join(", ");
|
||||
if (!BOT_TOKEN || !CHAT_ID) {
|
||||
console.error("Missing BOT_TOKEN or CHAT_ID in environment variables");
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
@@ -45,7 +54,7 @@ async function run() {
|
||||
`🆔 Session: \`${sessionId}\` \n\n` +
|
||||
`🧠 Output:\n${finalText.substring(0, 3500)}`;
|
||||
|
||||
await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
||||
const res = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -55,6 +64,13 @@ async function run() {
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
console.error("Telegram API Error:", errorData);
|
||||
} else {
|
||||
console.log("Notification sent successfully!");
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify({ status: "continue" }));
|
||||
} catch (err) {
|
||||
console.error("Hook Error:", err);
|
||||
|
||||
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
@@ -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
@@ -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"}')"
|
||||
93
.github/workflows/script/re-pull.sh
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/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}"
|
||||
|
||||
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!"
|
||||
echo " Pastikan nama stack sudah benar."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STACK_ID=$(echo "$STACK" | jq -r .Id)
|
||||
ENDPOINT_ID=$(echo "$STACK" | jq -r .EndpointId)
|
||||
ENV=$(echo "$STACK" | jq '.Env // []')
|
||||
|
||||
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 "🚀 Redeploying $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 container running..."
|
||||
|
||||
MAX_RETRY=15
|
||||
COUNT=0
|
||||
|
||||
while [ $COUNT -lt $MAX_RETRY ]; do
|
||||
sleep 5
|
||||
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}")
|
||||
|
||||
TOTAL=$(echo "$CONTAINERS" | jq 'length')
|
||||
RUNNING=$(echo "$CONTAINERS" | jq '[.[] | select(.State == "running")] | length')
|
||||
FAILED=$(echo "$CONTAINERS" | jq '[.[] | select(.State == "exited" and (.Status | test("Exited \\(0\\)") | not))] | length')
|
||||
|
||||
echo "🔄 [${COUNT}/${MAX_RETRY}] Running: ${RUNNING} | Failed: ${FAILED} | Total: ${TOTAL}"
|
||||
echo "$CONTAINERS" | jq -r '.[] | " → \(.Names[0]) | \(.State) | \(.Status)"'
|
||||
|
||||
if [ "$FAILED" -gt "0" ]; then
|
||||
echo ""
|
||||
echo "❌ Ada container yang crash!"
|
||||
echo "$CONTAINERS" | jq -r '.[] | select(.State == "exited" and (.Status | test("Exited \\(0\\)") | not)) | " → \(.Names[0]) | \(.Status)"'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$RUNNING" -gt "0" ]; then
|
||||
echo ""
|
||||
echo "✅ Stack $STACK_NAME berhasil di-redeploy dan running!"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "❌ Timeout! Stack tidak kunjung running setelah $((MAX_RETRY * 5)) detik."
|
||||
exit 1
|
||||
7
.gitignore
vendored
@@ -16,6 +16,7 @@ _.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
# Only .env.example is allowed to be committed
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
@@ -33,6 +34,12 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
# Dashboard-MD
|
||||
Dashboard-MD
|
||||
|
||||
# md
|
||||
*.md
|
||||
|
||||
# Playwright artifacts
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
5
.qwen/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Bash(bun *)"]
|
||||
}
|
||||
}
|
||||
62
Dockerfile
Normal file
@@ -0,0 +1,62 @@
|
||||
# Stage 1: Build
|
||||
FROM oven/bun:1.3 AS build
|
||||
|
||||
# Install build dependencies for native modules
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json bun.lock* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Use .env.example as default env for build
|
||||
RUN cp .env.example .env
|
||||
|
||||
# Generate Prisma client
|
||||
RUN bun x prisma generate
|
||||
|
||||
# Generate API types
|
||||
RUN bun run gen:api
|
||||
|
||||
# Build the application frontend using our custom build script
|
||||
RUN bun run build
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM oven/bun:1.3-slim AS runtime
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy necessary files from build stage
|
||||
COPY --from=build /app/package.json ./
|
||||
COPY --from=build /app/tsconfig.json ./
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/generated ./generated
|
||||
COPY --from=build /app/src ./src
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/prisma ./prisma
|
||||
|
||||
# Expose the port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the application
|
||||
CMD ["bun", "start"]
|
||||
86
MIND/TASK/database-implementation/phase-1-core-schema.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# TASK: Phase 1 - Implementasi Skema Inti & API Endpoints
|
||||
|
||||
**ID:** `TASK-DB-001`
|
||||
**Konteks:** Database Implementation
|
||||
**Status:** ✅ COMPLETED (95% Selesai)
|
||||
**Prioritas:** 🔴 KRITIS (Blokade Fitur)
|
||||
**Estimasi:** 7 Hari Kerja
|
||||
|
||||
---
|
||||
|
||||
## 🎯 OBJEKTIF
|
||||
Mengganti mock data pada fitur-fitur inti (Kinerja Divisi, Pengaduan, Kependudukan) dengan data riil dari database PostgreSQL melalui Prisma ORM dan menyediakan endpoint API yang type-safe menggunakan ElysiaJS.
|
||||
|
||||
---
|
||||
|
||||
## 📋 DAFTAR TUGAS (TODO)
|
||||
|
||||
### 1. Database Migration (Prisma)
|
||||
- [x] Implementasikan model `Division`, `Activity`, `Document`, `Discussion`, dan `DivisionMetric` di `schema.prisma`.
|
||||
- [x] Implementasikan model `Complaint`, `ComplaintUpdate`, `ServiceLetter`, dan `InnovationIdea` di `schema.prisma`.
|
||||
- [x] Implementasikan model `Resident` dan `Banjar` di `schema.prisma`.
|
||||
- [x] Implementasikan model `Event` di `schema.prisma`.
|
||||
- [x] Jalankan `bun x prisma migrate dev --name init_core_features`.
|
||||
- [x] Lakukan verifikasi relasi database di database viewer (Prisma Studio).
|
||||
|
||||
### 2. Seeding Data
|
||||
- [x] Update `prisma/seed.ts` untuk menyertakan data dummy yang realistis untuk:
|
||||
- 6 Banjar (Darmasaba, Manesa, dll)
|
||||
- 4 Divisi utama
|
||||
- Contoh Pengaduan & Layanan Surat
|
||||
- Contoh Event & Aktivitas
|
||||
- [x] Jalankan `bun run seed` dan pastikan tidak ada error relasi.
|
||||
|
||||
### 3. Backend API Development (ElysiaJS)
|
||||
- [x] Buat route handler di `src/api/` untuk setiap modul:
|
||||
- `division.ts`: CRUD Divisi & Aktivitas
|
||||
- `complaint.ts`: CRUD Pengaduan & Update Status
|
||||
- `resident.ts`: Endpoint untuk statistik demografi & list penduduk per banjar
|
||||
- `event.ts`: CRUD Agenda & Kalender
|
||||
- [x] Integrasikan `apiMiddleware` untuk proteksi rute (Admin/Moderator).
|
||||
- [x] Pastikan skema input/output didefinisikan menggunakan `t.Object` untuk OpenAPI documentation.
|
||||
|
||||
### 4. Contract-First Sync
|
||||
- [x] Jalankan `bun run gen:api` untuk memperbarui `generated/api.ts`.
|
||||
- [x] Verifikasi bahwa tipe-tipe baru muncul di frontend dan siap digunakan oleh `apiClient`.
|
||||
|
||||
### 5. Frontend Integration (Surgical Update)
|
||||
- [x] Update `src/hooks/` atau `src/store/` untuk memanggil API riil menggantikan mock data.
|
||||
- [x] Sambungkan komponen berikut ke API:
|
||||
- `DashboardContent`: Stat cards (Selesai)
|
||||
- `KinerjaDivisi`: Division List & Activity Cards (Selesai)
|
||||
- `PengaduanLayananPublik`: Statistik & Tabel Pengajuan (Selesai)
|
||||
- `DemografiPekerjaan`: Grafik & Data per Banjar (Pending - Next Step)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ INSTRUKSI TEKNIS
|
||||
|
||||
### Penanganan Relasi Prisma
|
||||
Gunakan transaksi atau `onDelete: Cascade` pada relasi yang bergantung secara total (misal: `Activity` ke `Division`) untuk menjaga integritas data.
|
||||
|
||||
### Struktur API Route
|
||||
Contoh struktur yang diharapkan untuk `src/api/division.ts`:
|
||||
```typescript
|
||||
export const divisionRoutes = new Elysia({ prefix: '/division' })
|
||||
.get('/', () => db.division.findMany({ include: { activities: true } }))
|
||||
.post('/', ({ body }) => db.division.create({ data: body }), {
|
||||
body: t.Object({ ... })
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ DEFINITION OF DONE (DoD)
|
||||
1. [ ] Skema database berhasil dimigrasi tanpa error.
|
||||
2. [ ] API Endpoints muncul di `/api/docs` (Swagger).
|
||||
3. [ ] `bun run test` (API tests) berhasil untuk endpoint baru.
|
||||
4. [ ] Frontend menampilkan data riil dari database (bukan mock) pada rute yang ditentukan.
|
||||
5. [ ] Performa query optimal (tidak ada N+1 problem pada relasi Prisma).
|
||||
|
||||
---
|
||||
|
||||
## 📝 CATATAN
|
||||
- Fokus pada **READ** operations terlebih dahulu agar dashboard bisa tampil.
|
||||
- Fitur **WRITE** (Create/Update) bisa diimplementasikan secara bertahap setelah tampilan dashboard stabil.
|
||||
- Jangan lupa update `GEMINI.md` jika ada perubahan pada alur pengembangan.
|
||||
76
MIND/TASK/developer-experience/implement-dev-inspector.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# TASK: Implementasi Click-to-Source (Dev Inspector)
|
||||
|
||||
**ID:** `TASK-DX-001`
|
||||
**Konteks:** Developer Experience (DX)
|
||||
**Status:** ✅ COMPLETED
|
||||
**Prioritas:** 🟡 TINGGI (Peningkatan Produktivitas)
|
||||
**Estimasi:** 1 Hari Kerja
|
||||
|
||||
---
|
||||
|
||||
## 🎯 OBJEKTIF
|
||||
Mengaktifkan fitur **Click-to-Source** di lingkungan pengembangan: klik elemen UI di browser sambil menekan hotkey (`Ctrl+Shift+Cmd+C` atau `Ctrl+Shift+Alt+C`) untuk langsung membuka file source code di editor (VS Code, Cursor, dll) pada baris dan kolom yang tepat.
|
||||
|
||||
---
|
||||
|
||||
## 📋 DAFTAR TUGAS (TODO)
|
||||
|
||||
### 1. Vite Plugin Configuration
|
||||
- [x] Buat file `src/utils/dev-inspector-plugin.ts` yang berisi `inspectorPlugin()` (regex-based JSX attribute injection).
|
||||
- [x] Modifikasi `src/vite.ts`:
|
||||
- [x] Impor `inspectorPlugin`.
|
||||
- [x] Tambahkan `inspectorPlugin()` ke array `plugins` **sebelum** `react()`.
|
||||
- [x] Gunakan `enforce: 'pre'` pada plugin tersebut.
|
||||
|
||||
### 2. Frontend Component Development
|
||||
- [x] Buat komponen `src/components/dev-inspector.tsx`:
|
||||
- [x] Implementasikan hotkey listener.
|
||||
- [x] Tambahkan overlay UI (border biru & tooltip nama file) saat hover.
|
||||
- [x] Implementasikan `getCodeInfoFromElement` dengan fallback (fiber props -> DOM attributes).
|
||||
- [x] Tambahkan fungsi `openInEditor` (POST ke `/__open-in-editor`).
|
||||
|
||||
### 3. Backend Integration (Elysia)
|
||||
- [x] Modifikasi `src/index.ts`:
|
||||
- [x] Tambahkan handler `onRequest` sebelum middleware lainnya.
|
||||
- [x] Intercept request ke path `/__open-in-editor` (POST).
|
||||
- [x] Gunakan `Bun.spawn()` untuk memanggil editor (berdasarkan `.env` `REACT_EDITOR`).
|
||||
- [x] Gunakan `Bun.which()` untuk verifikasi keberadaan editor di system PATH.
|
||||
|
||||
### 4. Application Root Integration
|
||||
- [x] Modifikasi `src/frontend.tsx`:
|
||||
- [x] Implementasikan **Conditional Dynamic Import** untuk `DevInspector`.
|
||||
- [x] Gunakan `import.meta.env?.DEV` agar tidak ada overhead di production.
|
||||
- [x] Bungkus `<App />` (atau router) dengan `<DevInspectorWrapper>`.
|
||||
|
||||
### 5. Environment Setup
|
||||
- [x] Tambahkan `REACT_EDITOR=code` (atau `cursor`, `windsurf`) di file `.env`.
|
||||
- [x] Pastikan alias `@/` berfungsi dengan benar di plugin Vite untuk resolusi path.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ INSTRUKSI TEKNIS
|
||||
|
||||
### Urutan Plugin di Vite
|
||||
Sangat krusial agar `inspectorPlugin` berjalan di fase **pre-transform** sebelum JSX diubah menjadi `React.createElement` oleh compiler Rust (OXC) milik Vite React Plugin.
|
||||
|
||||
### Penanganan React 19
|
||||
Gunakan strategi *multi-fallback* karena React 19 menghapus `_debugSource`. Prioritas pencarian info:
|
||||
1. `__reactProps$*` (React internal props)
|
||||
2. `__reactFiber$*` (Fiber tree walk-up)
|
||||
3. DOM attribute `data-inspector-*` (Fallback universal)
|
||||
|
||||
---
|
||||
|
||||
## ✅ DEFINITION OF DONE (DoD)
|
||||
1. [ ] Hotkey `Ctrl+Shift+Cmd+C` (macOS) / `Ctrl+Shift+Alt+C` mengaktifkan mode inspeksi.
|
||||
2. [ ] Klik pada elemen UI membuka file yang benar di VS Code/Cursor pada baris yang tepat.
|
||||
3. [ ] Fitur hanya aktif di mode pengembangan (`bun run dev`).
|
||||
4. [ ] Di mode produksi (`bun run build`), tidak ada kode `DevInspector` yang masuk ke bundle (verifikasi via `dist/` jika perlu).
|
||||
5. [ ] Kode mengikuti standar linting Biome (jalankan `bun run lint`).
|
||||
|
||||
---
|
||||
|
||||
## 📝 CATATAN
|
||||
- Gunakan `Bun.spawn()` dengan mode `detached: true` jika memungkinkan (atau default fire-and-forget).
|
||||
- Jika menggunakan Windows (WSL), pastikan path file dikonversi dengan benar (jika ada kendala).
|
||||
- Gunakan log di console saat mode inspeksi aktif untuk mempermudah debugging.
|
||||
168
Pengaduan-New.md
Normal file
@@ -0,0 +1,168 @@
|
||||
Create a modern analytics dashboard UI for a village complaint system (Pengaduan Dashboard).
|
||||
|
||||
Tech stack:
|
||||
- React 19 + Vite (Bun runtime)
|
||||
- Mantine UI (core components)
|
||||
- TailwindCSS (layout & spacing only)
|
||||
- Recharts (charts)
|
||||
- TanStack Router
|
||||
- Icons: lucide-react
|
||||
- State: Valtio
|
||||
- Date: dayjs
|
||||
|
||||
---
|
||||
|
||||
## 🎨 DESIGN STYLE
|
||||
|
||||
- Clean, minimal, and soft dashboard
|
||||
- Background: light gray (#f3f4f6)
|
||||
- Card: white with subtle shadow
|
||||
- Border radius: 16px–24px (rounded-2xl)
|
||||
- Typography: medium contrast (not too bold)
|
||||
- Primary color: navy blue (#1E3A5F)
|
||||
- Accent: soft blue + neutral gray
|
||||
- Icons inside circular solid background
|
||||
|
||||
Spacing:
|
||||
- Use gap-6 consistently
|
||||
- Internal padding: p-5 or p-6
|
||||
- Layout must feel breathable (no clutter)
|
||||
|
||||
---
|
||||
|
||||
## 🧱 LAYOUT STRUCTURE
|
||||
|
||||
### 🔹 TOP SECTION (4 STAT CARDS - GRID)
|
||||
Grid: 4 columns (responsive → 2 / 1)
|
||||
|
||||
Each card contains:
|
||||
- Title (small, muted)
|
||||
- Big number (bold, large)
|
||||
- Subtitle (small gray text)
|
||||
- Right side: circular icon container
|
||||
|
||||
Example:
|
||||
- Total Pengaduan → 42 → "Bulan ini"
|
||||
- Baru → 14 → "Belum diproses"
|
||||
- Diproses → 14 → "Sedang ditangani"
|
||||
- Selesai → 14 → "Terselesaikan"
|
||||
|
||||
Use:
|
||||
- Mantine Card
|
||||
- Group justify="space-between"
|
||||
- Icon inside circle (bg navy, icon white)
|
||||
|
||||
---
|
||||
|
||||
## 📈 MAIN CHART (FULL WIDTH)
|
||||
Title: "Tren Pengaduan"
|
||||
|
||||
- Use Recharts LineChart
|
||||
- Smooth line (monotone)
|
||||
- Show dots on each point
|
||||
- Data: Apr → Okt
|
||||
- Value range: 30–60
|
||||
|
||||
Style:
|
||||
- Minimal grid (light dashed)
|
||||
- No heavy colors (use gray/blue line)
|
||||
- Rounded container card
|
||||
- Add small top-right icon (expand)
|
||||
|
||||
---
|
||||
|
||||
## 📊 BOTTOM SECTION (3 COLUMN GRID)
|
||||
|
||||
### 🔹 LEFT: "Surat Terbanyak"
|
||||
- Horizontal bar chart (Recharts)
|
||||
- Categories:
|
||||
- KTP
|
||||
- KK
|
||||
- Domisili
|
||||
- Usaha
|
||||
- Lainnya
|
||||
|
||||
Style:
|
||||
- Dark blue bars
|
||||
- Rounded edges
|
||||
- Clean axis
|
||||
|
||||
---
|
||||
|
||||
### 🔹 CENTER: "Pengajuan Terbaru"
|
||||
List of activity cards:
|
||||
|
||||
Each item:
|
||||
- Name (bold)
|
||||
- Subtitle (jenis surat)
|
||||
- Time (small text)
|
||||
- Status badge (kanan)
|
||||
|
||||
Status:
|
||||
- baru → red
|
||||
- proses → blue
|
||||
- selesai → green
|
||||
|
||||
Style:
|
||||
- Card per item
|
||||
- Soft border
|
||||
- Rounded
|
||||
- Compact spacing
|
||||
|
||||
---
|
||||
|
||||
### 🔹 RIGHT: "Ajuan Ide Inovatif"
|
||||
List mirip dengan pengajuan terbaru:
|
||||
|
||||
Each item:
|
||||
- Nama
|
||||
- Judul ide
|
||||
- Waktu
|
||||
- Button kecil "Detail"
|
||||
|
||||
Style:
|
||||
- Right-aligned action button
|
||||
- Light border
|
||||
- Clean spacing
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ COMPONENT STRUCTURE
|
||||
|
||||
components/
|
||||
- StatCard.tsx
|
||||
- LineChartCard.tsx
|
||||
- BarChartCard.tsx
|
||||
- ActivityList.tsx
|
||||
- IdeaList.tsx
|
||||
|
||||
routes/
|
||||
- dashboard.tsx
|
||||
|
||||
---
|
||||
|
||||
## ✨ INTERACTIONS (IMPORTANT)
|
||||
|
||||
- Hover card → scale(1.02)
|
||||
- Transition: 150ms ease
|
||||
- Icon circle slightly pop on hover
|
||||
- List item hover → subtle bg change
|
||||
|
||||
---
|
||||
|
||||
## 🎯 UX DETAILS
|
||||
|
||||
- Numbers must be visually dominant
|
||||
- Icons must balance layout (not too big)
|
||||
- Avoid heavy borders
|
||||
- Keep everything aligned perfectly
|
||||
- No clutter
|
||||
|
||||
---
|
||||
|
||||
## 🚀 OUTPUT
|
||||
|
||||
- Modular React components (NOT one file)
|
||||
- Clean code (production-ready)
|
||||
- Use Mantine properly (no hacky inline styles unless needed)
|
||||
- Use Tailwind only for layout/grid/spacing
|
||||
BIN
Screenshot 2026-03-10 at 16.48.25.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
93
__tests__/api/noc.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import api from "@/api";
|
||||
import { prisma } from "@/utils/db";
|
||||
|
||||
describe("NOC API Module", () => {
|
||||
const idDesa = "desa1";
|
||||
|
||||
it("should return last sync timestamp", async () => {
|
||||
const response = await api.handle(
|
||||
new Request(`http://localhost/api/noc/last-sync?idDesa=${idDesa}`),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data).toHaveProperty("lastSyncedAt");
|
||||
});
|
||||
|
||||
it("should return active divisions", async () => {
|
||||
const response = await api.handle(
|
||||
new Request(`http://localhost/api/noc/active-divisions?idDesa=${idDesa}`),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(Array.isArray(data.data)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return latest projects", async () => {
|
||||
const response = await api.handle(
|
||||
new Request(`http://localhost/api/noc/latest-projects?idDesa=${idDesa}`),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(Array.isArray(data.data)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return upcoming events", async () => {
|
||||
const response = await api.handle(
|
||||
new Request(`http://localhost/api/noc/upcoming-events?idDesa=${idDesa}`),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(Array.isArray(data.data)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return diagram jumlah document", async () => {
|
||||
const response = await api.handle(
|
||||
new Request(
|
||||
`http://localhost/api/noc/diagram-jumlah-document?idDesa=${idDesa}`,
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(Array.isArray(data.data)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return diagram progres kegiatan", async () => {
|
||||
const response = await api.handle(
|
||||
new Request(
|
||||
`http://localhost/api/noc/diagram-progres-kegiatan?idDesa=${idDesa}`,
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(Array.isArray(data.data)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return latest discussion", async () => {
|
||||
const response = await api.handle(
|
||||
new Request(
|
||||
`http://localhost/api/noc/latest-discussion?idDesa=${idDesa}`,
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(Array.isArray(data.data)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return 400 for missing idDesa in active-divisions", async () => {
|
||||
const response = await api.handle(
|
||||
new Request("http://localhost/api/noc/active-divisions"),
|
||||
);
|
||||
// Elysia returns 400 or 422 for validation errors
|
||||
expect([400, 422]).toContain(response.status);
|
||||
});
|
||||
|
||||
it("should return 401 or 422 for sync without admin auth", async () => {
|
||||
const response = await api.handle(
|
||||
new Request("http://localhost/api/noc/sync", {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
expect([401, 422]).toContain(response.status);
|
||||
});
|
||||
});
|
||||
110
__tests__/e2e/noc-sync.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("NOC Synchronization UI", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock the session API to simulate being logged in as an admin
|
||||
await page.route("**/api/session", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
user: {
|
||||
id: "user_123",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
role: "admin",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock the last-sync API
|
||||
await page.route("**/api/noc/last-sync*", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
lastSyncedAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should navigate to NOC Sync page from sidebar", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Open Settings/Pengaturan submenu if not open
|
||||
const settingsNavLink = page.locator('button:has-text("Pengaturan")');
|
||||
await settingsNavLink.click();
|
||||
|
||||
// Click on Sinkronisasi NOC
|
||||
const syncNavLink = page.locator('a:has-text("Sinkronisasi NOC")');
|
||||
// In Mantine NavLink with navigate, it might be a button or div with role button depending on implementation
|
||||
// Based on Sidebar.tsx, it's a MantineNavLink which renders as a button or anchor
|
||||
const syncLink = page.getByRole("button", { name: "Sinkronisasi NOC" });
|
||||
await syncLink.click();
|
||||
|
||||
// Verify we are on the sync page
|
||||
await expect(page).toHaveURL(/\/pengaturan\/sinkronisasi/);
|
||||
await expect(page.locator("h2")).toContainText("Sinkronisasi Data NOC");
|
||||
});
|
||||
|
||||
test("should perform synchronization successfully", async ({ page }) => {
|
||||
await page.goto("/pengaturan/sinkronisasi");
|
||||
|
||||
// Initial state check
|
||||
await expect(page.locator("text=Waktu Sinkronisasi Terakhir:")).toBeVisible();
|
||||
|
||||
// Mock the sync API
|
||||
const now = new Date().toISOString();
|
||||
await page.route("**/api/noc/sync", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
message: "Sinkronisasi berhasil diselesaikan",
|
||||
lastSyncedAt: now,
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Click Sync button
|
||||
await page.click('button:has-text("Sinkronkan Sekarang")');
|
||||
|
||||
// Verify success message
|
||||
await expect(page.locator("text=Sinkronisasi berhasil dilakukan")).toBeVisible();
|
||||
|
||||
// Verify timestamp updated (it should show "beberapa detik yang lalu" or similar because of dayjs fromNow)
|
||||
// We can just check if the new time format is there or the relative time updated
|
||||
await expect(page.locator("text=beberapa detik yang lalu")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should handle synchronization error", async ({ page }) => {
|
||||
await page.goto("/pengaturan/sinkronisasi");
|
||||
|
||||
// Mock the sync API failure
|
||||
await page.route("**/api/noc/sync", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
await route.fulfill({
|
||||
status: 200, // API returns 200 but with success: false for business logic errors
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
error: "Sinkronisasi gagal dijalankan",
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Click Sync button
|
||||
await page.click('button:has-text("Sinkronkan Sekarang")');
|
||||
|
||||
// Verify error message
|
||||
await expect(page.locator("text=Sinkronisasi gagal dijalankan")).toBeVisible();
|
||||
});
|
||||
});
|
||||
31
bun.lock
@@ -92,7 +92,6 @@
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prisma": "^6.19.2",
|
||||
"react-dev-inspector": "^2.0.1",
|
||||
"vite": "^7.3.1",
|
||||
},
|
||||
},
|
||||
@@ -474,14 +473,8 @@
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@react-dev-inspector/babel-plugin": ["@react-dev-inspector/babel-plugin@2.0.1", "", { "dependencies": { "@babel/core": "^7.20.5", "@babel/generator": "^7.20.5", "@babel/parser": "^7.20.5", "@babel/traverse": "^7.20.5", "@babel/types": "7.20.5" } }, "sha512-V2MzN9dj3uZu6NvAjSxXwa3+FOciVIuwAUwPLpO6ji5xpUyx8E6UiEng1QqzttdpacKHFKtkNYjtQAE+Lsqa5A=="],
|
||||
|
||||
"@react-dev-inspector/middleware": ["@react-dev-inspector/middleware@2.0.1", "", { "dependencies": { "react-dev-utils": "12.0.1" } }, "sha512-qDMtBzAxNNAX01jjU1THZVuNiVB7J1Hjk42k8iLSSwfinc3hk667iqgdzeq1Za1a0V2bF5Ev6D4+nkZ+E1YUrQ=="],
|
||||
|
||||
"@react-dev-inspector/umi3-plugin": ["@react-dev-inspector/umi3-plugin@2.0.1", "", { "dependencies": { "@react-dev-inspector/babel-plugin": "2.0.1", "@react-dev-inspector/middleware": "2.0.1" } }, "sha512-lRw65yKQdI/1BwrRXWJEHDJel4DWboOartGmR3S5xiTF+EiOLjmndxdA5LoVSdqbcggdtq5SWcsoZqI0TkhH7Q=="],
|
||||
|
||||
"@react-dev-inspector/umi4-plugin": ["@react-dev-inspector/umi4-plugin@2.0.1", "", { "dependencies": { "@react-dev-inspector/babel-plugin": "2.0.1", "@react-dev-inspector/middleware": "2.0.1" } }, "sha512-vTefsJVAZsgpuO9IZ1ZFIoyryVUU+hjV8OPD8DfDU+po5LjVXc5Uncn+MkFOsT24AMpNdDvCnTRYiuSkFn8EsA=="],
|
||||
|
||||
"@react-dev-inspector/vite-plugin": ["@react-dev-inspector/vite-plugin@2.0.1", "", { "dependencies": { "@react-dev-inspector/middleware": "2.0.1" } }, "sha512-J1eI7cIm2IXE6EwhHR1OyoefvobUJEn/vJWEBwOM5uW4JkkLwuVoV9vk++XJyAmKUNQ87gdWZvSWrI2LjfrSug=="],
|
||||
|
||||
"@redocly/ajv": ["@redocly/ajv@8.17.3", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-NQsbJbB/GV7JVO88ebFkMndrnuGp/dTm5/2NISeg+JGcLzTfGBJZ01+V5zD8nKBOpi/dLLNFT+Ql6IcUk8ehng=="],
|
||||
@@ -674,8 +667,6 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/react-reconciler": ["@types/react-reconciler@0.33.0", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
|
||||
"@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="],
|
||||
@@ -1084,8 +1075,6 @@
|
||||
|
||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"hotkeys-js": ["hotkeys-js@3.13.15", "", {}, "sha512-gHh8a/cPTCpanraePpjRxyIlxDFrIhYqjuh01UHWEwDpglJKCnvLW8kqSx5gQtOuSsJogNZXLhOdbSExpgUiqg=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
@@ -1116,7 +1105,7 @@
|
||||
|
||||
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||
|
||||
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
@@ -1132,7 +1121,7 @@
|
||||
|
||||
"is-root": ["is-root@2.1.0", "", {}, "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg=="],
|
||||
|
||||
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
"is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
|
||||
|
||||
"isbot": ["isbot@5.1.34", "", {}, "sha512-aCMIBSKd/XPRYdiCQTLC8QHH4YT8B3JUADu+7COgYIZPvkeoMcUHMRjZLM9/7V8fCj+l7FSREc1lOPNjzogo/A=="],
|
||||
|
||||
@@ -1396,8 +1385,6 @@
|
||||
|
||||
"react-day-picker": ["react-day-picker@8.10.1", "", { "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA=="],
|
||||
|
||||
"react-dev-inspector": ["react-dev-inspector@2.0.1", "", { "dependencies": { "@react-dev-inspector/babel-plugin": "2.0.1", "@react-dev-inspector/middleware": "2.0.1", "@react-dev-inspector/umi3-plugin": "2.0.1", "@react-dev-inspector/umi4-plugin": "2.0.1", "@react-dev-inspector/vite-plugin": "2.0.1", "@types/react-reconciler": ">=0.26.6", "hotkeys-js": "^3.8.1", "picocolors": "1.0.0", "react-dev-utils": "12.0.1" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-b8PAmbwGFrWcxeaX8wYveqO+VTwTXGJaz/yl9RO31LK1zeLKJVlkkbeLExLnJ6IvhXY1TwL8Q4+gR2GKJ8BI6Q=="],
|
||||
|
||||
"react-dev-utils": ["react-dev-utils@12.0.1", "", { "dependencies": { "@babel/code-frame": "^7.16.0", "address": "^1.1.2", "browserslist": "^4.18.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.3", "detect-port-alt": "^1.1.6", "escape-string-regexp": "^4.0.0", "filesize": "^8.0.6", "find-up": "^5.0.0", "fork-ts-checker-webpack-plugin": "^6.5.0", "global-modules": "^2.0.0", "globby": "^11.0.4", "gzip-size": "^6.0.0", "immer": "^9.0.7", "is-root": "^2.1.0", "loader-utils": "^3.2.0", "open": "^8.4.0", "pkg-up": "^3.1.0", "prompts": "^2.4.2", "react-error-overlay": "^6.0.11", "recursive-readdir": "^2.2.2", "shell-quote": "^1.7.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" } }, "sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
@@ -1570,8 +1557,6 @@
|
||||
|
||||
"tldts-core": ["tldts-core@7.0.22", "", {}, "sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw=="],
|
||||
|
||||
"to-fast-properties": ["to-fast-properties@2.0.0", "", {}, "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
|
||||
@@ -1730,8 +1715,6 @@
|
||||
|
||||
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@react-dev-inspector/babel-plugin/@babel/types": ["@babel/types@7.20.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.19.4", "@babel/helper-validator-identifier": "^7.19.1", "to-fast-properties": "^2.0.0" } }, "sha512-c9fst/h2/dcF7H+MJKZ2T0KjEQ8hY/BNnDk/H3XY8C4Aw/eWQXWn/lWntHF9ooUBnGmEvbfGrTgLWc+um0YDUg=="],
|
||||
|
||||
"@redocly/openapi-core/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
|
||||
|
||||
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
|
||||
@@ -1794,8 +1777,6 @@
|
||||
|
||||
"global-prefix/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="],
|
||||
|
||||
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
@@ -1816,8 +1797,6 @@
|
||||
|
||||
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||
|
||||
"react-dev-inspector/picocolors": ["picocolors@1.0.0", "", {}, "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="],
|
||||
|
||||
"react-dev-utils/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"react-dev-utils/immer": ["immer@9.0.21", "", {}, "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA=="],
|
||||
@@ -1838,8 +1817,6 @@
|
||||
|
||||
"webpack/es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
|
||||
|
||||
"wsl-utils/is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
|
||||
|
||||
"@prisma/config/c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"@prisma/config/c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
@@ -1874,6 +1851,10 @@
|
||||
|
||||
"react-dev-utils/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
|
||||
|
||||
"react-dev-utils/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"react-dev-utils/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"recursive-readdir/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"@prisma/config/c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
1819
generated/api.ts
269
generated/noc-external.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* This file was auto-generated by openapi-typescript.
|
||||
* Do not make direct changes to the file.
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/api/noc/active-divisions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Divisi Teraktif
|
||||
* @description Menu Beranda - Mendapatkan daftar divisi teraktif berdasarkan jumlah proyek pada desa tertentu.
|
||||
*/
|
||||
get: operations["getApiNocActive-divisions"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/noc/latest-projects": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Latest Projects General
|
||||
* @description Menu kinerja divisi - Mendapatkan daftar proyek umum terbaru dari berbagai grup pada desa tertentu.
|
||||
*/
|
||||
get: operations["getApiNocLatest-projects"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/noc/upcoming-events": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Events (Today & Upcoming)
|
||||
* @description Menu beranda dan kinerja divisi - Mendapatkan daftar event pada hari ini dan yang akan datang untuk semua divisi pada desa tertentu.
|
||||
*/
|
||||
get: operations["getApiNocUpcoming-events"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/noc/diagram-jumlah-document": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Diagram Jumlah Document
|
||||
* @description Menu kinerja divisi - Mendapatkan diagram jumlah document pada desa tertentu.
|
||||
*/
|
||||
get: operations["getApiNocDiagram-jumlah-document"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/noc/diagram-progres-kegiatan": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Diagram Progres Kegiatan
|
||||
* @description Menu kinerja divisi - Mendapatkan diagram progres kegiatan pada desa tertentu.
|
||||
*/
|
||||
get: operations["getApiNocDiagram-progres-kegiatan"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/noc/latest-discussion": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Latest Discussion
|
||||
* @description Menu kinerja divisi - Mendapatkan latest discussion pada desa tertentu.
|
||||
*/
|
||||
get: operations["getApiNocLatest-discussion"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: never;
|
||||
responses: never;
|
||||
parameters: never;
|
||||
requestBodies: never;
|
||||
headers: never;
|
||||
pathItems: never;
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
"getApiNocActive-divisions": {
|
||||
parameters: {
|
||||
query: {
|
||||
/** @description ID Desa yang ingin dicari */
|
||||
idDesa: string;
|
||||
/** @description Jumlah maksimal data (default: 5) */
|
||||
limit?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
"getApiNocLatest-projects": {
|
||||
parameters: {
|
||||
query: {
|
||||
/** @description ID Desa yang ingin dicari */
|
||||
idDesa: string;
|
||||
/** @description Jumlah maksimal proyek (default: 5, maks: 50) */
|
||||
limit?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
"getApiNocUpcoming-events": {
|
||||
parameters: {
|
||||
query: {
|
||||
/** @description ID Desa yang ingin dicari */
|
||||
idDesa: string;
|
||||
/** @description Jumlah maksimal event (default: 10, maks: 50) */
|
||||
limit?: string;
|
||||
/** @description Filter event: 'today' atau 'upcoming' */
|
||||
filter?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
"getApiNocDiagram-jumlah-document": {
|
||||
parameters: {
|
||||
query: {
|
||||
/** @description ID Desa yang ingin dicari */
|
||||
idDesa: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
"getApiNocDiagram-progres-kegiatan": {
|
||||
parameters: {
|
||||
query: {
|
||||
/** @description ID Desa yang ingin dicari */
|
||||
idDesa: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
"getApiNocLatest-discussion": {
|
||||
parameters: {
|
||||
query: {
|
||||
/** @description ID Desa yang ingin dicari */
|
||||
idDesa: string;
|
||||
/** @description Limit data */
|
||||
limit?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
BIN
mantine-expert.skill
Normal file
15
package.json
@@ -4,17 +4,25 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run gen:api && REACT_EDITOR=antigravity bun --hot src/index.ts",
|
||||
"dev": "lsof -ti:3000 | xargs kill -9 2>/dev/null || true; bun run gen:api && REACT_EDITOR=antigravity bun --hot src/index.ts",
|
||||
"lint": "biome check .",
|
||||
"check": "biome check --write .",
|
||||
"format": "biome format --write .",
|
||||
"gen:api": "bun scripts/generate-schema.ts && bun x openapi-typescript generated/schema.json -o generated/api.ts",
|
||||
"sync:noc": "bun scripts/sync-noc.ts",
|
||||
"test": "bun test __tests__/api",
|
||||
"test:ui": "bun test --ui __tests__/api",
|
||||
"test:e2e": "bun run build && playwright test",
|
||||
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='VITE_*'",
|
||||
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='VITE_*' && cp -r public/* dist/ 2>/dev/null || true",
|
||||
"start": "NODE_ENV=production bun src/index.ts",
|
||||
"seed": "bun prisma/seed.ts"
|
||||
"seed": "bun prisma/seed.ts",
|
||||
"seed:auth": "bun prisma/seed.ts auth",
|
||||
"seed:demographics": "bun prisma/seed.ts demographics",
|
||||
"seed:divisions": "bun prisma/seed.ts divisions",
|
||||
"seed:services": "bun prisma/seed.ts services",
|
||||
"seed:documents": "bun prisma/seed.ts documents",
|
||||
"seed:dashboard": "bun prisma/seed.ts dashboard",
|
||||
"seed:phase2": "bun prisma/seed.ts phase2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/cli": "^1.4.18",
|
||||
@@ -104,7 +112,6 @@
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prisma": "^6.19.2",
|
||||
"react-dev-inspector": "^2.0.1",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActivityStatus" AS ENUM ('BERJALAN', 'SELESAI', 'TERTUNDA', 'DIBATALKAN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Priority" AS ENUM ('RENDAH', 'SEDANG', 'TINGGI', 'DARURAT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DocumentCategory" AS ENUM ('SURAT_KEPUTUSAN', 'DOKUMENTASI', 'LAPORAN_KEUANGAN', 'NOTULENSI_RAPAT', 'UMUM');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EventType" AS ENUM ('RAPAT', 'KEGIATAN', 'UPACARA', 'SOSIAL', 'BUDAYA', 'LAINNYA');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ComplaintCategory" AS ENUM ('KETERTIBAN_UMUM', 'PELAYANAN_KESEHATAN', 'INFRASTRUKTUR', 'ADMINISTRASI', 'KEAMANAN', 'LAINNYA');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ComplaintStatus" AS ENUM ('BARU', 'DIPROSES', 'SELESAI', 'DITOLAK');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LetterType" AS ENUM ('KTP', 'KK', 'DOMISILI', 'USAHA', 'KETERANGAN_TIDAK_MAMPU', 'SURAT_PENGANTAR', 'LAINNYA');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ServiceStatus" AS ENUM ('BARU', 'DIPROSES', 'SELESAI', 'DIAMBIL');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "IdeaStatus" AS ENUM ('BARU', 'DIKAJI', 'DISETUJUI', 'DITOLAK', 'DIIMPLEMENTASI');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Gender" AS ENUM ('LAKI_LAKI', 'PEREMPUAN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Religion" AS ENUM ('HINDU', 'ISLAM', 'KRISTEN', 'KATOLIK', 'BUDDHA', 'KONGHUCU', 'LAINNYA');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MaritalStatus" AS ENUM ('BELUM_KAWIN', 'KAWIN', 'CERAI_HIDUP', 'CERAI_MATI');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EducationLevel" AS ENUM ('TIDAK_SEKOLAH', 'SD', 'SMP', 'SMA', 'D3', 'S1', 'S2', 'S3');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"emailVerified" BOOLEAN,
|
||||
"image" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"role" TEXT DEFAULT 'user',
|
||||
|
||||
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "division" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"color" TEXT NOT NULL DEFAULT '#1E3A5F',
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "division_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "activity" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"divisionId" TEXT NOT NULL,
|
||||
"startDate" TIMESTAMP(3),
|
||||
"endDate" TIMESTAMP(3),
|
||||
"dueDate" TIMESTAMP(3),
|
||||
"progress" INTEGER NOT NULL DEFAULT 0,
|
||||
"status" "ActivityStatus" NOT NULL DEFAULT 'BERJALAN',
|
||||
"priority" "Priority" NOT NULL DEFAULT 'SEDANG',
|
||||
"assignedTo" TEXT,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "activity_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "document" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"category" "DocumentCategory" NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"fileUrl" TEXT NOT NULL,
|
||||
"fileSize" INTEGER,
|
||||
"divisionId" TEXT,
|
||||
"uploadedBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "document_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "discussion" (
|
||||
"id" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"senderId" TEXT NOT NULL,
|
||||
"parentId" TEXT,
|
||||
"divisionId" TEXT,
|
||||
"isResolved" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "discussion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "event" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"eventType" "EventType" NOT NULL,
|
||||
"startDate" TIMESTAMP(3) NOT NULL,
|
||||
"endDate" TIMESTAMP(3),
|
||||
"location" TEXT,
|
||||
"isAllDay" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isRecurring" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "event_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "division_metric" (
|
||||
"id" TEXT NOT NULL,
|
||||
"divisionId" TEXT NOT NULL,
|
||||
"period" TEXT NOT NULL,
|
||||
"activityCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"completionRate" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"avgProgress" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "division_metric_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "complaint" (
|
||||
"id" TEXT NOT NULL,
|
||||
"complaintNumber" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"category" "ComplaintCategory" NOT NULL,
|
||||
"status" "ComplaintStatus" NOT NULL DEFAULT 'BARU',
|
||||
"priority" "Priority" NOT NULL DEFAULT 'SEDANG',
|
||||
"reporterId" TEXT,
|
||||
"reporterPhone" TEXT,
|
||||
"reporterEmail" TEXT,
|
||||
"isAnonymous" BOOLEAN NOT NULL DEFAULT false,
|
||||
"assignedTo" TEXT,
|
||||
"resolvedBy" TEXT,
|
||||
"resolvedAt" TIMESTAMP(3),
|
||||
"location" TEXT,
|
||||
"imageUrl" TEXT[],
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "complaint_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "complaint_update" (
|
||||
"id" TEXT NOT NULL,
|
||||
"complaintId" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"status" "ComplaintStatus",
|
||||
"updatedBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "complaint_update_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "service_letter" (
|
||||
"id" TEXT NOT NULL,
|
||||
"letterNumber" TEXT NOT NULL,
|
||||
"letterType" "LetterType" NOT NULL,
|
||||
"applicantName" TEXT NOT NULL,
|
||||
"applicantNik" TEXT NOT NULL,
|
||||
"applicantAddress" TEXT NOT NULL,
|
||||
"purpose" TEXT,
|
||||
"status" "ServiceStatus" NOT NULL DEFAULT 'BARU',
|
||||
"processedBy" TEXT,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "service_letter_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "innovation_idea" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"submitterName" TEXT NOT NULL,
|
||||
"submitterContact" TEXT,
|
||||
"status" "IdeaStatus" NOT NULL DEFAULT 'BARU',
|
||||
"reviewedBy" TEXT,
|
||||
"reviewedAt" TIMESTAMP(3),
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "innovation_idea_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "resident" (
|
||||
"id" TEXT NOT NULL,
|
||||
"nik" TEXT NOT NULL,
|
||||
"kk" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"birthDate" TIMESTAMP(3) NOT NULL,
|
||||
"birthPlace" TEXT NOT NULL,
|
||||
"gender" "Gender" NOT NULL,
|
||||
"religion" "Religion" NOT NULL,
|
||||
"maritalStatus" "MaritalStatus" NOT NULL DEFAULT 'BELUM_KAWIN',
|
||||
"education" "EducationLevel",
|
||||
"occupation" TEXT,
|
||||
"banjarId" TEXT NOT NULL,
|
||||
"rt" TEXT NOT NULL,
|
||||
"rw" TEXT NOT NULL,
|
||||
"address" TEXT NOT NULL,
|
||||
"isHeadOfHousehold" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isPoor" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isStunting" BOOLEAN NOT NULL DEFAULT false,
|
||||
"deathDate" TIMESTAMP(3),
|
||||
"moveInDate" TIMESTAMP(3),
|
||||
"moveOutDate" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "resident_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "banjar" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"totalPopulation" INTEGER NOT NULL DEFAULT 0,
|
||||
"totalKK" INTEGER NOT NULL DEFAULT 0,
|
||||
"totalPoor" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "banjar_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "HealthRecord" (
|
||||
"id" TEXT NOT NULL,
|
||||
"residentId" TEXT NOT NULL,
|
||||
"recordedBy" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "HealthRecord_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EmploymentRecord" (
|
||||
"id" TEXT NOT NULL,
|
||||
"residentId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "EmploymentRecord_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PopulationDynamic" (
|
||||
"id" TEXT NOT NULL,
|
||||
"documentedBy" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "PopulationDynamic_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Budget" (
|
||||
"id" TEXT NOT NULL,
|
||||
"approvedBy" TEXT,
|
||||
|
||||
CONSTRAINT "Budget_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BudgetTransaction" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdBy" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "BudgetTransaction_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Umkm" (
|
||||
"id" TEXT NOT NULL,
|
||||
"banjarId" TEXT,
|
||||
|
||||
CONSTRAINT "Umkm_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Posyandu" (
|
||||
"id" TEXT NOT NULL,
|
||||
"coordinatorId" TEXT,
|
||||
|
||||
CONSTRAINT "Posyandu_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SecurityReport" (
|
||||
"id" TEXT NOT NULL,
|
||||
"assignedTo" TEXT,
|
||||
|
||||
CONSTRAINT "SecurityReport_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"ipAddress" TEXT,
|
||||
"userAgent" TEXT,
|
||||
|
||||
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "account" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"providerId" TEXT NOT NULL,
|
||||
"accessToken" TEXT,
|
||||
"refreshToken" TEXT,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"password" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"idToken" TEXT,
|
||||
"accessTokenExpiresAt" TIMESTAMP(3),
|
||||
"refreshTokenExpiresAt" TIMESTAMP(3),
|
||||
"scope" TEXT,
|
||||
|
||||
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verification" (
|
||||
"id" TEXT NOT NULL,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "api_key" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "api_key_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "division_name_key" ON "division"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "activity_divisionId_idx" ON "activity"("divisionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "activity_status_idx" ON "activity"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "document_category_idx" ON "document"("category");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "document_divisionId_idx" ON "document"("divisionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "discussion_divisionId_idx" ON "discussion"("divisionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "discussion_createdAt_idx" ON "discussion"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "event_startDate_idx" ON "event"("startDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "event_eventType_idx" ON "event"("eventType");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "division_metric_divisionId_period_key" ON "division_metric"("divisionId", "period");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "complaint_complaintNumber_key" ON "complaint"("complaintNumber");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "complaint_status_idx" ON "complaint"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "complaint_category_idx" ON "complaint"("category");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "complaint_createdAt_idx" ON "complaint"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "complaint_update_complaintId_idx" ON "complaint_update"("complaintId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "service_letter_letterNumber_key" ON "service_letter"("letterNumber");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "service_letter_letterType_idx" ON "service_letter"("letterType");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "service_letter_status_idx" ON "service_letter"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "service_letter_createdAt_idx" ON "service_letter"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "innovation_idea_category_idx" ON "innovation_idea"("category");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "innovation_idea_status_idx" ON "innovation_idea"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "resident_nik_key" ON "resident"("nik");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "resident_banjarId_idx" ON "resident"("banjarId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "resident_religion_idx" ON "resident"("religion");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "resident_occupation_idx" ON "resident"("occupation");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "banjar_name_key" ON "banjar"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "banjar_code_key" ON "banjar"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "session_userId_idx" ON "session"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "account_userId_idx" ON "account"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "api_key_key_key" ON "api_key"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "api_key_userId_idx" ON "api_key"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "activity" ADD CONSTRAINT "activity_divisionId_fkey" FOREIGN KEY ("divisionId") REFERENCES "division"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "document" ADD CONSTRAINT "document_divisionId_fkey" FOREIGN KEY ("divisionId") REFERENCES "division"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "discussion" ADD CONSTRAINT "discussion_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "discussion" ADD CONSTRAINT "discussion_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "discussion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "discussion" ADD CONSTRAINT "discussion_divisionId_fkey" FOREIGN KEY ("divisionId") REFERENCES "division"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "event" ADD CONSTRAINT "event_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "division_metric" ADD CONSTRAINT "division_metric_divisionId_fkey" FOREIGN KEY ("divisionId") REFERENCES "division"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "complaint" ADD CONSTRAINT "complaint_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "complaint" ADD CONSTRAINT "complaint_assignedTo_fkey" FOREIGN KEY ("assignedTo") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "complaint_update" ADD CONSTRAINT "complaint_update_complaintId_fkey" FOREIGN KEY ("complaintId") REFERENCES "complaint"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "complaint_update" ADD CONSTRAINT "complaint_update_updatedBy_fkey" FOREIGN KEY ("updatedBy") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "service_letter" ADD CONSTRAINT "service_letter_processedBy_fkey" FOREIGN KEY ("processedBy") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "innovation_idea" ADD CONSTRAINT "innovation_idea_reviewedBy_fkey" FOREIGN KEY ("reviewedBy") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "resident" ADD CONSTRAINT "resident_banjarId_fkey" FOREIGN KEY ("banjarId") REFERENCES "banjar"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HealthRecord" ADD CONSTRAINT "HealthRecord_residentId_fkey" FOREIGN KEY ("residentId") REFERENCES "resident"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HealthRecord" ADD CONSTRAINT "HealthRecord_recordedBy_fkey" FOREIGN KEY ("recordedBy") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EmploymentRecord" ADD CONSTRAINT "EmploymentRecord_residentId_fkey" FOREIGN KEY ("residentId") REFERENCES "resident"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PopulationDynamic" ADD CONSTRAINT "PopulationDynamic_documentedBy_fkey" FOREIGN KEY ("documentedBy") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Budget" ADD CONSTRAINT "Budget_approvedBy_fkey" FOREIGN KEY ("approvedBy") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BudgetTransaction" ADD CONSTRAINT "BudgetTransaction_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Umkm" ADD CONSTRAINT "Umkm_banjarId_fkey" FOREIGN KEY ("banjarId") REFERENCES "banjar"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Posyandu" ADD CONSTRAINT "Posyandu_coordinatorId_fkey" FOREIGN KEY ("coordinatorId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SecurityReport" ADD CONSTRAINT "SecurityReport_assignedTo_fkey" FOREIGN KEY ("assignedTo") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "api_key" ADD CONSTRAINT "api_key_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `Budget` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Budget" DROP CONSTRAINT "Budget_approvedBy_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Budget";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "budget" (
|
||||
"id" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"amount" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"percentage" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"color" TEXT NOT NULL DEFAULT '#3B82F6',
|
||||
"fiscalYear" INTEGER NOT NULL DEFAULT 2025,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "budget_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sdgs_score" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"score" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"image" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "sdgs_score_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "satisfaction_rating" (
|
||||
"id" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"value" INTEGER NOT NULL DEFAULT 0,
|
||||
"color" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "satisfaction_rating_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "budget_category_fiscalYear_key" ON "budget"("category", "fiscalYear");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sdgs_score_title_key" ON "sdgs_score"("title");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "satisfaction_rating_category_key" ON "satisfaction_rating"("category");
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `name` to the `Umkm` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `owner` to the `Umkm` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `updatedAt` to the `Umkm` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Umkm" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "description" TEXT,
|
||||
ADD COLUMN "name" TEXT NOT NULL,
|
||||
ADD COLUMN "owner" TEXT NOT NULL,
|
||||
ADD COLUMN "productType" TEXT,
|
||||
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[reportNumber]` on the table `SecurityReport` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `location` to the `Posyandu` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `name` to the `Posyandu` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `schedule` to the `Posyandu` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `type` to the `Posyandu` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `updatedAt` to the `Posyandu` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `description` to the `SecurityReport` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `reportNumber` to the `SecurityReport` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `reportedBy` to the `SecurityReport` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `title` to the `SecurityReport` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `updatedAt` to the `SecurityReport` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Posyandu" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "location" TEXT NOT NULL,
|
||||
ADD COLUMN "name" TEXT NOT NULL,
|
||||
ADD COLUMN "schedule" TEXT NOT NULL,
|
||||
ADD COLUMN "type" TEXT NOT NULL,
|
||||
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "SecurityReport" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "description" TEXT NOT NULL,
|
||||
ADD COLUMN "location" TEXT,
|
||||
ADD COLUMN "reportNumber" TEXT NOT NULL,
|
||||
ADD COLUMN "reportedBy" TEXT NOT NULL,
|
||||
ADD COLUMN "status" TEXT NOT NULL DEFAULT 'BARU',
|
||||
ADD COLUMN "title" TEXT NOT NULL,
|
||||
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SecurityReport_reportNumber_key" ON "SecurityReport"("reportNumber");
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[transactionNumber]` on the table `BudgetTransaction` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `amount` to the `BudgetTransaction` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `category` to the `BudgetTransaction` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `date` to the `BudgetTransaction` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `transactionNumber` to the `BudgetTransaction` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `type` to the `BudgetTransaction` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `companyName` to the `EmploymentRecord` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `position` to the `EmploymentRecord` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `startDate` to the `EmploymentRecord` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `type` to the `HealthRecord` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `eventDate` to the `PopulationDynamic` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `residentName` to the `PopulationDynamic` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `type` to the `PopulationDynamic` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "BudgetTransaction" ADD COLUMN "amount" DOUBLE PRECISION NOT NULL,
|
||||
ADD COLUMN "category" TEXT NOT NULL,
|
||||
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "date" TIMESTAMP(3) NOT NULL,
|
||||
ADD COLUMN "description" TEXT,
|
||||
ADD COLUMN "transactionNumber" TEXT NOT NULL,
|
||||
ADD COLUMN "type" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "EmploymentRecord" ADD COLUMN "companyName" TEXT NOT NULL,
|
||||
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "endDate" TIMESTAMP(3),
|
||||
ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "position" TEXT NOT NULL,
|
||||
ADD COLUMN "startDate" TIMESTAMP(3) NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "HealthRecord" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "notes" TEXT,
|
||||
ADD COLUMN "type" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "PopulationDynamic" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "description" TEXT,
|
||||
ADD COLUMN "eventDate" TIMESTAMP(3) NOT NULL,
|
||||
ADD COLUMN "residentName" TEXT NOT NULL,
|
||||
ADD COLUMN "type" TEXT NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "BudgetTransaction_transactionNumber_key" ON "BudgetTransaction"("transactionNumber");
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[externalId]` on the table `activity` will be added. If there are existing duplicate values, this will fail.
|
||||
- A unique constraint covering the columns `[externalId]` on the table `discussion` will be added. If there are existing duplicate values, this will fail.
|
||||
- A unique constraint covering the columns `[externalId]` on the table `division` will be added. If there are existing duplicate values, this will fail.
|
||||
- A unique constraint covering the columns `[externalId]` on the table `document` will be added. If there are existing duplicate values, this will fail.
|
||||
- A unique constraint covering the columns `[externalId]` on the table `event` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "activity" ADD COLUMN "externalId" TEXT,
|
||||
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "discussion" ADD COLUMN "externalId" TEXT,
|
||||
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "division" ADD COLUMN "externalId" TEXT,
|
||||
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "document" ADD COLUMN "externalId" TEXT,
|
||||
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "event" ADD COLUMN "externalId" TEXT,
|
||||
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "activity_externalId_key" ON "activity"("externalId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "discussion_externalId_key" ON "discussion"("externalId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "division_externalId_key" ON "division"("externalId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "document_externalId_key" ON "document"("externalId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "event_externalId_key" ON "event"("externalId");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "division" ADD COLUMN "lastSyncedAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "activity" ALTER COLUMN "villageId" SET DEFAULT 'desa1';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "discussion" ALTER COLUMN "villageId" SET DEFAULT 'desa1';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "division" ADD COLUMN "externalActivityCount" INTEGER NOT NULL DEFAULT 0,
|
||||
ALTER COLUMN "villageId" SET DEFAULT 'desa1';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "document" ALTER COLUMN "villageId" SET DEFAULT 'desa1';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "event" ALTER COLUMN "villageId" SET DEFAULT 'desa1';
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -21,9 +21,545 @@ model User {
|
||||
sessions Session[]
|
||||
apiKeys ApiKey[]
|
||||
|
||||
// Relations
|
||||
discussions Discussion[]
|
||||
events Event[]
|
||||
complaints Complaint[] @relation("ComplaintReporter")
|
||||
assignedComplaints Complaint[] @relation("ComplaintAssignee")
|
||||
complaintUpdates ComplaintUpdate[]
|
||||
serviceLetters ServiceLetter[]
|
||||
innovationIdeas InnovationIdea[] @relation("IdeaReviewer")
|
||||
healthRecords HealthRecord[]
|
||||
populationDynamics PopulationDynamic[]
|
||||
budgetTransactions BudgetTransaction[]
|
||||
posyandus Posyandu[]
|
||||
securityReports SecurityReport[]
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
|
||||
// --- KATEGORI 1: KINERJA DIVISI & AKTIVITAS ---
|
||||
|
||||
model Division {
|
||||
id String @id @default(cuid())
|
||||
externalId String? @unique // ID asli dari server NOC
|
||||
villageId String? @default("desa1") // ID Desa dari sistem NOC
|
||||
name String @unique
|
||||
description String?
|
||||
color String @default("#1E3A5F")
|
||||
isActive Boolean @default(true)
|
||||
externalActivityCount Int @default(0) // Total kegiatan dari sistem NOC (misal: 47)
|
||||
lastSyncedAt DateTime? // Terakhir kali sinkronisasi dilakukan
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
activities Activity[]
|
||||
documents Document[]
|
||||
discussions Discussion[]
|
||||
divisionMetrics DivisionMetric[]
|
||||
|
||||
@@map("division")
|
||||
}
|
||||
|
||||
model Activity {
|
||||
id String @id @default(cuid())
|
||||
externalId String? @unique // ID asli dari server NOC
|
||||
villageId String? @default("desa1")
|
||||
title String
|
||||
description String?
|
||||
divisionId String
|
||||
startDate DateTime?
|
||||
endDate DateTime?
|
||||
dueDate DateTime?
|
||||
progress Int @default(0) // 0-100
|
||||
status ActivityStatus @default(BERJALAN)
|
||||
priority Priority @default(SEDANG)
|
||||
assignedTo String? // JSON array of user IDs
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
division Division @relation(fields: [divisionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([divisionId])
|
||||
@@index([status])
|
||||
@@map("activity")
|
||||
}
|
||||
|
||||
model Document {
|
||||
id String @id @default(cuid())
|
||||
externalId String? @unique // ID asli dari server NOC
|
||||
villageId String? @default("desa1")
|
||||
title String
|
||||
category DocumentCategory
|
||||
type String // "Gambar", "Dokumen", "PDF", etc
|
||||
fileUrl String
|
||||
fileSize Int? // in bytes
|
||||
divisionId String?
|
||||
uploadedBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
division Division? @relation(fields: [divisionId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([category])
|
||||
@@index([divisionId])
|
||||
@@map("document")
|
||||
}
|
||||
|
||||
model Discussion {
|
||||
id String @id @default(cuid())
|
||||
externalId String? @unique // ID asli dari server NOC
|
||||
villageId String? @default("desa1")
|
||||
message String
|
||||
senderId String
|
||||
parentId String? // For threaded discussions
|
||||
divisionId String?
|
||||
isResolved Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
||||
parent Discussion? @relation("DiscussionThread", fields: [parentId], references: [id], onDelete: SetNull)
|
||||
replies Discussion[] @relation("DiscussionThread")
|
||||
division Division? @relation(fields: [divisionId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([divisionId])
|
||||
@@index([createdAt])
|
||||
@@map("discussion")
|
||||
}
|
||||
|
||||
model Event {
|
||||
id String @id @default(cuid())
|
||||
externalId String? @unique // ID asli dari server NOC
|
||||
villageId String? @default("desa1")
|
||||
title String
|
||||
description String?
|
||||
eventType EventType
|
||||
startDate DateTime
|
||||
endDate DateTime?
|
||||
location String?
|
||||
isAllDay Boolean @default(false)
|
||||
isRecurring Boolean @default(false)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
creator User @relation(fields: [createdBy], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([startDate])
|
||||
@@index([eventType])
|
||||
@@map("event")
|
||||
}
|
||||
|
||||
model DivisionMetric {
|
||||
id String @id @default(cuid())
|
||||
divisionId String
|
||||
period String // "2025-Q1", "2025-01"
|
||||
activityCount Int @default(0)
|
||||
completionRate Float @default(0)
|
||||
avgProgress Float @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
division Division @relation(fields: [divisionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([divisionId, period])
|
||||
@@map("division_metric")
|
||||
}
|
||||
|
||||
// --- KATEGORI 2: PENGADUAN & LAYANAN PUBLIK ---
|
||||
|
||||
model Complaint {
|
||||
id String @id @default(cuid())
|
||||
complaintNumber String @unique // Auto-generated: COMPLAINT-YYYYMMDD-XXX
|
||||
title String
|
||||
description String
|
||||
category ComplaintCategory
|
||||
status ComplaintStatus @default(BARU)
|
||||
priority Priority @default(SEDANG)
|
||||
|
||||
reporterId String?
|
||||
reporterPhone String?
|
||||
reporterEmail String?
|
||||
isAnonymous Boolean @default(false)
|
||||
|
||||
assignedTo String? // User ID
|
||||
resolvedBy String? // User ID
|
||||
resolvedAt DateTime?
|
||||
|
||||
location String?
|
||||
imageUrl String[] // Array of image URLs
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
reporter User? @relation("ComplaintReporter", fields: [reporterId], references: [id], onDelete: SetNull)
|
||||
assignee User? @relation("ComplaintAssignee", fields: [assignedTo], references: [id], onDelete: SetNull)
|
||||
|
||||
complaintUpdates ComplaintUpdate[]
|
||||
|
||||
@@index([status])
|
||||
@@index([category])
|
||||
@@index([createdAt])
|
||||
@@map("complaint")
|
||||
}
|
||||
|
||||
model ComplaintUpdate {
|
||||
id String @id @default(cuid())
|
||||
complaintId String
|
||||
message String
|
||||
status ComplaintStatus?
|
||||
updatedBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
complaint Complaint @relation(fields: [complaintId], references: [id], onDelete: Cascade)
|
||||
updater User @relation(fields: [updatedBy], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([complaintId])
|
||||
@@map("complaint_update")
|
||||
}
|
||||
|
||||
model ServiceLetter {
|
||||
id String @id @default(cuid())
|
||||
letterNumber String @unique
|
||||
letterType LetterType
|
||||
applicantName String
|
||||
applicantNik String
|
||||
applicantAddress String
|
||||
purpose String?
|
||||
status ServiceStatus @default(BARU)
|
||||
|
||||
processedBy String?
|
||||
completedAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
processor User? @relation(fields: [processedBy], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([letterType])
|
||||
@@index([status])
|
||||
@@index([createdAt])
|
||||
@@map("service_letter")
|
||||
}
|
||||
|
||||
model InnovationIdea {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String
|
||||
category String // "Teknologi", "Ekonomi", "Kesehatan", "Pendidikan"
|
||||
submitterName String
|
||||
submitterContact String?
|
||||
status IdeaStatus @default(BARU)
|
||||
reviewedBy String?
|
||||
reviewedAt DateTime?
|
||||
notes String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
reviewer User? @relation("IdeaReviewer", fields: [reviewedBy], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([category])
|
||||
@@index([status])
|
||||
@@map("innovation_idea")
|
||||
}
|
||||
|
||||
// --- KATEGORI 3: DEMOGRAFI & KEPENDUDUKAN ---
|
||||
|
||||
model Resident {
|
||||
id String @id @default(cuid())
|
||||
nik String @unique
|
||||
kk String
|
||||
name String
|
||||
birthDate DateTime
|
||||
birthPlace String
|
||||
gender Gender
|
||||
religion Religion
|
||||
maritalStatus MaritalStatus @default(BELUM_KAWIN)
|
||||
education EducationLevel?
|
||||
occupation String?
|
||||
|
||||
banjarId String
|
||||
rt String
|
||||
rw String
|
||||
address String
|
||||
|
||||
isHeadOfHousehold Boolean @default(false)
|
||||
isPoor Boolean @default(false)
|
||||
isStunting Boolean @default(false)
|
||||
|
||||
deathDate DateTime?
|
||||
moveInDate DateTime?
|
||||
moveOutDate DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
banjar Banjar @relation(fields: [banjarId], references: [id], onDelete: Cascade)
|
||||
|
||||
healthRecords HealthRecord[]
|
||||
employmentRecords EmploymentRecord[]
|
||||
|
||||
@@index([banjarId])
|
||||
@@index([religion])
|
||||
@@index([occupation])
|
||||
@@map("resident")
|
||||
}
|
||||
|
||||
model Banjar {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
code String @unique
|
||||
description String?
|
||||
|
||||
totalPopulation Int @default(0)
|
||||
totalKK Int @default(0)
|
||||
totalPoor Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
residents Resident[]
|
||||
umkms Umkm[]
|
||||
|
||||
@@map("banjar")
|
||||
}
|
||||
|
||||
// --- KATEGORI 4: KEUANGAN & ANGGARAN ---
|
||||
|
||||
model Budget {
|
||||
id String @id @default(cuid())
|
||||
category String // "Belanja", "Pangan", "Pembiayaan", "Pendapatan"
|
||||
amount Float @default(0)
|
||||
percentage Float @default(0)
|
||||
color String @default("#3B82F6")
|
||||
fiscalYear Int @default(2025)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([category, fiscalYear])
|
||||
@@map("budget")
|
||||
}
|
||||
|
||||
// --- KATEGORI 5: METRIK DASHBOARD & SDGS ---
|
||||
|
||||
model SdgsScore {
|
||||
id String @id @default(cuid())
|
||||
title String @unique
|
||||
score Float @default(0)
|
||||
image String? // filename in public folder
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("sdgs_score")
|
||||
}
|
||||
|
||||
model SatisfactionRating {
|
||||
id String @id @default(cuid())
|
||||
category String @unique // "Sangat Puas", "Puas", "Cukup", "Kurang"
|
||||
value Int @default(0)
|
||||
color String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("satisfaction_rating")
|
||||
}
|
||||
|
||||
// --- STUBS FOR PHASE 2+ (To maintain relations) ---
|
||||
|
||||
model HealthRecord {
|
||||
id String @id @default(cuid())
|
||||
residentId String
|
||||
resident Resident @relation(fields: [residentId], references: [id])
|
||||
recordedBy String
|
||||
recorder User @relation(fields: [recordedBy], references: [id])
|
||||
type String // "Pemeriksaan", "Imunisasi", "Ibu Hamil"
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model EmploymentRecord {
|
||||
id String @id @default(cuid())
|
||||
residentId String
|
||||
resident Resident @relation(fields: [residentId], references: [id])
|
||||
companyName String
|
||||
position String
|
||||
startDate DateTime
|
||||
endDate DateTime?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model PopulationDynamic {
|
||||
id String @id @default(cuid())
|
||||
documentedBy String
|
||||
documentor User @relation(fields: [documentedBy], references: [id])
|
||||
type String // "KELAHIRAN", "KEMATIAN", "KEDATANGAN", "KEPERGIAN"
|
||||
residentName String
|
||||
eventDate DateTime
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model BudgetTransaction {
|
||||
id String @id @default(cuid())
|
||||
createdBy String
|
||||
creator User @relation(fields: [createdBy], references: [id])
|
||||
transactionNumber String @unique
|
||||
type String // "PENGELUARAN", "PENDAPATAN"
|
||||
category String
|
||||
amount Float
|
||||
description String?
|
||||
date DateTime
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Umkm {
|
||||
id String @id @default(cuid())
|
||||
banjarId String?
|
||||
banjar Banjar? @relation(fields: [banjarId], references: [id])
|
||||
name String
|
||||
owner String
|
||||
productType String?
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Posyandu {
|
||||
id String @id @default(cuid())
|
||||
coordinatorId String?
|
||||
coordinator User? @relation(fields: [coordinatorId], references: [id])
|
||||
name String
|
||||
location String
|
||||
schedule String
|
||||
type String // "Ibu dan Anak", "Lansia", etc.
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model SecurityReport {
|
||||
id String @id @default(cuid())
|
||||
assignedTo String?
|
||||
assignee User? @relation(fields: [assignedTo], references: [id])
|
||||
reportNumber String @unique
|
||||
title String
|
||||
description String
|
||||
location String?
|
||||
reportedBy String
|
||||
status String @default("BARU") // BARU, DIPROSES, SELESAI
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// --- ENUMS ---
|
||||
|
||||
enum ActivityStatus {
|
||||
BERJALAN
|
||||
SELESAI
|
||||
TERTUNDA
|
||||
DIBATALKAN
|
||||
}
|
||||
|
||||
enum Priority {
|
||||
RENDAH
|
||||
SEDANG
|
||||
TINGGI
|
||||
DARURAT
|
||||
}
|
||||
|
||||
enum DocumentCategory {
|
||||
SURAT_KEPUTUSAN
|
||||
DOKUMENTASI
|
||||
LAPORAN_KEUANGAN
|
||||
NOTULENSI_RAPAT
|
||||
UMUM
|
||||
}
|
||||
|
||||
enum EventType {
|
||||
RAPAT
|
||||
KEGIATAN
|
||||
UPACARA
|
||||
SOSIAL
|
||||
BUDAYA
|
||||
LAINNYA
|
||||
}
|
||||
|
||||
enum ComplaintCategory {
|
||||
KETERTIBAN_UMUM
|
||||
PELAYANAN_KESEHATAN
|
||||
INFRASTRUKTUR
|
||||
ADMINISTRASI
|
||||
KEAMANAN
|
||||
LAINNYA
|
||||
}
|
||||
|
||||
enum ComplaintStatus {
|
||||
BARU
|
||||
DIPROSES
|
||||
SELESAI
|
||||
DITOLAK
|
||||
}
|
||||
|
||||
enum LetterType {
|
||||
KTP
|
||||
KK
|
||||
DOMISILI
|
||||
USAHA
|
||||
KETERANGAN_TIDAK_MAMPU
|
||||
SURAT_PENGANTAR
|
||||
LAINNYA
|
||||
}
|
||||
|
||||
enum ServiceStatus {
|
||||
BARU
|
||||
DIPROSES
|
||||
SELESAI
|
||||
DIAMBIL
|
||||
}
|
||||
|
||||
enum IdeaStatus {
|
||||
BARU
|
||||
DIKAJI
|
||||
DISETUJUI
|
||||
DITOLAK
|
||||
DIIMPLEMENTASI
|
||||
}
|
||||
|
||||
enum Gender {
|
||||
LAKI_LAKI
|
||||
PEREMPUAN
|
||||
}
|
||||
|
||||
enum Religion {
|
||||
HINDU
|
||||
ISLAM
|
||||
KRISTEN
|
||||
KATOLIK
|
||||
BUDDHA
|
||||
KONGHUCU
|
||||
LAINNYA
|
||||
}
|
||||
|
||||
enum MaritalStatus {
|
||||
BELUM_KAWIN
|
||||
KAWIN
|
||||
CERAI_HIDUP
|
||||
CERAI_MATI
|
||||
}
|
||||
|
||||
enum EducationLevel {
|
||||
TIDAK_SEKOLAH
|
||||
SD
|
||||
SMP
|
||||
SMA
|
||||
D3
|
||||
S1
|
||||
S2
|
||||
S3
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
|
||||
363
prisma/seed.ts
@@ -1,139 +1,248 @@
|
||||
import "dotenv/config";
|
||||
import { hash } from "bcryptjs";
|
||||
import { generateId } from "better-auth";
|
||||
import { prisma } from "@/utils/db";
|
||||
import { PrismaClient } from "../generated/prisma";
|
||||
|
||||
async function seedAdminUser() {
|
||||
// Load environment variables
|
||||
const adminEmail = process.env.ADMIN_EMAIL;
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || "admin123";
|
||||
// Import all seeders
|
||||
import { seedAdminUser, seedApiKeys, seedDemoUsers } from "./seeders/seed-auth";
|
||||
import { seedDashboardMetrics } from "./seeders/seed-dashboard-metrics";
|
||||
import {
|
||||
getBanjarIds,
|
||||
seedBanjars,
|
||||
seedResidents,
|
||||
} from "./seeders/seed-demographics";
|
||||
import {
|
||||
seedDiscussions,
|
||||
seedDivisionMetrics,
|
||||
seedDocuments,
|
||||
} from "./seeders/seed-discussions";
|
||||
import {
|
||||
getDivisionIds,
|
||||
seedActivities,
|
||||
seedDivisions,
|
||||
} from "./seeders/seed-division-performance";
|
||||
import { seedPhase2 } from "./seeders/seed-phase2";
|
||||
import {
|
||||
getComplaintIds,
|
||||
seedComplaints,
|
||||
seedComplaintUpdates,
|
||||
seedEvents,
|
||||
seedInnovationIdeas,
|
||||
seedServiceLetters,
|
||||
} from "./seeders/seed-public-services";
|
||||
|
||||
if (!adminEmail) {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Check if seed has already been run
|
||||
* Returns true if core data already exists
|
||||
*/
|
||||
export async function hasExistingData(): Promise<boolean> {
|
||||
// Check for core entities that should always exist after seeding
|
||||
const [userCount, banjarCount, divisionCount] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.banjar.count(),
|
||||
prisma.division.count(),
|
||||
]);
|
||||
|
||||
// If we have more than 1 user (admin), 6 banjars, and 4 divisions, assume seeded
|
||||
return userCount > 1 && banjarCount >= 6 && divisionCount >= 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run All Seeders
|
||||
* Executes all seeder functions in the correct order
|
||||
*/
|
||||
export async function runSeed() {
|
||||
console.log("🌱 Starting seed...\n");
|
||||
|
||||
// Check if data already exists
|
||||
const existingData = await hasExistingData();
|
||||
if (existingData) {
|
||||
console.log(
|
||||
"No ADMIN_EMAIL environment variable found. Skipping admin user creation.",
|
||||
"⏭️ Existing data detected. Skipping seed to prevent duplicates.\n",
|
||||
);
|
||||
console.log("💡 To re-seed, either:");
|
||||
console.log(" 1. Run: bun x prisma migrate reset (resets database)");
|
||||
console.log(" 2. Manually delete data from tables\n");
|
||||
console.log("✅ Seed skipped successfully!\n");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if admin user already exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: adminEmail },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
// Update existing user to have admin role if they don't already
|
||||
if (existingUser.role !== "admin") {
|
||||
await prisma.user.update({
|
||||
where: { email: adminEmail },
|
||||
data: { role: "admin" },
|
||||
});
|
||||
console.log(`User with email ${adminEmail} updated to admin role.`);
|
||||
} else {
|
||||
console.log(`User with email ${adminEmail} already has admin role.`);
|
||||
}
|
||||
} else {
|
||||
// Create new admin user
|
||||
const hashedPassword = await hash(adminPassword, 12);
|
||||
const userId = generateId();
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: userId,
|
||||
email: adminEmail,
|
||||
name: "Admin User",
|
||||
role: "admin",
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.account.create({
|
||||
data: {
|
||||
id: generateId(),
|
||||
userId,
|
||||
accountId: userId,
|
||||
providerId: "credential",
|
||||
password: hashedPassword,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Admin user created with email: ${adminEmail}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error seeding admin user:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function seedDemoUsers() {
|
||||
const demoUsers = [
|
||||
{ email: "demo1@example.com", name: "Demo User 1", role: "user" },
|
||||
{ email: "demo2@example.com", name: "Demo User 2", role: "user" },
|
||||
{
|
||||
email: "moderator@example.com",
|
||||
name: "Moderator User",
|
||||
role: "moderator",
|
||||
},
|
||||
];
|
||||
|
||||
for (const userData of demoUsers) {
|
||||
try {
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: userData.email },
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
const userId = generateId();
|
||||
const hashedPassword = await hash("demo123", 12);
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: userId,
|
||||
email: userData.email,
|
||||
name: userData.name,
|
||||
role: userData.role,
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.account.create({
|
||||
data: {
|
||||
id: generateId(),
|
||||
userId,
|
||||
accountId: userId,
|
||||
providerId: "credential",
|
||||
password: hashedPassword,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Demo user created: ${userData.email}`);
|
||||
} else {
|
||||
console.log(`Demo user already exists: ${userData.email}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error seeding user ${userData.email}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Seeding database...");
|
||||
|
||||
await seedAdminUser();
|
||||
// 1. Seed Authentication (Admin & Demo Users)
|
||||
console.log("📁 [1/7] Authentication & Users");
|
||||
const adminId = await seedAdminUser();
|
||||
await seedDemoUsers();
|
||||
await seedApiKeys(adminId);
|
||||
console.log();
|
||||
|
||||
console.log("Database seeding completed.");
|
||||
// 2. Seed Demographics (Banjars & Residents)
|
||||
console.log("📁 [2/7] Demographics & Population");
|
||||
await seedBanjars();
|
||||
const banjarIds = await getBanjarIds();
|
||||
await seedResidents(banjarIds);
|
||||
console.log();
|
||||
|
||||
// 3. Seed Division Performance (Divisions & Activities)
|
||||
console.log("📁 [3/7] Division Performance");
|
||||
const divisions = await seedDivisions();
|
||||
const divisionIds = divisions.map((d) => d.id);
|
||||
await seedActivities(divisionIds);
|
||||
await seedDivisionMetrics(divisionIds);
|
||||
console.log();
|
||||
|
||||
// 4. Seed Public Services (Complaints, Service Letters, Events, Innovation)
|
||||
console.log("📁 [4/7] Public Services");
|
||||
await seedComplaints(adminId);
|
||||
await seedServiceLetters(adminId);
|
||||
await seedEvents(adminId);
|
||||
await seedInnovationIdeas(adminId);
|
||||
const complaintIds = await getComplaintIds();
|
||||
await seedComplaintUpdates(complaintIds, adminId);
|
||||
console.log();
|
||||
|
||||
// 5. Seed Documents & Discussions
|
||||
console.log("📁 [5/7] Documents & Discussions");
|
||||
await seedDocuments(divisionIds, adminId);
|
||||
await seedDiscussions(divisionIds, adminId);
|
||||
console.log();
|
||||
|
||||
// 6. Seed Dashboard Metrics (Budget, SDGs, Satisfaction)
|
||||
console.log("📁 [6/7] Dashboard Metrics");
|
||||
await seedDashboardMetrics();
|
||||
console.log();
|
||||
|
||||
// 7. Seed Phase 2+ Features (UMKM, Posyandu, Security, etc.)
|
||||
console.log("📁 [7/7] Phase 2+ Features");
|
||||
await seedPhase2(banjarIds, adminId);
|
||||
console.log();
|
||||
|
||||
console.log("✅ Seed finished successfully!\n");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Error during seeding:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
/**
|
||||
* Run Specific Seeder
|
||||
* Allows running individual seeders by name
|
||||
*/
|
||||
export async function runSpecificSeeder(name: string) {
|
||||
console.log(`🌱 Running specific seeder: ${name}\n`);
|
||||
|
||||
// Check if data already exists for specific seeder
|
||||
const existingData = await hasExistingData();
|
||||
if (existingData && name !== "auth") {
|
||||
console.log(
|
||||
"⚠️ Warning: Existing data detected for this seeder category.\n",
|
||||
);
|
||||
console.log("💡 To re-seed, either:");
|
||||
console.log(" 1. Run: bun x prisma migrate reset (resets database)");
|
||||
console.log(" 2. Manually delete data from tables\n");
|
||||
console.log("✅ Seeder skipped to prevent duplicates!\n");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case "auth":
|
||||
case "users": {
|
||||
console.log("📁 Authentication & Users");
|
||||
const adminId = await seedAdminUser();
|
||||
await seedDemoUsers();
|
||||
await seedApiKeys(adminId);
|
||||
break;
|
||||
}
|
||||
|
||||
case "demographics":
|
||||
case "population": {
|
||||
console.log("📁 Demographics & Population");
|
||||
await seedBanjars();
|
||||
const banjarIds = await getBanjarIds();
|
||||
await seedResidents(banjarIds);
|
||||
break;
|
||||
}
|
||||
|
||||
case "divisions":
|
||||
case "performance": {
|
||||
console.log("📁 Division Performance");
|
||||
const divisions = await seedDivisions();
|
||||
const divisionIds = divisions.map((d) => d.id);
|
||||
await seedActivities(divisionIds);
|
||||
await seedDivisionMetrics(divisionIds);
|
||||
break;
|
||||
}
|
||||
|
||||
case "complaints":
|
||||
case "services":
|
||||
case "public": {
|
||||
console.log("📁 Public Services");
|
||||
const pubAdminId = await seedAdminUser();
|
||||
await seedComplaints(pubAdminId);
|
||||
await seedServiceLetters(pubAdminId);
|
||||
await seedEvents(pubAdminId);
|
||||
await seedInnovationIdeas(pubAdminId);
|
||||
const compIds = await getComplaintIds();
|
||||
await seedComplaintUpdates(compIds, pubAdminId);
|
||||
break;
|
||||
}
|
||||
|
||||
case "documents":
|
||||
case "discussions": {
|
||||
console.log("📁 Documents & Discussions");
|
||||
const docAdminId = await seedAdminUser();
|
||||
const divs = await seedDivisions();
|
||||
const divIds = divs.map((d) => d.id);
|
||||
await seedDocuments(divIds, docAdminId);
|
||||
await seedDiscussions(divIds, docAdminId);
|
||||
break;
|
||||
}
|
||||
|
||||
case "dashboard":
|
||||
case "metrics":
|
||||
console.log("📁 Dashboard Metrics");
|
||||
await seedDashboardMetrics();
|
||||
break;
|
||||
|
||||
case "phase2":
|
||||
case "features": {
|
||||
console.log("📁 Phase 2+ Features");
|
||||
const p2AdminId = await seedAdminUser();
|
||||
await seedBanjars();
|
||||
const p2BanjarIds = await getBanjarIds();
|
||||
await seedPhase2(p2BanjarIds, p2AdminId);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(`❌ Unknown seeder: ${name}`);
|
||||
console.log(
|
||||
"Available seeders: auth, demographics, divisions, complaints, documents, dashboard, phase2",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("\n✅ Seeder finished successfully!\n");
|
||||
}
|
||||
|
||||
// Main execution
|
||||
if (import.meta.main) {
|
||||
const args = process.argv.slice(2);
|
||||
const seederName = args[0];
|
||||
|
||||
if (seederName) {
|
||||
// Run specific seeder
|
||||
runSpecificSeeder(seederName)
|
||||
.catch((e) => {
|
||||
console.error("❌ Seeder error:", e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
} else {
|
||||
// Run all seeders
|
||||
runSeed()
|
||||
.catch((e) => {
|
||||
console.error("❌ Seed error:", e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
161
prisma/seeders/seed-auth.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import "dotenv/config";
|
||||
import { hash } from "bcryptjs";
|
||||
import { generateId } from "better-auth";
|
||||
import { PrismaClient } from "../../generated/prisma";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Seed Admin User
|
||||
* Creates or updates the admin user account
|
||||
*/
|
||||
export async function seedAdminUser() {
|
||||
const adminEmail = process.env.ADMIN_EMAIL || "admin@example.com";
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || "admin123";
|
||||
|
||||
console.log(`Checking admin user: ${adminEmail}`);
|
||||
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: adminEmail },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
if (existingUser.role !== "admin") {
|
||||
await prisma.user.update({
|
||||
where: { email: adminEmail },
|
||||
data: { role: "admin" },
|
||||
});
|
||||
console.log("Updated existing user to admin role.");
|
||||
}
|
||||
return existingUser.id;
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(adminPassword, 12);
|
||||
const userId = generateId();
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: userId,
|
||||
email: adminEmail,
|
||||
name: "Admin Desa Darmasaba",
|
||||
role: "admin",
|
||||
emailVerified: true,
|
||||
accounts: {
|
||||
create: {
|
||||
id: generateId(),
|
||||
accountId: userId,
|
||||
providerId: "credential",
|
||||
password: hashedPassword,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Admin user created: ${adminEmail}`);
|
||||
return userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed Demo Users
|
||||
* Creates demo users for testing (user, moderator roles)
|
||||
*/
|
||||
export async function seedDemoUsers() {
|
||||
const demoUsers = [
|
||||
{
|
||||
email: "demo1@example.com",
|
||||
name: "Demo User 1",
|
||||
password: "demo123",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
email: "demo2@example.com",
|
||||
name: "Demo User 2",
|
||||
password: "demo123",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
email: "moderator@example.com",
|
||||
name: "Moderator Desa",
|
||||
password: "demo123",
|
||||
role: "moderator",
|
||||
},
|
||||
];
|
||||
|
||||
console.log("Seeding Demo Users...");
|
||||
|
||||
for (const demo of demoUsers) {
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: demo.email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
console.log(`⏭️ Demo user exists: ${demo.email}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(demo.password, 12);
|
||||
const userId = generateId();
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: userId,
|
||||
email: demo.email,
|
||||
name: demo.name,
|
||||
role: demo.role,
|
||||
emailVerified: true,
|
||||
accounts: {
|
||||
create: {
|
||||
id: generateId(),
|
||||
accountId: userId,
|
||||
providerId: "credential",
|
||||
password: hashedPassword,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Demo user created: ${demo.email}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed API Keys
|
||||
* Creates sample API keys for testing API access
|
||||
*/
|
||||
export async function seedApiKeys(adminId: string) {
|
||||
console.log("Seeding API Keys...");
|
||||
|
||||
const existingKeys = await prisma.apiKey.findMany({
|
||||
where: { userId: adminId },
|
||||
});
|
||||
|
||||
if (existingKeys.length > 0) {
|
||||
console.log("⏭️ API keys already exist, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKeys = [
|
||||
{
|
||||
name: "Development Key",
|
||||
key: "dev_key_" + generateId(),
|
||||
userId: adminId,
|
||||
isActive: true,
|
||||
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year
|
||||
},
|
||||
{
|
||||
name: "Production Key",
|
||||
key: "prod_key_" + generateId(),
|
||||
userId: adminId,
|
||||
isActive: true,
|
||||
expiresAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
for (const apiKey of apiKeys) {
|
||||
await prisma.apiKey.create({
|
||||
data: apiKey,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ API Keys seeded successfully");
|
||||
}
|
||||
109
prisma/seeders/seed-dashboard-metrics.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { PrismaClient } from "../../generated/prisma";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Seed Budget (APBDes)
|
||||
* Creates village budget allocation data
|
||||
*/
|
||||
export async function seedBudget() {
|
||||
console.log("Seeding Budget...");
|
||||
|
||||
const budgets = [
|
||||
{ category: "Belanja", amount: 70, percentage: 70, color: "#3B82F6" },
|
||||
{ category: "Pangan", amount: 45, percentage: 45, color: "#22C55E" },
|
||||
{ category: "Pembiayaan", amount: 55, percentage: 55, color: "#FACC15" },
|
||||
{ category: "Pendapatan", amount: 90, percentage: 90, color: "#3B82F6" },
|
||||
];
|
||||
|
||||
for (const budget of budgets) {
|
||||
await prisma.budget.upsert({
|
||||
where: {
|
||||
category_fiscalYear: {
|
||||
category: budget.category,
|
||||
fiscalYear: 2025,
|
||||
},
|
||||
},
|
||||
update: budget,
|
||||
create: { ...budget, fiscalYear: 2025 },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Budget seeded successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed SDGs Scores
|
||||
* Creates Sustainable Development Goals scores for dashboard
|
||||
*/
|
||||
export async function seedSdgsScores() {
|
||||
console.log("Seeding SDGs Scores...");
|
||||
|
||||
const sdgs = [
|
||||
{
|
||||
title: "Desa Berenergi Bersih dan Terbarukan",
|
||||
score: 99.64,
|
||||
image: "SDGS-7.png",
|
||||
},
|
||||
{
|
||||
title: "Desa Damai Berkeadilan",
|
||||
score: 78.65,
|
||||
image: "SDGS-16.png",
|
||||
},
|
||||
{
|
||||
title: "Desa Sehat dan Sejahtera",
|
||||
score: 77.37,
|
||||
image: "SDGS-3.png",
|
||||
},
|
||||
{
|
||||
title: "Desa Tanpa Kemiskinan",
|
||||
score: 52.62,
|
||||
image: "SDGS-1.png",
|
||||
},
|
||||
];
|
||||
|
||||
for (const sdg of sdgs) {
|
||||
await prisma.sdgsScore.upsert({
|
||||
where: { title: sdg.title },
|
||||
update: sdg,
|
||||
create: sdg,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ SDGs Scores seeded successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed Satisfaction Ratings
|
||||
* Creates public satisfaction survey data
|
||||
*/
|
||||
export async function seedSatisfactionRatings() {
|
||||
console.log("Seeding Satisfaction Ratings...");
|
||||
|
||||
const satisfactions = [
|
||||
{ category: "Sangat Puas", value: 25, color: "#4E5BA6" },
|
||||
{ category: "Puas", value: 25, color: "#F4C542" },
|
||||
{ category: "Cukup", value: 25, color: "#8CC63F" },
|
||||
{ category: "Kurang", value: 25, color: "#E57373" },
|
||||
];
|
||||
|
||||
for (const sat of satisfactions) {
|
||||
await prisma.satisfactionRating.upsert({
|
||||
where: { category: sat.category },
|
||||
update: sat,
|
||||
create: sat,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Satisfaction Ratings seeded successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed All Dashboard Metrics
|
||||
* Main function to run all dashboard metrics seeders
|
||||
*/
|
||||
export async function seedDashboardMetrics() {
|
||||
await seedBudget();
|
||||
await seedSdgsScores();
|
||||
await seedSatisfactionRatings();
|
||||
}
|
||||
124
prisma/seeders/seed-demographics.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Gender, PrismaClient, Religion } from "../../generated/prisma";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Seed Banjars (Village Hamlets)
|
||||
* Creates 6 banjars in Darmasaba village
|
||||
*/
|
||||
export async function seedBanjars() {
|
||||
const banjars = [
|
||||
{
|
||||
name: "Darmasaba",
|
||||
code: "DSB",
|
||||
totalPopulation: 1200,
|
||||
totalKK: 300,
|
||||
totalPoor: 45,
|
||||
},
|
||||
{
|
||||
name: "Manesa",
|
||||
code: "MNS",
|
||||
totalPopulation: 950,
|
||||
totalKK: 240,
|
||||
totalPoor: 32,
|
||||
},
|
||||
{
|
||||
name: "Cabe",
|
||||
code: "CBE",
|
||||
totalPopulation: 800,
|
||||
totalKK: 200,
|
||||
totalPoor: 28,
|
||||
},
|
||||
{
|
||||
name: "Penenjoan",
|
||||
code: "PNJ",
|
||||
totalPopulation: 1100,
|
||||
totalKK: 280,
|
||||
totalPoor: 50,
|
||||
},
|
||||
{
|
||||
name: "Baler Pasar",
|
||||
code: "BPS",
|
||||
totalPopulation: 850,
|
||||
totalKK: 210,
|
||||
totalPoor: 35,
|
||||
},
|
||||
{
|
||||
name: "Bucu",
|
||||
code: "BCU",
|
||||
totalPopulation: 734,
|
||||
totalKK: 184,
|
||||
totalPoor: 24,
|
||||
},
|
||||
];
|
||||
|
||||
console.log("Seeding Banjars...");
|
||||
for (const banjar of banjars) {
|
||||
await prisma.banjar.upsert({
|
||||
where: { name: banjar.name },
|
||||
update: banjar,
|
||||
create: banjar,
|
||||
});
|
||||
}
|
||||
console.log("✅ Banjars seeded successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Banjar IDs
|
||||
* Helper function to retrieve banjar IDs for other seeders
|
||||
*/
|
||||
export async function getBanjarIds(): Promise<string[]> {
|
||||
const banjars = await prisma.banjar.findMany();
|
||||
return banjars.map((b) => b.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed Residents
|
||||
* Creates sample resident data for demographics
|
||||
*/
|
||||
export async function seedResidents(banjarIds: string[]) {
|
||||
console.log("Seeding Residents...");
|
||||
|
||||
const residents = [
|
||||
{
|
||||
nik: "5103010101700001",
|
||||
kk: "5103010101700000",
|
||||
name: "I Wayan Sudarsana",
|
||||
birthDate: new Date("1970-05-15"),
|
||||
birthPlace: "Badung",
|
||||
gender: Gender.LAKI_LAKI,
|
||||
religion: Religion.HINDU,
|
||||
occupation: "Wiraswasta",
|
||||
banjarId: banjarIds[0] || "",
|
||||
rt: "001",
|
||||
rw: "000",
|
||||
address: "Jl. Raya Darmasaba No. 1",
|
||||
isHeadOfHousehold: true,
|
||||
},
|
||||
{
|
||||
nik: "5103010101850002",
|
||||
kk: "5103010101850000",
|
||||
name: "Ni Made Arianti",
|
||||
birthDate: new Date("1985-08-20"),
|
||||
birthPlace: "Denpasar",
|
||||
gender: Gender.PEREMPUAN,
|
||||
religion: Religion.HINDU,
|
||||
occupation: "Guru",
|
||||
banjarId: banjarIds[1] || banjarIds[0] || "",
|
||||
rt: "002",
|
||||
rw: "000",
|
||||
address: "Gg. Manesa No. 5",
|
||||
isPoor: true,
|
||||
},
|
||||
];
|
||||
|
||||
for (const res of residents) {
|
||||
await prisma.resident.upsert({
|
||||
where: { nik: res.nik },
|
||||
update: res,
|
||||
create: res,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Residents seeded successfully");
|
||||
}
|
||||
203
prisma/seeders/seed-discussions.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
DocumentCategory,
|
||||
Priority,
|
||||
PrismaClient,
|
||||
} from "../../generated/prisma";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Seed Documents
|
||||
* Creates sample documents for divisions (SK, laporan, dokumentasi)
|
||||
*/
|
||||
export async function seedDocuments(divisionIds: string[], userId: string) {
|
||||
console.log("Seeding Documents...");
|
||||
|
||||
const documents = [
|
||||
{
|
||||
title: "SK Kepala Desa No. 1/2025",
|
||||
category: DocumentCategory.SURAT_KEPUTUSAN,
|
||||
type: "PDF",
|
||||
fileUrl: "/documents/sk-kepala-desa-001.pdf",
|
||||
fileSize: 245000,
|
||||
divisionId: divisionIds[0] || null,
|
||||
uploadedBy: userId,
|
||||
},
|
||||
{
|
||||
title: "Laporan Keuangan Q1 2025",
|
||||
category: DocumentCategory.LAPORAN_KEUANGAN,
|
||||
type: "PDF",
|
||||
fileUrl: "/documents/laporan-keuangan-q1-2025.pdf",
|
||||
fileSize: 512000,
|
||||
divisionId: divisionIds[0] || null,
|
||||
uploadedBy: userId,
|
||||
},
|
||||
{
|
||||
title: "Dokumentasi Gotong Royong",
|
||||
category: DocumentCategory.DOKUMENTASI,
|
||||
type: "Gambar",
|
||||
fileUrl: "/images/gotong-royong-2025.jpg",
|
||||
fileSize: 1024000,
|
||||
divisionId: divisionIds[3] || null,
|
||||
uploadedBy: userId,
|
||||
},
|
||||
{
|
||||
title: "Notulensi Rapat Desa",
|
||||
category: DocumentCategory.NOTULENSI_RAPAT,
|
||||
type: "Dokumen",
|
||||
fileUrl: "/documents/notulensi-rapat-desa.pdf",
|
||||
fileSize: 128000,
|
||||
divisionId: divisionIds[0] || null,
|
||||
uploadedBy: userId,
|
||||
},
|
||||
{
|
||||
title: "Data Penduduk 2025",
|
||||
category: DocumentCategory.UMUM,
|
||||
type: "Excel",
|
||||
fileUrl: "/documents/data-penduduk-2025.xlsx",
|
||||
fileSize: 350000,
|
||||
divisionId: null,
|
||||
uploadedBy: userId,
|
||||
},
|
||||
];
|
||||
|
||||
for (const doc of documents) {
|
||||
await prisma.document.create({
|
||||
data: doc,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Documents seeded successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed Discussions
|
||||
* Creates sample discussions for divisions and activities
|
||||
*/
|
||||
export async function seedDiscussions(divisionIds: string[], userId: string) {
|
||||
console.log("Seeding Discussions...");
|
||||
|
||||
const discussions = [
|
||||
{
|
||||
message: "Mohon update progress pembangunan jalan",
|
||||
senderId: userId,
|
||||
divisionId: divisionIds[1] || null,
|
||||
isResolved: false,
|
||||
},
|
||||
{
|
||||
message: "Baik, akan segera kami tindak lanjuti",
|
||||
senderId: userId,
|
||||
divisionId: divisionIds[1] || null,
|
||||
isResolved: false,
|
||||
parentId: null, // Will be set as reply
|
||||
},
|
||||
{
|
||||
message: "Jadwal rapat koordinasi minggu depan?",
|
||||
senderId: userId,
|
||||
divisionId: divisionIds[0] || null,
|
||||
isResolved: true,
|
||||
},
|
||||
{
|
||||
message: "Rapat dijadwalkan hari Senin, 10:00 WITA",
|
||||
senderId: userId,
|
||||
divisionId: divisionIds[0] || null,
|
||||
isResolved: true,
|
||||
parentId: null, // Will be set as reply
|
||||
},
|
||||
{
|
||||
message: "Program pemberdayaan UMKM butuh anggaran tambahan",
|
||||
senderId: userId,
|
||||
divisionId: divisionIds[2] || null,
|
||||
isResolved: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Create parent discussions first
|
||||
const parentDiscussions = [];
|
||||
for (let i = 0; i < discussions.length; i += 2) {
|
||||
const discussion = await prisma.discussion.create({
|
||||
data: {
|
||||
message: discussions[i].message,
|
||||
senderId: discussions[i].senderId,
|
||||
divisionId: discussions[i].divisionId,
|
||||
isResolved: discussions[i].isResolved,
|
||||
},
|
||||
});
|
||||
parentDiscussions.push(discussion);
|
||||
}
|
||||
|
||||
// Create replies
|
||||
for (let i = 1; i < discussions.length; i += 2) {
|
||||
const parentIndex = Math.floor((i - 1) / 2);
|
||||
if (parentIndex < parentDiscussions.length) {
|
||||
await prisma.discussion.update({
|
||||
where: { id: parentDiscussions[parentIndex].id },
|
||||
data: {
|
||||
replies: {
|
||||
create: {
|
||||
message: discussions[i].message,
|
||||
senderId: discussions[i].senderId,
|
||||
isResolved: discussions[i].isResolved,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ Discussions seeded successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed Division Metrics
|
||||
* Creates performance metrics for each division
|
||||
*/
|
||||
export async function seedDivisionMetrics(divisionIds: string[]) {
|
||||
console.log("Seeding Division Metrics...");
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
divisionId: divisionIds[0] || "",
|
||||
period: "2025-Q1",
|
||||
activityCount: 12,
|
||||
completionRate: 75.5,
|
||||
avgProgress: 82.3,
|
||||
},
|
||||
{
|
||||
divisionId: divisionIds[1] || "",
|
||||
period: "2025-Q1",
|
||||
activityCount: 8,
|
||||
completionRate: 62.5,
|
||||
avgProgress: 65.0,
|
||||
},
|
||||
{
|
||||
divisionId: divisionIds[2] || "",
|
||||
period: "2025-Q1",
|
||||
activityCount: 10,
|
||||
completionRate: 80.0,
|
||||
avgProgress: 70.5,
|
||||
},
|
||||
{
|
||||
divisionId: divisionIds[3] || "",
|
||||
period: "2025-Q1",
|
||||
activityCount: 15,
|
||||
completionRate: 86.7,
|
||||
avgProgress: 88.2,
|
||||
},
|
||||
];
|
||||
|
||||
for (const metric of metrics) {
|
||||
await prisma.divisionMetric.upsert({
|
||||
where: {
|
||||
divisionId_period: {
|
||||
divisionId: metric.divisionId,
|
||||
period: metric.period,
|
||||
},
|
||||
},
|
||||
update: metric,
|
||||
create: metric,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Division Metrics seeded successfully");
|
||||
}
|
||||
97
prisma/seeders/seed-division-performance.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { ActivityStatus, Priority, PrismaClient } from "../../generated/prisma";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Seed Divisions
|
||||
* Creates 4 main village divisions/departments
|
||||
*/
|
||||
export async function seedDivisions() {
|
||||
const divisions = [
|
||||
{
|
||||
name: "Pemerintahan",
|
||||
description: "Urusan administrasi dan tata kelola desa",
|
||||
color: "#1E3A5F",
|
||||
},
|
||||
{
|
||||
name: "Pembangunan",
|
||||
description: "Infrastruktur dan sarana prasarana desa",
|
||||
color: "#2E7D32",
|
||||
},
|
||||
{
|
||||
name: "Pemberdayaan",
|
||||
description: "Pemberdayaan ekonomi dan masyarakat",
|
||||
color: "#EF6C00",
|
||||
},
|
||||
{
|
||||
name: "Kesejahteraan",
|
||||
description: "Kesehatan, pendidikan, dan sosial",
|
||||
color: "#C62828",
|
||||
},
|
||||
];
|
||||
|
||||
console.log("Seeding Divisions...");
|
||||
const createdDivisions = [];
|
||||
for (const div of divisions) {
|
||||
const d = await prisma.division.upsert({
|
||||
where: { name: div.name },
|
||||
update: div,
|
||||
create: div,
|
||||
});
|
||||
createdDivisions.push(d);
|
||||
}
|
||||
console.log("✅ Divisions seeded successfully");
|
||||
return createdDivisions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Division IDs
|
||||
* Helper function to retrieve division IDs for other seeders
|
||||
*/
|
||||
export async function getDivisionIds(): Promise<string[]> {
|
||||
const divisions = await prisma.division.findMany();
|
||||
return divisions.map((d) => d.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed Activities
|
||||
* Creates sample activities for each division
|
||||
*/
|
||||
export async function seedActivities(divisionIds: string[]) {
|
||||
console.log("Seeding Activities...");
|
||||
|
||||
const activities = [
|
||||
{
|
||||
title: "Rapat Koordinasi 2025",
|
||||
description: "Penyusunan rencana kerja tahunan",
|
||||
divisionId: divisionIds[0] || "",
|
||||
progress: 100,
|
||||
status: ActivityStatus.SELESAI,
|
||||
priority: Priority.TINGGI,
|
||||
},
|
||||
{
|
||||
title: "Pemutakhiran Indeks Desa",
|
||||
description: "Pendataan SDG's Desa 2025",
|
||||
divisionId: divisionIds[0] || "",
|
||||
progress: 65,
|
||||
status: ActivityStatus.BERJALAN,
|
||||
priority: Priority.SEDANG,
|
||||
},
|
||||
{
|
||||
title: "Pembangunan Jalan Banjar Cabe",
|
||||
description: "Pengaspalan jalan utama",
|
||||
divisionId: divisionIds[1] || divisionIds[0] || "",
|
||||
progress: 40,
|
||||
status: ActivityStatus.BERJALAN,
|
||||
priority: Priority.DARURAT,
|
||||
},
|
||||
];
|
||||
|
||||
for (const act of activities) {
|
||||
await prisma.activity.create({
|
||||
data: act,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Activities seeded successfully");
|
||||
}
|
||||
254
prisma/seeders/seed-phase2.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { PrismaClient } from "../../generated/prisma";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Seed UMKM (Usaha Mikro, Kecil, dan Menengah)
|
||||
* Creates sample local businesses for each banjar
|
||||
*/
|
||||
export async function seedUmkm(banjarIds: string[]) {
|
||||
console.log("Seeding UMKM...");
|
||||
|
||||
const umkms = [
|
||||
{
|
||||
banjarId: banjarIds[0] || null,
|
||||
name: "Kerajinan Anyaman Darmasaba",
|
||||
owner: "Ni Wayan Rajin",
|
||||
productType: "Kerajinan Tangan",
|
||||
description: "Produksi anyasan bambu dan rotan",
|
||||
},
|
||||
{
|
||||
banjarId: banjarIds[1] || null,
|
||||
name: "Warung Makan Manesa",
|
||||
owner: "Made Sari",
|
||||
productType: "Kuliner",
|
||||
description: "Makanan tradisional Bali",
|
||||
},
|
||||
{
|
||||
banjarId: banjarIds[2] || null,
|
||||
name: "Bengkel Cabe Motor",
|
||||
owner: "Ketut Arsana",
|
||||
productType: "Jasa",
|
||||
description: "Servis motor dan jual sparepart",
|
||||
},
|
||||
{
|
||||
banjarId: banjarIds[3] || null,
|
||||
name: "Produksi Keripik Pisang Penenjoan",
|
||||
owner: "Putu Suartika",
|
||||
productType: "Makanan Ringan",
|
||||
description: "Keripik pisang dengan berbagai varian rasa",
|
||||
},
|
||||
];
|
||||
|
||||
for (const umkm of umkms) {
|
||||
await prisma.umkm.create({
|
||||
data: umkm,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ UMKM seeded successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed Posyandu (Community Health Post)
|
||||
* Creates health service schedules and programs
|
||||
*/
|
||||
export async function seedPosyandu(userId: string) {
|
||||
console.log("Seeding Posyandu...");
|
||||
|
||||
const posyandus = [
|
||||
{
|
||||
name: "Posyandu Mawar",
|
||||
location: "Banjar Darmasaba",
|
||||
schedule: "Setiap tanggal 15",
|
||||
type: "Ibu dan Anak",
|
||||
coordinatorId: userId,
|
||||
},
|
||||
{
|
||||
name: "Posyandu Melati",
|
||||
location: "Banjar Manesa",
|
||||
schedule: "Setiap tanggal 20",
|
||||
type: "Ibu dan Anak",
|
||||
coordinatorId: userId,
|
||||
},
|
||||
{
|
||||
name: "Posyandu Lansia Sejahtera",
|
||||
location: "Balai Desa",
|
||||
schedule: "Setiap tanggal 25",
|
||||
type: "Lansia",
|
||||
coordinatorId: userId,
|
||||
},
|
||||
];
|
||||
|
||||
for (const posyandu of posyandus) {
|
||||
await prisma.posyandu.create({
|
||||
data: posyandu,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Posyandu seeded successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed Security Reports
|
||||
* Creates sample security incident reports
|
||||
*/
|
||||
export async function seedSecurityReports(userId: string) {
|
||||
console.log("Seeding Security Reports...");
|
||||
|
||||
const securityReports = [
|
||||
{
|
||||
reportNumber: "SEC-2025-001",
|
||||
title: "Pencurian Kendaraan",
|
||||
description: "Laporan kehilangan motor di area pasar",
|
||||
location: "Pasar Darmasaba",
|
||||
reportedBy: "I Wayan Aman",
|
||||
status: "DIPROSES",
|
||||
assignedTo: userId,
|
||||
},
|
||||
{
|
||||
reportNumber: "SEC-2025-002",
|
||||
title: "Gangguan Ketertiban",
|
||||
description: "Keributan di jalan utama pada malam hari",
|
||||
location: "Jl. Raya Darmasaba",
|
||||
reportedBy: "Made Tertib",
|
||||
status: "SELESAI",
|
||||
assignedTo: userId,
|
||||
},
|
||||
];
|
||||
|
||||
for (const report of securityReports) {
|
||||
await prisma.securityReport.upsert({
|
||||
where: { reportNumber: report.reportNumber },
|
||||
update: report,
|
||||
create: report,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Security Reports seeded successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed Employment Records
|
||||
* Creates employment history for residents
|
||||
*/
|
||||
export async function seedEmploymentRecords() {
|
||||
console.log("Seeding Employment Records...");
|
||||
|
||||
// Get residents first
|
||||
const residents = await prisma.resident.findMany({
|
||||
take: 2,
|
||||
});
|
||||
|
||||
if (residents.length === 0) {
|
||||
console.log("⏭️ No residents found, skipping employment records");
|
||||
return;
|
||||
}
|
||||
|
||||
const employmentRecords = residents.map((resident) => ({
|
||||
residentId: resident.id,
|
||||
companyName: `PT. Desa Makmur ${resident.name.split(" ")[0]}`,
|
||||
position: "Staff",
|
||||
startDate: new Date("2020-01-01"),
|
||||
endDate: null,
|
||||
isActive: true,
|
||||
}));
|
||||
|
||||
for (const record of employmentRecords) {
|
||||
await prisma.employmentRecord.create({
|
||||
data: record,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Employment Records seeded successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed Population Dynamics
|
||||
* Creates population change records (births, deaths, migration)
|
||||
*/
|
||||
export async function seedPopulationDynamics(userId: string) {
|
||||
console.log("Seeding Population Dynamics...");
|
||||
|
||||
const populationDynamics = [
|
||||
{
|
||||
type: "KELAHIRAN",
|
||||
residentName: "Anak Baru Darmasaba",
|
||||
eventDate: new Date("2025-01-15"),
|
||||
description: "Kelahiran bayi laki-laki",
|
||||
documentedBy: userId,
|
||||
},
|
||||
{
|
||||
type: "KEMATIAN",
|
||||
residentName: "Almarhum Warga Desa",
|
||||
eventDate: new Date("2025-02-20"),
|
||||
description: "Meninggal dunia karena sakit",
|
||||
documentedBy: userId,
|
||||
},
|
||||
{
|
||||
type: "KEDATANGAN",
|
||||
residentName: "Pendatang Baru",
|
||||
eventDate: new Date("2025-03-01"),
|
||||
description: "Pindah masuk dari desa lain",
|
||||
documentedBy: userId,
|
||||
},
|
||||
];
|
||||
|
||||
for (const dynamic of populationDynamics) {
|
||||
await prisma.populationDynamic.create({
|
||||
data: dynamic,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Population Dynamics seeded successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed Budget Transactions
|
||||
* Creates sample financial transactions
|
||||
*/
|
||||
export async function seedBudgetTransactions(userId: string) {
|
||||
console.log("Seeding Budget Transactions...");
|
||||
|
||||
const transactions = [
|
||||
{
|
||||
transactionNumber: "TRX-2025-001",
|
||||
type: "PENGELUARAN",
|
||||
category: "Infrastruktur",
|
||||
amount: 50000000,
|
||||
description: "Pembangunan jalan desa",
|
||||
date: new Date("2025-01-10"),
|
||||
createdBy: userId,
|
||||
},
|
||||
{
|
||||
transactionNumber: "TRX-2025-002",
|
||||
type: "PENDAPATAN",
|
||||
category: "Dana Desa",
|
||||
amount: 500000000,
|
||||
description: "Penyaluran dana desa Q1",
|
||||
date: new Date("2025-01-05"),
|
||||
createdBy: userId,
|
||||
},
|
||||
];
|
||||
|
||||
for (const transaction of transactions) {
|
||||
await prisma.budgetTransaction.create({
|
||||
data: transaction,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Budget Transactions seeded successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed All Phase 2 Data
|
||||
* Main function to run all Phase 2 seeders
|
||||
*/
|
||||
export async function seedPhase2(banjarIds: string[], userId: string) {
|
||||
await seedUmkm(banjarIds);
|
||||
await seedPosyandu(userId);
|
||||
await seedSecurityReports(userId);
|
||||
await seedEmploymentRecords();
|
||||
await seedPopulationDynamics(userId);
|
||||
await seedBudgetTransactions(userId);
|
||||
}
|
||||
393
prisma/seeders/seed-public-services.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import {
|
||||
ComplaintCategory,
|
||||
ComplaintStatus,
|
||||
EventType,
|
||||
Priority,
|
||||
PrismaClient,
|
||||
} from "../../generated/prisma";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Get Complaint IDs
|
||||
* Helper function to retrieve complaint IDs for other seeders
|
||||
*/
|
||||
export async function getComplaintIds(): Promise<string[]> {
|
||||
const complaints = await prisma.complaint.findMany();
|
||||
return complaints.map((c) => c.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed Complaints
|
||||
* Creates sample citizen complaints spread across 7 months for trend visualization
|
||||
*/
|
||||
export async function seedComplaints(adminId: string) {
|
||||
console.log("Seeding Complaints...");
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const complaints = [
|
||||
// Recent complaints (this month)
|
||||
{
|
||||
complaintNumber: `COMP-20260327-001`,
|
||||
title: "Lampu Jalan Mati",
|
||||
description:
|
||||
"Lampu jalan di depan Balai Banjar Manesa mati sejak 3 hari lalu.",
|
||||
category: ComplaintCategory.INFRASTRUKTUR,
|
||||
status: ComplaintStatus.BARU,
|
||||
priority: Priority.SEDANG,
|
||||
location: "Banjar Manesa",
|
||||
reporterId: adminId,
|
||||
createdAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), // 5 days ago
|
||||
},
|
||||
{
|
||||
complaintNumber: `COMP-20260325-002`,
|
||||
title: "Sampah Menumpuk",
|
||||
description: "Tumpukan sampah di area pasar Darmasaba belum diangkut.",
|
||||
category: ComplaintCategory.KETERTIBAN_UMUM,
|
||||
status: ComplaintStatus.DIPROSES,
|
||||
priority: Priority.TINGGI,
|
||||
location: "Pasar Darmasaba",
|
||||
assignedTo: adminId,
|
||||
createdAt: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), // 7 days ago
|
||||
},
|
||||
{
|
||||
complaintNumber: `COMP-20260320-003`,
|
||||
title: "Jalan Rusak",
|
||||
description: "Jalan di Banjar Cabe rusak dan berlubang.",
|
||||
category: ComplaintCategory.INFRASTRUKTUR,
|
||||
status: ComplaintStatus.SELESAI,
|
||||
priority: Priority.TINGGI,
|
||||
location: "Banjar Cabe",
|
||||
assignedTo: adminId,
|
||||
resolvedBy: adminId,
|
||||
resolvedAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000),
|
||||
createdAt: new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000), // 12 days ago
|
||||
},
|
||||
// Last month (February 2026)
|
||||
{
|
||||
complaintNumber: `COMP-20260215-004`,
|
||||
title: "Saluran Air Tersumbat",
|
||||
description: "Saluran air di depan rumah warga tersumbat sampah.",
|
||||
category: ComplaintCategory.INFRASTRUKTUR,
|
||||
status: ComplaintStatus.SELESAI,
|
||||
priority: Priority.SEDANG,
|
||||
location: "Banjar Darmasaba",
|
||||
assignedTo: adminId,
|
||||
resolvedBy: adminId,
|
||||
createdAt: new Date(now.getTime() - 45 * 24 * 60 * 60 * 1000), // 45 days ago
|
||||
},
|
||||
{
|
||||
complaintNumber: `COMP-20260210-005`,
|
||||
title: "Parkir Liar",
|
||||
description: "Parkir liar di depan pasar mengganggu lalu lintas.",
|
||||
category: ComplaintCategory.KETERTIBAN_UMUM,
|
||||
status: ComplaintStatus.SELESAI,
|
||||
priority: Priority.RENDAH,
|
||||
location: "Pasar Darmasaba",
|
||||
assignedTo: adminId,
|
||||
resolvedBy: adminId,
|
||||
createdAt: new Date(now.getTime() - 50 * 24 * 60 * 60 * 1000), // 50 days ago
|
||||
},
|
||||
// January 2026
|
||||
{
|
||||
complaintNumber: `COMP-20260120-006`,
|
||||
title: "Penerangan Jalan Umum Rusak",
|
||||
description: "5 titik lampu jalan di Jl. Raya Darmasaba tidak menyala.",
|
||||
category: ComplaintCategory.INFRASTRUKTUR,
|
||||
status: ComplaintStatus.SELESAI,
|
||||
priority: Priority.TINGGI,
|
||||
location: "Jl. Raya Darmasaba",
|
||||
assignedTo: adminId,
|
||||
resolvedBy: adminId,
|
||||
createdAt: new Date(now.getTime() - 70 * 24 * 60 * 60 * 1000), // 70 days ago
|
||||
},
|
||||
{
|
||||
complaintNumber: `COMP-20260115-007`,
|
||||
title: "Pelayanan Administrasi Lambat",
|
||||
description: "Proses pembuatan surat keterangan lambat.",
|
||||
category: ComplaintCategory.ADMINISTRASI,
|
||||
status: ComplaintStatus.SELESAI,
|
||||
priority: Priority.SEDANG,
|
||||
location: "Kantor Desa",
|
||||
assignedTo: adminId,
|
||||
resolvedBy: adminId,
|
||||
createdAt: new Date(now.getTime() - 75 * 24 * 60 * 60 * 1000), // 75 days ago
|
||||
},
|
||||
// December 2025
|
||||
{
|
||||
complaintNumber: `COMP-20251210-008`,
|
||||
title: "Jembatan Rusak Ringan",
|
||||
description: "Pagar jembatan di Banjar Penenjoan rusak.",
|
||||
category: ComplaintCategory.INFRASTRUKTUR,
|
||||
status: ComplaintStatus.SELESAI,
|
||||
priority: Priority.SEDANG,
|
||||
location: "Banjar Penenjoan",
|
||||
assignedTo: adminId,
|
||||
resolvedBy: adminId,
|
||||
createdAt: new Date(now.getTime() - 110 * 24 * 60 * 60 * 1000), // 110 days ago
|
||||
},
|
||||
{
|
||||
complaintNumber: `COMP-20251205-009`,
|
||||
title: "Suara Bising Kegiatan Malam",
|
||||
description: "Kegiatan karaoke malam hari mengganggu ketenangan.",
|
||||
category: ComplaintCategory.KETERTIBAN_UMUM,
|
||||
status: ComplaintStatus.SELESAI,
|
||||
priority: Priority.RENDAH,
|
||||
location: "Banjar Baler Pasar",
|
||||
assignedTo: adminId,
|
||||
resolvedBy: adminId,
|
||||
createdAt: new Date(now.getTime() - 115 * 24 * 60 * 60 * 1000), // 115 days ago
|
||||
},
|
||||
// November 2025
|
||||
{
|
||||
complaintNumber: `COMP-20251115-010`,
|
||||
title: "Genangan Air Saat Hujan",
|
||||
description: "Jalan utama tergenang air saat hujan deras.",
|
||||
category: ComplaintCategory.INFRASTRUKTUR,
|
||||
status: ComplaintStatus.SELESAI,
|
||||
priority: Priority.TINGGI,
|
||||
location: "Jl. Raya Cabe",
|
||||
assignedTo: adminId,
|
||||
resolvedBy: adminId,
|
||||
createdAt: new Date(now.getTime() - 135 * 24 * 60 * 60 * 1000), // 135 days ago
|
||||
},
|
||||
// October 2025
|
||||
{
|
||||
complaintNumber: `COMP-20251020-011`,
|
||||
title: "Pungli Pelayanan KTP",
|
||||
description: "Ada oknum yang meminta biaya tambahan untuk KTP.",
|
||||
category: ComplaintCategory.ADMINISTRASI,
|
||||
status: ComplaintStatus.SELESAI,
|
||||
priority: Priority.DARURAT,
|
||||
location: "Kantor Desa",
|
||||
assignedTo: adminId,
|
||||
resolvedBy: adminId,
|
||||
createdAt: new Date(now.getTime() - 160 * 24 * 60 * 60 * 1000), // 160 days ago
|
||||
},
|
||||
// September 2025
|
||||
{
|
||||
complaintNumber: `COMP-20250915-012`,
|
||||
title: "Tanah Longsor",
|
||||
description: "Tanah longsor di tepi jalan Banjar Bucu.",
|
||||
category: ComplaintCategory.INFRASTRUKTUR,
|
||||
status: ComplaintStatus.SELESAI,
|
||||
priority: Priority.DARURAT,
|
||||
location: "Banjar Bucu",
|
||||
assignedTo: adminId,
|
||||
resolvedBy: adminId,
|
||||
createdAt: new Date(now.getTime() - 195 * 24 * 60 * 60 * 1000), // 195 days ago
|
||||
},
|
||||
];
|
||||
|
||||
for (const comp of complaints) {
|
||||
await prisma.complaint.upsert({
|
||||
where: { complaintNumber: comp.complaintNumber },
|
||||
update: comp,
|
||||
create: comp,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
"✅ Complaints seeded successfully (12 complaints across 7 months)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed Service Letters
|
||||
* Creates sample administrative letter requests with dates spread across 6 months
|
||||
*/
|
||||
export async function seedServiceLetters(adminId: string) {
|
||||
console.log("Seeding Service Letters...");
|
||||
|
||||
const now = new Date();
|
||||
const serviceLetters = [
|
||||
{
|
||||
letterNumber: "SKT-2025-001",
|
||||
letterType: "KTP",
|
||||
applicantName: "I Wayan Sudarsana",
|
||||
applicantNik: "5103010101700001",
|
||||
applicantAddress: "Jl. Raya Darmasaba No. 1",
|
||||
purpose: "Pembuatan KTP baru",
|
||||
status: "SELESAI",
|
||||
processedBy: adminId,
|
||||
completedAt: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000), // 2 days ago
|
||||
createdAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), // 5 days ago (this week!)
|
||||
},
|
||||
{
|
||||
letterNumber: "SKT-2025-002",
|
||||
letterType: "KK",
|
||||
applicantName: "Ni Made Arianti",
|
||||
applicantNik: "5103010101850002",
|
||||
applicantAddress: "Gg. Manesa No. 5",
|
||||
purpose: "Perubahan data KK",
|
||||
status: "DIPROSES",
|
||||
processedBy: adminId,
|
||||
createdAt: new Date(now.getTime() - 45 * 24 * 60 * 60 * 1000), // 45 days ago
|
||||
},
|
||||
{
|
||||
letterNumber: "SKT-2025-003",
|
||||
letterType: "DOMISILI",
|
||||
applicantName: "I Ketut Arsana",
|
||||
applicantNik: "5103010101900003",
|
||||
applicantAddress: "Jl. Cabe No. 10",
|
||||
purpose: "Surat keterangan domisili",
|
||||
status: "BARU",
|
||||
createdAt: new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000), // 90 days ago
|
||||
},
|
||||
{
|
||||
letterNumber: "SKT-2024-004",
|
||||
letterType: "USAHA",
|
||||
applicantName: "Made Wijaya",
|
||||
applicantNik: "5103010101950004",
|
||||
applicantAddress: "Jl. Penenjoan No. 15",
|
||||
purpose: "Surat keterangan usaha",
|
||||
status: "SELESAI",
|
||||
processedBy: adminId,
|
||||
completedAt: new Date(now.getTime() - 120 * 24 * 60 * 60 * 1000), // 120 days ago
|
||||
createdAt: new Date(now.getTime() - 130 * 24 * 60 * 60 * 1000), // 130 days ago
|
||||
},
|
||||
{
|
||||
letterNumber: "SKT-2024-005",
|
||||
letterType: "KETERANGAN_TIDAK_MAMPU",
|
||||
applicantName: "Putu Sari",
|
||||
applicantNik: "5103010101980005",
|
||||
applicantAddress: "Gg. Bucu No. 8",
|
||||
purpose: "Keterangan tidak mampu untuk beasiswa",
|
||||
status: "SELESAI",
|
||||
processedBy: adminId,
|
||||
completedAt: new Date(now.getTime() - 150 * 24 * 60 * 60 * 1000), // 150 days ago
|
||||
createdAt: new Date(now.getTime() - 160 * 24 * 60 * 60 * 1000), // 160 days ago
|
||||
},
|
||||
];
|
||||
|
||||
for (const letter of serviceLetters) {
|
||||
const existing = await prisma.serviceLetter.findUnique({
|
||||
where: { letterNumber: letter.letterNumber },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.serviceLetter.update({
|
||||
where: { letterNumber: letter.letterNumber },
|
||||
data: letter,
|
||||
});
|
||||
} else {
|
||||
await prisma.serviceLetter.create({
|
||||
data: letter,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ Service Letters seeded successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed Events
|
||||
* Creates sample village events and meetings
|
||||
*/
|
||||
export async function seedEvents(adminId: string) {
|
||||
console.log("Seeding Events...");
|
||||
|
||||
const events = [
|
||||
{
|
||||
title: "Rapat Pleno Desa",
|
||||
description: "Pembahasan anggaran belanja desa",
|
||||
eventType: EventType.RAPAT,
|
||||
startDate: new Date(),
|
||||
location: "Balai Desa Darmasaba",
|
||||
createdBy: adminId,
|
||||
},
|
||||
{
|
||||
title: "Gotong Royong Kebersihan",
|
||||
description: "Kegiatan rutin mingguan",
|
||||
eventType: EventType.SOSIAL,
|
||||
startDate: new Date(Date.now() + 86400000), // Besok
|
||||
location: "Seluruh Banjar",
|
||||
createdBy: adminId,
|
||||
},
|
||||
];
|
||||
|
||||
for (const event of events) {
|
||||
await prisma.event.create({
|
||||
data: event,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Events seeded successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed Innovation Ideas
|
||||
* Creates sample citizen innovation submissions
|
||||
*/
|
||||
export async function seedInnovationIdeas(adminId: string) {
|
||||
console.log("Seeding Innovation Ideas...");
|
||||
|
||||
const innovationIdeas = [
|
||||
{
|
||||
title: "Sistem Informasi Desa Digital",
|
||||
description: "Platform digital untuk layanan administrasi desa",
|
||||
category: "Teknologi",
|
||||
submitterName: "I Made Wijaya",
|
||||
submitterContact: "081234567890",
|
||||
status: "DIKAJI",
|
||||
reviewedBy: adminId,
|
||||
notes: "Perlu kajian lebih lanjut tentang anggaran",
|
||||
},
|
||||
{
|
||||
title: "Program Bank Sampah",
|
||||
description: "Pengelolaan sampah berbasis bank sampah",
|
||||
category: "Lingkungan",
|
||||
submitterName: "Ni Putu Sari",
|
||||
submitterContact: "081234567891",
|
||||
status: "BARU",
|
||||
},
|
||||
];
|
||||
|
||||
for (const idea of innovationIdeas) {
|
||||
await prisma.innovationIdea.create({
|
||||
data: idea,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Innovation Ideas seeded successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed Complaint Updates
|
||||
* Creates status update history for complaints
|
||||
*/
|
||||
export async function seedComplaintUpdates(
|
||||
complaintIds: string[],
|
||||
userId: string,
|
||||
) {
|
||||
console.log("Seeding Complaint Updates...");
|
||||
|
||||
if (complaintIds.length === 0) {
|
||||
console.log("⏭️ No complaints found, skipping updates");
|
||||
return;
|
||||
}
|
||||
|
||||
const updates = [
|
||||
{
|
||||
complaintId: complaintIds[0],
|
||||
message: "Laporan diterima, akan segera ditindaklanjuti",
|
||||
status: ComplaintStatus.BARU,
|
||||
updatedBy: userId,
|
||||
},
|
||||
{
|
||||
complaintId: complaintIds[1],
|
||||
message: "Tim kebersihan telah dikirim ke lokasi",
|
||||
status: ComplaintStatus.DIPROSES,
|
||||
updatedBy: userId,
|
||||
},
|
||||
];
|
||||
|
||||
for (const update of updates) {
|
||||
await prisma.complaintUpdate.create({
|
||||
data: update,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Complaint Updates seeded successfully");
|
||||
}
|
||||
BIN
public/SDGS-1.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
public/SDGS-16.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
public/SDGS-3.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
public/SDGS-7.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
public/light-mode.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
public/logo-desa-plus.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/white.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
58
scripts/build.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Build script for production
|
||||
* 1. Build CSS with PostCSS/Tailwind
|
||||
* 2. Bundle JS with Bun (without CSS)
|
||||
* 3. Replace CSS reference in HTML
|
||||
*/
|
||||
|
||||
import { $ } from "bun";
|
||||
import fs from "node:fs";
|
||||
import postcss from "postcss";
|
||||
import tailwindcss from "@tailwindcss/postcss";
|
||||
import autoprefixer from "autoprefixer";
|
||||
|
||||
console.log("🔨 Starting production build...");
|
||||
|
||||
// Ensure dist directory exists
|
||||
if (!fs.existsSync("./dist")) {
|
||||
fs.mkdirSync("./dist", { recursive: true });
|
||||
}
|
||||
|
||||
// Step 1: Build CSS with PostCSS
|
||||
console.log("🎨 Building CSS...");
|
||||
const cssInput = fs.readFileSync("./src/index.css", "utf-8");
|
||||
const cssResult = await postcss([tailwindcss(), autoprefixer()]).process(
|
||||
cssInput,
|
||||
{
|
||||
from: "./src/index.css",
|
||||
to: "./dist/index.css",
|
||||
},
|
||||
);
|
||||
|
||||
fs.writeFileSync("./dist/index.css", cssResult.css);
|
||||
console.log("✅ CSS built successfully!");
|
||||
|
||||
// Step 2: Build JS with Bun (build HTML too, we'll fix CSS link later)
|
||||
console.log("📦 Bundling JavaScript...");
|
||||
await $`bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='"production"' --env='VITE_*'`;
|
||||
|
||||
// Step 3: Copy public assets
|
||||
console.log("📁 Copying public assets...");
|
||||
if (fs.existsSync("./public")) {
|
||||
await $`cp -r public/* dist/ 2>/dev/null || true`;
|
||||
}
|
||||
|
||||
// Step 4: Ensure HTML references the correct CSS
|
||||
// Bun build might have renamed the CSS, we want to use our own index.css
|
||||
console.log("🔧 Fixing HTML CSS reference...");
|
||||
const htmlPath = "./dist/index.html";
|
||||
if (fs.existsSync(htmlPath)) {
|
||||
let html = fs.readFileSync(htmlPath, "utf-8");
|
||||
// Replace any bundled CSS reference with our index.css
|
||||
html = html.replace(/href="[^"]*\.css"/g, 'href="/index.css"');
|
||||
fs.writeFileSync(htmlPath, html);
|
||||
}
|
||||
|
||||
console.log("✅ Build completed successfully!");
|
||||
22
scripts/check-sync-data.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { prisma } from "../src/utils/db";
|
||||
|
||||
async function check() {
|
||||
console.log("--- Checking Division Data in DB ---");
|
||||
const divisions = await prisma.division.findMany({
|
||||
select: {
|
||||
name: true,
|
||||
externalActivityCount: true,
|
||||
}
|
||||
});
|
||||
console.table(divisions);
|
||||
|
||||
console.log("\n--- Checking API Response for /api/division/ ---");
|
||||
// Mocking the mapping logic from src/api/division.ts
|
||||
const formatted = divisions.map(d => ({
|
||||
name: d.name,
|
||||
activityCount: d.externalActivityCount
|
||||
}));
|
||||
console.table(formatted);
|
||||
}
|
||||
|
||||
check().catch(console.error).finally(() => prisma.$disconnect());
|
||||
38
scripts/inspect-noc-data.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { nocExternalClient } from "../src/utils/noc-external-client";
|
||||
|
||||
async function inspect() {
|
||||
const ID_DESA = "desa1";
|
||||
console.log("Checking NOC API Data structure...");
|
||||
|
||||
const endpoints = [
|
||||
"/api/noc/active-divisions",
|
||||
"/api/noc/latest-projects",
|
||||
"/api/noc/upcoming-events",
|
||||
"/api/noc/latest-discussion"
|
||||
];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
console.log(`\n--- Endpoint: ${endpoint} ---`);
|
||||
try {
|
||||
const { data, error } = await (nocExternalClient as any).GET(endpoint, {
|
||||
params: { query: { idDesa: ID_DESA, limit: "1" } }
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(`Error fetching ${endpoint}:`, error);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data && data.data && data.data.length > 0) {
|
||||
console.log("Sample Data Object Keys:", Object.keys(data.data[0]));
|
||||
console.log("Sample Data Object Values:", JSON.stringify(data.data[0], null, 2));
|
||||
} else {
|
||||
console.log("No data returned or data is empty.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch ${endpoint}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inspect();
|
||||
38
scripts/reset-noc-data.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { prisma } from "../src/utils/db";
|
||||
import logger from "../src/utils/logger";
|
||||
|
||||
async function resetNocData() {
|
||||
try {
|
||||
logger.info("Starting NOC Data Reset...");
|
||||
|
||||
// Delete in order to respect relations
|
||||
// 1. Delete Activities (though Division cascade might handle it, let's be explicit)
|
||||
const deletedActivities = await prisma.activity.deleteMany({});
|
||||
logger.info(`Deleted ${deletedActivities.count} activities`);
|
||||
|
||||
// 2. Delete Documents
|
||||
const deletedDocuments = await prisma.document.deleteMany({});
|
||||
logger.info(`Deleted ${deletedDocuments.count} documents`);
|
||||
|
||||
// 3. Delete Discussions
|
||||
const deletedDiscussions = await prisma.discussion.deleteMany({});
|
||||
logger.info(`Deleted ${deletedDiscussions.count} discussions`);
|
||||
|
||||
// 4. Delete Events
|
||||
const deletedEvents = await prisma.event.deleteMany({});
|
||||
logger.info(`Deleted ${deletedEvents.count} events`);
|
||||
|
||||
// 5. Delete Divisions
|
||||
const deletedDivisions = await prisma.division.deleteMany({});
|
||||
logger.info(`Deleted ${deletedDivisions.count} divisions`);
|
||||
|
||||
logger.info("NOC Data Reset Completed Successfully");
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Error during NOC data reset");
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
resetNocData();
|
||||
261
scripts/sync-noc.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { prisma } from "../src/utils/db";
|
||||
import { nocExternalClient } from "../src/utils/noc-external-client";
|
||||
import logger from "../src/utils/logger";
|
||||
|
||||
const ID_DESA = "desa1";
|
||||
|
||||
/**
|
||||
* Helper untuk mendapatkan system user ID untuk relasi
|
||||
*/
|
||||
async function getSystemUserId() {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { role: "admin" },
|
||||
});
|
||||
if (!user) {
|
||||
// Buat system user jika tidak ada
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
email: "system@desa1.id",
|
||||
name: "System Sync",
|
||||
role: "admin",
|
||||
},
|
||||
});
|
||||
return newUser.id;
|
||||
}
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Sync Divisions
|
||||
*/
|
||||
async function syncActiveDivisions() {
|
||||
logger.info("Syncing Divisions...");
|
||||
const { data, error } = await nocExternalClient.GET("/api/noc/active-divisions", {
|
||||
params: { query: { idDesa: ID_DESA } },
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
logger.error({ error }, "Failed to fetch divisions from NOC");
|
||||
return;
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: External API response is untyped
|
||||
const resData = (data as any).data;
|
||||
const divisions = Array.isArray(resData) ? resData : (resData?.divisi || []);
|
||||
|
||||
if (!Array.isArray(divisions)) {
|
||||
logger.warn({ data }, "Divisions data from NOC is not an array");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const div of divisions) {
|
||||
const name = div.name || div.division;
|
||||
const extId = div.id || div.externalId || `div-${name.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
|
||||
await prisma.division.upsert({
|
||||
where: { name: name },
|
||||
update: {
|
||||
externalId: extId,
|
||||
color: div.color || "#1E3A5F",
|
||||
villageId: ID_DESA,
|
||||
externalActivityCount: div.totalKegiatan || 0,
|
||||
},
|
||||
create: {
|
||||
externalId: extId,
|
||||
name: name,
|
||||
color: div.color || "#1E3A5F",
|
||||
villageId: ID_DESA,
|
||||
externalActivityCount: div.totalKegiatan || 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
logger.info(`Synced ${divisions.length} divisions`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. Sync Activities
|
||||
*/
|
||||
async function syncLatestProjects() {
|
||||
logger.info("Syncing Activities...");
|
||||
const { data, error } = await nocExternalClient.GET("/api/noc/latest-projects", {
|
||||
params: { query: { idDesa: ID_DESA, limit: "50" } },
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
logger.error({ error }, "Failed to fetch projects from NOC");
|
||||
return;
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: External API response
|
||||
const resData = (data as any).data;
|
||||
const projects = Array.isArray(resData) ? resData : (resData?.projects || []);
|
||||
|
||||
if (!Array.isArray(projects)) {
|
||||
logger.warn({ data }, "Projects data from NOC is not an array");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const proj of projects) {
|
||||
const extId = proj.id || proj.externalId || `proj-${proj.title.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
|
||||
// Temukan divisi lokal berdasarkan nama atau externalId
|
||||
const divisionName = proj.divisionName || proj.group;
|
||||
const division = await prisma.division.findFirst({
|
||||
where: { name: divisionName },
|
||||
});
|
||||
|
||||
if (!division) continue;
|
||||
|
||||
await prisma.activity.upsert({
|
||||
where: { externalId: extId },
|
||||
update: {
|
||||
title: proj.title,
|
||||
status: (typeof proj.status === 'number' ? (proj.status === 2 ? 'Completed' : 'OnProgress') : proj.status) as any,
|
||||
progress: proj.progress || (proj.status === 2 ? 100 : 50),
|
||||
divisionId: division.id,
|
||||
villageId: ID_DESA,
|
||||
},
|
||||
create: {
|
||||
externalId: extId,
|
||||
title: proj.title,
|
||||
status: (typeof proj.status === 'number' ? (proj.status === 2 ? 'Completed' : 'OnProgress') : proj.status) as any,
|
||||
progress: proj.progress || (proj.status === 2 ? 100 : 50),
|
||||
divisionId: division.id,
|
||||
villageId: ID_DESA,
|
||||
},
|
||||
});
|
||||
}
|
||||
logger.info(`Synced ${projects.length} activities`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. Sync Events
|
||||
*/
|
||||
async function syncUpcomingEvents() {
|
||||
logger.info("Syncing Events...");
|
||||
const systemUserId = await getSystemUserId();
|
||||
const { data, error } = await nocExternalClient.GET("/api/noc/upcoming-events", {
|
||||
params: { query: { idDesa: ID_DESA, limit: "50" } },
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
logger.error({ error }, "Failed to fetch events from NOC");
|
||||
return;
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: External API response
|
||||
const resData = (data as any).data;
|
||||
let events: any[] = [];
|
||||
if (Array.isArray(resData)) {
|
||||
events = resData;
|
||||
} else if (resData?.today || resData?.upcoming) {
|
||||
events = [...(resData.today || []), ...(resData.upcoming || [])];
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
const extId = event.id || event.externalId || `event-${event.title.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
await prisma.event.upsert({
|
||||
where: { externalId: extId },
|
||||
update: {
|
||||
title: event.title,
|
||||
startDate: new Date(event.startDate || event.date),
|
||||
location: event.location || "N/A",
|
||||
eventType: (event.eventType || "Meeting") as any,
|
||||
villageId: ID_DESA,
|
||||
},
|
||||
create: {
|
||||
externalId: extId,
|
||||
title: event.title,
|
||||
startDate: new Date(event.startDate || event.date),
|
||||
location: event.location || "N/A",
|
||||
eventType: (event.eventType || "Meeting") as any,
|
||||
createdBy: systemUserId,
|
||||
villageId: ID_DESA,
|
||||
},
|
||||
});
|
||||
}
|
||||
logger.info(`Synced ${events.length} events`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. Sync Discussions
|
||||
*/
|
||||
async function syncLatestDiscussion() {
|
||||
logger.info("Syncing Discussions...");
|
||||
const systemUserId = await getSystemUserId();
|
||||
const { data, error } = await nocExternalClient.GET("/api/noc/latest-discussion", {
|
||||
params: { query: { idDesa: ID_DESA, limit: "50" } },
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
logger.error({ error }, "Failed to fetch discussions from NOC");
|
||||
return;
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: External API response
|
||||
const resData = (data as any).data;
|
||||
const discussions = Array.isArray(resData) ? resData : (resData?.discussions || resData?.data || []);
|
||||
|
||||
if (!Array.isArray(discussions)) {
|
||||
logger.warn({ data }, "Discussions data from NOC is not an array");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const disc of discussions) {
|
||||
const division = await prisma.division.findFirst({
|
||||
where: { name: disc.divisionName || disc.group },
|
||||
});
|
||||
|
||||
await prisma.discussion.upsert({
|
||||
where: { externalId: disc.id },
|
||||
update: {
|
||||
message: disc.message || disc.desc || disc.title,
|
||||
divisionId: division?.id,
|
||||
villageId: ID_DESA,
|
||||
},
|
||||
create: {
|
||||
externalId: disc.id,
|
||||
message: disc.message || disc.desc || disc.title,
|
||||
senderId: systemUserId,
|
||||
divisionId: division?.id,
|
||||
villageId: ID_DESA,
|
||||
},
|
||||
});
|
||||
}
|
||||
logger.info(`Synced ${discussions.length} discussions`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. Update lastSyncedAt timestamp
|
||||
*/
|
||||
async function syncLastTimestamp() {
|
||||
logger.info("Updating sync timestamp...");
|
||||
await prisma.division.updateMany({
|
||||
where: { villageId: ID_DESA },
|
||||
data: { lastSyncedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main Sync Function
|
||||
*/
|
||||
async function main() {
|
||||
try {
|
||||
logger.info("Starting NOC Data Synchronization...");
|
||||
|
||||
await syncActiveDivisions();
|
||||
await syncLatestProjects();
|
||||
await syncUpcomingEvents();
|
||||
await syncLatestDiscussion();
|
||||
await syncLastTimestamp();
|
||||
|
||||
logger.info("NOC Data Synchronization Completed Successfully");
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Fatal error during NOC synchronization");
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
216
src/api/complaint.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import { prisma } from "../utils/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
export const complaint = new Elysia({
|
||||
prefix: "/complaint",
|
||||
})
|
||||
.get(
|
||||
"/stats",
|
||||
async ({ set }) => {
|
||||
try {
|
||||
const [total, baru, proses, selesai] = await Promise.all([
|
||||
prisma.complaint.count(),
|
||||
prisma.complaint.count({ where: { status: "BARU" } }),
|
||||
prisma.complaint.count({ where: { status: "DIPROSES" } }),
|
||||
prisma.complaint.count({ where: { status: "SELESAI" } }),
|
||||
]);
|
||||
return { data: { total, baru, proses, selesai } };
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fetch complaint stats");
|
||||
set.status = 500;
|
||||
return { error: "Internal Server Error" };
|
||||
}
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Object({
|
||||
total: t.Number(),
|
||||
baru: t.Number(),
|
||||
proses: t.Number(),
|
||||
selesai: t.Number(),
|
||||
}),
|
||||
}),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
detail: { summary: "Get complaint statistics" },
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/recent",
|
||||
async ({ set }) => {
|
||||
try {
|
||||
const recent = await prisma.complaint.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
});
|
||||
return { data: recent };
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fetch recent complaints");
|
||||
set.status = 500;
|
||||
return { error: "Internal Server Error" };
|
||||
}
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(t.Any()),
|
||||
}),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
detail: { summary: "Get recent complaints" },
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/trends",
|
||||
async ({ set }) => {
|
||||
try {
|
||||
// Get last 7 months complaint trends
|
||||
const trends = await prisma.$queryRaw<
|
||||
{ month: string; month_num: number; count: number }[]
|
||||
>`
|
||||
SELECT
|
||||
TO_CHAR("createdAt", 'Mon') as month,
|
||||
EXTRACT(MONTH FROM "createdAt") as month_num,
|
||||
COUNT(*)::INTEGER as count
|
||||
FROM complaint
|
||||
WHERE "createdAt" > NOW() - INTERVAL '7 months'
|
||||
GROUP BY month, month_num
|
||||
ORDER BY month_num ASC
|
||||
`;
|
||||
return { data: trends };
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fetch complaint trends");
|
||||
set.status = 500;
|
||||
return { error: "Internal Server Error" };
|
||||
}
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(t.Any()),
|
||||
}),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
detail: { summary: "Get complaint trends for last 7 months" },
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/service-stats",
|
||||
async ({ set }) => {
|
||||
try {
|
||||
const serviceStats = await prisma.serviceLetter.groupBy({
|
||||
by: ["letterType"],
|
||||
_count: { _all: true },
|
||||
});
|
||||
return { data: serviceStats };
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fetch service stats");
|
||||
set.status = 500;
|
||||
return { error: "Internal Server Error" };
|
||||
}
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(t.Any()),
|
||||
}),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
detail: { summary: "Get service letter statistics by type" },
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/innovation-ideas",
|
||||
async ({ set }) => {
|
||||
try {
|
||||
const ideas = await prisma.innovationIdea.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 5,
|
||||
});
|
||||
return { data: ideas };
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fetch innovation ideas");
|
||||
set.status = 500;
|
||||
return { error: "Internal Server Error" };
|
||||
}
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(t.Any()),
|
||||
}),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
detail: { summary: "Get recent innovation ideas" },
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/service-trends",
|
||||
async ({ set }) => {
|
||||
try {
|
||||
// Get last 6 months trends for service letters
|
||||
const trends = await prisma.$queryRaw<
|
||||
{ month: string; month_num: number; count: number }[]
|
||||
>`
|
||||
SELECT
|
||||
TO_CHAR("createdAt", 'Mon') as month,
|
||||
EXTRACT(MONTH FROM "createdAt") as month_num,
|
||||
COUNT(*)::INTEGER as count
|
||||
FROM service_letter
|
||||
WHERE "createdAt" > NOW() - INTERVAL '6 months'
|
||||
GROUP BY month, month_num
|
||||
ORDER BY month_num ASC
|
||||
`;
|
||||
return { data: trends };
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fetch service trends");
|
||||
set.status = 500;
|
||||
return { error: "Internal Server Error" };
|
||||
}
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(t.Any()),
|
||||
}),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
detail: { summary: "Get service letter trends for last 6 months" },
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/service-weekly",
|
||||
async ({ set }) => {
|
||||
try {
|
||||
const startOfWeek = new Date();
|
||||
startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay());
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
|
||||
const count = await prisma.serviceLetter.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: startOfWeek,
|
||||
},
|
||||
},
|
||||
});
|
||||
return { data: { count } };
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fetch weekly service stats");
|
||||
set.status = 500;
|
||||
return { error: "Internal Server Error" };
|
||||
}
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Object({
|
||||
count: t.Number(),
|
||||
}),
|
||||
}),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
detail: { summary: "Get service letter count for current week" },
|
||||
},
|
||||
);
|
||||
72
src/api/dashboard.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { prisma } from "../utils/db";
|
||||
|
||||
export const dashboard = new Elysia({ prefix: "/dashboard" })
|
||||
.get(
|
||||
"/budget",
|
||||
async () => {
|
||||
const data = await prisma.budget.findMany({
|
||||
where: { fiscalYear: 2025 },
|
||||
orderBy: { category: "asc" },
|
||||
});
|
||||
return { data };
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(
|
||||
t.Object({
|
||||
category: t.String(),
|
||||
amount: t.Number(),
|
||||
percentage: t.Number(),
|
||||
color: t.String(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/sdgs",
|
||||
async () => {
|
||||
const data = await prisma.sdgsScore.findMany({
|
||||
orderBy: { score: "desc" },
|
||||
});
|
||||
return { data };
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(
|
||||
t.Object({
|
||||
title: t.String(),
|
||||
score: t.Number(),
|
||||
image: t.Nullable(t.String()),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/satisfaction",
|
||||
async () => {
|
||||
const data = await prisma.satisfactionRating.findMany({
|
||||
orderBy: { value: "desc" },
|
||||
});
|
||||
return { data };
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(
|
||||
t.Object({
|
||||
category: t.String(),
|
||||
value: t.Number(),
|
||||
color: t.String(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
222
src/api/division.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import { prisma } from "../utils/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
export const division = new Elysia({
|
||||
prefix: "/division",
|
||||
})
|
||||
.get(
|
||||
"/",
|
||||
async ({ set }) => {
|
||||
try {
|
||||
const divisions = await prisma.division.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { activities: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
data: divisions.map(d => ({
|
||||
...d,
|
||||
activityCount: d.externalActivityCount || d._count.activities
|
||||
}))
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fetch divisions");
|
||||
set.status = 500;
|
||||
return { error: "Internal Server Error" };
|
||||
}
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(t.Any()),
|
||||
}),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
detail: { summary: "Get all divisions" },
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/activities",
|
||||
async ({ set }) => {
|
||||
try {
|
||||
const activities = await prisma.activity.findMany({
|
||||
include: {
|
||||
division: {
|
||||
select: { name: true, color: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
});
|
||||
return { data: activities };
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fetch activities");
|
||||
set.status = 500;
|
||||
return { error: "Internal Server Error" };
|
||||
}
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(t.Any()),
|
||||
}),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
detail: { summary: "Get recent activities" },
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/activities/stats",
|
||||
async ({ set }) => {
|
||||
try {
|
||||
// Get activity count by status
|
||||
const [selesai, berjalan, tertunda, dibatalkan] = await Promise.all([
|
||||
prisma.activity.count({ where: { status: "SELESAI" } }),
|
||||
prisma.activity.count({ where: { status: "BERJALAN" } }),
|
||||
prisma.activity.count({ where: { status: "TERTUNDA" } }),
|
||||
prisma.activity.count({ where: { status: "DIBATALKAN" } }),
|
||||
]);
|
||||
|
||||
const total = selesai + berjalan + tertunda + dibatalkan;
|
||||
|
||||
// Calculate percentages
|
||||
const percentages = {
|
||||
selesai: total > 0 ? (selesai / total) * 100 : 0,
|
||||
berjalan: total > 0 ? (berjalan / total) * 100 : 0,
|
||||
tertunda: total > 0 ? (tertunda / total) * 100 : 0,
|
||||
dibatalkan: total > 0 ? (dibatalkan / total) * 100 : 0,
|
||||
};
|
||||
|
||||
return {
|
||||
data: {
|
||||
total,
|
||||
counts: { selesai, berjalan, tertunda, dibatalkan },
|
||||
percentages,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fetch activity stats");
|
||||
set.status = 500;
|
||||
return { error: "Internal Server Error" };
|
||||
}
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Object({
|
||||
total: t.Number(),
|
||||
counts: t.Object({
|
||||
selesai: t.Number(),
|
||||
berjalan: t.Number(),
|
||||
tertunda: t.Number(),
|
||||
dibatalkan: t.Number(),
|
||||
}),
|
||||
percentages: t.Object({
|
||||
selesai: t.Number(),
|
||||
berjalan: t.Number(),
|
||||
tertunda: t.Number(),
|
||||
dibatalkan: t.Number(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
detail: { summary: "Get activity statistics by status" },
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/documents/stats",
|
||||
async ({ set }) => {
|
||||
try {
|
||||
// Group documents by type
|
||||
const [gambarCount, dokumenCount] = await Promise.all([
|
||||
prisma.document.count({ where: { type: "Gambar" } }),
|
||||
prisma.document.count({ where: { type: "Dokumen" } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: [
|
||||
{ name: "Gambar", jumlah: gambarCount, color: "#FACC15" },
|
||||
{ name: "Dokumen", jumlah: dokumenCount, color: "#22C55E" },
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fetch document stats");
|
||||
set.status = 500;
|
||||
return { error: "Internal Server Error" };
|
||||
}
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(
|
||||
t.Object({
|
||||
name: t.String(),
|
||||
jumlah: t.Number(),
|
||||
color: t.String(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
detail: { summary: "Get document statistics by type" },
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/discussions",
|
||||
async ({ set }) => {
|
||||
try {
|
||||
// Get recent discussions with sender info
|
||||
const discussions = await prisma.discussion.findMany({
|
||||
where: { parentId: null }, // Only top-level discussions
|
||||
include: {
|
||||
sender: {
|
||||
select: { name: true, email: true },
|
||||
},
|
||||
division: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
});
|
||||
|
||||
// Format for frontend
|
||||
const formattedDiscussions = discussions.map((d) => ({
|
||||
id: d.id,
|
||||
message: d.message,
|
||||
sender: d.sender.name || d.sender.email,
|
||||
date: d.createdAt.toISOString(),
|
||||
division: d.division?.name || null,
|
||||
isResolved: d.isResolved,
|
||||
}));
|
||||
|
||||
return { data: formattedDiscussions };
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fetch discussions");
|
||||
set.status = 500;
|
||||
return { error: "Internal Server Error" };
|
||||
}
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(
|
||||
t.Object({
|
||||
id: t.String(),
|
||||
message: t.String(),
|
||||
sender: t.String(),
|
||||
date: t.String(),
|
||||
division: t.Nullable(t.String()),
|
||||
isResolved: t.Boolean(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
detail: { summary: "Get recent discussions" },
|
||||
},
|
||||
);
|
||||
66
src/api/event.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import { prisma } from "../utils/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
export const event = new Elysia({
|
||||
prefix: "/event",
|
||||
})
|
||||
.get(
|
||||
"/",
|
||||
async ({ set }) => {
|
||||
try {
|
||||
const events = await prisma.event.findMany({
|
||||
orderBy: { startDate: "asc" },
|
||||
take: 20,
|
||||
});
|
||||
return { data: events };
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fetch events");
|
||||
set.status = 500;
|
||||
return { error: "Internal Server Error" };
|
||||
}
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(t.Any()),
|
||||
}),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
detail: { summary: "Get upcoming events" },
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/today",
|
||||
async ({ set }) => {
|
||||
try {
|
||||
const start = new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
const events = await prisma.event.findMany({
|
||||
where: {
|
||||
startDate: {
|
||||
gte: start,
|
||||
lte: end,
|
||||
},
|
||||
},
|
||||
});
|
||||
return { data: events };
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fetch today's events");
|
||||
set.status = 500;
|
||||
return { error: "Internal Server Error" };
|
||||
}
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(t.Any()),
|
||||
}),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
detail: { summary: "Get events for today" },
|
||||
},
|
||||
);
|
||||
@@ -1,10 +1,16 @@
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { swagger } from "@elysiajs/swagger";
|
||||
import Elysia from "elysia";
|
||||
import Elysia, { t } from "elysia";
|
||||
import { apiMiddleware } from "../middleware/apiMiddleware";
|
||||
import { auth } from "../utils/auth";
|
||||
import { apikey } from "./apikey";
|
||||
import { complaint } from "./complaint";
|
||||
import { dashboard } from "./dashboard";
|
||||
import { division } from "./division";
|
||||
import { event } from "./event";
|
||||
import { noc } from "./noc";
|
||||
import { profile } from "./profile";
|
||||
import { resident } from "./resident";
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
|
||||
@@ -12,15 +18,33 @@ const api = new Elysia({
|
||||
prefix: "/api",
|
||||
})
|
||||
.use(cors())
|
||||
.get("/health", () => ({ ok: true }))
|
||||
.all("/auth/*", ({ request }) => auth.handler(request))
|
||||
.get("/session", async ({ request }) => {
|
||||
const data = await auth.api.getSession({ headers: request.headers });
|
||||
return { data };
|
||||
.get("/health", () => ({ ok: true }), {
|
||||
response: {
|
||||
200: t.Object({ ok: t.Boolean() }),
|
||||
},
|
||||
})
|
||||
.all("/auth/*", ({ request }) => auth.handler(request))
|
||||
.get(
|
||||
"/session",
|
||||
async ({ request }) => {
|
||||
const data = await auth.api.getSession({ headers: request.headers });
|
||||
return { data };
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({ data: t.Any() }),
|
||||
},
|
||||
},
|
||||
)
|
||||
.use(apiMiddleware)
|
||||
.use(noc)
|
||||
.use(apikey)
|
||||
.use(profile);
|
||||
.use(profile)
|
||||
.use(division)
|
||||
.use(complaint)
|
||||
.use(resident)
|
||||
.use(event)
|
||||
.use(dashboard);
|
||||
|
||||
if (!isProduction) {
|
||||
api.use(
|
||||
|
||||
330
src/api/noc.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { prisma } from "../utils/db";
|
||||
import { $ } from "bun";
|
||||
|
||||
export const noc = new Elysia({ prefix: "/noc" })
|
||||
.post(
|
||||
"/sync",
|
||||
async ({ set, user }) => {
|
||||
if (!user || user.role !== "admin") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Jalankan script sinkronisasi
|
||||
await $`bun run sync:noc`.quiet();
|
||||
return {
|
||||
success: true,
|
||||
message: "Sinkronisasi berhasil diselesaikan",
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: "Sinkronisasi gagal dijalankan" };
|
||||
}
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
success: t.Boolean(),
|
||||
message: t.Optional(t.String()),
|
||||
error: t.Optional(t.String()),
|
||||
lastSyncedAt: t.Optional(t.String()),
|
||||
}),
|
||||
401: t.Object({ error: t.String() }),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/last-sync",
|
||||
async ({ query }) => {
|
||||
const { idDesa } = query;
|
||||
const latest = await prisma.division.findFirst({
|
||||
where: { villageId: idDesa },
|
||||
select: { lastSyncedAt: true },
|
||||
orderBy: { lastSyncedAt: "desc" },
|
||||
});
|
||||
|
||||
return { lastSyncedAt: latest?.lastSyncedAt?.toISOString() || null };
|
||||
},
|
||||
{
|
||||
query: t.Object({ idDesa: t.String() }),
|
||||
response: {
|
||||
200: t.Object({
|
||||
lastSyncedAt: t.Nullable(t.String()),
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/active-divisions",
|
||||
async ({ query }) => {
|
||||
const { idDesa, limit } = query;
|
||||
const data = await prisma.division.findMany({
|
||||
where: { villageId: idDesa },
|
||||
include: {
|
||||
_count: {
|
||||
select: { activities: true },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
activities: {
|
||||
_count: "desc",
|
||||
},
|
||||
},
|
||||
take: limit ? Number.parseInt(limit) : 5,
|
||||
});
|
||||
|
||||
return {
|
||||
data: data.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
activityCount: d._count.activities,
|
||||
color: d.color,
|
||||
})),
|
||||
};
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
idDesa: t.String(),
|
||||
limit: t.Optional(t.String()),
|
||||
}),
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(
|
||||
t.Object({
|
||||
id: t.String(),
|
||||
name: t.String(),
|
||||
activityCount: t.Number(),
|
||||
color: t.String(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/latest-projects",
|
||||
async ({ query }) => {
|
||||
const { idDesa, limit } = query;
|
||||
const data = await prisma.activity.findMany({
|
||||
where: { villageId: idDesa },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit ? Number.parseInt(limit) : 5,
|
||||
include: { division: true },
|
||||
});
|
||||
|
||||
return {
|
||||
data: data.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
status: a.status,
|
||||
progress: a.progress,
|
||||
divisionName: a.division.name,
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
idDesa: t.String(),
|
||||
limit: t.Optional(t.String()),
|
||||
}),
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(
|
||||
t.Object({
|
||||
id: t.String(),
|
||||
title: t.String(),
|
||||
status: t.String(),
|
||||
progress: t.Number(),
|
||||
divisionName: t.String(),
|
||||
createdAt: t.String(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/upcoming-events",
|
||||
async ({ query }) => {
|
||||
const { idDesa, limit, filter } = query;
|
||||
const now = new Date();
|
||||
const where: any = { villageId: idDesa };
|
||||
|
||||
if (filter === "today") {
|
||||
const startOfDay = new Date(now.setHours(0, 0, 0, 0));
|
||||
const endOfDay = new Date(now.setHours(23, 59, 59, 999));
|
||||
where.startDate = {
|
||||
gte: startOfDay,
|
||||
lte: endOfDay,
|
||||
};
|
||||
} else {
|
||||
where.startDate = {
|
||||
gte: now,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await prisma.event.findMany({
|
||||
where,
|
||||
orderBy: { startDate: "asc" },
|
||||
take: limit ? Number.parseInt(limit) : 5,
|
||||
});
|
||||
|
||||
return {
|
||||
data: data.map((e) => ({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
startDate: e.startDate.toISOString(),
|
||||
location: e.location,
|
||||
eventType: e.eventType,
|
||||
})),
|
||||
};
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
idDesa: t.String(),
|
||||
limit: t.Optional(t.String()),
|
||||
filter: t.Optional(t.String()), // today/upcoming
|
||||
}),
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(
|
||||
t.Object({
|
||||
id: t.String(),
|
||||
title: t.String(),
|
||||
startDate: t.String(),
|
||||
location: t.Nullable(t.String()),
|
||||
eventType: t.String(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/diagram-jumlah-document",
|
||||
async ({ query }) => {
|
||||
const { idDesa } = query;
|
||||
const data = await prisma.document.groupBy({
|
||||
where: { villageId: idDesa },
|
||||
by: ["category"],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: data.map((d) => ({
|
||||
category: d.category,
|
||||
count: d._count._all,
|
||||
})),
|
||||
};
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
idDesa: t.String(),
|
||||
}),
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(
|
||||
t.Object({
|
||||
category: t.String(),
|
||||
count: t.Number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/diagram-progres-kegiatan",
|
||||
async ({ query }) => {
|
||||
const { idDesa } = query;
|
||||
const data = await prisma.activity.groupBy({
|
||||
where: { villageId: idDesa },
|
||||
by: ["status"],
|
||||
_avg: {
|
||||
progress: true,
|
||||
},
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: data.map((d) => ({
|
||||
status: d.status,
|
||||
avgProgress: d._avg.progress || 0,
|
||||
count: d._count._all,
|
||||
})),
|
||||
};
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
idDesa: t.String(),
|
||||
}),
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(
|
||||
t.Object({
|
||||
status: t.String(),
|
||||
avgProgress: t.Number(),
|
||||
count: t.Number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/latest-discussion",
|
||||
async ({ query }) => {
|
||||
const { idDesa, limit } = query;
|
||||
const data = await prisma.discussion.findMany({
|
||||
where: { villageId: idDesa },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit ? Number.parseInt(limit) : 5,
|
||||
include: {
|
||||
sender: {
|
||||
select: { name: true, image: true },
|
||||
},
|
||||
division: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: data.map((d) => ({
|
||||
id: d.id,
|
||||
message: d.message,
|
||||
senderName: d.sender.name || "Anonymous",
|
||||
senderImage: d.sender.image,
|
||||
divisionName: d.division?.name || "General",
|
||||
createdAt: d.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
idDesa: t.String(),
|
||||
limit: t.Optional(t.String()),
|
||||
}),
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(
|
||||
t.Object({
|
||||
id: t.String(),
|
||||
message: t.String(),
|
||||
senderName: t.String(),
|
||||
senderImage: t.Nullable(t.String()),
|
||||
divisionName: t.String(),
|
||||
createdAt: t.String(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,67 +1,69 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import { apiMiddleware } from "../middleware/apiMiddleware";
|
||||
import { prisma } from "../utils/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
export const profile = new Elysia({
|
||||
prefix: "/profile",
|
||||
}).post(
|
||||
"/update",
|
||||
async (ctx) => {
|
||||
const { body, set, user } = ctx as any;
|
||||
try {
|
||||
if (!user) {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized" };
|
||||
})
|
||||
.use(apiMiddleware)
|
||||
.post(
|
||||
"/update",
|
||||
async ({ body, set, user }) => {
|
||||
try {
|
||||
if (!user) {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
|
||||
const { name, image } = body;
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
name: name || undefined,
|
||||
image: image || undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info({ userId: user.id }, "Profile updated successfully");
|
||||
|
||||
return { user: updatedUser };
|
||||
} catch (error) {
|
||||
logger.error({ error, userId: user?.id }, "Failed to update profile");
|
||||
set.status = 500;
|
||||
return { error: "Failed to update profile" };
|
||||
}
|
||||
|
||||
const { name, image } = body;
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
name: name || undefined,
|
||||
image: image || undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info({ userId: user.id }, "Profile updated successfully");
|
||||
|
||||
return { user: updatedUser };
|
||||
} catch (error) {
|
||||
logger.error({ error, userId: user?.id }, "Failed to update profile");
|
||||
set.status = 500;
|
||||
return { error: "Failed to update profile" };
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
image: t.Optional(t.String()),
|
||||
}),
|
||||
response: {
|
||||
200: t.Object({
|
||||
user: t.Object({
|
||||
id: t.String(),
|
||||
name: t.Any(),
|
||||
email: t.String(),
|
||||
image: t.Any(),
|
||||
role: t.Any(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
image: t.Optional(t.String()),
|
||||
}),
|
||||
401: t.Object({ error: t.String() }),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
response: {
|
||||
200: t.Object({
|
||||
user: t.Object({
|
||||
id: t.String(),
|
||||
name: t.Any(),
|
||||
email: t.String(),
|
||||
image: t.Any(),
|
||||
role: t.Any(),
|
||||
}),
|
||||
}),
|
||||
401: t.Object({ error: t.String() }),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
|
||||
detail: {
|
||||
summary: "Update user profile",
|
||||
description: "Update the authenticated user's name or profile image",
|
||||
detail: {
|
||||
summary: "Update user profile",
|
||||
description: "Update the authenticated user's name or profile image",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
129
src/api/resident.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import { prisma } from "../utils/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
export const resident = new Elysia({
|
||||
prefix: "/resident",
|
||||
})
|
||||
.get(
|
||||
"/stats",
|
||||
async ({ set }) => {
|
||||
try {
|
||||
const [total, heads, poor] = await Promise.all([
|
||||
prisma.resident.count(),
|
||||
prisma.resident.count({ where: { isHeadOfHousehold: true } }),
|
||||
prisma.resident.count({ where: { isPoor: true } }),
|
||||
]);
|
||||
return { data: { total, heads, poor } };
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fetch resident stats");
|
||||
set.status = 500;
|
||||
return { error: "Internal Server Error" };
|
||||
}
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Object({
|
||||
total: t.Number(),
|
||||
heads: t.Number(),
|
||||
poor: t.Number(),
|
||||
}),
|
||||
}),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
detail: { summary: "Get resident statistics" },
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/banjar-stats",
|
||||
async ({ set }) => {
|
||||
try {
|
||||
const banjarStats = await prisma.banjar.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
totalPopulation: true,
|
||||
totalKK: true,
|
||||
totalPoor: true,
|
||||
},
|
||||
});
|
||||
return { data: banjarStats };
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fetch banjar stats");
|
||||
set.status = 500;
|
||||
return { error: "Internal Server Error" };
|
||||
}
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(t.Any()),
|
||||
}),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
detail: { summary: "Get population data per banjar" },
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/demographics",
|
||||
async ({ set }) => {
|
||||
try {
|
||||
const [religion, gender, occupation, ageGroups] = await Promise.all([
|
||||
prisma.resident.groupBy({
|
||||
by: ["religion"],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.resident.groupBy({
|
||||
by: ["gender"],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.resident.groupBy({
|
||||
by: ["occupation"],
|
||||
_count: { _all: true },
|
||||
orderBy: { _count: { occupation: "desc" } },
|
||||
take: 10,
|
||||
}),
|
||||
// Group by age ranges (simplified calculation)
|
||||
prisma.$queryRaw<{ range: string; count: number }[]>`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 0 AND 16 THEN '0-16'
|
||||
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 17 AND 25 THEN '17-25'
|
||||
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 26 AND 35 THEN '26-35'
|
||||
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 36 AND 45 THEN '36-45'
|
||||
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 46 AND 55 THEN '46-55'
|
||||
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 56 AND 65 THEN '56-65'
|
||||
ELSE '65+'
|
||||
END as range,
|
||||
COUNT(*) as count
|
||||
FROM resident
|
||||
GROUP BY range
|
||||
ORDER BY range ASC
|
||||
`,
|
||||
]);
|
||||
return { data: { religion, gender, occupation, ageGroups } };
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fetch demographics");
|
||||
set.status = 500;
|
||||
return { error: "Internal Server Error" };
|
||||
}
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Object({
|
||||
religion: t.Array(t.Any()),
|
||||
gender: t.Array(t.Any()),
|
||||
occupation: t.Array(t.Any()),
|
||||
ageGroups: t.Array(t.Any()),
|
||||
}),
|
||||
}),
|
||||
500: t.Object({ error: t.String() }),
|
||||
},
|
||||
detail: {
|
||||
summary:
|
||||
"Get demographics including religion, gender, occupation and age",
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,385 +1,38 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Grid,
|
||||
GridCol,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBuildingStore,
|
||||
IconCategory,
|
||||
IconCurrency,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { Grid, GridCol, Stack } from "@mantine/core";
|
||||
import { HeaderToggle } from "./umkm/header-toggle";
|
||||
import { ProdukUnggulan } from "./umkm/produk-unggulan";
|
||||
import type { SalesData } from "./umkm/sales-table";
|
||||
import { SalesTable } from "./umkm/sales-table";
|
||||
import { SummaryCards } from "./umkm/summary-cards";
|
||||
import { TopProducts } from "./umkm/top-products";
|
||||
|
||||
const BumdesPage = () => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const [timeFilter, setTimeFilter] = useState<string>("bulan");
|
||||
|
||||
// Sample data for KPI cards
|
||||
const kpiData = [
|
||||
{
|
||||
title: "UMKM Aktif",
|
||||
value: 45,
|
||||
icon: <IconUsers size={24} />,
|
||||
color: "darmasaba-blue",
|
||||
},
|
||||
{
|
||||
title: "UMKM Terdaftar",
|
||||
value: 68,
|
||||
icon: <IconBuildingStore size={24} />,
|
||||
color: "darmasaba-success",
|
||||
},
|
||||
{
|
||||
title: "Omzet",
|
||||
value: "Rp 48.000.000",
|
||||
icon: <IconCurrency size={24} />,
|
||||
color: "darmasaba-warning",
|
||||
},
|
||||
{
|
||||
title: "Kategori UMKM",
|
||||
value: 34,
|
||||
icon: <IconCategory size={24} />,
|
||||
color: "darmasaba-danger",
|
||||
},
|
||||
];
|
||||
|
||||
// Sample data for top products
|
||||
const topProducts = [
|
||||
{
|
||||
rank: 1,
|
||||
name: "Beras Premium Organik",
|
||||
umkmOwner: "Warung Pak Joko",
|
||||
growth: "+12%",
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
name: "Keripik Singkong",
|
||||
umkmOwner: "Ibu Sari Snack",
|
||||
growth: "+8%",
|
||||
},
|
||||
{
|
||||
rank: 3,
|
||||
name: "Madu Alami",
|
||||
umkmOwner: "Peternakan Lebah",
|
||||
growth: "+5%",
|
||||
},
|
||||
];
|
||||
|
||||
// Sample data for product sales
|
||||
const productSales = [
|
||||
{
|
||||
produk: "Beras Premium Organik",
|
||||
penjualanBulanIni: "Rp 8.500.000",
|
||||
bulanLalu: "Rp 8.500.000",
|
||||
trend: 10,
|
||||
volume: "650 Kg",
|
||||
stok: "850 Kg",
|
||||
},
|
||||
{
|
||||
produk: "Keripik Singkong",
|
||||
penjualanBulanIni: "Rp 4.200.000",
|
||||
bulanLalu: "Rp 3.800.000",
|
||||
trend: 10,
|
||||
volume: "320 Kg",
|
||||
stok: "120 Kg",
|
||||
},
|
||||
{
|
||||
produk: "Madu Alami",
|
||||
penjualanBulanIni: "Rp 3.750.000",
|
||||
bulanLalu: "Rp 4.100.000",
|
||||
trend: -8,
|
||||
volume: "150 Liter",
|
||||
stok: "45 Liter",
|
||||
},
|
||||
{
|
||||
produk: "Kecap Tradisional",
|
||||
penjualanBulanIni: "Rp 2.800.000",
|
||||
bulanLalu: "Rp 2.500.000",
|
||||
trend: 12,
|
||||
volume: "280 Botol",
|
||||
stok: "95 Botol",
|
||||
},
|
||||
];
|
||||
const handleDetailClick = (product: SalesData) => {
|
||||
console.log("Detail clicked for:", product);
|
||||
// TODO: Open modal or navigate to detail page
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* KPI Cards */}
|
||||
<Grid gutter="md">
|
||||
{kpiData.map((kpi, index) => (
|
||||
<GridCol key={index} span={{ base: 12, sm: 6, md: 3 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
{kpi.title}
|
||||
</Text>
|
||||
<Text size="xl" fw={700} c={dark ? "dark.0" : "black"}>
|
||||
{typeof kpi.value === "number"
|
||||
? kpi.value.toLocaleString()
|
||||
: kpi.value}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge variant="light" color={kpi.color} p={8} radius="md">
|
||||
{kpi.icon}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
{/* KPI Summary Cards */}
|
||||
<SummaryCards />
|
||||
|
||||
{/* Update Penjualan Produk Header */}
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Group justify="space-between" align="center" px="md" py="xs">
|
||||
<Title order={3} c={dark ? "dark.0" : "black"}>
|
||||
Update Penjualan Produk
|
||||
</Title>
|
||||
<Group>
|
||||
<Button
|
||||
variant={timeFilter === "minggu" ? "filled" : "light"}
|
||||
onClick={() => setTimeFilter("minggu")}
|
||||
color="darmasaba-blue"
|
||||
>
|
||||
Minggu ini
|
||||
</Button>
|
||||
<Button
|
||||
variant={timeFilter === "bulan" ? "filled" : "light"}
|
||||
onClick={() => setTimeFilter("bulan")}
|
||||
color="darmasaba-blue"
|
||||
>
|
||||
Bulan ini
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
{/* Header with Time Range Toggle */}
|
||||
<HeaderToggle />
|
||||
|
||||
{/* Main Content - 2 Column Layout */}
|
||||
<Grid gutter="md">
|
||||
{/* Produk Unggulan (Left Column) */}
|
||||
{/* Left Panel - Produk Unggulan */}
|
||||
<GridCol span={{ base: 12, lg: 4 }}>
|
||||
<Stack gap="md">
|
||||
{/* Total Penjualan, Produk Aktif, Total Transaksi */}
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
Total Penjualan
|
||||
</Text>
|
||||
<Text size="lg" fw={700} c={dark ? "dark.0" : "black"}>
|
||||
Rp 28.500.000
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
Produk Aktif
|
||||
</Text>
|
||||
<Text size="lg" fw={700} c={dark ? "dark.0" : "black"}>
|
||||
124 Produk
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
Total Transaksi
|
||||
</Text>
|
||||
<Text size="lg" fw={700} c={dark ? "dark.0" : "black"}>
|
||||
1.240 Transaksi
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Top 3 Produk Terlaris */}
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Title order={4} mb="md" c={dark ? "dark.0" : "black"}>
|
||||
Top 3 Produk Terlaris
|
||||
</Title>
|
||||
<Stack gap="sm">
|
||||
{topProducts.map((product) => (
|
||||
<Group
|
||||
key={product.rank}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<Group gap="sm">
|
||||
<Badge
|
||||
variant="filled"
|
||||
color={
|
||||
product.rank === 1
|
||||
? "gold"
|
||||
: product.rank === 2
|
||||
? "gray"
|
||||
: "bronze"
|
||||
}
|
||||
radius="xl"
|
||||
size="lg"
|
||||
>
|
||||
{product.rank}
|
||||
</Badge>
|
||||
<Stack gap={0}>
|
||||
<Text fw={500} c={dark ? "dark.0" : "black"}>
|
||||
{product.name}
|
||||
</Text>
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
{product.umkmOwner}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={product.growth.startsWith("+") ? "green" : "red"}
|
||||
>
|
||||
{product.growth}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
<ProdukUnggulan />
|
||||
<TopProducts />
|
||||
</Stack>
|
||||
</GridCol>
|
||||
|
||||
{/* Detail Penjualan Produk (Right Column) */}
|
||||
{/* Right Panel - Detail Penjualan Produk */}
|
||||
<GridCol span={{ base: 12, lg: 8 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4} c={dark ? "dark.0" : "black"}>
|
||||
Detail Penjualan Produk
|
||||
</Title>
|
||||
<Select
|
||||
placeholder="Filter kategori"
|
||||
data={[
|
||||
{ value: "semua", label: "Semua Kategori" },
|
||||
{ value: "makanan", label: "Makanan" },
|
||||
{ value: "minuman", label: "Minuman" },
|
||||
{ value: "kerajinan", label: "Kerajinan" },
|
||||
]}
|
||||
defaultValue="semua"
|
||||
w={200}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Table striped highlightOnHover withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>
|
||||
<Text c={dark ? "white" : "dimmed"}>Produk</Text>
|
||||
</Table.Th>
|
||||
<Table.Th>
|
||||
<Text c={dark ? "white" : "dimmed"}>
|
||||
Penjualan Bulan Ini
|
||||
</Text>
|
||||
</Table.Th>
|
||||
<Table.Th>
|
||||
<Text c={dark ? "white" : "dimmed"}>Bulan Lalu</Text>
|
||||
</Table.Th>
|
||||
<Table.Th>
|
||||
<Text c={dark ? "white" : "dimmed"}>Trend</Text>
|
||||
</Table.Th>
|
||||
<Table.Th>
|
||||
<Text c={dark ? "white" : "dimmed"}>Volume</Text>
|
||||
</Table.Th>
|
||||
<Table.Th>
|
||||
<Text c={dark ? "white" : "dimmed"}>Stok</Text>
|
||||
</Table.Th>
|
||||
<Table.Th>
|
||||
<Text c={dark ? "white" : "dimmed"}>Aksi</Text>
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{productSales.map((product, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Text fw={500} c={dark ? "dark.0" : "black"}>
|
||||
{product.produk}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={"sm"} c={dark ? "dark.0" : "black"}>
|
||||
{product.penjualanBulanIni}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={"sm"} c={dark ? "white" : "dimmed"}>
|
||||
{product.bulanLalu}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Text c={product.trend >= 0 ? "green" : "red"}>
|
||||
{product.trend >= 0 ? "↑" : "↓"}{" "}
|
||||
{Math.abs(product.trend)}%
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={"sm"} c={dark ? "dark.0" : "black"}>
|
||||
{product.volume}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={
|
||||
parseInt(product.stok) > 200 ? "green" : "yellow"
|
||||
}
|
||||
>
|
||||
{product.stok}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
color="darmasaba-blue"
|
||||
>
|
||||
Detail
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
<SalesTable onDetailClick={handleDetailClick} />
|
||||
</GridCol>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
@@ -1,510 +1,154 @@
|
||||
import {
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
FileText,
|
||||
MessageCircle,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip, // Added Tooltip import
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
// Import Mantine components
|
||||
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Card, // Added for icon containers
|
||||
Grid,
|
||||
Group,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
useMantineColorScheme, // Add this import
|
||||
} from "@mantine/core";
|
||||
|
||||
const barChartData = [
|
||||
{ month: "Jan", value: 145 },
|
||||
{ month: "Feb", value: 165 },
|
||||
{ month: "Mar", value: 195 },
|
||||
{ month: "Apr", value: 155 },
|
||||
{ month: "Mei", value: 205 },
|
||||
{ month: "Jun", value: 185 },
|
||||
];
|
||||
|
||||
const pieChartData = [
|
||||
{ name: "Puas", value: 25 },
|
||||
{ name: "Cukup", value: 25 },
|
||||
{ name: "Kurang", value: 25 },
|
||||
{ name: "Sangat puas", value: 25 },
|
||||
];
|
||||
|
||||
const COLORS = ["#4E5BA6", "#F4C542", "#8CC63F", "#E57373"];
|
||||
|
||||
const divisiData = [
|
||||
{ name: "Kesejahteraan", value: 37 },
|
||||
{ name: "Pemerintahan", value: 26 },
|
||||
{ name: "Keuangan", value: 17 },
|
||||
{ name: "Sekretaris Desa", value: 15 },
|
||||
];
|
||||
|
||||
const eventData = [
|
||||
{ date: "1 Oktober 2025", title: "Hari Kesaktian Pancasila" },
|
||||
{ date: "15 Oktober 2025", title: "Davest" },
|
||||
{ date: "19 Oktober 2025", title: "Rapat Koordinasi" },
|
||||
];
|
||||
|
||||
const apbdesData = [
|
||||
{ name: "Belanja", value: 70, color: "blue" },
|
||||
{ name: "Pendapatan", value: 90, color: "green" },
|
||||
{ name: "Pembangunan", value: 50, color: "orange" },
|
||||
];
|
||||
import { Center, Grid, Image, Loader, Stack } from "@mantine/core";
|
||||
import { CheckCircle, FileText, MessageCircle, Users } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import { ActivityList } from "./dashboard/activity-list";
|
||||
import { ChartAPBDes } from "./dashboard/chart-apbdes";
|
||||
import { ChartSurat } from "./dashboard/chart-surat";
|
||||
import { DivisionProgress } from "./dashboard/division-progress";
|
||||
import { SatisfactionChart } from "./dashboard/satisfaction-chart";
|
||||
import { SDGSCard } from "./dashboard/sdgs-card";
|
||||
import { StatCard } from "./dashboard/stat-card";
|
||||
|
||||
export function DashboardContent() {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
const [stats, setStats] = useState({
|
||||
complaints: { total: 0, baru: 0, proses: 0, selesai: 0 },
|
||||
residents: { total: 0, heads: 0, poor: 0 },
|
||||
weeklyService: 0,
|
||||
loading: true,
|
||||
});
|
||||
|
||||
const [sdgsData, setSdgsData] = useState<
|
||||
{ title: string; score: number; image: string | null }[]
|
||||
>([]);
|
||||
const [sdgsLoading, setSdgsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const [complaintRes, residentRes, weeklyServiceRes, sdgsRes] =
|
||||
await Promise.all([
|
||||
apiClient.GET("/api/complaint/stats"),
|
||||
apiClient.GET("/api/resident/stats"),
|
||||
apiClient.GET("/api/complaint/service-weekly"),
|
||||
apiClient.GET("/api/dashboard/sdgs"),
|
||||
]);
|
||||
|
||||
setStats({
|
||||
complaints: (complaintRes.data as { data: typeof stats.complaints })
|
||||
?.data || {
|
||||
total: 0,
|
||||
baru: 0,
|
||||
proses: 0,
|
||||
selesai: 0,
|
||||
},
|
||||
residents: (residentRes.data as { data: typeof stats.residents })
|
||||
?.data || {
|
||||
total: 0,
|
||||
heads: 0,
|
||||
poor: 0,
|
||||
},
|
||||
weeklyService:
|
||||
(weeklyServiceRes.data as { data: { count: number } })?.data
|
||||
?.count || 0,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
if (sdgsRes.data?.data) {
|
||||
setSdgsData(sdgsRes.data.data);
|
||||
}
|
||||
setSdgsLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch dashboard content", error);
|
||||
setStats((prev) => ({ ...prev, loading: false }));
|
||||
setSdgsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Stats Cards */}
|
||||
{/* Header Metrics - 4 Stat Cards */}
|
||||
<Grid gutter="md">
|
||||
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
||||
<Card
|
||||
p="md"
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
radius="md"
|
||||
h="100%"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" w="100%">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
Surat Minggu Ini
|
||||
</Text>
|
||||
<Group align="baseline" gap="xs">
|
||||
<Text size="xl" fw={700}>
|
||||
99
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
14 baru, 14 diproses
|
||||
</Text>
|
||||
<Text size="sm" c="red" mt="xs">
|
||||
12% dari minggu lalu ↗ +12%
|
||||
</Text>
|
||||
</Box>
|
||||
<ThemeIcon
|
||||
variant="filled"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
color={dark ? "gray" : "darmasaba-blue"}
|
||||
>
|
||||
<FileText style={{ width: "70%", height: "70%" }} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
<StatCard
|
||||
title="Surat Minggu Ini"
|
||||
value={stats.weeklyService}
|
||||
detail="Total surat diajukan"
|
||||
icon={<FileText style={{ width: "70%", height: "70%" }} />}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
||||
<Card
|
||||
p="md"
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
radius="md"
|
||||
h="100%"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" w="100%">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
Pengaduan Aktif
|
||||
</Text>
|
||||
<Group align="baseline" gap="xs">
|
||||
<Text size="xl" fw={700}>
|
||||
28
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
14 baru, 14 diproses
|
||||
</Text>
|
||||
</Box>
|
||||
<ThemeIcon
|
||||
variant="filled"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
color={dark ? "gray" : "darmasaba-blue"}
|
||||
>
|
||||
<MessageCircle style={{ width: "70%", height: "70%" }} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
<StatCard
|
||||
title="Pengaduan Aktif"
|
||||
value={stats.complaints.baru + stats.complaints.proses}
|
||||
detail={`${stats.complaints.baru} baru, ${stats.complaints.proses} diproses`}
|
||||
icon={<MessageCircle style={{ width: "70%", height: "70%" }} />}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
||||
<Card
|
||||
p="md"
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
radius="md"
|
||||
h="100%"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" w="100%">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
Layanan Selesai
|
||||
</Text>
|
||||
<Group align="baseline" gap="xs">
|
||||
<Text size="xl" fw={700}>
|
||||
156
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
bulan ini
|
||||
</Text>
|
||||
<Text size="sm" c="red" mt="xs">
|
||||
+8%
|
||||
</Text>
|
||||
</Box>
|
||||
<ThemeIcon
|
||||
variant="filled"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
color={dark ? "gray" : "darmasaba-blue"}
|
||||
>
|
||||
<CheckCircle style={{ width: "70%", height: "70%" }} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
<StatCard
|
||||
title="Layanan Selesai"
|
||||
value={stats.complaints.selesai}
|
||||
detail="Total diselesaikan"
|
||||
icon={<CheckCircle style={{ width: "70%", height: "70%" }} />}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
||||
<Card
|
||||
p="md"
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
radius="md"
|
||||
h="100%"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" w="100%">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
Kepuasan Warga
|
||||
</Text>
|
||||
<Group align="baseline" gap="xs">
|
||||
<Text size="xl" fw={700}>
|
||||
87.2%
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
dari 482 responden
|
||||
</Text>
|
||||
</Box>
|
||||
<ThemeIcon
|
||||
variant="filled"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
color={dark ? "gray" : "darmasaba-blue"}
|
||||
>
|
||||
<Users style={{ width: "70%", height: "70%" }} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
<StatCard
|
||||
title="Total Penduduk"
|
||||
value={stats.residents.total.toLocaleString()}
|
||||
detail={`${stats.residents.heads} Kepala Keluarga`}
|
||||
icon={<Users style={{ width: "70%", height: "70%" }} />}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{/* Section 2: Chart & Division Progress */}
|
||||
<Grid gutter="lg">
|
||||
{/* Bar Chart */}
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Box>
|
||||
<Title order={4} mb={5}>
|
||||
Statistik Pengajuan Surat
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Trend pengajuan surat 6 bulan terakhir
|
||||
</Text>
|
||||
</Box>
|
||||
<ActionIcon variant="subtle" size="lg" radius="md">
|
||||
{/* Original SVG converted to a generic Icon placeholder */}
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 5L13 10L8 15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={barChartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
stroke="var(--mantine-color-gray-3)"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "var(--mantine-color-text)" }}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
ticks={[0, 55, 110, 165, 220]}
|
||||
tick={{ fill: "var(--mantine-color-text)" }}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill="var(--mantine-color-blue-filled)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<ChartSurat />
|
||||
</Grid.Col>
|
||||
|
||||
{/* Pie Chart */}
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
>
|
||||
<Title order={4} mb={5}>
|
||||
Tingkat Kepuasan
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Tingkat kepuasan layanan
|
||||
</Text>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieChartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={80}
|
||||
outerRadius={120}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{pieChartData.map((_entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<Group justify="center" gap="md" mt="md">
|
||||
<Group gap="xs">
|
||||
<Box
|
||||
w={12}
|
||||
h={12}
|
||||
style={{ backgroundColor: COLORS[0], borderRadius: "50%" }}
|
||||
/>
|
||||
<Text size="sm">Sangat puas (0%)</Text>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Box
|
||||
w={12}
|
||||
h={12}
|
||||
style={{ backgroundColor: COLORS[1], borderRadius: "50%" }}
|
||||
/>
|
||||
<Text size="sm">Puas (0%)</Text>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Box
|
||||
w={12}
|
||||
h={12}
|
||||
style={{ backgroundColor: COLORS[2], borderRadius: "50%" }}
|
||||
/>
|
||||
<Text size="sm">Cukup (0%)</Text>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Box
|
||||
w={12}
|
||||
h={12}
|
||||
style={{ backgroundColor: COLORS[3], borderRadius: "50%" }}
|
||||
/>
|
||||
<Text size="sm">Kurang (0%)</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<SatisfactionChart />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{/* Bottom Section */}
|
||||
{/* Section 3: APBDes Chart */}
|
||||
<Grid gutter="lg">
|
||||
{/* Divisi Teraktif */}
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
>
|
||||
<Group gap="xs" mb="lg">
|
||||
<Box>
|
||||
{/* Original SVG icon */}
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="3"
|
||||
width="7"
|
||||
height="7"
|
||||
rx="1"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<rect
|
||||
x="3"
|
||||
y="14"
|
||||
width="7"
|
||||
height="7"
|
||||
rx="1"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<rect
|
||||
x="14"
|
||||
y="3"
|
||||
width="7"
|
||||
height="7"
|
||||
rx="1"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<rect
|
||||
x="14"
|
||||
y="14"
|
||||
width="7"
|
||||
height="7"
|
||||
rx="1"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
<Title order={4}>Divisi Teraktif</Title>
|
||||
</Group>
|
||||
<Stack gap="sm">
|
||||
{divisiData.map((divisi, index) => (
|
||||
<Box key={index}>
|
||||
<Group justify="space-between" mb={5}>
|
||||
<Text size="sm" fw={500}>
|
||||
{divisi.name}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{divisi.value} Kegiatan
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={(divisi.value / 37) * 100}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
color="blue"
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<DivisionProgress />
|
||||
</Grid.Col>
|
||||
|
||||
{/* Kalender */}
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
>
|
||||
<Group gap="xs" mb="lg">
|
||||
<Calendar style={{ width: 20, height: 20 }} />
|
||||
<Title order={4}>Kalender & Kegiatan Mendatang</Title>
|
||||
</Group>
|
||||
<Stack gap="md">
|
||||
{eventData.map((event, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
style={{
|
||||
borderLeft: "4px solid var(--mantine-color-blue-filled)",
|
||||
paddingLeft: 12,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{event.date}
|
||||
</Text>
|
||||
<Text fw={500}>{event.title}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<ActivityList />
|
||||
{/* <SatisfactionChart /> */}
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{/* APBDes Chart */}
|
||||
<Card
|
||||
p="md"
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
>
|
||||
<Title order={4} mb="lg">
|
||||
Grafik APBDes
|
||||
</Title>
|
||||
<Stack gap="xs">
|
||||
{apbdesData.map((data, index) => (
|
||||
<Grid key={index} align="center">
|
||||
<Grid.Col span={3}>
|
||||
<Text size="sm" fw={500}>
|
||||
{data.name}
|
||||
</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={9}>
|
||||
<Progress
|
||||
value={data.value}
|
||||
size="lg"
|
||||
radius="xl"
|
||||
color={data.color}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<ChartAPBDes />
|
||||
|
||||
{/* Section 6: SDGs Desa Cards */}
|
||||
{sdgsLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<Grid gutter="md">
|
||||
{sdgsData.map((sdg) => (
|
||||
<Grid.Col key={sdg.title} span={{ base: 9, md: 3 }}>
|
||||
<SDGSCard
|
||||
image={
|
||||
sdg.image ? <Image src={sdg.image} alt={sdg.title} /> : null
|
||||
}
|
||||
title={sdg.title}
|
||||
score={sdg.score}
|
||||
/>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
105
src/components/dashboard/activity-list.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
|
||||
interface EventData {
|
||||
date: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function ActivityList() {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const [data, setData] = useState<EventData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchEvents() {
|
||||
try {
|
||||
const res = await apiClient.GET("/api/event/");
|
||||
if (res.data?.data) {
|
||||
setData(
|
||||
(res.data.data as { startDate: string; title: string }[]).map(
|
||||
(e) => ({
|
||||
date: dayjs(e.startDate).format("D MMMM YYYY"),
|
||||
title: e.title,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch events", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchEvents();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: dark
|
||||
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Group gap="xs" mb="lg">
|
||||
<Calendar
|
||||
style={{ width: 20, height: 20 }}
|
||||
color={dark ? "#E2E8F0" : "#1E3A5F"}
|
||||
/>
|
||||
<Title order={4} c={dark ? "white" : "gray.9"}>
|
||||
Kalender & Kegiatan Mendatang
|
||||
</Title>
|
||||
</Group>
|
||||
<Stack gap="md">
|
||||
{loading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : data.length > 0 ? (
|
||||
data.map((event) => (
|
||||
<Box
|
||||
key={`${event.title}-${event.date}`}
|
||||
style={{
|
||||
borderLeft: "4px solid var(--mantine-color-blue-filled)",
|
||||
paddingLeft: 12,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{event.date}
|
||||
</Text>
|
||||
<Text fw={500} c={dark ? "white" : "gray.9"}>
|
||||
{event.title}
|
||||
</Text>
|
||||
</Box>
|
||||
))
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Tidak ada kegiatan mendatang
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
118
src/components/dashboard/chart-apbdes.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
|
||||
interface ApbdesData {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function ChartAPBDes() {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const [data, setData] = useState<ApbdesData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchApbdes() {
|
||||
try {
|
||||
const res = await apiClient.GET("/api/dashboard/budget");
|
||||
if (res.data?.data) {
|
||||
setData(
|
||||
res.data.data.map((d) => ({
|
||||
name: d.category,
|
||||
value: d.percentage,
|
||||
color: d.color,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch APBDes data", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchApbdes();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: dark
|
||||
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Title order={4} c={dark ? "white" : "gray.9"} mb="lg">
|
||||
Grafik APBDes
|
||||
</Title>
|
||||
<Stack gap="xs">
|
||||
{loading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : data.length > 0 ? (
|
||||
data.map((item) => (
|
||||
<Group key={item.name} align="center" gap="md">
|
||||
<Text size="sm" fw={500} w={100} c={dark ? "white" : "gray.7"}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<ResponsiveContainer width="100%" height={12} style={{ flex: 1 }}>
|
||||
<BarChart
|
||||
layout="vertical"
|
||||
data={[item]}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
>
|
||||
<XAxis type="number" hide domain={[0, 100]} />
|
||||
<YAxis type="category" hide dataKey="name" />
|
||||
<Bar dataKey="value" radius={[10, 10, 10, 10]} barSize={12}>
|
||||
<Cell fill={item.color} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
w={40}
|
||||
ta="right"
|
||||
c={dark ? "white" : "gray.9"}
|
||||
>
|
||||
{item.value}%
|
||||
</Text>
|
||||
</Group>
|
||||
))
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Tidak ada data APBDes
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
179
src/components/dashboard/chart-surat.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Text,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
|
||||
interface ChartData {
|
||||
month: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export function ChartSurat() {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const [data, setData] = useState<ChartData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// DEBUG: Uncomment to test chart rendering with sample data
|
||||
// useEffect(() => {
|
||||
// setData([
|
||||
// { month: "Oct", value: 1 },
|
||||
// { month: "Nov", value: 1 },
|
||||
// { month: "Dec", value: 1 },
|
||||
// { month: "Feb", value: 1 },
|
||||
// { month: "Mar", value: 1 },
|
||||
// ]);
|
||||
// setLoading(false);
|
||||
// }, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchTrends() {
|
||||
try {
|
||||
const res = await apiClient.GET("/api/complaint/service-trends");
|
||||
console.log("📊 Service trends response:", res);
|
||||
|
||||
// Check if response has data
|
||||
if (
|
||||
res.data?.data &&
|
||||
Array.isArray(res.data.data) &&
|
||||
res.data.data.length > 0
|
||||
) {
|
||||
const chartData = (
|
||||
res.data.data as { month: string; count: number }[]
|
||||
).map((d) => ({
|
||||
month: d.month,
|
||||
value: Number(d.count),
|
||||
}));
|
||||
console.log("📈 Mapped chart data:", chartData);
|
||||
console.log("✅ Chart data count:", chartData.length);
|
||||
setData(chartData);
|
||||
} else {
|
||||
console.warn("⚠️ No data in response or empty array");
|
||||
console.log("Response structure:", JSON.stringify(res, null, 2));
|
||||
setData([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to fetch service trends", error);
|
||||
console.log("Error details:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchTrends();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: dark
|
||||
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Box>
|
||||
<Title order={4} c={dark ? "white" : "gray.9"} mb={5}>
|
||||
Statistik Pengajuan Surat
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Trend pengajuan surat 6 bulan terakhir
|
||||
</Text>
|
||||
</Box>
|
||||
<ActionIcon variant="subtle" size="lg" radius="md">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="Tampilkan Detail"
|
||||
>
|
||||
<title>Tampilkan Detail</title>
|
||||
<path
|
||||
d="M8 5L13 10L8 15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Box style={{ width: "100%", height: 300 }}>
|
||||
{loading ? (
|
||||
<Group justify="center" align="center" h="100%">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : data.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
stroke={dark ? "#334155" : "#e5e7eb"}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: dark ? "#1E293B" : "white",
|
||||
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 4px 6px -1px rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={dark ? "#60A5FA" : "#3B82F6"}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Group justify="center" align="center" h="100%">
|
||||
<Text size="sm" c="dimmed">
|
||||
Tidak ada data pengajuan surat 6 bulan terakhir
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
110
src/components/dashboard/division-progress.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
|
||||
interface DivisionData {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface DivisionApiResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
activityCount: number;
|
||||
_count?: {
|
||||
activities: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function DivisionProgress() {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const [data, setData] = useState<DivisionData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDivisions() {
|
||||
try {
|
||||
const res = await apiClient.GET("/api/division/");
|
||||
if (res.data?.data) {
|
||||
setData(
|
||||
(res.data.data as DivisionApiResponse[]).map((d) => ({
|
||||
name: d.name,
|
||||
value: d.activityCount || 0,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch division stats", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchDivisions();
|
||||
}, []);
|
||||
|
||||
const max_value = Math.max(...data.map((d) => d.value), 1);
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: dark
|
||||
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Title order={4} c={dark ? "white" : "gray.9"} mb="lg">
|
||||
Divisi Teraktif
|
||||
</Title>
|
||||
<Stack gap="sm">
|
||||
{loading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : data.length > 0 ? (
|
||||
data.map((divisi) => (
|
||||
<Box key={divisi.name}>
|
||||
<Group justify="space-between" mb={5}>
|
||||
<Text size="sm" fw={500} c={dark ? "white" : "gray.7"}>
|
||||
{divisi.name}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}>
|
||||
{divisi.value} Kegiatan
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={(divisi.value / max_value) * 100}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
color="blue"
|
||||
animated
|
||||
/>
|
||||
</Box>
|
||||
))
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Tidak ada data divisi
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
7
src/components/dashboard/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { ActivityList } from "./activity-list";
|
||||
export { ChartAPBDes } from "./chart-apbdes";
|
||||
export { ChartSurat } from "./chart-surat";
|
||||
export { DivisionProgress } from "./division-progress";
|
||||
export { SatisfactionChart } from "./satisfaction-chart";
|
||||
export { SDGSCard } from "./sdgs-card";
|
||||
export { StatCard } from "./stat-card";
|
||||
116
src/components/dashboard/satisfaction-chart.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Text,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
|
||||
interface SatisfactionData {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function SatisfactionChart() {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const [data, setData] = useState<SatisfactionData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchSatisfaction() {
|
||||
try {
|
||||
const res = await apiClient.GET("/api/dashboard/satisfaction");
|
||||
if (res.data?.data) {
|
||||
setData(
|
||||
res.data.data.map((d) => ({
|
||||
name: d.category,
|
||||
value: d.value,
|
||||
color: d.color,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch satisfaction data", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchSatisfaction();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: dark
|
||||
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Title order={4} c={dark ? "white" : "gray.9"} mb={5}>
|
||||
Tingkat Kepuasan
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Tingkat kepuasan layanan
|
||||
</Text>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
{loading ? (
|
||||
<Group justify="center" align="center" h="100%">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={80}
|
||||
outerRadius={120}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((entry) => (
|
||||
<Cell key={`cell-${entry.name}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: dark ? "#1E293B" : "white",
|
||||
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
<Group justify="center" gap="md" mt="md">
|
||||
{data.map((item) => (
|
||||
<Group key={item.name} gap="xs">
|
||||
<Box
|
||||
w={12}
|
||||
h={12}
|
||||
style={{ backgroundColor: item.color, borderRadius: "50%" }}
|
||||
/>
|
||||
<Text size="sm" c={dark ? "white" : "gray.7"}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
45
src/components/dashboard/sdgs-card.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Box, Card, Group, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface SDGSCardProps {
|
||||
title: string;
|
||||
score: number;
|
||||
image: ReactNode;
|
||||
}
|
||||
|
||||
export function SDGSCard({ title, score, image }: SDGSCardProps) {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" w="100%">
|
||||
<Box>{image}</Box>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text
|
||||
ta={"center"}
|
||||
size="sm"
|
||||
c={dark ? "white" : "gray.8"}
|
||||
fw={500}
|
||||
mb="xs"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Text ta={"center"} size="xl" c={dark ? "white" : "gray.8"} fw={700}>
|
||||
{score.toFixed(2)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
87
src/components/dashboard/stat-card.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
detail?: string;
|
||||
trend?: string;
|
||||
trendValue?: number;
|
||||
icon: ReactNode;
|
||||
iconColor?: string;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
detail,
|
||||
trend,
|
||||
trendValue,
|
||||
icon,
|
||||
iconColor = "#1E3A5F",
|
||||
}: StatCardProps) {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const isPositiveTrend = trendValue ? trendValue >= 0 : true;
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: dark
|
||||
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" w="100%">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
{title}
|
||||
</Text>
|
||||
<Group align="baseline" gap="xs">
|
||||
<Text size="xl" fw={700} c={dark ? "white" : "gray.9"}>
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
{detail && (
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
{detail}
|
||||
</Text>
|
||||
)}
|
||||
{trend && (
|
||||
<Text
|
||||
size="sm"
|
||||
c={isPositiveTrend ? "green" : "red"}
|
||||
mt="xs"
|
||||
fw={500}
|
||||
>
|
||||
{trend}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<ThemeIcon
|
||||
variant="filled"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
color={dark ? "gray" : iconColor}
|
||||
bg={dark ? "gray" : iconColor}
|
||||
>
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
219
src/components/dev-inspector.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface CodeInfo {
|
||||
relativePath: string;
|
||||
line: string;
|
||||
column: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts data-inspector-* from fiber props or DOM attributes.
|
||||
* Handles React 19 fiber tree walk-up and DOM attribute fallbacks.
|
||||
*/
|
||||
function getCodeInfoFromElement(element: HTMLElement): CodeInfo | null {
|
||||
// Strategy 1: React internal props __reactProps$ (most accurate in R19)
|
||||
for (const key of Object.keys(element)) {
|
||||
if (key.startsWith("__reactProps$")) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: React internals
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
// Strategy 2: Walk fiber tree __reactFiber$
|
||||
if (key.startsWith("__reactFiber$")) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: React internals
|
||||
let f = (element as any)[key];
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Universal DOM attribute fallback
|
||||
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;
|
||||
}
|
||||
|
||||
/** Walks up DOM tree until source info is found. */
|
||||
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`;
|
||||
}, []);
|
||||
|
||||
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);
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -23,10 +23,10 @@ export function ImageWithFallback(
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<img
|
||||
src={ERROR_IMG_SRC}
|
||||
alt="Error loading image"
|
||||
alt="Error loading content"
|
||||
{...rest}
|
||||
data-original-url={src}
|
||||
/>
|
||||
/>{" "}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,72 +1,175 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Divider,
|
||||
Group,
|
||||
Text,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useLocation } from "@tanstack/react-router";
|
||||
import { Bell, Moon, Sun } from "lucide-react";
|
||||
import {
|
||||
IconLayoutSidebarLeftCollapse,
|
||||
IconUserShield,
|
||||
} from "@tabler/icons-react";
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { Bell, Moon, Sun, User as UserIcon } from "lucide-react";
|
||||
|
||||
export function Header() {
|
||||
interface HeaderProps {
|
||||
onSidebarToggle?: () => void;
|
||||
}
|
||||
|
||||
export function Header({ onSidebarToggle }: HeaderProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const title =
|
||||
location.pathname === "/"
|
||||
? "Desa Darmasaba"
|
||||
: "Desa Darmasaba";
|
||||
const pathnames = location.pathname.split("/").filter((x) => x);
|
||||
|
||||
const breadcrumbItems = [
|
||||
<Anchor
|
||||
key="home"
|
||||
onClick={() => navigate({ to: "/" })}
|
||||
c="white"
|
||||
size="sm"
|
||||
underline="hover"
|
||||
>
|
||||
Desa Darmasaba
|
||||
</Anchor>,
|
||||
...pathnames.map((value, index) => {
|
||||
const to = `/${pathnames.slice(0, index + 1).join("/")}`;
|
||||
const isLast = index === pathnames.length - 1;
|
||||
|
||||
// Map route path to human-readable label
|
||||
const labelMap: Record<string, string> = {
|
||||
"kinerja-divisi": "Kinerja Divisi",
|
||||
"pengaduan-layanan-publik": "Pengaduan & Layanan Publik",
|
||||
"jenna-analytic": "Jenna Analytic",
|
||||
"demografi-pekerjaan": "Demografi & Kependudukan",
|
||||
"keuangan-anggaran": "Keuangan & Anggaran",
|
||||
bumdes: "Bumdes & UMKM",
|
||||
sosial: "Sosial",
|
||||
keamanan: "Keamanan",
|
||||
bantuan: "Bantuan",
|
||||
pengaturan: "Pengaturan",
|
||||
umum: "Umum",
|
||||
notifikasi: "Notifikasi",
|
||||
"akses-dan-tim": "Akses & Tim",
|
||||
profile: "Profil",
|
||||
edit: "Edit",
|
||||
};
|
||||
|
||||
const label =
|
||||
labelMap[value] || value.charAt(0).toUpperCase() + value.slice(1);
|
||||
|
||||
return isLast ? (
|
||||
<Text key={to} c="white" size="sm" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
) : (
|
||||
<Anchor
|
||||
key={to}
|
||||
onClick={() => navigate({ to })}
|
||||
c="white"
|
||||
size="sm"
|
||||
underline="hover"
|
||||
>
|
||||
{label}
|
||||
</Anchor>
|
||||
);
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto 1fr",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{/* LEFT SPACER (burger sudah di luar) */}
|
||||
<Box />
|
||||
|
||||
{/* CENTER TITLE */}
|
||||
<Text
|
||||
c="white"
|
||||
fw={600}
|
||||
size="md"
|
||||
style={{
|
||||
textAlign: "center",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{/* RIGHT ICONS */}
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Group justify="space-between" w="100%">
|
||||
{/* Title & Breadcrumbs */}
|
||||
<Group gap="md">
|
||||
<ActionIcon
|
||||
onClick={toggleColorScheme}
|
||||
onClick={onSidebarToggle}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
radius="xl"
|
||||
visibleFrom="sm"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
{dark ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon variant="subtle" radius="xl" pos="relative">
|
||||
<Bell size={18} />
|
||||
<Badge
|
||||
size="xs"
|
||||
color="red"
|
||||
style={{ position: "absolute", top: -4, right: -4 }}
|
||||
>
|
||||
10
|
||||
</Badge>
|
||||
<IconLayoutSidebarLeftCollapse
|
||||
color="white"
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
<Breadcrumbs
|
||||
separator={
|
||||
<Text c="white" size="xs">
|
||||
/
|
||||
</Text>
|
||||
}
|
||||
styles={{
|
||||
separator: { color: "white" },
|
||||
}}
|
||||
>
|
||||
{breadcrumbItems}
|
||||
</Breadcrumbs>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* Right Section */}
|
||||
<Group gap="md">
|
||||
{/* User Info */}
|
||||
<Group gap="sm">
|
||||
<Box ta="right">
|
||||
<Text c={"white"} size="sm" fw={500}>
|
||||
I. B. Surya Prabhawa M...
|
||||
</Text>
|
||||
<Text c={"white"} size="xs">
|
||||
Kepala Desa
|
||||
</Text>
|
||||
</Box>
|
||||
<Avatar color="blue" radius="xl">
|
||||
<UserIcon color="white" style={{ width: "70%", height: "70%" }} />
|
||||
</Avatar>
|
||||
</Group>
|
||||
|
||||
{/* Divider */}
|
||||
<Divider orientation="vertical" h={30} />
|
||||
|
||||
{/* Icons */}
|
||||
<Group gap="sm">
|
||||
<ActionIcon
|
||||
onClick={() => toggleColorScheme()}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
radius="xl"
|
||||
aria-label="Toggle color scheme"
|
||||
>
|
||||
{dark ? (
|
||||
<Sun color="white" style={{ width: "70%", height: "70%" }} />
|
||||
) : (
|
||||
<Moon color="white" style={{ width: "70%", height: "70%" }} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" size="lg" radius="xl" pos="relative">
|
||||
<Bell color="white" style={{ width: "70%", height: "70%" }} />
|
||||
<Badge
|
||||
size="xs"
|
||||
color="red"
|
||||
variant="filled"
|
||||
style={{ position: "absolute", top: 0, right: 0 }}
|
||||
radius={"xl"}
|
||||
>
|
||||
10
|
||||
</Badge>
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" size="lg" radius="xl">
|
||||
<IconUserShield
|
||||
color="white"
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
onClick={() => navigate({ to: "/admin" })}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,16 +143,25 @@ const HelpPage = () => {
|
||||
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<Title order={1} mb="xl" ta="center">
|
||||
Pusat Bantuan
|
||||
</Title>
|
||||
<Text size="lg" color="dimmed" ta="center" mb="xl">
|
||||
Temukan jawaban untuk pertanyaan Anda atau hubungi tim support kami
|
||||
</Text>
|
||||
|
||||
{/* Statistics Section */}
|
||||
<SimpleGrid cols={3} spacing="lg" mb="xl">
|
||||
{stats.map((stat, index) => (
|
||||
{stats.map((stat) => (
|
||||
<HelpCard
|
||||
key={index}
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
key={stat.label}
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
p="lg"
|
||||
style={{
|
||||
textAlign: "center",
|
||||
borderColor: dark ? "#141D34" : "white",
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
@@ -172,16 +181,20 @@ const HelpPage = () => {
|
||||
{/* Panduan Memulai */}
|
||||
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
||||
<HelpCard
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
icon={<IconBook size={24} />}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
icon={<IconBook size={24} color="white" />}
|
||||
title="Panduan Memulai"
|
||||
h="100%"
|
||||
>
|
||||
<Box>
|
||||
{guideItems.map((item, index) => (
|
||||
{guideItems.map((item) => (
|
||||
<Box
|
||||
key={index}
|
||||
key={item.title}
|
||||
py="sm"
|
||||
style={{
|
||||
borderBottom: "1px solid #eee",
|
||||
@@ -202,16 +215,20 @@ const HelpPage = () => {
|
||||
{/* Video Tutorial */}
|
||||
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
||||
<HelpCard
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
icon={<IconVideo size={24} />}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
icon={<IconVideo size={24} color="white" />}
|
||||
title="Video Tutorial"
|
||||
h="100%"
|
||||
>
|
||||
<Box>
|
||||
{videoItems.map((item, index) => (
|
||||
{videoItems.map((item) => (
|
||||
<Box
|
||||
key={index}
|
||||
key={item.title}
|
||||
py="sm"
|
||||
style={{
|
||||
borderBottom: "1px solid #eee",
|
||||
@@ -232,20 +249,24 @@ const HelpPage = () => {
|
||||
{/* FAQ */}
|
||||
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
||||
<HelpCard
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
icon={<IconHelpCircle size={24} />}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
icon={<IconHelpCircle size={24} color="white" />}
|
||||
title="FAQ"
|
||||
h="100%"
|
||||
>
|
||||
<Accordion variant="separated">
|
||||
{faqItems.map((item, index) => (
|
||||
{faqItems.map((item) => (
|
||||
<Accordion.Item
|
||||
style={{
|
||||
backgroundColor: dark ? "#263852ff" : "#F1F5F9",
|
||||
}}
|
||||
key={index}
|
||||
value={`faq-${index}`}
|
||||
key={item.question}
|
||||
value={item.question}
|
||||
>
|
||||
<Accordion.Control>{item.question}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
@@ -264,9 +285,13 @@ const HelpPage = () => {
|
||||
{/* Hubungi Support */}
|
||||
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
||||
<HelpCard
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
icon={<IconHeadphones size={24} />}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
icon={<IconHeadphones size={24} color="white" />}
|
||||
title="Hubungi Support"
|
||||
h="100%"
|
||||
>
|
||||
@@ -299,16 +324,20 @@ const HelpPage = () => {
|
||||
{/* Dokumentasi */}
|
||||
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
||||
<HelpCard
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
icon={<IconFileText size={24} />}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
icon={<IconFileText size={24} color="white" />}
|
||||
title="Dokumentasi"
|
||||
h="100%"
|
||||
>
|
||||
<Box>
|
||||
{documentationItems.map((item, index) => (
|
||||
{documentationItems.map((item) => (
|
||||
<Box
|
||||
key={index}
|
||||
key={item.title}
|
||||
py="sm"
|
||||
style={{
|
||||
borderBottom: "1px solid #eee",
|
||||
@@ -331,9 +360,13 @@ const HelpPage = () => {
|
||||
{/* Jenna - Virtual Assistant */}
|
||||
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
||||
<HelpCard
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
icon={<IconMessage size={24} />}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
icon={<IconMessage size={24} color="white" />}
|
||||
title="Jenna - Virtual Assistant"
|
||||
h="100%"
|
||||
>
|
||||
@@ -401,6 +434,7 @@ const HelpPage = () => {
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendMessage}
|
||||
disabled={isLoading || inputValue.trim() === ""}
|
||||
style={{
|
||||
|
||||
@@ -1,123 +1,78 @@
|
||||
import { BarChart } from "@mantine/charts";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Grid,
|
||||
Group,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import React from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
MessageCircle,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
// Sample Data
|
||||
// KPI Data
|
||||
const kpiData = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Interaksi Hari Ini",
|
||||
value: "61",
|
||||
delta: "+15% dari kemarin",
|
||||
deltaType: "positive",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="h-6 w-6 text-muted-foreground"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H16.5m-13.5 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
subtitle: "+15% dari kemarin",
|
||||
trend: "positive",
|
||||
icon: MessageCircle,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Jawaban Otomatis",
|
||||
value: "87%",
|
||||
sub: "53 dari 61 interaksi",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="h-6 w-6 text-muted-foreground"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.473-1.688 3.342-.48.485-.926.97-1.378 1.44c-1.472 1.58-2.306 2.787-2.91 3.514-.15.18-.207.33-.207.33A.75.75 0 0 1 15 21h-3c-1.104 0-2.08-.542-2.657-1.455-.139-.201-.264-.406-.38-.614l-.014-.025C8.85 18.067 8.156 17.2 7.5 16.325.728 12.56.728 7.44 7.5 3.675c3.04-.482 5.584.47 7.042 1.956.674.672 1.228 1.462 1.696 2.307.426.786.793 1.582 1.113 2.392h.001Z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
subtitle: "53 dari 61 interaksi",
|
||||
icon: CheckCircle,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Belum Ditindak",
|
||||
value: "8",
|
||||
sub: "Perlu respon manual",
|
||||
deltaType: "negative",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="h-6 w-6 text-muted-foreground"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
subtitle: "Perlu respon manual",
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Waktu Respon",
|
||||
value: "2.3 sec",
|
||||
sub: "Rata-rata",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="h-6 w-6 text-muted-foreground"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
subtitle: "Rata-rata",
|
||||
icon: Clock,
|
||||
},
|
||||
];
|
||||
|
||||
// Chart Data
|
||||
const chartData = [
|
||||
{ day: "Sen", total: 100 },
|
||||
{ day: "Sel", total: 120 },
|
||||
{ day: "Rab", total: 90 },
|
||||
{ day: "Kam", total: 150 },
|
||||
{ day: "Jum", total: 110 },
|
||||
{ day: "Sab", total: 80 },
|
||||
{ day: "Min", total: 130 },
|
||||
{ day: "Sen", total: 45 },
|
||||
{ day: "Sel", total: 62 },
|
||||
{ day: "Rab", total: 38 },
|
||||
{ day: "Kam", total: 75 },
|
||||
{ day: "Jum", total: 58 },
|
||||
{ day: "Sab", total: 32 },
|
||||
{ day: "Min", total: 51 },
|
||||
];
|
||||
|
||||
// Top Topics Data
|
||||
const topTopics = [
|
||||
{ topic: "Cara mengurus KTP", count: 89 },
|
||||
{ topic: "Syarat Kartu Keluarga", count: 76 },
|
||||
@@ -126,6 +81,7 @@ const topTopics = [
|
||||
{ topic: "Info program bansos", count: 48 },
|
||||
];
|
||||
|
||||
// Busy Hours Data
|
||||
const busyHours = [
|
||||
{ period: "Pagi (08–12)", percentage: 30 },
|
||||
{ period: "Siang (12–16)", percentage: 40 },
|
||||
@@ -138,146 +94,206 @@ const JennaAnalytic = () => {
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
return (
|
||||
<Box className="space-y-6">
|
||||
<Stack gap="xl">
|
||||
{/* KPI Cards */}
|
||||
<Grid gutter="lg">
|
||||
{kpiData.map((kpi) => (
|
||||
<Grid.Col key={kpi.id} span={{ base: 12, md: 6, lg: 3 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" mb="xs">
|
||||
<Text size="sm" fw={500} c="dimmed">
|
||||
{kpi.title}
|
||||
</Text>
|
||||
{React.cloneElement(kpi.icon, {
|
||||
className: "h-6 w-6", // Keeping classes for now, can be replaced by Mantine Icon component if available or styled with sx prop
|
||||
color: "var(--mantine-color-dimmed)", // Set color via prop
|
||||
})}
|
||||
</Group>
|
||||
<Title order={3} fw={700} mt="xs">
|
||||
{kpi.value}
|
||||
</Title>
|
||||
{kpi.delta && (
|
||||
<Text
|
||||
size="xs"
|
||||
c={
|
||||
kpi.deltaType === "positive"
|
||||
? "green"
|
||||
: kpi.deltaType === "negative"
|
||||
? "red"
|
||||
: "dimmed"
|
||||
}
|
||||
mt={4}
|
||||
>
|
||||
{kpi.delta}
|
||||
</Text>
|
||||
)}
|
||||
{kpi.sub && (
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{kpi.sub}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Title order={3} fw={500} mb="md">
|
||||
Interaksi Chatbot
|
||||
</Title>
|
||||
<BarChart
|
||||
h={300}
|
||||
data={chartData}
|
||||
dataKey="day"
|
||||
series={[{ name: "total", color: "blue" }]}
|
||||
withLegend
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Charts and Lists Section */}
|
||||
<Grid gutter="lg">
|
||||
{/* Grafik Interaksi Chatbot (now Bar Chart) */}
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<Stack gap="lg">
|
||||
{/* TOP SECTION - 4 STAT CARDS */}
|
||||
<Grid gutter="md">
|
||||
{kpiData.map((item) => (
|
||||
<Grid.Col key={item.id} span={{ base: 12, sm: 6, lg: 3 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Title order={3} fw={500} mb="md">
|
||||
Jam Tersibuk
|
||||
</Title>
|
||||
<Stack gap="sm">
|
||||
{busyHours.map((item, index) => (
|
||||
<Box key={index}>
|
||||
<Text size="sm">{item.period}</Text>
|
||||
<Group align="center">
|
||||
<Progress value={item.percentage} flex={1} />
|
||||
<Text size="sm" fw={500}>
|
||||
{item.percentage}%
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
<Group justify="space-between" align="flex-start" w="100%">
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text size="xl" fw={700} c={dark ? "white" : "gray.9"}>
|
||||
{item.value}
|
||||
</Text>
|
||||
<Group gap={4} align="flex-start">
|
||||
{item.trend === "positive" && (
|
||||
<TrendingUp size={14} color="#22C55E" />
|
||||
)}
|
||||
<Text
|
||||
size="xs"
|
||||
c={
|
||||
item.trend === "positive"
|
||||
? "green"
|
||||
: dark
|
||||
? "gray.4"
|
||||
: "gray.5"
|
||||
}
|
||||
>
|
||||
{item.subtitle}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
<ThemeIcon
|
||||
color="#1E3A5F"
|
||||
variant="filled"
|
||||
size="lg"
|
||||
radius="xl"
|
||||
>
|
||||
<item.icon style={{ width: "60%", height: "60%" }} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Topik Pertanyaan Terbanyak & Jam Tersibuk */}
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<Stack gap="lg">
|
||||
{/* Topik Pertanyaan Terbanyak */}
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
h="100%"
|
||||
>
|
||||
<Title order={3} fw={500} mb="md">
|
||||
Topik Pertanyaan Terbanyak
|
||||
</Title>
|
||||
<Stack gap="xs">
|
||||
{topTopics.map((item, index) => (
|
||||
<Group
|
||||
key={index}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
p="xs"
|
||||
{/* MAIN CHART - INTERAKSI CHATBOT */}
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4} c={dark ? "white" : "gray.9"}>
|
||||
Interaksi Chatbot
|
||||
</Title>
|
||||
</Group>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
stroke={dark ? "#334155" : "#e5e7eb"}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: dark ? "#1E293B" : "white",
|
||||
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
|
||||
cursor={{ fill: dark ? "#334155" : "#f3f4f6" }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="total"
|
||||
fill="#396aaaff"
|
||||
radius={[8, 8, 0, 0]}
|
||||
maxBarSize={60}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
{/* BOTTOM SECTION - 2 COLUMNS */}
|
||||
<Grid gutter="lg">
|
||||
{/* LEFT: TOPIK PERTANYAAN TERBANYAK */}
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Title order={4} c={dark ? "white" : "gray.9"} mb="md">
|
||||
Topik Pertanyaan Terbanyak
|
||||
</Title>
|
||||
<Stack gap="xs">
|
||||
{topTopics.map((item) => (
|
||||
<Box
|
||||
key={item.topic}
|
||||
p="sm"
|
||||
bg={dark ? "#334155" : "#F1F5F9"}
|
||||
style={{
|
||||
transition: "background-color 0.15s ease",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={500} c={dark ? "white" : "gray.9"}>
|
||||
{item.topic}
|
||||
</Text>
|
||||
<Badge
|
||||
variant="light"
|
||||
color="darmasaba-blue"
|
||||
radius="sm"
|
||||
fw={600}
|
||||
>
|
||||
<Text size="sm" fw={500}>
|
||||
{item.topic}
|
||||
</Text>
|
||||
<Badge variant="light" color="gray">
|
||||
{item.count}x
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Jam Tersibuk */}
|
||||
{item.count}x
|
||||
</Badge>
|
||||
</Group>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT: JAM TERSIBUK */}
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Title order={4} c={dark ? "white" : "gray.9"} mb="md">
|
||||
Jam Tersibuk
|
||||
</Title>
|
||||
<Stack gap="md">
|
||||
{busyHours.map((item) => (
|
||||
<Box key={item.period}>
|
||||
<Group justify="space-between" mb={5}>
|
||||
<Text size="sm" fw={500} c={dark ? "white" : "gray.9"}>
|
||||
{item.period}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}>
|
||||
{item.percentage}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={item.percentage}
|
||||
size="lg"
|
||||
radius="xl"
|
||||
color="#1E3A5F"
|
||||
animated
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default JennaAnalytic;
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Grid,
|
||||
GridCol,
|
||||
Group,
|
||||
List,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
@@ -16,11 +15,8 @@ import {
|
||||
IconAlertTriangle,
|
||||
IconCamera,
|
||||
IconClock,
|
||||
IconEye,
|
||||
IconMapPin,
|
||||
IconShieldLock,
|
||||
} from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
|
||||
const KeamananPage = () => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
@@ -118,138 +114,144 @@ const KeamananPage = () => {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Page Header */}
|
||||
<Group justify="space-between" align="center">
|
||||
<Title order={2} c={dark ? "dark.0" : "black"}>
|
||||
Keamanan Lingkungan Desa
|
||||
</Title>
|
||||
</Group>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<Grid gutter="md">
|
||||
{kpiData.map((kpi, index) => (
|
||||
<GridCol key={index} span={{ base: 12, sm: 6, md: 6 }}>
|
||||
{/* Peta Keamanan CCTV */}
|
||||
<GridCol span={{ base: 12, lg: 6 }}>
|
||||
<Stack gap={"xs"}>
|
||||
{/* KPI Cards */}
|
||||
<Grid gutter="md">
|
||||
{kpiData.map((kpi) => (
|
||||
<GridCol key={kpi.title} span={{ base: 12, sm: 6, md: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
{kpi.subtitle}
|
||||
</Text>
|
||||
<Group gap="xs" align="center">
|
||||
<Text
|
||||
size="xl"
|
||||
fw={700}
|
||||
c={dark ? "dark.0" : "black"}
|
||||
>
|
||||
{kpi.value}
|
||||
</Text>
|
||||
<Text size="sm" c={dark ? "white" : "dimmed"}>
|
||||
{kpi.title}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={kpi.color}
|
||||
size="xl"
|
||||
radius="xl"
|
||||
>
|
||||
{kpi.icon}
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
{kpi.subtitle}
|
||||
</Text>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="xl" fw={700} c={dark ? "dark.0" : "black"}>
|
||||
{kpi.value}
|
||||
</Text>
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
{kpi.title}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={kpi.color}
|
||||
size="xl"
|
||||
radius="xl"
|
||||
>
|
||||
{kpi.icon}
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Grid gutter="md">
|
||||
{/* Peta Keamanan CCTV */}
|
||||
<GridCol span={{ base: 12, lg: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
h="100%"
|
||||
>
|
||||
<Title order={3} mb="md" c={dark ? "dark.0" : "black"}>
|
||||
Peta Keamanan CCTV
|
||||
</Title>
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"} mb="md">
|
||||
Titik Lokasi CCTV
|
||||
</Text>
|
||||
|
||||
{/* Placeholder for map */}
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: dark ? "#2d3748" : "#e2e8f0",
|
||||
borderRadius: "8px",
|
||||
height: "400px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Stack align="center">
|
||||
<IconMapPin
|
||||
size={48}
|
||||
stroke={1.5}
|
||||
color={dark ? "#94a3b8" : "#64748b"}
|
||||
/>
|
||||
<Text c={dark ? "dark.3" : "dimmed"}>Peta Lokasi CCTV</Text>
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"} ta="center">
|
||||
Integrasi dengan Google Maps atau Mapbox akan ditampilkan di
|
||||
sini
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* CCTV Locations List */}
|
||||
<Stack mt="md" gap="sm">
|
||||
<Title order={4} c={dark ? "dark.0" : "black"}>
|
||||
Daftar CCTV
|
||||
<Title order={3} mb="md" c={dark ? "dark.0" : "black"}>
|
||||
Peta Keamanan CCTV
|
||||
</Title>
|
||||
{cctvLocations.map((cctv, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#263852ff" : "#F1F5F9"}
|
||||
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Stack gap={0}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} c={dark ? "dark.0" : "black"}>
|
||||
{cctv.id}
|
||||
<Text size="sm" c={dark ? "white" : "dimmed"} mb="md">
|
||||
Titik Lokasi CCTV
|
||||
</Text>
|
||||
|
||||
{/* Placeholder for map */}
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: dark ? "#2d3748" : "#e2e8f0",
|
||||
borderRadius: "8px",
|
||||
height: "400px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Stack align="center">
|
||||
<IconMapPin
|
||||
size={48}
|
||||
stroke={1.5}
|
||||
color={dark ? "#94a3b8" : "#64748b"}
|
||||
/>
|
||||
<Text c={dark ? "dark.3" : "dimmed"}>Peta Lokasi CCTV</Text>
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"} ta="center">
|
||||
Integrasi dengan Google Maps atau Mapbox akan ditampilkan di
|
||||
sini
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* CCTV Locations List */}
|
||||
<Stack mt="md" gap="sm">
|
||||
<Title order={4} c={dark ? "dark.0" : "black"}>
|
||||
Daftar CCTV
|
||||
</Title>
|
||||
{cctvLocations.map((cctv) => (
|
||||
<Card
|
||||
key={cctv.id}
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#263852ff" : "#F1F5F9"}
|
||||
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Stack gap={0}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} c={dark ? "dark.0" : "black"}>
|
||||
{cctv.id}
|
||||
</Text>
|
||||
<Badge
|
||||
variant="dot"
|
||||
color={cctv.status === "active" ? "green" : "gray"}
|
||||
>
|
||||
{cctv.status === "active" ? "Online" : "Offline"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c={dark ? "white" : "dimmed"}>
|
||||
{cctv.location}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<IconClock size={16} stroke={1.5} />
|
||||
<Text size="sm" c={dark ? "white" : "dimmed"}>
|
||||
{cctv.lastSeen}
|
||||
</Text>
|
||||
<Badge
|
||||
variant="dot"
|
||||
color={cctv.status === "active" ? "green" : "gray"}
|
||||
>
|
||||
{cctv.status === "active" ? "Online" : "Offline"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
{cctv.location}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<IconClock size={16} stroke={1.5} />
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
{cctv.lastSeen}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</GridCol>
|
||||
|
||||
{/* Daftar Laporan Keamanan */}
|
||||
@@ -258,18 +260,18 @@ const KeamananPage = () => {
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Title order={3} mb="md" c={dark ? "dark.0" : "black"}>
|
||||
Laporan Keamanan Lingkungan
|
||||
</Title>
|
||||
|
||||
<Stack gap="sm">
|
||||
{securityReports.map((report, index) => (
|
||||
{securityReports.map((report) => (
|
||||
<Card
|
||||
key={index}
|
||||
key={report.id}
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
@@ -297,19 +299,19 @@ const KeamananPage = () => {
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<IconMapPin size={16} stroke={1.5} />
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
<Text size="sm" c={dark ? "white" : "dimmed"}>
|
||||
{report.location}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<IconClock size={16} stroke={1.5} />
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
<Text size="sm" c={dark ? "white" : "dimmed"}>
|
||||
{report.reportedAt}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"} mt="sm">
|
||||
<Text size="sm" c={dark ? "white" : "dimmed"} mt="sm">
|
||||
{report.date}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
@@ -1,73 +1,69 @@
|
||||
import { BarChart } from "@mantine/charts";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Grid,
|
||||
Group,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconCurrency,
|
||||
IconTrendingDown,
|
||||
IconTrendingUp,
|
||||
} from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
CheckCircle,
|
||||
Coins,
|
||||
PieChart as PieChartIcon,
|
||||
Receipt,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
// Sample Data
|
||||
// KPI Data
|
||||
const kpiData = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Total APBDes",
|
||||
value: "Rp 5.2M",
|
||||
sub: "Tahun 2025",
|
||||
icon: <IconCurrency className="h-6 w-6 text-muted-foreground" />,
|
||||
subtitle: "Tahun 2025",
|
||||
icon: Coins,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Realisasi",
|
||||
value: "68%",
|
||||
sub: "Rp 3.5M dari 5.2M",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="h-6 w-6 text-muted-foreground"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.473-1.688 3.342-.48.485-.926.97-1.378 1.44c-1.472 1.58-2.306 2.787-2.91 3.514-.15.18-.207.33-.207.33A.75.75 0 0 1 15 21h-3c-1.104 0-2.08-.542-2.657-1.455-.139-.201-.264-.406-.38-.614l-.014-.025C8.85 18.067 8.156 17.2 7.5 16.325.728 12.56.728 7.44 7.5 3.675c3.04-.482 5.584.47 7.042 1.956.674.672 1.228 1.462 1.696 2.307.426.786.793 1.582 1.113 2.392h.001Z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
subtitle: "Rp 3.5M dari 5.2M",
|
||||
icon: CheckCircle,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Pemasukan",
|
||||
value: "Rp 580jt",
|
||||
sub: "Bulan ini",
|
||||
delta: "+8%",
|
||||
deltaType: "positive",
|
||||
icon: <IconTrendingUp className="h-6 w-6 text-muted-foreground" />,
|
||||
subtitle: "Bulan ini",
|
||||
trend: "+8%",
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Pengeluaran",
|
||||
value: "Rp 520jt",
|
||||
sub: "Bulan ini",
|
||||
icon: <IconTrendingDown className="h-6 w-6 text-muted-foreground" />,
|
||||
subtitle: "Bulan ini",
|
||||
icon: TrendingDown,
|
||||
},
|
||||
];
|
||||
|
||||
// Income & Expense Data
|
||||
const incomeExpenseData = [
|
||||
{ month: "Apr", income: 450, expense: 380 },
|
||||
{ month: "Mei", income: 520, expense: 420 },
|
||||
@@ -78,6 +74,7 @@ const incomeExpenseData = [
|
||||
{ month: "Okt", income: 580, expense: 520 },
|
||||
];
|
||||
|
||||
// Sector Allocation Data
|
||||
const allocationData = [
|
||||
{ sector: "Pembangunan", amount: 1200 },
|
||||
{ sector: "Kesehatan", amount: 800 },
|
||||
@@ -87,13 +84,7 @@ const allocationData = [
|
||||
{ sector: "Teknologi", amount: 300 },
|
||||
];
|
||||
|
||||
const assistanceFundData = [
|
||||
{ source: "Dana Desa (DD)", amount: 1800, status: "cair" },
|
||||
{ source: "Alokasi Dana Desa (ADD)", amount: 950, status: "cair" },
|
||||
{ source: "Bagi Hasil Pajak", amount: 450, status: "cair" },
|
||||
{ source: "Hibah Provinsi", amount: 300, status: "proses" },
|
||||
];
|
||||
|
||||
// APBDes Report Data
|
||||
const apbdReport = {
|
||||
income: [
|
||||
{ category: "Dana Desa", amount: 1800 },
|
||||
@@ -113,244 +104,410 @@ const apbdReport = {
|
||||
totalExpenses: 2155,
|
||||
};
|
||||
|
||||
// Aid & Grants Data
|
||||
const assistanceFundData = [
|
||||
{ source: "Dana Desa (DD)", amount: 1800, status: "cair" },
|
||||
{ source: "Alokasi Dana Desa (ADD)", amount: 950, status: "cair" },
|
||||
{ source: "Bagi Hasil Pajak", amount: 450, status: "cair" },
|
||||
{ source: "Hibah Provinsi", amount: 300, status: "proses" },
|
||||
];
|
||||
|
||||
const KeuanganAnggaran = () => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
return (
|
||||
<Box>
|
||||
<Stack gap="xl">
|
||||
{/* KPI Cards */}
|
||||
<Grid gutter="lg">
|
||||
{kpiData.map((kpi) => (
|
||||
<Grid.Col key={kpi.id} span={{ base: 12, md: 6, lg: 3 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
h="100%"
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" mb="xs">
|
||||
<Text size="sm" fw={500} c="dimmed">
|
||||
{kpi.title}
|
||||
</Text>
|
||||
{React.cloneElement(kpi.icon, {
|
||||
className: "h-6 w-6",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
})}
|
||||
</Group>
|
||||
<Title order={3} fw={700} mt="xs">
|
||||
{kpi.value}
|
||||
</Title>
|
||||
{kpi.delta && (
|
||||
<Text
|
||||
size="xs"
|
||||
c={
|
||||
kpi.deltaType === "positive"
|
||||
? "green"
|
||||
: kpi.deltaType === "negative"
|
||||
? "red"
|
||||
: "dimmed"
|
||||
}
|
||||
mt={4}
|
||||
>
|
||||
{kpi.delta}
|
||||
</Text>
|
||||
)}
|
||||
{kpi.sub && (
|
||||
<Text size="xs" c="dimmed" mt="auto">
|
||||
{kpi.sub}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Charts Section */}
|
||||
<Grid gutter="lg">
|
||||
{/* Grafik Pemasukan vs Pengeluaran */}
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* TOP SECTION - 4 STAT CARDS */}
|
||||
<Grid gutter="md">
|
||||
{kpiData.map((item) => (
|
||||
<Grid.Col key={item.id} span={{ base: 12, sm: 6, lg: 3 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Title order={3} fw={500} mb="md">
|
||||
Pemasukan vs Pengeluaran
|
||||
</Title>
|
||||
<BarChart
|
||||
h={300}
|
||||
data={incomeExpenseData}
|
||||
dataKey="month"
|
||||
series={[
|
||||
{ name: "income", color: "green", label: "Pemasukan" },
|
||||
{ name: "expense", color: "red", label: "Pengeluaran" },
|
||||
]}
|
||||
withLegend
|
||||
/>
|
||||
<Group justify="space-between" align="flex-start" w="100%">
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text size="xl" fw={700} c={dark ? "white" : "gray.9"}>
|
||||
{item.value}
|
||||
</Text>
|
||||
<Group gap={4} align="flex-start">
|
||||
{item.trend && <TrendingUp size={14} color="#22C55E" />}
|
||||
<Text
|
||||
size="xs"
|
||||
c={item.trend ? "green" : dark ? "gray.4" : "gray.5"}
|
||||
>
|
||||
{item.subtitle}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
<ThemeIcon
|
||||
color="#1E3A5F"
|
||||
variant="filled"
|
||||
size="lg"
|
||||
radius="xl"
|
||||
>
|
||||
<item.icon style={{ width: "60%", height: "60%" }} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Alokasi Anggaran Per Sektor */}
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Title order={3} fw={500} mb="md">
|
||||
{/* MAIN CHART SECTION */}
|
||||
<Grid gutter="lg">
|
||||
{/* LEFT: PEMASUKAN DAN PENGELUARAN (70%) */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Group gap="xs" mb="md">
|
||||
<ThemeIcon color="#1E3A5F" variant="filled" size="sm" radius="sm">
|
||||
<PieChartIcon size={14} />
|
||||
</ThemeIcon>
|
||||
<Title order={4} c={dark ? "white" : "gray.9"}>
|
||||
Pemasukan dan Pengeluaran
|
||||
</Title>
|
||||
</Group>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={incomeExpenseData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
stroke={dark ? "#334155" : "#e5e7eb"}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{
|
||||
fill: dark ? "#E2E8F0" : "#374151",
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{
|
||||
fill: dark ? "#E2E8F0" : "#374151",
|
||||
fontSize: 12,
|
||||
}}
|
||||
tickFormatter={(value) => `Rp ${value}jt`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: dark ? "#1E293B" : "white",
|
||||
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
|
||||
formatter={(value: number | undefined) => [
|
||||
`Rp ${value}jt`,
|
||||
"",
|
||||
]}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="income"
|
||||
stroke="#22C55E"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "#22C55E", strokeWidth: 2, r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
name="Pemasukan"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="expense"
|
||||
stroke="#EF4444"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "#EF4444", strokeWidth: 2, r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
name="Pengeluaran"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT: ALOKASI ANGGARAN PER SEKTOR (30%) */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Group gap="xs" mb="md">
|
||||
<ThemeIcon color="#1E3A5F" variant="filled" size="sm" radius="sm">
|
||||
<PieChartIcon size={14} />
|
||||
</ThemeIcon>
|
||||
<Title order={4} c={dark ? "white" : "gray.9"}>
|
||||
Alokasi Anggaran Per Sektor
|
||||
</Title>
|
||||
<BarChart
|
||||
h={300}
|
||||
data={allocationData}
|
||||
dataKey="sector"
|
||||
series={[
|
||||
{ name: "amount", color: "darmasaba-navy", label: "Jumlah" },
|
||||
]}
|
||||
withLegend
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Group>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={allocationData} layout="vertical">
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={false}
|
||||
stroke={dark ? "#334155" : "#e5e7eb"}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{
|
||||
fill: dark ? "#E2E8F0" : "#374151",
|
||||
fontSize: 12,
|
||||
}}
|
||||
tickFormatter={(value) => `${value}`}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="sector"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{
|
||||
fill: dark ? "#E2E8F0" : "#374151",
|
||||
fontSize: 11,
|
||||
}}
|
||||
width={100}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: dark ? "#1E293B" : "white",
|
||||
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
formatter={(value: number | undefined) => [
|
||||
`Rp ${value}jt`,
|
||||
"Jumlah",
|
||||
]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="amount"
|
||||
fill="#396aaaff"
|
||||
radius={[0, 8, 8, 0]}
|
||||
maxBarSize={30}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid gutter="lg">
|
||||
{/* Dana Bantuan & Hibah */}
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Title order={3} fw={500} mb="md">
|
||||
Dana Bantuan & Hibah
|
||||
{/* BOTTOM SECTION */}
|
||||
<Grid gutter="lg">
|
||||
{/* LEFT: LAPORAN APBDES */}
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Group gap="xs" mb="md">
|
||||
<ThemeIcon color="#1E3A5F" variant="filled" size="sm" radius="sm">
|
||||
<Receipt size={14} />
|
||||
</ThemeIcon>
|
||||
<Title order={4} c={dark ? "white" : "gray.9"}>
|
||||
Laporan APBDes
|
||||
</Title>
|
||||
<Stack gap="sm">
|
||||
{assistanceFundData.map((fund, index) => (
|
||||
<Group
|
||||
key={index}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
p="sm"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
}}
|
||||
>
|
||||
</Group>
|
||||
|
||||
<Grid gutter="md">
|
||||
{/* Pendapatan */}
|
||||
<Grid.Col span={6}>
|
||||
<Card p="sm" radius="lg" bg={dark ? "#064E3B" : "#DCFCE7"}>
|
||||
<Title order={5} c="#22C55E" mb="sm">
|
||||
Pendapatan
|
||||
</Title>
|
||||
<Stack gap="xs">
|
||||
{apbdReport.income.map((item) => (
|
||||
<Group key={item.category} justify="space-between">
|
||||
<Text size="sm" c={dark ? "gray.3" : "gray.7"}>
|
||||
{item.category}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c="#22C55E">
|
||||
Rp {item.amount.toLocaleString()}jt
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt="sm"
|
||||
pt="sm"
|
||||
style={{
|
||||
borderTop: `1px solid ${dark ? "#065F46" : "#86EFAC"}`,
|
||||
}}
|
||||
>
|
||||
<Text fw={700} c="#22C55E">
|
||||
Total:
|
||||
</Text>
|
||||
<Text fw={700} c="#22C55E">
|
||||
Rp {apbdReport.totalIncome.toLocaleString()}jt
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Belanja */}
|
||||
<Grid.Col span={6}>
|
||||
<Card p="sm" radius="lg" bg={dark ? "#7F1D1D" : "#FEE2E2"}>
|
||||
<Title order={5} c="#EF4444" mb="sm">
|
||||
Belanja
|
||||
</Title>
|
||||
<Stack gap="xs">
|
||||
{apbdReport.expenses.map((item) => (
|
||||
<Group key={item.category} justify="space-between">
|
||||
<Text size="sm" c={dark ? "gray.3" : "gray.7"}>
|
||||
{item.category}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c="#EF4444">
|
||||
Rp {item.amount.toLocaleString()}jt
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt="sm"
|
||||
pt="sm"
|
||||
style={{
|
||||
borderTop: `1px solid ${dark ? "#991B1B" : "#FCA5A5"}`,
|
||||
}}
|
||||
>
|
||||
<Text fw={700} c="#EF4444">
|
||||
Total:
|
||||
</Text>
|
||||
<Text fw={700} c="#EF4444">
|
||||
Rp {apbdReport.totalExpenses.toLocaleString()}jt
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{/* Saldo */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt="md"
|
||||
pt="md"
|
||||
style={{
|
||||
borderTop: `1px solid ${dark ? "#334155" : "#e5e7eb"}`,
|
||||
}}
|
||||
>
|
||||
<Text fw={700} c={dark ? "white" : "gray.9"}>
|
||||
Saldo:
|
||||
</Text>
|
||||
<Text
|
||||
fw={700}
|
||||
size="lg"
|
||||
c={
|
||||
apbdReport.totalIncome > apbdReport.totalExpenses
|
||||
? "#22C55E"
|
||||
: "#EF4444"
|
||||
}
|
||||
>
|
||||
Rp{" "}
|
||||
{(
|
||||
apbdReport.totalIncome - apbdReport.totalExpenses
|
||||
).toLocaleString()}
|
||||
jt
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT: DANA BANTUAN DAN HIBAH */}
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Group gap="xs" mb="md">
|
||||
<ThemeIcon color="#1E3A5F" variant="filled" size="sm" radius="sm">
|
||||
<Coins size={14} />
|
||||
</ThemeIcon>
|
||||
<Title order={4} c={dark ? "white" : "gray.9"}>
|
||||
Dana Bantuan dan Hibah
|
||||
</Title>
|
||||
</Group>
|
||||
<Stack gap="sm">
|
||||
{assistanceFundData.map((fund) => (
|
||||
<Card
|
||||
key={fund.source}
|
||||
p="sm"
|
||||
radius="lg"
|
||||
bg={dark ? "#334155" : "#F1F5F9"}
|
||||
style={{
|
||||
borderColor: "transparent",
|
||||
transition: "background-color 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Box>
|
||||
<Text size="sm" fw={500}>
|
||||
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}>
|
||||
{fund.source}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text size="xs" c="dimmed">
|
||||
Rp {fund.amount.toLocaleString()}jt
|
||||
</Text>
|
||||
</Box>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={fund.status === "cair" ? "green" : "yellow"}
|
||||
radius="sm"
|
||||
fw={600}
|
||||
>
|
||||
{fund.status}
|
||||
{fund.status === "cair" ? "Cair" : "Proses"}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Laporan APBDes */}
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Title order={3} fw={500} mb="md">
|
||||
Laporan APBDes
|
||||
</Title>
|
||||
|
||||
<Box mb="md">
|
||||
<Title order={4} mb="sm">
|
||||
Pendapatan
|
||||
</Title>
|
||||
<Stack gap="xs">
|
||||
{apbdReport.income.map((item, index) => (
|
||||
<Group key={index} justify="space-between">
|
||||
<Text size="sm">{item.category}</Text>
|
||||
<Text size="sm" c="green">
|
||||
Rp {item.amount.toLocaleString()}jt
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Group justify="space-between" mt="sm">
|
||||
<Text fw={700}>Total Pendapatan:</Text>
|
||||
<Text fw={700} c="green">
|
||||
Rp {apbdReport.totalIncome.toLocaleString()}jt
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Title order={4} mb="sm">
|
||||
Belanja
|
||||
</Title>
|
||||
<Stack gap="xs">
|
||||
{apbdReport.expenses.map((item, index) => (
|
||||
<Group key={index} justify="space-between">
|
||||
<Text size="sm">{item.category}</Text>
|
||||
<Text size="sm" c="red">
|
||||
Rp {item.amount.toLocaleString()}jt
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Group justify="space-between" mt="sm">
|
||||
<Text fw={700}>Total Belanja:</Text>
|
||||
<Text fw={700} c="red">
|
||||
Rp {apbdReport.totalExpenses.toLocaleString()}jt
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
mt="md"
|
||||
pt="md"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-gray-3)" }}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text fw={700}>Saldo:</Text>
|
||||
<Text
|
||||
fw={700}
|
||||
c={
|
||||
apbdReport.totalIncome > apbdReport.totalExpenses
|
||||
? "green"
|
||||
: "red"
|
||||
}
|
||||
>
|
||||
Rp{" "}
|
||||
{(
|
||||
apbdReport.totalIncome - apbdReport.totalExpenses
|
||||
).toLocaleString()}
|
||||
jt
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,537 +1,133 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Card,
|
||||
Divider,
|
||||
Grid,
|
||||
GridCol,
|
||||
Group,
|
||||
List,
|
||||
Badge as MantineBadge,
|
||||
Progress as MantineProgress,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, Grid, Stack } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import { ActivityCard } from "./kinerja-divisi/activity-card";
|
||||
import { ArchiveCard } from "./kinerja-divisi/archive-card";
|
||||
import { DiscussionPanel } from "./kinerja-divisi/discussion-panel";
|
||||
import { DivisionList } from "./kinerja-divisi/division-list";
|
||||
import { DocumentChart } from "./kinerja-divisi/document-chart";
|
||||
import { EventCard } from "./kinerja-divisi/event-card";
|
||||
import { ProgressChart } from "./kinerja-divisi/progress-chart";
|
||||
|
||||
// Data for arsip digital (Section 5)
|
||||
const archiveData = [
|
||||
{ name: "Surat Keputusan" },
|
||||
{ name: "Dokumentasi" },
|
||||
{ name: "Laporan Keuangan" },
|
||||
{ name: "Notulensi Rapat" },
|
||||
];
|
||||
|
||||
interface Activity {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
progress: number;
|
||||
status: "SELESAI" | "BERJALAN" | "TERTUNDA";
|
||||
}
|
||||
|
||||
interface EventData {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
}
|
||||
|
||||
const KinerjaDivisi = () => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
const [activities, setActivities] = useState<Activity[]>([]);
|
||||
const [todayEvents, setTodayEvents] = useState<EventData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Data for division progress chart
|
||||
const divisionProgressData = [
|
||||
{ name: "Sekretariat", selesai: 12, berjalan: 5, tertunda: 2 },
|
||||
{ name: "Keuangan", selesai: 8, berjalan: 7, tertunda: 1 },
|
||||
{ name: "Sosial", selesai: 10, berjalan: 3, tertunda: 4 },
|
||||
{ name: "Humas", selesai: 6, berjalan: 9, tertunda: 3 },
|
||||
];
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [activityRes, eventRes] = await Promise.all([
|
||||
apiClient.GET("/api/division/activities"),
|
||||
apiClient.GET("/api/event/today"),
|
||||
]);
|
||||
|
||||
// Division task summaries
|
||||
const divisionTasks = [
|
||||
{
|
||||
name: "Sekretariat",
|
||||
tasks: [
|
||||
{ title: "Laporan Bulanan", status: "selesai" },
|
||||
{ title: "Arsip Dokumen", status: "berjalan" },
|
||||
{ title: "Undangan Rapat", status: "tertunda" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Keuangan",
|
||||
tasks: [
|
||||
{ title: "Laporan APBDes", status: "selesai" },
|
||||
{ title: "Verifikasi Dana", status: "tertunda" },
|
||||
{ title: "Pengeluaran Harian", status: "berjalan" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Sosial",
|
||||
tasks: [
|
||||
{ title: "Program Bantuan", status: "selesai" },
|
||||
{ title: "Kegiatan Posyandu", status: "berjalan" },
|
||||
{ title: "Monitoring Stunting", status: "tertunda" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Humas",
|
||||
tasks: [
|
||||
{ title: "Publikasi Kegiatan", status: "selesai" },
|
||||
{ title: "Koordinasi Media", status: "berjalan" },
|
||||
{ title: "Laporan Kegiatan", status: "tertunda" },
|
||||
],
|
||||
},
|
||||
];
|
||||
if (activityRes.data?.data) {
|
||||
setActivities(activityRes.data.data as Activity[]);
|
||||
}
|
||||
if (eventRes.data?.data) {
|
||||
setTodayEvents(eventRes.data.data as EventData[]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch kinerja divisi data", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Archive items
|
||||
const archiveItems = [
|
||||
{ name: "Surat Keputusan", count: 12 },
|
||||
{ name: "Laporan Keuangan", count: 8 },
|
||||
{ name: "Dokumentasi", count: 24 },
|
||||
{ name: "Notulensi Rapat", count: 15 },
|
||||
];
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Activity progress
|
||||
const activityProgress = [
|
||||
{
|
||||
name: "Pembangunan Jalan",
|
||||
progress: 75,
|
||||
date: "15 Feb 2026",
|
||||
status: "berjalan",
|
||||
},
|
||||
{
|
||||
name: "Posyandu Bulanan",
|
||||
progress: 100,
|
||||
date: "10 Feb 2026",
|
||||
status: "selesai",
|
||||
},
|
||||
{
|
||||
name: "Vaksinasi Massal",
|
||||
progress: 45,
|
||||
date: "20 Feb 2026",
|
||||
status: "berjalan",
|
||||
},
|
||||
{
|
||||
name: "Festival Budaya",
|
||||
progress: 20,
|
||||
date: "5 Mar 2026",
|
||||
status: "berjalan",
|
||||
},
|
||||
];
|
||||
|
||||
// Document statistics
|
||||
const documentStats = [
|
||||
{ name: "Gambar", value: 42 },
|
||||
{ name: "Dokumen", value: 87 },
|
||||
];
|
||||
|
||||
// Activity progress statistics
|
||||
const activityProgressStats = [
|
||||
{ name: "Selesai", value: 12, fill: "#10B981" },
|
||||
{ name: "Dikerjakan", value: 8, fill: "#F59E0B" },
|
||||
{ name: "Segera Dikerjakan", value: 5, fill: "#EF4444" },
|
||||
{ name: "Dibatalkan", value: 2, fill: "#6B7280" },
|
||||
];
|
||||
|
||||
const COLORS = ["#10B981", "#F59E0B", "#EF4444", "#6B7280"];
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
selesai: "green",
|
||||
berjalan: "blue",
|
||||
tertunda: "red",
|
||||
proses: "yellow",
|
||||
};
|
||||
|
||||
// Discussion data
|
||||
const discussions = [
|
||||
{
|
||||
title: "Pembahasan APBDes 2026",
|
||||
sender: "Kepala Desa",
|
||||
timestamp: "2 jam yang lalu",
|
||||
},
|
||||
{
|
||||
title: "Kegiatan Posyandu",
|
||||
sender: "Divisi Sosial",
|
||||
timestamp: "5 jam yang lalu",
|
||||
},
|
||||
{
|
||||
title: "Festival Budaya",
|
||||
sender: "Divisi Humas",
|
||||
timestamp: "1 hari yang lalu",
|
||||
},
|
||||
];
|
||||
|
||||
// Today's agenda
|
||||
const todayAgenda = [
|
||||
{ time: "09:00", event: "Rapat Evaluasi Bulanan" },
|
||||
{ time: "14:00", event: "Koordinasi Program Bantuan" },
|
||||
];
|
||||
// Format events for EventCard
|
||||
const formattedEvents = todayEvents.map((event) => ({
|
||||
time: dayjs(event.startDate).format("HH:mm"),
|
||||
event: event.title,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Grafik Progres Tugas per Divisi */}
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Title order={4} mb="md" c={dark ? "white" : "darmasaba-navy"}>
|
||||
Grafik Progres Tugas per Divisi
|
||||
</Title>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={divisionProgressData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
stroke={dark ? "#141D34" : "white"}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{
|
||||
fill: dark
|
||||
? "var(--mantine-color-text)"
|
||||
: "var(--mantine-color-text)",
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{
|
||||
fill: dark
|
||||
? "var(--mantine-color-text)"
|
||||
: "var(--mantine-color-text)",
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={
|
||||
dark
|
||||
? {
|
||||
backgroundColor: "var(--mantine-color-dark-7)",
|
||||
borderColor: "var(--mantine-color-dark-6)",
|
||||
}
|
||||
: {}
|
||||
{/* SECTION 1 — PROGRAM KEGIATAN */}
|
||||
<Grid gutter="md">
|
||||
{activities.slice(0, 4).map((kegiatan) => (
|
||||
<Grid.Col key={kegiatan.id} span={{ base: 12, md: 6, lg: 3 }}>
|
||||
<ActivityCard
|
||||
title={kegiatan.title}
|
||||
date={dayjs(kegiatan.createdAt).format("D MMMM YYYY")}
|
||||
progress={kegiatan.progress}
|
||||
status={
|
||||
kegiatan.status === "SELESAI"
|
||||
? "Selesai"
|
||||
: kegiatan.status === "BERJALAN"
|
||||
? "Berjalan"
|
||||
: "Tertunda"
|
||||
}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="selesai"
|
||||
stackId="a"
|
||||
fill="#10B981"
|
||||
name="Selesai"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="berjalan"
|
||||
stackId="a"
|
||||
fill="#3B82F6"
|
||||
name="Berjalan"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="tertunda"
|
||||
stackId="a"
|
||||
fill="#EF4444"
|
||||
name="Tertunda"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
{/* Ringkasan Tugas per Divisi */}
|
||||
<Grid gutter="md">
|
||||
{divisionTasks.map((division, index) => (
|
||||
<GridCol key={index} span={{ base: 12, md: 6, lg: 3 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
h="100%"
|
||||
>
|
||||
<Title order={4} mb="sm" c={dark ? "white" : "darmasaba-navy"}>
|
||||
{division.name}
|
||||
</Title>
|
||||
<Stack gap="sm">
|
||||
{division.tasks.map((task, taskIndex) => (
|
||||
<Box key={taskIndex}>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c={dark ? "white" : "darmasaba-navy"}>
|
||||
{task.title}
|
||||
</Text>
|
||||
<MantineBadge
|
||||
color={STATUS_COLORS[task.status] || "gray"}
|
||||
variant="light"
|
||||
>
|
||||
{task.status}
|
||||
</MantineBadge>
|
||||
</Group>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
))}
|
||||
{!loading && activities.length === 0 && (
|
||||
<Grid.Col span={12}>
|
||||
<Card p="md" radius="xl" withBorder ta="center" c="dimmed">
|
||||
Tidak ada aktivitas terbaru
|
||||
</Card>
|
||||
</GridCol>
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* SECTION 2 — GRID DASHBOARD (3 Columns) */}
|
||||
<Grid gutter="lg">
|
||||
{/* Left Column - Division List */}
|
||||
<Grid.Col span={{ base: 12, lg: 3 }}>
|
||||
<DivisionList />
|
||||
</Grid.Col>
|
||||
|
||||
{/* Middle Column - Document Chart */}
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<DocumentChart />
|
||||
</Grid.Col>
|
||||
|
||||
{/* Right Column - Progress Chart */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<ProgressChart />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{/* SECTION 3 — DISCUSSION PANEL */}
|
||||
<DiscussionPanel />
|
||||
|
||||
{/* SECTION 4 — ACARA HARI INI */}
|
||||
<EventCard agendas={formattedEvents} />
|
||||
|
||||
{/* SECTION 5 — ARSIP DIGITAL PERANGKAT DESA */}
|
||||
<Grid gutter="md">
|
||||
{archiveData.map((item) => (
|
||||
<Grid.Col key={item.name} span={{ base: 12, md: 6 }}>
|
||||
<ArchiveCard item={item} />
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Arsip Digital Perangkat Desa */}
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Title order={4} mb="md" c={dark ? "white" : "darmasaba-navy"}>
|
||||
Arsip Digital Perangkat Desa
|
||||
</Title>
|
||||
<Grid gutter="md">
|
||||
{archiveItems.map((item, index) => (
|
||||
<GridCol key={index} span={{ base: 12, md: 6, lg: 3 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#263852ff" : "#F1F5F9"}
|
||||
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text c={dark ? "white" : "darmasaba-navy"} fw={500}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text c={dark ? "white" : "darmasaba-navy"} fw={700}>
|
||||
{item.count}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
</Card>
|
||||
|
||||
{/* Kartu Progres Kegiatan */}
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Title order={4} mb="md" c={dark ? "white" : "darmasaba-navy"}>
|
||||
Progres Kegiatan / Program
|
||||
</Title>
|
||||
<Stack gap="md">
|
||||
{activityProgress.map((activity, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#263852ff" : "#F1F5F9"}
|
||||
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
|
||||
>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text c={dark ? "white" : "darmasaba-navy"} fw={500}>
|
||||
{activity.name}
|
||||
</Text>
|
||||
<MantineBadge
|
||||
color={STATUS_COLORS[activity.status] || "gray"}
|
||||
variant="light"
|
||||
>
|
||||
{activity.status}
|
||||
</MantineBadge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<MantineProgress
|
||||
value={activity.progress}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
color={activity.progress === 100 ? "green" : "blue"}
|
||||
w="calc(100% - 80px)"
|
||||
/>
|
||||
<Text size="sm" c={dark ? "white" : "darmasaba-navy"}>
|
||||
{activity.progress}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt="sm">
|
||||
{activity.date}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Statistik Dokumen & Progres Kegiatan */}
|
||||
<Grid gutter="md">
|
||||
<GridCol span={{ base: 12, lg: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Title order={4} mb="md" c={dark ? "white" : "darmasaba-navy"}>
|
||||
Jumlah Dokumen
|
||||
</Title>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={documentStats}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
stroke={dark ? "#141D34" : "white"}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{
|
||||
fill: dark
|
||||
? "var(--mantine-color-text)"
|
||||
: "var(--mantine-color-text)",
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{
|
||||
fill: dark
|
||||
? "var(--mantine-color-text)"
|
||||
: "var(--mantine-color-text)",
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={
|
||||
dark
|
||||
? {
|
||||
backgroundColor: "var(--mantine-color-dark-7)",
|
||||
borderColor: "var(--mantine-color-dark-6)",
|
||||
}
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={
|
||||
dark
|
||||
? "var(--mantine-color-blue-6)"
|
||||
: "var(--mantine-color-blue-filled)"
|
||||
}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</GridCol>
|
||||
|
||||
<GridCol span={{ base: 12, lg: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Title order={4} mb="md" c={dark ? "white" : "darmasaba-navy"}>
|
||||
Progres Kegiatan
|
||||
</Title>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart
|
||||
margin={{ top: 20, right: 80, bottom: 20, left: 80 }}
|
||||
>
|
||||
<Pie
|
||||
data={activityProgressStats}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine
|
||||
outerRadius={65}
|
||||
dataKey="value"
|
||||
label={({ name, percent }) =>
|
||||
`${name}: ${percent ? (percent * 100).toFixed(0) : "0"}%`
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={
|
||||
dark
|
||||
? {
|
||||
backgroundColor: "var(--mantine-color-dark-7)",
|
||||
borderColor: "var(--mantine-color-dark-6)",
|
||||
}
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</GridCol>
|
||||
</Grid>
|
||||
|
||||
{/* Diskusi Internal */}
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Title order={4} mb="md" c={dark ? "white" : "darmasaba-navy"}>
|
||||
Diskusi Internal
|
||||
</Title>
|
||||
<Stack gap="sm">
|
||||
{discussions.map((discussion, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#263852ff" : "#F1F5F9"}
|
||||
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text c={dark ? "white" : "darmasaba-navy"} fw={500}>
|
||||
{discussion.title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{discussion.timestamp}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{discussion.sender}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Agenda / Acara Hari Ini */}
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Title order={4} mb="md" c={dark ? "white" : "darmasaba-navy"}>
|
||||
Agenda / Acara Hari Ini
|
||||
</Title>
|
||||
{todayAgenda.length > 0 ? (
|
||||
<Stack gap="sm">
|
||||
{todayAgenda.map((agenda, index) => (
|
||||
<Group key={index} align="flex-start">
|
||||
<Box w={60}>
|
||||
<Text c="dimmed">{agenda.time}</Text>
|
||||
</Box>
|
||||
<Divider orientation="vertical" mx="sm" />
|
||||
<Text c={dark ? "white" : "darmasaba-navy"}>
|
||||
{agenda.event}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text c="dimmed" ta="center" py="md">
|
||||
Tidak ada acara hari ini
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
100
src/components/kinerja-divisi/activity-card.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Progress,
|
||||
Text,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
|
||||
interface ActivityCardProps {
|
||||
title: string;
|
||||
date: string;
|
||||
progress: number;
|
||||
status: "Selesai" | "Berjalan" | "Tertunda";
|
||||
}
|
||||
|
||||
export function ActivityCard({
|
||||
title,
|
||||
date,
|
||||
progress,
|
||||
status,
|
||||
}: ActivityCardProps) {
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case "Selesai":
|
||||
return "#22C55E";
|
||||
case "Berjalan":
|
||||
return "#3B82F6";
|
||||
case "Tertunda":
|
||||
return "#EF4444";
|
||||
default:
|
||||
return "#9CA3AF";
|
||||
}
|
||||
};
|
||||
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
return (
|
||||
<Card
|
||||
radius="xl"
|
||||
p={0}
|
||||
withBorder={false}
|
||||
style={{
|
||||
backgroundColor: dark ? "#334155" : "white",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
h={"100%"}
|
||||
>
|
||||
{/* 🔵 HEADER */}
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: "#1E3A5F",
|
||||
padding: "16px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Text c="white" fw={700} size="md">
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* CONTENT */}
|
||||
<Box p="md">
|
||||
{/* PROGRESS */}
|
||||
<Progress
|
||||
value={progress}
|
||||
radius="xl"
|
||||
size="lg"
|
||||
color="orange"
|
||||
styles={{
|
||||
root: {
|
||||
height: 16,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* FOOTER */}
|
||||
<Group justify="space-between" mt="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{date}
|
||||
</Text>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: getStatusColor(),
|
||||
color: "white",
|
||||
padding: "4px 12px",
|
||||
borderRadius: 999,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
42
src/components/kinerja-divisi/archive-card.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Card, Group, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import { FileText } from "lucide-react";
|
||||
|
||||
interface ArchiveItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ArchiveCardProps {
|
||||
item: ArchiveItem;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function ArchiveCard({ item, onClick }: ArchiveCardProps) {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||
boxShadow: dark
|
||||
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
cursor: "pointer",
|
||||
transition: "transform 0.2s, box-shadow 0.2s",
|
||||
}}
|
||||
h="100%"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Group gap="md">
|
||||
<FileText size={32} color={dark ? "#60A5FA" : "#3B82F6"} />
|
||||
<Text size="sm" fw={500} c={dark ? "white" : "#1E3A5F"}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
125
src/components/kinerja-divisi/discussion-panel.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { format } from "date-fns";
|
||||
import { id } from "date-fns/locale";
|
||||
import { MessageCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
|
||||
interface DiscussionItem {
|
||||
id: string;
|
||||
message: string;
|
||||
sender: string;
|
||||
date: string;
|
||||
division: string | null;
|
||||
isResolved: boolean;
|
||||
}
|
||||
|
||||
export function DiscussionPanel() {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const [discussions, setDiscussions] = useState<DiscussionItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDiscussions() {
|
||||
try {
|
||||
const res = await apiClient.GET("/api/division/discussions");
|
||||
if (res.data?.data) {
|
||||
setDiscussions(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch discussions", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchDiscussions();
|
||||
}, []);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
return format(new Date(dateString), "dd MMM yyyy", { locale: id });
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: dark
|
||||
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Group gap="xs" mb="md">
|
||||
<MessageCircle size={20} color={dark ? "#E2E8F0" : "#1E3A5F"} />
|
||||
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"}>
|
||||
Diskusi
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap="sm">
|
||||
{loading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : discussions.length > 0 ? (
|
||||
discussions.map((discussion) => (
|
||||
<Card
|
||||
key={discussion.id}
|
||||
p="sm"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#334155" : "#F1F5F9"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "#F1F5F9",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
c={dark ? "white" : "#1E3A5F"}
|
||||
fw={500}
|
||||
mb="xs"
|
||||
lineClamp={2}
|
||||
>
|
||||
{discussion.message}
|
||||
</Text>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{discussion.sender}
|
||||
{discussion.division && (
|
||||
<Text span size="xs" c="dimmed" ml="xs">
|
||||
• {discussion.division}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDate(discussion.date)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
Tidak ada diskusi
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
109
src/components/kinerja-divisi/division-list.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
|
||||
interface DivisionItem {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface DivisionApiResponse {
|
||||
name: string;
|
||||
activityCount: number;
|
||||
_count?: {
|
||||
activities: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function DivisionList() {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const [divisions, setDivisions] = useState<DivisionItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDivisions() {
|
||||
try {
|
||||
const { data } = await apiClient.GET("/api/division/");
|
||||
if (data?.data) {
|
||||
const mapped = (data.data as DivisionApiResponse[]).map((div) => ({
|
||||
name: div.name,
|
||||
count: div.activityCount || 0,
|
||||
}));
|
||||
setDivisions(mapped);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch divisions", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchDivisions();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: dark
|
||||
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"} mb="md">
|
||||
Divisi Teraktif
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
{loading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : divisions.length > 0 ? (
|
||||
divisions.map((division) => (
|
||||
<Group
|
||||
key={division.name}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
backgroundColor: dark ? "#334155" : "#F1F5F9",
|
||||
transition: "background-color 0.2s",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c={dark ? "white" : "#1E3A5F"}>
|
||||
{division.name}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"}>
|
||||
{division.count}
|
||||
</Text>
|
||||
<ChevronRight size={16} color={dark ? "#94A3B8" : "#64748B"} />
|
||||
</Group>
|
||||
</Group>
|
||||
))
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
Tidak ada data divisi
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
116
src/components/kinerja-divisi/document-chart.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Text,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
|
||||
interface DocumentData {
|
||||
name: string;
|
||||
jumlah: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function DocumentChart() {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const [data, setData] = useState<DocumentData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDocumentStats() {
|
||||
try {
|
||||
const res = await apiClient.GET("/api/division/documents/stats");
|
||||
if (res.data?.data) {
|
||||
setData(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch document stats", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchDocumentStats();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: dark
|
||||
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"} mb="md">
|
||||
Jumlah Dokumen
|
||||
</Text>
|
||||
{loading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : data.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
stroke={dark ? "#334155" : "#e5e7eb"}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: dark ? "#1E293B" : "white",
|
||||
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
|
||||
/>
|
||||
<Bar dataKey="jumlah" radius={[4, 4, 0, 0]}>
|
||||
{data.map((entry) => (
|
||||
<Cell key={`cell-${entry.name}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Group justify="center" py="xl">
|
||||
<Text size="sm" c="dimmed">
|
||||
Tidak ada dokumen
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
70
src/components/kinerja-divisi/event-card.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { Calendar } from "lucide-react";
|
||||
|
||||
interface AgendaItem {
|
||||
time: string;
|
||||
event: string;
|
||||
}
|
||||
|
||||
interface EventCardProps {
|
||||
agendas?: AgendaItem[];
|
||||
}
|
||||
|
||||
export function EventCard({ agendas = [] }: EventCardProps) {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: dark
|
||||
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Group gap="xs" mb="md">
|
||||
<Calendar size={20} color={dark ? "#E2E8F0" : "#1E3A5F"} />
|
||||
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"}>
|
||||
Acara Hari Ini
|
||||
</Text>
|
||||
</Group>
|
||||
{agendas.length > 0 ? (
|
||||
<Stack gap="sm">
|
||||
{agendas.map((agenda) => (
|
||||
<Group
|
||||
key={`${agenda.time}-${agenda.event}`}
|
||||
align="flex-start"
|
||||
gap="md"
|
||||
>
|
||||
<Box w={60}>
|
||||
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"}>
|
||||
{agenda.time}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text size="sm" c={dark ? "white" : "#1E3A5F"}>
|
||||
{agenda.event}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text c="dimmed" ta="center" py="md">
|
||||
Tidak ada acara hari ini
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
7
src/components/kinerja-divisi/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { ActivityCard } from "./activity-card";
|
||||
export { ArchiveCard } from "./archive-card";
|
||||
export { DiscussionPanel } from "./discussion-panel";
|
||||
export { DivisionList } from "./division-list";
|
||||
export { DocumentChart } from "./document-chart";
|
||||
export { EventCard } from "./event-card";
|
||||
export { ProgressChart } from "./progress-chart";
|
||||
153
src/components/kinerja-divisi/progress-chart.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
|
||||
interface ProgressData {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ActivityStats {
|
||||
total: number;
|
||||
counts: {
|
||||
selesai: number;
|
||||
berjalan: number;
|
||||
tertunda: number;
|
||||
dibatalkan: number;
|
||||
};
|
||||
percentages: {
|
||||
selesai: number;
|
||||
berjalan: number;
|
||||
tertunda: number;
|
||||
dibatalkan: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function ProgressChart() {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const [data, setData] = useState<ProgressData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchActivityStats() {
|
||||
try {
|
||||
const res = await apiClient.GET("/api/division/activities/stats");
|
||||
if (res.data?.data) {
|
||||
const stats = res.data.data as ActivityStats;
|
||||
const chartData: ProgressData[] = [
|
||||
{
|
||||
name: "Selesai",
|
||||
value: stats.percentages.selesai,
|
||||
color: "#22C55E",
|
||||
},
|
||||
{
|
||||
name: "Dikerjakan",
|
||||
value: stats.percentages.berjalan,
|
||||
color: "#F59E0B",
|
||||
},
|
||||
{
|
||||
name: "Segera Dikerjakan",
|
||||
value: stats.percentages.tertunda,
|
||||
color: "#3B82F6",
|
||||
},
|
||||
{
|
||||
name: "Dibatalkan",
|
||||
value: stats.percentages.dibatalkan,
|
||||
color: "#EF4444",
|
||||
},
|
||||
];
|
||||
setData(chartData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch activity stats", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchActivityStats();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: dark
|
||||
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
}}
|
||||
h="100%"
|
||||
>
|
||||
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"} mb="md">
|
||||
Progres Kegiatan
|
||||
</Text>
|
||||
{loading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((entry) => (
|
||||
<Cell key={`cell-${entry.name}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: dark ? "#1E293B" : "white",
|
||||
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<Stack gap="xs" mt="md">
|
||||
{data.map((item) => (
|
||||
<Group key={item.name} justify="space-between">
|
||||
<Group gap="xs">
|
||||
<Box
|
||||
w={12}
|
||||
h={12}
|
||||
style={{ backgroundColor: item.color, borderRadius: 2 }}
|
||||
/>
|
||||
<Text size="sm" c={dark ? "white" : "gray.7"}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}>
|
||||
{item.value.toFixed(2)}%
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
66
src/components/layout/main-layout.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { AppShell, Burger, Group, useMantineColorScheme } from "@mantine/core";
|
||||
import type React from "react";
|
||||
import { Header } from "@/components/header";
|
||||
import { Sidebar } from "@/components/sidebar";
|
||||
import { useSidebarFullscreen } from "@/hooks/use-sidebar-fullscreen";
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MainLayout({ children }: MainLayoutProps) {
|
||||
const {
|
||||
opened,
|
||||
toggleMobile,
|
||||
sidebarCollapsed,
|
||||
toggleSidebar,
|
||||
handleMainClick,
|
||||
} = useSidebarFullscreen();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
|
||||
const headerBgColor = colorScheme === "dark" ? "#11192D" : "#19355E";
|
||||
const navbarBgColor = colorScheme === "dark" ? "#11192D" : "white";
|
||||
const mainBgColor = colorScheme === "dark" ? "#11192D" : "#edf3f8ff";
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 60 }}
|
||||
navbar={{
|
||||
width: 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !opened, desktop: sidebarCollapsed },
|
||||
}}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header bg={headerBgColor}>
|
||||
<Group h="100%" px="md">
|
||||
<Burger
|
||||
opened={opened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
<Header onSidebarToggle={toggleSidebar} />
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar
|
||||
p="md"
|
||||
bg={navbarBgColor}
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||
<Sidebar />
|
||||
</div>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main
|
||||
bg={mainBgColor}
|
||||
onClick={handleMainClick}
|
||||
style={{ cursor: sidebarCollapsed ? "default" : "pointer" }}
|
||||
>
|
||||
{children}
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,189 +1,78 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconEdit,
|
||||
IconInfoCircle,
|
||||
IconTrash,
|
||||
IconUser,
|
||||
IconUserPlus,
|
||||
} from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { Box, Button, Group, Stack, Switch, Text, Title } from "@mantine/core";
|
||||
|
||||
const AksesDanTimSettings = () => {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
// Sample team members data
|
||||
const teamMembers = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Admin Utama",
|
||||
email: "admin@desa.go.id",
|
||||
role: "Administrator",
|
||||
status: "Aktif",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Operator Desa",
|
||||
email: "operator@desa.go.id",
|
||||
role: "Operator",
|
||||
status: "Aktif",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Staff Keuangan",
|
||||
email: "keuangan@desa.go.id",
|
||||
role: "Keuangan",
|
||||
status: "Aktif",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Staff Umum",
|
||||
email: "umum@desa.go.id",
|
||||
role: "Umum",
|
||||
status: "Nonaktif",
|
||||
},
|
||||
];
|
||||
|
||||
const roles = [
|
||||
{ value: "administrator", label: "Administrator" },
|
||||
{ value: "operator", label: "Operator" },
|
||||
{ value: "keuangan", label: "Keuangan" },
|
||||
{ value: "umum", label: "Umum" },
|
||||
{ value: "keamanan", label: "Keamanan" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="xl"
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title="Tambah Anggota Tim"
|
||||
size="lg"
|
||||
>
|
||||
<TextInput
|
||||
label="Nama Lengkap"
|
||||
placeholder="Masukkan nama lengkap anggota tim"
|
||||
mb="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="Alamat Email"
|
||||
placeholder="Masukkan alamat email"
|
||||
mb="md"
|
||||
/>
|
||||
<Select
|
||||
label="Peran"
|
||||
placeholder="Pilih peran anggota tim"
|
||||
data={roles}
|
||||
mb="md"
|
||||
/>
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button variant="outline" onClick={() => setOpened(false)}>
|
||||
Batal
|
||||
<Stack pr={"50%"} gap={"xl"}>
|
||||
<Box>
|
||||
<Stack gap={"xs"}>
|
||||
<Title order={2}>Manajemen Tim</Title>
|
||||
<Button bg={"#1E3A5F"} radius={"md"} c={"white"} fullWidth>
|
||||
Undangan Anggota Baru
|
||||
</Button>
|
||||
<Button>Undang Anggota</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
|
||||
<Title order={2} mb="lg">
|
||||
Akses & Tim
|
||||
</Title>
|
||||
<Text color="dimmed" mb="xl">
|
||||
Kelola akses dan anggota tim Anda
|
||||
</Text>
|
||||
|
||||
<Space h="lg" />
|
||||
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>Anggota Tim</Title>
|
||||
<Button
|
||||
leftSection={<IconUserPlus size={16} />}
|
||||
onClick={() => setOpened(true)}
|
||||
>
|
||||
Tambah Anggota
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Nama</Table.Th>
|
||||
<Table.Th>Email</Table.Th>
|
||||
<Table.Th>Peran</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Aksi</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{teamMembers.map((member) => (
|
||||
<Table.Tr key={member.id}>
|
||||
<Table.Td>
|
||||
<Group gap="sm">
|
||||
<IconUser size={20} />
|
||||
<Text>{member.name}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>{member.email}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={500}>{member.role}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text c={member.status === "Aktif" ? "green" : "red"} fw={500}>
|
||||
{member.status}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group>
|
||||
<ActionIcon variant="subtle" color="blue">
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red">
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Space h="xl" />
|
||||
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
title="Informasi"
|
||||
color="blue"
|
||||
mb="md"
|
||||
>
|
||||
Administrator memiliki akses penuh ke semua fitur. Peran lainnya
|
||||
memiliki akses terbatas sesuai kebutuhan.
|
||||
</Alert>
|
||||
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button bg={"#1E3A5F"} radius={"md"} c={"white"} fullWidth>
|
||||
Kelola Role & Permission
|
||||
</Button>
|
||||
<Group justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Daftar Anggota Teraktif
|
||||
</Text>
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
12 Anggota
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box>
|
||||
<Stack gap={"xs"}>
|
||||
<Title order={2}>Hak Akses</Title>
|
||||
<Group justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Administrator
|
||||
</Text>
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
2 Orang
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Editor
|
||||
</Text>
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
5 Orang
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Viewer
|
||||
</Text>
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
5 Orang
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box>
|
||||
<Stack gap={"xs"}>
|
||||
<Title order={2}>Kolaborasi</Title>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Izin Export Data
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Require Approval Untuk Perubahan
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Group justify="flex-start" mt="xl">
|
||||
<Button variant="outline">Batal</Button>
|
||||
<Button>Simpan Perubahan</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,89 +1,64 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
PasswordInput,
|
||||
Space,
|
||||
Switch,
|
||||
Text,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { IconInfoCircle, IconLock } from "@tabler/icons-react";
|
||||
import { Box, Button, Group, Stack, Switch, Text, Title } from "@mantine/core";
|
||||
|
||||
const KeamananSettings = () => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="xl"
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Title order={2} mb="lg">
|
||||
Pengaturan Keamanan
|
||||
</Title>
|
||||
<Text color="dimmed" mb="xl">
|
||||
Kelola keamanan akun Anda
|
||||
</Text>
|
||||
|
||||
<Space h="lg" />
|
||||
|
||||
<PasswordInput
|
||||
label="Kata Sandi Saat Ini"
|
||||
placeholder="Masukkan kata sandi saat ini"
|
||||
mb="md"
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label="Kata Sandi Baru"
|
||||
placeholder="Masukkan kata sandi baru"
|
||||
mb="md"
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label="Konfirmasi Kata Sandi Baru"
|
||||
placeholder="Konfirmasi kata sandi baru"
|
||||
mb="md"
|
||||
/>
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
<Group mb="md">
|
||||
<Switch label="Verifikasi Dua Langkah" />
|
||||
<Switch label="Login Otentikasi Aplikasi" />
|
||||
</Group>
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
<Alert
|
||||
icon={<IconLock size={16} />}
|
||||
title="Keamanan"
|
||||
color="orange"
|
||||
mb="md"
|
||||
>
|
||||
Gunakan kata sandi yang kuat dan unik. Hindari menggunakan kata sandi
|
||||
yang sama di banyak layanan.
|
||||
</Alert>
|
||||
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
title="Informasi"
|
||||
color="blue"
|
||||
mb="md"
|
||||
>
|
||||
Setelah mengganti kata sandi, Anda akan diminta logout dari semua
|
||||
perangkat.
|
||||
</Alert>
|
||||
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Stack pr={"50%"} gap={"xl"}>
|
||||
<Box>
|
||||
<Stack gap={"xs"}>
|
||||
<Title order={2}>Autentikasi</Title>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Two-Factor Authentication
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Biometrik Login
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
IP Whitelist
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box>
|
||||
<Stack gap={"xs"}>
|
||||
<Title order={2}>Password</Title>
|
||||
<Button bg={"#1E3A5F"} radius={"md"} c={"white"} fullWidth>
|
||||
Ubah Password
|
||||
</Button>
|
||||
<Button bg={"#1E3A5F"} radius={"md"} c={"white"} fullWidth>
|
||||
Riwayat Login
|
||||
</Button>
|
||||
<Button bg={"#1E3A5F"} radius={"md"} c={"white"} fullWidth>
|
||||
Perangkat Terdaftar
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box>
|
||||
<Stack gap={"xs"}>
|
||||
<Title order={2}>Audit & Log</Title>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Log Aktivitas
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
<Button bg={"#1E3A5F"} radius={"md"} c={"white"} fullWidth>
|
||||
Download Log
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Group justify="flex-start" mt="xl">
|
||||
<Button variant="outline">Batal</Button>
|
||||
<Button>Perbarui Kata Sandi</Button>
|
||||
<Button>Simpan Perubahan</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,85 +1,114 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Grid,
|
||||
GridCol,
|
||||
Group,
|
||||
Space,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
const NotifikasiSettings = () => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
const _dark = colorScheme === "dark";
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="xl"
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Title order={2} mb="lg">
|
||||
Pengaturan Notifikasi
|
||||
</Title>
|
||||
<Text color="dimmed" mb="xl">
|
||||
Kelola preferensi notifikasi Anda
|
||||
</Text>
|
||||
|
||||
<Space h="lg" />
|
||||
|
||||
<Checkbox.Group defaultValue={["email", "push"]} mb="md">
|
||||
<Title order={4} mb="sm">
|
||||
Metode Notifikasi
|
||||
</Title>
|
||||
<Group>
|
||||
<Checkbox value="email" label="Email" />
|
||||
<Checkbox value="push" label="Notifikasi Push" />
|
||||
<Checkbox value="sms" label="SMS" />
|
||||
</Group>
|
||||
</Checkbox.Group>
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
<Group mb="md">
|
||||
<Switch label="Notifikasi Email" defaultChecked />
|
||||
<Switch label="Notifikasi Push" defaultChecked />
|
||||
</Group>
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
<Title order={4} mb="sm">
|
||||
Jenis Notifikasi
|
||||
</Title>
|
||||
<Group align="start">
|
||||
<Switch label="Pengaduan Baru" defaultChecked />
|
||||
<Switch label="Update Status Pengaduan" defaultChecked />
|
||||
<Switch label="Laporan Mingguan" />
|
||||
<Switch label="Pemberitahuan Keamanan" defaultChecked />
|
||||
<Switch label="Aktivitas Akun" defaultChecked />
|
||||
</Group>
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
title="Tip"
|
||||
color="blue"
|
||||
mb="md"
|
||||
>
|
||||
Anda dapat menyesuaikan frekuensi notifikasi mingguan sesuai kebutuhan
|
||||
Anda.
|
||||
</Alert>
|
||||
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Stack pr={"20%"} gap={"xs"}>
|
||||
<Grid gutter={{ base: 5, xs: "md", md: "xl", xl: 50 }}>
|
||||
<GridCol span={6}>
|
||||
<Stack gap={"xs"}>
|
||||
<Title order={3} mb="sm">
|
||||
Metode Notifikasi
|
||||
</Title>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Laporan Harian
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Alert Sistem
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Update Keamanan
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Newsletter Bulanan
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
</Stack>
|
||||
</GridCol>
|
||||
<GridCol span={6}>
|
||||
<Stack gap={"xs"}>
|
||||
<Title order={3} mb="sm">
|
||||
Preferensi Alert
|
||||
</Title>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Treshold Memori
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Treshold CPU
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Treshold Disk
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
</Stack>
|
||||
</GridCol>
|
||||
<GridCol span={6}>
|
||||
<Stack gap={"xs"}>
|
||||
<Title order={3} mb="sm">
|
||||
Notifikasi Push
|
||||
</Title>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Alert Kritis
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Aktivitas Tim
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Komentar & Mention
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Bunyi Notifikasi
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
</Stack>
|
||||
</GridCol>
|
||||
</Grid>
|
||||
<Group justify="flex-start" mt="xl">
|
||||
<Button variant="outline">Batal</Button>
|
||||
<Button>Simpan Preferensi</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
176
src/components/pengaturan/sinkronisasi.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Alert,
|
||||
Loader,
|
||||
Badge,
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import { IconRefresh, IconCheck, IconAlertCircle, IconClock } from "@tabler/icons-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import dayjs from "dayjs";
|
||||
import "dayjs/locale/id";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.locale("id");
|
||||
|
||||
const SinkronisasiSettings = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lastSync, setLastSync] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<{
|
||||
type: "success" | "error" | null;
|
||||
message: string;
|
||||
}>({ type: null, message: "" });
|
||||
|
||||
const fetchLastSync = async () => {
|
||||
const { data } = await apiClient.GET("/api/noc/last-sync", {
|
||||
params: { query: { idDesa: "desa1" } },
|
||||
});
|
||||
if (data?.lastSyncedAt) {
|
||||
setLastSync(data.lastSyncedAt);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchLastSync();
|
||||
}, []);
|
||||
|
||||
const handleSync = async () => {
|
||||
setLoading(true);
|
||||
setStatus({ type: null, message: "" });
|
||||
|
||||
try {
|
||||
const { data, error } = await apiClient.POST("/api/noc/sync");
|
||||
|
||||
if (error) {
|
||||
setStatus({
|
||||
type: "error",
|
||||
message: (error as any).error || "Gagal melakukan sinkronisasi",
|
||||
});
|
||||
} else if (data?.success) {
|
||||
setStatus({
|
||||
type: "success",
|
||||
message: data.message || "Sinkronisasi berhasil dilakukan",
|
||||
});
|
||||
if (data.lastSyncedAt) {
|
||||
setLastSync(data.lastSyncedAt);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus({
|
||||
type: "error",
|
||||
message: "Terjadi kesalahan sistem saat sinkronisasi",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box pr={"50%"}>
|
||||
<Title order={2} mb="lg">
|
||||
Sinkronisasi Data NOC
|
||||
</Title>
|
||||
|
||||
<Text c="dimmed" mb="xl">
|
||||
Gunakan fitur ini untuk memperbarui data dashboard dengan data terbaru dari
|
||||
server Network Operation Center (NOC) darmasaba.muku.id.
|
||||
</Text>
|
||||
|
||||
<Card withBorder padding="lg" radius="md" mb="xl">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<IconClock size={20} color="gray" />
|
||||
<Text fw={500}>Status Terakhir</Text>
|
||||
</Group>
|
||||
<Badge color={lastSync ? "green" : "gray"} variant="light">
|
||||
{lastSync ? "Terkoneksi" : "Belum Pernah Sinkron"}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text size="sm" c="dimmed">
|
||||
Waktu Sinkronisasi Terakhir:
|
||||
</Text>
|
||||
<Text fw={700} size="lg">
|
||||
{lastSync
|
||||
? dayjs(lastSync).format("DD MMMM YYYY, HH:mm:ss")
|
||||
: "Belum pernah dilakukan"}
|
||||
</Text>
|
||||
{lastSync && (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
({dayjs(lastSync).fromNow()})
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{status.type && (
|
||||
<Alert
|
||||
icon={
|
||||
status.type === "success" ? (
|
||||
<IconCheck size={16} />
|
||||
) : (
|
||||
<IconAlertCircle size={16} />
|
||||
)
|
||||
}
|
||||
title={status.type === "success" ? "Berhasil" : "Kesalahan"}
|
||||
color={status.type === "success" ? "green" : "red"}
|
||||
onClose={() => setStatus({ type: null, message: "" })}
|
||||
withCloseButton
|
||||
>
|
||||
{status.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
leftSection={
|
||||
loading ? <Loader size={16} color="white" /> : <IconRefresh size={16} />
|
||||
}
|
||||
onClick={handleSync}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
mt="md"
|
||||
>
|
||||
Sinkronkan Sekarang
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Title order={2} mb="lg">
|
||||
Informasi API
|
||||
</Title>
|
||||
|
||||
<Card withBorder padding="md" radius="md" bg="gray.0">
|
||||
<Stack gap="xs">
|
||||
<Group>
|
||||
<Text fw={600} size="sm" w={100}>URL Sumber:</Text>
|
||||
<Text size="sm" style={{ wordBreak: 'break-all' }}>https://darmasaba.muku.id/api/noc/</Text>
|
||||
</Group>
|
||||
<Group>
|
||||
<Text fw={600} size="sm" w={100}>ID Desa:</Text>
|
||||
<Text size="sm">desa1</Text>
|
||||
</Group>
|
||||
<Group>
|
||||
<Text fw={600} size="sm" w={100}>Model Data:</Text>
|
||||
<Badge size="xs" variant="outline">Divisi</Badge>
|
||||
<Badge size="xs" variant="outline">Kegiatan</Badge>
|
||||
<Badge size="xs" variant="outline">Event</Badge>
|
||||
<Badge size="xs" variant="outline">Diskusi</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SinkronisasiSettings;
|
||||
@@ -1,44 +1,12 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { Box, Button, Group, Select, Switch, Text, Title } from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
|
||||
const UmumSettings = () => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="xl"
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Box pr={"50%"}>
|
||||
<Title order={2} mb="lg">
|
||||
Pengaturan Umum
|
||||
Preferensi Tampilan
|
||||
</Title>
|
||||
<Text color="dimmed" mb="xl">
|
||||
Kelola pengaturan umum aplikasi Anda
|
||||
</Text>
|
||||
|
||||
<Space h="lg" />
|
||||
|
||||
<TextInput
|
||||
label="Nama Aplikasi"
|
||||
placeholder="Masukkan nama aplikasi"
|
||||
defaultValue="Dashboard Desa Plus"
|
||||
mb="md"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Bahasa Aplikasi"
|
||||
@@ -61,25 +29,53 @@ const UmumSettings = () => {
|
||||
mb="md"
|
||||
/>
|
||||
|
||||
<Group mb="md">
|
||||
<Switch label="Notifikasi Email" defaultChecked />
|
||||
<DateInput label="Format Tanggal" mb={"xl"} />
|
||||
|
||||
<Title order={2} mb="lg">
|
||||
Dashboard
|
||||
</Title>
|
||||
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Refresh Otomatis
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
title="Informasi"
|
||||
color="blue"
|
||||
mb="md"
|
||||
>
|
||||
Beberapa pengaturan mungkin memerlukan restart aplikasi untuk diterapkan
|
||||
sepenuhnya.
|
||||
</Alert>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Interval Refresh
|
||||
</Text>
|
||||
<Select
|
||||
data={[
|
||||
{ value: "1", label: "30d" },
|
||||
{ value: "2", label: "60d" },
|
||||
{ value: "3", label: "90d" },
|
||||
]}
|
||||
defaultValue="1"
|
||||
w={90}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Tampilkan Grid
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={"bold"} fz={"sm"}>
|
||||
Animasi Transisi
|
||||
</Text>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button variant="outline">Batal</Button>
|
||||
<Button>Simpan Perubahan</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Collapse,
|
||||
Group,
|
||||
Image,
|
||||
Input,
|
||||
NavLink as MantineNavLink,
|
||||
Stack,
|
||||
Text,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
@@ -27,35 +25,30 @@ export function Sidebar({ className }: SidebarProps) {
|
||||
|
||||
// State for settings submenu collapse
|
||||
const [settingsOpen, setSettingsOpen] = useState(
|
||||
location.pathname.startsWith("/dashboard/pengaturan"),
|
||||
location.pathname.startsWith("/pengaturan"),
|
||||
);
|
||||
|
||||
// Define menu items with their paths
|
||||
const menuItems = [
|
||||
{ name: "Beranda", path: "/dashboard" },
|
||||
{ name: "Kinerja Divisi", path: "/dashboard/kinerja-divisi" },
|
||||
{
|
||||
name: "Pengaduan & Layanan Publik",
|
||||
path: "/dashboard/pengaduan-layanan-publik",
|
||||
},
|
||||
{ name: "Jenna Analytic", path: "/dashboard/jenna-analytic" },
|
||||
{
|
||||
name: "Demografi & Kependudukan",
|
||||
path: "/dashboard/demografi-pekerjaan",
|
||||
},
|
||||
{ name: "Keuangan & Anggaran", path: "/dashboard/keuangan-anggaran" },
|
||||
{ name: "Bumdes & UMKM Desa", path: "/dashboard/bumdes" },
|
||||
{ name: "Sosial", path: "/dashboard/sosial" },
|
||||
{ name: "Keamanan", path: "/dashboard/keamanan" },
|
||||
{ name: "Bantuan", path: "/dashboard/bantuan" },
|
||||
{ name: "Beranda", path: "/" },
|
||||
{ name: "Kinerja Divisi", path: "/kinerja-divisi" },
|
||||
{ name: "Pengaduan & Layanan Publik", path: "/pengaduan-layanan-publik" },
|
||||
{ name: "Jenna Analytic", path: "/jenna-analytic" },
|
||||
{ name: "Demografi & Kependudukan", path: "/demografi-pekerjaan" },
|
||||
{ name: "Keuangan & Anggaran", path: "/keuangan-anggaran" },
|
||||
{ name: "Bumdes & UMKM Desa", path: "/bumdes" },
|
||||
{ name: "Sosial", path: "/sosial" },
|
||||
{ name: "Keamanan", path: "/keamanan" },
|
||||
{ name: "Bantuan", path: "/bantuan" },
|
||||
];
|
||||
|
||||
// Settings submenu items
|
||||
const settingsItems = [
|
||||
{ name: "Umum", path: "/dashboard/pengaturan/umum" },
|
||||
{ name: "Notifikasi", path: "/dashboard/pengaturan/notifikasi" },
|
||||
{ name: "Keamanan", path: "/dashboard/pengaturan/keamanan" },
|
||||
{ name: "Akses & Tim", path: "/dashboard/pengaturan/akses-dan-tim" },
|
||||
{ name: "Umum", path: "/pengaturan/umum" },
|
||||
{ name: "Notifikasi", path: "/pengaturan/notifikasi" },
|
||||
{ name: "Keamanan", path: "/pengaturan/keamanan" },
|
||||
{ name: "Akses & Tim", path: "/pengaturan/akses-dan-tim" },
|
||||
{ name: "Sinkronisasi NOC", path: "/pengaturan/sinkronisasi" },
|
||||
];
|
||||
|
||||
// Check if any settings submenu is active
|
||||
@@ -66,30 +59,7 @@ export function Sidebar({ className }: SidebarProps) {
|
||||
return (
|
||||
<Box className={className}>
|
||||
{/* Logo */}
|
||||
<Box
|
||||
p="md"
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-gray-3)" }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<Badge
|
||||
color="dark"
|
||||
variant="filled"
|
||||
size="xl"
|
||||
radius="md"
|
||||
py="xs"
|
||||
px="md"
|
||||
style={{ fontSize: "1.5rem", fontWeight: "bold" }}
|
||||
>
|
||||
DESA
|
||||
</Badge>
|
||||
<Badge color="green" variant="filled" size="md" radius="md">
|
||||
+
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
Digitalisasi Desa Transparansi Kerja
|
||||
</Text>
|
||||
</Box>
|
||||
<Image src={dark ? "/white.png" : "/light-mode.png"} alt="Logo" />
|
||||
|
||||
{/* Search */}
|
||||
<Box p="md">
|
||||
@@ -108,11 +78,11 @@ export function Sidebar({ className }: SidebarProps) {
|
||||
|
||||
{/* Menu Items */}
|
||||
<Stack gap={0} px="xs" style={{ overflowY: "auto" }}>
|
||||
{menuItems.map((item, index) => {
|
||||
{menuItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<MantineNavLink
|
||||
key={index}
|
||||
key={item.path}
|
||||
onClick={() => navigate({ to: item.path })}
|
||||
label={item.name}
|
||||
active={isActive}
|
||||
@@ -174,11 +144,11 @@ export function Sidebar({ className }: SidebarProps) {
|
||||
ml="lg"
|
||||
style={{ overflowY: "auto", maxHeight: "200px" }}
|
||||
>
|
||||
{settingsItems.map((item, index) => {
|
||||
{settingsItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<MantineNavLink
|
||||
key={index}
|
||||
key={item.path}
|
||||
onClick={() => navigate({ to: item.path })}
|
||||
label={item.name}
|
||||
active={isActive}
|
||||
|
||||
@@ -1,463 +1,45 @@
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Grid,
|
||||
GridCol,
|
||||
Group,
|
||||
List,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAward,
|
||||
IconBabyCarriage,
|
||||
IconBook,
|
||||
IconCalendarEvent,
|
||||
IconHeartbeat,
|
||||
IconMedicalCross,
|
||||
IconSchool,
|
||||
IconStethoscope,
|
||||
} from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { Grid, GridCol, Stack } from "@mantine/core";
|
||||
import { Beasiswa } from "./sosial/beasiswa";
|
||||
import { EventCalendar } from "./sosial/event-calendar";
|
||||
import { HealthStats } from "./sosial/health-stats";
|
||||
import { Pendidikan } from "./sosial/pendidikan";
|
||||
import { PosyanduSchedule } from "./sosial/posyandu-schedule";
|
||||
import { SummaryCards } from "./sosial/summary-cards";
|
||||
|
||||
const SosialPage = () => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
// Sample data for health statistics
|
||||
const healthStats = {
|
||||
ibuHamil: 87,
|
||||
balita: 342,
|
||||
alertStunting: 12,
|
||||
posyanduAktif: 8,
|
||||
};
|
||||
|
||||
// Sample data for health progress
|
||||
const healthProgress = [
|
||||
{ label: "Imunisasi Lengkap", value: 92, color: "green" },
|
||||
{ label: "Pemeriksaan Rutin", value: 88, color: "blue" },
|
||||
{ label: "Gizi Baik", value: 86, color: "teal" },
|
||||
{ label: "Target Stunting", value: 14, color: "red" },
|
||||
];
|
||||
|
||||
// Sample data for posyandu schedule
|
||||
const posyanduSchedule = [
|
||||
{
|
||||
nama: "Posyandu Mawar",
|
||||
tanggal: "Senin, 15 Feb 2026",
|
||||
jam: "08:00 - 11:00",
|
||||
},
|
||||
{
|
||||
nama: "Posyandu Melati",
|
||||
tanggal: "Selasa, 16 Feb 2026",
|
||||
jam: "08:00 - 11:00",
|
||||
},
|
||||
{
|
||||
nama: "Posyandu Dahlia",
|
||||
tanggal: "Rabu, 17 Feb 2026",
|
||||
jam: "08:00 - 11:00",
|
||||
},
|
||||
{
|
||||
nama: "Posyandu Anggrek",
|
||||
tanggal: "Kamis, 18 Feb 2026",
|
||||
jam: "08:00 - 11:00",
|
||||
},
|
||||
];
|
||||
|
||||
// Sample data for education stats
|
||||
const educationStats = {
|
||||
siswa: {
|
||||
tk: 125,
|
||||
sd: 480,
|
||||
smp: 210,
|
||||
sma: 150,
|
||||
},
|
||||
sekolah: {
|
||||
jumlah: 8,
|
||||
guru: 42,
|
||||
},
|
||||
};
|
||||
|
||||
// Sample data for scholarships
|
||||
const scholarshipData = {
|
||||
penerima: 45,
|
||||
dana: "Rp 1.200.000.000",
|
||||
tahunAjaran: "2025/2026",
|
||||
};
|
||||
|
||||
// Sample data for cultural events
|
||||
const culturalEvents = [
|
||||
{
|
||||
nama: "Hari Kesaktian Pancasila",
|
||||
tanggal: "1 Oktober 2025",
|
||||
lokasi: "Balai Desa",
|
||||
},
|
||||
{
|
||||
nama: "Festival Budaya Desa",
|
||||
tanggal: "20 Mei 2026",
|
||||
lokasi: "Lapangan Desa",
|
||||
},
|
||||
{
|
||||
nama: "Perayaan HUT Desa",
|
||||
tanggal: "17 Agustus 2026",
|
||||
lokasi: "Balai Desa",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Health Statistics Cards */}
|
||||
{/* Top Summary Cards - 4 Grid */}
|
||||
<SummaryCards />
|
||||
|
||||
{/* Second Row - 2 Column Grid */}
|
||||
<Grid gutter="md">
|
||||
<GridCol span={{ base: 12, sm: 6, md: 3 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
Ibu Hamil Aktif
|
||||
</Text>
|
||||
<Text size="xl" fw={700} c={dark ? "dark.0" : "black"}>
|
||||
{healthStats.ibuHamil}
|
||||
</Text>
|
||||
</Stack>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color="darmasaba-blue"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
>
|
||||
<IconHeartbeat size={24} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
{/* Left - Statistik Kesehatan */}
|
||||
<GridCol span={{ base: 12, lg: 6 }}>
|
||||
<HealthStats />
|
||||
</GridCol>
|
||||
|
||||
<GridCol span={{ base: 12, sm: 6, md: 3 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
Balita Terdaftar
|
||||
</Text>
|
||||
<Text size="xl" fw={700} c={dark ? "dark.0" : "black"}>
|
||||
{healthStats.balita}
|
||||
</Text>
|
||||
</Stack>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color="darmasaba-success"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
>
|
||||
<IconBabyCarriage size={24} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
</GridCol>
|
||||
|
||||
<GridCol span={{ base: 12, sm: 6, md: 3 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
Alert Stunting
|
||||
</Text>
|
||||
<Text size="xl" fw={700} c="red">
|
||||
{healthStats.alertStunting}
|
||||
</Text>
|
||||
</Stack>
|
||||
<ThemeIcon variant="light" color="red" size="xl" radius="xl">
|
||||
<IconStethoscope size={24} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
</GridCol>
|
||||
|
||||
<GridCol span={{ base: 12, sm: 6, md: 3 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
Posyandu Aktif
|
||||
</Text>
|
||||
<Text size="xl" fw={700} c={dark ? "dark.0" : "black"}>
|
||||
{healthStats.posyanduAktif}
|
||||
</Text>
|
||||
</Stack>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color="darmasaba-warning"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
>
|
||||
<IconMedicalCross size={24} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
{/* Right - Jadwal Posyandu */}
|
||||
<GridCol span={{ base: 12, lg: 6 }}>
|
||||
<PosyanduSchedule />
|
||||
</GridCol>
|
||||
</Grid>
|
||||
|
||||
{/* Health Progress Bars */}
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Title order={3} mb="md" c={dark ? "dark.0" : "black"}>
|
||||
Statistik Kesehatan
|
||||
</Title>
|
||||
<Stack gap="md">
|
||||
{healthProgress.map((item, index) => (
|
||||
<div key={index}>
|
||||
<Group justify="space-between" mb={5}>
|
||||
<Text size="sm" fw={500} c={dark ? "dark.0" : "black"}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c={dark ? "dark.0" : "black"}>
|
||||
{item.value}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={item.value}
|
||||
size="lg"
|
||||
radius="xl"
|
||||
color={item.color}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Third Row - 2 Column Grid */}
|
||||
<Grid gutter="md">
|
||||
{/* Jadwal Posyandu */}
|
||||
{/* Left - Pendidikan */}
|
||||
<GridCol span={{ base: 12, lg: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
>
|
||||
<Title order={3} mb="md" c={dark ? "dark.0" : "black"}>
|
||||
Jadwal Posyandu
|
||||
</Title>
|
||||
<Stack gap="sm">
|
||||
{posyanduSchedule.map((item, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#263852ff" : "#F1F5F9"}
|
||||
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
|
||||
h="100%"
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Stack gap={0}>
|
||||
<Text fw={500} c={dark ? "dark.0" : "black"}>
|
||||
{item.nama}
|
||||
</Text>
|
||||
<Text size="sm" c={dark ? "dark.0" : "black"}>
|
||||
{item.tanggal}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge variant="light" color="darmasaba-blue">
|
||||
{item.jam}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
<Pendidikan />
|
||||
</GridCol>
|
||||
|
||||
{/* Pendidikan */}
|
||||
{/* Right - Beasiswa Desa */}
|
||||
<GridCol span={{ base: 12, lg: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
h="100%"
|
||||
>
|
||||
<Title order={3} mb="md" c={dark ? "dark.0" : "black"}>
|
||||
Pendidikan
|
||||
</Title>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={500} c={dark ? "dark.0" : "black"}>
|
||||
TK / PAUD
|
||||
</Text>
|
||||
<Text fw={700} c={dark ? "dark.0" : "black"}>
|
||||
{educationStats.siswa.tk}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500} c={dark ? "dark.0" : "black"}>
|
||||
SD
|
||||
</Text>
|
||||
<Text fw={700} c={dark ? "dark.0" : "black"}>
|
||||
{educationStats.siswa.sd}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500} c={dark ? "dark.0" : "black"}>
|
||||
SMP
|
||||
</Text>
|
||||
<Text fw={700} c={dark ? "dark.0" : "black"}>
|
||||
{educationStats.siswa.smp}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500} c={dark ? "dark.0" : "black"}>
|
||||
SMA
|
||||
</Text>
|
||||
<Text fw={700} c={dark ? "dark.0" : "black"}>
|
||||
{educationStats.siswa.sma}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
mt="md"
|
||||
bg={dark ? "#263852ff" : "#F1F5F9"}
|
||||
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500} c={dark ? "dark.0" : "black"}>
|
||||
Jumlah Lembaga Pendidikan
|
||||
</Text>
|
||||
<Text fw={700} c={dark ? "dark.0" : "black"}>
|
||||
{educationStats.sekolah.jumlah}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between" mt="sm">
|
||||
<Text fw={500} c={dark ? "dark.0" : "black"}>
|
||||
Jumlah Tenaga Pengajar
|
||||
</Text>
|
||||
<Text fw={700} c={dark ? "dark.0" : "black"}>
|
||||
{educationStats.sekolah.guru}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Beasiswa />
|
||||
</GridCol>
|
||||
</Grid>
|
||||
|
||||
<Grid gutter="md">
|
||||
{/* Beasiswa Desa */}
|
||||
<GridCol span={{ base: 12, lg: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
h="100%"
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
Beasiswa Desa
|
||||
</Text>
|
||||
<Text size="xl" fw={700} c={dark ? "dark.0" : "black"}>
|
||||
Penerima: {scholarshipData.penerima}
|
||||
</Text>
|
||||
</Stack>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color="darmasaba-success"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
>
|
||||
<IconAward size={24} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<Text mt="md" c={dark ? "dark.0" : "black"}>
|
||||
Dana Tersalurkan:{" "}
|
||||
<Text span fw={700}>
|
||||
{scholarshipData.dana}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text mt="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
Tahun Ajaran: {scholarshipData.tahunAjaran}
|
||||
</Text>
|
||||
</Card>
|
||||
</GridCol>
|
||||
|
||||
{/* Kalender Event Budaya */}
|
||||
<GridCol span={{ base: 12, lg: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#141D34" : "white"}
|
||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
||||
h="100%"
|
||||
>
|
||||
<Title order={3} mb="md" c={dark ? "dark.0" : "black"}>
|
||||
Kalender Event Budaya
|
||||
</Title>
|
||||
<List spacing="sm">
|
||||
{culturalEvents.map((event, index) => (
|
||||
<List.Item
|
||||
key={index}
|
||||
icon={
|
||||
<ThemeIcon color="darmasaba-blue" size={24} radius="xl">
|
||||
<IconCalendarEvent size={12} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500} c={dark ? "dark.0" : "black"}>
|
||||
{event.nama}
|
||||
</Text>
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
{event.lokasi}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||
{event.tanggal}
|
||||
</Text>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
</GridCol>
|
||||
</Grid>
|
||||
{/* Bottom Section - Event Budaya */}
|
||||
<EventCalendar />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
79
src/components/sosial/beasiswa.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { IconAward } from "@tabler/icons-react";
|
||||
|
||||
interface ScholarshipData {
|
||||
penerima: number;
|
||||
dana: string;
|
||||
tahunAjaran: string;
|
||||
}
|
||||
|
||||
interface BeasiswaProps {
|
||||
data?: ScholarshipData;
|
||||
}
|
||||
|
||||
export const Beasiswa = ({ data }: BeasiswaProps) => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const defaultData: ScholarshipData = {
|
||||
penerima: 45,
|
||||
dana: "Rp 1.200.000.000",
|
||||
tahunAjaran: "2025/2026",
|
||||
};
|
||||
|
||||
const displayData = data || defaultData;
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
shadow="sm"
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
h={"100%"}
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" c={dark ? "white" : "dimmed"} fw={500}>
|
||||
Beasiswa Desa
|
||||
</Text>
|
||||
<Text size="xl" fw={700} c={dark ? "white" : "#1e3a5f"}>
|
||||
Penerima: {displayData.penerima}
|
||||
</Text>
|
||||
</Stack>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color="darmasaba-success"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
>
|
||||
<IconAward size={24} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<Stack gap="xs" mt="md">
|
||||
<Group justify="space-between">
|
||||
<Text c={dark ? "white" : "dimmed"}>Dana Tersalurkan:</Text>
|
||||
<Text fw={700} c={dark ? "white" : "#1e3a5f"}>
|
||||
{displayData.dana}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text c={dark ? "white" : "dimmed"}>Tahun Ajaran:</Text>
|
||||
<Text c={dark ? "white" : "#1e3a5f"}>{displayData.tahunAjaran}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
104
src/components/sosial/event-calendar.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { IconCalendarEvent } from "@tabler/icons-react";
|
||||
|
||||
interface EventItem {
|
||||
id: string;
|
||||
nama: string;
|
||||
tanggal: string;
|
||||
lokasi: string;
|
||||
}
|
||||
|
||||
interface EventCalendarProps {
|
||||
data?: EventItem[];
|
||||
}
|
||||
|
||||
export const EventCalendar = ({ data }: EventCalendarProps) => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const defaultData: EventItem[] = [
|
||||
{
|
||||
id: "1",
|
||||
nama: "Hari Kesaktian Pancasila",
|
||||
tanggal: "1 Oktober 2025",
|
||||
lokasi: "Balai Desa",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
nama: "Festival Budaya Desa",
|
||||
tanggal: "20 Mei 2026",
|
||||
lokasi: "Lapangan Desa",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
nama: "Perayaan HUT Desa",
|
||||
tanggal: "17 Agustus 2026",
|
||||
lokasi: "Balai Desa",
|
||||
},
|
||||
];
|
||||
|
||||
const displayData = data || defaultData;
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
shadow="sm"
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<Title order={3} mb="md" c={dark ? "dark.0" : "#1e3a5f"}>
|
||||
Kalender Event Budaya
|
||||
</Title>
|
||||
<Stack gap="sm">
|
||||
{displayData.map((event) => (
|
||||
<Card
|
||||
key={event.id}
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#263852ff" : "#F1F5F9"}
|
||||
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
|
||||
>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Group gap="sm" align="center">
|
||||
<ThemeIcon
|
||||
color="darmasaba-blue"
|
||||
size="md"
|
||||
radius="xl"
|
||||
variant="light"
|
||||
>
|
||||
<IconCalendarEvent size={16} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||
{event.nama}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"} fw={500}>
|
||||
{event.lokasi}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group pl={36}>
|
||||
<Text size="sm" c={dark ? "white" : "gray.6"}>
|
||||
{event.tanggal}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
77
src/components/sosial/health-stats.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Card,
|
||||
Group,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
|
||||
interface HealthProgressItem {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface HealthStatsProps {
|
||||
data?: HealthProgressItem[];
|
||||
}
|
||||
|
||||
export const HealthStats = ({ data }: HealthStatsProps) => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const defaultData: HealthProgressItem[] = [
|
||||
{ label: "Imunisasi Lengkap", value: 92, color: "green" },
|
||||
{ label: "Pemeriksaan Rutin", value: 88, color: "blue" },
|
||||
{ label: "Gizi Baik", value: 86, color: "teal" },
|
||||
{ label: "Target Stunting", value: 14, color: "red" },
|
||||
];
|
||||
|
||||
const displayData = data || defaultData;
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
shadow="sm"
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
h={"100%"}
|
||||
>
|
||||
<Title order={3} mb="md" c={dark ? "dark.0" : "#1e3a5f"}>
|
||||
Statistik Kesehatan
|
||||
</Title>
|
||||
<Stack gap="md">
|
||||
{displayData.map((item) => (
|
||||
<div key={item.label}>
|
||||
<Group justify="space-between" mb={5}>
|
||||
<Text size="sm" fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
c={item.color === "red" ? "red" : dark ? "dark.0" : "#1e3a5f"}
|
||||
>
|
||||
{item.value}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={item.value}
|
||||
size="lg"
|
||||
radius="xl"
|
||||
color={item.color}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
124
src/components/sosial/pendidikan.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
|
||||
interface EducationData {
|
||||
siswa: {
|
||||
tk: number;
|
||||
sd: number;
|
||||
smp: number;
|
||||
sma: number;
|
||||
};
|
||||
sekolah: {
|
||||
jumlah: number;
|
||||
guru: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface PendidikanProps {
|
||||
data?: EducationData;
|
||||
}
|
||||
|
||||
export const Pendidikan = ({ data }: PendidikanProps) => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const defaultData: EducationData = {
|
||||
siswa: {
|
||||
tk: 125,
|
||||
sd: 480,
|
||||
smp: 210,
|
||||
sma: 150,
|
||||
},
|
||||
sekolah: {
|
||||
jumlah: 8,
|
||||
guru: 42,
|
||||
},
|
||||
};
|
||||
|
||||
const displayData = data || defaultData;
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
shadow="sm"
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<Title order={3} mb="md" c={dark ? "dark.0" : "#1e3a5f"}>
|
||||
Pendidikan
|
||||
</Title>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||
TK / PAUD
|
||||
</Text>
|
||||
<Text fw={700} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||
{displayData.siswa.tk}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||
SD
|
||||
</Text>
|
||||
<Text fw={700} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||
{displayData.siswa.sd}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||
SMP
|
||||
</Text>
|
||||
<Text fw={700} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||
{displayData.siswa.smp}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||
SMA
|
||||
</Text>
|
||||
<Text fw={700} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||
{displayData.siswa.sma}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
mt="md"
|
||||
bg={dark ? "#263852ff" : "#F1F5F9"}
|
||||
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||
Jumlah Lembaga Pendidikan
|
||||
</Text>
|
||||
<Text fw={700} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||
{displayData.sekolah.jumlah}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between" mt="sm">
|
||||
<Text fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||
Jumlah Tenaga Pengajar
|
||||
</Text>
|
||||
<Text fw={700} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||
{displayData.sekolah.guru}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
99
src/components/sosial/posyandu-schedule.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
|
||||
interface PosyanduItem {
|
||||
id: string;
|
||||
nama: string;
|
||||
tanggal: string;
|
||||
jam: string;
|
||||
}
|
||||
|
||||
interface PosyanduScheduleProps {
|
||||
data?: PosyanduItem[];
|
||||
}
|
||||
|
||||
export const PosyanduSchedule = ({ data }: PosyanduScheduleProps) => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const defaultData: PosyanduItem[] = [
|
||||
{
|
||||
id: "1",
|
||||
nama: "Posyandu Mawar",
|
||||
tanggal: "Senin, 15 Feb 2026",
|
||||
jam: "08:00 - 11:00",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
nama: "Posyandu Melati",
|
||||
tanggal: "Selasa, 16 Feb 2026",
|
||||
jam: "08:00 - 11:00",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
nama: "Posyandu Dahlia",
|
||||
tanggal: "Rabu, 17 Feb 2026",
|
||||
jam: "08:00 - 11:00",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
nama: "Posyandu Anggrek",
|
||||
tanggal: "Kamis, 18 Feb 2026",
|
||||
jam: "08:00 - 11:00",
|
||||
},
|
||||
];
|
||||
|
||||
const displayData = data || defaultData;
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
shadow="sm"
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
style={{
|
||||
borderColor: dark ? "#334155" : "white",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<Title order={3} mb="md" c={dark ? "dark.0" : "#1e3a5f"}>
|
||||
Jadwal Posyandu
|
||||
</Title>
|
||||
<Stack gap="sm">
|
||||
{displayData.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#263852ff" : "#F1F5F9"}
|
||||
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Stack gap={0}>
|
||||
<Text fw={600} c={dark ? "white" : "#1e3a5f"}>
|
||||
{item.nama}
|
||||
</Text>
|
||||
<Text size="sm" c={dark ? "white" : "dimmed"}>
|
||||
{item.tanggal}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge variant="light" color="darmasaba-blue" size="md">
|
||||
{item.jam}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||