Compare commits

..

3 Commits

164 changed files with 5944 additions and 18549 deletions

View File

@@ -1,20 +0,0 @@
# 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"

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,93 +0,0 @@
#!/bin/bash
: "${PORTAINER_URL:?PORTAINER_URL tidak di-set}"
: "${PORTAINER_USERNAME:?PORTAINER_USERNAME tidak di-set}"
: "${PORTAINER_PASSWORD:?PORTAINER_PASSWORD tidak di-set}"
: "${STACK_NAME:?STACK_NAME tidak di-set}"
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
View File

@@ -16,7 +16,6 @@ _.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
@@ -34,12 +33,6 @@ 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/

View File

@@ -1,5 +0,0 @@
{
"permissions": {
"allow": ["Bash(bun *)"]
}
}

View File

@@ -1,62 +0,0 @@
# 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"]

View File

@@ -1,86 +0,0 @@
# 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.

View File

@@ -1,76 +0,0 @@
# 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.

View File

@@ -1,168 +0,0 @@
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: 16px24px (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: 3060
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

View File

@@ -1,93 +0,0 @@
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);
});
});

View File

@@ -1,110 +0,0 @@
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();
});
});

View File

@@ -92,6 +92,7 @@
"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",
},
},
@@ -473,8 +474,14 @@
"@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=="],
@@ -667,6 +674,8 @@
"@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=="],
@@ -1075,6 +1084,8 @@
"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=="],
@@ -1105,7 +1116,7 @@
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -1121,7 +1132,7 @@
"is-root": ["is-root@2.1.0", "", {}, "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg=="],
"is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"isbot": ["isbot@5.1.34", "", {}, "sha512-aCMIBSKd/XPRYdiCQTLC8QHH4YT8B3JUADu+7COgYIZPvkeoMcUHMRjZLM9/7V8fCj+l7FSREc1lOPNjzogo/A=="],
@@ -1385,6 +1396,8 @@
"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=="],
@@ -1557,6 +1570,8 @@
"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=="],
@@ -1715,6 +1730,8 @@
"@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=="],
@@ -1777,6 +1794,8 @@
"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=="],
@@ -1797,6 +1816,8 @@
"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=="],
@@ -1817,6 +1838,8 @@
"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=="],
@@ -1851,10 +1874,6 @@
"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=="],

File diff suppressed because it is too large Load Diff

View File

@@ -1,269 +0,0 @@
/**
* 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;
};
};
};
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -4,25 +4,17 @@
"private": true,
"type": "module",
"scripts": {
"dev": "lsof -ti:3000 | xargs kill -9 2>/dev/null || true; bun run gen:api && REACT_EDITOR=antigravity bun --hot src/index.ts",
"dev": "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_*' && cp -r public/* dist/ 2>/dev/null || true",
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='VITE_*'",
"start": "NODE_ENV=production bun src/index.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"
"seed": "bun prisma/seed.ts"
},
"dependencies": {
"@better-auth/cli": "^1.4.18",
@@ -112,6 +104,7 @@
"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"
}
}

View File

@@ -1,568 +0,0 @@
-- 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;

View File

@@ -1,58 +0,0 @@
/*
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");

View File

@@ -1,15 +0,0 @@
/*
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;

View File

@@ -1,36 +0,0 @@
/*
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");

View File

@@ -1,49 +0,0 @@
/*
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");

View File

@@ -1,44 +0,0 @@
/*
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");

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "division" ADD COLUMN "lastSyncedAt" TIMESTAMP(3);

View File

@@ -1,15 +0,0 @@
-- 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';

View File

@@ -1,15 +0,0 @@
-- CreateTable
CREATE TABLE "document_stat" (
"id" TEXT NOT NULL,
"villageId" TEXT NOT NULL DEFAULT 'desa1',
"label" 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 "document_stat_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "document_stat_villageId_label_key" ON "document_stat"("villageId", "label");

View File

@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -21,558 +21,9 @@ 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 DocumentStat {
id String @id @default(cuid())
villageId String @default("desa1")
label String
value Int @default(0)
color String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([villageId, label])
@@map("document_stat")
}
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

View File

@@ -1,251 +1,139 @@
import "dotenv/config";
import { PrismaClient } from "../generated/prisma";
import { hash } from "bcryptjs";
import { generateId } from "better-auth";
import { prisma } from "@/utils/db";
// 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,
seedDocumentStats,
} 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";
async function seedAdminUser() {
// Load environment variables
const adminEmail = process.env.ADMIN_EMAIL;
const adminPassword = process.env.ADMIN_PASSWORD || "admin123";
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) {
if (!adminEmail) {
console.log(
"⏭️ Existing data detected. Skipping seed to prevent duplicates.\n",
"No ADMIN_EMAIL environment variable found. Skipping admin user creation.",
);
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;
}
// 1. Seed Authentication (Admin & Demo Users)
console.log("📁 [1/7] Authentication & Users");
const adminId = await seedAdminUser();
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();
await seedDemoUsers();
await seedApiKeys(adminId);
console.log();
// 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 seedDocumentStats();
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");
console.log("Database seeding completed.");
}
/**
* 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 seedDocumentStats();
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();
});
}
}
main().catch((error) => {
console.error("Error during seeding:", error);
process.exit(1);
});

View File

@@ -1,161 +0,0 @@
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");
}

View File

@@ -1,109 +0,0 @@
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();
}

View File

@@ -1,124 +0,0 @@
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");
}

View File

@@ -1,248 +0,0 @@
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 Document Stats
* Creates aggregate document counts matching user request
*/
export async function seedDocumentStats() {
console.log("Seeding Document Stats...");
const stats = [
{
villageId: "desa1",
label: "Gambar",
value: 389,
color: "#fac858",
},
{
villageId: "desa1",
label: "Dokumen",
value: 147,
color: "#92cc76",
},
];
for (const stat of stats) {
await prisma.documentStat.upsert({
where: {
villageId_label: {
villageId: stat.villageId,
label: stat.label,
},
},
update: stat,
create: stat,
});
}
console.log("✅ Document Stats 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 current = discussions[i];
if (!current) continue;
const discussion = await prisma.discussion.create({
data: {
message: current.message,
senderId: current.senderId,
divisionId: current.divisionId,
isResolved: current.isResolved,
},
});
parentDiscussions.push(discussion);
}
// Create replies
for (let i = 1; i < discussions.length; i += 2) {
const current = discussions[i];
if (!current) continue;
const parentIndex = Math.floor((i - 1) / 2);
const parent = parentDiscussions[parentIndex];
if (parent) {
await prisma.discussion.update({
where: { id: parent.id },
data: {
replies: {
create: {
message: current.message,
senderId: current.senderId,
isResolved: current.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");
}

View File

@@ -1,97 +0,0 @@
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");
}

View File

@@ -1,254 +0,0 @@
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);
}

View File

@@ -1,393 +0,0 @@
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");
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

View File

@@ -1,58 +0,0 @@
#!/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!");

View File

@@ -1,22 +0,0 @@
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());

View File

@@ -1,38 +0,0 @@
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();

View File

@@ -1,38 +0,0 @@
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();

View File

@@ -1,306 +0,0 @@
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. Sync Document Stats (New)
*/
async function syncDocumentStats() {
logger.info("Syncing Document Stats...");
const { data, error } = await nocExternalClient.GET("/api/noc/diagram-jumlah-document", {
params: { query: { idDesa: ID_DESA } },
});
if (error || !data) {
logger.error({ error }, "Failed to fetch document stats from NOC");
return;
}
// biome-ignore lint/suspicious/noExplicitAny: External API response
const resData = (data as any).data;
if (!Array.isArray(resData)) {
logger.warn({ data }, "Document stats data from NOC is not an array");
return;
}
for (const stat of resData) {
await prisma.documentStat.upsert({
where: {
villageId_label: {
villageId: ID_DESA,
label: stat.label,
},
},
update: {
value: stat.value,
color: stat.color,
},
create: {
villageId: ID_DESA,
label: stat.label,
value: stat.value,
color: stat.color,
},
});
}
logger.info(`Synced ${resData.length} document stats`);
}
/**
* 6. 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 syncDocumentStats();
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();

View File

@@ -1,216 +0,0 @@
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" },
},
);

View File

@@ -1,72 +0,0 @@
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(),
}),
),
}),
},
},
);

View File

@@ -1,222 +0,0 @@
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" },
},
);

View File

@@ -1,66 +0,0 @@
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" },
},
);

View File

@@ -1,16 +1,10 @@
import { cors } from "@elysiajs/cors";
import { swagger } from "@elysiajs/swagger";
import Elysia, { t } from "elysia";
import Elysia 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";
@@ -18,33 +12,15 @@ const api = new Elysia({
prefix: "/api",
})
.use(cors())
.get("/health", () => ({ ok: true }), {
response: {
200: t.Object({ ok: t.Boolean() }),
},
})
.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 };
},
{
response: {
200: t.Object({ data: t.Any() }),
},
},
)
.get("/session", async ({ request }) => {
const data = await auth.api.getSession({ headers: request.headers });
return { data };
})
.use(apiMiddleware)
.use(noc)
.use(apikey)
.use(profile)
.use(division)
.use(complaint)
.use(resident)
.use(event)
.use(dashboard);
.use(profile);
if (!isProduction) {
api.use(

View File

@@ -1,379 +0,0 @@
import { Elysia, t } from "elysia";
import { prisma } from "../utils/db";
import { $ } from "bun";
import { nocExternalClient } from "../utils/noc-external-client";
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;
try {
// Coba tarik data dari NOC External API (sesuai permintaan user)
const { data: extData, error } = await nocExternalClient.GET(
"/api/noc/diagram-jumlah-document",
{
params: { query: { idDesa } },
},
);
if (!error && extData && (extData as any).success) {
return extData as any;
}
} catch (err) {
console.error("Failed to fetch document stats from NOC External", err);
}
// Fallback ke local database (tabel DocumentStat yang baru)
const stats = await prisma.documentStat.findMany({
where: { villageId: idDesa },
});
if (stats.length > 0) {
return {
success: true,
message: "Berhasil mendapatkan jumlah document dari database",
data: stats.map((s) => ({
label: s.label,
value: s.value,
color: s.color,
})),
};
}
// Fallback terakhir: groupBy Document (model lama)
const data = await prisma.document.groupBy({
where: { villageId: idDesa },
by: ["type"],
_count: {
_all: true,
},
});
const colorMap: Record<string, string> = {
Gambar: "#fac858",
Dokumen: "#92cc76",
PDF: "#3B82F6",
Excel: "#10B981",
};
return {
success: true,
message: "Berhasil mendapatkan jumlah document",
data: data.map((d) => ({
label: d.type,
value: d._count._all,
color: colorMap[d.type] || "#6B7280",
})),
};
},
{
query: t.Object({
idDesa: t.String(),
}),
response: {
200: t.Object({
success: t.Boolean(),
message: t.String(),
data: t.Array(
t.Object({
label: t.String(),
value: t.Number(),
color: t.String(),
}),
),
}),
},
},
)
.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(),
}),
),
}),
},
},
);

View File

@@ -1,69 +1,67 @@
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",
})
.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" };
}).post(
"/update",
async (ctx) => {
const { body, set, user } = ctx as any;
try {
if (!user) {
set.status = 401;
return { error: "Unauthorized" };
}
},
{
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(),
}),
}),
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",
},
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(),
}),
}),
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",
},
},
);

View File

@@ -1,129 +0,0 @@
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",
},
},
);

View File

@@ -1,38 +1,385 @@
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";
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";
const BumdesPage = () => {
const handleDetailClick = (product: SalesData) => {
console.log("Detail clicked for:", product);
// TODO: Open modal or navigate to detail page
};
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",
},
];
return (
<Stack gap="lg">
{/* KPI Summary Cards */}
<SummaryCards />
{/* Header with Time Range Toggle */}
<HeaderToggle />
{/* Main Content - 2 Column Layout */}
{/* KPI Cards */}
<Grid gutter="md">
{/* Left Panel - Produk Unggulan */}
{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>
{/* 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>
<Grid gutter="md">
{/* Produk Unggulan (Left Column) */}
<GridCol span={{ base: 12, lg: 4 }}>
<Stack gap="md">
<ProdukUnggulan />
<TopProducts />
{/* 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>
</Stack>
</GridCol>
{/* Right Panel - Detail Penjualan Produk */}
{/* Detail Penjualan Produk (Right Column) */}
<GridCol span={{ base: 12, lg: 8 }}>
<SalesTable onDetailClick={handleDetailClick} />
<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>
</GridCol>
</Grid>
</Stack>

View File

@@ -1,154 +1,510 @@
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";
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" },
];
export function DashboardContent() {
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();
}, []);
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
return (
<Stack gap="lg">
{/* Header Metrics - 4 Stat Cards */}
{/* Stats Cards */}
<Grid gutter="md">
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
<StatCard
title="Surat Minggu Ini"
value={stats.weeklyService}
detail="Total surat diajukan"
icon={<FileText style={{ width: "70%", height: "70%" }} />}
/>
<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>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
<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%" }} />}
/>
<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>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
<StatCard
title="Layanan Selesai"
value={stats.complaints.selesai}
detail="Total diselesaikan"
icon={<CheckCircle style={{ width: "70%", height: "70%" }} />}
/>
<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>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
<StatCard
title="Total Penduduk"
value={stats.residents.total.toLocaleString()}
detail={`${stats.residents.heads} Kepala Keluarga`}
icon={<Users style={{ width: "70%", height: "70%" }} />}
/>
<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>
</Grid.Col>
</Grid>
{/* Section 2: Chart & Division Progress */}
<Grid gutter="lg">
<Grid.Col span={{ base: 12, lg: 7 }}>
<ChartSurat />
{/* 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>
<Grid.Col span={{ base: 12, lg: 5 }}>
<SatisfactionChart />
{/* 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>
</Grid>
{/* Section 3: APBDes Chart */}
{/* Bottom Section */}
<Grid gutter="lg">
<Grid.Col span={{ base: 12, lg: 7 }}>
<DivisionProgress />
{/* 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>
<Grid.Col span={{ base: 12, lg: 5 }}>
<ActivityList />
{/* <SatisfactionChart /> */}
{/* 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>
</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>
{/* 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>
))}
</Grid>
)}
</Stack>
</Card>
</Stack>
);
}

View File

@@ -1,105 +0,0 @@
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>
);
}

View File

@@ -1,118 +0,0 @@
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>
);
}

View File

@@ -1,179 +0,0 @@
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>
);
}

View File

@@ -1,110 +0,0 @@
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>
);
}

View File

@@ -1,7 +0,0 @@
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";

View File

@@ -1,116 +0,0 @@
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>
);
}

View File

@@ -1,45 +0,0 @@
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>
);
}

View File

@@ -1,87 +0,0 @@
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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,219 +0,0 @@
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",
}}
/>
</>
);
}

View File

@@ -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 content"
alt="Error loading image"
{...rest}
data-original-url={src}
/>{" "}
/>
</div>
</div>
) : (

View File

@@ -1,175 +1,72 @@
import {
ActionIcon,
Anchor,
Avatar,
Badge,
Box,
Breadcrumbs,
Divider,
Group,
Text,
useMantineColorScheme,
} from "@mantine/core";
import {
IconLayoutSidebarLeftCollapse,
IconUserShield,
} from "@tabler/icons-react";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { Bell, Moon, Sun, User as UserIcon } from "lucide-react";
import { useLocation } from "@tanstack/react-router";
import { Bell, Moon, Sun } from "lucide-react";
interface HeaderProps {
onSidebarToggle?: () => void;
}
export function Header({ onSidebarToggle }: HeaderProps) {
export function Header() {
const location = useLocation();
const navigate = useNavigate();
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
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>
);
}),
];
const title =
location.pathname === "/"
? "Desa Darmasaba"
: "Desa Darmasaba";
return (
<Group justify="space-between" w="100%">
{/* Title & Breadcrumbs */}
<Group gap="md">
<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">
<ActionIcon
onClick={onSidebarToggle}
onClick={toggleColorScheme}
variant="subtle"
size="lg"
radius="xl"
visibleFrom="sm"
aria-label="Toggle sidebar"
>
<IconLayoutSidebarLeftCollapse
color="white"
style={{ width: "70%", height: "70%" }}
/>
{dark ? <Sun size={18} /> : <Moon size={18} />}
</ActionIcon>
<Breadcrumbs
separator={
<Text c="white" size="xs">
/
</Text>
}
styles={{
separator: { color: "white" },
}}
>
{breadcrumbItems}
</Breadcrumbs>
</Group>
{/* 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"
<ActionIcon variant="subtle" radius="xl" pos="relative">
<Bell size={18} />
<Badge
size="xs"
color="red"
style={{ position: "absolute", top: -4, right: -4 }}
>
{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>
10
</Badge>
</ActionIcon>
</Group>
</Group>
</Box>
);
}

View File

@@ -143,25 +143,16 @@ 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) => (
{stats.map((stat, index) => (
<HelpCard
key={stat.label}
bg={dark ? "#1E293B" : "white"}
key={index}
bg={dark ? "#141D34" : "white"}
p="lg"
style={{
textAlign: "center",
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",
borderColor: dark ? "#141D34" : "white",
}}
h="100%"
>
@@ -181,20 +172,16 @@ const HelpPage = () => {
{/* Panduan Memulai */}
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
<HelpCard
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" />}
style={{ borderColor: dark ? "#141D34" : "white" }}
bg={dark ? "#141D34" : "white"}
icon={<IconBook size={24} />}
title="Panduan Memulai"
h="100%"
>
<Box>
{guideItems.map((item) => (
{guideItems.map((item, index) => (
<Box
key={item.title}
key={index}
py="sm"
style={{
borderBottom: "1px solid #eee",
@@ -215,20 +202,16 @@ const HelpPage = () => {
{/* Video Tutorial */}
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
<HelpCard
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" />}
style={{ borderColor: dark ? "#141D34" : "white" }}
bg={dark ? "#141D34" : "white"}
icon={<IconVideo size={24} />}
title="Video Tutorial"
h="100%"
>
<Box>
{videoItems.map((item) => (
{videoItems.map((item, index) => (
<Box
key={item.title}
key={index}
py="sm"
style={{
borderBottom: "1px solid #eee",
@@ -249,24 +232,20 @@ const HelpPage = () => {
{/* FAQ */}
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
<HelpCard
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" />}
style={{ borderColor: dark ? "#141D34" : "white" }}
bg={dark ? "#141D34" : "white"}
icon={<IconHelpCircle size={24} />}
title="FAQ"
h="100%"
>
<Accordion variant="separated">
{faqItems.map((item) => (
{faqItems.map((item, index) => (
<Accordion.Item
style={{
backgroundColor: dark ? "#263852ff" : "#F1F5F9",
}}
key={item.question}
value={item.question}
key={index}
value={`faq-${index}`}
>
<Accordion.Control>{item.question}</Accordion.Control>
<Accordion.Panel>
@@ -285,13 +264,9 @@ const HelpPage = () => {
{/* Hubungi Support */}
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
<HelpCard
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" />}
style={{ borderColor: dark ? "#141D34" : "white" }}
bg={dark ? "#141D34" : "white"}
icon={<IconHeadphones size={24} />}
title="Hubungi Support"
h="100%"
>
@@ -324,20 +299,16 @@ const HelpPage = () => {
{/* Dokumentasi */}
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
<HelpCard
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" />}
style={{ borderColor: dark ? "#141D34" : "white" }}
bg={dark ? "#141D34" : "white"}
icon={<IconFileText size={24} />}
title="Dokumentasi"
h="100%"
>
<Box>
{documentationItems.map((item) => (
{documentationItems.map((item, index) => (
<Box
key={item.title}
key={index}
py="sm"
style={{
borderBottom: "1px solid #eee",
@@ -360,13 +331,9 @@ const HelpPage = () => {
{/* Jenna - Virtual Assistant */}
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
<HelpCard
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" />}
style={{ borderColor: dark ? "#141D34" : "white" }}
bg={dark ? "#141D34" : "white"}
icon={<IconMessage size={24} />}
title="Jenna - Virtual Assistant"
h="100%"
>
@@ -434,7 +401,6 @@ const HelpPage = () => {
disabled={isLoading}
/>
<button
type="button"
onClick={handleSendMessage}
disabled={isLoading || inputValue.trim() === ""}
style={{

View File

@@ -1,78 +1,123 @@
import { BarChart } from "@mantine/charts";
import {
Badge,
Box,
Button,
Card,
Grid,
Group,
Progress,
Stack,
Text,
ThemeIcon,
Title,
useMantineColorScheme,
} from "@mantine/core";
import {
AlertTriangle,
CheckCircle,
Clock,
MessageCircle,
TrendingUp,
} from "lucide-react";
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import React from "react";
// KPI Data
// Sample Data
const kpiData = [
{
id: 1,
title: "Interaksi Hari Ini",
value: "61",
subtitle: "+15% dari kemarin",
trend: "positive",
icon: MessageCircle,
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>
),
},
{
id: 2,
title: "Jawaban Otomatis",
value: "87%",
subtitle: "53 dari 61 interaksi",
icon: CheckCircle,
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>
),
},
{
id: 3,
title: "Belum Ditindak",
value: "8",
subtitle: "Perlu respon manual",
icon: AlertTriangle,
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>
),
},
{
id: 4,
title: "Waktu Respon",
value: "2.3 sec",
subtitle: "Rata-rata",
icon: Clock,
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>
),
},
];
// Chart Data
const chartData = [
{ 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 },
{ 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 },
];
// Top Topics Data
const topTopics = [
{ topic: "Cara mengurus KTP", count: 89 },
{ topic: "Syarat Kartu Keluarga", count: 76 },
@@ -81,7 +126,6 @@ const topTopics = [
{ topic: "Info program bansos", count: 48 },
];
// Busy Hours Data
const busyHours = [
{ period: "Pagi (0812)", percentage: 30 },
{ period: "Siang (1216)", percentage: 40 },
@@ -94,206 +138,146 @@ const JennaAnalytic = () => {
const dark = colorScheme === "dark";
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="xl"
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="flex-start" w="100%">
<Stack gap={2}>
<Text size="sm" c="dimmed">
{item.title}
<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>
<Text size="xl" fw={700} c={dark ? "white" : "gray.9"}>
{item.value}
{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>
<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>
)}
{kpi.sub && (
<Text size="xs" c="dimmed" mt={2}>
{kpi.sub}
</Text>
)}
</Card>
</Grid.Col>
))}
</Grid>
{/* 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"}>
<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>
</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>
<BarChart
h={300}
data={chartData}
dataKey="day"
series={[{ name: "total", color: "blue" }]}
withLegend
/>
</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}
{/* Charts and Lists Section */}
<Grid gutter="lg">
{/* Grafik Interaksi Chatbot (now Bar Chart) */}
<Grid.Col 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} 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>
</Card>
</Grid.Col>
{/* 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"
>
{item.count}x
</Badge>
</Group>
</Box>
))}
</Stack>
</Card>
</Grid.Col>
<Text size="sm" fw={500}>
{item.topic}
</Text>
<Badge variant="light" color="gray">
{item.count}x
</Badge>
</Group>
))}
</Stack>
</Card>
{/* 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>
))}
{/* Jam Tersibuk */}
</Stack>
</Card>
</Grid.Col>
</Grid>
</Stack>
</Grid.Col>
</Grid>
</Stack>
</Box>
);
};
export default JennaAnalytic;

View File

@@ -5,6 +5,7 @@ import {
Grid,
GridCol,
Group,
List,
Stack,
Text,
ThemeIcon,
@@ -15,8 +16,11 @@ import {
IconAlertTriangle,
IconCamera,
IconClock,
IconEye,
IconMapPin,
IconShieldLock,
} from "@tabler/icons-react";
import { useState } from "react";
const KeamananPage = () => {
const { colorScheme } = useMantineColorScheme();
@@ -114,144 +118,138 @@ 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">
{/* 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>
{kpiData.map((kpi, index) => (
<GridCol key={index} 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",
}}
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 ? "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
<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>
</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>
</Group>
</Group>
</Card>
))}
</Stack>
<ThemeIcon
variant="light"
color={kpi.color}
size="xl"
radius="xl"
>
{kpi.icon}
</ThemeIcon>
</Group>
</Card>
</Stack>
</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>
{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>
<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>
</GridCol>
{/* Daftar Laporan Keamanan */}
@@ -260,18 +258,18 @@ const KeamananPage = () => {
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",
}}
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
h="100%"
>
<Title order={3} mb="md" c={dark ? "dark.0" : "black"}>
Laporan Keamanan Lingkungan
</Title>
<Stack gap="sm">
{securityReports.map((report) => (
{securityReports.map((report, index) => (
<Card
key={report.id}
key={index}
p="md"
radius="md"
withBorder
@@ -299,19 +297,19 @@ const KeamananPage = () => {
<Group justify="space-between">
<Group gap="xs">
<IconMapPin size={16} stroke={1.5} />
<Text size="sm" c={dark ? "white" : "dimmed"}>
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
{report.location}
</Text>
</Group>
<Group gap="xs">
<IconClock size={16} stroke={1.5} />
<Text size="sm" c={dark ? "white" : "dimmed"}>
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
{report.reportedAt}
</Text>
</Group>
</Group>
<Text size="sm" c={dark ? "white" : "dimmed"} mt="sm">
<Text size="sm" c={dark ? "dark.3" : "dimmed"} mt="sm">
{report.date}
</Text>
</Card>

View File

@@ -1,69 +1,73 @@
import { BarChart } from "@mantine/charts";
import {
Badge,
Box,
Button,
Card,
Grid,
Group,
Progress,
Stack,
Text,
ThemeIcon,
Title,
useMantineColorScheme,
} from "@mantine/core";
import {
CheckCircle,
Coins,
PieChart as PieChartIcon,
Receipt,
TrendingDown,
TrendingUp,
} from "lucide-react";
import {
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
IconCurrency,
IconTrendingDown,
IconTrendingUp,
} from "@tabler/icons-react";
import React from "react";
// KPI Data
// Sample Data
const kpiData = [
{
id: 1,
title: "Total APBDes",
value: "Rp 5.2M",
subtitle: "Tahun 2025",
icon: Coins,
sub: "Tahun 2025",
icon: <IconCurrency className="h-6 w-6 text-muted-foreground" />,
},
{
id: 2,
title: "Realisasi",
value: "68%",
subtitle: "Rp 3.5M dari 5.2M",
icon: CheckCircle,
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>
),
},
{
id: 3,
title: "Pemasukan",
value: "Rp 580jt",
subtitle: "Bulan ini",
trend: "+8%",
icon: TrendingUp,
sub: "Bulan ini",
delta: "+8%",
deltaType: "positive",
icon: <IconTrendingUp className="h-6 w-6 text-muted-foreground" />,
},
{
id: 4,
title: "Pengeluaran",
value: "Rp 520jt",
subtitle: "Bulan ini",
icon: TrendingDown,
sub: "Bulan ini",
icon: <IconTrendingDown className="h-6 w-6 text-muted-foreground" />,
},
];
// Income & Expense Data
const incomeExpenseData = [
{ month: "Apr", income: 450, expense: 380 },
{ month: "Mei", income: 520, expense: 420 },
@@ -74,7 +78,6 @@ const incomeExpenseData = [
{ month: "Okt", income: 580, expense: 520 },
];
// Sector Allocation Data
const allocationData = [
{ sector: "Pembangunan", amount: 1200 },
{ sector: "Kesehatan", amount: 800 },
@@ -84,7 +87,13 @@ const allocationData = [
{ sector: "Teknologi", amount: 300 },
];
// APBDes Report 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 apbdReport = {
income: [
{ category: "Dana Desa", amount: 1800 },
@@ -104,410 +113,244 @@ 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 (
<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 }}>
<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 }}>
<Card
p="md"
radius="xl"
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%"
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<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>
<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
/>
</Card>
</Grid.Col>
))}
</Grid>
{/* 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 */}
<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">
Alokasi Anggaran Per Sektor
</Title>
</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>
<BarChart
h={300}
data={allocationData}
dataKey="sector"
series={[
{ name: "amount", color: "darmasaba-navy", label: "Jumlah" },
]}
withLegend
orientation="horizontal"
/>
</Card>
</Grid.Col>
</Grid>
{/* 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>
</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"}`,
}}
<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" }}
>
<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 order={3} fw={500} mb="md">
Dana Bantuan & 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">
<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)",
}}
>
<Box>
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}>
<Text size="sm" fw={500}>
{fund.source}
</Text>
<Text size="xs" c="dimmed">
<Text size="sm" c="dimmed">
Rp {fund.amount.toLocaleString()}jt
</Text>
</Box>
<Badge
variant="light"
color={fund.status === "cair" ? "green" : "yellow"}
radius="sm"
fw={600}
>
{fund.status === "cair" ? "Cair" : "Proses"}
{fund.status}
</Badge>
</Group>
</Card>
))}
</Stack>
</Card>
</Grid.Col>
</Grid>
</Stack>
))}
</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>
);
};

View File

@@ -1,133 +1,537 @@
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;
}
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";
const KinerjaDivisi = () => {
const [activities, setActivities] = useState<Activity[]>([]);
const [todayEvents, setTodayEvents] = useState<EventData[]>([]);
const [loading, setLoading] = useState(true);
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
useEffect(() => {
async function fetchData() {
try {
const [activityRes, eventRes] = await Promise.all([
apiClient.GET("/api/division/activities"),
apiClient.GET("/api/event/today"),
]);
// 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 },
];
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);
}
}
// 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" },
],
},
];
fetchData();
}, []);
// Archive items
const archiveItems = [
{ name: "Surat Keputusan", count: 12 },
{ name: "Laporan Keuangan", count: 8 },
{ name: "Dokumentasi", count: 24 },
{ name: "Notulensi Rapat", count: 15 },
];
// Format events for EventCard
const formattedEvents = todayEvents.map((event) => ({
time: dayjs(event.startDate).format("HH:mm"),
event: event.title,
}));
// 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" },
];
return (
<Stack gap="lg">
{/* 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"
{/* 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)",
}
: {}
}
/>
</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>
</Grid.Col>
)}
</Grid>
<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>
{/* 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 */}
{/* Ringkasan Tugas per Divisi */}
<Grid gutter="md">
{archiveData.map((item) => (
<Grid.Col key={item.name} span={{ base: 12, md: 6 }}>
<ArchiveCard item={item} />
</Grid.Col>
{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>
</Card>
</GridCol>
))}
</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>
);
};

View File

@@ -1,100 +0,0 @@
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>
);
}

View File

@@ -1,42 +0,0 @@
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>
);
}

View File

@@ -1,125 +0,0 @@
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>
);
}

View File

@@ -1,109 +0,0 @@
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>
);
}

View File

@@ -1,122 +0,0 @@
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 {
label: string;
value: 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/noc/diagram-jumlah-document", {
params: {
query: {
idDesa: "desa1",
},
},
});
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="label"
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="value" radius={[4, 4, 0, 0]}>
{data.map((entry) => (
<Cell key={`cell-${entry.label}`} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<Group justify="center" py="xl">
<Text size="sm" c="dimmed">
Tidak ada dokumen
</Text>
</Group>
)}
</Card>
);
}

View File

@@ -1,70 +0,0 @@
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>
);
}

View File

@@ -1,7 +0,0 @@
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";

View File

@@ -1,153 +0,0 @@
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>
);
}

View File

@@ -1,66 +0,0 @@
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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,78 +1,189 @@
import { Box, Button, Group, Stack, Switch, Text, Title } from "@mantine/core";
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";
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 (
<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
<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
</Button>
<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>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 variant="outline">Batal</Button>
<Button>Simpan Perubahan</Button>
</Group>
</Stack>
</Card>
);
};

View File

@@ -1,64 +1,89 @@
import { Box, Button, Group, Stack, Switch, Text, Title } from "@mantine/core";
import {
Alert,
Button,
Card,
Group,
PasswordInput,
Space,
Switch,
Text,
Title,
useMantineColorScheme,
} from "@mantine/core";
import { IconInfoCircle, IconLock } from "@tabler/icons-react";
const KeamananSettings = () => {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
return (
<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>Simpan Perubahan</Button>
<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>
</Stack>
<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">
<Button variant="outline">Batal</Button>
<Button>Perbarui Kata Sandi</Button>
</Group>
</Card>
);
};

View File

@@ -1,114 +1,85 @@
import {
Alert,
Button,
Grid,
GridCol,
Card,
Checkbox,
Group,
Stack,
Space,
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 (
<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">
<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">
<Button variant="outline">Batal</Button>
<Button>Simpan Preferensi</Button>
</Group>
</Stack>
</Card>
);
};

View File

@@ -1,176 +0,0 @@
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;

View File

@@ -1,12 +1,44 @@
import { Box, Button, Group, Select, Switch, Text, Title } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import {
Alert,
Button,
Card,
Group,
Select,
Space,
Switch,
Text,
TextInput,
Title,
useMantineColorScheme,
} from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
const UmumSettings = () => {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
return (
<Box pr={"50%"}>
<Card
withBorder
radius="md"
p="xl"
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Title order={2} mb="lg">
Preferensi Tampilan
Pengaturan Umum
</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"
@@ -29,53 +61,25 @@ const UmumSettings = () => {
mb="md"
/>
<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 mb="md">
<Switch label="Notifikasi Email" defaultChecked />
</Group>
<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>
<Alert
icon={<IconInfoCircle size={16} />}
title="Informasi"
color="blue"
mb="md"
>
Beberapa pengaturan mungkin memerlukan restart aplikasi untuk diterapkan
sepenuhnya.
</Alert>
<Group justify="flex-end" mt="xl">
<Button variant="outline">Batal</Button>
<Button>Simpan Perubahan</Button>
</Group>
</Box>
</Card>
);
};

View File

@@ -1,10 +1,12 @@
import {
Badge,
Box,
Collapse,
Image,
Group,
Input,
NavLink as MantineNavLink,
Stack,
Text,
useMantineColorScheme,
} from "@mantine/core";
import { useLocation, useNavigate } from "@tanstack/react-router";
@@ -25,30 +27,35 @@ export function Sidebar({ className }: SidebarProps) {
// State for settings submenu collapse
const [settingsOpen, setSettingsOpen] = useState(
location.pathname.startsWith("/pengaturan"),
location.pathname.startsWith("/dashboard/pengaturan"),
);
// Define menu items with their paths
const menuItems = [
{ 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" },
{ 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" },
];
// Settings submenu items
const settingsItems = [
{ 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" },
{ 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" },
];
// Check if any settings submenu is active
@@ -59,7 +66,30 @@ export function Sidebar({ className }: SidebarProps) {
return (
<Box className={className}>
{/* Logo */}
<Image src={dark ? "/white.png" : "/light-mode.png"} alt="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>
{/* Search */}
<Box p="md">
@@ -78,11 +108,11 @@ export function Sidebar({ className }: SidebarProps) {
{/* Menu Items */}
<Stack gap={0} px="xs" style={{ overflowY: "auto" }}>
{menuItems.map((item) => {
{menuItems.map((item, index) => {
const isActive = location.pathname === item.path;
return (
<MantineNavLink
key={item.path}
key={index}
onClick={() => navigate({ to: item.path })}
label={item.name}
active={isActive}
@@ -144,11 +174,11 @@ export function Sidebar({ className }: SidebarProps) {
ml="lg"
style={{ overflowY: "auto", maxHeight: "200px" }}
>
{settingsItems.map((item) => {
{settingsItems.map((item, index) => {
const isActive = location.pathname === item.path;
return (
<MantineNavLink
key={item.path}
key={index}
onClick={() => navigate({ to: item.path })}
label={item.name}
active={isActive}

View File

@@ -1,45 +1,463 @@
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";
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";
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">
{/* Top Summary Cards - 4 Grid */}
<SummaryCards />
{/* Second Row - 2 Column Grid */}
{/* Health Statistics Cards */}
<Grid gutter="md">
{/* Left - Statistik Kesehatan */}
<GridCol span={{ base: 12, lg: 6 }}>
<HealthStats />
<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>
</GridCol>
{/* Right - Jadwal Posyandu */}
<GridCol span={{ base: 12, lg: 6 }}>
<PosyanduSchedule />
<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>
</GridCol>
</Grid>
{/* Third Row - 2 Column 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>
<Grid gutter="md">
{/* Left - Pendidikan */}
{/* Jadwal Posyandu */}
<GridCol span={{ base: 12, lg: 6 }}>
<Pendidikan />
<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>
</GridCol>
{/* Right - Beasiswa Desa */}
{/* Pendidikan */}
<GridCol span={{ base: 12, lg: 6 }}>
<Beasiswa />
<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>
</GridCol>
</Grid>
{/* Bottom Section - Event Budaya */}
<EventCalendar />
<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>
</Stack>
);
};

View File

@@ -1,79 +0,0 @@
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>
);
};

View File

@@ -1,104 +0,0 @@
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>
);
};

View File

@@ -1,77 +0,0 @@
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>
);
};

View File

@@ -1,124 +0,0 @@
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>
);
};

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