Compare commits
62 Commits
stg
...
tasks/noc-
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ace5b5d1c | |||
| 11ef320d55 | |||
| 1b1dc71225 | |||
| 2c5fa52608 | |||
| f066defcba | |||
| fd52b0d281 | |||
| 65844bac7e | |||
| 3125bc1002 | |||
| ed93363de1 | |||
| 8e2608a2be | |||
| 0736df8523 | |||
| 097f9f34cc | |||
| 75c7bc249e | |||
| b77822f2dd | |||
| 5058e2cc1c | |||
| 3bed181805 | |||
| c7a986aebc | |||
| c6951dec80 | |||
| 8da53127c7 | |||
| 354e706dc5 | |||
| c216fa074d | |||
| 44b6b158ef | |||
| 34804127c5 | |||
| 0d0dc187a5 | |||
| ec057ef2e5 | |||
| 0900b8f199 | |||
| aeedb17402 | |||
| ebc1242bee | |||
| 0e063cb79e | |||
| 3eb84921a1 | |||
| c6415c5aab | |||
| 519a14adaa | |||
| 366c08fbaa | |||
| 5c09e7a0be | |||
| 7c8012d277 | |||
| 687ce11a81 | |||
| 1ba4643e23 | |||
| 113dd7ba6f | |||
| 71a305cd4b | |||
| 84b96ca3be | |||
| 8159216a2c | |||
| d714c09efc | |||
| 0a97e31416 | |||
| 158a2db435 | |||
| 2d68d4dc06 | |||
| 97e6caa332 | |||
| f0c37272b9 | |||
| 8c35d58b38 | |||
| 952f7ecb16 | |||
| a74e0c02e5 | |||
| 17ecd3feca | |||
| d88cf2b100 | |||
| e0955ed2c4 | |||
| 918399bf62 | |||
| 7ce2eb6ae8 | |||
| 40772859f9 | |||
| c7b34b8c28 | |||
| 9bf73a305c | |||
| 947adc1537 | |||
| 9086e28961 | |||
| 66d207c081 | |||
| b77f6e8fa3 |
@@ -17,3 +17,4 @@ LOG_LEVEL=info
|
|||||||
|
|
||||||
# Public URL
|
# Public URL
|
||||||
VITE_PUBLIC_URL="http://localhost:3000"
|
VITE_PUBLIC_URL="http://localhost:3000"
|
||||||
|
NOC_API_URL="https://darmasaba.muku.id/api/noc/docs/json"
|
||||||
|
|||||||
@@ -1,43 +1,52 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
// Fungsi untuk mencari string terpanjang dalam objek (biasanya balasan AI)
|
// Function to manually load .env from project root if process.env is missing keys
|
||||||
function findLongestString(obj: any): string {
|
function loadEnv() {
|
||||||
let longest = "";
|
const envPath = join(process.cwd(), ".env");
|
||||||
const search = (item: any) => {
|
if (existsSync(envPath)) {
|
||||||
if (typeof item === "string") {
|
const envContent = readFileSync(envPath, "utf-8");
|
||||||
if (item.length > longest.length) longest = item;
|
const lines = envContent.split("\n");
|
||||||
} else if (Array.isArray(item)) {
|
for (const line of lines) {
|
||||||
item.forEach(search);
|
if (line && !line.startsWith("#")) {
|
||||||
} else if (item && typeof item === "object") {
|
const [key, ...valueParts] = line.split("=");
|
||||||
Object.values(item).forEach(search);
|
if (key && valueParts.length > 0) {
|
||||||
|
const value = valueParts.join("=").trim().replace(/^["']|["']$/g, "");
|
||||||
|
process.env[key.trim()] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
search(obj);
|
|
||||||
return longest;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
|
// Ensure environment variables are loaded
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
const inputRaw = readFileSync(0, "utf-8");
|
const inputRaw = readFileSync(0, "utf-8");
|
||||||
if (!inputRaw) return;
|
if (!inputRaw) return;
|
||||||
const input = JSON.parse(inputRaw);
|
|
||||||
|
|
||||||
// DEBUG: Lihat struktur asli di console terminal (stderr)
|
let finalText = "";
|
||||||
console.error("DEBUG KEYS:", Object.keys(input));
|
let sessionId = "dashboard-desa-plus";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try parsing as JSON first
|
||||||
|
const input = JSON.parse(inputRaw);
|
||||||
|
sessionId = input.session_id || "dashboard-desa-plus";
|
||||||
|
finalText = typeof input === "string" ? input : (input.response || input.text || JSON.stringify(input));
|
||||||
|
} catch {
|
||||||
|
// If not JSON, use raw text
|
||||||
|
finalText = inputRaw;
|
||||||
|
}
|
||||||
|
|
||||||
const BOT_TOKEN = process.env.BOT_TOKEN;
|
const BOT_TOKEN = process.env.BOT_TOKEN;
|
||||||
const CHAT_ID = process.env.CHAT_ID;
|
const CHAT_ID = process.env.CHAT_ID;
|
||||||
|
|
||||||
const sessionId = input.session_id || "unknown";
|
if (!BOT_TOKEN || !CHAT_ID) {
|
||||||
|
console.error("Missing BOT_TOKEN or CHAT_ID in environment variables");
|
||||||
// Cari teks secara otomatis di seluruh objek JSON
|
return;
|
||||||
let finalText = findLongestString(input.response || input);
|
|
||||||
|
|
||||||
if (!finalText || finalText.length < 5) {
|
|
||||||
finalText =
|
|
||||||
"Teks masih gagal diekstraksi. Struktur: " +
|
|
||||||
Object.keys(input).join(", ");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
@@ -45,7 +54,7 @@ async function run() {
|
|||||||
`🆔 Session: \`${sessionId}\` \n\n` +
|
`🆔 Session: \`${sessionId}\` \n\n` +
|
||||||
`🧠 Output:\n${finalText.substring(0, 3500)}`;
|
`🧠 Output:\n${finalText.substring(0, 3500)}`;
|
||||||
|
|
||||||
await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
const res = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -55,6 +64,13 @@ async function run() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json();
|
||||||
|
console.error("Telegram API Error:", errorData);
|
||||||
|
} else {
|
||||||
|
console.log("Notification sent successfully!");
|
||||||
|
}
|
||||||
|
|
||||||
process.stdout.write(JSON.stringify({ status: "continue" }));
|
process.stdout.write(JSON.stringify({ status: "continue" }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Hook Error:", err);
|
console.error("Hook Error:", err);
|
||||||
|
|||||||
3
.gitignore
vendored
@@ -37,6 +37,9 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
# Dashboard-MD
|
# Dashboard-MD
|
||||||
Dashboard-MD
|
Dashboard-MD
|
||||||
|
|
||||||
|
# md
|
||||||
|
*.md
|
||||||
|
|
||||||
# Playwright artifacts
|
# Playwright artifacts
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
|||||||
5
.qwen/settings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": ["Bash(bun *)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ RUN bun x prisma generate
|
|||||||
# Generate API types
|
# Generate API types
|
||||||
RUN bun run gen:api
|
RUN bun run gen:api
|
||||||
|
|
||||||
# Build the application frontend
|
# Build the application frontend using our custom build script
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
# Stage 2: Runtime
|
# Stage 2: Runtime
|
||||||
|
|||||||
86
MIND/TASK/database-implementation/phase-1-core-schema.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# TASK: Phase 1 - Implementasi Skema Inti & API Endpoints
|
||||||
|
|
||||||
|
**ID:** `TASK-DB-001`
|
||||||
|
**Konteks:** Database Implementation
|
||||||
|
**Status:** ✅ COMPLETED (95% Selesai)
|
||||||
|
**Prioritas:** 🔴 KRITIS (Blokade Fitur)
|
||||||
|
**Estimasi:** 7 Hari Kerja
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 OBJEKTIF
|
||||||
|
Mengganti mock data pada fitur-fitur inti (Kinerja Divisi, Pengaduan, Kependudukan) dengan data riil dari database PostgreSQL melalui Prisma ORM dan menyediakan endpoint API yang type-safe menggunakan ElysiaJS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 DAFTAR TUGAS (TODO)
|
||||||
|
|
||||||
|
### 1. Database Migration (Prisma)
|
||||||
|
- [x] Implementasikan model `Division`, `Activity`, `Document`, `Discussion`, dan `DivisionMetric` di `schema.prisma`.
|
||||||
|
- [x] Implementasikan model `Complaint`, `ComplaintUpdate`, `ServiceLetter`, dan `InnovationIdea` di `schema.prisma`.
|
||||||
|
- [x] Implementasikan model `Resident` dan `Banjar` di `schema.prisma`.
|
||||||
|
- [x] Implementasikan model `Event` di `schema.prisma`.
|
||||||
|
- [x] Jalankan `bun x prisma migrate dev --name init_core_features`.
|
||||||
|
- [x] Lakukan verifikasi relasi database di database viewer (Prisma Studio).
|
||||||
|
|
||||||
|
### 2. Seeding Data
|
||||||
|
- [x] Update `prisma/seed.ts` untuk menyertakan data dummy yang realistis untuk:
|
||||||
|
- 6 Banjar (Darmasaba, Manesa, dll)
|
||||||
|
- 4 Divisi utama
|
||||||
|
- Contoh Pengaduan & Layanan Surat
|
||||||
|
- Contoh Event & Aktivitas
|
||||||
|
- [x] Jalankan `bun run seed` dan pastikan tidak ada error relasi.
|
||||||
|
|
||||||
|
### 3. Backend API Development (ElysiaJS)
|
||||||
|
- [x] Buat route handler di `src/api/` untuk setiap modul:
|
||||||
|
- `division.ts`: CRUD Divisi & Aktivitas
|
||||||
|
- `complaint.ts`: CRUD Pengaduan & Update Status
|
||||||
|
- `resident.ts`: Endpoint untuk statistik demografi & list penduduk per banjar
|
||||||
|
- `event.ts`: CRUD Agenda & Kalender
|
||||||
|
- [x] Integrasikan `apiMiddleware` untuk proteksi rute (Admin/Moderator).
|
||||||
|
- [x] Pastikan skema input/output didefinisikan menggunakan `t.Object` untuk OpenAPI documentation.
|
||||||
|
|
||||||
|
### 4. Contract-First Sync
|
||||||
|
- [x] Jalankan `bun run gen:api` untuk memperbarui `generated/api.ts`.
|
||||||
|
- [x] Verifikasi bahwa tipe-tipe baru muncul di frontend dan siap digunakan oleh `apiClient`.
|
||||||
|
|
||||||
|
### 5. Frontend Integration (Surgical Update)
|
||||||
|
- [x] Update `src/hooks/` atau `src/store/` untuk memanggil API riil menggantikan mock data.
|
||||||
|
- [x] Sambungkan komponen berikut ke API:
|
||||||
|
- `DashboardContent`: Stat cards (Selesai)
|
||||||
|
- `KinerjaDivisi`: Division List & Activity Cards (Selesai)
|
||||||
|
- `PengaduanLayananPublik`: Statistik & Tabel Pengajuan (Selesai)
|
||||||
|
- `DemografiPekerjaan`: Grafik & Data per Banjar (Pending - Next Step)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ INSTRUKSI TEKNIS
|
||||||
|
|
||||||
|
### Penanganan Relasi Prisma
|
||||||
|
Gunakan transaksi atau `onDelete: Cascade` pada relasi yang bergantung secara total (misal: `Activity` ke `Division`) untuk menjaga integritas data.
|
||||||
|
|
||||||
|
### Struktur API Route
|
||||||
|
Contoh struktur yang diharapkan untuk `src/api/division.ts`:
|
||||||
|
```typescript
|
||||||
|
export const divisionRoutes = new Elysia({ prefix: '/division' })
|
||||||
|
.get('/', () => db.division.findMany({ include: { activities: true } }))
|
||||||
|
.post('/', ({ body }) => db.division.create({ data: body }), {
|
||||||
|
body: t.Object({ ... })
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ DEFINITION OF DONE (DoD)
|
||||||
|
1. [ ] Skema database berhasil dimigrasi tanpa error.
|
||||||
|
2. [ ] API Endpoints muncul di `/api/docs` (Swagger).
|
||||||
|
3. [ ] `bun run test` (API tests) berhasil untuk endpoint baru.
|
||||||
|
4. [ ] Frontend menampilkan data riil dari database (bukan mock) pada rute yang ditentukan.
|
||||||
|
5. [ ] Performa query optimal (tidak ada N+1 problem pada relasi Prisma).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 CATATAN
|
||||||
|
- Fokus pada **READ** operations terlebih dahulu agar dashboard bisa tampil.
|
||||||
|
- Fitur **WRITE** (Create/Update) bisa diimplementasikan secara bertahap setelah tampilan dashboard stabil.
|
||||||
|
- Jangan lupa update `GEMINI.md` jika ada perubahan pada alur pengembangan.
|
||||||
76
MIND/TASK/developer-experience/implement-dev-inspector.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# TASK: Implementasi Click-to-Source (Dev Inspector)
|
||||||
|
|
||||||
|
**ID:** `TASK-DX-001`
|
||||||
|
**Konteks:** Developer Experience (DX)
|
||||||
|
**Status:** ✅ COMPLETED
|
||||||
|
**Prioritas:** 🟡 TINGGI (Peningkatan Produktivitas)
|
||||||
|
**Estimasi:** 1 Hari Kerja
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 OBJEKTIF
|
||||||
|
Mengaktifkan fitur **Click-to-Source** di lingkungan pengembangan: klik elemen UI di browser sambil menekan hotkey (`Ctrl+Shift+Cmd+C` atau `Ctrl+Shift+Alt+C`) untuk langsung membuka file source code di editor (VS Code, Cursor, dll) pada baris dan kolom yang tepat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 DAFTAR TUGAS (TODO)
|
||||||
|
|
||||||
|
### 1. Vite Plugin Configuration
|
||||||
|
- [x] Buat file `src/utils/dev-inspector-plugin.ts` yang berisi `inspectorPlugin()` (regex-based JSX attribute injection).
|
||||||
|
- [x] Modifikasi `src/vite.ts`:
|
||||||
|
- [x] Impor `inspectorPlugin`.
|
||||||
|
- [x] Tambahkan `inspectorPlugin()` ke array `plugins` **sebelum** `react()`.
|
||||||
|
- [x] Gunakan `enforce: 'pre'` pada plugin tersebut.
|
||||||
|
|
||||||
|
### 2. Frontend Component Development
|
||||||
|
- [x] Buat komponen `src/components/dev-inspector.tsx`:
|
||||||
|
- [x] Implementasikan hotkey listener.
|
||||||
|
- [x] Tambahkan overlay UI (border biru & tooltip nama file) saat hover.
|
||||||
|
- [x] Implementasikan `getCodeInfoFromElement` dengan fallback (fiber props -> DOM attributes).
|
||||||
|
- [x] Tambahkan fungsi `openInEditor` (POST ke `/__open-in-editor`).
|
||||||
|
|
||||||
|
### 3. Backend Integration (Elysia)
|
||||||
|
- [x] Modifikasi `src/index.ts`:
|
||||||
|
- [x] Tambahkan handler `onRequest` sebelum middleware lainnya.
|
||||||
|
- [x] Intercept request ke path `/__open-in-editor` (POST).
|
||||||
|
- [x] Gunakan `Bun.spawn()` untuk memanggil editor (berdasarkan `.env` `REACT_EDITOR`).
|
||||||
|
- [x] Gunakan `Bun.which()` untuk verifikasi keberadaan editor di system PATH.
|
||||||
|
|
||||||
|
### 4. Application Root Integration
|
||||||
|
- [x] Modifikasi `src/frontend.tsx`:
|
||||||
|
- [x] Implementasikan **Conditional Dynamic Import** untuk `DevInspector`.
|
||||||
|
- [x] Gunakan `import.meta.env?.DEV` agar tidak ada overhead di production.
|
||||||
|
- [x] Bungkus `<App />` (atau router) dengan `<DevInspectorWrapper>`.
|
||||||
|
|
||||||
|
### 5. Environment Setup
|
||||||
|
- [x] Tambahkan `REACT_EDITOR=code` (atau `cursor`, `windsurf`) di file `.env`.
|
||||||
|
- [x] Pastikan alias `@/` berfungsi dengan benar di plugin Vite untuk resolusi path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ INSTRUKSI TEKNIS
|
||||||
|
|
||||||
|
### Urutan Plugin di Vite
|
||||||
|
Sangat krusial agar `inspectorPlugin` berjalan di fase **pre-transform** sebelum JSX diubah menjadi `React.createElement` oleh compiler Rust (OXC) milik Vite React Plugin.
|
||||||
|
|
||||||
|
### Penanganan React 19
|
||||||
|
Gunakan strategi *multi-fallback* karena React 19 menghapus `_debugSource`. Prioritas pencarian info:
|
||||||
|
1. `__reactProps$*` (React internal props)
|
||||||
|
2. `__reactFiber$*` (Fiber tree walk-up)
|
||||||
|
3. DOM attribute `data-inspector-*` (Fallback universal)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ DEFINITION OF DONE (DoD)
|
||||||
|
1. [ ] Hotkey `Ctrl+Shift+Cmd+C` (macOS) / `Ctrl+Shift+Alt+C` mengaktifkan mode inspeksi.
|
||||||
|
2. [ ] Klik pada elemen UI membuka file yang benar di VS Code/Cursor pada baris yang tepat.
|
||||||
|
3. [ ] Fitur hanya aktif di mode pengembangan (`bun run dev`).
|
||||||
|
4. [ ] Di mode produksi (`bun run build`), tidak ada kode `DevInspector` yang masuk ke bundle (verifikasi via `dist/` jika perlu).
|
||||||
|
5. [ ] Kode mengikuti standar linting Biome (jalankan `bun run lint`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 CATATAN
|
||||||
|
- Gunakan `Bun.spawn()` dengan mode `detached: true` jika memungkinkan (atau default fire-and-forget).
|
||||||
|
- Jika menggunakan Windows (WSL), pastikan path file dikonversi dengan benar (jika ada kendala).
|
||||||
|
- Gunakan log di console saat mode inspeksi aktif untuk mempermudah debugging.
|
||||||
168
Pengaduan-New.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
Create a modern analytics dashboard UI for a village complaint system (Pengaduan Dashboard).
|
||||||
|
|
||||||
|
Tech stack:
|
||||||
|
- React 19 + Vite (Bun runtime)
|
||||||
|
- Mantine UI (core components)
|
||||||
|
- TailwindCSS (layout & spacing only)
|
||||||
|
- Recharts (charts)
|
||||||
|
- TanStack Router
|
||||||
|
- Icons: lucide-react
|
||||||
|
- State: Valtio
|
||||||
|
- Date: dayjs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 DESIGN STYLE
|
||||||
|
|
||||||
|
- Clean, minimal, and soft dashboard
|
||||||
|
- Background: light gray (#f3f4f6)
|
||||||
|
- Card: white with subtle shadow
|
||||||
|
- Border radius: 16px–24px (rounded-2xl)
|
||||||
|
- Typography: medium contrast (not too bold)
|
||||||
|
- Primary color: navy blue (#1E3A5F)
|
||||||
|
- Accent: soft blue + neutral gray
|
||||||
|
- Icons inside circular solid background
|
||||||
|
|
||||||
|
Spacing:
|
||||||
|
- Use gap-6 consistently
|
||||||
|
- Internal padding: p-5 or p-6
|
||||||
|
- Layout must feel breathable (no clutter)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧱 LAYOUT STRUCTURE
|
||||||
|
|
||||||
|
### 🔹 TOP SECTION (4 STAT CARDS - GRID)
|
||||||
|
Grid: 4 columns (responsive → 2 / 1)
|
||||||
|
|
||||||
|
Each card contains:
|
||||||
|
- Title (small, muted)
|
||||||
|
- Big number (bold, large)
|
||||||
|
- Subtitle (small gray text)
|
||||||
|
- Right side: circular icon container
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Total Pengaduan → 42 → "Bulan ini"
|
||||||
|
- Baru → 14 → "Belum diproses"
|
||||||
|
- Diproses → 14 → "Sedang ditangani"
|
||||||
|
- Selesai → 14 → "Terselesaikan"
|
||||||
|
|
||||||
|
Use:
|
||||||
|
- Mantine Card
|
||||||
|
- Group justify="space-between"
|
||||||
|
- Icon inside circle (bg navy, icon white)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 MAIN CHART (FULL WIDTH)
|
||||||
|
Title: "Tren Pengaduan"
|
||||||
|
|
||||||
|
- Use Recharts LineChart
|
||||||
|
- Smooth line (monotone)
|
||||||
|
- Show dots on each point
|
||||||
|
- Data: Apr → Okt
|
||||||
|
- Value range: 30–60
|
||||||
|
|
||||||
|
Style:
|
||||||
|
- Minimal grid (light dashed)
|
||||||
|
- No heavy colors (use gray/blue line)
|
||||||
|
- Rounded container card
|
||||||
|
- Add small top-right icon (expand)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 BOTTOM SECTION (3 COLUMN GRID)
|
||||||
|
|
||||||
|
### 🔹 LEFT: "Surat Terbanyak"
|
||||||
|
- Horizontal bar chart (Recharts)
|
||||||
|
- Categories:
|
||||||
|
- KTP
|
||||||
|
- KK
|
||||||
|
- Domisili
|
||||||
|
- Usaha
|
||||||
|
- Lainnya
|
||||||
|
|
||||||
|
Style:
|
||||||
|
- Dark blue bars
|
||||||
|
- Rounded edges
|
||||||
|
- Clean axis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔹 CENTER: "Pengajuan Terbaru"
|
||||||
|
List of activity cards:
|
||||||
|
|
||||||
|
Each item:
|
||||||
|
- Name (bold)
|
||||||
|
- Subtitle (jenis surat)
|
||||||
|
- Time (small text)
|
||||||
|
- Status badge (kanan)
|
||||||
|
|
||||||
|
Status:
|
||||||
|
- baru → red
|
||||||
|
- proses → blue
|
||||||
|
- selesai → green
|
||||||
|
|
||||||
|
Style:
|
||||||
|
- Card per item
|
||||||
|
- Soft border
|
||||||
|
- Rounded
|
||||||
|
- Compact spacing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔹 RIGHT: "Ajuan Ide Inovatif"
|
||||||
|
List mirip dengan pengajuan terbaru:
|
||||||
|
|
||||||
|
Each item:
|
||||||
|
- Nama
|
||||||
|
- Judul ide
|
||||||
|
- Waktu
|
||||||
|
- Button kecil "Detail"
|
||||||
|
|
||||||
|
Style:
|
||||||
|
- Right-aligned action button
|
||||||
|
- Light border
|
||||||
|
- Clean spacing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ COMPONENT STRUCTURE
|
||||||
|
|
||||||
|
components/
|
||||||
|
- StatCard.tsx
|
||||||
|
- LineChartCard.tsx
|
||||||
|
- BarChartCard.tsx
|
||||||
|
- ActivityList.tsx
|
||||||
|
- IdeaList.tsx
|
||||||
|
|
||||||
|
routes/
|
||||||
|
- dashboard.tsx
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ INTERACTIONS (IMPORTANT)
|
||||||
|
|
||||||
|
- Hover card → scale(1.02)
|
||||||
|
- Transition: 150ms ease
|
||||||
|
- Icon circle slightly pop on hover
|
||||||
|
- List item hover → subtle bg change
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 UX DETAILS
|
||||||
|
|
||||||
|
- Numbers must be visually dominant
|
||||||
|
- Icons must balance layout (not too big)
|
||||||
|
- Avoid heavy borders
|
||||||
|
- Keep everything aligned perfectly
|
||||||
|
- No clutter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 OUTPUT
|
||||||
|
|
||||||
|
- Modular React components (NOT one file)
|
||||||
|
- Clean code (production-ready)
|
||||||
|
- Use Mantine properly (no hacky inline styles unless needed)
|
||||||
|
- Use Tailwind only for layout/grid/spacing
|
||||||
BIN
Screenshot 2026-03-10 at 16.48.25.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
93
__tests__/api/noc.test.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import api from "@/api";
|
||||||
|
import { prisma } from "@/utils/db";
|
||||||
|
|
||||||
|
describe("NOC API Module", () => {
|
||||||
|
const idDesa = "desa1";
|
||||||
|
|
||||||
|
it("should return last sync timestamp", async () => {
|
||||||
|
const response = await api.handle(
|
||||||
|
new Request(`http://localhost/api/noc/last-sync?idDesa=${idDesa}`),
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data).toHaveProperty("lastSyncedAt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return active divisions", async () => {
|
||||||
|
const response = await api.handle(
|
||||||
|
new Request(`http://localhost/api/noc/active-divisions?idDesa=${idDesa}`),
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(Array.isArray(data.data)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return latest projects", async () => {
|
||||||
|
const response = await api.handle(
|
||||||
|
new Request(`http://localhost/api/noc/latest-projects?idDesa=${idDesa}`),
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(Array.isArray(data.data)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return upcoming events", async () => {
|
||||||
|
const response = await api.handle(
|
||||||
|
new Request(`http://localhost/api/noc/upcoming-events?idDesa=${idDesa}`),
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(Array.isArray(data.data)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return diagram jumlah document", async () => {
|
||||||
|
const response = await api.handle(
|
||||||
|
new Request(
|
||||||
|
`http://localhost/api/noc/diagram-jumlah-document?idDesa=${idDesa}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(Array.isArray(data.data)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return diagram progres kegiatan", async () => {
|
||||||
|
const response = await api.handle(
|
||||||
|
new Request(
|
||||||
|
`http://localhost/api/noc/diagram-progres-kegiatan?idDesa=${idDesa}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(Array.isArray(data.data)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return latest discussion", async () => {
|
||||||
|
const response = await api.handle(
|
||||||
|
new Request(
|
||||||
|
`http://localhost/api/noc/latest-discussion?idDesa=${idDesa}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(Array.isArray(data.data)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 400 for missing idDesa in active-divisions", async () => {
|
||||||
|
const response = await api.handle(
|
||||||
|
new Request("http://localhost/api/noc/active-divisions"),
|
||||||
|
);
|
||||||
|
// Elysia returns 400 or 422 for validation errors
|
||||||
|
expect([400, 422]).toContain(response.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 401 or 422 for sync without admin auth", async () => {
|
||||||
|
const response = await api.handle(
|
||||||
|
new Request("http://localhost/api/noc/sync", {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect([401, 422]).toContain(response.status);
|
||||||
|
});
|
||||||
|
});
|
||||||
110
__tests__/e2e/noc-sync.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("NOC Synchronization UI", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Mock the session API to simulate being logged in as an admin
|
||||||
|
await page.route("**/api/session", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
id: "user_123",
|
||||||
|
name: "Admin User",
|
||||||
|
email: "admin@example.com",
|
||||||
|
role: "admin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the last-sync API
|
||||||
|
await page.route("**/api/noc/last-sync*", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
lastSyncedAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should navigate to NOC Sync page from sidebar", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
// Open Settings/Pengaturan submenu if not open
|
||||||
|
const settingsNavLink = page.locator('button:has-text("Pengaturan")');
|
||||||
|
await settingsNavLink.click();
|
||||||
|
|
||||||
|
// Click on Sinkronisasi NOC
|
||||||
|
const syncNavLink = page.locator('a:has-text("Sinkronisasi NOC")');
|
||||||
|
// In Mantine NavLink with navigate, it might be a button or div with role button depending on implementation
|
||||||
|
// Based on Sidebar.tsx, it's a MantineNavLink which renders as a button or anchor
|
||||||
|
const syncLink = page.getByRole("button", { name: "Sinkronisasi NOC" });
|
||||||
|
await syncLink.click();
|
||||||
|
|
||||||
|
// Verify we are on the sync page
|
||||||
|
await expect(page).toHaveURL(/\/pengaturan\/sinkronisasi/);
|
||||||
|
await expect(page.locator("h2")).toContainText("Sinkronisasi Data NOC");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should perform synchronization successfully", async ({ page }) => {
|
||||||
|
await page.goto("/pengaturan/sinkronisasi");
|
||||||
|
|
||||||
|
// Initial state check
|
||||||
|
await expect(page.locator("text=Waktu Sinkronisasi Terakhir:")).toBeVisible();
|
||||||
|
|
||||||
|
// Mock the sync API
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await page.route("**/api/noc/sync", async (route) => {
|
||||||
|
if (route.request().method() === "POST") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: "Sinkronisasi berhasil diselesaikan",
|
||||||
|
lastSyncedAt: now,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click Sync button
|
||||||
|
await page.click('button:has-text("Sinkronkan Sekarang")');
|
||||||
|
|
||||||
|
// Verify success message
|
||||||
|
await expect(page.locator("text=Sinkronisasi berhasil dilakukan")).toBeVisible();
|
||||||
|
|
||||||
|
// Verify timestamp updated (it should show "beberapa detik yang lalu" or similar because of dayjs fromNow)
|
||||||
|
// We can just check if the new time format is there or the relative time updated
|
||||||
|
await expect(page.locator("text=beberapa detik yang lalu")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle synchronization error", async ({ page }) => {
|
||||||
|
await page.goto("/pengaturan/sinkronisasi");
|
||||||
|
|
||||||
|
// Mock the sync API failure
|
||||||
|
await page.route("**/api/noc/sync", async (route) => {
|
||||||
|
if (route.request().method() === "POST") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200, // API returns 200 but with success: false for business logic errors
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: "Sinkronisasi gagal dijalankan",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click Sync button
|
||||||
|
await page.click('button:has-text("Sinkronkan Sekarang")');
|
||||||
|
|
||||||
|
// Verify error message
|
||||||
|
await expect(page.locator("text=Sinkronisasi gagal dijalankan")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
31
bun.lock
@@ -92,7 +92,6 @@
|
|||||||
"postcss-preset-mantine": "^1.18.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"react-dev-inspector": "^2.0.1",
|
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -474,14 +473,8 @@
|
|||||||
|
|
||||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
"@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/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=="],
|
"@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=="],
|
"@redocly/ajv": ["@redocly/ajv@8.17.3", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-NQsbJbB/GV7JVO88ebFkMndrnuGp/dTm5/2NISeg+JGcLzTfGBJZ01+V5zD8nKBOpi/dLLNFT+Ql6IcUk8ehng=="],
|
||||||
@@ -674,8 +667,6 @@
|
|||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
"@types/react-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=="],
|
"@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=="],
|
"@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="],
|
||||||
@@ -1084,8 +1075,6 @@
|
|||||||
|
|
||||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
"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=="],
|
"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=="],
|
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||||
@@ -1116,7 +1105,7 @@
|
|||||||
|
|
||||||
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||||
|
|
||||||
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||||
|
|
||||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
@@ -1132,7 +1121,7 @@
|
|||||||
|
|
||||||
"is-root": ["is-root@2.1.0", "", {}, "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg=="],
|
"is-root": ["is-root@2.1.0", "", {}, "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg=="],
|
||||||
|
|
||||||
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
"is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
|
||||||
|
|
||||||
"isbot": ["isbot@5.1.34", "", {}, "sha512-aCMIBSKd/XPRYdiCQTLC8QHH4YT8B3JUADu+7COgYIZPvkeoMcUHMRjZLM9/7V8fCj+l7FSREc1lOPNjzogo/A=="],
|
"isbot": ["isbot@5.1.34", "", {}, "sha512-aCMIBSKd/XPRYdiCQTLC8QHH4YT8B3JUADu+7COgYIZPvkeoMcUHMRjZLM9/7V8fCj+l7FSREc1lOPNjzogo/A=="],
|
||||||
|
|
||||||
@@ -1396,8 +1385,6 @@
|
|||||||
|
|
||||||
"react-day-picker": ["react-day-picker@8.10.1", "", { "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA=="],
|
"react-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-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=="],
|
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||||
@@ -1570,8 +1557,6 @@
|
|||||||
|
|
||||||
"tldts-core": ["tldts-core@7.0.22", "", {}, "sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw=="],
|
"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=="],
|
"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=="],
|
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
|
||||||
@@ -1730,8 +1715,6 @@
|
|||||||
|
|
||||||
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@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=="],
|
"@redocly/openapi-core/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
|
||||||
|
|
||||||
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
|
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
|
||||||
@@ -1794,8 +1777,6 @@
|
|||||||
|
|
||||||
"global-prefix/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="],
|
"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=="],
|
"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=="],
|
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
@@ -1816,8 +1797,6 @@
|
|||||||
|
|
||||||
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
"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/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=="],
|
"react-dev-utils/immer": ["immer@9.0.21", "", {}, "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA=="],
|
||||||
@@ -1838,8 +1817,6 @@
|
|||||||
|
|
||||||
"webpack/es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
|
"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/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
"@prisma/config/c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
"@prisma/config/c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||||
@@ -1874,6 +1851,10 @@
|
|||||||
|
|
||||||
"react-dev-utils/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
|
"react-dev-utils/open/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=="],
|
"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=="],
|
"@prisma/config/c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||||
|
|||||||
1828
generated/api.ts
269
generated/noc-external.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* This file was auto-generated by openapi-typescript.
|
||||||
|
* Do not make direct changes to the file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface paths {
|
||||||
|
"/api/noc/active-divisions": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Divisi Teraktif
|
||||||
|
* @description Menu Beranda - Mendapatkan daftar divisi teraktif berdasarkan jumlah proyek pada desa tertentu.
|
||||||
|
*/
|
||||||
|
get: operations["getApiNocActive-divisions"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/noc/latest-projects": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Latest Projects General
|
||||||
|
* @description Menu kinerja divisi - Mendapatkan daftar proyek umum terbaru dari berbagai grup pada desa tertentu.
|
||||||
|
*/
|
||||||
|
get: operations["getApiNocLatest-projects"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/noc/upcoming-events": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Events (Today & Upcoming)
|
||||||
|
* @description Menu beranda dan kinerja divisi - Mendapatkan daftar event pada hari ini dan yang akan datang untuk semua divisi pada desa tertentu.
|
||||||
|
*/
|
||||||
|
get: operations["getApiNocUpcoming-events"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/noc/diagram-jumlah-document": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Diagram Jumlah Document
|
||||||
|
* @description Menu kinerja divisi - Mendapatkan diagram jumlah document pada desa tertentu.
|
||||||
|
*/
|
||||||
|
get: operations["getApiNocDiagram-jumlah-document"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/noc/diagram-progres-kegiatan": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Diagram Progres Kegiatan
|
||||||
|
* @description Menu kinerja divisi - Mendapatkan diagram progres kegiatan pada desa tertentu.
|
||||||
|
*/
|
||||||
|
get: operations["getApiNocDiagram-progres-kegiatan"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/noc/latest-discussion": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Latest Discussion
|
||||||
|
* @description Menu kinerja divisi - Mendapatkan latest discussion pada desa tertentu.
|
||||||
|
*/
|
||||||
|
get: operations["getApiNocLatest-discussion"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type webhooks = Record<string, never>;
|
||||||
|
export interface components {
|
||||||
|
schemas: never;
|
||||||
|
responses: never;
|
||||||
|
parameters: never;
|
||||||
|
requestBodies: never;
|
||||||
|
headers: never;
|
||||||
|
pathItems: never;
|
||||||
|
}
|
||||||
|
export type $defs = Record<string, never>;
|
||||||
|
export interface operations {
|
||||||
|
"getApiNocActive-divisions": {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
/** @description ID Desa yang ingin dicari */
|
||||||
|
idDesa: string;
|
||||||
|
/** @description Jumlah maksimal data (default: 5) */
|
||||||
|
limit?: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"getApiNocLatest-projects": {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
/** @description ID Desa yang ingin dicari */
|
||||||
|
idDesa: string;
|
||||||
|
/** @description Jumlah maksimal proyek (default: 5, maks: 50) */
|
||||||
|
limit?: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"getApiNocUpcoming-events": {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
/** @description ID Desa yang ingin dicari */
|
||||||
|
idDesa: string;
|
||||||
|
/** @description Jumlah maksimal event (default: 10, maks: 50) */
|
||||||
|
limit?: string;
|
||||||
|
/** @description Filter event: 'today' atau 'upcoming' */
|
||||||
|
filter?: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"getApiNocDiagram-jumlah-document": {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
/** @description ID Desa yang ingin dicari */
|
||||||
|
idDesa: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"getApiNocDiagram-progres-kegiatan": {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
/** @description ID Desa yang ingin dicari */
|
||||||
|
idDesa: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"getApiNocLatest-discussion": {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
/** @description ID Desa yang ingin dicari */
|
||||||
|
idDesa: string;
|
||||||
|
/** @description Limit data */
|
||||||
|
limit?: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
BIN
mantine-expert.skill
Normal file
11
package.json
@@ -9,12 +9,20 @@
|
|||||||
"check": "biome check --write .",
|
"check": "biome check --write .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"gen:api": "bun scripts/generate-schema.ts && bun x openapi-typescript generated/schema.json -o generated/api.ts",
|
"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": "bun test __tests__/api",
|
||||||
"test:ui": "bun test --ui __tests__/api",
|
"test:ui": "bun test --ui __tests__/api",
|
||||||
"test:e2e": "bun run build && playwright test",
|
"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_*' && cp -r public/* dist/ 2>/dev/null || true",
|
||||||
"start": "NODE_ENV=production bun src/index.ts",
|
"start": "NODE_ENV=production bun src/index.ts",
|
||||||
"seed": "bun prisma/seed.ts"
|
"seed": "bun prisma/seed.ts",
|
||||||
|
"seed:auth": "bun prisma/seed.ts auth",
|
||||||
|
"seed:demographics": "bun prisma/seed.ts demographics",
|
||||||
|
"seed:divisions": "bun prisma/seed.ts divisions",
|
||||||
|
"seed:services": "bun prisma/seed.ts services",
|
||||||
|
"seed:documents": "bun prisma/seed.ts documents",
|
||||||
|
"seed:dashboard": "bun prisma/seed.ts dashboard",
|
||||||
|
"seed:phase2": "bun prisma/seed.ts phase2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/cli": "^1.4.18",
|
"@better-auth/cli": "^1.4.18",
|
||||||
@@ -104,7 +112,6 @@
|
|||||||
"postcss-preset-mantine": "^1.18.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"react-dev-inspector": "^2.0.1",
|
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,568 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ActivityStatus" AS ENUM ('BERJALAN', 'SELESAI', 'TERTUNDA', 'DIBATALKAN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Priority" AS ENUM ('RENDAH', 'SEDANG', 'TINGGI', 'DARURAT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DocumentCategory" AS ENUM ('SURAT_KEPUTUSAN', 'DOKUMENTASI', 'LAPORAN_KEUANGAN', 'NOTULENSI_RAPAT', 'UMUM');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "EventType" AS ENUM ('RAPAT', 'KEGIATAN', 'UPACARA', 'SOSIAL', 'BUDAYA', 'LAINNYA');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ComplaintCategory" AS ENUM ('KETERTIBAN_UMUM', 'PELAYANAN_KESEHATAN', 'INFRASTRUKTUR', 'ADMINISTRASI', 'KEAMANAN', 'LAINNYA');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ComplaintStatus" AS ENUM ('BARU', 'DIPROSES', 'SELESAI', 'DITOLAK');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "LetterType" AS ENUM ('KTP', 'KK', 'DOMISILI', 'USAHA', 'KETERANGAN_TIDAK_MAMPU', 'SURAT_PENGANTAR', 'LAINNYA');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ServiceStatus" AS ENUM ('BARU', 'DIPROSES', 'SELESAI', 'DIAMBIL');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "IdeaStatus" AS ENUM ('BARU', 'DIKAJI', 'DISETUJUI', 'DITOLAK', 'DIIMPLEMENTASI');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Gender" AS ENUM ('LAKI_LAKI', 'PEREMPUAN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Religion" AS ENUM ('HINDU', 'ISLAM', 'KRISTEN', 'KATOLIK', 'BUDDHA', 'KONGHUCU', 'LAINNYA');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "MaritalStatus" AS ENUM ('BELUM_KAWIN', 'KAWIN', 'CERAI_HIDUP', 'CERAI_MATI');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "EducationLevel" AS ENUM ('TIDAK_SEKOLAH', 'SD', 'SMP', 'SMA', 'D3', 'S1', 'S2', 'S3');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "user" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"emailVerified" BOOLEAN,
|
||||||
|
"image" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"role" TEXT DEFAULT 'user',
|
||||||
|
|
||||||
|
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "division" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"color" TEXT NOT NULL DEFAULT '#1E3A5F',
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "division_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "activity" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"divisionId" TEXT NOT NULL,
|
||||||
|
"startDate" TIMESTAMP(3),
|
||||||
|
"endDate" TIMESTAMP(3),
|
||||||
|
"dueDate" TIMESTAMP(3),
|
||||||
|
"progress" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"status" "ActivityStatus" NOT NULL DEFAULT 'BERJALAN',
|
||||||
|
"priority" "Priority" NOT NULL DEFAULT 'SEDANG',
|
||||||
|
"assignedTo" TEXT,
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "activity_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "document" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"category" "DocumentCategory" NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"fileUrl" TEXT NOT NULL,
|
||||||
|
"fileSize" INTEGER,
|
||||||
|
"divisionId" TEXT,
|
||||||
|
"uploadedBy" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "document_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "discussion" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"message" TEXT NOT NULL,
|
||||||
|
"senderId" TEXT NOT NULL,
|
||||||
|
"parentId" TEXT,
|
||||||
|
"divisionId" TEXT,
|
||||||
|
"isResolved" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "discussion_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "event" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"eventType" "EventType" NOT NULL,
|
||||||
|
"startDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"endDate" TIMESTAMP(3),
|
||||||
|
"location" TEXT,
|
||||||
|
"isAllDay" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isRecurring" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdBy" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "event_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "division_metric" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"divisionId" TEXT NOT NULL,
|
||||||
|
"period" TEXT NOT NULL,
|
||||||
|
"activityCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"completionRate" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
"avgProgress" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "division_metric_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "complaint" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"complaintNumber" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"category" "ComplaintCategory" NOT NULL,
|
||||||
|
"status" "ComplaintStatus" NOT NULL DEFAULT 'BARU',
|
||||||
|
"priority" "Priority" NOT NULL DEFAULT 'SEDANG',
|
||||||
|
"reporterId" TEXT,
|
||||||
|
"reporterPhone" TEXT,
|
||||||
|
"reporterEmail" TEXT,
|
||||||
|
"isAnonymous" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"assignedTo" TEXT,
|
||||||
|
"resolvedBy" TEXT,
|
||||||
|
"resolvedAt" TIMESTAMP(3),
|
||||||
|
"location" TEXT,
|
||||||
|
"imageUrl" TEXT[],
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "complaint_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "complaint_update" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"complaintId" TEXT NOT NULL,
|
||||||
|
"message" TEXT NOT NULL,
|
||||||
|
"status" "ComplaintStatus",
|
||||||
|
"updatedBy" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "complaint_update_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "service_letter" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"letterNumber" TEXT NOT NULL,
|
||||||
|
"letterType" "LetterType" NOT NULL,
|
||||||
|
"applicantName" TEXT NOT NULL,
|
||||||
|
"applicantNik" TEXT NOT NULL,
|
||||||
|
"applicantAddress" TEXT NOT NULL,
|
||||||
|
"purpose" TEXT,
|
||||||
|
"status" "ServiceStatus" NOT NULL DEFAULT 'BARU',
|
||||||
|
"processedBy" TEXT,
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "service_letter_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "innovation_idea" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"submitterName" TEXT NOT NULL,
|
||||||
|
"submitterContact" TEXT,
|
||||||
|
"status" "IdeaStatus" NOT NULL DEFAULT 'BARU',
|
||||||
|
"reviewedBy" TEXT,
|
||||||
|
"reviewedAt" TIMESTAMP(3),
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "innovation_idea_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "resident" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"nik" TEXT NOT NULL,
|
||||||
|
"kk" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"birthDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"birthPlace" TEXT NOT NULL,
|
||||||
|
"gender" "Gender" NOT NULL,
|
||||||
|
"religion" "Religion" NOT NULL,
|
||||||
|
"maritalStatus" "MaritalStatus" NOT NULL DEFAULT 'BELUM_KAWIN',
|
||||||
|
"education" "EducationLevel",
|
||||||
|
"occupation" TEXT,
|
||||||
|
"banjarId" TEXT NOT NULL,
|
||||||
|
"rt" TEXT NOT NULL,
|
||||||
|
"rw" TEXT NOT NULL,
|
||||||
|
"address" TEXT NOT NULL,
|
||||||
|
"isHeadOfHousehold" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isPoor" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isStunting" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"deathDate" TIMESTAMP(3),
|
||||||
|
"moveInDate" TIMESTAMP(3),
|
||||||
|
"moveOutDate" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "resident_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "banjar" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"totalPopulation" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"totalKK" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"totalPoor" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "banjar_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "HealthRecord" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"residentId" TEXT NOT NULL,
|
||||||
|
"recordedBy" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "HealthRecord_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "EmploymentRecord" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"residentId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "EmploymentRecord_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PopulationDynamic" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"documentedBy" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PopulationDynamic_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Budget" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"approvedBy" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Budget_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BudgetTransaction" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdBy" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "BudgetTransaction_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Umkm" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"banjarId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Umkm_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Posyandu" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"coordinatorId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Posyandu_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SecurityReport" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"assignedTo" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "SecurityReport_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "session" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"ipAddress" TEXT,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "account" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"providerId" TEXT NOT NULL,
|
||||||
|
"accessToken" TEXT,
|
||||||
|
"refreshToken" TEXT,
|
||||||
|
"expiresAt" TIMESTAMP(3),
|
||||||
|
"password" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"idToken" TEXT,
|
||||||
|
"accessTokenExpiresAt" TIMESTAMP(3),
|
||||||
|
"refreshTokenExpiresAt" TIMESTAMP(3),
|
||||||
|
"scope" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "verification" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"identifier" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "api_key" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"expiresAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "api_key_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "division_name_key" ON "division"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "activity_divisionId_idx" ON "activity"("divisionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "activity_status_idx" ON "activity"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "document_category_idx" ON "document"("category");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "document_divisionId_idx" ON "document"("divisionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "discussion_divisionId_idx" ON "discussion"("divisionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "discussion_createdAt_idx" ON "discussion"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "event_startDate_idx" ON "event"("startDate");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "event_eventType_idx" ON "event"("eventType");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "division_metric_divisionId_period_key" ON "division_metric"("divisionId", "period");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "complaint_complaintNumber_key" ON "complaint"("complaintNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "complaint_status_idx" ON "complaint"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "complaint_category_idx" ON "complaint"("category");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "complaint_createdAt_idx" ON "complaint"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "complaint_update_complaintId_idx" ON "complaint_update"("complaintId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "service_letter_letterNumber_key" ON "service_letter"("letterNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "service_letter_letterType_idx" ON "service_letter"("letterType");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "service_letter_status_idx" ON "service_letter"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "service_letter_createdAt_idx" ON "service_letter"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "innovation_idea_category_idx" ON "innovation_idea"("category");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "innovation_idea_status_idx" ON "innovation_idea"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "resident_nik_key" ON "resident"("nik");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "resident_banjarId_idx" ON "resident"("banjarId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "resident_religion_idx" ON "resident"("religion");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "resident_occupation_idx" ON "resident"("occupation");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "banjar_name_key" ON "banjar"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "banjar_code_key" ON "banjar"("code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "session_userId_idx" ON "session"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "account_userId_idx" ON "account"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "api_key_key_key" ON "api_key"("key");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "api_key_userId_idx" ON "api_key"("userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "activity" ADD CONSTRAINT "activity_divisionId_fkey" FOREIGN KEY ("divisionId") REFERENCES "division"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "document" ADD CONSTRAINT "document_divisionId_fkey" FOREIGN KEY ("divisionId") REFERENCES "division"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "discussion" ADD CONSTRAINT "discussion_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "discussion" ADD CONSTRAINT "discussion_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "discussion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "discussion" ADD CONSTRAINT "discussion_divisionId_fkey" FOREIGN KEY ("divisionId") REFERENCES "division"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "event" ADD CONSTRAINT "event_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "division_metric" ADD CONSTRAINT "division_metric_divisionId_fkey" FOREIGN KEY ("divisionId") REFERENCES "division"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "complaint" ADD CONSTRAINT "complaint_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "complaint" ADD CONSTRAINT "complaint_assignedTo_fkey" FOREIGN KEY ("assignedTo") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "complaint_update" ADD CONSTRAINT "complaint_update_complaintId_fkey" FOREIGN KEY ("complaintId") REFERENCES "complaint"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "complaint_update" ADD CONSTRAINT "complaint_update_updatedBy_fkey" FOREIGN KEY ("updatedBy") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "service_letter" ADD CONSTRAINT "service_letter_processedBy_fkey" FOREIGN KEY ("processedBy") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "innovation_idea" ADD CONSTRAINT "innovation_idea_reviewedBy_fkey" FOREIGN KEY ("reviewedBy") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "resident" ADD CONSTRAINT "resident_banjarId_fkey" FOREIGN KEY ("banjarId") REFERENCES "banjar"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "HealthRecord" ADD CONSTRAINT "HealthRecord_residentId_fkey" FOREIGN KEY ("residentId") REFERENCES "resident"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "HealthRecord" ADD CONSTRAINT "HealthRecord_recordedBy_fkey" FOREIGN KEY ("recordedBy") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "EmploymentRecord" ADD CONSTRAINT "EmploymentRecord_residentId_fkey" FOREIGN KEY ("residentId") REFERENCES "resident"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PopulationDynamic" ADD CONSTRAINT "PopulationDynamic_documentedBy_fkey" FOREIGN KEY ("documentedBy") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Budget" ADD CONSTRAINT "Budget_approvedBy_fkey" FOREIGN KEY ("approvedBy") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BudgetTransaction" ADD CONSTRAINT "BudgetTransaction_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Umkm" ADD CONSTRAINT "Umkm_banjarId_fkey" FOREIGN KEY ("banjarId") REFERENCES "banjar"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Posyandu" ADD CONSTRAINT "Posyandu_coordinatorId_fkey" FOREIGN KEY ("coordinatorId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SecurityReport" ADD CONSTRAINT "SecurityReport_assignedTo_fkey" FOREIGN KEY ("assignedTo") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "api_key" ADD CONSTRAINT "api_key_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the `Budget` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Budget" DROP CONSTRAINT "Budget_approvedBy_fkey";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "Budget";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "budget" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"amount" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
"percentage" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
"color" TEXT NOT NULL DEFAULT '#3B82F6',
|
||||||
|
"fiscalYear" INTEGER NOT NULL DEFAULT 2025,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "budget_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sdgs_score" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"score" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
"image" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "sdgs_score_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "satisfaction_rating" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"value" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"color" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "satisfaction_rating_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "budget_category_fiscalYear_key" ON "budget"("category", "fiscalYear");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sdgs_score_title_key" ON "sdgs_score"("title");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "satisfaction_rating_category_key" ON "satisfaction_rating"("category");
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `name` to the `Umkm` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `owner` to the `Umkm` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `updatedAt` to the `Umkm` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Umkm" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN "description" TEXT,
|
||||||
|
ADD COLUMN "name" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "owner" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "productType" TEXT,
|
||||||
|
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[reportNumber]` on the table `SecurityReport` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Added the required column `location` to the `Posyandu` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `name` to the `Posyandu` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `schedule` to the `Posyandu` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `type` to the `Posyandu` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `updatedAt` to the `Posyandu` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `description` to the `SecurityReport` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `reportNumber` to the `SecurityReport` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `reportedBy` to the `SecurityReport` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `title` to the `SecurityReport` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `updatedAt` to the `SecurityReport` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Posyandu" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN "location" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "name" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "schedule" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "type" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "SecurityReport" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN "description" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "location" TEXT,
|
||||||
|
ADD COLUMN "reportNumber" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "reportedBy" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "status" TEXT NOT NULL DEFAULT 'BARU',
|
||||||
|
ADD COLUMN "title" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "SecurityReport_reportNumber_key" ON "SecurityReport"("reportNumber");
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[transactionNumber]` on the table `BudgetTransaction` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Added the required column `amount` to the `BudgetTransaction` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `category` to the `BudgetTransaction` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `date` to the `BudgetTransaction` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `transactionNumber` to the `BudgetTransaction` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `type` to the `BudgetTransaction` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `companyName` to the `EmploymentRecord` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `position` to the `EmploymentRecord` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `startDate` to the `EmploymentRecord` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `type` to the `HealthRecord` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `eventDate` to the `PopulationDynamic` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `residentName` to the `PopulationDynamic` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `type` to the `PopulationDynamic` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "BudgetTransaction" ADD COLUMN "amount" DOUBLE PRECISION NOT NULL,
|
||||||
|
ADD COLUMN "category" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN "date" TIMESTAMP(3) NOT NULL,
|
||||||
|
ADD COLUMN "description" TEXT,
|
||||||
|
ADD COLUMN "transactionNumber" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "type" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "EmploymentRecord" ADD COLUMN "companyName" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN "endDate" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN "position" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "startDate" TIMESTAMP(3) NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "HealthRecord" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN "notes" TEXT,
|
||||||
|
ADD COLUMN "type" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PopulationDynamic" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN "description" TEXT,
|
||||||
|
ADD COLUMN "eventDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
ADD COLUMN "residentName" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "type" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "BudgetTransaction_transactionNumber_key" ON "BudgetTransaction"("transactionNumber");
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[externalId]` on the table `activity` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[externalId]` on the table `discussion` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[externalId]` on the table `division` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[externalId]` on the table `document` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[externalId]` on the table `event` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "activity" ADD COLUMN "externalId" TEXT,
|
||||||
|
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "discussion" ADD COLUMN "externalId" TEXT,
|
||||||
|
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "division" ADD COLUMN "externalId" TEXT,
|
||||||
|
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "document" ADD COLUMN "externalId" TEXT,
|
||||||
|
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "event" ADD COLUMN "externalId" TEXT,
|
||||||
|
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "activity_externalId_key" ON "activity"("externalId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "discussion_externalId_key" ON "discussion"("externalId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "division_externalId_key" ON "division"("externalId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "document_externalId_key" ON "document"("externalId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "event_externalId_key" ON "event"("externalId");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "division" ADD COLUMN "lastSyncedAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "activity" ALTER COLUMN "villageId" SET DEFAULT 'desa1';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "discussion" ALTER COLUMN "villageId" SET DEFAULT 'desa1';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "division" ADD COLUMN "externalActivityCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ALTER COLUMN "villageId" SET DEFAULT 'desa1';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "document" ALTER COLUMN "villageId" SET DEFAULT 'desa1';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "event" ALTER COLUMN "villageId" SET DEFAULT 'desa1';
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- 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");
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -21,9 +21,558 @@ model User {
|
|||||||
sessions Session[]
|
sessions Session[]
|
||||||
apiKeys ApiKey[]
|
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")
|
@@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 {
|
model Session {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
371
prisma/seed.ts
@@ -1,150 +1,251 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { hash } from "bcryptjs";
|
import { PrismaClient } from "../generated/prisma";
|
||||||
import { generateId } from "better-auth";
|
|
||||||
import { prisma } from "@/utils/db";
|
|
||||||
|
|
||||||
async function seedAdminUser() {
|
// Import all seeders
|
||||||
// Load environment variables
|
import { seedAdminUser, seedApiKeys, seedDemoUsers } from "./seeders/seed-auth";
|
||||||
const adminEmail = process.env.ADMIN_EMAIL;
|
import { seedDashboardMetrics } from "./seeders/seed-dashboard-metrics";
|
||||||
const adminPassword = process.env.ADMIN_PASSWORD || "admin123";
|
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";
|
||||||
|
|
||||||
if (!adminEmail) {
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if seed has already been run
|
||||||
|
* Returns true if core data already exists
|
||||||
|
*/
|
||||||
|
export async function hasExistingData(): Promise<boolean> {
|
||||||
|
// Check for core entities that should always exist after seeding
|
||||||
|
const [userCount, banjarCount, divisionCount] = await Promise.all([
|
||||||
|
prisma.user.count(),
|
||||||
|
prisma.banjar.count(),
|
||||||
|
prisma.division.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// If we have more than 1 user (admin), 6 banjars, and 4 divisions, assume seeded
|
||||||
|
return userCount > 1 && banjarCount >= 6 && divisionCount >= 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run All Seeders
|
||||||
|
* Executes all seeder functions in the correct order
|
||||||
|
*/
|
||||||
|
export async function runSeed() {
|
||||||
|
console.log("🌱 Starting seed...\n");
|
||||||
|
|
||||||
|
// Check if data already exists
|
||||||
|
const existingData = await hasExistingData();
|
||||||
|
if (existingData) {
|
||||||
console.log(
|
console.log(
|
||||||
"No ADMIN_EMAIL environment variable found. Skipping admin user creation.",
|
"⏭️ Existing data detected. Skipping seed to prevent duplicates.\n",
|
||||||
);
|
);
|
||||||
|
console.log("💡 To re-seed, either:");
|
||||||
|
console.log(" 1. Run: bun x prisma migrate reset (resets database)");
|
||||||
|
console.log(" 2. Manually delete data from tables\n");
|
||||||
|
console.log("✅ Seed skipped successfully!\n");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// 1. Seed Authentication (Admin & Demo Users)
|
||||||
// Check if admin user already exists
|
console.log("📁 [1/7] Authentication & Users");
|
||||||
const existingUser = await prisma.user.findUnique({
|
const adminId = await seedAdminUser();
|
||||||
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 seedDemoUsers();
|
||||||
|
await seedApiKeys(adminId);
|
||||||
|
console.log();
|
||||||
|
|
||||||
console.log("Database seeding completed.");
|
// 2. Seed Demographics (Banjars & Residents)
|
||||||
|
console.log("📁 [2/7] Demographics & Population");
|
||||||
|
await seedBanjars();
|
||||||
|
const banjarIds = await getBanjarIds();
|
||||||
|
await seedResidents(banjarIds);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
// 3. Seed Division Performance (Divisions & Activities)
|
||||||
|
console.log("📁 [3/7] Division Performance");
|
||||||
|
const divisions = await seedDivisions();
|
||||||
|
const divisionIds = divisions.map((d) => d.id);
|
||||||
|
await seedActivities(divisionIds);
|
||||||
|
await seedDivisionMetrics(divisionIds);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
// 4. Seed Public Services (Complaints, Service Letters, Events, Innovation)
|
||||||
|
console.log("📁 [4/7] Public Services");
|
||||||
|
await seedComplaints(adminId);
|
||||||
|
await seedServiceLetters(adminId);
|
||||||
|
await seedEvents(adminId);
|
||||||
|
await seedInnovationIdeas(adminId);
|
||||||
|
const complaintIds = await getComplaintIds();
|
||||||
|
await seedComplaintUpdates(complaintIds, adminId);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
// 5. Seed Documents & Discussions
|
||||||
|
console.log("📁 [5/7] Documents & Discussions");
|
||||||
|
await seedDocuments(divisionIds, adminId);
|
||||||
|
await 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");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only auto-execute when run directly (not when imported)
|
/**
|
||||||
const isMainModule =
|
* Run Specific Seeder
|
||||||
typeof require !== "undefined"
|
* Allows running individual seeders by name
|
||||||
? require.main === module
|
*/
|
||||||
: import.meta.path.endsWith("seed.ts");
|
export async function runSpecificSeeder(name: string) {
|
||||||
|
console.log(`🌱 Running specific seeder: ${name}\n`);
|
||||||
|
|
||||||
if (isMainModule) {
|
// Check if data already exists for specific seeder
|
||||||
main().catch((error) => {
|
const existingData = await hasExistingData();
|
||||||
console.error("Error during seeding:", error);
|
if (existingData && name !== "auth") {
|
||||||
process.exit(1);
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export for programmatic use
|
// Main execution
|
||||||
export { seedAdminUser, seedDemoUsers, main as runSeed };
|
if (import.meta.main) {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const seederName = args[0];
|
||||||
|
|
||||||
|
if (seederName) {
|
||||||
|
// Run specific seeder
|
||||||
|
runSpecificSeeder(seederName)
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("❌ Seeder error:", e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Run all seeders
|
||||||
|
runSeed()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("❌ Seed error:", e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
161
prisma/seeders/seed-auth.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { hash } from "bcryptjs";
|
||||||
|
import { generateId } from "better-auth";
|
||||||
|
import { PrismaClient } from "../../generated/prisma";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Admin User
|
||||||
|
* Creates or updates the admin user account
|
||||||
|
*/
|
||||||
|
export async function seedAdminUser() {
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL || "admin@example.com";
|
||||||
|
const adminPassword = process.env.ADMIN_PASSWORD || "admin123";
|
||||||
|
|
||||||
|
console.log(`Checking admin user: ${adminEmail}`);
|
||||||
|
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email: adminEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
if (existingUser.role !== "admin") {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { email: adminEmail },
|
||||||
|
data: { role: "admin" },
|
||||||
|
});
|
||||||
|
console.log("Updated existing user to admin role.");
|
||||||
|
}
|
||||||
|
return existingUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await hash(adminPassword, 12);
|
||||||
|
const userId = generateId();
|
||||||
|
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: userId,
|
||||||
|
email: adminEmail,
|
||||||
|
name: "Admin Desa Darmasaba",
|
||||||
|
role: "admin",
|
||||||
|
emailVerified: true,
|
||||||
|
accounts: {
|
||||||
|
create: {
|
||||||
|
id: generateId(),
|
||||||
|
accountId: userId,
|
||||||
|
providerId: "credential",
|
||||||
|
password: hashedPassword,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Admin user created: ${adminEmail}`);
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Demo Users
|
||||||
|
* Creates demo users for testing (user, moderator roles)
|
||||||
|
*/
|
||||||
|
export async function seedDemoUsers() {
|
||||||
|
const demoUsers = [
|
||||||
|
{
|
||||||
|
email: "demo1@example.com",
|
||||||
|
name: "Demo User 1",
|
||||||
|
password: "demo123",
|
||||||
|
role: "user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "demo2@example.com",
|
||||||
|
name: "Demo User 2",
|
||||||
|
password: "demo123",
|
||||||
|
role: "user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "moderator@example.com",
|
||||||
|
name: "Moderator Desa",
|
||||||
|
password: "demo123",
|
||||||
|
role: "moderator",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log("Seeding Demo Users...");
|
||||||
|
|
||||||
|
for (const demo of demoUsers) {
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email: demo.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
console.log(`⏭️ Demo user exists: ${demo.email}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await hash(demo.password, 12);
|
||||||
|
const userId = generateId();
|
||||||
|
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: userId,
|
||||||
|
email: demo.email,
|
||||||
|
name: demo.name,
|
||||||
|
role: demo.role,
|
||||||
|
emailVerified: true,
|
||||||
|
accounts: {
|
||||||
|
create: {
|
||||||
|
id: generateId(),
|
||||||
|
accountId: userId,
|
||||||
|
providerId: "credential",
|
||||||
|
password: hashedPassword,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Demo user created: ${demo.email}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed API Keys
|
||||||
|
* Creates sample API keys for testing API access
|
||||||
|
*/
|
||||||
|
export async function seedApiKeys(adminId: string) {
|
||||||
|
console.log("Seeding API Keys...");
|
||||||
|
|
||||||
|
const existingKeys = await prisma.apiKey.findMany({
|
||||||
|
where: { userId: adminId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingKeys.length > 0) {
|
||||||
|
console.log("⏭️ API keys already exist, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeys = [
|
||||||
|
{
|
||||||
|
name: "Development Key",
|
||||||
|
key: "dev_key_" + generateId(),
|
||||||
|
userId: adminId,
|
||||||
|
isActive: true,
|
||||||
|
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Production Key",
|
||||||
|
key: "prod_key_" + generateId(),
|
||||||
|
userId: adminId,
|
||||||
|
isActive: true,
|
||||||
|
expiresAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const apiKey of apiKeys) {
|
||||||
|
await prisma.apiKey.create({
|
||||||
|
data: apiKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ API Keys seeded successfully");
|
||||||
|
}
|
||||||
109
prisma/seeders/seed-dashboard-metrics.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { PrismaClient } from "../../generated/prisma";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Budget (APBDes)
|
||||||
|
* Creates village budget allocation data
|
||||||
|
*/
|
||||||
|
export async function seedBudget() {
|
||||||
|
console.log("Seeding Budget...");
|
||||||
|
|
||||||
|
const budgets = [
|
||||||
|
{ category: "Belanja", amount: 70, percentage: 70, color: "#3B82F6" },
|
||||||
|
{ category: "Pangan", amount: 45, percentage: 45, color: "#22C55E" },
|
||||||
|
{ category: "Pembiayaan", amount: 55, percentage: 55, color: "#FACC15" },
|
||||||
|
{ category: "Pendapatan", amount: 90, percentage: 90, color: "#3B82F6" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const budget of budgets) {
|
||||||
|
await prisma.budget.upsert({
|
||||||
|
where: {
|
||||||
|
category_fiscalYear: {
|
||||||
|
category: budget.category,
|
||||||
|
fiscalYear: 2025,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: budget,
|
||||||
|
create: { ...budget, fiscalYear: 2025 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Budget seeded successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed SDGs Scores
|
||||||
|
* Creates Sustainable Development Goals scores for dashboard
|
||||||
|
*/
|
||||||
|
export async function seedSdgsScores() {
|
||||||
|
console.log("Seeding SDGs Scores...");
|
||||||
|
|
||||||
|
const sdgs = [
|
||||||
|
{
|
||||||
|
title: "Desa Berenergi Bersih dan Terbarukan",
|
||||||
|
score: 99.64,
|
||||||
|
image: "SDGS-7.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Desa Damai Berkeadilan",
|
||||||
|
score: 78.65,
|
||||||
|
image: "SDGS-16.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Desa Sehat dan Sejahtera",
|
||||||
|
score: 77.37,
|
||||||
|
image: "SDGS-3.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Desa Tanpa Kemiskinan",
|
||||||
|
score: 52.62,
|
||||||
|
image: "SDGS-1.png",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sdg of sdgs) {
|
||||||
|
await prisma.sdgsScore.upsert({
|
||||||
|
where: { title: sdg.title },
|
||||||
|
update: sdg,
|
||||||
|
create: sdg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ SDGs Scores seeded successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Satisfaction Ratings
|
||||||
|
* Creates public satisfaction survey data
|
||||||
|
*/
|
||||||
|
export async function seedSatisfactionRatings() {
|
||||||
|
console.log("Seeding Satisfaction Ratings...");
|
||||||
|
|
||||||
|
const satisfactions = [
|
||||||
|
{ category: "Sangat Puas", value: 25, color: "#4E5BA6" },
|
||||||
|
{ category: "Puas", value: 25, color: "#F4C542" },
|
||||||
|
{ category: "Cukup", value: 25, color: "#8CC63F" },
|
||||||
|
{ category: "Kurang", value: 25, color: "#E57373" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sat of satisfactions) {
|
||||||
|
await prisma.satisfactionRating.upsert({
|
||||||
|
where: { category: sat.category },
|
||||||
|
update: sat,
|
||||||
|
create: sat,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Satisfaction Ratings seeded successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed All Dashboard Metrics
|
||||||
|
* Main function to run all dashboard metrics seeders
|
||||||
|
*/
|
||||||
|
export async function seedDashboardMetrics() {
|
||||||
|
await seedBudget();
|
||||||
|
await seedSdgsScores();
|
||||||
|
await seedSatisfactionRatings();
|
||||||
|
}
|
||||||
124
prisma/seeders/seed-demographics.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { Gender, PrismaClient, Religion } from "../../generated/prisma";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Banjars (Village Hamlets)
|
||||||
|
* Creates 6 banjars in Darmasaba village
|
||||||
|
*/
|
||||||
|
export async function seedBanjars() {
|
||||||
|
const banjars = [
|
||||||
|
{
|
||||||
|
name: "Darmasaba",
|
||||||
|
code: "DSB",
|
||||||
|
totalPopulation: 1200,
|
||||||
|
totalKK: 300,
|
||||||
|
totalPoor: 45,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Manesa",
|
||||||
|
code: "MNS",
|
||||||
|
totalPopulation: 950,
|
||||||
|
totalKK: 240,
|
||||||
|
totalPoor: 32,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cabe",
|
||||||
|
code: "CBE",
|
||||||
|
totalPopulation: 800,
|
||||||
|
totalKK: 200,
|
||||||
|
totalPoor: 28,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Penenjoan",
|
||||||
|
code: "PNJ",
|
||||||
|
totalPopulation: 1100,
|
||||||
|
totalKK: 280,
|
||||||
|
totalPoor: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Baler Pasar",
|
||||||
|
code: "BPS",
|
||||||
|
totalPopulation: 850,
|
||||||
|
totalKK: 210,
|
||||||
|
totalPoor: 35,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bucu",
|
||||||
|
code: "BCU",
|
||||||
|
totalPopulation: 734,
|
||||||
|
totalKK: 184,
|
||||||
|
totalPoor: 24,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log("Seeding Banjars...");
|
||||||
|
for (const banjar of banjars) {
|
||||||
|
await prisma.banjar.upsert({
|
||||||
|
where: { name: banjar.name },
|
||||||
|
update: banjar,
|
||||||
|
create: banjar,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log("✅ Banjars seeded successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all Banjar IDs
|
||||||
|
* Helper function to retrieve banjar IDs for other seeders
|
||||||
|
*/
|
||||||
|
export async function getBanjarIds(): Promise<string[]> {
|
||||||
|
const banjars = await prisma.banjar.findMany();
|
||||||
|
return banjars.map((b) => b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Residents
|
||||||
|
* Creates sample resident data for demographics
|
||||||
|
*/
|
||||||
|
export async function seedResidents(banjarIds: string[]) {
|
||||||
|
console.log("Seeding Residents...");
|
||||||
|
|
||||||
|
const residents = [
|
||||||
|
{
|
||||||
|
nik: "5103010101700001",
|
||||||
|
kk: "5103010101700000",
|
||||||
|
name: "I Wayan Sudarsana",
|
||||||
|
birthDate: new Date("1970-05-15"),
|
||||||
|
birthPlace: "Badung",
|
||||||
|
gender: Gender.LAKI_LAKI,
|
||||||
|
religion: Religion.HINDU,
|
||||||
|
occupation: "Wiraswasta",
|
||||||
|
banjarId: banjarIds[0] || "",
|
||||||
|
rt: "001",
|
||||||
|
rw: "000",
|
||||||
|
address: "Jl. Raya Darmasaba No. 1",
|
||||||
|
isHeadOfHousehold: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nik: "5103010101850002",
|
||||||
|
kk: "5103010101850000",
|
||||||
|
name: "Ni Made Arianti",
|
||||||
|
birthDate: new Date("1985-08-20"),
|
||||||
|
birthPlace: "Denpasar",
|
||||||
|
gender: Gender.PEREMPUAN,
|
||||||
|
religion: Religion.HINDU,
|
||||||
|
occupation: "Guru",
|
||||||
|
banjarId: banjarIds[1] || banjarIds[0] || "",
|
||||||
|
rt: "002",
|
||||||
|
rw: "000",
|
||||||
|
address: "Gg. Manesa No. 5",
|
||||||
|
isPoor: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const res of residents) {
|
||||||
|
await prisma.resident.upsert({
|
||||||
|
where: { nik: res.nik },
|
||||||
|
update: res,
|
||||||
|
create: res,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Residents seeded successfully");
|
||||||
|
}
|
||||||
248
prisma/seeders/seed-discussions.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
97
prisma/seeders/seed-division-performance.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { ActivityStatus, Priority, PrismaClient } from "../../generated/prisma";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Divisions
|
||||||
|
* Creates 4 main village divisions/departments
|
||||||
|
*/
|
||||||
|
export async function seedDivisions() {
|
||||||
|
const divisions = [
|
||||||
|
{
|
||||||
|
name: "Pemerintahan",
|
||||||
|
description: "Urusan administrasi dan tata kelola desa",
|
||||||
|
color: "#1E3A5F",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pembangunan",
|
||||||
|
description: "Infrastruktur dan sarana prasarana desa",
|
||||||
|
color: "#2E7D32",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pemberdayaan",
|
||||||
|
description: "Pemberdayaan ekonomi dan masyarakat",
|
||||||
|
color: "#EF6C00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Kesejahteraan",
|
||||||
|
description: "Kesehatan, pendidikan, dan sosial",
|
||||||
|
color: "#C62828",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log("Seeding Divisions...");
|
||||||
|
const createdDivisions = [];
|
||||||
|
for (const div of divisions) {
|
||||||
|
const d = await prisma.division.upsert({
|
||||||
|
where: { name: div.name },
|
||||||
|
update: div,
|
||||||
|
create: div,
|
||||||
|
});
|
||||||
|
createdDivisions.push(d);
|
||||||
|
}
|
||||||
|
console.log("✅ Divisions seeded successfully");
|
||||||
|
return createdDivisions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all Division IDs
|
||||||
|
* Helper function to retrieve division IDs for other seeders
|
||||||
|
*/
|
||||||
|
export async function getDivisionIds(): Promise<string[]> {
|
||||||
|
const divisions = await prisma.division.findMany();
|
||||||
|
return divisions.map((d) => d.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Activities
|
||||||
|
* Creates sample activities for each division
|
||||||
|
*/
|
||||||
|
export async function seedActivities(divisionIds: string[]) {
|
||||||
|
console.log("Seeding Activities...");
|
||||||
|
|
||||||
|
const activities = [
|
||||||
|
{
|
||||||
|
title: "Rapat Koordinasi 2025",
|
||||||
|
description: "Penyusunan rencana kerja tahunan",
|
||||||
|
divisionId: divisionIds[0] || "",
|
||||||
|
progress: 100,
|
||||||
|
status: ActivityStatus.SELESAI,
|
||||||
|
priority: Priority.TINGGI,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Pemutakhiran Indeks Desa",
|
||||||
|
description: "Pendataan SDG's Desa 2025",
|
||||||
|
divisionId: divisionIds[0] || "",
|
||||||
|
progress: 65,
|
||||||
|
status: ActivityStatus.BERJALAN,
|
||||||
|
priority: Priority.SEDANG,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Pembangunan Jalan Banjar Cabe",
|
||||||
|
description: "Pengaspalan jalan utama",
|
||||||
|
divisionId: divisionIds[1] || divisionIds[0] || "",
|
||||||
|
progress: 40,
|
||||||
|
status: ActivityStatus.BERJALAN,
|
||||||
|
priority: Priority.DARURAT,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const act of activities) {
|
||||||
|
await prisma.activity.create({
|
||||||
|
data: act,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Activities seeded successfully");
|
||||||
|
}
|
||||||
254
prisma/seeders/seed-phase2.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { PrismaClient } from "../../generated/prisma";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed UMKM (Usaha Mikro, Kecil, dan Menengah)
|
||||||
|
* Creates sample local businesses for each banjar
|
||||||
|
*/
|
||||||
|
export async function seedUmkm(banjarIds: string[]) {
|
||||||
|
console.log("Seeding UMKM...");
|
||||||
|
|
||||||
|
const umkms = [
|
||||||
|
{
|
||||||
|
banjarId: banjarIds[0] || null,
|
||||||
|
name: "Kerajinan Anyaman Darmasaba",
|
||||||
|
owner: "Ni Wayan Rajin",
|
||||||
|
productType: "Kerajinan Tangan",
|
||||||
|
description: "Produksi anyasan bambu dan rotan",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
banjarId: banjarIds[1] || null,
|
||||||
|
name: "Warung Makan Manesa",
|
||||||
|
owner: "Made Sari",
|
||||||
|
productType: "Kuliner",
|
||||||
|
description: "Makanan tradisional Bali",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
banjarId: banjarIds[2] || null,
|
||||||
|
name: "Bengkel Cabe Motor",
|
||||||
|
owner: "Ketut Arsana",
|
||||||
|
productType: "Jasa",
|
||||||
|
description: "Servis motor dan jual sparepart",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
banjarId: banjarIds[3] || null,
|
||||||
|
name: "Produksi Keripik Pisang Penenjoan",
|
||||||
|
owner: "Putu Suartika",
|
||||||
|
productType: "Makanan Ringan",
|
||||||
|
description: "Keripik pisang dengan berbagai varian rasa",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const umkm of umkms) {
|
||||||
|
await prisma.umkm.create({
|
||||||
|
data: umkm,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ UMKM seeded successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Posyandu (Community Health Post)
|
||||||
|
* Creates health service schedules and programs
|
||||||
|
*/
|
||||||
|
export async function seedPosyandu(userId: string) {
|
||||||
|
console.log("Seeding Posyandu...");
|
||||||
|
|
||||||
|
const posyandus = [
|
||||||
|
{
|
||||||
|
name: "Posyandu Mawar",
|
||||||
|
location: "Banjar Darmasaba",
|
||||||
|
schedule: "Setiap tanggal 15",
|
||||||
|
type: "Ibu dan Anak",
|
||||||
|
coordinatorId: userId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Posyandu Melati",
|
||||||
|
location: "Banjar Manesa",
|
||||||
|
schedule: "Setiap tanggal 20",
|
||||||
|
type: "Ibu dan Anak",
|
||||||
|
coordinatorId: userId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Posyandu Lansia Sejahtera",
|
||||||
|
location: "Balai Desa",
|
||||||
|
schedule: "Setiap tanggal 25",
|
||||||
|
type: "Lansia",
|
||||||
|
coordinatorId: userId,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const posyandu of posyandus) {
|
||||||
|
await prisma.posyandu.create({
|
||||||
|
data: posyandu,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Posyandu seeded successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Security Reports
|
||||||
|
* Creates sample security incident reports
|
||||||
|
*/
|
||||||
|
export async function seedSecurityReports(userId: string) {
|
||||||
|
console.log("Seeding Security Reports...");
|
||||||
|
|
||||||
|
const securityReports = [
|
||||||
|
{
|
||||||
|
reportNumber: "SEC-2025-001",
|
||||||
|
title: "Pencurian Kendaraan",
|
||||||
|
description: "Laporan kehilangan motor di area pasar",
|
||||||
|
location: "Pasar Darmasaba",
|
||||||
|
reportedBy: "I Wayan Aman",
|
||||||
|
status: "DIPROSES",
|
||||||
|
assignedTo: userId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reportNumber: "SEC-2025-002",
|
||||||
|
title: "Gangguan Ketertiban",
|
||||||
|
description: "Keributan di jalan utama pada malam hari",
|
||||||
|
location: "Jl. Raya Darmasaba",
|
||||||
|
reportedBy: "Made Tertib",
|
||||||
|
status: "SELESAI",
|
||||||
|
assignedTo: userId,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const report of securityReports) {
|
||||||
|
await prisma.securityReport.upsert({
|
||||||
|
where: { reportNumber: report.reportNumber },
|
||||||
|
update: report,
|
||||||
|
create: report,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Security Reports seeded successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Employment Records
|
||||||
|
* Creates employment history for residents
|
||||||
|
*/
|
||||||
|
export async function seedEmploymentRecords() {
|
||||||
|
console.log("Seeding Employment Records...");
|
||||||
|
|
||||||
|
// Get residents first
|
||||||
|
const residents = await prisma.resident.findMany({
|
||||||
|
take: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (residents.length === 0) {
|
||||||
|
console.log("⏭️ No residents found, skipping employment records");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const employmentRecords = residents.map((resident) => ({
|
||||||
|
residentId: resident.id,
|
||||||
|
companyName: `PT. Desa Makmur ${resident.name.split(" ")[0]}`,
|
||||||
|
position: "Staff",
|
||||||
|
startDate: new Date("2020-01-01"),
|
||||||
|
endDate: null,
|
||||||
|
isActive: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const record of employmentRecords) {
|
||||||
|
await prisma.employmentRecord.create({
|
||||||
|
data: record,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Employment Records seeded successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Population Dynamics
|
||||||
|
* Creates population change records (births, deaths, migration)
|
||||||
|
*/
|
||||||
|
export async function seedPopulationDynamics(userId: string) {
|
||||||
|
console.log("Seeding Population Dynamics...");
|
||||||
|
|
||||||
|
const populationDynamics = [
|
||||||
|
{
|
||||||
|
type: "KELAHIRAN",
|
||||||
|
residentName: "Anak Baru Darmasaba",
|
||||||
|
eventDate: new Date("2025-01-15"),
|
||||||
|
description: "Kelahiran bayi laki-laki",
|
||||||
|
documentedBy: userId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "KEMATIAN",
|
||||||
|
residentName: "Almarhum Warga Desa",
|
||||||
|
eventDate: new Date("2025-02-20"),
|
||||||
|
description: "Meninggal dunia karena sakit",
|
||||||
|
documentedBy: userId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "KEDATANGAN",
|
||||||
|
residentName: "Pendatang Baru",
|
||||||
|
eventDate: new Date("2025-03-01"),
|
||||||
|
description: "Pindah masuk dari desa lain",
|
||||||
|
documentedBy: userId,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const dynamic of populationDynamics) {
|
||||||
|
await prisma.populationDynamic.create({
|
||||||
|
data: dynamic,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Population Dynamics seeded successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Budget Transactions
|
||||||
|
* Creates sample financial transactions
|
||||||
|
*/
|
||||||
|
export async function seedBudgetTransactions(userId: string) {
|
||||||
|
console.log("Seeding Budget Transactions...");
|
||||||
|
|
||||||
|
const transactions = [
|
||||||
|
{
|
||||||
|
transactionNumber: "TRX-2025-001",
|
||||||
|
type: "PENGELUARAN",
|
||||||
|
category: "Infrastruktur",
|
||||||
|
amount: 50000000,
|
||||||
|
description: "Pembangunan jalan desa",
|
||||||
|
date: new Date("2025-01-10"),
|
||||||
|
createdBy: userId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
transactionNumber: "TRX-2025-002",
|
||||||
|
type: "PENDAPATAN",
|
||||||
|
category: "Dana Desa",
|
||||||
|
amount: 500000000,
|
||||||
|
description: "Penyaluran dana desa Q1",
|
||||||
|
date: new Date("2025-01-05"),
|
||||||
|
createdBy: userId,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const transaction of transactions) {
|
||||||
|
await prisma.budgetTransaction.create({
|
||||||
|
data: transaction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Budget Transactions seeded successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed All Phase 2 Data
|
||||||
|
* Main function to run all Phase 2 seeders
|
||||||
|
*/
|
||||||
|
export async function seedPhase2(banjarIds: string[], userId: string) {
|
||||||
|
await seedUmkm(banjarIds);
|
||||||
|
await seedPosyandu(userId);
|
||||||
|
await seedSecurityReports(userId);
|
||||||
|
await seedEmploymentRecords();
|
||||||
|
await seedPopulationDynamics(userId);
|
||||||
|
await seedBudgetTransactions(userId);
|
||||||
|
}
|
||||||
393
prisma/seeders/seed-public-services.ts
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
import {
|
||||||
|
ComplaintCategory,
|
||||||
|
ComplaintStatus,
|
||||||
|
EventType,
|
||||||
|
Priority,
|
||||||
|
PrismaClient,
|
||||||
|
} from "../../generated/prisma";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Complaint IDs
|
||||||
|
* Helper function to retrieve complaint IDs for other seeders
|
||||||
|
*/
|
||||||
|
export async function getComplaintIds(): Promise<string[]> {
|
||||||
|
const complaints = await prisma.complaint.findMany();
|
||||||
|
return complaints.map((c) => c.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Complaints
|
||||||
|
* Creates sample citizen complaints spread across 7 months for trend visualization
|
||||||
|
*/
|
||||||
|
export async function seedComplaints(adminId: string) {
|
||||||
|
console.log("Seeding Complaints...");
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const complaints = [
|
||||||
|
// Recent complaints (this month)
|
||||||
|
{
|
||||||
|
complaintNumber: `COMP-20260327-001`,
|
||||||
|
title: "Lampu Jalan Mati",
|
||||||
|
description:
|
||||||
|
"Lampu jalan di depan Balai Banjar Manesa mati sejak 3 hari lalu.",
|
||||||
|
category: ComplaintCategory.INFRASTRUKTUR,
|
||||||
|
status: ComplaintStatus.BARU,
|
||||||
|
priority: Priority.SEDANG,
|
||||||
|
location: "Banjar Manesa",
|
||||||
|
reporterId: adminId,
|
||||||
|
createdAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), // 5 days ago
|
||||||
|
},
|
||||||
|
{
|
||||||
|
complaintNumber: `COMP-20260325-002`,
|
||||||
|
title: "Sampah Menumpuk",
|
||||||
|
description: "Tumpukan sampah di area pasar Darmasaba belum diangkut.",
|
||||||
|
category: ComplaintCategory.KETERTIBAN_UMUM,
|
||||||
|
status: ComplaintStatus.DIPROSES,
|
||||||
|
priority: Priority.TINGGI,
|
||||||
|
location: "Pasar Darmasaba",
|
||||||
|
assignedTo: adminId,
|
||||||
|
createdAt: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), // 7 days ago
|
||||||
|
},
|
||||||
|
{
|
||||||
|
complaintNumber: `COMP-20260320-003`,
|
||||||
|
title: "Jalan Rusak",
|
||||||
|
description: "Jalan di Banjar Cabe rusak dan berlubang.",
|
||||||
|
category: ComplaintCategory.INFRASTRUKTUR,
|
||||||
|
status: ComplaintStatus.SELESAI,
|
||||||
|
priority: Priority.TINGGI,
|
||||||
|
location: "Banjar Cabe",
|
||||||
|
assignedTo: adminId,
|
||||||
|
resolvedBy: adminId,
|
||||||
|
resolvedAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000),
|
||||||
|
createdAt: new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000), // 12 days ago
|
||||||
|
},
|
||||||
|
// Last month (February 2026)
|
||||||
|
{
|
||||||
|
complaintNumber: `COMP-20260215-004`,
|
||||||
|
title: "Saluran Air Tersumbat",
|
||||||
|
description: "Saluran air di depan rumah warga tersumbat sampah.",
|
||||||
|
category: ComplaintCategory.INFRASTRUKTUR,
|
||||||
|
status: ComplaintStatus.SELESAI,
|
||||||
|
priority: Priority.SEDANG,
|
||||||
|
location: "Banjar Darmasaba",
|
||||||
|
assignedTo: adminId,
|
||||||
|
resolvedBy: adminId,
|
||||||
|
createdAt: new Date(now.getTime() - 45 * 24 * 60 * 60 * 1000), // 45 days ago
|
||||||
|
},
|
||||||
|
{
|
||||||
|
complaintNumber: `COMP-20260210-005`,
|
||||||
|
title: "Parkir Liar",
|
||||||
|
description: "Parkir liar di depan pasar mengganggu lalu lintas.",
|
||||||
|
category: ComplaintCategory.KETERTIBAN_UMUM,
|
||||||
|
status: ComplaintStatus.SELESAI,
|
||||||
|
priority: Priority.RENDAH,
|
||||||
|
location: "Pasar Darmasaba",
|
||||||
|
assignedTo: adminId,
|
||||||
|
resolvedBy: adminId,
|
||||||
|
createdAt: new Date(now.getTime() - 50 * 24 * 60 * 60 * 1000), // 50 days ago
|
||||||
|
},
|
||||||
|
// January 2026
|
||||||
|
{
|
||||||
|
complaintNumber: `COMP-20260120-006`,
|
||||||
|
title: "Penerangan Jalan Umum Rusak",
|
||||||
|
description: "5 titik lampu jalan di Jl. Raya Darmasaba tidak menyala.",
|
||||||
|
category: ComplaintCategory.INFRASTRUKTUR,
|
||||||
|
status: ComplaintStatus.SELESAI,
|
||||||
|
priority: Priority.TINGGI,
|
||||||
|
location: "Jl. Raya Darmasaba",
|
||||||
|
assignedTo: adminId,
|
||||||
|
resolvedBy: adminId,
|
||||||
|
createdAt: new Date(now.getTime() - 70 * 24 * 60 * 60 * 1000), // 70 days ago
|
||||||
|
},
|
||||||
|
{
|
||||||
|
complaintNumber: `COMP-20260115-007`,
|
||||||
|
title: "Pelayanan Administrasi Lambat",
|
||||||
|
description: "Proses pembuatan surat keterangan lambat.",
|
||||||
|
category: ComplaintCategory.ADMINISTRASI,
|
||||||
|
status: ComplaintStatus.SELESAI,
|
||||||
|
priority: Priority.SEDANG,
|
||||||
|
location: "Kantor Desa",
|
||||||
|
assignedTo: adminId,
|
||||||
|
resolvedBy: adminId,
|
||||||
|
createdAt: new Date(now.getTime() - 75 * 24 * 60 * 60 * 1000), // 75 days ago
|
||||||
|
},
|
||||||
|
// December 2025
|
||||||
|
{
|
||||||
|
complaintNumber: `COMP-20251210-008`,
|
||||||
|
title: "Jembatan Rusak Ringan",
|
||||||
|
description: "Pagar jembatan di Banjar Penenjoan rusak.",
|
||||||
|
category: ComplaintCategory.INFRASTRUKTUR,
|
||||||
|
status: ComplaintStatus.SELESAI,
|
||||||
|
priority: Priority.SEDANG,
|
||||||
|
location: "Banjar Penenjoan",
|
||||||
|
assignedTo: adminId,
|
||||||
|
resolvedBy: adminId,
|
||||||
|
createdAt: new Date(now.getTime() - 110 * 24 * 60 * 60 * 1000), // 110 days ago
|
||||||
|
},
|
||||||
|
{
|
||||||
|
complaintNumber: `COMP-20251205-009`,
|
||||||
|
title: "Suara Bising Kegiatan Malam",
|
||||||
|
description: "Kegiatan karaoke malam hari mengganggu ketenangan.",
|
||||||
|
category: ComplaintCategory.KETERTIBAN_UMUM,
|
||||||
|
status: ComplaintStatus.SELESAI,
|
||||||
|
priority: Priority.RENDAH,
|
||||||
|
location: "Banjar Baler Pasar",
|
||||||
|
assignedTo: adminId,
|
||||||
|
resolvedBy: adminId,
|
||||||
|
createdAt: new Date(now.getTime() - 115 * 24 * 60 * 60 * 1000), // 115 days ago
|
||||||
|
},
|
||||||
|
// November 2025
|
||||||
|
{
|
||||||
|
complaintNumber: `COMP-20251115-010`,
|
||||||
|
title: "Genangan Air Saat Hujan",
|
||||||
|
description: "Jalan utama tergenang air saat hujan deras.",
|
||||||
|
category: ComplaintCategory.INFRASTRUKTUR,
|
||||||
|
status: ComplaintStatus.SELESAI,
|
||||||
|
priority: Priority.TINGGI,
|
||||||
|
location: "Jl. Raya Cabe",
|
||||||
|
assignedTo: adminId,
|
||||||
|
resolvedBy: adminId,
|
||||||
|
createdAt: new Date(now.getTime() - 135 * 24 * 60 * 60 * 1000), // 135 days ago
|
||||||
|
},
|
||||||
|
// October 2025
|
||||||
|
{
|
||||||
|
complaintNumber: `COMP-20251020-011`,
|
||||||
|
title: "Pungli Pelayanan KTP",
|
||||||
|
description: "Ada oknum yang meminta biaya tambahan untuk KTP.",
|
||||||
|
category: ComplaintCategory.ADMINISTRASI,
|
||||||
|
status: ComplaintStatus.SELESAI,
|
||||||
|
priority: Priority.DARURAT,
|
||||||
|
location: "Kantor Desa",
|
||||||
|
assignedTo: adminId,
|
||||||
|
resolvedBy: adminId,
|
||||||
|
createdAt: new Date(now.getTime() - 160 * 24 * 60 * 60 * 1000), // 160 days ago
|
||||||
|
},
|
||||||
|
// September 2025
|
||||||
|
{
|
||||||
|
complaintNumber: `COMP-20250915-012`,
|
||||||
|
title: "Tanah Longsor",
|
||||||
|
description: "Tanah longsor di tepi jalan Banjar Bucu.",
|
||||||
|
category: ComplaintCategory.INFRASTRUKTUR,
|
||||||
|
status: ComplaintStatus.SELESAI,
|
||||||
|
priority: Priority.DARURAT,
|
||||||
|
location: "Banjar Bucu",
|
||||||
|
assignedTo: adminId,
|
||||||
|
resolvedBy: adminId,
|
||||||
|
createdAt: new Date(now.getTime() - 195 * 24 * 60 * 60 * 1000), // 195 days ago
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const comp of complaints) {
|
||||||
|
await prisma.complaint.upsert({
|
||||||
|
where: { complaintNumber: comp.complaintNumber },
|
||||||
|
update: comp,
|
||||||
|
create: comp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"✅ Complaints seeded successfully (12 complaints across 7 months)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Service Letters
|
||||||
|
* Creates sample administrative letter requests with dates spread across 6 months
|
||||||
|
*/
|
||||||
|
export async function seedServiceLetters(adminId: string) {
|
||||||
|
console.log("Seeding Service Letters...");
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const serviceLetters = [
|
||||||
|
{
|
||||||
|
letterNumber: "SKT-2025-001",
|
||||||
|
letterType: "KTP",
|
||||||
|
applicantName: "I Wayan Sudarsana",
|
||||||
|
applicantNik: "5103010101700001",
|
||||||
|
applicantAddress: "Jl. Raya Darmasaba No. 1",
|
||||||
|
purpose: "Pembuatan KTP baru",
|
||||||
|
status: "SELESAI",
|
||||||
|
processedBy: adminId,
|
||||||
|
completedAt: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000), // 2 days ago
|
||||||
|
createdAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), // 5 days ago (this week!)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
letterNumber: "SKT-2025-002",
|
||||||
|
letterType: "KK",
|
||||||
|
applicantName: "Ni Made Arianti",
|
||||||
|
applicantNik: "5103010101850002",
|
||||||
|
applicantAddress: "Gg. Manesa No. 5",
|
||||||
|
purpose: "Perubahan data KK",
|
||||||
|
status: "DIPROSES",
|
||||||
|
processedBy: adminId,
|
||||||
|
createdAt: new Date(now.getTime() - 45 * 24 * 60 * 60 * 1000), // 45 days ago
|
||||||
|
},
|
||||||
|
{
|
||||||
|
letterNumber: "SKT-2025-003",
|
||||||
|
letterType: "DOMISILI",
|
||||||
|
applicantName: "I Ketut Arsana",
|
||||||
|
applicantNik: "5103010101900003",
|
||||||
|
applicantAddress: "Jl. Cabe No. 10",
|
||||||
|
purpose: "Surat keterangan domisili",
|
||||||
|
status: "BARU",
|
||||||
|
createdAt: new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000), // 90 days ago
|
||||||
|
},
|
||||||
|
{
|
||||||
|
letterNumber: "SKT-2024-004",
|
||||||
|
letterType: "USAHA",
|
||||||
|
applicantName: "Made Wijaya",
|
||||||
|
applicantNik: "5103010101950004",
|
||||||
|
applicantAddress: "Jl. Penenjoan No. 15",
|
||||||
|
purpose: "Surat keterangan usaha",
|
||||||
|
status: "SELESAI",
|
||||||
|
processedBy: adminId,
|
||||||
|
completedAt: new Date(now.getTime() - 120 * 24 * 60 * 60 * 1000), // 120 days ago
|
||||||
|
createdAt: new Date(now.getTime() - 130 * 24 * 60 * 60 * 1000), // 130 days ago
|
||||||
|
},
|
||||||
|
{
|
||||||
|
letterNumber: "SKT-2024-005",
|
||||||
|
letterType: "KETERANGAN_TIDAK_MAMPU",
|
||||||
|
applicantName: "Putu Sari",
|
||||||
|
applicantNik: "5103010101980005",
|
||||||
|
applicantAddress: "Gg. Bucu No. 8",
|
||||||
|
purpose: "Keterangan tidak mampu untuk beasiswa",
|
||||||
|
status: "SELESAI",
|
||||||
|
processedBy: adminId,
|
||||||
|
completedAt: new Date(now.getTime() - 150 * 24 * 60 * 60 * 1000), // 150 days ago
|
||||||
|
createdAt: new Date(now.getTime() - 160 * 24 * 60 * 60 * 1000), // 160 days ago
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const letter of serviceLetters) {
|
||||||
|
const existing = await prisma.serviceLetter.findUnique({
|
||||||
|
where: { letterNumber: letter.letterNumber },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.serviceLetter.update({
|
||||||
|
where: { letterNumber: letter.letterNumber },
|
||||||
|
data: letter,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.serviceLetter.create({
|
||||||
|
data: letter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Service Letters seeded successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Events
|
||||||
|
* Creates sample village events and meetings
|
||||||
|
*/
|
||||||
|
export async function seedEvents(adminId: string) {
|
||||||
|
console.log("Seeding Events...");
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
title: "Rapat Pleno Desa",
|
||||||
|
description: "Pembahasan anggaran belanja desa",
|
||||||
|
eventType: EventType.RAPAT,
|
||||||
|
startDate: new Date(),
|
||||||
|
location: "Balai Desa Darmasaba",
|
||||||
|
createdBy: adminId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gotong Royong Kebersihan",
|
||||||
|
description: "Kegiatan rutin mingguan",
|
||||||
|
eventType: EventType.SOSIAL,
|
||||||
|
startDate: new Date(Date.now() + 86400000), // Besok
|
||||||
|
location: "Seluruh Banjar",
|
||||||
|
createdBy: adminId,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
await prisma.event.create({
|
||||||
|
data: event,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Events seeded successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Innovation Ideas
|
||||||
|
* Creates sample citizen innovation submissions
|
||||||
|
*/
|
||||||
|
export async function seedInnovationIdeas(adminId: string) {
|
||||||
|
console.log("Seeding Innovation Ideas...");
|
||||||
|
|
||||||
|
const innovationIdeas = [
|
||||||
|
{
|
||||||
|
title: "Sistem Informasi Desa Digital",
|
||||||
|
description: "Platform digital untuk layanan administrasi desa",
|
||||||
|
category: "Teknologi",
|
||||||
|
submitterName: "I Made Wijaya",
|
||||||
|
submitterContact: "081234567890",
|
||||||
|
status: "DIKAJI",
|
||||||
|
reviewedBy: adminId,
|
||||||
|
notes: "Perlu kajian lebih lanjut tentang anggaran",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Program Bank Sampah",
|
||||||
|
description: "Pengelolaan sampah berbasis bank sampah",
|
||||||
|
category: "Lingkungan",
|
||||||
|
submitterName: "Ni Putu Sari",
|
||||||
|
submitterContact: "081234567891",
|
||||||
|
status: "BARU",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const idea of innovationIdeas) {
|
||||||
|
await prisma.innovationIdea.create({
|
||||||
|
data: idea,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Innovation Ideas seeded successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Complaint Updates
|
||||||
|
* Creates status update history for complaints
|
||||||
|
*/
|
||||||
|
export async function seedComplaintUpdates(
|
||||||
|
complaintIds: string[],
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
console.log("Seeding Complaint Updates...");
|
||||||
|
|
||||||
|
if (complaintIds.length === 0) {
|
||||||
|
console.log("⏭️ No complaints found, skipping updates");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = [
|
||||||
|
{
|
||||||
|
complaintId: complaintIds[0],
|
||||||
|
message: "Laporan diterima, akan segera ditindaklanjuti",
|
||||||
|
status: ComplaintStatus.BARU,
|
||||||
|
updatedBy: userId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
complaintId: complaintIds[1],
|
||||||
|
message: "Tim kebersihan telah dikirim ke lokasi",
|
||||||
|
status: ComplaintStatus.DIPROSES,
|
||||||
|
updatedBy: userId,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const update of updates) {
|
||||||
|
await prisma.complaintUpdate.create({
|
||||||
|
data: update,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Complaint Updates seeded successfully");
|
||||||
|
}
|
||||||
BIN
public/SDGS-1.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
public/SDGS-16.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
public/SDGS-3.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
public/SDGS-7.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
public/light-mode.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
public/white.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
58
scripts/build.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build script for production
|
||||||
|
* 1. Build CSS with PostCSS/Tailwind
|
||||||
|
* 2. Bundle JS with Bun (without CSS)
|
||||||
|
* 3. Replace CSS reference in HTML
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { $ } from "bun";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import postcss from "postcss";
|
||||||
|
import tailwindcss from "@tailwindcss/postcss";
|
||||||
|
import autoprefixer from "autoprefixer";
|
||||||
|
|
||||||
|
console.log("🔨 Starting production build...");
|
||||||
|
|
||||||
|
// Ensure dist directory exists
|
||||||
|
if (!fs.existsSync("./dist")) {
|
||||||
|
fs.mkdirSync("./dist", { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Build CSS with PostCSS
|
||||||
|
console.log("🎨 Building CSS...");
|
||||||
|
const cssInput = fs.readFileSync("./src/index.css", "utf-8");
|
||||||
|
const cssResult = await postcss([tailwindcss(), autoprefixer()]).process(
|
||||||
|
cssInput,
|
||||||
|
{
|
||||||
|
from: "./src/index.css",
|
||||||
|
to: "./dist/index.css",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync("./dist/index.css", cssResult.css);
|
||||||
|
console.log("✅ CSS built successfully!");
|
||||||
|
|
||||||
|
// Step 2: Build JS with Bun (build HTML too, we'll fix CSS link later)
|
||||||
|
console.log("📦 Bundling JavaScript...");
|
||||||
|
await $`bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='"production"' --env='VITE_*'`;
|
||||||
|
|
||||||
|
// Step 3: Copy public assets
|
||||||
|
console.log("📁 Copying public assets...");
|
||||||
|
if (fs.existsSync("./public")) {
|
||||||
|
await $`cp -r public/* dist/ 2>/dev/null || true`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Ensure HTML references the correct CSS
|
||||||
|
// Bun build might have renamed the CSS, we want to use our own index.css
|
||||||
|
console.log("🔧 Fixing HTML CSS reference...");
|
||||||
|
const htmlPath = "./dist/index.html";
|
||||||
|
if (fs.existsSync(htmlPath)) {
|
||||||
|
let html = fs.readFileSync(htmlPath, "utf-8");
|
||||||
|
// Replace any bundled CSS reference with our index.css
|
||||||
|
html = html.replace(/href="[^"]*\.css"/g, 'href="/index.css"');
|
||||||
|
fs.writeFileSync(htmlPath, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Build completed successfully!");
|
||||||
22
scripts/check-sync-data.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { prisma } from "../src/utils/db";
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
console.log("--- Checking Division Data in DB ---");
|
||||||
|
const divisions = await prisma.division.findMany({
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
externalActivityCount: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.table(divisions);
|
||||||
|
|
||||||
|
console.log("\n--- Checking API Response for /api/division/ ---");
|
||||||
|
// Mocking the mapping logic from src/api/division.ts
|
||||||
|
const formatted = divisions.map(d => ({
|
||||||
|
name: d.name,
|
||||||
|
activityCount: d.externalActivityCount
|
||||||
|
}));
|
||||||
|
console.table(formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
check().catch(console.error).finally(() => prisma.$disconnect());
|
||||||
38
scripts/inspect-noc-data.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { nocExternalClient } from "../src/utils/noc-external-client";
|
||||||
|
|
||||||
|
async function inspect() {
|
||||||
|
const ID_DESA = "desa1";
|
||||||
|
console.log("Checking NOC API Data structure...");
|
||||||
|
|
||||||
|
const endpoints = [
|
||||||
|
"/api/noc/active-divisions",
|
||||||
|
"/api/noc/latest-projects",
|
||||||
|
"/api/noc/upcoming-events",
|
||||||
|
"/api/noc/latest-discussion"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
console.log(`\n--- Endpoint: ${endpoint} ---`);
|
||||||
|
try {
|
||||||
|
const { data, error } = await (nocExternalClient as any).GET(endpoint, {
|
||||||
|
params: { query: { idDesa: ID_DESA, limit: "1" } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error(`Error fetching ${endpoint}:`, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && data.data && data.data.length > 0) {
|
||||||
|
console.log("Sample Data Object Keys:", Object.keys(data.data[0]));
|
||||||
|
console.log("Sample Data Object Values:", JSON.stringify(data.data[0], null, 2));
|
||||||
|
} else {
|
||||||
|
console.log("No data returned or data is empty.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to fetch ${endpoint}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inspect();
|
||||||
38
scripts/reset-noc-data.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { prisma } from "../src/utils/db";
|
||||||
|
import logger from "../src/utils/logger";
|
||||||
|
|
||||||
|
async function resetNocData() {
|
||||||
|
try {
|
||||||
|
logger.info("Starting NOC Data Reset...");
|
||||||
|
|
||||||
|
// Delete in order to respect relations
|
||||||
|
// 1. Delete Activities (though Division cascade might handle it, let's be explicit)
|
||||||
|
const deletedActivities = await prisma.activity.deleteMany({});
|
||||||
|
logger.info(`Deleted ${deletedActivities.count} activities`);
|
||||||
|
|
||||||
|
// 2. Delete Documents
|
||||||
|
const deletedDocuments = await prisma.document.deleteMany({});
|
||||||
|
logger.info(`Deleted ${deletedDocuments.count} documents`);
|
||||||
|
|
||||||
|
// 3. Delete Discussions
|
||||||
|
const deletedDiscussions = await prisma.discussion.deleteMany({});
|
||||||
|
logger.info(`Deleted ${deletedDiscussions.count} discussions`);
|
||||||
|
|
||||||
|
// 4. Delete Events
|
||||||
|
const deletedEvents = await prisma.event.deleteMany({});
|
||||||
|
logger.info(`Deleted ${deletedEvents.count} events`);
|
||||||
|
|
||||||
|
// 5. Delete Divisions
|
||||||
|
const deletedDivisions = await prisma.division.deleteMany({});
|
||||||
|
logger.info(`Deleted ${deletedDivisions.count} divisions`);
|
||||||
|
|
||||||
|
logger.info("NOC Data Reset Completed Successfully");
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Error during NOC data reset");
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetNocData();
|
||||||
306
scripts/sync-noc.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
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();
|
||||||
216
src/api/complaint.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import Elysia, { t } from "elysia";
|
||||||
|
import { prisma } from "../utils/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
export const complaint = new Elysia({
|
||||||
|
prefix: "/complaint",
|
||||||
|
})
|
||||||
|
.get(
|
||||||
|
"/stats",
|
||||||
|
async ({ set }) => {
|
||||||
|
try {
|
||||||
|
const [total, baru, proses, selesai] = await Promise.all([
|
||||||
|
prisma.complaint.count(),
|
||||||
|
prisma.complaint.count({ where: { status: "BARU" } }),
|
||||||
|
prisma.complaint.count({ where: { status: "DIPROSES" } }),
|
||||||
|
prisma.complaint.count({ where: { status: "SELESAI" } }),
|
||||||
|
]);
|
||||||
|
return { data: { total, baru, proses, selesai } };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to fetch complaint stats");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal Server Error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Object({
|
||||||
|
total: t.Number(),
|
||||||
|
baru: t.Number(),
|
||||||
|
proses: t.Number(),
|
||||||
|
selesai: t.Number(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
500: t.Object({ error: t.String() }),
|
||||||
|
},
|
||||||
|
detail: { summary: "Get complaint statistics" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/recent",
|
||||||
|
async ({ set }) => {
|
||||||
|
try {
|
||||||
|
const recent = await prisma.complaint.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
return { data: recent };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to fetch recent complaints");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal Server Error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Array(t.Any()),
|
||||||
|
}),
|
||||||
|
500: t.Object({ error: t.String() }),
|
||||||
|
},
|
||||||
|
detail: { summary: "Get recent complaints" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/trends",
|
||||||
|
async ({ set }) => {
|
||||||
|
try {
|
||||||
|
// Get last 7 months complaint trends
|
||||||
|
const trends = await prisma.$queryRaw<
|
||||||
|
{ month: string; month_num: number; count: number }[]
|
||||||
|
>`
|
||||||
|
SELECT
|
||||||
|
TO_CHAR("createdAt", 'Mon') as month,
|
||||||
|
EXTRACT(MONTH FROM "createdAt") as month_num,
|
||||||
|
COUNT(*)::INTEGER as count
|
||||||
|
FROM complaint
|
||||||
|
WHERE "createdAt" > NOW() - INTERVAL '7 months'
|
||||||
|
GROUP BY month, month_num
|
||||||
|
ORDER BY month_num ASC
|
||||||
|
`;
|
||||||
|
return { data: trends };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to fetch complaint trends");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal Server Error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Array(t.Any()),
|
||||||
|
}),
|
||||||
|
500: t.Object({ error: t.String() }),
|
||||||
|
},
|
||||||
|
detail: { summary: "Get complaint trends for last 7 months" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/service-stats",
|
||||||
|
async ({ set }) => {
|
||||||
|
try {
|
||||||
|
const serviceStats = await prisma.serviceLetter.groupBy({
|
||||||
|
by: ["letterType"],
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
return { data: serviceStats };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to fetch service stats");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal Server Error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Array(t.Any()),
|
||||||
|
}),
|
||||||
|
500: t.Object({ error: t.String() }),
|
||||||
|
},
|
||||||
|
detail: { summary: "Get service letter statistics by type" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/innovation-ideas",
|
||||||
|
async ({ set }) => {
|
||||||
|
try {
|
||||||
|
const ideas = await prisma.innovationIdea.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 5,
|
||||||
|
});
|
||||||
|
return { data: ideas };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to fetch innovation ideas");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal Server Error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Array(t.Any()),
|
||||||
|
}),
|
||||||
|
500: t.Object({ error: t.String() }),
|
||||||
|
},
|
||||||
|
detail: { summary: "Get recent innovation ideas" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/service-trends",
|
||||||
|
async ({ set }) => {
|
||||||
|
try {
|
||||||
|
// Get last 6 months trends for service letters
|
||||||
|
const trends = await prisma.$queryRaw<
|
||||||
|
{ month: string; month_num: number; count: number }[]
|
||||||
|
>`
|
||||||
|
SELECT
|
||||||
|
TO_CHAR("createdAt", 'Mon') as month,
|
||||||
|
EXTRACT(MONTH FROM "createdAt") as month_num,
|
||||||
|
COUNT(*)::INTEGER as count
|
||||||
|
FROM service_letter
|
||||||
|
WHERE "createdAt" > NOW() - INTERVAL '6 months'
|
||||||
|
GROUP BY month, month_num
|
||||||
|
ORDER BY month_num ASC
|
||||||
|
`;
|
||||||
|
return { data: trends };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to fetch service trends");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal Server Error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Array(t.Any()),
|
||||||
|
}),
|
||||||
|
500: t.Object({ error: t.String() }),
|
||||||
|
},
|
||||||
|
detail: { summary: "Get service letter trends for last 6 months" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/service-weekly",
|
||||||
|
async ({ set }) => {
|
||||||
|
try {
|
||||||
|
const startOfWeek = new Date();
|
||||||
|
startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay());
|
||||||
|
startOfWeek.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const count = await prisma.serviceLetter.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: startOfWeek,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { data: { count } };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to fetch weekly service stats");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal Server Error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Object({
|
||||||
|
count: t.Number(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
500: t.Object({ error: t.String() }),
|
||||||
|
},
|
||||||
|
detail: { summary: "Get service letter count for current week" },
|
||||||
|
},
|
||||||
|
);
|
||||||
72
src/api/dashboard.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import { prisma } from "../utils/db";
|
||||||
|
|
||||||
|
export const dashboard = new Elysia({ prefix: "/dashboard" })
|
||||||
|
.get(
|
||||||
|
"/budget",
|
||||||
|
async () => {
|
||||||
|
const data = await prisma.budget.findMany({
|
||||||
|
where: { fiscalYear: 2025 },
|
||||||
|
orderBy: { category: "asc" },
|
||||||
|
});
|
||||||
|
return { data };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Array(
|
||||||
|
t.Object({
|
||||||
|
category: t.String(),
|
||||||
|
amount: t.Number(),
|
||||||
|
percentage: t.Number(),
|
||||||
|
color: t.String(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/sdgs",
|
||||||
|
async () => {
|
||||||
|
const data = await prisma.sdgsScore.findMany({
|
||||||
|
orderBy: { score: "desc" },
|
||||||
|
});
|
||||||
|
return { data };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Array(
|
||||||
|
t.Object({
|
||||||
|
title: t.String(),
|
||||||
|
score: t.Number(),
|
||||||
|
image: t.Nullable(t.String()),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/satisfaction",
|
||||||
|
async () => {
|
||||||
|
const data = await prisma.satisfactionRating.findMany({
|
||||||
|
orderBy: { value: "desc" },
|
||||||
|
});
|
||||||
|
return { data };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Array(
|
||||||
|
t.Object({
|
||||||
|
category: t.String(),
|
||||||
|
value: t.Number(),
|
||||||
|
color: t.String(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
222
src/api/division.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import Elysia, { t } from "elysia";
|
||||||
|
import { prisma } from "../utils/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
export const division = new Elysia({
|
||||||
|
prefix: "/division",
|
||||||
|
})
|
||||||
|
.get(
|
||||||
|
"/",
|
||||||
|
async ({ set }) => {
|
||||||
|
try {
|
||||||
|
const divisions = await prisma.division.findMany({
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { activities: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
data: divisions.map(d => ({
|
||||||
|
...d,
|
||||||
|
activityCount: d.externalActivityCount || d._count.activities
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to fetch divisions");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal Server Error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Array(t.Any()),
|
||||||
|
}),
|
||||||
|
500: t.Object({ error: t.String() }),
|
||||||
|
},
|
||||||
|
detail: { summary: "Get all divisions" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/activities",
|
||||||
|
async ({ set }) => {
|
||||||
|
try {
|
||||||
|
const activities = await prisma.activity.findMany({
|
||||||
|
include: {
|
||||||
|
division: {
|
||||||
|
select: { name: true, color: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
return { data: activities };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to fetch activities");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal Server Error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Array(t.Any()),
|
||||||
|
}),
|
||||||
|
500: t.Object({ error: t.String() }),
|
||||||
|
},
|
||||||
|
detail: { summary: "Get recent activities" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/activities/stats",
|
||||||
|
async ({ set }) => {
|
||||||
|
try {
|
||||||
|
// Get activity count by status
|
||||||
|
const [selesai, berjalan, tertunda, dibatalkan] = await Promise.all([
|
||||||
|
prisma.activity.count({ where: { status: "SELESAI" } }),
|
||||||
|
prisma.activity.count({ where: { status: "BERJALAN" } }),
|
||||||
|
prisma.activity.count({ where: { status: "TERTUNDA" } }),
|
||||||
|
prisma.activity.count({ where: { status: "DIBATALKAN" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = selesai + berjalan + tertunda + dibatalkan;
|
||||||
|
|
||||||
|
// Calculate percentages
|
||||||
|
const percentages = {
|
||||||
|
selesai: total > 0 ? (selesai / total) * 100 : 0,
|
||||||
|
berjalan: total > 0 ? (berjalan / total) * 100 : 0,
|
||||||
|
tertunda: total > 0 ? (tertunda / total) * 100 : 0,
|
||||||
|
dibatalkan: total > 0 ? (dibatalkan / total) * 100 : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
total,
|
||||||
|
counts: { selesai, berjalan, tertunda, dibatalkan },
|
||||||
|
percentages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to fetch activity stats");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal Server Error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Object({
|
||||||
|
total: t.Number(),
|
||||||
|
counts: t.Object({
|
||||||
|
selesai: t.Number(),
|
||||||
|
berjalan: t.Number(),
|
||||||
|
tertunda: t.Number(),
|
||||||
|
dibatalkan: t.Number(),
|
||||||
|
}),
|
||||||
|
percentages: t.Object({
|
||||||
|
selesai: t.Number(),
|
||||||
|
berjalan: t.Number(),
|
||||||
|
tertunda: t.Number(),
|
||||||
|
dibatalkan: t.Number(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
500: t.Object({ error: t.String() }),
|
||||||
|
},
|
||||||
|
detail: { summary: "Get activity statistics by status" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/documents/stats",
|
||||||
|
async ({ set }) => {
|
||||||
|
try {
|
||||||
|
// Group documents by type
|
||||||
|
const [gambarCount, dokumenCount] = await Promise.all([
|
||||||
|
prisma.document.count({ where: { type: "Gambar" } }),
|
||||||
|
prisma.document.count({ where: { type: "Dokumen" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: [
|
||||||
|
{ name: "Gambar", jumlah: gambarCount, color: "#FACC15" },
|
||||||
|
{ name: "Dokumen", jumlah: dokumenCount, color: "#22C55E" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to fetch document stats");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal Server Error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Array(
|
||||||
|
t.Object({
|
||||||
|
name: t.String(),
|
||||||
|
jumlah: t.Number(),
|
||||||
|
color: t.String(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
500: t.Object({ error: t.String() }),
|
||||||
|
},
|
||||||
|
detail: { summary: "Get document statistics by type" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/discussions",
|
||||||
|
async ({ set }) => {
|
||||||
|
try {
|
||||||
|
// Get recent discussions with sender info
|
||||||
|
const discussions = await prisma.discussion.findMany({
|
||||||
|
where: { parentId: null }, // Only top-level discussions
|
||||||
|
include: {
|
||||||
|
sender: {
|
||||||
|
select: { name: true, email: true },
|
||||||
|
},
|
||||||
|
division: {
|
||||||
|
select: { name: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format for frontend
|
||||||
|
const formattedDiscussions = discussions.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
message: d.message,
|
||||||
|
sender: d.sender.name || d.sender.email,
|
||||||
|
date: d.createdAt.toISOString(),
|
||||||
|
division: d.division?.name || null,
|
||||||
|
isResolved: d.isResolved,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { data: formattedDiscussions };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to fetch discussions");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal Server Error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Array(
|
||||||
|
t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
message: t.String(),
|
||||||
|
sender: t.String(),
|
||||||
|
date: t.String(),
|
||||||
|
division: t.Nullable(t.String()),
|
||||||
|
isResolved: t.Boolean(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
500: t.Object({ error: t.String() }),
|
||||||
|
},
|
||||||
|
detail: { summary: "Get recent discussions" },
|
||||||
|
},
|
||||||
|
);
|
||||||
66
src/api/event.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import Elysia, { t } from "elysia";
|
||||||
|
import { prisma } from "../utils/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
export const event = new Elysia({
|
||||||
|
prefix: "/event",
|
||||||
|
})
|
||||||
|
.get(
|
||||||
|
"/",
|
||||||
|
async ({ set }) => {
|
||||||
|
try {
|
||||||
|
const events = await prisma.event.findMany({
|
||||||
|
orderBy: { startDate: "asc" },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
return { data: events };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to fetch events");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal Server Error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Array(t.Any()),
|
||||||
|
}),
|
||||||
|
500: t.Object({ error: t.String() }),
|
||||||
|
},
|
||||||
|
detail: { summary: "Get upcoming events" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/today",
|
||||||
|
async ({ set }) => {
|
||||||
|
try {
|
||||||
|
const start = new Date();
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
const end = new Date();
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const events = await prisma.event.findMany({
|
||||||
|
where: {
|
||||||
|
startDate: {
|
||||||
|
gte: start,
|
||||||
|
lte: end,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { data: events };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to fetch today's events");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal Server Error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Array(t.Any()),
|
||||||
|
}),
|
||||||
|
500: t.Object({ error: t.String() }),
|
||||||
|
},
|
||||||
|
detail: { summary: "Get events for today" },
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import { cors } from "@elysiajs/cors";
|
import { cors } from "@elysiajs/cors";
|
||||||
import { swagger } from "@elysiajs/swagger";
|
import { swagger } from "@elysiajs/swagger";
|
||||||
import Elysia from "elysia";
|
import Elysia, { t } from "elysia";
|
||||||
import { apiMiddleware } from "../middleware/apiMiddleware";
|
import { apiMiddleware } from "../middleware/apiMiddleware";
|
||||||
import { auth } from "../utils/auth";
|
import { auth } from "../utils/auth";
|
||||||
import { apikey } from "./apikey";
|
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 { profile } from "./profile";
|
||||||
|
import { resident } from "./resident";
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === "production";
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
|
|
||||||
@@ -12,15 +18,33 @@ const api = new Elysia({
|
|||||||
prefix: "/api",
|
prefix: "/api",
|
||||||
})
|
})
|
||||||
.use(cors())
|
.use(cors())
|
||||||
.get("/health", () => ({ ok: true }))
|
.get("/health", () => ({ ok: true }), {
|
||||||
.all("/auth/*", ({ request }) => auth.handler(request))
|
response: {
|
||||||
.get("/session", async ({ request }) => {
|
200: t.Object({ ok: t.Boolean() }),
|
||||||
const data = await auth.api.getSession({ headers: request.headers });
|
},
|
||||||
return { data };
|
|
||||||
})
|
})
|
||||||
|
.all("/auth/*", ({ request }) => auth.handler(request))
|
||||||
|
.get(
|
||||||
|
"/session",
|
||||||
|
async ({ request }) => {
|
||||||
|
const data = await auth.api.getSession({ headers: request.headers });
|
||||||
|
return { data };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({ data: t.Any() }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
.use(apiMiddleware)
|
.use(apiMiddleware)
|
||||||
|
.use(noc)
|
||||||
.use(apikey)
|
.use(apikey)
|
||||||
.use(profile);
|
.use(profile)
|
||||||
|
.use(division)
|
||||||
|
.use(complaint)
|
||||||
|
.use(resident)
|
||||||
|
.use(event)
|
||||||
|
.use(dashboard);
|
||||||
|
|
||||||
if (!isProduction) {
|
if (!isProduction) {
|
||||||
api.use(
|
api.use(
|
||||||
|
|||||||
379
src/api/noc.ts
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
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(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,67 +1,69 @@
|
|||||||
import Elysia, { t } from "elysia";
|
import Elysia, { t } from "elysia";
|
||||||
|
import { apiMiddleware } from "../middleware/apiMiddleware";
|
||||||
import { prisma } from "../utils/db";
|
import { prisma } from "../utils/db";
|
||||||
import logger from "../utils/logger";
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
export const profile = new Elysia({
|
export const profile = new Elysia({
|
||||||
prefix: "/profile",
|
prefix: "/profile",
|
||||||
}).post(
|
})
|
||||||
"/update",
|
.use(apiMiddleware)
|
||||||
async (ctx) => {
|
.post(
|
||||||
const { body, set, user } = ctx as any;
|
"/update",
|
||||||
try {
|
async ({ body, set, user }) => {
|
||||||
if (!user) {
|
try {
|
||||||
set.status = 401;
|
if (!user) {
|
||||||
return { error: "Unauthorized" };
|
set.status = 401;
|
||||||
|
return { error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, image } = body;
|
||||||
|
|
||||||
|
const updatedUser = await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
name: name || undefined,
|
||||||
|
image: image || undefined,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
image: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info({ userId: user.id }, "Profile updated successfully");
|
||||||
|
|
||||||
|
return { user: updatedUser };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, userId: user?.id }, "Failed to update profile");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Failed to update profile" };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const { name, image } = body;
|
{
|
||||||
|
body: t.Object({
|
||||||
const updatedUser = await prisma.user.update({
|
name: t.Optional(t.String()),
|
||||||
where: { id: user.id },
|
image: t.Optional(t.String()),
|
||||||
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() }),
|
response: {
|
||||||
500: t.Object({ error: t.String() }),
|
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: {
|
detail: {
|
||||||
summary: "Update user profile",
|
summary: "Update user profile",
|
||||||
description: "Update the authenticated user's name or profile image",
|
description: "Update the authenticated user's name or profile image",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
);
|
||||||
);
|
|
||||||
|
|||||||
129
src/api/resident.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import Elysia, { t } from "elysia";
|
||||||
|
import { prisma } from "../utils/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
export const resident = new Elysia({
|
||||||
|
prefix: "/resident",
|
||||||
|
})
|
||||||
|
.get(
|
||||||
|
"/stats",
|
||||||
|
async ({ set }) => {
|
||||||
|
try {
|
||||||
|
const [total, heads, poor] = await Promise.all([
|
||||||
|
prisma.resident.count(),
|
||||||
|
prisma.resident.count({ where: { isHeadOfHousehold: true } }),
|
||||||
|
prisma.resident.count({ where: { isPoor: true } }),
|
||||||
|
]);
|
||||||
|
return { data: { total, heads, poor } };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to fetch resident stats");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal Server Error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Object({
|
||||||
|
total: t.Number(),
|
||||||
|
heads: t.Number(),
|
||||||
|
poor: t.Number(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
500: t.Object({ error: t.String() }),
|
||||||
|
},
|
||||||
|
detail: { summary: "Get resident statistics" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/banjar-stats",
|
||||||
|
async ({ set }) => {
|
||||||
|
try {
|
||||||
|
const banjarStats = await prisma.banjar.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
totalPopulation: true,
|
||||||
|
totalKK: true,
|
||||||
|
totalPoor: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { data: banjarStats };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to fetch banjar stats");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal Server Error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Array(t.Any()),
|
||||||
|
}),
|
||||||
|
500: t.Object({ error: t.String() }),
|
||||||
|
},
|
||||||
|
detail: { summary: "Get population data per banjar" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/demographics",
|
||||||
|
async ({ set }) => {
|
||||||
|
try {
|
||||||
|
const [religion, gender, occupation, ageGroups] = await Promise.all([
|
||||||
|
prisma.resident.groupBy({
|
||||||
|
by: ["religion"],
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
prisma.resident.groupBy({
|
||||||
|
by: ["gender"],
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
prisma.resident.groupBy({
|
||||||
|
by: ["occupation"],
|
||||||
|
_count: { _all: true },
|
||||||
|
orderBy: { _count: { occupation: "desc" } },
|
||||||
|
take: 10,
|
||||||
|
}),
|
||||||
|
// Group by age ranges (simplified calculation)
|
||||||
|
prisma.$queryRaw<{ range: string; count: number }[]>`
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 0 AND 16 THEN '0-16'
|
||||||
|
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 17 AND 25 THEN '17-25'
|
||||||
|
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 26 AND 35 THEN '26-35'
|
||||||
|
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 36 AND 45 THEN '36-45'
|
||||||
|
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 46 AND 55 THEN '46-55'
|
||||||
|
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 56 AND 65 THEN '56-65'
|
||||||
|
ELSE '65+'
|
||||||
|
END as range,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM resident
|
||||||
|
GROUP BY range
|
||||||
|
ORDER BY range ASC
|
||||||
|
`,
|
||||||
|
]);
|
||||||
|
return { data: { religion, gender, occupation, ageGroups } };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to fetch demographics");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal Server Error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
data: t.Object({
|
||||||
|
religion: t.Array(t.Any()),
|
||||||
|
gender: t.Array(t.Any()),
|
||||||
|
occupation: t.Array(t.Any()),
|
||||||
|
ageGroups: t.Array(t.Any()),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
500: t.Object({ error: t.String() }),
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
summary:
|
||||||
|
"Get demographics including religion, gender, occupation and age",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,385 +1,38 @@
|
|||||||
import {
|
import { Grid, GridCol, Stack } from "@mantine/core";
|
||||||
Badge,
|
import { HeaderToggle } from "./umkm/header-toggle";
|
||||||
Button,
|
import { ProdukUnggulan } from "./umkm/produk-unggulan";
|
||||||
Card,
|
import type { SalesData } from "./umkm/sales-table";
|
||||||
Grid,
|
import { SalesTable } from "./umkm/sales-table";
|
||||||
GridCol,
|
import { SummaryCards } from "./umkm/summary-cards";
|
||||||
Group,
|
import { TopProducts } from "./umkm/top-products";
|
||||||
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 BumdesPage = () => {
|
||||||
const { colorScheme } = useMantineColorScheme();
|
const handleDetailClick = (product: SalesData) => {
|
||||||
const dark = colorScheme === "dark";
|
console.log("Detail clicked for:", product);
|
||||||
|
// TODO: Open modal or navigate to detail page
|
||||||
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 (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{/* KPI Cards */}
|
{/* KPI Summary Cards */}
|
||||||
<Grid gutter="md">
|
<SummaryCards />
|
||||||
{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 */}
|
{/* Header with Time Range Toggle */}
|
||||||
<Card
|
<HeaderToggle />
|
||||||
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>
|
|
||||||
|
|
||||||
|
{/* Main Content - 2 Column Layout */}
|
||||||
<Grid gutter="md">
|
<Grid gutter="md">
|
||||||
{/* Produk Unggulan (Left Column) */}
|
{/* Left Panel - Produk Unggulan */}
|
||||||
<GridCol span={{ base: 12, lg: 4 }}>
|
<GridCol span={{ base: 12, lg: 4 }}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{/* Total Penjualan, Produk Aktif, Total Transaksi */}
|
<ProdukUnggulan />
|
||||||
<Card
|
<TopProducts />
|
||||||
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>
|
</Stack>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
|
|
||||||
{/* Detail Penjualan Produk (Right Column) */}
|
{/* Right Panel - Detail Penjualan Produk */}
|
||||||
<GridCol span={{ base: 12, lg: 8 }}>
|
<GridCol span={{ base: 12, lg: 8 }}>
|
||||||
<Card
|
<SalesTable onDetailClick={handleDetailClick} />
|
||||||
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>
|
</GridCol>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,510 +1,154 @@
|
|||||||
import {
|
import { Center, Grid, Image, Loader, Stack } from "@mantine/core";
|
||||||
Calendar,
|
import { CheckCircle, FileText, MessageCircle, Users } from "lucide-react";
|
||||||
CheckCircle,
|
import { useEffect, useState } from "react";
|
||||||
FileText,
|
import { apiClient } from "@/utils/api-client";
|
||||||
MessageCircle,
|
import { ActivityList } from "./dashboard/activity-list";
|
||||||
Users,
|
import { ChartAPBDes } from "./dashboard/chart-apbdes";
|
||||||
} from "lucide-react";
|
import { ChartSurat } from "./dashboard/chart-surat";
|
||||||
import {
|
import { DivisionProgress } from "./dashboard/division-progress";
|
||||||
Bar,
|
import { SatisfactionChart } from "./dashboard/satisfaction-chart";
|
||||||
BarChart,
|
import { SDGSCard } from "./dashboard/sdgs-card";
|
||||||
CartesianGrid,
|
import { StatCard } from "./dashboard/stat-card";
|
||||||
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() {
|
export function DashboardContent() {
|
||||||
const { colorScheme } = useMantineColorScheme();
|
const [stats, setStats] = useState({
|
||||||
const dark = colorScheme === "dark";
|
complaints: { total: 0, baru: 0, proses: 0, selesai: 0 },
|
||||||
|
residents: { total: 0, heads: 0, poor: 0 },
|
||||||
|
weeklyService: 0,
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [sdgsData, setSdgsData] = useState<
|
||||||
|
{ title: string; score: number; image: string | null }[]
|
||||||
|
>([]);
|
||||||
|
const [sdgsLoading, setSdgsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchStats() {
|
||||||
|
try {
|
||||||
|
const [complaintRes, residentRes, weeklyServiceRes, sdgsRes] =
|
||||||
|
await Promise.all([
|
||||||
|
apiClient.GET("/api/complaint/stats"),
|
||||||
|
apiClient.GET("/api/resident/stats"),
|
||||||
|
apiClient.GET("/api/complaint/service-weekly"),
|
||||||
|
apiClient.GET("/api/dashboard/sdgs"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setStats({
|
||||||
|
complaints: (complaintRes.data as { data: typeof stats.complaints })
|
||||||
|
?.data || {
|
||||||
|
total: 0,
|
||||||
|
baru: 0,
|
||||||
|
proses: 0,
|
||||||
|
selesai: 0,
|
||||||
|
},
|
||||||
|
residents: (residentRes.data as { data: typeof stats.residents })
|
||||||
|
?.data || {
|
||||||
|
total: 0,
|
||||||
|
heads: 0,
|
||||||
|
poor: 0,
|
||||||
|
},
|
||||||
|
weeklyService:
|
||||||
|
(weeklyServiceRes.data as { data: { count: number } })?.data
|
||||||
|
?.count || 0,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sdgsRes.data?.data) {
|
||||||
|
setSdgsData(sdgsRes.data.data);
|
||||||
|
}
|
||||||
|
setSdgsLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch dashboard content", error);
|
||||||
|
setStats((prev) => ({ ...prev, loading: false }));
|
||||||
|
setSdgsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{/* Stats Cards */}
|
{/* Header Metrics - 4 Stat Cards */}
|
||||||
<Grid gutter="md">
|
<Grid gutter="md">
|
||||||
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
||||||
<Card
|
<StatCard
|
||||||
p="md"
|
title="Surat Minggu Ini"
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
value={stats.weeklyService}
|
||||||
radius="md"
|
detail="Total surat diajukan"
|
||||||
h="100%"
|
icon={<FileText style={{ width: "70%", height: "70%" }} />}
|
||||||
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>
|
||||||
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
||||||
<Card
|
<StatCard
|
||||||
p="md"
|
title="Pengaduan Aktif"
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
value={stats.complaints.baru + stats.complaints.proses}
|
||||||
radius="md"
|
detail={`${stats.complaints.baru} baru, ${stats.complaints.proses} diproses`}
|
||||||
h="100%"
|
icon={<MessageCircle style={{ width: "70%", height: "70%" }} />}
|
||||||
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>
|
||||||
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
||||||
<Card
|
<StatCard
|
||||||
p="md"
|
title="Layanan Selesai"
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
value={stats.complaints.selesai}
|
||||||
radius="md"
|
detail="Total diselesaikan"
|
||||||
h="100%"
|
icon={<CheckCircle style={{ width: "70%", height: "70%" }} />}
|
||||||
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>
|
||||||
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
||||||
<Card
|
<StatCard
|
||||||
p="md"
|
title="Total Penduduk"
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
value={stats.residents.total.toLocaleString()}
|
||||||
radius="md"
|
detail={`${stats.residents.heads} Kepala Keluarga`}
|
||||||
h="100%"
|
icon={<Users style={{ width: "70%", height: "70%" }} />}
|
||||||
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.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Section 2: Chart & Division Progress */}
|
||||||
<Grid gutter="lg">
|
<Grid gutter="lg">
|
||||||
{/* Bar Chart */}
|
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
<ChartSurat />
|
||||||
<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>
|
||||||
|
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||||
{/* Pie Chart */}
|
<SatisfactionChart />
|
||||||
<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.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Bottom Section */}
|
{/* Section 3: APBDes Chart */}
|
||||||
<Grid gutter="lg">
|
<Grid gutter="lg">
|
||||||
{/* Divisi Teraktif */}
|
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
<DivisionProgress />
|
||||||
<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>
|
||||||
|
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||||
{/* Kalender */}
|
<ActivityList />
|
||||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
{/* <SatisfactionChart /> */}
|
||||||
<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.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* APBDes Chart */}
|
<ChartAPBDes />
|
||||||
<Card
|
|
||||||
p="md"
|
{/* Section 6: SDGs Desa Cards */}
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
{sdgsLoading ? (
|
||||||
radius="md"
|
<Center py="xl">
|
||||||
withBorder
|
<Loader />
|
||||||
bg={dark ? "#141D34" : "white"}
|
</Center>
|
||||||
>
|
) : (
|
||||||
<Title order={4} mb="lg">
|
<Grid gutter="md">
|
||||||
Grafik APBDes
|
{sdgsData.map((sdg) => (
|
||||||
</Title>
|
<Grid.Col key={sdg.title} span={{ base: 9, md: 3 }}>
|
||||||
<Stack gap="xs">
|
<SDGSCard
|
||||||
{apbdesData.map((data, index) => (
|
image={
|
||||||
<Grid key={index} align="center">
|
sdg.image ? <Image src={sdg.image} alt={sdg.title} /> : null
|
||||||
<Grid.Col span={3}>
|
}
|
||||||
<Text size="sm" fw={500}>
|
title={sdg.title}
|
||||||
{data.name}
|
score={sdg.score}
|
||||||
</Text>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={9}>
|
|
||||||
<Progress
|
|
||||||
value={data.value}
|
|
||||||
size="lg"
|
|
||||||
radius="xl"
|
|
||||||
color={data.color}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Grid>
|
||||||
</Card>
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
105
src/components/dashboard/activity-list.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { Calendar } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
|
||||||
|
interface EventData {
|
||||||
|
date: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityList() {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const [data, setData] = useState<EventData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchEvents() {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.GET("/api/event/");
|
||||||
|
if (res.data?.data) {
|
||||||
|
setData(
|
||||||
|
(res.data.data as { startDate: string; title: string }[]).map(
|
||||||
|
(e) => ({
|
||||||
|
date: dayjs(e.startDate).format("D MMMM YYYY"),
|
||||||
|
title: e.title,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch events", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchEvents();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: dark
|
||||||
|
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||||
|
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
}}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<Group gap="xs" mb="lg">
|
||||||
|
<Calendar
|
||||||
|
style={{ width: 20, height: 20 }}
|
||||||
|
color={dark ? "#E2E8F0" : "#1E3A5F"}
|
||||||
|
/>
|
||||||
|
<Title order={4} c={dark ? "white" : "gray.9"}>
|
||||||
|
Kalender & Kegiatan Mendatang
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
<Stack gap="md">
|
||||||
|
{loading ? (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
) : data.length > 0 ? (
|
||||||
|
data.map((event) => (
|
||||||
|
<Box
|
||||||
|
key={`${event.title}-${event.date}`}
|
||||||
|
style={{
|
||||||
|
borderLeft: "4px solid var(--mantine-color-blue-filled)",
|
||||||
|
paddingLeft: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{event.date}
|
||||||
|
</Text>
|
||||||
|
<Text fw={500} c={dark ? "white" : "gray.9"}>
|
||||||
|
{event.title}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
Tidak ada kegiatan mendatang
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/components/dashboard/chart-apbdes.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
Cell,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
|
||||||
|
interface ApbdesData {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartAPBDes() {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const [data, setData] = useState<ApbdesData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchApbdes() {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.GET("/api/dashboard/budget");
|
||||||
|
if (res.data?.data) {
|
||||||
|
setData(
|
||||||
|
res.data.data.map((d) => ({
|
||||||
|
name: d.category,
|
||||||
|
value: d.percentage,
|
||||||
|
color: d.color,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch APBDes data", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchApbdes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: dark
|
||||||
|
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||||
|
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
}}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<Title order={4} c={dark ? "white" : "gray.9"} mb="lg">
|
||||||
|
Grafik APBDes
|
||||||
|
</Title>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{loading ? (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
) : data.length > 0 ? (
|
||||||
|
data.map((item) => (
|
||||||
|
<Group key={item.name} align="center" gap="md">
|
||||||
|
<Text size="sm" fw={500} w={100} c={dark ? "white" : "gray.7"}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<ResponsiveContainer width="100%" height={12} style={{ flex: 1 }}>
|
||||||
|
<BarChart
|
||||||
|
layout="vertical"
|
||||||
|
data={[item]}
|
||||||
|
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<XAxis type="number" hide domain={[0, 100]} />
|
||||||
|
<YAxis type="category" hide dataKey="name" />
|
||||||
|
<Bar dataKey="value" radius={[10, 10, 10, 10]} barSize={12}>
|
||||||
|
<Cell fill={item.color} />
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
w={40}
|
||||||
|
ta="right"
|
||||||
|
c={dark ? "white" : "gray.9"}
|
||||||
|
>
|
||||||
|
{item.value}%
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
Tidak ada data APBDes
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
src/components/dashboard/chart-surat.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
|
||||||
|
interface ChartData {
|
||||||
|
month: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartSurat() {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const [data, setData] = useState<ChartData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// DEBUG: Uncomment to test chart rendering with sample data
|
||||||
|
// useEffect(() => {
|
||||||
|
// setData([
|
||||||
|
// { month: "Oct", value: 1 },
|
||||||
|
// { month: "Nov", value: 1 },
|
||||||
|
// { month: "Dec", value: 1 },
|
||||||
|
// { month: "Feb", value: 1 },
|
||||||
|
// { month: "Mar", value: 1 },
|
||||||
|
// ]);
|
||||||
|
// setLoading(false);
|
||||||
|
// }, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchTrends() {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.GET("/api/complaint/service-trends");
|
||||||
|
console.log("📊 Service trends response:", res);
|
||||||
|
|
||||||
|
// Check if response has data
|
||||||
|
if (
|
||||||
|
res.data?.data &&
|
||||||
|
Array.isArray(res.data.data) &&
|
||||||
|
res.data.data.length > 0
|
||||||
|
) {
|
||||||
|
const chartData = (
|
||||||
|
res.data.data as { month: string; count: number }[]
|
||||||
|
).map((d) => ({
|
||||||
|
month: d.month,
|
||||||
|
value: Number(d.count),
|
||||||
|
}));
|
||||||
|
console.log("📈 Mapped chart data:", chartData);
|
||||||
|
console.log("✅ Chart data count:", chartData.length);
|
||||||
|
setData(chartData);
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ No data in response or empty array");
|
||||||
|
console.log("Response structure:", JSON.stringify(res, null, 2));
|
||||||
|
setData([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Failed to fetch service trends", error);
|
||||||
|
console.log("Error details:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchTrends();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: dark
|
||||||
|
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||||
|
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
}}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Box>
|
||||||
|
<Title order={4} c={dark ? "white" : "gray.9"} mb={5}>
|
||||||
|
Statistik Pengajuan Surat
|
||||||
|
</Title>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Trend pengajuan surat 6 bulan terakhir
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<ActionIcon variant="subtle" size="lg" radius="md">
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
role="img"
|
||||||
|
aria-label="Tampilkan Detail"
|
||||||
|
>
|
||||||
|
<title>Tampilkan Detail</title>
|
||||||
|
<path
|
||||||
|
d="M8 5L13 10L8 15"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
<Box style={{ width: "100%", height: 300 }}>
|
||||||
|
{loading ? (
|
||||||
|
<Group justify="center" align="center" h="100%">
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
) : data.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={data}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
vertical={false}
|
||||||
|
stroke={dark ? "#334155" : "#e5e7eb"}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: dark ? "#1E293B" : "white",
|
||||||
|
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||||
|
borderRadius: "8px",
|
||||||
|
boxShadow: "0 4px 6px -1px rgb(0 0 0 / 0.1)",
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="value"
|
||||||
|
fill={dark ? "#60A5FA" : "#3B82F6"}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<Group justify="center" align="center" h="100%">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Tidak ada data pengajuan surat 6 bulan terakhir
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/components/dashboard/division-progress.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Progress,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
|
||||||
|
interface DivisionData {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DivisionApiResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
activityCount: number;
|
||||||
|
_count?: {
|
||||||
|
activities: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DivisionProgress() {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const [data, setData] = useState<DivisionData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchDivisions() {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.GET("/api/division/");
|
||||||
|
if (res.data?.data) {
|
||||||
|
setData(
|
||||||
|
(res.data.data as DivisionApiResponse[]).map((d) => ({
|
||||||
|
name: d.name,
|
||||||
|
value: d.activityCount || 0,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch division stats", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchDivisions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const max_value = Math.max(...data.map((d) => d.value), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: dark
|
||||||
|
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||||
|
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
}}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<Title order={4} c={dark ? "white" : "gray.9"} mb="lg">
|
||||||
|
Divisi Teraktif
|
||||||
|
</Title>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{loading ? (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
) : data.length > 0 ? (
|
||||||
|
data.map((divisi) => (
|
||||||
|
<Box key={divisi.name}>
|
||||||
|
<Group justify="space-between" mb={5}>
|
||||||
|
<Text size="sm" fw={500} c={dark ? "white" : "gray.7"}>
|
||||||
|
{divisi.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}>
|
||||||
|
{divisi.value} Kegiatan
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress
|
||||||
|
value={(divisi.value / max_value) * 100}
|
||||||
|
size="sm"
|
||||||
|
radius="xl"
|
||||||
|
color="blue"
|
||||||
|
animated
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
Tidak ada data divisi
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/components/dashboard/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { ActivityList } from "./activity-list";
|
||||||
|
export { ChartAPBDes } from "./chart-apbdes";
|
||||||
|
export { ChartSurat } from "./chart-surat";
|
||||||
|
export { DivisionProgress } from "./division-progress";
|
||||||
|
export { SatisfactionChart } from "./satisfaction-chart";
|
||||||
|
export { SDGSCard } from "./sdgs-card";
|
||||||
|
export { StatCard } from "./stat-card";
|
||||||
116
src/components/dashboard/satisfaction-chart.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
|
||||||
|
interface SatisfactionData {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SatisfactionChart() {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const [data, setData] = useState<SatisfactionData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchSatisfaction() {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.GET("/api/dashboard/satisfaction");
|
||||||
|
if (res.data?.data) {
|
||||||
|
setData(
|
||||||
|
res.data.data.map((d) => ({
|
||||||
|
name: d.category,
|
||||||
|
value: d.value,
|
||||||
|
color: d.color,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch satisfaction data", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSatisfaction();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: dark
|
||||||
|
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||||
|
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
}}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<Title order={4} c={dark ? "white" : "gray.9"} mb={5}>
|
||||||
|
Tingkat Kepuasan
|
||||||
|
</Title>
|
||||||
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
|
Tingkat kepuasan layanan
|
||||||
|
</Text>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
{loading ? (
|
||||||
|
<Group justify="center" align="center" h="100%">
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={80}
|
||||||
|
outerRadius={120}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{data.map((entry) => (
|
||||||
|
<Cell key={`cell-${entry.name}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: dark ? "#1E293B" : "white",
|
||||||
|
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
)}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<Group justify="center" gap="md" mt="md">
|
||||||
|
{data.map((item) => (
|
||||||
|
<Group key={item.name} gap="xs">
|
||||||
|
<Box
|
||||||
|
w={12}
|
||||||
|
h={12}
|
||||||
|
style={{ backgroundColor: item.color, borderRadius: "50%" }}
|
||||||
|
/>
|
||||||
|
<Text size="sm" c={dark ? "white" : "gray.7"}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/components/dashboard/sdgs-card.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Box, Card, Group, Text, useMantineColorScheme } from "@mantine/core";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface SDGSCardProps {
|
||||||
|
title: string;
|
||||||
|
score: number;
|
||||||
|
image: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SDGSCard({ title, score, image }: SDGSCardProps) {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
}}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" w="100%">
|
||||||
|
<Box>{image}</Box>
|
||||||
|
<Box style={{ flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
ta={"center"}
|
||||||
|
size="sm"
|
||||||
|
c={dark ? "white" : "gray.8"}
|
||||||
|
fw={500}
|
||||||
|
mb="xs"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text ta={"center"} size="xl" c={dark ? "white" : "gray.8"} fw={700}>
|
||||||
|
{score.toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/components/dashboard/stat-card.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
detail?: string;
|
||||||
|
trend?: string;
|
||||||
|
trendValue?: number;
|
||||||
|
icon: ReactNode;
|
||||||
|
iconColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
detail,
|
||||||
|
trend,
|
||||||
|
trendValue,
|
||||||
|
icon,
|
||||||
|
iconColor = "#1E3A5F",
|
||||||
|
}: StatCardProps) {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const isPositiveTrend = trendValue ? trendValue >= 0 : true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: dark
|
||||||
|
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||||
|
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
}}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" w="100%">
|
||||||
|
<Box style={{ flex: 1 }}>
|
||||||
|
<Text size="sm" c="dimmed" mb="xs">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Group align="baseline" gap="xs">
|
||||||
|
<Text size="xl" fw={700} c={dark ? "white" : "gray.9"}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{detail && (
|
||||||
|
<Text size="sm" c="dimmed" mt="xs">
|
||||||
|
{detail}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{trend && (
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c={isPositiveTrend ? "green" : "red"}
|
||||||
|
mt="xs"
|
||||||
|
fw={500}
|
||||||
|
>
|
||||||
|
{trend}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<ThemeIcon
|
||||||
|
variant="filled"
|
||||||
|
size="xl"
|
||||||
|
radius="xl"
|
||||||
|
color={dark ? "gray" : iconColor}
|
||||||
|
bg={dark ? "gray" : iconColor}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
src/components/dev-inspector.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface CodeInfo {
|
||||||
|
relativePath: string;
|
||||||
|
line: string;
|
||||||
|
column: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts data-inspector-* from fiber props or DOM attributes.
|
||||||
|
* Handles React 19 fiber tree walk-up and DOM attribute fallbacks.
|
||||||
|
*/
|
||||||
|
function getCodeInfoFromElement(element: HTMLElement): CodeInfo | null {
|
||||||
|
// Strategy 1: React internal props __reactProps$ (most accurate in R19)
|
||||||
|
for (const key of Object.keys(element)) {
|
||||||
|
if (key.startsWith("__reactProps$")) {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: React internals
|
||||||
|
const props = (element as any)[key];
|
||||||
|
if (props?.["data-inspector-relative-path"]) {
|
||||||
|
return {
|
||||||
|
relativePath: props["data-inspector-relative-path"],
|
||||||
|
line: props["data-inspector-line"] || "1",
|
||||||
|
column: props["data-inspector-column"] || "1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strategy 2: Walk fiber tree __reactFiber$
|
||||||
|
if (key.startsWith("__reactFiber$")) {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: React internals
|
||||||
|
let f = (element as any)[key];
|
||||||
|
while (f) {
|
||||||
|
const p = f.pendingProps || f.memoizedProps;
|
||||||
|
if (p?.["data-inspector-relative-path"]) {
|
||||||
|
return {
|
||||||
|
relativePath: p["data-inspector-relative-path"],
|
||||||
|
line: p["data-inspector-line"] || "1",
|
||||||
|
column: p["data-inspector-column"] || "1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Fallback: _debugSource (React < 19)
|
||||||
|
const src = f._debugSource ?? f._debugOwner?._debugSource;
|
||||||
|
if (src?.fileName && src?.lineNumber) {
|
||||||
|
return {
|
||||||
|
relativePath: src.fileName,
|
||||||
|
line: String(src.lineNumber),
|
||||||
|
column: String(src.columnNumber ?? 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
f = f.return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Universal DOM attribute fallback
|
||||||
|
const rp = element.getAttribute("data-inspector-relative-path");
|
||||||
|
if (rp) {
|
||||||
|
return {
|
||||||
|
relativePath: rp,
|
||||||
|
line: element.getAttribute("data-inspector-line") || "1",
|
||||||
|
column: element.getAttribute("data-inspector-column") || "1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walks up DOM tree until source info is found. */
|
||||||
|
function findCodeInfo(target: HTMLElement): CodeInfo | null {
|
||||||
|
let el: HTMLElement | null = target;
|
||||||
|
while (el) {
|
||||||
|
const info = getCodeInfoFromElement(el);
|
||||||
|
if (info) return info;
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInEditor(info: CodeInfo) {
|
||||||
|
fetch("/__open-in-editor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
relativePath: info.relativePath,
|
||||||
|
lineNumber: info.line,
|
||||||
|
columnNumber: info.column,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DevInspector({ children }: { children: React.ReactNode }) {
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const lastInfoRef = useRef<CodeInfo | null>(null);
|
||||||
|
|
||||||
|
const updateOverlay = useCallback((target: HTMLElement | null) => {
|
||||||
|
const ov = overlayRef.current;
|
||||||
|
const tt = tooltipRef.current;
|
||||||
|
if (!ov || !tt) return;
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
ov.style.display = "none";
|
||||||
|
tt.style.display = "none";
|
||||||
|
lastInfoRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = findCodeInfo(target);
|
||||||
|
if (!info) {
|
||||||
|
ov.style.display = "none";
|
||||||
|
tt.style.display = "none";
|
||||||
|
lastInfoRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastInfoRef.current = info;
|
||||||
|
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
ov.style.display = "block";
|
||||||
|
ov.style.top = `${rect.top + window.scrollY}px`;
|
||||||
|
ov.style.left = `${rect.left + window.scrollX}px`;
|
||||||
|
ov.style.width = `${rect.width}px`;
|
||||||
|
ov.style.height = `${rect.height}px`;
|
||||||
|
|
||||||
|
tt.style.display = "block";
|
||||||
|
tt.textContent = `${info.relativePath}:${info.line}`;
|
||||||
|
const ttTop = rect.top + window.scrollY - 24;
|
||||||
|
tt.style.top = `${ttTop > 0 ? ttTop : rect.bottom + window.scrollY + 4}px`;
|
||||||
|
tt.style.left = `${rect.left + window.scrollX}px`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
const onMouseOver = (e: MouseEvent) =>
|
||||||
|
updateOverlay(e.target as HTMLElement);
|
||||||
|
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const info = lastInfoRef.current ?? findCodeInfo(e.target as HTMLElement);
|
||||||
|
if (info) {
|
||||||
|
const loc = `${info.relativePath}:${info.line}:${info.column}`;
|
||||||
|
console.log("[DevInspector] Open:", loc);
|
||||||
|
openInEditor(info);
|
||||||
|
}
|
||||||
|
setActive(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setActive(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mouseover", onMouseOver, true);
|
||||||
|
document.addEventListener("click", onClick, true);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
document.body.style.cursor = "crosshair";
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mouseover", onMouseOver, true);
|
||||||
|
document.removeEventListener("click", onClick, true);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
if (overlayRef.current) overlayRef.current.style.display = "none";
|
||||||
|
if (tooltipRef.current) tooltipRef.current.style.display = "none";
|
||||||
|
};
|
||||||
|
}, [active, updateOverlay]);
|
||||||
|
|
||||||
|
// Hotkey: Ctrl+Shift+Cmd+C (macOS) / Ctrl+Shift+Alt+C
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
e.key.toLowerCase() === "c" &&
|
||||||
|
e.ctrlKey &&
|
||||||
|
e.shiftKey &&
|
||||||
|
(e.metaKey || e.altKey)
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
setActive((prev) => !prev);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
style={{
|
||||||
|
display: "none",
|
||||||
|
position: "absolute",
|
||||||
|
pointerEvents: "none",
|
||||||
|
border: "2px solid #3b82f6",
|
||||||
|
backgroundColor: "rgba(59,130,246,0.1)",
|
||||||
|
zIndex: 99999,
|
||||||
|
transition: "all 0.05s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
style={{
|
||||||
|
display: "none",
|
||||||
|
position: "absolute",
|
||||||
|
pointerEvents: "none",
|
||||||
|
backgroundColor: "#1e293b",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
padding: "2px 6px",
|
||||||
|
borderRadius: "3px",
|
||||||
|
zIndex: 100000,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,10 +23,10 @@ export function ImageWithFallback(
|
|||||||
<div className="flex items-center justify-center w-full h-full">
|
<div className="flex items-center justify-center w-full h-full">
|
||||||
<img
|
<img
|
||||||
src={ERROR_IMG_SRC}
|
src={ERROR_IMG_SRC}
|
||||||
alt="Error loading image"
|
alt="Error loading content"
|
||||||
{...rest}
|
{...rest}
|
||||||
data-original-url={src}
|
data-original-url={src}
|
||||||
/>
|
/>{" "}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,72 +1,175 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Anchor,
|
||||||
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
|
Breadcrumbs,
|
||||||
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Text,
|
Text,
|
||||||
useMantineColorScheme,
|
useMantineColorScheme,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useLocation } from "@tanstack/react-router";
|
import {
|
||||||
import { Bell, Moon, Sun } from "lucide-react";
|
IconLayoutSidebarLeftCollapse,
|
||||||
|
IconUserShield,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Bell, Moon, Sun, User as UserIcon } from "lucide-react";
|
||||||
|
|
||||||
export function Header() {
|
interface HeaderProps {
|
||||||
|
onSidebarToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ onSidebarToggle }: HeaderProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||||
const dark = colorScheme === "dark";
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
const title =
|
const pathnames = location.pathname.split("/").filter((x) => x);
|
||||||
location.pathname === "/"
|
|
||||||
? "Desa Darmasaba"
|
const breadcrumbItems = [
|
||||||
: "Desa Darmasaba";
|
<Anchor
|
||||||
|
key="home"
|
||||||
|
onClick={() => navigate({ to: "/" })}
|
||||||
|
c="white"
|
||||||
|
size="sm"
|
||||||
|
underline="hover"
|
||||||
|
>
|
||||||
|
Desa Darmasaba
|
||||||
|
</Anchor>,
|
||||||
|
...pathnames.map((value, index) => {
|
||||||
|
const to = `/${pathnames.slice(0, index + 1).join("/")}`;
|
||||||
|
const isLast = index === pathnames.length - 1;
|
||||||
|
|
||||||
|
// Map route path to human-readable label
|
||||||
|
const labelMap: Record<string, string> = {
|
||||||
|
"kinerja-divisi": "Kinerja Divisi",
|
||||||
|
"pengaduan-layanan-publik": "Pengaduan & Layanan Publik",
|
||||||
|
"jenna-analytic": "Jenna Analytic",
|
||||||
|
"demografi-pekerjaan": "Demografi & Kependudukan",
|
||||||
|
"keuangan-anggaran": "Keuangan & Anggaran",
|
||||||
|
bumdes: "Bumdes & UMKM",
|
||||||
|
sosial: "Sosial",
|
||||||
|
keamanan: "Keamanan",
|
||||||
|
bantuan: "Bantuan",
|
||||||
|
pengaturan: "Pengaturan",
|
||||||
|
umum: "Umum",
|
||||||
|
notifikasi: "Notifikasi",
|
||||||
|
"akses-dan-tim": "Akses & Tim",
|
||||||
|
profile: "Profil",
|
||||||
|
edit: "Edit",
|
||||||
|
};
|
||||||
|
|
||||||
|
const label =
|
||||||
|
labelMap[value] || value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
|
|
||||||
|
return isLast ? (
|
||||||
|
<Text key={to} c="white" size="sm" fw={600}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Anchor
|
||||||
|
key={to}
|
||||||
|
onClick={() => navigate({ to })}
|
||||||
|
c="white"
|
||||||
|
size="sm"
|
||||||
|
underline="hover"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Anchor>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Group justify="space-between" w="100%">
|
||||||
style={{
|
{/* Title & Breadcrumbs */}
|
||||||
display: "grid",
|
<Group gap="md">
|
||||||
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
|
<ActionIcon
|
||||||
onClick={toggleColorScheme}
|
onClick={onSidebarToggle}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
|
size="lg"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
|
visibleFrom="sm"
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
>
|
>
|
||||||
{dark ? <Sun size={18} /> : <Moon size={18} />}
|
<IconLayoutSidebarLeftCollapse
|
||||||
</ActionIcon>
|
color="white"
|
||||||
|
style={{ width: "70%", height: "70%" }}
|
||||||
<ActionIcon variant="subtle" radius="xl" pos="relative">
|
/>
|
||||||
<Bell size={18} />
|
|
||||||
<Badge
|
|
||||||
size="xs"
|
|
||||||
color="red"
|
|
||||||
style={{ position: "absolute", top: -4, right: -4 }}
|
|
||||||
>
|
|
||||||
10
|
|
||||||
</Badge>
|
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
<Breadcrumbs
|
||||||
|
separator={
|
||||||
|
<Text c="white" size="xs">
|
||||||
|
/
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
styles={{
|
||||||
|
separator: { color: "white" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{breadcrumbItems}
|
||||||
|
</Breadcrumbs>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
|
||||||
|
{/* Right Section */}
|
||||||
|
<Group gap="md">
|
||||||
|
{/* User Info */}
|
||||||
|
<Group gap="sm">
|
||||||
|
<Box ta="right">
|
||||||
|
<Text c={"white"} size="sm" fw={500}>
|
||||||
|
I. B. Surya Prabhawa M...
|
||||||
|
</Text>
|
||||||
|
<Text c={"white"} size="xs">
|
||||||
|
Kepala Desa
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Avatar color="blue" radius="xl">
|
||||||
|
<UserIcon color="white" style={{ width: "70%", height: "70%" }} />
|
||||||
|
</Avatar>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<Divider orientation="vertical" h={30} />
|
||||||
|
|
||||||
|
{/* Icons */}
|
||||||
|
<Group gap="sm">
|
||||||
|
<ActionIcon
|
||||||
|
onClick={() => toggleColorScheme()}
|
||||||
|
variant="subtle"
|
||||||
|
size="lg"
|
||||||
|
radius="xl"
|
||||||
|
aria-label="Toggle color scheme"
|
||||||
|
>
|
||||||
|
{dark ? (
|
||||||
|
<Sun color="white" style={{ width: "70%", height: "70%" }} />
|
||||||
|
) : (
|
||||||
|
<Moon color="white" style={{ width: "70%", height: "70%" }} />
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon variant="subtle" size="lg" radius="xl" pos="relative">
|
||||||
|
<Bell color="white" style={{ width: "70%", height: "70%" }} />
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
color="red"
|
||||||
|
variant="filled"
|
||||||
|
style={{ position: "absolute", top: 0, right: 0 }}
|
||||||
|
radius={"xl"}
|
||||||
|
>
|
||||||
|
10
|
||||||
|
</Badge>
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon variant="subtle" size="lg" radius="xl">
|
||||||
|
<IconUserShield
|
||||||
|
color="white"
|
||||||
|
style={{ width: "70%", height: "70%" }}
|
||||||
|
onClick={() => navigate({ to: "/admin" })}
|
||||||
|
/>
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,16 +143,25 @@ const HelpPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="lg" py="xl">
|
<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 */}
|
{/* Statistics Section */}
|
||||||
<SimpleGrid cols={3} spacing="lg" mb="xl">
|
<SimpleGrid cols={3} spacing="lg" mb="xl">
|
||||||
{stats.map((stat, index) => (
|
{stats.map((stat) => (
|
||||||
<HelpCard
|
<HelpCard
|
||||||
key={index}
|
key={stat.label}
|
||||||
bg={dark ? "#141D34" : "white"}
|
bg={dark ? "#1E293B" : "white"}
|
||||||
p="lg"
|
p="lg"
|
||||||
style={{
|
style={{
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
borderColor: dark ? "#141D34" : "white",
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||||
}}
|
}}
|
||||||
h="100%"
|
h="100%"
|
||||||
>
|
>
|
||||||
@@ -172,16 +181,20 @@ const HelpPage = () => {
|
|||||||
{/* Panduan Memulai */}
|
{/* Panduan Memulai */}
|
||||||
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
||||||
<HelpCard
|
<HelpCard
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
style={{
|
||||||
bg={dark ? "#141D34" : "white"}
|
borderColor: dark ? "#334155" : "white",
|
||||||
icon={<IconBook size={24} />}
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||||
|
}}
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
icon={<IconBook size={24} color="white" />}
|
||||||
title="Panduan Memulai"
|
title="Panduan Memulai"
|
||||||
h="100%"
|
h="100%"
|
||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
{guideItems.map((item, index) => (
|
{guideItems.map((item) => (
|
||||||
<Box
|
<Box
|
||||||
key={index}
|
key={item.title}
|
||||||
py="sm"
|
py="sm"
|
||||||
style={{
|
style={{
|
||||||
borderBottom: "1px solid #eee",
|
borderBottom: "1px solid #eee",
|
||||||
@@ -202,16 +215,20 @@ const HelpPage = () => {
|
|||||||
{/* Video Tutorial */}
|
{/* Video Tutorial */}
|
||||||
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
||||||
<HelpCard
|
<HelpCard
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
style={{
|
||||||
bg={dark ? "#141D34" : "white"}
|
borderColor: dark ? "#334155" : "white",
|
||||||
icon={<IconVideo size={24} />}
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||||
|
}}
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
icon={<IconVideo size={24} color="white" />}
|
||||||
title="Video Tutorial"
|
title="Video Tutorial"
|
||||||
h="100%"
|
h="100%"
|
||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
{videoItems.map((item, index) => (
|
{videoItems.map((item) => (
|
||||||
<Box
|
<Box
|
||||||
key={index}
|
key={item.title}
|
||||||
py="sm"
|
py="sm"
|
||||||
style={{
|
style={{
|
||||||
borderBottom: "1px solid #eee",
|
borderBottom: "1px solid #eee",
|
||||||
@@ -232,20 +249,24 @@ const HelpPage = () => {
|
|||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
||||||
<HelpCard
|
<HelpCard
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
style={{
|
||||||
bg={dark ? "#141D34" : "white"}
|
borderColor: dark ? "#334155" : "white",
|
||||||
icon={<IconHelpCircle size={24} />}
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||||
|
}}
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
icon={<IconHelpCircle size={24} color="white" />}
|
||||||
title="FAQ"
|
title="FAQ"
|
||||||
h="100%"
|
h="100%"
|
||||||
>
|
>
|
||||||
<Accordion variant="separated">
|
<Accordion variant="separated">
|
||||||
{faqItems.map((item, index) => (
|
{faqItems.map((item) => (
|
||||||
<Accordion.Item
|
<Accordion.Item
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: dark ? "#263852ff" : "#F1F5F9",
|
backgroundColor: dark ? "#263852ff" : "#F1F5F9",
|
||||||
}}
|
}}
|
||||||
key={index}
|
key={item.question}
|
||||||
value={`faq-${index}`}
|
value={item.question}
|
||||||
>
|
>
|
||||||
<Accordion.Control>{item.question}</Accordion.Control>
|
<Accordion.Control>{item.question}</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
@@ -264,9 +285,13 @@ const HelpPage = () => {
|
|||||||
{/* Hubungi Support */}
|
{/* Hubungi Support */}
|
||||||
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
||||||
<HelpCard
|
<HelpCard
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
style={{
|
||||||
bg={dark ? "#141D34" : "white"}
|
borderColor: dark ? "#334155" : "white",
|
||||||
icon={<IconHeadphones size={24} />}
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||||
|
}}
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
icon={<IconHeadphones size={24} color="white" />}
|
||||||
title="Hubungi Support"
|
title="Hubungi Support"
|
||||||
h="100%"
|
h="100%"
|
||||||
>
|
>
|
||||||
@@ -299,16 +324,20 @@ const HelpPage = () => {
|
|||||||
{/* Dokumentasi */}
|
{/* Dokumentasi */}
|
||||||
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
||||||
<HelpCard
|
<HelpCard
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
style={{
|
||||||
bg={dark ? "#141D34" : "white"}
|
borderColor: dark ? "#334155" : "white",
|
||||||
icon={<IconFileText size={24} />}
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||||
|
}}
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
icon={<IconFileText size={24} color="white" />}
|
||||||
title="Dokumentasi"
|
title="Dokumentasi"
|
||||||
h="100%"
|
h="100%"
|
||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
{documentationItems.map((item, index) => (
|
{documentationItems.map((item) => (
|
||||||
<Box
|
<Box
|
||||||
key={index}
|
key={item.title}
|
||||||
py="sm"
|
py="sm"
|
||||||
style={{
|
style={{
|
||||||
borderBottom: "1px solid #eee",
|
borderBottom: "1px solid #eee",
|
||||||
@@ -331,9 +360,13 @@ const HelpPage = () => {
|
|||||||
{/* Jenna - Virtual Assistant */}
|
{/* Jenna - Virtual Assistant */}
|
||||||
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
||||||
<HelpCard
|
<HelpCard
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
style={{
|
||||||
bg={dark ? "#141D34" : "white"}
|
borderColor: dark ? "#334155" : "white",
|
||||||
icon={<IconMessage size={24} />}
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||||
|
}}
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
icon={<IconMessage size={24} color="white" />}
|
||||||
title="Jenna - Virtual Assistant"
|
title="Jenna - Virtual Assistant"
|
||||||
h="100%"
|
h="100%"
|
||||||
>
|
>
|
||||||
@@ -401,6 +434,7 @@ const HelpPage = () => {
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleSendMessage}
|
onClick={handleSendMessage}
|
||||||
disabled={isLoading || inputValue.trim() === ""}
|
disabled={isLoading || inputValue.trim() === ""}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,123 +1,78 @@
|
|||||||
import { BarChart } from "@mantine/charts";
|
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Card,
|
Card,
|
||||||
Grid,
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Progress,
|
Progress,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
useMantineColorScheme,
|
useMantineColorScheme,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import React from "react";
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
MessageCircle,
|
||||||
|
TrendingUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
// Sample Data
|
// KPI Data
|
||||||
const kpiData = [
|
const kpiData = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
title: "Interaksi Hari Ini",
|
title: "Interaksi Hari Ini",
|
||||||
value: "61",
|
value: "61",
|
||||||
delta: "+15% dari kemarin",
|
subtitle: "+15% dari kemarin",
|
||||||
deltaType: "positive",
|
trend: "positive",
|
||||||
icon: (
|
icon: MessageCircle,
|
||||||
<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,
|
id: 2,
|
||||||
title: "Jawaban Otomatis",
|
title: "Jawaban Otomatis",
|
||||||
value: "87%",
|
value: "87%",
|
||||||
sub: "53 dari 61 interaksi",
|
subtitle: "53 dari 61 interaksi",
|
||||||
icon: (
|
icon: CheckCircle,
|
||||||
<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,
|
id: 3,
|
||||||
title: "Belum Ditindak",
|
title: "Belum Ditindak",
|
||||||
value: "8",
|
value: "8",
|
||||||
sub: "Perlu respon manual",
|
subtitle: "Perlu respon manual",
|
||||||
deltaType: "negative",
|
icon: AlertTriangle,
|
||||||
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,
|
id: 4,
|
||||||
title: "Waktu Respon",
|
title: "Waktu Respon",
|
||||||
value: "2.3 sec",
|
value: "2.3 sec",
|
||||||
sub: "Rata-rata",
|
subtitle: "Rata-rata",
|
||||||
icon: (
|
icon: Clock,
|
||||||
<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 = [
|
const chartData = [
|
||||||
{ day: "Sen", total: 100 },
|
{ day: "Sen", total: 45 },
|
||||||
{ day: "Sel", total: 120 },
|
{ day: "Sel", total: 62 },
|
||||||
{ day: "Rab", total: 90 },
|
{ day: "Rab", total: 38 },
|
||||||
{ day: "Kam", total: 150 },
|
{ day: "Kam", total: 75 },
|
||||||
{ day: "Jum", total: 110 },
|
{ day: "Jum", total: 58 },
|
||||||
{ day: "Sab", total: 80 },
|
{ day: "Sab", total: 32 },
|
||||||
{ day: "Min", total: 130 },
|
{ day: "Min", total: 51 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Top Topics Data
|
||||||
const topTopics = [
|
const topTopics = [
|
||||||
{ topic: "Cara mengurus KTP", count: 89 },
|
{ topic: "Cara mengurus KTP", count: 89 },
|
||||||
{ topic: "Syarat Kartu Keluarga", count: 76 },
|
{ topic: "Syarat Kartu Keluarga", count: 76 },
|
||||||
@@ -126,6 +81,7 @@ const topTopics = [
|
|||||||
{ topic: "Info program bansos", count: 48 },
|
{ topic: "Info program bansos", count: 48 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Busy Hours Data
|
||||||
const busyHours = [
|
const busyHours = [
|
||||||
{ period: "Pagi (08–12)", percentage: 30 },
|
{ period: "Pagi (08–12)", percentage: 30 },
|
||||||
{ period: "Siang (12–16)", percentage: 40 },
|
{ period: "Siang (12–16)", percentage: 40 },
|
||||||
@@ -138,146 +94,206 @@ const JennaAnalytic = () => {
|
|||||||
const dark = colorScheme === "dark";
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="space-y-6">
|
<Stack gap="lg">
|
||||||
<Stack gap="xl">
|
{/* TOP SECTION - 4 STAT CARDS */}
|
||||||
{/* KPI Cards */}
|
<Grid gutter="md">
|
||||||
<Grid gutter="lg">
|
{kpiData.map((item) => (
|
||||||
{kpiData.map((kpi) => (
|
<Grid.Col key={item.id} span={{ base: 12, sm: 6, lg: 3 }}>
|
||||||
<Grid.Col key={kpi.id} span={{ base: 12, md: 6, lg: 3 }}>
|
|
||||||
<Card
|
|
||||||
p="md"
|
|
||||||
radius="md"
|
|
||||||
withBorder
|
|
||||||
bg={dark ? "#141D34" : "white"}
|
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" align="flex-start" mb="xs">
|
|
||||||
<Text size="sm" fw={500} c="dimmed">
|
|
||||||
{kpi.title}
|
|
||||||
</Text>
|
|
||||||
{React.cloneElement(kpi.icon, {
|
|
||||||
className: "h-6 w-6", // Keeping classes for now, can be replaced by Mantine Icon component if available or styled with sx prop
|
|
||||||
color: "var(--mantine-color-dimmed)", // Set color via prop
|
|
||||||
})}
|
|
||||||
</Group>
|
|
||||||
<Title order={3} fw={700} mt="xs">
|
|
||||||
{kpi.value}
|
|
||||||
</Title>
|
|
||||||
{kpi.delta && (
|
|
||||||
<Text
|
|
||||||
size="xs"
|
|
||||||
c={
|
|
||||||
kpi.deltaType === "positive"
|
|
||||||
? "green"
|
|
||||||
: kpi.deltaType === "negative"
|
|
||||||
? "red"
|
|
||||||
: "dimmed"
|
|
||||||
}
|
|
||||||
mt={4}
|
|
||||||
>
|
|
||||||
{kpi.delta}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{kpi.sub && (
|
|
||||||
<Text size="xs" c="dimmed" mt={2}>
|
|
||||||
{kpi.sub}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</Grid.Col>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
p="md"
|
|
||||||
radius="md"
|
|
||||||
withBorder
|
|
||||||
bg={dark ? "#141D34" : "white"}
|
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
|
||||||
>
|
|
||||||
<Title order={3} fw={500} mb="md">
|
|
||||||
Interaksi Chatbot
|
|
||||||
</Title>
|
|
||||||
<BarChart
|
|
||||||
h={300}
|
|
||||||
data={chartData}
|
|
||||||
dataKey="day"
|
|
||||||
series={[{ name: "total", color: "blue" }]}
|
|
||||||
withLegend
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Charts and Lists Section */}
|
|
||||||
<Grid gutter="lg">
|
|
||||||
{/* Grafik Interaksi Chatbot (now Bar Chart) */}
|
|
||||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
|
||||||
<Card
|
<Card
|
||||||
p="md"
|
p="md"
|
||||||
radius="md"
|
radius="xl"
|
||||||
withBorder
|
withBorder
|
||||||
bg={dark ? "#141D34" : "white"}
|
bg={dark ? "#1E293B" : "white"}
|
||||||
style={{ borderColor: dark ? "#141D34" : "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%"
|
h="100%"
|
||||||
>
|
>
|
||||||
<Title order={3} fw={500} mb="md">
|
<Group justify="space-between" align="flex-start" w="100%">
|
||||||
Jam Tersibuk
|
<Stack gap={2}>
|
||||||
</Title>
|
<Text size="sm" c="dimmed">
|
||||||
<Stack gap="sm">
|
{item.title}
|
||||||
{busyHours.map((item, index) => (
|
</Text>
|
||||||
<Box key={index}>
|
<Text size="xl" fw={700} c={dark ? "white" : "gray.9"}>
|
||||||
<Text size="sm">{item.period}</Text>
|
{item.value}
|
||||||
<Group align="center">
|
</Text>
|
||||||
<Progress value={item.percentage} flex={1} />
|
<Group gap={4} align="flex-start">
|
||||||
<Text size="sm" fw={500}>
|
{item.trend === "positive" && (
|
||||||
{item.percentage}%
|
<TrendingUp size={14} color="#22C55E" />
|
||||||
</Text>
|
)}
|
||||||
</Group>
|
<Text
|
||||||
</Box>
|
size="xs"
|
||||||
))}
|
c={
|
||||||
</Stack>
|
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>
|
</Card>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
{/* Topik Pertanyaan Terbanyak & Jam Tersibuk */}
|
{/* MAIN CHART - INTERAKSI CHATBOT */}
|
||||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
<Card
|
||||||
<Stack gap="lg">
|
p="md"
|
||||||
{/* Topik Pertanyaan Terbanyak */}
|
radius="xl"
|
||||||
<Card
|
withBorder
|
||||||
p="md"
|
bg={dark ? "#1E293B" : "white"}
|
||||||
radius="md"
|
style={{
|
||||||
withBorder
|
borderColor: dark ? "#334155" : "white",
|
||||||
bg={dark ? "#141D34" : "white"}
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
}}
|
||||||
h="100%"
|
>
|
||||||
>
|
<Group justify="space-between" mb="md">
|
||||||
<Title order={3} fw={500} mb="md">
|
<Title order={4} c={dark ? "white" : "gray.9"}>
|
||||||
Topik Pertanyaan Terbanyak
|
Interaksi Chatbot
|
||||||
</Title>
|
</Title>
|
||||||
<Stack gap="xs">
|
</Group>
|
||||||
{topTopics.map((item, index) => (
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<Group
|
<BarChart data={chartData}>
|
||||||
key={index}
|
<CartesianGrid
|
||||||
justify="space-between"
|
strokeDasharray="3 3"
|
||||||
align="center"
|
vertical={false}
|
||||||
p="xs"
|
stroke={dark ? "#334155" : "#e5e7eb"}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="day"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: dark ? "#1E293B" : "white",
|
||||||
|
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
|
||||||
|
cursor={{ fill: dark ? "#334155" : "#f3f4f6" }}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="total"
|
||||||
|
fill="#396aaaff"
|
||||||
|
radius={[8, 8, 0, 0]}
|
||||||
|
maxBarSize={60}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* BOTTOM SECTION - 2 COLUMNS */}
|
||||||
|
<Grid gutter="lg">
|
||||||
|
{/* LEFT: TOPIK PERTANYAAN TERBANYAK */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
}}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<Title order={4} c={dark ? "white" : "gray.9"} mb="md">
|
||||||
|
Topik Pertanyaan Terbanyak
|
||||||
|
</Title>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{topTopics.map((item) => (
|
||||||
|
<Box
|
||||||
|
key={item.topic}
|
||||||
|
p="sm"
|
||||||
|
bg={dark ? "#334155" : "#F1F5F9"}
|
||||||
|
style={{
|
||||||
|
transition: "background-color 0.15s ease",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" fw={500} c={dark ? "white" : "gray.9"}>
|
||||||
|
{item.topic}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color="darmasaba-blue"
|
||||||
|
radius="sm"
|
||||||
|
fw={600}
|
||||||
>
|
>
|
||||||
<Text size="sm" fw={500}>
|
{item.count}x
|
||||||
{item.topic}
|
</Badge>
|
||||||
</Text>
|
</Group>
|
||||||
<Badge variant="light" color="gray">
|
</Box>
|
||||||
{item.count}x
|
))}
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Jam Tersibuk */}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Grid.Col>
|
</Card>
|
||||||
</Grid>
|
</Grid.Col>
|
||||||
</Stack>
|
|
||||||
</Box>
|
{/* RIGHT: JAM TERSIBUK */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
}}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<Title order={4} c={dark ? "white" : "gray.9"} mb="md">
|
||||||
|
Jam Tersibuk
|
||||||
|
</Title>
|
||||||
|
<Stack gap="md">
|
||||||
|
{busyHours.map((item) => (
|
||||||
|
<Box key={item.period}>
|
||||||
|
<Group justify="space-between" mb={5}>
|
||||||
|
<Text size="sm" fw={500} c={dark ? "white" : "gray.9"}>
|
||||||
|
{item.period}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}>
|
||||||
|
{item.percentage}%
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress
|
||||||
|
value={item.percentage}
|
||||||
|
size="lg"
|
||||||
|
radius="xl"
|
||||||
|
color="#1E3A5F"
|
||||||
|
animated
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default JennaAnalytic;
|
export default JennaAnalytic;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
GridCol,
|
GridCol,
|
||||||
Group,
|
Group,
|
||||||
List,
|
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
@@ -16,11 +15,8 @@ import {
|
|||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconCamera,
|
IconCamera,
|
||||||
IconClock,
|
IconClock,
|
||||||
IconEye,
|
|
||||||
IconMapPin,
|
IconMapPin,
|
||||||
IconShieldLock,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
const KeamananPage = () => {
|
const KeamananPage = () => {
|
||||||
const { colorScheme } = useMantineColorScheme();
|
const { colorScheme } = useMantineColorScheme();
|
||||||
@@ -118,138 +114,144 @@ const KeamananPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<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">
|
<Grid gutter="md">
|
||||||
{kpiData.map((kpi, index) => (
|
{/* Peta Keamanan CCTV */}
|
||||||
<GridCol key={index} span={{ base: 12, sm: 6, md: 6 }}>
|
<GridCol span={{ base: 12, lg: 6 }}>
|
||||||
|
<Stack gap={"xs"}>
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<Grid gutter="md">
|
||||||
|
{kpiData.map((kpi) => (
|
||||||
|
<GridCol key={kpi.title} span={{ base: 12, sm: 6, md: 6 }}>
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||||
|
}}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
||||||
|
{kpi.subtitle}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs" align="center">
|
||||||
|
<Text
|
||||||
|
size="xl"
|
||||||
|
fw={700}
|
||||||
|
c={dark ? "dark.0" : "black"}
|
||||||
|
>
|
||||||
|
{kpi.value}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c={dark ? "white" : "dimmed"}>
|
||||||
|
{kpi.title}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color={kpi.color}
|
||||||
|
size="xl"
|
||||||
|
radius="xl"
|
||||||
|
>
|
||||||
|
{kpi.icon}
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</GridCol>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
<Card
|
<Card
|
||||||
p="md"
|
p="md"
|
||||||
radius="md"
|
radius="md"
|
||||||
withBorder
|
withBorder
|
||||||
bg={dark ? "#141D34" : "white"}
|
bg={dark ? "#1E293B" : "white"}
|
||||||
style={{ borderColor: dark ? "#141D34" : "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%"
|
h="100%"
|
||||||
>
|
>
|
||||||
<Group justify="space-between" align="center">
|
<Title order={3} mb="md" c={dark ? "dark.0" : "black"}>
|
||||||
<Stack gap={0}>
|
Peta Keamanan CCTV
|
||||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
|
||||||
{kpi.subtitle}
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs" align="center">
|
|
||||||
<Text size="xl" fw={700} c={dark ? "dark.0" : "black"}>
|
|
||||||
{kpi.value}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
|
||||||
{kpi.title}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
<ThemeIcon
|
|
||||||
variant="light"
|
|
||||||
color={kpi.color}
|
|
||||||
size="xl"
|
|
||||||
radius="xl"
|
|
||||||
>
|
|
||||||
{kpi.icon}
|
|
||||||
</ThemeIcon>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
</GridCol>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid gutter="md">
|
|
||||||
{/* Peta Keamanan CCTV */}
|
|
||||||
<GridCol span={{ base: 12, lg: 6 }}>
|
|
||||||
<Card
|
|
||||||
p="md"
|
|
||||||
radius="md"
|
|
||||||
withBorder
|
|
||||||
bg={dark ? "#141D34" : "white"}
|
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
|
||||||
h="100%"
|
|
||||||
>
|
|
||||||
<Title order={3} mb="md" c={dark ? "dark.0" : "black"}>
|
|
||||||
Peta Keamanan CCTV
|
|
||||||
</Title>
|
|
||||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"} mb="md">
|
|
||||||
Titik Lokasi CCTV
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Placeholder for map */}
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
backgroundColor: dark ? "#2d3748" : "#e2e8f0",
|
|
||||||
borderRadius: "8px",
|
|
||||||
height: "400px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack align="center">
|
|
||||||
<IconMapPin
|
|
||||||
size={48}
|
|
||||||
stroke={1.5}
|
|
||||||
color={dark ? "#94a3b8" : "#64748b"}
|
|
||||||
/>
|
|
||||||
<Text c={dark ? "dark.3" : "dimmed"}>Peta Lokasi CCTV</Text>
|
|
||||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"} ta="center">
|
|
||||||
Integrasi dengan Google Maps atau Mapbox akan ditampilkan di
|
|
||||||
sini
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* CCTV Locations List */}
|
|
||||||
<Stack mt="md" gap="sm">
|
|
||||||
<Title order={4} c={dark ? "dark.0" : "black"}>
|
|
||||||
Daftar CCTV
|
|
||||||
</Title>
|
</Title>
|
||||||
{cctvLocations.map((cctv, index) => (
|
<Text size="sm" c={dark ? "white" : "dimmed"} mb="md">
|
||||||
<Card
|
Titik Lokasi CCTV
|
||||||
key={index}
|
</Text>
|
||||||
p="md"
|
|
||||||
radius="md"
|
{/* Placeholder for map */}
|
||||||
withBorder
|
<Box
|
||||||
bg={dark ? "#263852ff" : "#F1F5F9"}
|
style={{
|
||||||
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
|
backgroundColor: dark ? "#2d3748" : "#e2e8f0",
|
||||||
>
|
borderRadius: "8px",
|
||||||
<Group justify="space-between">
|
height: "400px",
|
||||||
<Stack gap={0}>
|
display: "flex",
|
||||||
<Group gap="xs">
|
alignItems: "center",
|
||||||
<Text fw={500} c={dark ? "dark.0" : "black"}>
|
justifyContent: "center",
|
||||||
{cctv.id}
|
}}
|
||||||
|
>
|
||||||
|
<Stack align="center">
|
||||||
|
<IconMapPin
|
||||||
|
size={48}
|
||||||
|
stroke={1.5}
|
||||||
|
color={dark ? "#94a3b8" : "#64748b"}
|
||||||
|
/>
|
||||||
|
<Text c={dark ? "dark.3" : "dimmed"}>Peta Lokasi CCTV</Text>
|
||||||
|
<Text size="sm" c={dark ? "dark.3" : "dimmed"} ta="center">
|
||||||
|
Integrasi dengan Google Maps atau Mapbox akan ditampilkan di
|
||||||
|
sini
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* CCTV Locations List */}
|
||||||
|
<Stack mt="md" gap="sm">
|
||||||
|
<Title order={4} c={dark ? "dark.0" : "black"}>
|
||||||
|
Daftar CCTV
|
||||||
|
</Title>
|
||||||
|
{cctvLocations.map((cctv) => (
|
||||||
|
<Card
|
||||||
|
key={cctv.id}
|
||||||
|
p="md"
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#263852ff" : "#F1F5F9"}
|
||||||
|
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text fw={500} c={dark ? "dark.0" : "black"}>
|
||||||
|
{cctv.id}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
variant="dot"
|
||||||
|
color={cctv.status === "active" ? "green" : "gray"}
|
||||||
|
>
|
||||||
|
{cctv.status === "active" ? "Online" : "Offline"}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" c={dark ? "white" : "dimmed"}>
|
||||||
|
{cctv.location}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconClock size={16} stroke={1.5} />
|
||||||
|
<Text size="sm" c={dark ? "white" : "dimmed"}>
|
||||||
|
{cctv.lastSeen}
|
||||||
</Text>
|
</Text>
|
||||||
<Badge
|
|
||||||
variant="dot"
|
|
||||||
color={cctv.status === "active" ? "green" : "gray"}
|
|
||||||
>
|
|
||||||
{cctv.status === "active" ? "Online" : "Offline"}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
</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>
|
||||||
</Group>
|
</Card>
|
||||||
</Card>
|
))}
|
||||||
))}
|
</Stack>
|
||||||
</Stack>
|
</Card>
|
||||||
</Card>
|
</Stack>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
|
|
||||||
{/* Daftar Laporan Keamanan */}
|
{/* Daftar Laporan Keamanan */}
|
||||||
@@ -258,18 +260,18 @@ const KeamananPage = () => {
|
|||||||
p="md"
|
p="md"
|
||||||
radius="md"
|
radius="md"
|
||||||
withBorder
|
withBorder
|
||||||
bg={dark ? "#141D34" : "white"}
|
bg={dark ? "#1E293B" : "white"}
|
||||||
style={{ borderColor: dark ? "#141D34" : "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%"
|
h="100%"
|
||||||
>
|
>
|
||||||
<Title order={3} mb="md" c={dark ? "dark.0" : "black"}>
|
|
||||||
Laporan Keamanan Lingkungan
|
|
||||||
</Title>
|
|
||||||
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{securityReports.map((report, index) => (
|
{securityReports.map((report) => (
|
||||||
<Card
|
<Card
|
||||||
key={index}
|
key={report.id}
|
||||||
p="md"
|
p="md"
|
||||||
radius="md"
|
radius="md"
|
||||||
withBorder
|
withBorder
|
||||||
@@ -297,19 +299,19 @@ const KeamananPage = () => {
|
|||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<IconMapPin size={16} stroke={1.5} />
|
<IconMapPin size={16} stroke={1.5} />
|
||||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
<Text size="sm" c={dark ? "white" : "dimmed"}>
|
||||||
{report.location}
|
{report.location}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<IconClock size={16} stroke={1.5} />
|
<IconClock size={16} stroke={1.5} />
|
||||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
|
<Text size="sm" c={dark ? "white" : "dimmed"}>
|
||||||
{report.reportedAt}
|
{report.reportedAt}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Text size="sm" c={dark ? "dark.3" : "dimmed"} mt="sm">
|
<Text size="sm" c={dark ? "white" : "dimmed"} mt="sm">
|
||||||
{report.date}
|
{report.date}
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,73 +1,69 @@
|
|||||||
import { BarChart } from "@mantine/charts";
|
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Card,
|
Card,
|
||||||
Grid,
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Progress,
|
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
useMantineColorScheme,
|
useMantineColorScheme,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconCurrency,
|
CheckCircle,
|
||||||
IconTrendingDown,
|
Coins,
|
||||||
IconTrendingUp,
|
PieChart as PieChartIcon,
|
||||||
} from "@tabler/icons-react";
|
Receipt,
|
||||||
import React from "react";
|
TrendingDown,
|
||||||
|
TrendingUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
// Sample Data
|
// KPI Data
|
||||||
const kpiData = [
|
const kpiData = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
title: "Total APBDes",
|
title: "Total APBDes",
|
||||||
value: "Rp 5.2M",
|
value: "Rp 5.2M",
|
||||||
sub: "Tahun 2025",
|
subtitle: "Tahun 2025",
|
||||||
icon: <IconCurrency className="h-6 w-6 text-muted-foreground" />,
|
icon: Coins,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
title: "Realisasi",
|
title: "Realisasi",
|
||||||
value: "68%",
|
value: "68%",
|
||||||
sub: "Rp 3.5M dari 5.2M",
|
subtitle: "Rp 3.5M dari 5.2M",
|
||||||
icon: (
|
icon: CheckCircle,
|
||||||
<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,
|
id: 3,
|
||||||
title: "Pemasukan",
|
title: "Pemasukan",
|
||||||
value: "Rp 580jt",
|
value: "Rp 580jt",
|
||||||
sub: "Bulan ini",
|
subtitle: "Bulan ini",
|
||||||
delta: "+8%",
|
trend: "+8%",
|
||||||
deltaType: "positive",
|
icon: TrendingUp,
|
||||||
icon: <IconTrendingUp className="h-6 w-6 text-muted-foreground" />,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
title: "Pengeluaran",
|
title: "Pengeluaran",
|
||||||
value: "Rp 520jt",
|
value: "Rp 520jt",
|
||||||
sub: "Bulan ini",
|
subtitle: "Bulan ini",
|
||||||
icon: <IconTrendingDown className="h-6 w-6 text-muted-foreground" />,
|
icon: TrendingDown,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Income & Expense Data
|
||||||
const incomeExpenseData = [
|
const incomeExpenseData = [
|
||||||
{ month: "Apr", income: 450, expense: 380 },
|
{ month: "Apr", income: 450, expense: 380 },
|
||||||
{ month: "Mei", income: 520, expense: 420 },
|
{ month: "Mei", income: 520, expense: 420 },
|
||||||
@@ -78,6 +74,7 @@ const incomeExpenseData = [
|
|||||||
{ month: "Okt", income: 580, expense: 520 },
|
{ month: "Okt", income: 580, expense: 520 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Sector Allocation Data
|
||||||
const allocationData = [
|
const allocationData = [
|
||||||
{ sector: "Pembangunan", amount: 1200 },
|
{ sector: "Pembangunan", amount: 1200 },
|
||||||
{ sector: "Kesehatan", amount: 800 },
|
{ sector: "Kesehatan", amount: 800 },
|
||||||
@@ -87,13 +84,7 @@ const allocationData = [
|
|||||||
{ sector: "Teknologi", amount: 300 },
|
{ sector: "Teknologi", amount: 300 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const assistanceFundData = [
|
// APBDes Report Data
|
||||||
{ 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 = {
|
const apbdReport = {
|
||||||
income: [
|
income: [
|
||||||
{ category: "Dana Desa", amount: 1800 },
|
{ category: "Dana Desa", amount: 1800 },
|
||||||
@@ -113,244 +104,410 @@ const apbdReport = {
|
|||||||
totalExpenses: 2155,
|
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 KeuanganAnggaran = () => {
|
||||||
const { colorScheme } = useMantineColorScheme();
|
const { colorScheme } = useMantineColorScheme();
|
||||||
const dark = colorScheme === "dark";
|
const dark = colorScheme === "dark";
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Stack gap="xl">
|
|
||||||
{/* KPI Cards */}
|
|
||||||
<Grid gutter="lg">
|
|
||||||
{kpiData.map((kpi) => (
|
|
||||||
<Grid.Col key={kpi.id} span={{ base: 12, md: 6, lg: 3 }}>
|
|
||||||
<Card
|
|
||||||
p="md"
|
|
||||||
radius="md"
|
|
||||||
withBorder
|
|
||||||
bg={dark ? "#141D34" : "white"}
|
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
|
||||||
h="100%"
|
|
||||||
>
|
|
||||||
<Group justify="space-between" align="flex-start" mb="xs">
|
|
||||||
<Text size="sm" fw={500} c="dimmed">
|
|
||||||
{kpi.title}
|
|
||||||
</Text>
|
|
||||||
{React.cloneElement(kpi.icon, {
|
|
||||||
className: "h-6 w-6",
|
|
||||||
color: "var(--mantine-color-dimmed)",
|
|
||||||
})}
|
|
||||||
</Group>
|
|
||||||
<Title order={3} fw={700} mt="xs">
|
|
||||||
{kpi.value}
|
|
||||||
</Title>
|
|
||||||
{kpi.delta && (
|
|
||||||
<Text
|
|
||||||
size="xs"
|
|
||||||
c={
|
|
||||||
kpi.deltaType === "positive"
|
|
||||||
? "green"
|
|
||||||
: kpi.deltaType === "negative"
|
|
||||||
? "red"
|
|
||||||
: "dimmed"
|
|
||||||
}
|
|
||||||
mt={4}
|
|
||||||
>
|
|
||||||
{kpi.delta}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{kpi.sub && (
|
|
||||||
<Text size="xs" c="dimmed" mt="auto">
|
|
||||||
{kpi.sub}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</Grid.Col>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Charts Section */}
|
return (
|
||||||
<Grid gutter="lg">
|
<Stack gap="lg">
|
||||||
{/* Grafik Pemasukan vs Pengeluaran */}
|
{/* TOP SECTION - 4 STAT CARDS */}
|
||||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
<Grid gutter="md">
|
||||||
|
{kpiData.map((item) => (
|
||||||
|
<Grid.Col key={item.id} span={{ base: 12, sm: 6, lg: 3 }}>
|
||||||
<Card
|
<Card
|
||||||
p="md"
|
p="md"
|
||||||
radius="md"
|
radius="xl"
|
||||||
withBorder
|
withBorder
|
||||||
bg={dark ? "#141D34" : "white"}
|
bg={dark ? "#1E293B" : "white"}
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||||
|
}}
|
||||||
|
h="100%"
|
||||||
>
|
>
|
||||||
<Title order={3} fw={500} mb="md">
|
<Group justify="space-between" align="flex-start" w="100%">
|
||||||
Pemasukan vs Pengeluaran
|
<Stack gap={2}>
|
||||||
</Title>
|
<Text size="sm" c="dimmed">
|
||||||
<BarChart
|
{item.title}
|
||||||
h={300}
|
</Text>
|
||||||
data={incomeExpenseData}
|
<Text size="xl" fw={700} c={dark ? "white" : "gray.9"}>
|
||||||
dataKey="month"
|
{item.value}
|
||||||
series={[
|
</Text>
|
||||||
{ name: "income", color: "green", label: "Pemasukan" },
|
<Group gap={4} align="flex-start">
|
||||||
{ name: "expense", color: "red", label: "Pengeluaran" },
|
{item.trend && <TrendingUp size={14} color="#22C55E" />}
|
||||||
]}
|
<Text
|
||||||
withLegend
|
size="xs"
|
||||||
/>
|
c={item.trend ? "green" : dark ? "gray.4" : "gray.5"}
|
||||||
|
>
|
||||||
|
{item.subtitle}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
<ThemeIcon
|
||||||
|
color="#1E3A5F"
|
||||||
|
variant="filled"
|
||||||
|
size="lg"
|
||||||
|
radius="xl"
|
||||||
|
>
|
||||||
|
<item.icon style={{ width: "60%", height: "60%" }} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
{/* Alokasi Anggaran Per Sektor */}
|
{/* MAIN CHART SECTION */}
|
||||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
<Grid gutter="lg">
|
||||||
<Card
|
{/* LEFT: PEMASUKAN DAN PENGELUARAN (70%) */}
|
||||||
p="md"
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
radius="md"
|
<Card
|
||||||
withBorder
|
p="md"
|
||||||
bg={dark ? "#141D34" : "white"}
|
radius="xl"
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
withBorder
|
||||||
>
|
bg={dark ? "#1E293B" : "white"}
|
||||||
<Title order={3} fw={500} mb="md">
|
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
|
Alokasi Anggaran Per Sektor
|
||||||
</Title>
|
</Title>
|
||||||
<BarChart
|
</Group>
|
||||||
h={300}
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
data={allocationData}
|
<BarChart data={allocationData} layout="vertical">
|
||||||
dataKey="sector"
|
<CartesianGrid
|
||||||
series={[
|
strokeDasharray="3 3"
|
||||||
{ name: "amount", color: "darmasaba-navy", label: "Jumlah" },
|
horizontal={false}
|
||||||
]}
|
stroke={dark ? "#334155" : "#e5e7eb"}
|
||||||
withLegend
|
/>
|
||||||
orientation="horizontal"
|
<XAxis
|
||||||
/>
|
type="number"
|
||||||
</Card>
|
axisLine={false}
|
||||||
</Grid.Col>
|
tickLine={false}
|
||||||
</Grid>
|
tick={{
|
||||||
|
fill: dark ? "#E2E8F0" : "#374151",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
tickFormatter={(value) => `${value}`}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="sector"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{
|
||||||
|
fill: dark ? "#E2E8F0" : "#374151",
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
width={100}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: dark ? "#1E293B" : "white",
|
||||||
|
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
formatter={(value: number | undefined) => [
|
||||||
|
`Rp ${value}jt`,
|
||||||
|
"Jumlah",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="amount"
|
||||||
|
fill="#396aaaff"
|
||||||
|
radius={[0, 8, 8, 0]}
|
||||||
|
maxBarSize={30}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Grid gutter="lg">
|
{/* BOTTOM SECTION */}
|
||||||
{/* Dana Bantuan & Hibah */}
|
<Grid gutter="lg">
|
||||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
{/* LEFT: LAPORAN APBDES */}
|
||||||
<Card
|
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||||
p="md"
|
<Card
|
||||||
radius="md"
|
p="md"
|
||||||
withBorder
|
radius="xl"
|
||||||
bg={dark ? "#141D34" : "white"}
|
withBorder
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
bg={dark ? "#1E293B" : "white"}
|
||||||
>
|
style={{
|
||||||
<Title order={3} fw={500} mb="md">
|
borderColor: dark ? "#334155" : "white",
|
||||||
Dana Bantuan & Hibah
|
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>
|
</Title>
|
||||||
<Stack gap="sm">
|
</Group>
|
||||||
{assistanceFundData.map((fund, index) => (
|
|
||||||
<Group
|
<Grid gutter="md">
|
||||||
key={index}
|
{/* Pendapatan */}
|
||||||
justify="space-between"
|
<Grid.Col span={6}>
|
||||||
align="center"
|
<Card p="sm" radius="lg" bg={dark ? "#064E3B" : "#DCFCE7"}>
|
||||||
p="sm"
|
<Title order={5} c="#22C55E" mb="sm">
|
||||||
style={{
|
Pendapatan
|
||||||
border: "1px solid var(--mantine-color-gray-3)",
|
</Title>
|
||||||
borderRadius: "var(--mantine-radius-sm)",
|
<Stack gap="xs">
|
||||||
}}
|
{apbdReport.income.map((item) => (
|
||||||
>
|
<Group key={item.category} justify="space-between">
|
||||||
|
<Text size="sm" c={dark ? "gray.3" : "gray.7"}>
|
||||||
|
{item.category}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} c="#22C55E">
|
||||||
|
Rp {item.amount.toLocaleString()}jt
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
mt="sm"
|
||||||
|
pt="sm"
|
||||||
|
style={{
|
||||||
|
borderTop: `1px solid ${dark ? "#065F46" : "#86EFAC"}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text fw={700} c="#22C55E">
|
||||||
|
Total:
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} c="#22C55E">
|
||||||
|
Rp {apbdReport.totalIncome.toLocaleString()}jt
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* Belanja */}
|
||||||
|
<Grid.Col span={6}>
|
||||||
|
<Card p="sm" radius="lg" bg={dark ? "#7F1D1D" : "#FEE2E2"}>
|
||||||
|
<Title order={5} c="#EF4444" mb="sm">
|
||||||
|
Belanja
|
||||||
|
</Title>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{apbdReport.expenses.map((item) => (
|
||||||
|
<Group key={item.category} justify="space-between">
|
||||||
|
<Text size="sm" c={dark ? "gray.3" : "gray.7"}>
|
||||||
|
{item.category}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} c="#EF4444">
|
||||||
|
Rp {item.amount.toLocaleString()}jt
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
mt="sm"
|
||||||
|
pt="sm"
|
||||||
|
style={{
|
||||||
|
borderTop: `1px solid ${dark ? "#991B1B" : "#FCA5A5"}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text fw={700} c="#EF4444">
|
||||||
|
Total:
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} c="#EF4444">
|
||||||
|
Rp {apbdReport.totalExpenses.toLocaleString()}jt
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Saldo */}
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
mt="md"
|
||||||
|
pt="md"
|
||||||
|
style={{
|
||||||
|
borderTop: `1px solid ${dark ? "#334155" : "#e5e7eb"}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text fw={700} c={dark ? "white" : "gray.9"}>
|
||||||
|
Saldo:
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
fw={700}
|
||||||
|
size="lg"
|
||||||
|
c={
|
||||||
|
apbdReport.totalIncome > apbdReport.totalExpenses
|
||||||
|
? "#22C55E"
|
||||||
|
: "#EF4444"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Rp{" "}
|
||||||
|
{(
|
||||||
|
apbdReport.totalIncome - apbdReport.totalExpenses
|
||||||
|
).toLocaleString()}
|
||||||
|
jt
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* RIGHT: DANA BANTUAN DAN HIBAH */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
}}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<Group gap="xs" mb="md">
|
||||||
|
<ThemeIcon color="#1E3A5F" variant="filled" size="sm" radius="sm">
|
||||||
|
<Coins size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Title order={4} c={dark ? "white" : "gray.9"}>
|
||||||
|
Dana Bantuan dan Hibah
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{assistanceFundData.map((fund) => (
|
||||||
|
<Card
|
||||||
|
key={fund.source}
|
||||||
|
p="sm"
|
||||||
|
radius="lg"
|
||||||
|
bg={dark ? "#334155" : "#F1F5F9"}
|
||||||
|
style={{
|
||||||
|
borderColor: "transparent",
|
||||||
|
transition: "background-color 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
<Box>
|
<Box>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}>
|
||||||
{fund.source}
|
{fund.source}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Rp {fund.amount.toLocaleString()}jt
|
Rp {fund.amount.toLocaleString()}jt
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Badge
|
<Badge
|
||||||
variant="light"
|
variant="light"
|
||||||
color={fund.status === "cair" ? "green" : "yellow"}
|
color={fund.status === "cair" ? "green" : "yellow"}
|
||||||
|
radius="sm"
|
||||||
|
fw={600}
|
||||||
>
|
>
|
||||||
{fund.status}
|
{fund.status === "cair" ? "Cair" : "Proses"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
</Card>
|
||||||
</Stack>
|
))}
|
||||||
</Card>
|
</Stack>
|
||||||
</Grid.Col>
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
{/* Laporan APBDes */}
|
</Grid>
|
||||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
</Stack>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,537 +1,133 @@
|
|||||||
import {
|
import { Card, Grid, Stack } from "@mantine/core";
|
||||||
ActionIcon,
|
import dayjs from "dayjs";
|
||||||
Box,
|
import { useEffect, useState } from "react";
|
||||||
Card,
|
import { apiClient } from "@/utils/api-client";
|
||||||
Divider,
|
import { ActivityCard } from "./kinerja-divisi/activity-card";
|
||||||
Grid,
|
import { ArchiveCard } from "./kinerja-divisi/archive-card";
|
||||||
GridCol,
|
import { DiscussionPanel } from "./kinerja-divisi/discussion-panel";
|
||||||
Group,
|
import { DivisionList } from "./kinerja-divisi/division-list";
|
||||||
List,
|
import { DocumentChart } from "./kinerja-divisi/document-chart";
|
||||||
Badge as MantineBadge,
|
import { EventCard } from "./kinerja-divisi/event-card";
|
||||||
Progress as MantineProgress,
|
import { ProgressChart } from "./kinerja-divisi/progress-chart";
|
||||||
Skeleton,
|
|
||||||
Stack,
|
// Data for arsip digital (Section 5)
|
||||||
Text,
|
const archiveData = [
|
||||||
ThemeIcon,
|
{ name: "Surat Keputusan" },
|
||||||
Title,
|
{ name: "Dokumentasi" },
|
||||||
useMantineColorScheme,
|
{ name: "Laporan Keuangan" },
|
||||||
} from "@mantine/core";
|
{ name: "Notulensi Rapat" },
|
||||||
import {
|
];
|
||||||
Bar,
|
|
||||||
BarChart,
|
interface Activity {
|
||||||
CartesianGrid,
|
id: string;
|
||||||
Cell,
|
title: string;
|
||||||
Pie,
|
createdAt: string;
|
||||||
PieChart,
|
progress: number;
|
||||||
ResponsiveContainer,
|
status: "SELESAI" | "BERJALAN" | "TERTUNDA";
|
||||||
Tooltip,
|
}
|
||||||
XAxis,
|
|
||||||
YAxis,
|
interface EventData {
|
||||||
} from "recharts";
|
id: string;
|
||||||
import { Button } from "@/components/ui/button";
|
title: string;
|
||||||
|
startDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
const KinerjaDivisi = () => {
|
const KinerjaDivisi = () => {
|
||||||
const { colorScheme } = useMantineColorScheme();
|
const [activities, setActivities] = useState<Activity[]>([]);
|
||||||
const dark = colorScheme === "dark";
|
const [todayEvents, setTodayEvents] = useState<EventData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Data for division progress chart
|
useEffect(() => {
|
||||||
const divisionProgressData = [
|
async function fetchData() {
|
||||||
{ name: "Sekretariat", selesai: 12, berjalan: 5, tertunda: 2 },
|
try {
|
||||||
{ name: "Keuangan", selesai: 8, berjalan: 7, tertunda: 1 },
|
const [activityRes, eventRes] = await Promise.all([
|
||||||
{ name: "Sosial", selesai: 10, berjalan: 3, tertunda: 4 },
|
apiClient.GET("/api/division/activities"),
|
||||||
{ name: "Humas", selesai: 6, berjalan: 9, tertunda: 3 },
|
apiClient.GET("/api/event/today"),
|
||||||
];
|
]);
|
||||||
|
|
||||||
// Division task summaries
|
if (activityRes.data?.data) {
|
||||||
const divisionTasks = [
|
setActivities(activityRes.data.data as Activity[]);
|
||||||
{
|
}
|
||||||
name: "Sekretariat",
|
if (eventRes.data?.data) {
|
||||||
tasks: [
|
setTodayEvents(eventRes.data.data as EventData[]);
|
||||||
{ title: "Laporan Bulanan", status: "selesai" },
|
}
|
||||||
{ title: "Arsip Dokumen", status: "berjalan" },
|
} catch (error) {
|
||||||
{ title: "Undangan Rapat", status: "tertunda" },
|
console.error("Failed to fetch kinerja divisi data", error);
|
||||||
],
|
} finally {
|
||||||
},
|
setLoading(false);
|
||||||
{
|
}
|
||||||
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" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Archive items
|
fetchData();
|
||||||
const archiveItems = [
|
}, []);
|
||||||
{ name: "Surat Keputusan", count: 12 },
|
|
||||||
{ name: "Laporan Keuangan", count: 8 },
|
|
||||||
{ name: "Dokumentasi", count: 24 },
|
|
||||||
{ name: "Notulensi Rapat", count: 15 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Activity progress
|
// Format events for EventCard
|
||||||
const activityProgress = [
|
const formattedEvents = todayEvents.map((event) => ({
|
||||||
{
|
time: dayjs(event.startDate).format("HH:mm"),
|
||||||
name: "Pembangunan Jalan",
|
event: event.title,
|
||||||
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 (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{/* Grafik Progres Tugas per Divisi */}
|
{/* SECTION 1 — PROGRAM KEGIATAN */}
|
||||||
<Card
|
<Grid gutter="md">
|
||||||
p="md"
|
{activities.slice(0, 4).map((kegiatan) => (
|
||||||
radius="md"
|
<Grid.Col key={kegiatan.id} span={{ base: 12, md: 6, lg: 3 }}>
|
||||||
withBorder
|
<ActivityCard
|
||||||
bg={dark ? "#141D34" : "white"}
|
title={kegiatan.title}
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
date={dayjs(kegiatan.createdAt).format("D MMMM YYYY")}
|
||||||
>
|
progress={kegiatan.progress}
|
||||||
<Title order={4} mb="md" c={dark ? "white" : "darmasaba-navy"}>
|
status={
|
||||||
Grafik Progres Tugas per Divisi
|
kegiatan.status === "SELESAI"
|
||||||
</Title>
|
? "Selesai"
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
: kegiatan.status === "BERJALAN"
|
||||||
<BarChart data={divisionProgressData}>
|
? "Berjalan"
|
||||||
<CartesianGrid
|
: "Tertunda"
|
||||||
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
|
</Grid.Col>
|
||||||
dataKey="selesai"
|
))}
|
||||||
stackId="a"
|
{!loading && activities.length === 0 && (
|
||||||
fill="#10B981"
|
<Grid.Col span={12}>
|
||||||
name="Selesai"
|
<Card p="md" radius="xl" withBorder ta="center" c="dimmed">
|
||||||
radius={[4, 4, 0, 0]}
|
Tidak ada aktivitas terbaru
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="berjalan"
|
|
||||||
stackId="a"
|
|
||||||
fill="#3B82F6"
|
|
||||||
name="Berjalan"
|
|
||||||
radius={[4, 4, 0, 0]}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="tertunda"
|
|
||||||
stackId="a"
|
|
||||||
fill="#EF4444"
|
|
||||||
name="Tertunda"
|
|
||||||
radius={[4, 4, 0, 0]}
|
|
||||||
/>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Ringkasan Tugas per Divisi */}
|
|
||||||
<Grid gutter="md">
|
|
||||||
{divisionTasks.map((division, index) => (
|
|
||||||
<GridCol key={index} span={{ base: 12, md: 6, lg: 3 }}>
|
|
||||||
<Card
|
|
||||||
p="md"
|
|
||||||
radius="md"
|
|
||||||
withBorder
|
|
||||||
bg={dark ? "#141D34" : "white"}
|
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
|
||||||
h="100%"
|
|
||||||
>
|
|
||||||
<Title order={4} mb="sm" c={dark ? "white" : "darmasaba-navy"}>
|
|
||||||
{division.name}
|
|
||||||
</Title>
|
|
||||||
<Stack gap="sm">
|
|
||||||
{division.tasks.map((task, taskIndex) => (
|
|
||||||
<Box key={taskIndex}>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c={dark ? "white" : "darmasaba-navy"}>
|
|
||||||
{task.title}
|
|
||||||
</Text>
|
|
||||||
<MantineBadge
|
|
||||||
color={STATUS_COLORS[task.status] || "gray"}
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
{task.status}
|
|
||||||
</MantineBadge>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
</Card>
|
||||||
</GridCol>
|
</Grid.Col>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* SECTION 2 — GRID DASHBOARD (3 Columns) */}
|
||||||
|
<Grid gutter="lg">
|
||||||
|
{/* Left Column - Division List */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 3 }}>
|
||||||
|
<DivisionList />
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* Middle Column - Document Chart */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||||
|
<DocumentChart />
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* Right Column - Progress Chart */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
|
<ProgressChart />
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* SECTION 3 — DISCUSSION PANEL */}
|
||||||
|
<DiscussionPanel />
|
||||||
|
|
||||||
|
{/* SECTION 4 — ACARA HARI INI */}
|
||||||
|
<EventCard agendas={formattedEvents} />
|
||||||
|
|
||||||
|
{/* SECTION 5 — ARSIP DIGITAL PERANGKAT DESA */}
|
||||||
|
<Grid gutter="md">
|
||||||
|
{archiveData.map((item) => (
|
||||||
|
<Grid.Col key={item.name} span={{ base: 12, md: 6 }}>
|
||||||
|
<ArchiveCard item={item} />
|
||||||
|
</Grid.Col>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</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>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
100
src/components/kinerja-divisi/activity-card.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Progress,
|
||||||
|
Text,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
|
||||||
|
interface ActivityCardProps {
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
progress: number;
|
||||||
|
status: "Selesai" | "Berjalan" | "Tertunda";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityCard({
|
||||||
|
title,
|
||||||
|
date,
|
||||||
|
progress,
|
||||||
|
status,
|
||||||
|
}: ActivityCardProps) {
|
||||||
|
const getStatusColor = () => {
|
||||||
|
switch (status) {
|
||||||
|
case "Selesai":
|
||||||
|
return "#22C55E";
|
||||||
|
case "Berjalan":
|
||||||
|
return "#3B82F6";
|
||||||
|
case "Tertunda":
|
||||||
|
return "#EF4444";
|
||||||
|
default:
|
||||||
|
return "#9CA3AF";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
radius="xl"
|
||||||
|
p={0}
|
||||||
|
withBorder={false}
|
||||||
|
style={{
|
||||||
|
backgroundColor: dark ? "#334155" : "white",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
h={"100%"}
|
||||||
|
>
|
||||||
|
{/* 🔵 HEADER */}
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#1E3A5F",
|
||||||
|
padding: "16px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text c="white" fw={700} size="md">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* CONTENT */}
|
||||||
|
<Box p="md">
|
||||||
|
{/* PROGRESS */}
|
||||||
|
<Progress
|
||||||
|
value={progress}
|
||||||
|
radius="xl"
|
||||||
|
size="lg"
|
||||||
|
color="orange"
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
height: 16,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* FOOTER */}
|
||||||
|
<Group justify="space-between" mt="md">
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{date}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
backgroundColor: getStatusColor(),
|
||||||
|
color: "white",
|
||||||
|
padding: "4px 12px",
|
||||||
|
borderRadius: 999,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/kinerja-divisi/archive-card.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Card, Group, Text, useMantineColorScheme } from "@mantine/core";
|
||||||
|
import { FileText } from "lucide-react";
|
||||||
|
|
||||||
|
interface ArchiveItem {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArchiveCardProps {
|
||||||
|
item: ArchiveItem;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArchiveCard({ item, onClick }: ArchiveCardProps) {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||||
|
boxShadow: dark
|
||||||
|
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||||
|
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "transform 0.2s, box-shadow 0.2s",
|
||||||
|
}}
|
||||||
|
h="100%"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Group gap="md">
|
||||||
|
<FileText size={32} color={dark ? "#60A5FA" : "#3B82F6"} />
|
||||||
|
<Text size="sm" fw={500} c={dark ? "white" : "#1E3A5F"}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/components/kinerja-divisi/discussion-panel.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { id } from "date-fns/locale";
|
||||||
|
import { MessageCircle } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
|
||||||
|
interface DiscussionItem {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
sender: string;
|
||||||
|
date: string;
|
||||||
|
division: string | null;
|
||||||
|
isResolved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiscussionPanel() {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const [discussions, setDiscussions] = useState<DiscussionItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchDiscussions() {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.GET("/api/division/discussions");
|
||||||
|
if (res.data?.data) {
|
||||||
|
setDiscussions(res.data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch discussions", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchDiscussions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
try {
|
||||||
|
return format(new Date(dateString), "dd MMM yyyy", { locale: id });
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: dark
|
||||||
|
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||||
|
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
}}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<Group gap="xs" mb="md">
|
||||||
|
<MessageCircle size={20} color={dark ? "#E2E8F0" : "#1E3A5F"} />
|
||||||
|
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"}>
|
||||||
|
Diskusi
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{loading ? (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
) : discussions.length > 0 ? (
|
||||||
|
discussions.map((discussion) => (
|
||||||
|
<Card
|
||||||
|
key={discussion.id}
|
||||||
|
p="sm"
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#334155" : "#F1F5F9"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "#F1F5F9",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c={dark ? "white" : "#1E3A5F"}
|
||||||
|
fw={500}
|
||||||
|
mb="xs"
|
||||||
|
lineClamp={2}
|
||||||
|
>
|
||||||
|
{discussion.message}
|
||||||
|
</Text>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{discussion.sender}
|
||||||
|
{discussion.division && (
|
||||||
|
<Text span size="xs" c="dimmed" ml="xs">
|
||||||
|
• {discussion.division}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatDate(discussion.date)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
Tidak ada diskusi
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/components/kinerja-divisi/division-list.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
|
||||||
|
interface DivisionItem {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DivisionApiResponse {
|
||||||
|
name: string;
|
||||||
|
activityCount: number;
|
||||||
|
_count?: {
|
||||||
|
activities: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DivisionList() {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const [divisions, setDivisions] = useState<DivisionItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchDivisions() {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.GET("/api/division/");
|
||||||
|
if (data?.data) {
|
||||||
|
const mapped = (data.data as DivisionApiResponse[]).map((div) => ({
|
||||||
|
name: div.name,
|
||||||
|
count: div.activityCount || 0,
|
||||||
|
}));
|
||||||
|
setDivisions(mapped);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch divisions", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchDivisions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: dark
|
||||||
|
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||||
|
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
}}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"} mb="md">
|
||||||
|
Divisi Teraktif
|
||||||
|
</Text>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{loading ? (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : divisions.length > 0 ? (
|
||||||
|
divisions.map((division) => (
|
||||||
|
<Group
|
||||||
|
key={division.name}
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: dark ? "#334155" : "#F1F5F9",
|
||||||
|
transition: "background-color 0.2s",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" c={dark ? "white" : "#1E3A5F"}>
|
||||||
|
{division.name}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"}>
|
||||||
|
{division.count}
|
||||||
|
</Text>
|
||||||
|
<ChevronRight size={16} color={dark ? "#94A3B8" : "#64748B"} />
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text size="xs" c="dimmed" ta="center">
|
||||||
|
Tidak ada data divisi
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
src/components/kinerja-divisi/document-chart.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/kinerja-divisi/event-card.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Calendar } from "lucide-react";
|
||||||
|
|
||||||
|
interface AgendaItem {
|
||||||
|
time: string;
|
||||||
|
event: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventCardProps {
|
||||||
|
agendas?: AgendaItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventCard({ agendas = [] }: EventCardProps) {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: dark
|
||||||
|
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||||
|
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
}}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<Group gap="xs" mb="md">
|
||||||
|
<Calendar size={20} color={dark ? "#E2E8F0" : "#1E3A5F"} />
|
||||||
|
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"}>
|
||||||
|
Acara Hari Ini
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{agendas.length > 0 ? (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{agendas.map((agenda) => (
|
||||||
|
<Group
|
||||||
|
key={`${agenda.time}-${agenda.event}`}
|
||||||
|
align="flex-start"
|
||||||
|
gap="md"
|
||||||
|
>
|
||||||
|
<Box w={60}>
|
||||||
|
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"}>
|
||||||
|
{agenda.time}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Text size="sm" c={dark ? "white" : "#1E3A5F"}>
|
||||||
|
{agenda.event}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Text c="dimmed" ta="center" py="md">
|
||||||
|
Tidak ada acara hari ini
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/components/kinerja-divisi/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { ActivityCard } from "./activity-card";
|
||||||
|
export { ArchiveCard } from "./archive-card";
|
||||||
|
export { DiscussionPanel } from "./discussion-panel";
|
||||||
|
export { DivisionList } from "./division-list";
|
||||||
|
export { DocumentChart } from "./document-chart";
|
||||||
|
export { EventCard } from "./event-card";
|
||||||
|
export { ProgressChart } from "./progress-chart";
|
||||||
153
src/components/kinerja-divisi/progress-chart.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
|
||||||
|
interface ProgressData {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivityStats {
|
||||||
|
total: number;
|
||||||
|
counts: {
|
||||||
|
selesai: number;
|
||||||
|
berjalan: number;
|
||||||
|
tertunda: number;
|
||||||
|
dibatalkan: number;
|
||||||
|
};
|
||||||
|
percentages: {
|
||||||
|
selesai: number;
|
||||||
|
berjalan: number;
|
||||||
|
tertunda: number;
|
||||||
|
dibatalkan: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressChart() {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const [data, setData] = useState<ProgressData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchActivityStats() {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.GET("/api/division/activities/stats");
|
||||||
|
if (res.data?.data) {
|
||||||
|
const stats = res.data.data as ActivityStats;
|
||||||
|
const chartData: ProgressData[] = [
|
||||||
|
{
|
||||||
|
name: "Selesai",
|
||||||
|
value: stats.percentages.selesai,
|
||||||
|
color: "#22C55E",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Dikerjakan",
|
||||||
|
value: stats.percentages.berjalan,
|
||||||
|
color: "#F59E0B",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Segera Dikerjakan",
|
||||||
|
value: stats.percentages.tertunda,
|
||||||
|
color: "#3B82F6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Dibatalkan",
|
||||||
|
value: stats.percentages.dibatalkan,
|
||||||
|
color: "#EF4444",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setData(chartData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch activity stats", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchActivityStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: dark
|
||||||
|
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
|
||||||
|
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
}}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"} mb="md">
|
||||||
|
Progres Kegiatan
|
||||||
|
</Text>
|
||||||
|
{loading ? (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{data.map((entry) => (
|
||||||
|
<Cell key={`cell-${entry.name}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: dark ? "#1E293B" : "white",
|
||||||
|
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<Stack gap="xs" mt="md">
|
||||||
|
{data.map((item) => (
|
||||||
|
<Group key={item.name} justify="space-between">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Box
|
||||||
|
w={12}
|
||||||
|
h={12}
|
||||||
|
style={{ backgroundColor: item.color, borderRadius: 2 }}
|
||||||
|
/>
|
||||||
|
<Text size="sm" c={dark ? "white" : "gray.7"}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}>
|
||||||
|
{item.value.toFixed(2)}%
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/components/layout/main-layout.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { AppShell, Burger, Group, useMantineColorScheme } from "@mantine/core";
|
||||||
|
import type React from "react";
|
||||||
|
import { Header } from "@/components/header";
|
||||||
|
import { Sidebar } from "@/components/sidebar";
|
||||||
|
import { useSidebarFullscreen } from "@/hooks/use-sidebar-fullscreen";
|
||||||
|
|
||||||
|
interface MainLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MainLayout({ children }: MainLayoutProps) {
|
||||||
|
const {
|
||||||
|
opened,
|
||||||
|
toggleMobile,
|
||||||
|
sidebarCollapsed,
|
||||||
|
toggleSidebar,
|
||||||
|
handleMainClick,
|
||||||
|
} = useSidebarFullscreen();
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
|
||||||
|
const headerBgColor = colorScheme === "dark" ? "#11192D" : "#19355E";
|
||||||
|
const navbarBgColor = colorScheme === "dark" ? "#11192D" : "white";
|
||||||
|
const mainBgColor = colorScheme === "dark" ? "#11192D" : "#edf3f8ff";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
header={{ height: 60 }}
|
||||||
|
navbar={{
|
||||||
|
width: 300,
|
||||||
|
breakpoint: "sm",
|
||||||
|
collapsed: { mobile: !opened, desktop: sidebarCollapsed },
|
||||||
|
}}
|
||||||
|
padding="md"
|
||||||
|
>
|
||||||
|
<AppShell.Header bg={headerBgColor}>
|
||||||
|
<Group h="100%" px="md">
|
||||||
|
<Burger
|
||||||
|
opened={opened}
|
||||||
|
onClick={toggleMobile}
|
||||||
|
hiddenFrom="sm"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Header onSidebarToggle={toggleSidebar} />
|
||||||
|
</Group>
|
||||||
|
</AppShell.Header>
|
||||||
|
|
||||||
|
<AppShell.Navbar
|
||||||
|
p="md"
|
||||||
|
bg={navbarBgColor}
|
||||||
|
style={{ display: "flex", flexDirection: "column" }}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
</AppShell.Navbar>
|
||||||
|
|
||||||
|
<AppShell.Main
|
||||||
|
bg={mainBgColor}
|
||||||
|
onClick={handleMainClick}
|
||||||
|
style={{ cursor: sidebarCollapsed ? "default" : "pointer" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AppShell.Main>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,189 +1,78 @@
|
|||||||
import {
|
import { Box, Button, Group, Stack, Switch, Text, Title } from "@mantine/core";
|
||||||
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 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 (
|
return (
|
||||||
<Card
|
<Stack pr={"50%"} gap={"xl"}>
|
||||||
withBorder
|
<Box>
|
||||||
radius="md"
|
<Stack gap={"xs"}>
|
||||||
p="xl"
|
<Title order={2}>Manajemen Tim</Title>
|
||||||
bg={dark ? "#141D34" : "white"}
|
<Button bg={"#1E3A5F"} radius={"md"} c={"white"} fullWidth>
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
Undangan Anggota Baru
|
||||||
>
|
|
||||||
<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>
|
||||||
<Button>Undang Anggota</Button>
|
<Button bg={"#1E3A5F"} radius={"md"} c={"white"} fullWidth>
|
||||||
</Group>
|
Kelola Role & Permission
|
||||||
</Modal>
|
</Button>
|
||||||
|
<Group justify="space-between">
|
||||||
<Title order={2} mb="lg">
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
Akses & Tim
|
Daftar Anggota Teraktif
|
||||||
</Title>
|
</Text>
|
||||||
<Text color="dimmed" mb="xl">
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
Kelola akses dan anggota tim Anda
|
12 Anggota
|
||||||
</Text>
|
</Text>
|
||||||
|
</Group>
|
||||||
<Space h="lg" />
|
</Stack>
|
||||||
|
</Box>
|
||||||
<Group justify="space-between" mb="md">
|
<Box>
|
||||||
<Title order={4}>Anggota Tim</Title>
|
<Stack gap={"xs"}>
|
||||||
<Button
|
<Title order={2}>Hak Akses</Title>
|
||||||
leftSection={<IconUserPlus size={16} />}
|
<Group justify="space-between">
|
||||||
onClick={() => setOpened(true)}
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
>
|
Administrator
|
||||||
Tambah Anggota
|
</Text>
|
||||||
</Button>
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
</Group>
|
2 Orang
|
||||||
|
</Text>
|
||||||
<Table highlightOnHover>
|
</Group>
|
||||||
<Table.Thead>
|
<Group justify="space-between">
|
||||||
<Table.Tr>
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
<Table.Th>Nama</Table.Th>
|
Editor
|
||||||
<Table.Th>Email</Table.Th>
|
</Text>
|
||||||
<Table.Th>Peran</Table.Th>
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
<Table.Th>Status</Table.Th>
|
5 Orang
|
||||||
<Table.Th>Aksi</Table.Th>
|
</Text>
|
||||||
</Table.Tr>
|
</Group>
|
||||||
</Table.Thead>
|
<Group justify="space-between">
|
||||||
<Table.Tbody>
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
{teamMembers.map((member) => (
|
Viewer
|
||||||
<Table.Tr key={member.id}>
|
</Text>
|
||||||
<Table.Td>
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
<Group gap="sm">
|
5 Orang
|
||||||
<IconUser size={20} />
|
</Text>
|
||||||
<Text>{member.name}</Text>
|
</Group>
|
||||||
</Group>
|
</Stack>
|
||||||
</Table.Td>
|
</Box>
|
||||||
<Table.Td>{member.email}</Table.Td>
|
<Box>
|
||||||
<Table.Td>
|
<Stack gap={"xs"}>
|
||||||
<Text fw={500}>{member.role}</Text>
|
<Title order={2}>Kolaborasi</Title>
|
||||||
</Table.Td>
|
<Group mb="md" justify="space-between">
|
||||||
<Table.Td>
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
<Text c={member.status === "Aktif" ? "green" : "red"} fw={500}>
|
Izin Export Data
|
||||||
{member.status}
|
</Text>
|
||||||
</Text>
|
<Switch defaultChecked />
|
||||||
</Table.Td>
|
</Group>
|
||||||
<Table.Td>
|
<Group mb="md" justify="space-between">
|
||||||
<Group>
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
<ActionIcon variant="subtle" color="blue">
|
Require Approval Untuk Perubahan
|
||||||
<IconEdit size={16} />
|
</Text>
|
||||||
</ActionIcon>
|
<Switch defaultChecked />
|
||||||
<ActionIcon variant="subtle" color="red">
|
</Group>
|
||||||
<IconTrash size={16} />
|
</Stack>
|
||||||
</ActionIcon>
|
</Box>
|
||||||
</Group>
|
<Group justify="flex-start" mt="xl">
|
||||||
</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 variant="outline">Batal</Button>
|
||||||
<Button>Simpan Perubahan</Button>
|
<Button>Simpan Perubahan</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,89 +1,64 @@
|
|||||||
import {
|
import { Box, Button, Group, Stack, Switch, Text, Title } from "@mantine/core";
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Group,
|
|
||||||
PasswordInput,
|
|
||||||
Space,
|
|
||||||
Switch,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
useMantineColorScheme,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconInfoCircle, IconLock } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
const KeamananSettings = () => {
|
const KeamananSettings = () => {
|
||||||
const { colorScheme } = useMantineColorScheme();
|
|
||||||
const dark = colorScheme === "dark";
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Stack pr={"50%"} gap={"xl"}>
|
||||||
withBorder
|
<Box>
|
||||||
radius="md"
|
<Stack gap={"xs"}>
|
||||||
p="xl"
|
<Title order={2}>Autentikasi</Title>
|
||||||
bg={dark ? "#141D34" : "white"}
|
<Group mb="md" justify="space-between">
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
>
|
Two-Factor Authentication
|
||||||
<Title order={2} mb="lg">
|
</Text>
|
||||||
Pengaturan Keamanan
|
<Switch defaultChecked />
|
||||||
</Title>
|
</Group>
|
||||||
<Text color="dimmed" mb="xl">
|
<Group mb="md" justify="space-between">
|
||||||
Kelola keamanan akun Anda
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
</Text>
|
Biometrik Login
|
||||||
|
</Text>
|
||||||
<Space h="lg" />
|
<Switch defaultChecked />
|
||||||
|
</Group>
|
||||||
<PasswordInput
|
<Group mb="md" justify="space-between">
|
||||||
label="Kata Sandi Saat Ini"
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
placeholder="Masukkan kata sandi saat ini"
|
IP Whitelist
|
||||||
mb="md"
|
</Text>
|
||||||
/>
|
<Switch defaultChecked />
|
||||||
|
</Group>
|
||||||
<PasswordInput
|
</Stack>
|
||||||
label="Kata Sandi Baru"
|
</Box>
|
||||||
placeholder="Masukkan kata sandi baru"
|
<Box>
|
||||||
mb="md"
|
<Stack gap={"xs"}>
|
||||||
/>
|
<Title order={2}>Password</Title>
|
||||||
|
<Button bg={"#1E3A5F"} radius={"md"} c={"white"} fullWidth>
|
||||||
<PasswordInput
|
Ubah Password
|
||||||
label="Konfirmasi Kata Sandi Baru"
|
</Button>
|
||||||
placeholder="Konfirmasi kata sandi baru"
|
<Button bg={"#1E3A5F"} radius={"md"} c={"white"} fullWidth>
|
||||||
mb="md"
|
Riwayat Login
|
||||||
/>
|
</Button>
|
||||||
|
<Button bg={"#1E3A5F"} radius={"md"} c={"white"} fullWidth>
|
||||||
<Space h="md" />
|
Perangkat Terdaftar
|
||||||
|
</Button>
|
||||||
<Group mb="md">
|
</Stack>
|
||||||
<Switch label="Verifikasi Dua Langkah" />
|
</Box>
|
||||||
<Switch label="Login Otentikasi Aplikasi" />
|
<Box>
|
||||||
</Group>
|
<Stack gap={"xs"}>
|
||||||
|
<Title order={2}>Audit & Log</Title>
|
||||||
<Space h="md" />
|
<Group mb="md" justify="space-between">
|
||||||
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
<Alert
|
Log Aktivitas
|
||||||
icon={<IconLock size={16} />}
|
</Text>
|
||||||
title="Keamanan"
|
<Switch defaultChecked />
|
||||||
color="orange"
|
</Group>
|
||||||
mb="md"
|
<Button bg={"#1E3A5F"} radius={"md"} c={"white"} fullWidth>
|
||||||
>
|
Download Log
|
||||||
Gunakan kata sandi yang kuat dan unik. Hindari menggunakan kata sandi
|
</Button>
|
||||||
yang sama di banyak layanan.
|
</Stack>
|
||||||
</Alert>
|
</Box>
|
||||||
|
<Group justify="flex-start" mt="xl">
|
||||||
<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 variant="outline">Batal</Button>
|
||||||
<Button>Perbarui Kata Sandi</Button>
|
<Button>Simpan Perubahan</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,85 +1,114 @@
|
|||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Grid,
|
||||||
Checkbox,
|
GridCol,
|
||||||
Group,
|
Group,
|
||||||
Space,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
useMantineColorScheme,
|
useMantineColorScheme,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
const NotifikasiSettings = () => {
|
const NotifikasiSettings = () => {
|
||||||
const { colorScheme } = useMantineColorScheme();
|
const { colorScheme } = useMantineColorScheme();
|
||||||
const dark = colorScheme === "dark";
|
const _dark = colorScheme === "dark";
|
||||||
return (
|
return (
|
||||||
<Card
|
<Stack pr={"20%"} gap={"xs"}>
|
||||||
withBorder
|
<Grid gutter={{ base: 5, xs: "md", md: "xl", xl: 50 }}>
|
||||||
radius="md"
|
<GridCol span={6}>
|
||||||
p="xl"
|
<Stack gap={"xs"}>
|
||||||
bg={dark ? "#141D34" : "white"}
|
<Title order={3} mb="sm">
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
Metode Notifikasi
|
||||||
>
|
</Title>
|
||||||
<Title order={2} mb="lg">
|
<Group mb="md" justify="space-between">
|
||||||
Pengaturan Notifikasi
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
</Title>
|
Laporan Harian
|
||||||
<Text color="dimmed" mb="xl">
|
</Text>
|
||||||
Kelola preferensi notifikasi Anda
|
<Switch defaultChecked />
|
||||||
</Text>
|
</Group>
|
||||||
|
<Group mb="md" justify="space-between">
|
||||||
<Space h="lg" />
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
|
Alert Sistem
|
||||||
<Checkbox.Group defaultValue={["email", "push"]} mb="md">
|
</Text>
|
||||||
<Title order={4} mb="sm">
|
<Switch defaultChecked />
|
||||||
Metode Notifikasi
|
</Group>
|
||||||
</Title>
|
<Group mb="md" justify="space-between">
|
||||||
<Group>
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
<Checkbox value="email" label="Email" />
|
Update Keamanan
|
||||||
<Checkbox value="push" label="Notifikasi Push" />
|
</Text>
|
||||||
<Checkbox value="sms" label="SMS" />
|
<Switch defaultChecked />
|
||||||
</Group>
|
</Group>
|
||||||
</Checkbox.Group>
|
<Group mb="md" justify="space-between">
|
||||||
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
<Space h="md" />
|
Newsletter Bulanan
|
||||||
|
</Text>
|
||||||
<Group mb="md">
|
<Switch defaultChecked />
|
||||||
<Switch label="Notifikasi Email" defaultChecked />
|
</Group>
|
||||||
<Switch label="Notifikasi Push" defaultChecked />
|
</Stack>
|
||||||
</Group>
|
</GridCol>
|
||||||
|
<GridCol span={6}>
|
||||||
<Space h="md" />
|
<Stack gap={"xs"}>
|
||||||
|
<Title order={3} mb="sm">
|
||||||
<Title order={4} mb="sm">
|
Preferensi Alert
|
||||||
Jenis Notifikasi
|
</Title>
|
||||||
</Title>
|
<Group mb="md" justify="space-between">
|
||||||
<Group align="start">
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
<Switch label="Pengaduan Baru" defaultChecked />
|
Treshold Memori
|
||||||
<Switch label="Update Status Pengaduan" defaultChecked />
|
</Text>
|
||||||
<Switch label="Laporan Mingguan" />
|
<Switch defaultChecked />
|
||||||
<Switch label="Pemberitahuan Keamanan" defaultChecked />
|
</Group>
|
||||||
<Switch label="Aktivitas Akun" defaultChecked />
|
<Group mb="md" justify="space-between">
|
||||||
</Group>
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
|
Treshold CPU
|
||||||
<Space h="md" />
|
</Text>
|
||||||
|
<Switch defaultChecked />
|
||||||
<Alert
|
</Group>
|
||||||
icon={<IconInfoCircle size={16} />}
|
<Group mb="md" justify="space-between">
|
||||||
title="Tip"
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
color="blue"
|
Treshold Disk
|
||||||
mb="md"
|
</Text>
|
||||||
>
|
<Switch defaultChecked />
|
||||||
Anda dapat menyesuaikan frekuensi notifikasi mingguan sesuai kebutuhan
|
</Group>
|
||||||
Anda.
|
</Stack>
|
||||||
</Alert>
|
</GridCol>
|
||||||
|
<GridCol span={6}>
|
||||||
<Group justify="flex-end" mt="xl">
|
<Stack gap={"xs"}>
|
||||||
|
<Title order={3} mb="sm">
|
||||||
|
Notifikasi Push
|
||||||
|
</Title>
|
||||||
|
<Group mb="md" justify="space-between">
|
||||||
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
|
Alert Kritis
|
||||||
|
</Text>
|
||||||
|
<Switch defaultChecked />
|
||||||
|
</Group>
|
||||||
|
<Group mb="md" justify="space-between">
|
||||||
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
|
Aktivitas Tim
|
||||||
|
</Text>
|
||||||
|
<Switch defaultChecked />
|
||||||
|
</Group>
|
||||||
|
<Group mb="md" justify="space-between">
|
||||||
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
|
Komentar & Mention
|
||||||
|
</Text>
|
||||||
|
<Switch defaultChecked />
|
||||||
|
</Group>
|
||||||
|
<Group mb="md" justify="space-between">
|
||||||
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
|
Bunyi Notifikasi
|
||||||
|
</Text>
|
||||||
|
<Switch defaultChecked />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</GridCol>
|
||||||
|
</Grid>
|
||||||
|
<Group justify="flex-start" mt="xl">
|
||||||
<Button variant="outline">Batal</Button>
|
<Button variant="outline">Batal</Button>
|
||||||
<Button>Simpan Preferensi</Button>
|
<Button>Simpan Preferensi</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
176
src/components/pengaturan/sinkronisasi.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
Alert,
|
||||||
|
Loader,
|
||||||
|
Badge,
|
||||||
|
Divider,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconRefresh, IconCheck, IconAlertCircle, IconClock } from "@tabler/icons-react";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import "dayjs/locale/id";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.locale("id");
|
||||||
|
|
||||||
|
const SinkronisasiSettings = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [lastSync, setLastSync] = useState<string | null>(null);
|
||||||
|
const [status, setStatus] = useState<{
|
||||||
|
type: "success" | "error" | null;
|
||||||
|
message: string;
|
||||||
|
}>({ type: null, message: "" });
|
||||||
|
|
||||||
|
const fetchLastSync = async () => {
|
||||||
|
const { data } = await apiClient.GET("/api/noc/last-sync", {
|
||||||
|
params: { query: { idDesa: "desa1" } },
|
||||||
|
});
|
||||||
|
if (data?.lastSyncedAt) {
|
||||||
|
setLastSync(data.lastSyncedAt);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLastSync();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setStatus({ type: null, message: "" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error } = await apiClient.POST("/api/noc/sync");
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setStatus({
|
||||||
|
type: "error",
|
||||||
|
message: (error as any).error || "Gagal melakukan sinkronisasi",
|
||||||
|
});
|
||||||
|
} else if (data?.success) {
|
||||||
|
setStatus({
|
||||||
|
type: "success",
|
||||||
|
message: data.message || "Sinkronisasi berhasil dilakukan",
|
||||||
|
});
|
||||||
|
if (data.lastSyncedAt) {
|
||||||
|
setLastSync(data.lastSyncedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus({
|
||||||
|
type: "error",
|
||||||
|
message: "Terjadi kesalahan sistem saat sinkronisasi",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box pr={"50%"}>
|
||||||
|
<Title order={2} mb="lg">
|
||||||
|
Sinkronisasi Data NOC
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Text c="dimmed" mb="xl">
|
||||||
|
Gunakan fitur ini untuk memperbarui data dashboard dengan data terbaru dari
|
||||||
|
server Network Operation Center (NOC) darmasaba.muku.id.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Card withBorder padding="lg" radius="md" mb="xl">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group>
|
||||||
|
<IconClock size={20} color="gray" />
|
||||||
|
<Text fw={500}>Status Terakhir</Text>
|
||||||
|
</Group>
|
||||||
|
<Badge color={lastSync ? "green" : "gray"} variant="light">
|
||||||
|
{lastSync ? "Terkoneksi" : "Belum Pernah Sinkron"}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Waktu Sinkronisasi Terakhir:
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
{lastSync
|
||||||
|
? dayjs(lastSync).format("DD MMMM YYYY, HH:mm:ss")
|
||||||
|
: "Belum pernah dilakukan"}
|
||||||
|
</Text>
|
||||||
|
{lastSync && (
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
({dayjs(lastSync).fromNow()})
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{status.type && (
|
||||||
|
<Alert
|
||||||
|
icon={
|
||||||
|
status.type === "success" ? (
|
||||||
|
<IconCheck size={16} />
|
||||||
|
) : (
|
||||||
|
<IconAlertCircle size={16} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={status.type === "success" ? "Berhasil" : "Kesalahan"}
|
||||||
|
color={status.type === "success" ? "green" : "red"}
|
||||||
|
onClose={() => setStatus({ type: null, message: "" })}
|
||||||
|
withCloseButton
|
||||||
|
>
|
||||||
|
{status.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
leftSection={
|
||||||
|
loading ? <Loader size={16} color="white" /> : <IconRefresh size={16} />
|
||||||
|
}
|
||||||
|
onClick={handleSync}
|
||||||
|
loading={loading}
|
||||||
|
fullWidth
|
||||||
|
mt="md"
|
||||||
|
>
|
||||||
|
Sinkronkan Sekarang
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Title order={2} mb="lg">
|
||||||
|
Informasi API
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Card withBorder padding="md" radius="md" bg="gray.0">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group>
|
||||||
|
<Text fw={600} size="sm" w={100}>URL Sumber:</Text>
|
||||||
|
<Text size="sm" style={{ wordBreak: 'break-all' }}>https://darmasaba.muku.id/api/noc/</Text>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<Text fw={600} size="sm" w={100}>ID Desa:</Text>
|
||||||
|
<Text size="sm">desa1</Text>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<Text fw={600} size="sm" w={100}>Model Data:</Text>
|
||||||
|
<Badge size="xs" variant="outline">Divisi</Badge>
|
||||||
|
<Badge size="xs" variant="outline">Kegiatan</Badge>
|
||||||
|
<Badge size="xs" variant="outline">Event</Badge>
|
||||||
|
<Badge size="xs" variant="outline">Diskusi</Badge>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SinkronisasiSettings;
|
||||||
@@ -1,44 +1,12 @@
|
|||||||
import {
|
import { Box, Button, Group, Select, Switch, Text, Title } from "@mantine/core";
|
||||||
Alert,
|
import { DateInput } from "@mantine/dates";
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Group,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Switch,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Title,
|
|
||||||
useMantineColorScheme,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
const UmumSettings = () => {
|
const UmumSettings = () => {
|
||||||
const { colorScheme } = useMantineColorScheme();
|
|
||||||
const dark = colorScheme === "dark";
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Box pr={"50%"}>
|
||||||
withBorder
|
|
||||||
radius="md"
|
|
||||||
p="xl"
|
|
||||||
bg={dark ? "#141D34" : "white"}
|
|
||||||
style={{ borderColor: dark ? "#141D34" : "white" }}
|
|
||||||
>
|
|
||||||
<Title order={2} mb="lg">
|
<Title order={2} mb="lg">
|
||||||
Pengaturan Umum
|
Preferensi Tampilan
|
||||||
</Title>
|
</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
|
<Select
|
||||||
label="Bahasa Aplikasi"
|
label="Bahasa Aplikasi"
|
||||||
@@ -61,25 +29,53 @@ const UmumSettings = () => {
|
|||||||
mb="md"
|
mb="md"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group mb="md">
|
<DateInput label="Format Tanggal" mb={"xl"} />
|
||||||
<Switch label="Notifikasi Email" defaultChecked />
|
|
||||||
|
<Title order={2} mb="lg">
|
||||||
|
Dashboard
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Group mb="md" justify="space-between">
|
||||||
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
|
Refresh Otomatis
|
||||||
|
</Text>
|
||||||
|
<Switch defaultChecked />
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Alert
|
<Group mb="md" justify="space-between">
|
||||||
icon={<IconInfoCircle size={16} />}
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
title="Informasi"
|
Interval Refresh
|
||||||
color="blue"
|
</Text>
|
||||||
mb="md"
|
<Select
|
||||||
>
|
data={[
|
||||||
Beberapa pengaturan mungkin memerlukan restart aplikasi untuk diterapkan
|
{ value: "1", label: "30d" },
|
||||||
sepenuhnya.
|
{ value: "2", label: "60d" },
|
||||||
</Alert>
|
{ value: "3", label: "90d" },
|
||||||
|
]}
|
||||||
|
defaultValue="1"
|
||||||
|
w={90}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group mb="md" justify="space-between">
|
||||||
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
|
Tampilkan Grid
|
||||||
|
</Text>
|
||||||
|
<Switch defaultChecked />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group mb="md" justify="space-between">
|
||||||
|
<Text fw={"bold"} fz={"sm"}>
|
||||||
|
Animasi Transisi
|
||||||
|
</Text>
|
||||||
|
<Switch defaultChecked />
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Group justify="flex-end" mt="xl">
|
<Group justify="flex-end" mt="xl">
|
||||||
<Button variant="outline">Batal</Button>
|
<Button variant="outline">Batal</Button>
|
||||||
<Button>Simpan Perubahan</Button>
|
<Button>Simpan Perubahan</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
NavLink as MantineNavLink,
|
NavLink as MantineNavLink,
|
||||||
Stack,
|
Stack,
|
||||||
useMantineColorScheme
|
useMantineColorScheme,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||||
import { ChevronDown, ChevronUp, Search } from "lucide-react";
|
import { ChevronDown, ChevronUp, Search } from "lucide-react";
|
||||||
@@ -25,35 +25,30 @@ export function Sidebar({ className }: SidebarProps) {
|
|||||||
|
|
||||||
// State for settings submenu collapse
|
// State for settings submenu collapse
|
||||||
const [settingsOpen, setSettingsOpen] = useState(
|
const [settingsOpen, setSettingsOpen] = useState(
|
||||||
location.pathname.startsWith("/dashboard/pengaturan"),
|
location.pathname.startsWith("/pengaturan"),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Define menu items with their paths
|
// Define menu items with their paths
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ name: "Beranda", path: "/dashboard" },
|
{ name: "Beranda", path: "/" },
|
||||||
{ name: "Kinerja Divisi", path: "/dashboard/kinerja-divisi" },
|
{ name: "Kinerja Divisi", path: "/kinerja-divisi" },
|
||||||
{
|
{ name: "Pengaduan & Layanan Publik", path: "/pengaduan-layanan-publik" },
|
||||||
name: "Pengaduan & Layanan Publik",
|
{ name: "Jenna Analytic", path: "/jenna-analytic" },
|
||||||
path: "/dashboard/pengaduan-layanan-publik",
|
{ name: "Demografi & Kependudukan", path: "/demografi-pekerjaan" },
|
||||||
},
|
{ name: "Keuangan & Anggaran", path: "/keuangan-anggaran" },
|
||||||
{ name: "Jenna Analytic", path: "/dashboard/jenna-analytic" },
|
{ name: "Bumdes & UMKM Desa", path: "/bumdes" },
|
||||||
{
|
{ name: "Sosial", path: "/sosial" },
|
||||||
name: "Demografi & Kependudukan",
|
{ name: "Keamanan", path: "/keamanan" },
|
||||||
path: "/dashboard/demografi-pekerjaan",
|
{ name: "Bantuan", path: "/bantuan" },
|
||||||
},
|
|
||||||
{ 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
|
// Settings submenu items
|
||||||
const settingsItems = [
|
const settingsItems = [
|
||||||
{ name: "Umum", path: "/dashboard/pengaturan/umum" },
|
{ name: "Umum", path: "/pengaturan/umum" },
|
||||||
{ name: "Notifikasi", path: "/dashboard/pengaturan/notifikasi" },
|
{ name: "Notifikasi", path: "/pengaturan/notifikasi" },
|
||||||
{ name: "Keamanan", path: "/dashboard/pengaturan/keamanan" },
|
{ name: "Keamanan", path: "/pengaturan/keamanan" },
|
||||||
{ name: "Akses & Tim", path: "/dashboard/pengaturan/akses-dan-tim" },
|
{ name: "Akses & Tim", path: "/pengaturan/akses-dan-tim" },
|
||||||
|
{ name: "Sinkronisasi NOC", path: "/pengaturan/sinkronisasi" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Check if any settings submenu is active
|
// Check if any settings submenu is active
|
||||||
@@ -64,7 +59,7 @@ export function Sidebar({ className }: SidebarProps) {
|
|||||||
return (
|
return (
|
||||||
<Box className={className}>
|
<Box className={className}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Image src={"/logo-desa-plus.png"} width={201} height={84} />
|
<Image src={dark ? "/white.png" : "/light-mode.png"} alt="Logo" />
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<Box p="md">
|
<Box p="md">
|
||||||
@@ -83,11 +78,11 @@ export function Sidebar({ className }: SidebarProps) {
|
|||||||
|
|
||||||
{/* Menu Items */}
|
{/* Menu Items */}
|
||||||
<Stack gap={0} px="xs" style={{ overflowY: "auto" }}>
|
<Stack gap={0} px="xs" style={{ overflowY: "auto" }}>
|
||||||
{menuItems.map((item, index) => {
|
{menuItems.map((item) => {
|
||||||
const isActive = location.pathname === item.path;
|
const isActive = location.pathname === item.path;
|
||||||
return (
|
return (
|
||||||
<MantineNavLink
|
<MantineNavLink
|
||||||
key={index}
|
key={item.path}
|
||||||
onClick={() => navigate({ to: item.path })}
|
onClick={() => navigate({ to: item.path })}
|
||||||
label={item.name}
|
label={item.name}
|
||||||
active={isActive}
|
active={isActive}
|
||||||
@@ -149,11 +144,11 @@ export function Sidebar({ className }: SidebarProps) {
|
|||||||
ml="lg"
|
ml="lg"
|
||||||
style={{ overflowY: "auto", maxHeight: "200px" }}
|
style={{ overflowY: "auto", maxHeight: "200px" }}
|
||||||
>
|
>
|
||||||
{settingsItems.map((item, index) => {
|
{settingsItems.map((item) => {
|
||||||
const isActive = location.pathname === item.path;
|
const isActive = location.pathname === item.path;
|
||||||
return (
|
return (
|
||||||
<MantineNavLink
|
<MantineNavLink
|
||||||
key={index}
|
key={item.path}
|
||||||
onClick={() => navigate({ to: item.path })}
|
onClick={() => navigate({ to: item.path })}
|
||||||
label={item.name}
|
label={item.name}
|
||||||
active={isActive}
|
active={isActive}
|
||||||
@@ -184,6 +179,5 @@ export function Sidebar({ className }: SidebarProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,463 +1,45 @@
|
|||||||
import {
|
import { Grid, GridCol, Stack } from "@mantine/core";
|
||||||
Badge,
|
import { Beasiswa } from "./sosial/beasiswa";
|
||||||
Card,
|
import { EventCalendar } from "./sosial/event-calendar";
|
||||||
Grid,
|
import { HealthStats } from "./sosial/health-stats";
|
||||||
GridCol,
|
import { Pendidikan } from "./sosial/pendidikan";
|
||||||
Group,
|
import { PosyanduSchedule } from "./sosial/posyandu-schedule";
|
||||||
List,
|
import { SummaryCards } from "./sosial/summary-cards";
|
||||||
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 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 (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{/* Health Statistics Cards */}
|
{/* Top Summary Cards - 4 Grid */}
|
||||||
|
<SummaryCards />
|
||||||
|
|
||||||
|
{/* Second Row - 2 Column Grid */}
|
||||||
<Grid gutter="md">
|
<Grid gutter="md">
|
||||||
<GridCol span={{ base: 12, sm: 6, md: 3 }}>
|
{/* Left - Statistik Kesehatan */}
|
||||||
<Card
|
<GridCol span={{ base: 12, lg: 6 }}>
|
||||||
p="md"
|
<HealthStats />
|
||||||
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>
|
</GridCol>
|
||||||
|
|
||||||
<GridCol span={{ base: 12, sm: 6, md: 3 }}>
|
{/* Right - Jadwal Posyandu */}
|
||||||
<Card
|
<GridCol span={{ base: 12, lg: 6 }}>
|
||||||
p="md"
|
<PosyanduSchedule />
|
||||||
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>
|
</GridCol>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Health Progress Bars */}
|
{/* Third Row - 2 Column Grid */}
|
||||||
<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">
|
<Grid gutter="md">
|
||||||
{/* Jadwal Posyandu */}
|
{/* Left - Pendidikan */}
|
||||||
<GridCol span={{ base: 12, lg: 6 }}>
|
<GridCol span={{ base: 12, lg: 6 }}>
|
||||||
<Card
|
<Pendidikan />
|
||||||
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>
|
</GridCol>
|
||||||
|
|
||||||
{/* Pendidikan */}
|
{/* Right - Beasiswa Desa */}
|
||||||
<GridCol span={{ base: 12, lg: 6 }}>
|
<GridCol span={{ base: 12, lg: 6 }}>
|
||||||
<Card
|
<Beasiswa />
|
||||||
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>
|
</GridCol>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid gutter="md">
|
{/* Bottom Section - Event Budaya */}
|
||||||
{/* Beasiswa Desa */}
|
<EventCalendar />
|
||||||
<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>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
79
src/components/sosial/beasiswa.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconAward } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
interface ScholarshipData {
|
||||||
|
penerima: number;
|
||||||
|
dana: string;
|
||||||
|
tahunAjaran: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BeasiswaProps {
|
||||||
|
data?: ScholarshipData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Beasiswa = ({ data }: BeasiswaProps) => {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const defaultData: ScholarshipData = {
|
||||||
|
penerima: 45,
|
||||||
|
dana: "Rp 1.200.000.000",
|
||||||
|
tahunAjaran: "2025/2026",
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayData = data || defaultData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
shadow="sm"
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||||
|
}}
|
||||||
|
h={"100%"}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="sm" c={dark ? "white" : "dimmed"} fw={500}>
|
||||||
|
Beasiswa Desa
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" fw={700} c={dark ? "white" : "#1e3a5f"}>
|
||||||
|
Penerima: {displayData.penerima}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color="darmasaba-success"
|
||||||
|
size="xl"
|
||||||
|
radius="xl"
|
||||||
|
>
|
||||||
|
<IconAward size={24} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
<Stack gap="xs" mt="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text c={dark ? "white" : "dimmed"}>Dana Tersalurkan:</Text>
|
||||||
|
<Text fw={700} c={dark ? "white" : "#1e3a5f"}>
|
||||||
|
{displayData.dana}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text c={dark ? "white" : "dimmed"}>Tahun Ajaran:</Text>
|
||||||
|
<Text c={dark ? "white" : "#1e3a5f"}>{displayData.tahunAjaran}</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
104
src/components/sosial/event-calendar.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconCalendarEvent } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
interface EventItem {
|
||||||
|
id: string;
|
||||||
|
nama: string;
|
||||||
|
tanggal: string;
|
||||||
|
lokasi: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventCalendarProps {
|
||||||
|
data?: EventItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EventCalendar = ({ data }: EventCalendarProps) => {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const defaultData: EventItem[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
nama: "Hari Kesaktian Pancasila",
|
||||||
|
tanggal: "1 Oktober 2025",
|
||||||
|
lokasi: "Balai Desa",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
nama: "Festival Budaya Desa",
|
||||||
|
tanggal: "20 Mei 2026",
|
||||||
|
lokasi: "Lapangan Desa",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
nama: "Perayaan HUT Desa",
|
||||||
|
tanggal: "17 Agustus 2026",
|
||||||
|
lokasi: "Balai Desa",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const displayData = data || defaultData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
shadow="sm"
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Title order={3} mb="md" c={dark ? "dark.0" : "#1e3a5f"}>
|
||||||
|
Kalender Event Budaya
|
||||||
|
</Title>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{displayData.map((event) => (
|
||||||
|
<Card
|
||||||
|
key={event.id}
|
||||||
|
p="md"
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#263852ff" : "#F1F5F9"}
|
||||||
|
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Group gap="sm" align="center">
|
||||||
|
<ThemeIcon
|
||||||
|
color="darmasaba-blue"
|
||||||
|
size="md"
|
||||||
|
radius="xl"
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
<IconCalendarEvent size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={600} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||||
|
{event.nama}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" c={dark ? "dark.3" : "dimmed"} fw={500}>
|
||||||
|
{event.lokasi}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group pl={36}>
|
||||||
|
<Text size="sm" c={dark ? "white" : "gray.6"}>
|
||||||
|
{event.tanggal}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
77
src/components/sosial/health-stats.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Progress,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
|
||||||
|
interface HealthProgressItem {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthStatsProps {
|
||||||
|
data?: HealthProgressItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HealthStats = ({ data }: HealthStatsProps) => {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const defaultData: HealthProgressItem[] = [
|
||||||
|
{ label: "Imunisasi Lengkap", value: 92, color: "green" },
|
||||||
|
{ label: "Pemeriksaan Rutin", value: 88, color: "blue" },
|
||||||
|
{ label: "Gizi Baik", value: 86, color: "teal" },
|
||||||
|
{ label: "Target Stunting", value: 14, color: "red" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const displayData = data || defaultData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
shadow="sm"
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||||
|
}}
|
||||||
|
h={"100%"}
|
||||||
|
>
|
||||||
|
<Title order={3} mb="md" c={dark ? "dark.0" : "#1e3a5f"}>
|
||||||
|
Statistik Kesehatan
|
||||||
|
</Title>
|
||||||
|
<Stack gap="md">
|
||||||
|
{displayData.map((item) => (
|
||||||
|
<div key={item.label}>
|
||||||
|
<Group justify="space-between" mb={5}>
|
||||||
|
<Text size="sm" fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
c={item.color === "red" ? "red" : dark ? "dark.0" : "#1e3a5f"}
|
||||||
|
>
|
||||||
|
{item.value}%
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress
|
||||||
|
value={item.value}
|
||||||
|
size="lg"
|
||||||
|
radius="xl"
|
||||||
|
color={item.color}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
124
src/components/sosial/pendidikan.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
|
||||||
|
interface EducationData {
|
||||||
|
siswa: {
|
||||||
|
tk: number;
|
||||||
|
sd: number;
|
||||||
|
smp: number;
|
||||||
|
sma: number;
|
||||||
|
};
|
||||||
|
sekolah: {
|
||||||
|
jumlah: number;
|
||||||
|
guru: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendidikanProps {
|
||||||
|
data?: EducationData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Pendidikan = ({ data }: PendidikanProps) => {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const defaultData: EducationData = {
|
||||||
|
siswa: {
|
||||||
|
tk: 125,
|
||||||
|
sd: 480,
|
||||||
|
smp: 210,
|
||||||
|
sma: 150,
|
||||||
|
},
|
||||||
|
sekolah: {
|
||||||
|
jumlah: 8,
|
||||||
|
guru: 42,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayData = data || defaultData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
shadow="sm"
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Title order={3} mb="md" c={dark ? "dark.0" : "#1e3a5f"}>
|
||||||
|
Pendidikan
|
||||||
|
</Title>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||||
|
TK / PAUD
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||||
|
{displayData.siswa.tk}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||||
|
SD
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||||
|
{displayData.siswa.sd}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||||
|
SMP
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||||
|
{displayData.siswa.smp}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||||
|
SMA
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||||
|
{displayData.siswa.sma}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
p="md"
|
||||||
|
mt="md"
|
||||||
|
bg={dark ? "#263852ff" : "#F1F5F9"}
|
||||||
|
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||||
|
Jumlah Lembaga Pendidikan
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||||
|
{displayData.sekolah.jumlah}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between" mt="sm">
|
||||||
|
<Text fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||||
|
Jumlah Tenaga Pengajar
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} c={dark ? "dark.0" : "#1e3a5f"}>
|
||||||
|
{displayData.sekolah.guru}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
99
src/components/sosial/posyandu-schedule.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
|
||||||
|
interface PosyanduItem {
|
||||||
|
id: string;
|
||||||
|
nama: string;
|
||||||
|
tanggal: string;
|
||||||
|
jam: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PosyanduScheduleProps {
|
||||||
|
data?: PosyanduItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PosyanduSchedule = ({ data }: PosyanduScheduleProps) => {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const defaultData: PosyanduItem[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
nama: "Posyandu Mawar",
|
||||||
|
tanggal: "Senin, 15 Feb 2026",
|
||||||
|
jam: "08:00 - 11:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
nama: "Posyandu Melati",
|
||||||
|
tanggal: "Selasa, 16 Feb 2026",
|
||||||
|
jam: "08:00 - 11:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
nama: "Posyandu Dahlia",
|
||||||
|
tanggal: "Rabu, 17 Feb 2026",
|
||||||
|
jam: "08:00 - 11:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
nama: "Posyandu Anggrek",
|
||||||
|
tanggal: "Kamis, 18 Feb 2026",
|
||||||
|
jam: "08:00 - 11:00",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const displayData = data || defaultData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="xl"
|
||||||
|
withBorder
|
||||||
|
shadow="sm"
|
||||||
|
bg={dark ? "#1E293B" : "white"}
|
||||||
|
style={{
|
||||||
|
borderColor: dark ? "#334155" : "white",
|
||||||
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||||
|
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Title order={3} mb="md" c={dark ? "dark.0" : "#1e3a5f"}>
|
||||||
|
Jadwal Posyandu
|
||||||
|
</Title>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{displayData.map((item) => (
|
||||||
|
<Card
|
||||||
|
key={item.id}
|
||||||
|
p="md"
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
bg={dark ? "#263852ff" : "#F1F5F9"}
|
||||||
|
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw={600} c={dark ? "white" : "#1e3a5f"}>
|
||||||
|
{item.nama}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c={dark ? "white" : "dimmed"}>
|
||||||
|
{item.tanggal}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Badge variant="light" color="darmasaba-blue" size="md">
|
||||||
|
{item.jam}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
144
src/components/sosial/summary-cards.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Grid,
|
||||||
|
GridCol,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconBabyCarriage,
|
||||||
|
IconHeartbeat,
|
||||||
|
IconMedicalCross,
|
||||||
|
IconStethoscope,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
|
interface SummaryCardProps {
|
||||||
|
title: string;
|
||||||
|
value: number;
|
||||||
|
subtitle?: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
color: string;
|
||||||
|
highlight?: boolean;
|
||||||
|
backgroundColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SummaryCard = ({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
subtitle,
|
||||||
|
icon,
|
||||||
|
color,
|
||||||
|
highlight = false,
|
||||||
|
backgroundColor,
|
||||||
|
}: SummaryCardProps) => {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="sm" c={dark ? "dark.3" : "dimmed"} fw={500}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
size="xl"
|
||||||
|
fw={700}
|
||||||
|
c={highlight ? "red" : dark ? "white" : "#1e3a5f"}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
{subtitle && (
|
||||||
|
<Text size="xs" c={dark ? "white" : "gray.6"}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<ThemeIcon bg={backgroundColor} color={color} size="xl" radius="xl">
|
||||||
|
{icon}
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HealthSummaryData {
|
||||||
|
ibuHamil: number;
|
||||||
|
balita: number;
|
||||||
|
alertStunting: number;
|
||||||
|
posyanduAktif: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SummaryCardsProps {
|
||||||
|
data?: HealthSummaryData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SummaryCards = ({ data }: SummaryCardsProps) => {
|
||||||
|
const defaultData: HealthSummaryData = {
|
||||||
|
ibuHamil: 87,
|
||||||
|
balita: 342,
|
||||||
|
alertStunting: 12,
|
||||||
|
posyanduAktif: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayData = data || defaultData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid gutter="md">
|
||||||
|
<GridCol span={{ base: 12, sm: 6, lg: 3 }}>
|
||||||
|
<SummaryCard
|
||||||
|
title="Ibu Hamil Aktif"
|
||||||
|
value={displayData.ibuHamil}
|
||||||
|
subtitle="Aktif"
|
||||||
|
icon={<IconHeartbeat size={20} />}
|
||||||
|
color="white"
|
||||||
|
backgroundColor="#1E3A5F"
|
||||||
|
/>
|
||||||
|
</GridCol>
|
||||||
|
<GridCol span={{ base: 12, sm: 6, lg: 3 }}>
|
||||||
|
<SummaryCard
|
||||||
|
title="Balita Terdaftar"
|
||||||
|
value={displayData.balita}
|
||||||
|
subtitle="Terdaftar"
|
||||||
|
icon={<IconBabyCarriage size={20} />}
|
||||||
|
color="white"
|
||||||
|
backgroundColor="#1E3A5F"
|
||||||
|
/>
|
||||||
|
</GridCol>
|
||||||
|
<GridCol span={{ base: 12, sm: 6, lg: 3 }}>
|
||||||
|
<SummaryCard
|
||||||
|
title="Alert Stunting"
|
||||||
|
value={displayData.alertStunting}
|
||||||
|
subtitle="Perhatian"
|
||||||
|
icon={<IconStethoscope size={20} />}
|
||||||
|
color="white"
|
||||||
|
backgroundColor="#1E3A5F"
|
||||||
|
/>
|
||||||
|
</GridCol>
|
||||||
|
<GridCol span={{ base: 12, sm: 6, lg: 3 }}>
|
||||||
|
<SummaryCard
|
||||||
|
title="Posyandu Aktif"
|
||||||
|
value={displayData.posyanduAktif}
|
||||||
|
subtitle="Aktif"
|
||||||
|
icon={<IconMedicalCross size={20} />}
|
||||||
|
color="white"
|
||||||
|
backgroundColor="#1E3A5F"
|
||||||
|
/>
|
||||||
|
</GridCol>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -53,8 +53,6 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-slot="breadcrumb-page"
|
data-slot="breadcrumb-page"
|
||||||
role="link"
|
|
||||||
aria-disabled="true"
|
|
||||||
aria-current="page"
|
aria-current="page"
|
||||||
className={cn("text-foreground font-normal", className)}
|
className={cn("text-foreground font-normal", className)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
mantineVariant = "transparent";
|
mantineVariant = "transparent";
|
||||||
mantineColor = "blue"; // Assuming primary maps to blue in Mantine for now
|
mantineColor = "blue"; // Assuming primary maps to blue in Mantine for now
|
||||||
break;
|
break;
|
||||||
case "default":
|
|
||||||
default:
|
default:
|
||||||
mantineVariant = "filled";
|
mantineVariant = "filled";
|
||||||
mantineColor = "blue"; // Assuming primary maps to blue in Mantine for now
|
mantineColor = "blue"; // Assuming primary maps to blue in Mantine for now
|
||||||
|
|||||||
@@ -117,16 +117,16 @@ function Carousel({
|
|||||||
canScrollNext,
|
canScrollNext,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
{/* biome-ignore lint/a11y/useAriaPropsSupportedByRole: section with aria-roledescription is standard for carousels. */}
|
||||||
|
<section
|
||||||
onKeyDownCapture={handleKeyDown}
|
onKeyDownCapture={handleKeyDown}
|
||||||
className={cn("relative", className)}
|
className={cn("relative", className)}
|
||||||
role="region"
|
|
||||||
aria-roledescription="carousel"
|
aria-roledescription="carousel"
|
||||||
data-slot="carousel"
|
data-slot="carousel"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</section>{" "}
|
||||||
</CarouselContext.Provider>
|
</CarouselContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -156,6 +156,7 @@ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
const { orientation } = useCarousel();
|
const { orientation } = useCarousel();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// biome-ignore lint/a11y/useSemanticElements: role='group' and aria-roledescription='slide' is standard for carousel slides.
|
||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
aria-roledescription="slide"
|
aria-roledescription="slide"
|
||||||
|
|||||||