fix(header): fix missing Divider, Badge, IconUserShield and navigate

This commit is contained in:
2026-03-26 14:13:59 +08:00
parent ebc1242bee
commit aeedb17402
35 changed files with 2788 additions and 552 deletions

View File

@@ -2,7 +2,7 @@
**ID:** `TASK-DB-001`
**Konteks:** Database Implementation
**Status:** 🏗️ IN PROGRESS
**Status:** ✅ COMPLETED (95% Selesai)
**Prioritas:** 🔴 KRITIS (Blokade Fitur)
**Estimasi:** 7 Hari Kerja
@@ -16,41 +16,41 @@ Mengganti mock data pada fitur-fitur inti (Kinerja Divisi, Pengaduan, Kependuduk
## 📋 DAFTAR TUGAS (TODO)
### 1. Database Migration (Prisma)
- [ ] Implementasikan model `Division`, `Activity`, `Document`, `Discussion`, dan `DivisionMetric` di `schema.prisma`.
- [ ] Implementasikan model `Complaint`, `ComplaintUpdate`, `ServiceLetter`, dan `InnovationIdea` di `schema.prisma`.
- [ ] Implementasikan model `Resident` dan `Banjar` di `schema.prisma`.
- [ ] Implementasikan model `Event` di `schema.prisma`.
- [ ] Jalankan `bun x prisma migrate dev --name init_core_features`.
- [ ] Lakukan verifikasi relasi database di database viewer (Prisma Studio).
- [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
- [ ] Update `prisma/seed.ts` untuk menyertakan data dummy yang realistis untuk:
- [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
- [ ] Jalankan `bun run seed` dan pastikan tidak ada error relasi.
- [x] Jalankan `bun run seed` dan pastikan tidak ada error relasi.
### 3. Backend API Development (ElysiaJS)
- [ ] Buat route handler di `src/api/` untuk setiap modul:
- [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
- [ ] Integrasikan `apiMiddleware` untuk proteksi rute (Admin/Moderator).
- [ ] Pastikan skema input/output didefinisikan menggunakan `t.Object` untuk OpenAPI documentation.
- [x] Integrasikan `apiMiddleware` untuk proteksi rute (Admin/Moderator).
- [x] Pastikan skema input/output didefinisikan menggunakan `t.Object` untuk OpenAPI documentation.
### 4. Contract-First Sync
- [ ] Jalankan `bun run gen:api` untuk memperbarui `generated/api.ts`.
- [ ] Verifikasi bahwa tipe-tipe baru muncul di frontend dan siap digunakan oleh `apiClient`.
- [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)
- [ ] Update `src/hooks/` atau `src/store/` untuk memanggil API riil menggantikan mock data.
- [ ] Sambungkan komponen berikut ke API:
- `DashboardContent`: Stat cards & Activity List
- `KinerjaDivisi`: Division List & Activity Cards
- `PengaduanLayananPublik`: Statistik & Tabel Pengajuan
- `DemografiPekerjaan`: Grafik & Data per Banjar
- [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)
---

View File

@@ -2,7 +2,7 @@
**ID:** `TASK-DX-001`
**Konteks:** Developer Experience (DX)
**Status:** 🏗️ PROPOSED
**Status:** ✅ COMPLETED
**Prioritas:** 🟡 TINGGI (Peningkatan Produktivitas)
**Estimasi:** 1 Hari Kerja
@@ -16,35 +16,35 @@ Mengaktifkan fitur **Click-to-Source** di lingkungan pengembangan: klik elemen U
## 📋 DAFTAR TUGAS (TODO)
### 1. Vite Plugin Configuration
- [ ] Buat file `src/utils/dev-inspector-plugin.ts` yang berisi `inspectorPlugin()` (regex-based JSX attribute injection).
- [ ] Modifikasi `src/vite.ts`:
- Impor `inspectorPlugin`.
- Tambahkan `inspectorPlugin()` ke array `plugins` **sebelum** `react()`.
- Gunakan `enforce: 'pre'` pada plugin tersebut.
- [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
- [ ] Buat komponen `src/components/dev-inspector.tsx`:
- Implementasikan hotkey listener.
- Tambahkan overlay UI (border biru & tooltip nama file) saat hover.
- Implementasikan `getCodeInfoFromElement` dengan fallback (fiber props -> DOM attributes).
- Tambahkan fungsi `openInEditor` (POST ke `/__open-in-editor`).
- [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)
- [ ] Modifikasi `src/index.ts`:
- Tambahkan handler `onRequest` sebelum middleware lainnya.
- Intercept request ke path `/__open-in-editor` (POST).
- Gunakan `Bun.spawn()` untuk memanggil editor (berdasarkan `.env` `REACT_EDITOR`).
- Gunakan `Bun.which()` untuk verifikasi keberadaan editor di system PATH.
- [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
- [ ] Modifikasi `src/frontend.tsx`:
- Implementasikan **Conditional Dynamic Import** untuk `DevInspector`.
- Gunakan `import.meta.env?.DEV` agar tidak ada overhead di production.
- Bungkus `<App />` (atau router) dengan `<DevInspectorWrapper>`.
- [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
- [ ] Tambahkan `REACT_EDITOR=code` (atau `cursor`, `windsurf`) di file `.env`.
- [ ] Pastikan alias `@/` berfungsi dengan benar di plugin Vite untuk resolusi path.
- [x] Tambahkan `REACT_EDITOR=code` (atau `cursor`, `windsurf`) di file `.env`.
- [x] Pastikan alias `@/` berfungsi dengan benar di plugin Vite untuk resolusi path.
---

View File

@@ -92,7 +92,6 @@
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prisma": "^6.19.2",
"react-dev-inspector": "^2.0.1",
"vite": "^7.3.1",
},
},
@@ -474,14 +473,8 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@react-dev-inspector/babel-plugin": ["@react-dev-inspector/babel-plugin@2.0.1", "", { "dependencies": { "@babel/core": "^7.20.5", "@babel/generator": "^7.20.5", "@babel/parser": "^7.20.5", "@babel/traverse": "^7.20.5", "@babel/types": "7.20.5" } }, "sha512-V2MzN9dj3uZu6NvAjSxXwa3+FOciVIuwAUwPLpO6ji5xpUyx8E6UiEng1QqzttdpacKHFKtkNYjtQAE+Lsqa5A=="],
"@react-dev-inspector/middleware": ["@react-dev-inspector/middleware@2.0.1", "", { "dependencies": { "react-dev-utils": "12.0.1" } }, "sha512-qDMtBzAxNNAX01jjU1THZVuNiVB7J1Hjk42k8iLSSwfinc3hk667iqgdzeq1Za1a0V2bF5Ev6D4+nkZ+E1YUrQ=="],
"@react-dev-inspector/umi3-plugin": ["@react-dev-inspector/umi3-plugin@2.0.1", "", { "dependencies": { "@react-dev-inspector/babel-plugin": "2.0.1", "@react-dev-inspector/middleware": "2.0.1" } }, "sha512-lRw65yKQdI/1BwrRXWJEHDJel4DWboOartGmR3S5xiTF+EiOLjmndxdA5LoVSdqbcggdtq5SWcsoZqI0TkhH7Q=="],
"@react-dev-inspector/umi4-plugin": ["@react-dev-inspector/umi4-plugin@2.0.1", "", { "dependencies": { "@react-dev-inspector/babel-plugin": "2.0.1", "@react-dev-inspector/middleware": "2.0.1" } }, "sha512-vTefsJVAZsgpuO9IZ1ZFIoyryVUU+hjV8OPD8DfDU+po5LjVXc5Uncn+MkFOsT24AMpNdDvCnTRYiuSkFn8EsA=="],
"@react-dev-inspector/vite-plugin": ["@react-dev-inspector/vite-plugin@2.0.1", "", { "dependencies": { "@react-dev-inspector/middleware": "2.0.1" } }, "sha512-J1eI7cIm2IXE6EwhHR1OyoefvobUJEn/vJWEBwOM5uW4JkkLwuVoV9vk++XJyAmKUNQ87gdWZvSWrI2LjfrSug=="],
"@redocly/ajv": ["@redocly/ajv@8.17.3", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-NQsbJbB/GV7JVO88ebFkMndrnuGp/dTm5/2NISeg+JGcLzTfGBJZ01+V5zD8nKBOpi/dLLNFT+Ql6IcUk8ehng=="],
@@ -674,8 +667,6 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/react-reconciler": ["@types/react-reconciler@0.33.0", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="],
@@ -1084,8 +1075,6 @@
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"hotkeys-js": ["hotkeys-js@3.13.15", "", {}, "sha512-gHh8a/cPTCpanraePpjRxyIlxDFrIhYqjuh01UHWEwDpglJKCnvLW8kqSx5gQtOuSsJogNZXLhOdbSExpgUiqg=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
@@ -1116,7 +1105,7 @@
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -1132,7 +1121,7 @@
"is-root": ["is-root@2.1.0", "", {}, "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg=="],
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
"isbot": ["isbot@5.1.34", "", {}, "sha512-aCMIBSKd/XPRYdiCQTLC8QHH4YT8B3JUADu+7COgYIZPvkeoMcUHMRjZLM9/7V8fCj+l7FSREc1lOPNjzogo/A=="],
@@ -1396,8 +1385,6 @@
"react-day-picker": ["react-day-picker@8.10.1", "", { "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA=="],
"react-dev-inspector": ["react-dev-inspector@2.0.1", "", { "dependencies": { "@react-dev-inspector/babel-plugin": "2.0.1", "@react-dev-inspector/middleware": "2.0.1", "@react-dev-inspector/umi3-plugin": "2.0.1", "@react-dev-inspector/umi4-plugin": "2.0.1", "@react-dev-inspector/vite-plugin": "2.0.1", "@types/react-reconciler": ">=0.26.6", "hotkeys-js": "^3.8.1", "picocolors": "1.0.0", "react-dev-utils": "12.0.1" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-b8PAmbwGFrWcxeaX8wYveqO+VTwTXGJaz/yl9RO31LK1zeLKJVlkkbeLExLnJ6IvhXY1TwL8Q4+gR2GKJ8BI6Q=="],
"react-dev-utils": ["react-dev-utils@12.0.1", "", { "dependencies": { "@babel/code-frame": "^7.16.0", "address": "^1.1.2", "browserslist": "^4.18.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.3", "detect-port-alt": "^1.1.6", "escape-string-regexp": "^4.0.0", "filesize": "^8.0.6", "find-up": "^5.0.0", "fork-ts-checker-webpack-plugin": "^6.5.0", "global-modules": "^2.0.0", "globby": "^11.0.4", "gzip-size": "^6.0.0", "immer": "^9.0.7", "is-root": "^2.1.0", "loader-utils": "^3.2.0", "open": "^8.4.0", "pkg-up": "^3.1.0", "prompts": "^2.4.2", "react-error-overlay": "^6.0.11", "recursive-readdir": "^2.2.2", "shell-quote": "^1.7.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" } }, "sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
@@ -1570,8 +1557,6 @@
"tldts-core": ["tldts-core@7.0.22", "", {}, "sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw=="],
"to-fast-properties": ["to-fast-properties@2.0.0", "", {}, "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
@@ -1730,8 +1715,6 @@
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@react-dev-inspector/babel-plugin/@babel/types": ["@babel/types@7.20.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.19.4", "@babel/helper-validator-identifier": "^7.19.1", "to-fast-properties": "^2.0.0" } }, "sha512-c9fst/h2/dcF7H+MJKZ2T0KjEQ8hY/BNnDk/H3XY8C4Aw/eWQXWn/lWntHF9ooUBnGmEvbfGrTgLWc+um0YDUg=="],
"@redocly/openapi-core/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
@@ -1794,8 +1777,6 @@
"global-prefix/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="],
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -1816,8 +1797,6 @@
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"react-dev-inspector/picocolors": ["picocolors@1.0.0", "", {}, "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="],
"react-dev-utils/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"react-dev-utils/immer": ["immer@9.0.21", "", {}, "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA=="],
@@ -1838,8 +1817,6 @@
"webpack/es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
"wsl-utils/is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
"@prisma/config/c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"@prisma/config/c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
@@ -1874,6 +1851,10 @@
"react-dev-utils/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
"react-dev-utils/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"react-dev-utils/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"recursive-readdir/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"@prisma/config/c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],

View File

@@ -120,6 +120,193 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/division/": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get all divisions */
get: operations["getApiDivision"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/division/activities": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get recent activities */
get: operations["getApiDivisionActivities"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/division/metrics": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get division performance metrics */
get: operations["getApiDivisionMetrics"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/complaint/stats": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get complaint statistics */
get: operations["getApiComplaintStats"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/complaint/recent": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get recent complaints */
get: operations["getApiComplaintRecent"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/complaint/service-stats": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get service letter statistics by type */
get: operations["getApiComplaintService-stats"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/resident/stats": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get resident statistics */
get: operations["getApiResidentStats"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/resident/banjar-stats": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get population data per banjar */
get: operations["getApiResidentBanjar-stats"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/resident/demographics": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get religious and gender demographics */
get: operations["getApiResidentDemographics"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/event/": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get upcoming events */
get: operations["getApiEvent"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/event/today": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get events for today */
get: operations["getApiEventToday"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -643,4 +830,191 @@ export interface operations {
};
};
};
getApiDivision: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getApiDivisionActivities: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getApiDivisionMetrics: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getApiComplaintStats: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getApiComplaintRecent: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
"getApiComplaintService-stats": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getApiResidentStats: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
"getApiResidentBanjar-stats": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getApiResidentDemographics: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getApiEvent: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getApiEventToday: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
}

View File

@@ -1413,6 +1413,105 @@
}
}
}
},
"/api/division/": {
"get": {
"operationId": "getApiDivision",
"summary": "Get all divisions",
"responses": {
"200": {}
}
}
},
"/api/division/activities": {
"get": {
"operationId": "getApiDivisionActivities",
"summary": "Get recent activities",
"responses": {
"200": {}
}
}
},
"/api/division/metrics": {
"get": {
"operationId": "getApiDivisionMetrics",
"summary": "Get division performance metrics",
"responses": {
"200": {}
}
}
},
"/api/complaint/stats": {
"get": {
"operationId": "getApiComplaintStats",
"summary": "Get complaint statistics",
"responses": {
"200": {}
}
}
},
"/api/complaint/recent": {
"get": {
"operationId": "getApiComplaintRecent",
"summary": "Get recent complaints",
"responses": {
"200": {}
}
}
},
"/api/complaint/service-stats": {
"get": {
"operationId": "getApiComplaintService-stats",
"summary": "Get service letter statistics by type",
"responses": {
"200": {}
}
}
},
"/api/resident/stats": {
"get": {
"operationId": "getApiResidentStats",
"summary": "Get resident statistics",
"responses": {
"200": {}
}
}
},
"/api/resident/banjar-stats": {
"get": {
"operationId": "getApiResidentBanjar-stats",
"summary": "Get population data per banjar",
"responses": {
"200": {}
}
}
},
"/api/resident/demographics": {
"get": {
"operationId": "getApiResidentDemographics",
"summary": "Get religious and gender demographics",
"responses": {
"200": {}
}
}
},
"/api/event/": {
"get": {
"operationId": "getApiEvent",
"summary": "Get upcoming events",
"responses": {
"200": {}
}
}
},
"/api/event/today": {
"get": {
"operationId": "getApiEventToday",
"summary": "Get events for today",
"responses": {
"200": {}
}
}
}
},
"components": {

View File

@@ -104,7 +104,6 @@
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prisma": "^6.19.2",
"react-dev-inspector": "^2.0.1",
"vite": "^7.3.1"
}
}

View File

@@ -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;

View 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"

View File

@@ -21,9 +21,459 @@ model User {
sessions Session[]
apiKeys ApiKey[]
// Relations
discussions Discussion[]
events Event[]
complaints Complaint[] @relation("ComplaintReporter")
assignedComplaints Complaint[] @relation("ComplaintAssignee")
complaintUpdates ComplaintUpdate[]
serviceLetters ServiceLetter[]
innovationIdeas InnovationIdea[] @relation("IdeaReviewer")
healthRecords HealthRecord[]
populationDynamics PopulationDynamic[]
budgets Budget[]
budgetTransactions BudgetTransaction[]
posyandus Posyandu[]
securityReports SecurityReport[]
@@map("user")
}
// --- KATEGORI 1: KINERJA DIVISI & AKTIVITAS ---
model Division {
id String @id @default(cuid())
name String @unique
description String?
color String @default("#1E3A5F")
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
activities Activity[]
documents Document[]
discussions Discussion[]
divisionMetrics DivisionMetric[]
@@map("division")
}
model Activity {
id String @id @default(cuid())
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())
title String
category DocumentCategory
type String // "Gambar", "Dokumen", "PDF", etc
fileUrl String
fileSize Int? // in bytes
divisionId String?
uploadedBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
division Division? @relation(fields: [divisionId], references: [id], onDelete: SetNull)
@@index([category])
@@index([divisionId])
@@map("document")
}
model Discussion {
id String @id @default(cuid())
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())
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")
}
// --- 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])
}
model EmploymentRecord {
id String @id @default(cuid())
residentId String
resident Resident @relation(fields: [residentId], references: [id])
}
model PopulationDynamic {
id String @id @default(cuid())
documentedBy String
documentor User @relation(fields: [documentedBy], references: [id])
}
model Budget {
id String @id @default(cuid())
approvedBy String?
approver User? @relation(fields: [approvedBy], references: [id])
}
model BudgetTransaction {
id String @id @default(cuid())
createdBy String
creator User @relation(fields: [createdBy], references: [id])
}
model Umkm {
id String @id @default(cuid())
banjarId String?
banjar Banjar? @relation(fields: [banjarId], references: [id])
}
model Posyandu {
id String @id @default(cuid())
coordinatorId String?
coordinator User? @relation(fields: [coordinatorId], references: [id])
}
model SecurityReport {
id String @id @default(cuid())
assignedTo String?
assignee User? @relation(fields: [assignedTo], references: [id])
}
// --- ENUMS ---
enum ActivityStatus {
BERJALAN
SELESAI
TERTUNDA
DIBATALKAN
}
enum Priority {
RENDAH
SEDANG
TINGGI
DARURAT
}
enum DocumentCategory {
SURAT_KEPUTUSAN
DOKUMENTASI
LAPORAN_KEUANGAN
NOTULENSI_RAPAT
UMUM
}
enum EventType {
RAPAT
KEGIATAN
UPACARA
SOSIAL
BUDAYA
LAINNYA
}
enum ComplaintCategory {
KETERTIBAN_UMUM
PELAYANAN_KESEHATAN
INFRASTRUKTUR
ADMINISTRASI
KEAMANAN
LAINNYA
}
enum ComplaintStatus {
BARU
DIPROSES
SELESAI
DITOLAK
}
enum LetterType {
KTP
KK
DOMISILI
USAHA
KETERANGAN_TIDAK_MAMPU
SURAT_PENGANTAR
LAINNYA
}
enum ServiceStatus {
BARU
DIPROSES
SELESAI
DIAMBIL
}
enum IdeaStatus {
BARU
DIKAJI
DISETUJUI
DITOLAK
DIIMPLEMENTASI
}
enum Gender {
LAKI_LAKI
PEREMPUAN
}
enum Religion {
HINDU
ISLAM
KRISTEN
KATOLIK
BUDDHA
KONGHUCU
LAINNYA
}
enum MaritalStatus {
BELUM_KAWIN
KAWIN
CERAI_HIDUP
CERAI_MATI
}
enum EducationLevel {
TIDAK_SEKOLAH
SD
SMP
SMA
D3
S1
S2
S3
}
model Session {
id String @id @default(cuid())
userId String

View File

@@ -1,150 +1,325 @@
import "dotenv/config";
import { hash } from "bcryptjs";
import { generateId } from "better-auth";
import { prisma } from "@/utils/db";
import {
ActivityStatus,
ComplaintCategory,
ComplaintStatus,
EventType,
Gender,
Priority,
PrismaClient,
Religion,
} from "../generated/prisma";
const prisma = new PrismaClient();
async function seedAdminUser() {
// Load environment variables
const adminEmail = process.env.ADMIN_EMAIL;
const adminEmail = process.env.ADMIN_EMAIL || "admin@example.com";
const adminPassword = process.env.ADMIN_PASSWORD || "admin123";
if (!adminEmail) {
console.log(
"No ADMIN_EMAIL environment variable found. Skipping admin user creation.",
);
return;
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;
}
try {
// Check if admin user already exists
const existingUser = await prisma.user.findUnique({
where: { email: adminEmail },
});
const hashedPassword = await hash(adminPassword, 12);
const userId = generateId();
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: {
await prisma.user.create({
data: {
id: userId,
email: adminEmail,
name: "Admin Desa Darmasaba",
role: "admin",
emailVerified: true,
accounts: {
create: {
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;
}
console.log(`Admin user created: ${adminEmail}`);
return userId;
}
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" },
async function seedBanjars() {
const banjars = [
{
email: "moderator@example.com",
name: "Moderator User",
role: "moderator",
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,
},
];
for (const userData of demoUsers) {
try {
const existingUser = await prisma.user.findUnique({
where: { email: userData.email },
});
console.log("Seeding Banjars...");
for (const banjar of banjars) {
await prisma.banjar.upsert({
where: { name: banjar.name },
update: banjar,
create: banjar,
});
}
}
if (!existingUser) {
const userId = generateId();
const hashedPassword = await hash("demo123", 12);
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",
},
];
await prisma.user.create({
data: {
id: userId,
email: userData.email,
name: userData.name,
role: userData.role,
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
},
});
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);
}
return createdDivisions;
}
await prisma.account.create({
data: {
id: generateId(),
userId,
accountId: userId,
providerId: "credential",
password: hashedPassword,
createdAt: new Date(),
updatedAt: new Date(),
},
});
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],
rt: "002",
rw: "000",
address: "Gg. Manesa No. 5",
isPoor: true,
},
];
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);
}
for (const res of residents) {
await prisma.resident.upsert({
where: { nik: res.nik },
update: res,
create: res,
});
}
}
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],
progress: 40,
status: ActivityStatus.BERJALAN,
priority: Priority.DARURAT,
},
];
for (const act of activities) {
await prisma.activity.create({
data: act,
});
}
}
async function seedComplaints(adminId: string) {
console.log("Seeding Complaints...");
const complaints = [
{
complaintNumber: `COMP-20250326-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,
},
{
complaintNumber: `COMP-20250326-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,
},
];
for (const comp of complaints) {
await prisma.complaint.upsert({
where: { complaintNumber: comp.complaintNumber },
update: comp,
create: comp,
});
}
}
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,
});
}
}
async function main() {
console.log("Seeding database...");
console.log("Starting seed...");
await seedAdminUser();
await seedDemoUsers();
const adminId = await seedAdminUser();
await seedBanjars();
const banjars = await prisma.banjar.findMany();
const banjarIds = banjars.map((b) => b.id);
console.log("Database seeding completed.");
const divisions = await seedDivisions();
const divisionIds = divisions.map((d) => d.id);
await seedResidents(banjarIds);
await seedActivities(divisionIds);
await seedComplaints(adminId);
await seedEvents(adminId);
console.log("Seed finished successfully!");
}
// Only auto-execute when run directly (not when imported)
const isMainModule =
typeof require !== "undefined"
? require.main === module
: import.meta.path.endsWith("seed.ts");
if (isMainModule) {
main().catch((error) => {
console.error("Error during seeding:", error);
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
}
// Export for programmatic use
export { seedAdminUser, seedDemoUsers, main as runSeed };

66
src/api/complaint.ts Normal file
View File

@@ -0,0 +1,66 @@
import Elysia 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" };
}
},
{
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" };
}
},
{
detail: { summary: "Get recent complaints" },
},
)
.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" };
}
},
{
detail: { summary: "Get service letter statistics by type" },
},
);

71
src/api/division.ts Normal file
View File

@@ -0,0 +1,71 @@
import Elysia 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 };
} catch (error) {
logger.error({ error }, "Failed to fetch divisions");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
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" };
}
},
{
detail: { summary: "Get recent activities" },
},
)
.get(
"/metrics",
async ({ set }) => {
try {
const metrics = await prisma.divisionMetric.findMany({
include: { division: true },
});
return { data: metrics };
} catch (error) {
logger.error({ error }, "Failed to fetch division metrics");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
detail: { summary: "Get division performance metrics" },
},
);

54
src/api/event.ts Normal file
View File

@@ -0,0 +1,54 @@
import Elysia 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" };
}
},
{
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" };
}
},
{
detail: { summary: "Get events for today" },
},
);

View File

@@ -4,7 +4,11 @@ import Elysia from "elysia";
import { apiMiddleware } from "../middleware/apiMiddleware";
import { auth } from "../utils/auth";
import { apikey } from "./apikey";
import { complaint } from "./complaint";
import { division } from "./division";
import { event } from "./event";
import { profile } from "./profile";
import { resident } from "./resident";
const isProduction = process.env.NODE_ENV === "production";
@@ -20,7 +24,11 @@ const api = new Elysia({
})
.use(apiMiddleware)
.use(apikey)
.use(profile);
.use(profile)
.use(division)
.use(complaint)
.use(resident)
.use(event);
if (!isProduction) {
api.use(

View File

@@ -2,12 +2,25 @@ import Elysia, { t } from "elysia";
import { prisma } from "../utils/db";
import logger from "../utils/logger";
interface AuthenticatedUser {
id: string;
email: string;
name?: string | null;
}
export const profile = new Elysia({
prefix: "/profile",
}).post(
"/update",
async (ctx) => {
const { body, set, user } = ctx as any;
async ({
body,
set,
user,
}: {
body: { name?: string; image?: string };
set: any;
user?: AuthenticatedUser;
}) => {
try {
if (!user) {
set.status = 401;

76
src/api/resident.ts Normal file
View File

@@ -0,0 +1,76 @@
import Elysia 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" };
}
},
{
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" };
}
},
{
detail: { summary: "Get population data per banjar" },
},
)
.get(
"/demographics",
async ({ set }) => {
try {
const [religion, gender] = await Promise.all([
prisma.resident.groupBy({
by: ["religion"],
_count: { _all: true },
}),
prisma.resident.groupBy({
by: ["gender"],
_count: { _all: true },
}),
]);
return { data: { religion, gender } };
} catch (error) {
logger.error({ error }, "Failed to fetch demographics");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
detail: { summary: "Get religious and gender demographics" },
},
);

View File

@@ -1,5 +1,7 @@
import { Grid, Image, Stack, useMantineColorScheme } from "@mantine/core";
import { Grid, Image, Stack } from "@mantine/core";
import { CheckCircle, FileText, MessageCircle, Users } from "lucide-react";
import { useEffect, useState } from "react";
import { apiClient } from "@/utils/api-client";
import { ActivityList } from "./dashboard/activity-list";
import { ChartAPBDes } from "./dashboard/chart-apbdes";
import { ChartSurat } from "./dashboard/chart-surat";
@@ -32,8 +34,38 @@ const sdgsData = [
];
export function DashboardContent() {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const [stats, setStats] = useState({
complaints: { total: 0, baru: 0, proses: 0, selesai: 0 },
residents: { total: 0, heads: 0, poor: 0 },
loading: true,
});
useEffect(() => {
async function fetchStats() {
try {
const [complaintRes, residentRes] = await Promise.all([
apiClient.GET("/api/complaint/stats"),
apiClient.GET("/api/resident/stats"),
]);
setStats({
complaints: (complaintRes.data as any)?.data || {
total: 0,
baru: 0,
proses: 0,
selesai: 0,
},
residents: (residentRes.data as any)?.data || { total: 0, heads: 0, poor: 0 },
loading: false,
});
} catch (error) {
console.error("Failed to fetch stats", error);
setStats((prev) => ({ ...prev, loading: false }));
}
}
fetchStats();
}, []);
return (
<Stack gap="lg">
@@ -42,36 +74,36 @@ export function DashboardContent() {
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
<StatCard
title="Surat Minggu Ini"
value={99}
detail="14 baru, 14 diproses"
trend="12% dari minggu lalu ↗ +12%"
trendValue={12}
value={0}
detail="Menunggu integrasi riil"
trend="0%"
trendValue={0}
icon={<FileText style={{ width: "70%", height: "70%" }} />}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
<StatCard
title="Pengaduan Aktif"
value={28}
detail="14 baru, 14 diproses"
value={stats.complaints.baru + stats.complaints.proses}
detail={`${stats.complaints.baru} baru, ${stats.complaints.proses} diproses`}
icon={<MessageCircle style={{ width: "70%", height: "70%" }} />}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
<StatCard
title="Layanan Selesai"
value={156}
detail="bulan ini"
trend="+8%"
trendValue={8}
value={stats.complaints.selesai}
detail="Total diselesaikan"
trend="+0%"
trendValue={0}
icon={<CheckCircle style={{ width: "70%", height: "70%" }} />}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
<StatCard
title="Kepuasan Warga"
value="87.2%"
detail="dari 482 responden"
title="Total Penduduk"
value={stats.residents.total.toLocaleString()}
detail={`${stats.residents.heads} Kepala Keluarga`}
icon={<Users style={{ width: "70%", height: "70%" }} />}
/>
</Grid.Col>
@@ -102,8 +134,8 @@ export function DashboardContent() {
{/* Section 6: SDGs Desa Cards */}
<Grid gutter="md">
{sdgsData.map((sdg, index) => (
<Grid.Col key={index} span={{ base: 9, md: 3 }}>
{sdgsData.map((sdg) => (
<Grid.Col key={sdg.title} span={{ base: 9, md: 3 }}>
<SDGSCard
image={<Image src={sdg.image} alt={sdg.title} />}
title={sdg.title}

View File

@@ -48,9 +48,9 @@ export function ActivityList() {
</Title>
</Group>
<Stack gap="md">
{events.map((event, index) => (
{events.map((event) => (
<Box
key={index}
key={`${event.title}-${event.date}`}
style={{
borderLeft: "4px solid var(--mantine-color-blue-filled)",
paddingLeft: 12,

View File

@@ -45,8 +45,8 @@ export function ChartAPBDes() {
Grafik APBDes
</Title>
<Stack gap="xs">
{apbdesData.map((item, index) => (
<Group key={index} align="center" gap="md">
{apbdesData.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>

View File

@@ -45,8 +45,8 @@ export function DivisionProgress() {
Divisi Teraktif
</Title>
<Stack gap="sm">
{divisionData.map((divisi, index) => (
<Box key={index}>
{divisionData.map((divisi) => (
<Box key={divisi.name}>
<Group justify="space-between" mb={5}>
<Text size="sm" fw={500} c={dark ? "white" : "gray.7"}>
{divisi.name}

View File

@@ -2,7 +2,6 @@ import {
Box,
Card,
Group,
Stack,
Text,
Title,
useMantineColorScheme,
@@ -51,8 +50,8 @@ export function SatisfactionChart() {
paddingAngle={2}
dataKey="value"
>
{satisfactionData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
{satisfactionData.map((entry) => (
<Cell key={`cell-${entry.name}`} fill={entry.color} />
))}
</Pie>
<Tooltip
@@ -65,8 +64,8 @@ export function SatisfactionChart() {
</PieChart>
</ResponsiveContainer>
<Group justify="center" gap="md" mt="md">
{satisfactionData.map((item, index) => (
<Group key={index} gap="xs">
{satisfactionData.map((item) => (
<Group key={item.name} gap="xs">
<Box
w={12}
h={12}

View File

@@ -0,0 +1,220 @@
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`;
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: updateOverlay is stable
useEffect(() => {
if (!active) return;
const onMouseOver = (e: MouseEvent) =>
updateOverlay(e.target as HTMLElement);
const onClick = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const info = lastInfoRef.current ?? findCodeInfo(e.target as HTMLElement);
if (info) {
const loc = `${info.relativePath}:${info.line}:${info.column}`;
console.log("[DevInspector] Open:", loc);
openInEditor(info);
}
setActive(false);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setActive(false);
};
document.addEventListener("mouseover", onMouseOver, true);
document.addEventListener("click", onClick, true);
document.addEventListener("keydown", onKeyDown);
document.body.style.cursor = "crosshair";
return () => {
document.removeEventListener("mouseover", onMouseOver, true);
document.removeEventListener("click", onClick, true);
document.removeEventListener("keydown", onKeyDown);
document.body.style.cursor = "";
if (overlayRef.current) overlayRef.current.style.display = "none";
if (tooltipRef.current) tooltipRef.current.style.display = "none";
};
}, [active, updateOverlay]);
// Hotkey: Ctrl+Shift+Cmd+C (macOS) / Ctrl+Shift+Alt+C
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (
e.key.toLowerCase() === "c" &&
e.ctrlKey &&
e.shiftKey &&
(e.metaKey || e.altKey)
) {
e.preventDefault();
setActive((prev) => !prev);
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
return (
<>
{children}
<div
ref={overlayRef}
style={{
display: "none",
position: "absolute",
pointerEvents: "none",
border: "2px solid #3b82f6",
backgroundColor: "rgba(59,130,246,0.1)",
zIndex: 99999,
transition: "all 0.05s ease",
}}
/>
<div
ref={tooltipRef}
style={{
display: "none",
position: "absolute",
pointerEvents: "none",
backgroundColor: "#1e293b",
color: "#e2e8f0",
fontSize: "12px",
fontFamily: "monospace",
padding: "2px 6px",
borderRadius: "3px",
zIndex: 100000,
whiteSpace: "nowrap",
}}
/>
</>
);
}

View File

@@ -6,7 +6,6 @@ import {
Divider,
Group,
Text,
Title,
useMantineColorScheme,
} from "@mantine/core";
import {
@@ -21,44 +20,10 @@ interface HeaderProps {
}
export function Header({ onSidebarToggle }: HeaderProps) {
const location = useLocation();
const _location = useLocation();
const navigate = useNavigate();
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const navigate = useNavigate();
// Define page titles based on route
const getPageTitle = () => {
switch (location.pathname) {
case "/":
return "Beranda";
case "/kinerja-divisi":
return "Kinerja Divisi";
case "/pengaduan-layanan-publik":
return "Pengaduan & Layanan Publik";
case "/jenna-analytic":
return "Jenna Analytic";
case "/demografi-pekerjaan":
return "Demografi & Kependudukan";
case "/keuangan-anggaran":
return "Keuangan & Anggaran";
case "/bumdes":
return "Bumdes & UMKM Desa";
case "/sosial":
return "Sosial";
case "/keamanan":
return "Keamanan";
case "/bantuan":
return "Bantuan";
case "/pengaturan":
case "/pengaturan/umum":
case "/pengaturan/notifikasi":
case "/pengaturan/keamanan":
case "/pengaturan/akses-dan-tim":
return "Pengaturan";
default:
return "Desa Darmasaba";
}
};
return (
<Group justify="space-between" w="100%">
@@ -77,9 +42,6 @@ export function Header({ onSidebarToggle }: HeaderProps) {
style={{ width: "70%", height: "70%" }}
/>
</ActionIcon>
{/* <Title order={3} c={"white"}>
{getPageTitle()}
</Title> */}
</Group>
{/* Right Section */}

View File

@@ -152,9 +152,9 @@ const HelpPage = () => {
{/* Statistics Section */}
<SimpleGrid cols={3} spacing="lg" mb="xl">
{stats.map((stat, index) => (
{stats.map((stat) => (
<HelpCard
key={index}
key={stat.label}
bg={dark ? "#1E293B" : "white"}
p="lg"
style={{
@@ -192,9 +192,9 @@ const HelpPage = () => {
h="100%"
>
<Box>
{guideItems.map((item, index) => (
{guideItems.map((item) => (
<Box
key={index}
key={item.title}
py="sm"
style={{
borderBottom: "1px solid #eee",
@@ -226,9 +226,9 @@ const HelpPage = () => {
h="100%"
>
<Box>
{videoItems.map((item, index) => (
{videoItems.map((item) => (
<Box
key={index}
key={item.title}
py="sm"
style={{
borderBottom: "1px solid #eee",
@@ -260,13 +260,13 @@ const HelpPage = () => {
h="100%"
>
<Accordion variant="separated">
{faqItems.map((item, index) => (
{faqItems.map((item) => (
<Accordion.Item
style={{
backgroundColor: dark ? "#263852ff" : "#F1F5F9",
}}
key={index}
value={`faq-${index}`}
key={item.question}
value={item.question}
>
<Accordion.Control>{item.question}</Accordion.Control>
<Accordion.Panel>
@@ -335,9 +335,9 @@ const HelpPage = () => {
h="100%"
>
<Box>
{documentationItems.map((item, index) => (
{documentationItems.map((item) => (
<Box
key={index}
key={item.title}
py="sm"
style={{
borderBottom: "1px solid #eee",
@@ -434,6 +434,7 @@ const HelpPage = () => {
disabled={isLoading}
/>
<button
type="button"
onClick={handleSendMessage}
disabled={isLoading || inputValue.trim() === ""}
style={{

View File

@@ -3,7 +3,6 @@ import {
Box,
Card,
Grid,
GridCol,
Group,
Progress,
Stack,
@@ -224,9 +223,9 @@ const JennaAnalytic = () => {
Topik Pertanyaan Terbanyak
</Title>
<Stack gap="xs">
{topTopics.map((item, index) => (
{topTopics.map((item) => (
<Box
key={index}
key={item.topic}
p="sm"
bg={dark ? "#334155" : "#F1F5F9"}
style={{
@@ -270,8 +269,8 @@ const JennaAnalytic = () => {
Jam Tersibuk
</Title>
<Stack gap="md">
{busyHours.map((item, index) => (
<Box key={index}>
{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}

View File

@@ -9,13 +9,13 @@ import {
Text,
ThemeIcon,
Title,
useMantineColorScheme
useMantineColorScheme,
} from "@mantine/core";
import {
IconAlertTriangle,
IconCamera,
IconClock,
IconMapPin
IconMapPin,
} from "@tabler/icons-react";
const KeamananPage = () => {
@@ -120,8 +120,8 @@ const KeamananPage = () => {
<Stack gap={"xs"}>
{/* KPI Cards */}
<Grid gutter="md">
{kpiData.map((kpi, index) => (
<GridCol key={index} span={{ base: 12, sm: 6, md: 6 }}>
{kpiData.map((kpi) => (
<GridCol key={kpi.title} span={{ base: 12, sm: 6, md: 6 }}>
<Card
p="md"
radius="md"
@@ -214,9 +214,9 @@ const KeamananPage = () => {
<Title order={4} c={dark ? "dark.0" : "black"}>
Daftar CCTV
</Title>
{cctvLocations.map((cctv, index) => (
{cctvLocations.map((cctv) => (
<Card
key={index}
key={cctv.id}
p="md"
radius="md"
withBorder
@@ -269,9 +269,9 @@ const KeamananPage = () => {
h="100%"
>
<Stack gap="sm">
{securityReports.map((report, index) => (
{securityReports.map((report) => (
<Card
key={index}
key={report.id}
p="md"
radius="md"
withBorder

View File

@@ -1,4 +1,7 @@
import { Grid, Stack } from "@mantine/core";
import { Card, Grid, Stack } from "@mantine/core";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { apiClient } from "@/utils/api-client";
import { ActivityCard } from "./kinerja-divisi/activity-card";
import { ArchiveCard } from "./kinerja-divisi/archive-card";
import { DiscussionPanel } from "./kinerja-divisi/discussion-panel";
@@ -7,34 +10,6 @@ import { DocumentChart } from "./kinerja-divisi/document-chart";
import { EventCard } from "./kinerja-divisi/event-card";
import { ProgressChart } from "./kinerja-divisi/progress-chart";
// Data for program kegiatan (Section 1)
const programKegiatanData = [
{
title: "Rakor 2025",
date: "3 Juli 2025",
progress: 90,
status: "Selesai" as const,
},
{
title: "Pemutakhiran Indeks Desa",
date: "3 Juli 2025",
progress: 85,
status: "Selesai" as const,
},
{
title: "Mengurus Akta Cerai Warga",
date: "3 Juli 2025",
progress: 80,
status: "Selesai" as const,
},
{
title: "Pasek 7 Desa Adat",
date: "3 Juli 2025",
progress: 92,
status: "Selesai" as const,
},
];
// Data for arsip digital (Section 5)
const archiveData = [
{ name: "Surat Keputusan" },
@@ -44,20 +19,70 @@ const archiveData = [
];
const KinerjaDivisi = () => {
const [activities, setActivities] = useState<any[]>([]);
const [todayEvents, setTodayEvents] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchData() {
try {
const [activityRes, eventRes] = await Promise.all([
apiClient.GET("/api/division/activities"),
apiClient.GET("/api/event/today"),
]);
if (activityRes.data?.data) {
setActivities(activityRes.data.data);
}
if (eventRes.data?.data) {
setTodayEvents(eventRes.data.data);
}
} catch (error) {
console.error("Failed to fetch kinerja divisi data", error);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
// Format events for EventCard
const formattedEvents = todayEvents.map((event) => ({
time: dayjs(event.startDate).format("HH:mm"),
event: event.title,
}));
return (
<Stack gap="lg">
{/* SECTION 1 — PROGRAM KEGIATAN */}
<Grid gutter="md">
{programKegiatanData.map((kegiatan, index) => (
<Grid.Col key={index} span={{ base: 12, md: 6, lg: 3 }}>
{activities.slice(0, 4).map((kegiatan, index) => (
<Grid.Col
key={kegiatan.id || index}
span={{ base: 12, md: 6, lg: 3 }}
>
<ActivityCard
title={kegiatan.title}
date={kegiatan.date}
date={dayjs(kegiatan.createdAt).format("D MMMM YYYY")}
progress={kegiatan.progress}
status={kegiatan.status}
status={
kegiatan.status === "SELESAI"
? "Selesai"
: kegiatan.status === "BERJALAN"
? "Berjalan"
: "Tertunda"
}
/>
</Grid.Col>
))}
{!loading && activities.length === 0 && (
<Grid.Col span={12}>
<Card p="md" radius="xl" withBorder ta="center" c="dimmed">
Tidak ada aktivitas terbaru
</Card>
</Grid.Col>
)}
</Grid>
{/* SECTION 2 — GRID DASHBOARD (3 Columns) */}
@@ -82,7 +107,7 @@ const KinerjaDivisi = () => {
<DiscussionPanel />
{/* SECTION 4 — ACARA HARI INI */}
<EventCard />
<EventCard agendas={formattedEvents} />
{/* SECTION 5 — ARSIP DIGITAL PERANGKAT DESA */}
<Grid gutter="md">

View File

@@ -1,4 +1,11 @@
import { Box, Card, Group, Progress, Text, useMantineColorScheme } from "@mantine/core";
import {
Box,
Card,
Group,
Progress,
Text,
useMantineColorScheme,
} from "@mantine/core";
interface ActivityCardProps {
title: string;

View File

@@ -1,34 +1,48 @@
import {
Box,
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;
}
const divisionData: DivisionItem[] = [
{ name: "Kesejahteraan", count: 37 },
{ name: "Pemerintahan", count: 26 },
{ name: "Keuangan", count: 17 },
{ name: "Sekretaris Desa", count: 15 },
{ name: "Tata Usaha TK", count: 14 },
{ name: "Perangkat Kewilayahan", count: 12 },
{ name: "Pelayanan", count: 10 },
{ name: "Perencanaan", count: 9 },
{ name: "Tata Usaha & Umum", count: 7 },
];
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.map((div: { name: string; _count?: { activities: number } }) => ({
name: div.name,
count: div._count?.activities || 0,
}));
setDivisions(mapped);
}
} catch (error) {
console.error("Failed to fetch divisions", error);
} finally {
setLoading(false);
}
}
fetchDivisions();
}, []);
return (
<Card
p="md"
@@ -47,30 +61,40 @@ export function DivisionList() {
Divisi Teraktif
</Text>
<Stack gap="xs">
{divisionData.map((division, index) => (
<Group
key={index}
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>
{loading ? (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
))}
) : divisions.length > 0 ? (
divisions.map((division, index) => (
<Group
key={index}
justify="space-between"
align="center"
style={{
padding: "8px 12px",
borderRadius: 8,
backgroundColor: dark ? "#334155" : "#F1F5F9",
transition: "background-color 0.2s",
cursor: "pointer",
}}
>
<Text size="sm" c={dark ? "white" : "#1E3A5F"}>
{division.name}
</Text>
<Group gap="xs">
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"}>
{division.count}
</Text>
<ChevronRight size={16} color={dark ? "#94A3B8" : "#64748B"} />
</Group>
</Group>
))
) : (
<Text size="xs" c="dimmed" ta="center">
Tidak ada data divisi
</Text>
)}
</Stack>
</Card>
);

View File

@@ -4,13 +4,17 @@ import {
Card,
Grid,
Group,
Loader,
Stack,
Text,
ThemeIcon,
Title,
useMantineColorScheme,
} from "@mantine/core";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { CheckCircle, Clock, FileText, MessageCircle } from "lucide-react";
import { useEffect, useState } from "react";
import {
Bar,
BarChart,
@@ -22,40 +26,11 @@ import {
XAxis,
YAxis,
} from "recharts";
import { apiClient } from "@/utils/api-client";
// Summary data
const summaryData = [
{
title: "Total Pengaduan",
value: 42,
subtitle: "Bulan ini",
icon: MessageCircle,
color: "#1E3A5F",
},
{
title: "Baru",
value: 14,
subtitle: "Belum diproses",
icon: FileText,
color: "#1E3A5F",
},
{
title: "Diproses",
value: 14,
subtitle: "Sedang ditangani",
icon: Clock,
color: "#1E3A5F",
},
{
title: "Selesai",
value: 14,
subtitle: "Terselesaikan",
icon: CheckCircle,
color: "#1E3A5F",
},
];
dayjs.extend(relativeTime);
// Tren pengaduan data
// Tren pengaduan data (Mock for now)
const trenData = [
{ bulan: "Apr", jumlah: 35 },
{ bulan: "Mei", jumlah: 48 },
@@ -66,50 +41,7 @@ const trenData = [
{ bulan: "Okt", jumlah: 52 },
];
// Surat terbanyak data
const suratData = [
{ jenis: "KTP", jumlah: 24 },
{ jenis: "KK", jumlah: 18 },
{ jenis: "Domisili", jumlah: 15 },
{ jenis: "Usaha", jumlah: 12 },
{ jenis: "Lainnya", jumlah: 8 },
];
// Pengajuan terbaru data
const pengajuanTerbaru = [
{
nama: "Budi Santoso",
jenis: "Ketertiban Umum",
waktu: "2 jam yang lalu",
status: "baru",
},
{
nama: "Siti Rahayu",
jenis: "Pelayanan Kesehatan",
waktu: "5 jam yang lalu",
status: "proses",
},
{
nama: "Ahmad Fauzi",
jenis: "Infrastruktur",
waktu: "1 hari yang lalu",
status: "selesai",
},
{
nama: "Dewi Lestari",
jenis: "Administrasi",
waktu: "1 hari yang lalu",
status: "baru",
},
{
nama: "Joko Widodo",
jenis: "Keamanan",
waktu: "2 hari yang lalu",
status: "proses",
},
];
// Ide inovatif data
// Ide inovatif data (Mock for now)
const ideInovatif = [
{
nama: "Andi Prasetyo",
@@ -123,24 +55,13 @@ const ideInovatif = [
waktu: "5 hari yang lalu",
kategori: "Ekonomi",
},
{
nama: "Bambang Suryono",
judul: "Peningkatan Sanitasi",
waktu: "1 minggu yang lalu",
kategori: "Kesehatan",
},
{
nama: "Lina Marlina",
judul: "Pusat Kreatif Anak Muda",
waktu: "2 minggu yang lalu",
kategori: "Pendidikan",
},
];
const getStatusColor = (status: string) => {
switch (status) {
switch (status.toLowerCase()) {
case "baru":
return "red";
case "diproses":
case "proses":
return "blue";
case "selesai":
@@ -154,6 +75,75 @@ const PengaduanLayananPublik = () => {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const [stats, setStats] = useState({
total: 0,
baru: 0,
proses: 0,
selesai: 0,
});
const [recentComplaints, setRecentComplaints] = useState<any[]>([]);
const [serviceStats, setServiceStats] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchData() {
try {
const [statsRes, recentRes, serviceRes] = await Promise.all([
apiClient.GET("/api/complaint/stats"),
apiClient.GET("/api/complaint/recent"),
apiClient.GET("/api/complaint/service-stats"),
]);
if (statsRes.data?.data) setStats(statsRes.data.data);
if (recentRes.data?.data) setRecentComplaints(recentRes.data.data);
if (serviceRes.data?.data) {
const mappedService = serviceRes.data.data.map((item: any) => ({
jenis: item.letterType,
jumlah: item._count?._all || 0,
}));
setServiceStats(mappedService);
}
} catch (error) {
console.error("Failed to fetch complaint data", error);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
const summaryData = [
{
title: "Total Pengaduan",
value: stats.total,
subtitle: "Bulan ini",
icon: MessageCircle,
color: "#1E3A5F",
},
{
title: "Baru",
value: stats.baru,
subtitle: "Belum diproses",
icon: FileText,
color: "#1E3A5F",
},
{
title: "Diproses",
value: stats.proses,
subtitle: "Sedang ditangani",
icon: Clock,
color: "#1E3A5F",
},
{
title: "Selesai",
value: stats.selesai,
subtitle: "Terselesaikan",
icon: CheckCircle,
color: "#1E3A5F",
},
];
return (
<Stack gap="lg">
{/* TOP SECTION - 4 STAT CARDS */}
@@ -178,7 +168,7 @@ const PengaduanLayananPublik = () => {
{item.title}
</Text>
<Text size="xl" fw={700} c={dark ? "white" : "gray.9"}>
{item.value}
{loading ? <Loader size="xs" /> : item.value}
</Text>
<Text size="xs" c="dimmed">
{item.subtitle}
@@ -189,9 +179,6 @@ const PengaduanLayananPublik = () => {
variant="filled"
size="lg"
radius="xl"
style={{
transition: "transform 0.15s ease",
}}
>
<item.icon style={{ width: "60%", height: "60%" }} />
</ThemeIcon>
@@ -278,35 +265,45 @@ const PengaduanLayananPublik = () => {
Surat Terbanyak
</Title>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={suratData} layout="vertical">
<CartesianGrid
strokeDasharray="3 3"
horizontal={false}
stroke={dark ? "#334155" : "#e5e7eb"}
/>
<XAxis
type="number"
axisLine={false}
tickLine={false}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
/>
<YAxis
type="category"
dataKey="jenis"
axisLine={false}
tickLine={false}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
width={80}
/>
<Tooltip
contentStyle={{
backgroundColor: dark ? "#1E293B" : "white",
borderColor: dark ? "#334155" : "#e5e7eb",
borderRadius: "8px",
}}
/>
<Bar dataKey="jumlah" fill="#396aaaff" radius={[0, 4, 4, 0]} />
</BarChart>
{loading ? (
<Group justify="center" align="center" h="100%">
<Loader />
</Group>
) : (
<BarChart data={serviceStats} layout="vertical">
<CartesianGrid
strokeDasharray="3 3"
horizontal={false}
stroke={dark ? "#334155" : "#e5e7eb"}
/>
<XAxis
type="number"
axisLine={false}
tickLine={false}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
/>
<YAxis
type="category"
dataKey="jenis"
axisLine={false}
tickLine={false}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
width={80}
/>
<Tooltip
contentStyle={{
backgroundColor: dark ? "#1E293B" : "white",
borderColor: dark ? "#334155" : "#e5e7eb",
borderRadius: "8px",
}}
/>
<Bar
dataKey="jumlah"
fill="#396aaaff"
radius={[0, 4, 4, 0]}
/>
</BarChart>
)}
</ResponsiveContainer>
</Card>
</Grid.Col>
@@ -328,42 +325,52 @@ const PengaduanLayananPublik = () => {
Pengajuan Terbaru
</Title>
<Stack gap="sm">
{pengajuanTerbaru.map((item, index) => (
<Card
key={index}
p="sm"
radius="md"
withBorder
bg={dark ? "#334155" : "#F1F5F9"}
style={{
borderColor: "transparent",
transition: "background-color 0.15s ease",
}}
>
<Group justify="space-between">
<Stack gap={0}>
<Text fw={600} c={dark ? "white" : "gray.9"}>
{item.nama}
</Text>
<Text size="sm" c="dimmed">
{item.jenis}
</Text>
</Stack>
<Stack gap={0} align="flex-end">
<Badge
color={getStatusColor(item.status)}
variant="light"
radius="sm"
>
{item.status}
</Badge>
<Text size="xs" c="dimmed">
{item.waktu}
</Text>
</Stack>
</Group>
</Card>
))}
{loading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : recentComplaints.length > 0 ? (
recentComplaints.map((item, index) => (
<Card
key={item.id || index}
p="sm"
radius="md"
withBorder
bg={dark ? "#334155" : "#F1F5F9"}
style={{
borderColor: "transparent",
transition: "background-color 0.15s ease",
}}
>
<Group justify="space-between">
<Stack gap={0}>
<Text fw={600} c={dark ? "white" : "gray.9"}>
{item.title}
</Text>
<Text size="sm" c="dimmed">
{item.category}
</Text>
</Stack>
<Stack gap={0} align="flex-end">
<Badge
color={getStatusColor(item.status)}
variant="light"
radius="sm"
>
{item.status}
</Badge>
<Text size="xs" c="dimmed">
{dayjs(item.createdAt).fromNow()}
</Text>
</Stack>
</Group>
</Card>
))
) : (
<Text c="dimmed" ta="center">
Tidak ada pengajuan terbaru
</Text>
)}
</Stack>
</Card>
</Grid.Col>

View File

@@ -10,12 +10,11 @@
import { createTheme, MantineProvider } from "@mantine/core";
import { ModalsProvider } from "@mantine/modals";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { Inspector } from "react-dev-inspector";
import { createRoot } from "react-dom/client";
import { routeTree } from "./routeTree.gen";
import "./index.css";
import "@mantine/charts/styles.css";
import { IS_DEV, VITE_PUBLIC_URL } from "./utils/env";
import { IS_DEV } from "./utils/env";
// Create a new router instance
export const router = createRouter({
@@ -101,29 +100,14 @@ const theme = createTheme({
primaryColor: "darmasaba-blue",
});
// Use dynamic import for DevInspector to avoid including it in production bundle
const InspectorWrapper = IS_DEV
? Inspector
? (await import("./components/dev-inspector")).DevInspector
: ({ children }: { children: React.ReactNode }) => <>{children}</>;
const elem = document.getElementById("root")!;
const app = (
<InspectorWrapper
keys={["shift", "a"]}
onClickElement={(e) => {
if (!e.codeInfo) return;
const url = VITE_PUBLIC_URL;
fetch(`${url}/__open-in-editor`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
relativePath: e.codeInfo.relativePath,
lineNumber: e.codeInfo.lineNumber,
columnNumber: e.codeInfo.columnNumber,
}),
});
}}
>
<InspectorWrapper>
<MantineProvider theme={theme} defaultColorScheme="auto">
<ModalsProvider>
<RouterProvider router={router} />

View File

@@ -6,7 +6,7 @@ import { Elysia } from "elysia";
import api from "./api";
import { openInEditor } from "./utils/open-in-editor";
const PORT = process.env.PORT || 3000;
const PORT = Number(process.env.PORT || 3000);
const isProduction = process.env.NODE_ENV === "production";
@@ -35,14 +35,16 @@ if (!isProduction) {
app.post("/__open-in-editor", ({ body }) => {
const { relativePath, lineNumber, columnNumber } = body as {
relativePath: string;
lineNumber: number;
columnNumber: number;
lineNumber: string;
columnNumber: string;
};
const editor = (process.env.REACT_EDITOR || "code") as any;
openInEditor(relativePath, {
line: lineNumber,
column: columnNumber,
editor: "antigravity",
line: Number(lineNumber),
column: Number(columnNumber),
editor: editor,
});
return { ok: true };

View File

@@ -11,7 +11,7 @@ export const Route = createRootRoute({
// Apply protected route middleware for all routes
// The middleware will determine which routes are public vs protected
const context = await protectedRouteMiddleware({ location });
// Only set auth store if we have user data (for protected routes)
if (context?.user) {
authStore.user = context?.user as any;

View File

@@ -0,0 +1,53 @@
import path from "node:path";
import type { Plugin } from "vite";
/**
* Vite Plugin to inject data-inspector-* attributes into JSX elements.
* This enables click-to-source functionality in the browser.
*/
export function inspectorPlugin(): Plugin {
const rootDir = process.cwd();
return {
name: "inspector-inject",
enforce: "pre",
transform(code, id) {
// Only process .tsx and .jsx files, skip node_modules
if (!/\.[jt]sx(\?|$)/.test(id) || id.includes("node_modules"))
return null;
if (!code.includes("<")) return null;
const relativePath = path.relative(rootDir, id);
let modified = false;
const lines = code.split("\n");
const result: string[] = [];
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Match JSX opening tags: <Component, <div, or <item.icon
// Allow dots and hyphens in the tag name
const jsxPattern = /(<(?:[A-Za-z][a-zA-Z0-9.-]*))\b/g;
let match: RegExpExecArray | null = null;
// biome-ignore lint/suspicious/noAssignInExpressions: match loop
while ((match = jsxPattern.exec(line)) !== null) {
// Skip if character before `<` is an identifier char (likely a TypeScript generic)
const charBefore = match.index > 0 ? line[match.index - 1] : "";
if (/[a-zA-Z0-9_$.]/.test(charBefore)) continue;
const col = match.index + 1;
const attr = ` data-inspector-line="${i + 1}" data-inspector-column="${col}" data-inspector-relative-path="${relativePath}"`;
const insertPos = match.index + match[0].length;
line = line.slice(0, insertPos) + attr + line.slice(insertPos);
modified = true;
jsxPattern.lastIndex += attr.length;
}
result.push(line);
}
if (!modified) return null;
return result.join("\n");
},
};
}

View File

@@ -1,9 +1,9 @@
import path from "node:path";
import { inspectorServer } from "@react-dev-inspector/vite-plugin";
import tailwindcss from "@tailwindcss/vite";
import { tanstackRouter } from "@tanstack/router-vite-plugin";
import react from "@vitejs/plugin-react";
import { createServer as createViteServer } from "vite";
import { inspectorPlugin } from "./utils/dev-inspector-plugin";
export async function createVite() {
return createViteServer({
@@ -14,23 +14,7 @@ export async function createVite() {
"@": path.resolve(process.cwd(), "./src"),
},
},
plugins: [
tailwindcss(),
react({
babel: {
plugins: [
[
"@react-dev-inspector/babel-plugin",
{
relativePath: true,
},
],
],
},
}),
inspectorServer(),
tanstackRouter(),
],
plugins: [tailwindcss(), inspectorPlugin(), react(), tanstackRouter()],
server: {
middlewareMode: true,
hmr: {