Compare commits

...

67 Commits

Author SHA1 Message Date
11ef320d55 tasks/noc-integration/update-division-list-noc-data/20260330-1628 2026-03-30 17:15:44 +08:00
1b1dc71225 tasks/noc-integration/setup-skills-and-notifications/20260330-1610 2026-03-30 16:58:30 +08:00
2c5fa52608 tasks/noc-integration/update-village-id-to-desa1-and-fix-sync-logic/20260330-1522 2026-03-30 15:27:12 +08:00
f066defcba fix(noc): resolve 401 error on sync endpoint and allow public GET access to monitoring 2026-03-30 14:56:43 +08:00
fd52b0d281 test(noc): add API and E2E tests for NOC synchronization 2026-03-30 14:52:40 +08:00
65844bac7e feat(noc): implement sync management UI and backend integration 2026-03-30 14:48:47 +08:00
3125bc1002 feat(noc): implement NOC API module and sync strategy task 2026-03-30 14:32:12 +08:00
ed93363de1 feat: enhance complaint seeder with 12 complaints across 7 months
Complaint Data Enhancement:
- Increased from 3 complaints to 12 complaints
- Spread across 7 months (Sep 2025 - Mar 2026)
- Each month has 1-2 complaints for meaningful trend data
- Added variety in categories: INFRASTRUKTUR, KETERTIBAN_UMUM, ADMINISTRASI
- Added variety in priorities: RENDAH, SEDANG, TINGGI, DARURAT
- All complaints have realistic createdAt dates

Expected Trend Chart Data:
- Sep 2025: 1 complaint
- Oct 2025: 1 complaint
- Nov 2025: 1 complaint
- Dec 2025: 2 complaints
- Jan 2026: 2 complaints
- Feb 2026: 2 complaints
- Mar 2026: 3 complaints

Files changed:
- prisma/seeders/seed-public-services.ts: Enhanced seedComplaints()

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-27 16:33:59 +08:00
8e2608a2be fix: complaint trends API response type
- Changed response type from strict object to t.Any()
- Fixes 422 Unprocessable Entity error
- Allows flexible response format matching generated types

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-27 16:29:28 +08:00
0736df8523 feat: connect pengaduan-layanan-publik to live database
New API Endpoint:
- GET /api/complaint/trends - Fetch complaint trends for last 7 months

Component Updates:
- Removed hardcoded trenData array (7 months mock data)
- Removed hardcoded ideInovatif array (2 mock ideas)
- Added API calls to /api/complaint/trends and /api/complaint/innovation-ideas
- Added loading states for trend chart and innovation ideas
- Added empty states for both sections
- Connected LineChart to real complaint data
- Connected Innovation Ideas list to real InnovationIdea model

Features Added:
- Real-time complaint trend visualization
- Real innovation ideas from database
- Proper TypeScript typing for API responses
- Loading skeletons during data fetch
- Empty state messages when no data

Files changed:
- src/api/complaint.ts: Added /trends endpoint
- src/components/pengaduan-layanan-publik.tsx: Connected to APIs
- generated/api.ts: Regenerated types

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-27 16:26:24 +08:00
097f9f34cc chore: regenerate API types for new division endpoints
- Generated TypeScript types for /api/division/discussions
- Generated TypeScript types for /api/division/documents/stats
- Generated TypeScript types for /api/division/activities/stats
- Fixes TypeScript errors in progress-chart.tsx component

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-27 15:44:56 +08:00
75c7bc249e feat: connect kinerja divisi components to live database
New API Endpoints (src/api/division.ts):
- GET /api/division/discussions - Fetch recent discussions with sender info
- GET /api/division/documents/stats - Fetch document counts by type
- GET /api/division/activities/stats - Fetch activity status breakdown with percentages

Components Connected to Database:
- discussion-panel.tsx: Now fetches from /api/division/discussions
- document-chart.tsx: Now fetches from /api/division/documents/stats
- progress-chart.tsx: Now fetches from /api/division/activities/stats

Features Added:
- Loading states with Loader component
- Empty states with friendly messages
- Date formatting using date-fns with Indonesian locale
- Real-time data from database instead of hardcoded values
- Proper TypeScript typing for API responses

Files changed:
- src/api/division.ts: Added 3 new API endpoints
- src/components/kinerja-divisi/discussion-panel.tsx
- src/components/kinerja-divisi/document-chart.tsx
- src/components/kinerja-divisi/progress-chart.tsx

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-27 15:43:58 +08:00
b77822f2dd fix: chart surat BigInt serialization error
Root Cause:
- PostgreSQL COUNT(*) returns BigInt (1n)
- Elysia cannot serialize BigInt to JSON
- Frontend receives error instead of data

Solution:
- Cast COUNT(*) to INTEGER in SQL query
- Changed: COUNT(*) as count
- To: COUNT(*)::INTEGER as count
- Now returns regular number instead of BigInt

Files changed:
- src/api/complaint.ts: Fixed service-trends endpoint

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-27 15:04:53 +08:00
5058e2cc1c feat: add enhanced debugging for chart surat
- Added detailed console logging with response structure dump
- Added Array.isArray check for data validation
- Added test data fallback (commented out) for manual testing
- Added emoji indicators for easier debugging

Testing instructions:
1. Open browser DevTools console
2. Look for 📊 📈  ⚠️  emoji logs
3. If no data appears, check if API returns 401 (unauthorized)
4. To test chart rendering, uncomment the DEBUG useEffect block

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-27 14:59:16 +08:00
3bed181805 fix: chart surat barchart not rendering - fix multiple issues
1. ResponsiveContainer Layout Issue:
   - Wrapped in Box with explicit height (300px)
   - Changed ResponsiveContainer height to "100%" (relative to parent)
   - Fixes chart not rendering due to height constraint issue

2. Bar Fill Color:
   - Changed from CSS variable "var(--mantine-color-blue-filled)"
   - To direct hex colors: #3B82F6 (light) / #60A5FA (dark)
   - Fixes bar color not resolving

3. Empty Data Handling:
   - Added check for data.length > 0
   - Shows friendly message when no data available
   - Prevents rendering empty chart

4. YAxis Improvement:
   - Added allowDecimals={false} for cleaner integer display

5. Enhanced Debug Logging:
   - Added emoji icons for easier console scanning
   - Better error messages

Files changed:
- dashboard/chart-surat.tsx: Fixed all chart rendering issues

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-27 14:55:10 +08:00
c7a986aebc fix: remove hardcoded trend percentages and add debug logging
Chart Surat:
- Added console.log debugging to track API response and data mapping
- Helps identify if data is being received but not rendered

Stat Cards:
- Removed hardcoded trend='0%' and trendValue={0} props
- Cards now display clean without misleading 0% trends
- Trend feature can be re-added later with proper API support

Files changed:
- dashboard/chart-surat.tsx: Added debug logging
- dashboard-content.tsx: Removed hardcoded trend props

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-27 14:50:34 +08:00
c6951dec80 fix: add sample data for dashboard stats (weekly service & completed complaints)
- Added 1 service letter with createdAt 5 days ago (within current week)
  → SKT-2025-001 (KTP) now shows in 'Surat Minggu Ini' stat
- Added 1 complaint with status SELESAI
  → COMP-20250320-003 shows in 'Layanan Selesai' stat

Dashboard stats now display:
- Surat Minggu Ini: 1 (was 0)
- Layanan Selesai: 1 (was 0)
- Pengaduan Aktif: 2 (BARU + DIPROSES)
- Total Penduduk: from resident stats

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-27 14:42:09 +08:00
8da53127c7 feat: add duplicate prevention to seed - skip if data already exists
- Added hasExistingData() function to check for existing seed data
- Checks core entities: users (>1), banjars (>=6), divisions (>=4)
- runSeed() now skips entirely if data exists
- runSpecificSeeder() skips non-auth seeders if data exists
- Provides helpful message with reset instructions
- Prevents accidental duplicate data on re-run

Usage:
- bun run seed - Will skip if data exists
- bun x prisma migrate reset - Reset database before re-seeding

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-27 14:36:46 +08:00
354e706dc5 fix: update service letter seed with realistic dates for chart trends
- Added 5 service letters with createdAt dates spread across 6 months
- Changed from upsert to update/create pattern to allow createdAt modification
- This ensures chart-surat.tsx displays proper trend data
- Previous data had all timestamps at seed time, causing empty 6-month trend

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-27 14:34:49 +08:00
c216fa074d [darmasaba-dashboard][2026-03-27] feat: complete all seeders and update Phase 2 schema
Schema Updates:
- Added fields to Umkm model (name, owner, productType, description, timestamps)
- Added fields to Posyandu model (name, location, schedule, type, timestamps)
- Added fields to SecurityReport model (reportNumber, title, description, location, reportedBy, status, timestamps)
- Added fields to EmploymentRecord model (companyName, position, startDate, endDate, isActive, timestamps)
- Added fields to PopulationDynamic model (type, residentName, eventDate, description, timestamps)
- Added fields to BudgetTransaction model (transactionNumber, type, category, amount, description, date, timestamps)
- Added fields to HealthRecord model (type, notes, timestamps)

New Seeders:
- seed-discussions.ts: Documents, Discussions, DivisionMetrics
- seed-phase2.ts: UMKM, Posyandu, SecurityReports, EmploymentRecords, PopulationDynamics, BudgetTransactions

Enhanced Seeders:
- seed-auth.ts: Added seedApiKeys() function
- seed-public-services.ts: Added seedComplaintUpdates() and getComplaintIds()

New NPM Scripts:
- seed:documents - Seed documents and discussions
- seed:phase2 - Seed Phase 2+ features

All 33 Prisma models now have seeder coverage (82% direct, 12% stubs, 6% auto-managed)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-27 14:05:15 +08:00
44b6b158ef [darmasaba-dashboard][2026-03-27] feat: modular seeders and database-backed dashboard
- Split seeders into modular files per feature category
- Added seed:auth, seed:demographics, seed:divisions, seed:services, seed:dashboard commands
- Connected dashboard components to live database (Budget, SDGs, Satisfaction)
- Added API endpoints: /api/dashboard/budget, /api/dashboard/sdgs, /api/dashboard/satisfaction
- Updated prisma schema with dashboard metrics models
- Added loading states to dashboard components
- Fixed header navigation to /admin

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-27 12:14:19 +08:00
34804127c5 Refactor: move AppShell to global layout, add breadcrumbs, and restructure profile routes 2026-03-26 17:10:40 +08:00
0d0dc187a5 chore: fix linting and type safety across the project 2026-03-26 15:51:45 +08:00
ec057ef2e5 feat(dashboard): connect dashboard components to database 2026-03-26 14:28:09 +08:00
0900b8f199 feat(database): implement resident and complaint API and connect DemografiPekerjaan 2026-03-26 14:17:41 +08:00
aeedb17402 fix(header): fix missing Divider, Badge, IconUserShield and navigate 2026-03-26 14:13:59 +08:00
ebc1242bee docs: add task for dev-inspector implementation 2026-03-26 11:50:26 +08:00
0e063cb79e docs: add task for phase 1 core database implementation 2026-03-26 11:14:03 +08:00
3eb84921a1 Fix Konsisten Warna Bg 4 2026-03-25 17:15:07 +08:00
c6415c5aab Fix Konsisten Warna Bg 2 2026-03-25 17:08:39 +08:00
519a14adaa Fix Konsisten Warna Bg 2 2026-03-25 17:02:18 +08:00
366c08fbaa Fix Konsisten Warna Bg 2026-03-25 16:46:59 +08:00
5c09e7a0be Fix Ga Perlu Akses Login Saat Ke Semua Fitur Dashboard 2026-03-25 15:35:20 +08:00
7c8012d277 Fix Lint-1 2026-03-25 15:07:10 +08:00
687ce11a81 Refactor New Ui Semua Pengaturan 2026-03-25 14:38:37 +08:00
1ba4643e23 Refactor New Ui Pengaturan Umum 2026-03-25 11:56:22 +08:00
113dd7ba6f Refactor New Ui Sosial, Keamanan, & Bantuan 2026-03-25 11:10:50 +08:00
71a305cd4b Refactor New Ui Bumdes 02 2026-03-25 10:32:31 +08:00
84b96ca3be Refactor New Ui Bumdes 2026-03-25 00:09:38 +08:00
8159216a2c Refactor ui keuangan 2026-03-24 23:17:23 +08:00
d714c09efc Fix New UI Pengaduan 2026-03-18 00:43:44 +07:00
0a97e31416 Fix New UI Pengaduan 2026-03-18 00:34:53 +07:00
158a2db435 Fix New UI Pengaduan 2026-03-17 21:41:03 +07:00
2d68d4dc06 Fix New UI Kinerja Divisi 2026-03-17 21:19:10 +07:00
97e6caa332 Fix UI Beranda 2026-03-17 21:03:36 +07:00
f0c37272b9 Progress Tampilan UI Dashboard Desa Plus NOC 2026-03-17 20:53:33 +07:00
8c35d58b38 refactor: modularize kinerja-divisi components per PromptDashboard.md
- Create ActivityCard component for program kegiatan cards
- Create DivisionList component for divisi teraktif with arrow icons
- Create DocumentChart component for bar chart (jumlah dokumen)
- Create ProgressChart component for pie chart (progres kegiatan)
- Create DiscussionPanel component for diskusi internal
- Create EventCard component for agenda hari ini
- Create ArchiveCard component for arsip digital perangkat desa
- Refactor main KinerjaDivisi component to use new modular components
- Implement responsive 3-column grid layout
- Add proper dark mode support with specified colors
- Add hover effects and smooth animations

New components structure:
  src/components/kinerja-divisi/
    - activity-card.tsx
    - archive-card.tsx
    - discussion-panel.tsx
    - division-list.tsx
    - document-chart.tsx
    - event-card.tsx
    - progress-chart.tsx
    - index.ts (exports)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-13 16:47:19 +08:00
952f7ecb16 refactor: modularize dashboard components per PromptDashboard.md
- Create reusable StatCard component for header metrics
- Create ChartSurat component for bar chart (surat statistics)
- Create DivisionProgress component for divisi teraktif
- Create ChartAPBDes component for APBDes horizontal bar chart
- Create ActivityList component for calendar events
- Create SatisfactionChart component for donut chart
- Create SDGSCard component for SDGs metrics
- Refactor DashboardContent to use new modular components
- Add proper dark mode support with specified colors
- Implement responsive grid layout (12/6/1 columns)
- Add custom SDGs icons (Energy, Peace, Health, Poverty, Ocean)

New components structure:
  src/components/dashboard/
    - stat-card.tsx
    - chart-surat.tsx
    - chart-apbdes.tsx
    - division-progress.tsx
    - activity-list.tsx
    - satisfaction-chart.tsx
    - sdgs-card.tsx
    - index.ts (exports)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-13 15:36:31 +08:00
a74e0c02e5 fix: resolve merge conflict in pengaturan/route.tsx
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-13 14:41:22 +08:00
17ecd3feca fix: make dashboard public and remove admin-only restriction from main pages
- Make homepage (/) accessible without authentication
- Allow all authenticated users (user & admin) to access main pages:
  - /kinerja-divisi, /pengaduan, /jenna, /demografi
  - /keuangan, /bumdes, /sosial, /keamanan
  - /bantuan, /pengaturan
- Reserve admin-only access for /admin/* routes
- Update auth middleware to handle public routes properly

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-13 14:21:30 +08:00
d88cf2b100 Merge pull request #5 from bipprojectbali/nico/26-feb-26/fix-seed-2
Fix Gambar
2026-03-12 15:21:17 +08:00
e0955ed2c4 Merge branch 'stg' into nico/26-feb-26/fix-seed-2 2026-03-12 15:21:05 +08:00
918399bf62 Fix Gambar 2026-03-12 15:16:41 +08:00
7ce2eb6ae8 Merge pull request #4 from bipprojectbali/nico/27-feb-26/fix-gambar
Add Deploy
2026-03-12 14:47:12 +08:00
40772859f9 Merge branch 'stg' into nico/27-feb-26/fix-gambar 2026-03-12 14:47:03 +08:00
c7b34b8c28 Add Deploy 2026-03-12 14:40:15 +08:00
9bf73a305c Merge pull request #3 from bipprojectbali/fix-ui-berantakan
fix: production build CSS dan responsive layout untuk staging
2026-03-12 12:19:33 +08:00
947adc1537 fix: production build CSS dan responsive layout untuk staging
- Tambah scripts/build.ts untuk build CSS via PostCSS/Tailwind
- Update package.json build script untuk gunakan build script baru
- Fix responsive grid di sosial-page (lg -> md breakpoint)
- Tambah padding responsive untuk mobile display
- Convert inline styles ke Tailwind classes untuk konsistensi
- Update tailwind.config.js content paths
- Tambah CSS variables di index.css untuk color palette
- Update Dockerfile untuk gunakan build script baru

Fixes: tampilan berantakan di staging karena CSS tidak ter-build dengan benar

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-12 12:16:57 +08:00
9086e28961 Merge pull request #2 from bipprojectbali/nico/11-mar-26/fix-ui-to-figma
Nico/11 mar 26/fix UI to figma
2026-03-11 15:29:54 +08:00
66d207c081 feat: refactor UI components to TailwindCSS with dark mode support
- Convert Mantine-based components to TailwindCSS + Recharts
- Add dark mode support for all dashboard pages
- Update routing to allow public dashboard access
- Components refactored:
  - kinreja-divisi.tsx: Village performance dashboard
  - pengaduan-layanan-publik.tsx: Public complaint management
  - jenna-analytic.tsx: Chatbot analytics dashboard
  - demografi-pekerjaan.tsx: Demographic analytics
  - keuangan-anggaran.tsx: APBDes financial dashboard
  - bumdes-page.tsx: UMKM sales monitoring
  - sosial-page.tsx: Village social monitoring
- Remove landing page, redirect / to /dashboard
- Update auth middleware for public dashboard access

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-11 15:26:16 +08:00
b77f6e8fa3 feat: update APBDes data with real values and fix SDGS layout
- Update APBDes data with actual values (390M, 470M, 290M)
- Fix SDGS Desa layout to display horizontally with auto-wrap
- Add responsive grid for SDGS cards (1 col mobile, 2 tablet, 3 desktop)
- Add GitHub OAuth redirectURI configuration

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-10 16:37:33 +08:00
9e6734d1a5 Add .env.example template with environment variables documentation
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-10 13:55:14 +08:00
github-actions[bot]
1b9ddf0f4b chore: sync workflows from base-template 2026-03-10 04:39:12 +00:00
a0f440f6b3 Docker File 2026-03-10 12:36:33 +08:00
1f56dd7660 First Deploy 2026-03-10 10:24:45 +08:00
1a2a213d0a Ganti Image Logo 2026-02-27 14:57:01 +08:00
1ec10fe623 Fix seed-2 2026-02-26 16:30:22 +08:00
163 changed files with 18336 additions and 5952 deletions

20
.env.example Normal file
View File

@@ -0,0 +1,20 @@
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/dashboard_desa?schema=public"
# Authentication
BETTER_AUTH_SECRET="your-secret-key-here-min-32-characters"
ADMIN_EMAIL="admin@example.com"
ADMIN_PASSWORD="admin123"
# GitHub OAuth (Optional)
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
# Application
PORT=3000
NODE_ENV=development
LOG_LEVEL=info
# Public URL
VITE_PUBLIC_URL="http://localhost:3000"
NOC_API_URL="https://darmasaba.muku.id/api/noc/docs/json"

View File

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

106
.github/workflows/publish.yml vendored Normal file
View File

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

60
.github/workflows/re-pull.yml vendored Normal file
View File

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

26
.github/workflows/script/notify.sh vendored Normal file
View File

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

93
.github/workflows/script/re-pull.sh vendored Normal file
View File

@@ -0,0 +1,93 @@
#!/bin/bash
: "${PORTAINER_URL:?PORTAINER_URL tidak di-set}"
: "${PORTAINER_USERNAME:?PORTAINER_USERNAME tidak di-set}"
: "${PORTAINER_PASSWORD:?PORTAINER_PASSWORD tidak di-set}"
: "${STACK_NAME:?STACK_NAME tidak di-set}"
echo "🔐 Autentikasi ke Portainer..."
TOKEN=$(curl -s -X POST https://${PORTAINER_URL}/api/auth \
-H "Content-Type: application/json" \
-d "{\"username\": \"${PORTAINER_USERNAME}\", \"password\": \"${PORTAINER_PASSWORD}\"}" \
| jq -r .jwt)
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "❌ Autentikasi gagal! Cek PORTAINER_URL, USERNAME, dan PASSWORD."
exit 1
fi
echo "🔍 Mencari stack: $STACK_NAME..."
STACK=$(curl -s -X GET https://${PORTAINER_URL}/api/stacks \
-H "Authorization: Bearer ${TOKEN}" \
| jq ".[] | select(.Name == \"$STACK_NAME\")")
if [ -z "$STACK" ]; then
echo "❌ Stack '$STACK_NAME' tidak ditemukan di Portainer!"
echo " Pastikan nama stack sudah benar."
exit 1
fi
STACK_ID=$(echo "$STACK" | jq -r .Id)
ENDPOINT_ID=$(echo "$STACK" | jq -r .EndpointId)
ENV=$(echo "$STACK" | jq '.Env // []')
echo "📄 Mengambil compose file..."
STACK_FILE=$(curl -s -X GET "https://${PORTAINER_URL}/api/stacks/${STACK_ID}/file" \
-H "Authorization: Bearer ${TOKEN}" \
| jq -r .StackFileContent)
PAYLOAD=$(jq -n \
--arg content "$STACK_FILE" \
--argjson env "$ENV" \
'{stackFileContent: $content, env: $env, pullImage: true}')
echo "🚀 Redeploying $STACK_NAME (pull latest image)..."
HTTP_STATUS=$(curl -s -o /tmp/portainer_response.json -w "%{http_code}" \
-X PUT "https://${PORTAINER_URL}/api/stacks/${STACK_ID}?endpointId=${ENDPOINT_ID}" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
if [ "$HTTP_STATUS" != "200" ]; then
echo "❌ Redeploy gagal! HTTP Status: $HTTP_STATUS"
cat /tmp/portainer_response.json | jq .
exit 1
fi
echo "⏳ Menunggu container running..."
MAX_RETRY=15
COUNT=0
while [ $COUNT -lt $MAX_RETRY ]; do
sleep 5
COUNT=$((COUNT + 1))
CONTAINERS=$(curl -s -X GET \
"https://${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/json?all=true&filters=%7B%22label%22%3A%5B%22com.docker.compose.project%3D${STACK_NAME}%22%5D%7D" \
-H "Authorization: Bearer ${TOKEN}")
TOTAL=$(echo "$CONTAINERS" | jq 'length')
RUNNING=$(echo "$CONTAINERS" | jq '[.[] | select(.State == "running")] | length')
FAILED=$(echo "$CONTAINERS" | jq '[.[] | select(.State == "exited" and (.Status | test("Exited \\(0\\)") | not))] | length')
echo "🔄 [${COUNT}/${MAX_RETRY}] Running: ${RUNNING} | Failed: ${FAILED} | Total: ${TOTAL}"
echo "$CONTAINERS" | jq -r '.[] | " → \(.Names[0]) | \(.State) | \(.Status)"'
if [ "$FAILED" -gt "0" ]; then
echo ""
echo "❌ Ada container yang crash!"
echo "$CONTAINERS" | jq -r '.[] | select(.State == "exited" and (.Status | test("Exited \\(0\\)") | not)) | " → \(.Names[0]) | \(.Status)"'
exit 1
fi
if [ "$RUNNING" -gt "0" ]; then
echo ""
echo "✅ Stack $STACK_NAME berhasil di-redeploy dan running!"
exit 0
fi
done
echo ""
echo "❌ Timeout! Stack tidak kunjung running setelah $((MAX_RETRY * 5)) detik."
exit 1

7
.gitignore vendored
View File

@@ -16,6 +16,7 @@ _.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
# Only .env.example is allowed to be committed
.env
.env.development.local
.env.test.local
@@ -33,6 +34,12 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
# Dashboard-MD
Dashboard-MD
# md
*.md
# Playwright artifacts
test-results/
playwright-report/

5
.qwen/settings.json Normal file
View File

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

62
Dockerfile Normal file
View File

@@ -0,0 +1,62 @@
# Stage 1: Build
FROM oven/bun:1.3 AS build
# Install build dependencies for native modules
RUN apt-get update && apt-get install -y \
python3 \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Set the working directory
WORKDIR /app
# Copy package files
COPY package.json bun.lock* ./
# Install dependencies
RUN bun install --frozen-lockfile
# Copy the rest of the application code
COPY . .
# Use .env.example as default env for build
RUN cp .env.example .env
# Generate Prisma client
RUN bun x prisma generate
# Generate API types
RUN bun run gen:api
# Build the application frontend using our custom build script
RUN bun run build
# Stage 2: Runtime
FROM oven/bun:1.3-slim AS runtime
# Set environment variables
ENV NODE_ENV=production
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Set the working directory
WORKDIR /app
# Copy necessary files from build stage
COPY --from=build /app/package.json ./
COPY --from=build /app/tsconfig.json ./
COPY --from=build /app/dist ./dist
COPY --from=build /app/generated ./generated
COPY --from=build /app/src ./src
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/prisma ./prisma
# Expose the port
EXPOSE 3000
# Start the application
CMD ["bun", "start"]

View File

@@ -0,0 +1,86 @@
# TASK: Phase 1 - Implementasi Skema Inti & API Endpoints
**ID:** `TASK-DB-001`
**Konteks:** Database Implementation
**Status:** ✅ COMPLETED (95% Selesai)
**Prioritas:** 🔴 KRITIS (Blokade Fitur)
**Estimasi:** 7 Hari Kerja
---
## 🎯 OBJEKTIF
Mengganti mock data pada fitur-fitur inti (Kinerja Divisi, Pengaduan, Kependudukan) dengan data riil dari database PostgreSQL melalui Prisma ORM dan menyediakan endpoint API yang type-safe menggunakan ElysiaJS.
---
## 📋 DAFTAR TUGAS (TODO)
### 1. Database Migration (Prisma)
- [x] Implementasikan model `Division`, `Activity`, `Document`, `Discussion`, dan `DivisionMetric` di `schema.prisma`.
- [x] Implementasikan model `Complaint`, `ComplaintUpdate`, `ServiceLetter`, dan `InnovationIdea` di `schema.prisma`.
- [x] Implementasikan model `Resident` dan `Banjar` di `schema.prisma`.
- [x] Implementasikan model `Event` di `schema.prisma`.
- [x] Jalankan `bun x prisma migrate dev --name init_core_features`.
- [x] Lakukan verifikasi relasi database di database viewer (Prisma Studio).
### 2. Seeding Data
- [x] Update `prisma/seed.ts` untuk menyertakan data dummy yang realistis untuk:
- 6 Banjar (Darmasaba, Manesa, dll)
- 4 Divisi utama
- Contoh Pengaduan & Layanan Surat
- Contoh Event & Aktivitas
- [x] Jalankan `bun run seed` dan pastikan tidak ada error relasi.
### 3. Backend API Development (ElysiaJS)
- [x] Buat route handler di `src/api/` untuk setiap modul:
- `division.ts`: CRUD Divisi & Aktivitas
- `complaint.ts`: CRUD Pengaduan & Update Status
- `resident.ts`: Endpoint untuk statistik demografi & list penduduk per banjar
- `event.ts`: CRUD Agenda & Kalender
- [x] Integrasikan `apiMiddleware` untuk proteksi rute (Admin/Moderator).
- [x] Pastikan skema input/output didefinisikan menggunakan `t.Object` untuk OpenAPI documentation.
### 4. Contract-First Sync
- [x] Jalankan `bun run gen:api` untuk memperbarui `generated/api.ts`.
- [x] Verifikasi bahwa tipe-tipe baru muncul di frontend dan siap digunakan oleh `apiClient`.
### 5. Frontend Integration (Surgical Update)
- [x] Update `src/hooks/` atau `src/store/` untuk memanggil API riil menggantikan mock data.
- [x] Sambungkan komponen berikut ke API:
- `DashboardContent`: Stat cards (Selesai)
- `KinerjaDivisi`: Division List & Activity Cards (Selesai)
- `PengaduanLayananPublik`: Statistik & Tabel Pengajuan (Selesai)
- `DemografiPekerjaan`: Grafik & Data per Banjar (Pending - Next Step)
---
## 🛠️ INSTRUKSI TEKNIS
### Penanganan Relasi Prisma
Gunakan transaksi atau `onDelete: Cascade` pada relasi yang bergantung secara total (misal: `Activity` ke `Division`) untuk menjaga integritas data.
### Struktur API Route
Contoh struktur yang diharapkan untuk `src/api/division.ts`:
```typescript
export const divisionRoutes = new Elysia({ prefix: '/division' })
.get('/', () => db.division.findMany({ include: { activities: true } }))
.post('/', ({ body }) => db.division.create({ data: body }), {
body: t.Object({ ... })
})
```
---
## ✅ DEFINITION OF DONE (DoD)
1. [ ] Skema database berhasil dimigrasi tanpa error.
2. [ ] API Endpoints muncul di `/api/docs` (Swagger).
3. [ ] `bun run test` (API tests) berhasil untuk endpoint baru.
4. [ ] Frontend menampilkan data riil dari database (bukan mock) pada rute yang ditentukan.
5. [ ] Performa query optimal (tidak ada N+1 problem pada relasi Prisma).
---
## 📝 CATATAN
- Fokus pada **READ** operations terlebih dahulu agar dashboard bisa tampil.
- Fitur **WRITE** (Create/Update) bisa diimplementasikan secara bertahap setelah tampilan dashboard stabil.
- Jangan lupa update `GEMINI.md` jika ada perubahan pada alur pengembangan.

View File

@@ -0,0 +1,76 @@
# TASK: Implementasi Click-to-Source (Dev Inspector)
**ID:** `TASK-DX-001`
**Konteks:** Developer Experience (DX)
**Status:** ✅ COMPLETED
**Prioritas:** 🟡 TINGGI (Peningkatan Produktivitas)
**Estimasi:** 1 Hari Kerja
---
## 🎯 OBJEKTIF
Mengaktifkan fitur **Click-to-Source** di lingkungan pengembangan: klik elemen UI di browser sambil menekan hotkey (`Ctrl+Shift+Cmd+C` atau `Ctrl+Shift+Alt+C`) untuk langsung membuka file source code di editor (VS Code, Cursor, dll) pada baris dan kolom yang tepat.
---
## 📋 DAFTAR TUGAS (TODO)
### 1. Vite Plugin Configuration
- [x] Buat file `src/utils/dev-inspector-plugin.ts` yang berisi `inspectorPlugin()` (regex-based JSX attribute injection).
- [x] Modifikasi `src/vite.ts`:
- [x] Impor `inspectorPlugin`.
- [x] Tambahkan `inspectorPlugin()` ke array `plugins` **sebelum** `react()`.
- [x] Gunakan `enforce: 'pre'` pada plugin tersebut.
### 2. Frontend Component Development
- [x] Buat komponen `src/components/dev-inspector.tsx`:
- [x] Implementasikan hotkey listener.
- [x] Tambahkan overlay UI (border biru & tooltip nama file) saat hover.
- [x] Implementasikan `getCodeInfoFromElement` dengan fallback (fiber props -> DOM attributes).
- [x] Tambahkan fungsi `openInEditor` (POST ke `/__open-in-editor`).
### 3. Backend Integration (Elysia)
- [x] Modifikasi `src/index.ts`:
- [x] Tambahkan handler `onRequest` sebelum middleware lainnya.
- [x] Intercept request ke path `/__open-in-editor` (POST).
- [x] Gunakan `Bun.spawn()` untuk memanggil editor (berdasarkan `.env` `REACT_EDITOR`).
- [x] Gunakan `Bun.which()` untuk verifikasi keberadaan editor di system PATH.
### 4. Application Root Integration
- [x] Modifikasi `src/frontend.tsx`:
- [x] Implementasikan **Conditional Dynamic Import** untuk `DevInspector`.
- [x] Gunakan `import.meta.env?.DEV` agar tidak ada overhead di production.
- [x] Bungkus `<App />` (atau router) dengan `<DevInspectorWrapper>`.
### 5. Environment Setup
- [x] Tambahkan `REACT_EDITOR=code` (atau `cursor`, `windsurf`) di file `.env`.
- [x] Pastikan alias `@/` berfungsi dengan benar di plugin Vite untuk resolusi path.
---
## 🛠️ INSTRUKSI TEKNIS
### Urutan Plugin di Vite
Sangat krusial agar `inspectorPlugin` berjalan di fase **pre-transform** sebelum JSX diubah menjadi `React.createElement` oleh compiler Rust (OXC) milik Vite React Plugin.
### Penanganan React 19
Gunakan strategi *multi-fallback* karena React 19 menghapus `_debugSource`. Prioritas pencarian info:
1. `__reactProps$*` (React internal props)
2. `__reactFiber$*` (Fiber tree walk-up)
3. DOM attribute `data-inspector-*` (Fallback universal)
---
## ✅ DEFINITION OF DONE (DoD)
1. [ ] Hotkey `Ctrl+Shift+Cmd+C` (macOS) / `Ctrl+Shift+Alt+C` mengaktifkan mode inspeksi.
2. [ ] Klik pada elemen UI membuka file yang benar di VS Code/Cursor pada baris yang tepat.
3. [ ] Fitur hanya aktif di mode pengembangan (`bun run dev`).
4. [ ] Di mode produksi (`bun run build`), tidak ada kode `DevInspector` yang masuk ke bundle (verifikasi via `dist/` jika perlu).
5. [ ] Kode mengikuti standar linting Biome (jalankan `bun run lint`).
---
## 📝 CATATAN
- Gunakan `Bun.spawn()` dengan mode `detached: true` jika memungkinkan (atau default fire-and-forget).
- Jika menggunakan Windows (WSL), pastikan path file dikonversi dengan benar (jika ada kendala).
- Gunakan log di console saat mode inspeksi aktif untuk mempermudah debugging.

168
Pengaduan-New.md Normal file
View File

@@ -0,0 +1,168 @@
Create a modern analytics dashboard UI for a village complaint system (Pengaduan Dashboard).
Tech stack:
- React 19 + Vite (Bun runtime)
- Mantine UI (core components)
- TailwindCSS (layout & spacing only)
- Recharts (charts)
- TanStack Router
- Icons: lucide-react
- State: Valtio
- Date: dayjs
---
## 🎨 DESIGN STYLE
- Clean, minimal, and soft dashboard
- Background: light gray (#f3f4f6)
- Card: white with subtle shadow
- Border radius: 16px24px (rounded-2xl)
- Typography: medium contrast (not too bold)
- Primary color: navy blue (#1E3A5F)
- Accent: soft blue + neutral gray
- Icons inside circular solid background
Spacing:
- Use gap-6 consistently
- Internal padding: p-5 or p-6
- Layout must feel breathable (no clutter)
---
## 🧱 LAYOUT STRUCTURE
### 🔹 TOP SECTION (4 STAT CARDS - GRID)
Grid: 4 columns (responsive → 2 / 1)
Each card contains:
- Title (small, muted)
- Big number (bold, large)
- Subtitle (small gray text)
- Right side: circular icon container
Example:
- Total Pengaduan → 42 → "Bulan ini"
- Baru → 14 → "Belum diproses"
- Diproses → 14 → "Sedang ditangani"
- Selesai → 14 → "Terselesaikan"
Use:
- Mantine Card
- Group justify="space-between"
- Icon inside circle (bg navy, icon white)
---
## 📈 MAIN CHART (FULL WIDTH)
Title: "Tren Pengaduan"
- Use Recharts LineChart
- Smooth line (monotone)
- Show dots on each point
- Data: Apr → Okt
- Value range: 3060
Style:
- Minimal grid (light dashed)
- No heavy colors (use gray/blue line)
- Rounded container card
- Add small top-right icon (expand)
---
## 📊 BOTTOM SECTION (3 COLUMN GRID)
### 🔹 LEFT: "Surat Terbanyak"
- Horizontal bar chart (Recharts)
- Categories:
- KTP
- KK
- Domisili
- Usaha
- Lainnya
Style:
- Dark blue bars
- Rounded edges
- Clean axis
---
### 🔹 CENTER: "Pengajuan Terbaru"
List of activity cards:
Each item:
- Name (bold)
- Subtitle (jenis surat)
- Time (small text)
- Status badge (kanan)
Status:
- baru → red
- proses → blue
- selesai → green
Style:
- Card per item
- Soft border
- Rounded
- Compact spacing
---
### 🔹 RIGHT: "Ajuan Ide Inovatif"
List mirip dengan pengajuan terbaru:
Each item:
- Nama
- Judul ide
- Waktu
- Button kecil "Detail"
Style:
- Right-aligned action button
- Light border
- Clean spacing
---
## ⚙️ COMPONENT STRUCTURE
components/
- StatCard.tsx
- LineChartCard.tsx
- BarChartCard.tsx
- ActivityList.tsx
- IdeaList.tsx
routes/
- dashboard.tsx
---
## ✨ INTERACTIONS (IMPORTANT)
- Hover card → scale(1.02)
- Transition: 150ms ease
- Icon circle slightly pop on hover
- List item hover → subtle bg change
---
## 🎯 UX DETAILS
- Numbers must be visually dominant
- Icons must balance layout (not too big)
- Avoid heavy borders
- Keep everything aligned perfectly
- No clutter
---
## 🚀 OUTPUT
- Modular React components (NOT one file)
- Clean code (production-ready)
- Use Mantine properly (no hacky inline styles unless needed)
- Use Tailwind only for layout/grid/spacing

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

93
__tests__/api/noc.test.ts Normal file
View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from "bun:test";
import api from "@/api";
import { prisma } from "@/utils/db";
describe("NOC API Module", () => {
const idDesa = "desa1";
it("should return last sync timestamp", async () => {
const response = await api.handle(
new Request(`http://localhost/api/noc/last-sync?idDesa=${idDesa}`),
);
expect(response.status).toBe(200);
const data = await response.json();
expect(data).toHaveProperty("lastSyncedAt");
});
it("should return active divisions", async () => {
const response = await api.handle(
new Request(`http://localhost/api/noc/active-divisions?idDesa=${idDesa}`),
);
expect(response.status).toBe(200);
const data = await response.json();
expect(Array.isArray(data.data)).toBe(true);
});
it("should return latest projects", async () => {
const response = await api.handle(
new Request(`http://localhost/api/noc/latest-projects?idDesa=${idDesa}`),
);
expect(response.status).toBe(200);
const data = await response.json();
expect(Array.isArray(data.data)).toBe(true);
});
it("should return upcoming events", async () => {
const response = await api.handle(
new Request(`http://localhost/api/noc/upcoming-events?idDesa=${idDesa}`),
);
expect(response.status).toBe(200);
const data = await response.json();
expect(Array.isArray(data.data)).toBe(true);
});
it("should return diagram jumlah document", async () => {
const response = await api.handle(
new Request(
`http://localhost/api/noc/diagram-jumlah-document?idDesa=${idDesa}`,
),
);
expect(response.status).toBe(200);
const data = await response.json();
expect(Array.isArray(data.data)).toBe(true);
});
it("should return diagram progres kegiatan", async () => {
const response = await api.handle(
new Request(
`http://localhost/api/noc/diagram-progres-kegiatan?idDesa=${idDesa}`,
),
);
expect(response.status).toBe(200);
const data = await response.json();
expect(Array.isArray(data.data)).toBe(true);
});
it("should return latest discussion", async () => {
const response = await api.handle(
new Request(
`http://localhost/api/noc/latest-discussion?idDesa=${idDesa}`,
),
);
expect(response.status).toBe(200);
const data = await response.json();
expect(Array.isArray(data.data)).toBe(true);
});
it("should return 400 for missing idDesa in active-divisions", async () => {
const response = await api.handle(
new Request("http://localhost/api/noc/active-divisions"),
);
// Elysia returns 400 or 422 for validation errors
expect([400, 422]).toContain(response.status);
});
it("should return 401 or 422 for sync without admin auth", async () => {
const response = await api.handle(
new Request("http://localhost/api/noc/sync", {
method: "POST",
}),
);
expect([401, 422]).toContain(response.status);
});
});

View File

@@ -0,0 +1,110 @@
import { expect, test } from "@playwright/test";
test.describe("NOC Synchronization UI", () => {
test.beforeEach(async ({ page }) => {
// Mock the session API to simulate being logged in as an admin
await page.route("**/api/session", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
user: {
id: "user_123",
name: "Admin User",
email: "admin@example.com",
role: "admin",
},
},
}),
});
});
// Mock the last-sync API
await page.route("**/api/noc/last-sync*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
lastSyncedAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
}),
});
});
});
test("should navigate to NOC Sync page from sidebar", async ({ page }) => {
await page.goto("/");
// Open Settings/Pengaturan submenu if not open
const settingsNavLink = page.locator('button:has-text("Pengaturan")');
await settingsNavLink.click();
// Click on Sinkronisasi NOC
const syncNavLink = page.locator('a:has-text("Sinkronisasi NOC")');
// In Mantine NavLink with navigate, it might be a button or div with role button depending on implementation
// Based on Sidebar.tsx, it's a MantineNavLink which renders as a button or anchor
const syncLink = page.getByRole("button", { name: "Sinkronisasi NOC" });
await syncLink.click();
// Verify we are on the sync page
await expect(page).toHaveURL(/\/pengaturan\/sinkronisasi/);
await expect(page.locator("h2")).toContainText("Sinkronisasi Data NOC");
});
test("should perform synchronization successfully", async ({ page }) => {
await page.goto("/pengaturan/sinkronisasi");
// Initial state check
await expect(page.locator("text=Waktu Sinkronisasi Terakhir:")).toBeVisible();
// Mock the sync API
const now = new Date().toISOString();
await page.route("**/api/noc/sync", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
message: "Sinkronisasi berhasil diselesaikan",
lastSyncedAt: now,
}),
});
}
});
// Click Sync button
await page.click('button:has-text("Sinkronkan Sekarang")');
// Verify success message
await expect(page.locator("text=Sinkronisasi berhasil dilakukan")).toBeVisible();
// Verify timestamp updated (it should show "beberapa detik yang lalu" or similar because of dayjs fromNow)
// We can just check if the new time format is there or the relative time updated
await expect(page.locator("text=beberapa detik yang lalu")).toBeVisible();
});
test("should handle synchronization error", async ({ page }) => {
await page.goto("/pengaturan/sinkronisasi");
// Mock the sync API failure
await page.route("**/api/noc/sync", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
status: 200, // API returns 200 but with success: false for business logic errors
contentType: "application/json",
body: JSON.stringify({
success: false,
error: "Sinkronisasi gagal dijalankan",
}),
});
}
});
// Click Sync button
await page.click('button:has-text("Sinkronkan Sekarang")');
// Verify error message
await expect(page.locator("text=Sinkronisasi gagal dijalankan")).toBeVisible();
});
});

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=="],

File diff suppressed because it is too large Load Diff

269
generated/noc-external.ts Normal file
View File

@@ -0,0 +1,269 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/api/noc/active-divisions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Divisi Teraktif
* @description Menu Beranda - Mendapatkan daftar divisi teraktif berdasarkan jumlah proyek pada desa tertentu.
*/
get: operations["getApiNocActive-divisions"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/latest-projects": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Latest Projects General
* @description Menu kinerja divisi - Mendapatkan daftar proyek umum terbaru dari berbagai grup pada desa tertentu.
*/
get: operations["getApiNocLatest-projects"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/upcoming-events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Events (Today & Upcoming)
* @description Menu beranda dan kinerja divisi - Mendapatkan daftar event pada hari ini dan yang akan datang untuk semua divisi pada desa tertentu.
*/
get: operations["getApiNocUpcoming-events"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/diagram-jumlah-document": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Diagram Jumlah Document
* @description Menu kinerja divisi - Mendapatkan diagram jumlah document pada desa tertentu.
*/
get: operations["getApiNocDiagram-jumlah-document"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/diagram-progres-kegiatan": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Diagram Progres Kegiatan
* @description Menu kinerja divisi - Mendapatkan diagram progres kegiatan pada desa tertentu.
*/
get: operations["getApiNocDiagram-progres-kegiatan"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/latest-discussion": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Latest Discussion
* @description Menu kinerja divisi - Mendapatkan latest discussion pada desa tertentu.
*/
get: operations["getApiNocLatest-discussion"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: never;
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
"getApiNocActive-divisions": {
parameters: {
query: {
/** @description ID Desa yang ingin dicari */
idDesa: string;
/** @description Jumlah maksimal data (default: 5) */
limit?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
"getApiNocLatest-projects": {
parameters: {
query: {
/** @description ID Desa yang ingin dicari */
idDesa: string;
/** @description Jumlah maksimal proyek (default: 5, maks: 50) */
limit?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
"getApiNocUpcoming-events": {
parameters: {
query: {
/** @description ID Desa yang ingin dicari */
idDesa: string;
/** @description Jumlah maksimal event (default: 10, maks: 50) */
limit?: string;
/** @description Filter event: 'today' atau 'upcoming' */
filter?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
"getApiNocDiagram-jumlah-document": {
parameters: {
query: {
/** @description ID Desa yang ingin dicari */
idDesa: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
"getApiNocDiagram-progres-kegiatan": {
parameters: {
query: {
/** @description ID Desa yang ingin dicari */
idDesa: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
"getApiNocLatest-discussion": {
parameters: {
query: {
/** @description ID Desa yang ingin dicari */
idDesa: string;
/** @description Limit data */
limit?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
}

File diff suppressed because it is too large Load Diff

BIN
mantine-expert.skill Normal file

Binary file not shown.

View File

@@ -4,17 +4,25 @@
"private": true,
"type": "module",
"scripts": {
"dev": "bun run gen:api && REACT_EDITOR=antigravity bun --hot src/index.ts",
"dev": "lsof -ti:3000 | xargs kill -9 2>/dev/null || true; bun run gen:api && REACT_EDITOR=antigravity bun --hot src/index.ts",
"lint": "biome check .",
"check": "biome check --write .",
"format": "biome format --write .",
"gen:api": "bun scripts/generate-schema.ts && bun x openapi-typescript generated/schema.json -o generated/api.ts",
"sync:noc": "bun scripts/sync-noc.ts",
"test": "bun test __tests__/api",
"test:ui": "bun test --ui __tests__/api",
"test:e2e": "bun run build && playwright test",
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='VITE_*'",
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='VITE_*' && cp -r public/* dist/ 2>/dev/null || true",
"start": "NODE_ENV=production bun src/index.ts",
"seed": "bun prisma/seed.ts"
"seed": "bun prisma/seed.ts",
"seed:auth": "bun prisma/seed.ts auth",
"seed:demographics": "bun prisma/seed.ts demographics",
"seed:divisions": "bun prisma/seed.ts divisions",
"seed:services": "bun prisma/seed.ts services",
"seed:documents": "bun prisma/seed.ts documents",
"seed:dashboard": "bun prisma/seed.ts dashboard",
"seed:phase2": "bun prisma/seed.ts phase2"
},
"dependencies": {
"@better-auth/cli": "^1.4.18",
@@ -104,7 +112,6 @@
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prisma": "^6.19.2",
"react-dev-inspector": "^2.0.1",
"vite": "^7.3.1"
}
}

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,58 @@
/*
Warnings:
- You are about to drop the `Budget` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Budget" DROP CONSTRAINT "Budget_approvedBy_fkey";
-- DropTable
DROP TABLE "Budget";
-- CreateTable
CREATE TABLE "budget" (
"id" TEXT NOT NULL,
"category" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL DEFAULT 0,
"percentage" DOUBLE PRECISION NOT NULL DEFAULT 0,
"color" TEXT NOT NULL DEFAULT '#3B82F6',
"fiscalYear" INTEGER NOT NULL DEFAULT 2025,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "budget_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sdgs_score" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"score" DOUBLE PRECISION NOT NULL DEFAULT 0,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sdgs_score_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "satisfaction_rating" (
"id" TEXT NOT NULL,
"category" TEXT NOT NULL,
"value" INTEGER NOT NULL DEFAULT 0,
"color" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "satisfaction_rating_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "budget_category_fiscalYear_key" ON "budget"("category", "fiscalYear");
-- CreateIndex
CREATE UNIQUE INDEX "sdgs_score_title_key" ON "sdgs_score"("title");
-- CreateIndex
CREATE UNIQUE INDEX "satisfaction_rating_category_key" ON "satisfaction_rating"("category");

View File

@@ -0,0 +1,15 @@
/*
Warnings:
- Added the required column `name` to the `Umkm` table without a default value. This is not possible if the table is not empty.
- Added the required column `owner` to the `Umkm` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `Umkm` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Umkm" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "description" TEXT,
ADD COLUMN "name" TEXT NOT NULL,
ADD COLUMN "owner" TEXT NOT NULL,
ADD COLUMN "productType" TEXT,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;

View File

@@ -0,0 +1,36 @@
/*
Warnings:
- A unique constraint covering the columns `[reportNumber]` on the table `SecurityReport` will be added. If there are existing duplicate values, this will fail.
- Added the required column `location` to the `Posyandu` table without a default value. This is not possible if the table is not empty.
- Added the required column `name` to the `Posyandu` table without a default value. This is not possible if the table is not empty.
- Added the required column `schedule` to the `Posyandu` table without a default value. This is not possible if the table is not empty.
- Added the required column `type` to the `Posyandu` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `Posyandu` table without a default value. This is not possible if the table is not empty.
- Added the required column `description` to the `SecurityReport` table without a default value. This is not possible if the table is not empty.
- Added the required column `reportNumber` to the `SecurityReport` table without a default value. This is not possible if the table is not empty.
- Added the required column `reportedBy` to the `SecurityReport` table without a default value. This is not possible if the table is not empty.
- Added the required column `title` to the `SecurityReport` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `SecurityReport` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Posyandu" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "location" TEXT NOT NULL,
ADD COLUMN "name" TEXT NOT NULL,
ADD COLUMN "schedule" TEXT NOT NULL,
ADD COLUMN "type" TEXT NOT NULL,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
-- AlterTable
ALTER TABLE "SecurityReport" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "description" TEXT NOT NULL,
ADD COLUMN "location" TEXT,
ADD COLUMN "reportNumber" TEXT NOT NULL,
ADD COLUMN "reportedBy" TEXT NOT NULL,
ADD COLUMN "status" TEXT NOT NULL DEFAULT 'BARU',
ADD COLUMN "title" TEXT NOT NULL,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "SecurityReport_reportNumber_key" ON "SecurityReport"("reportNumber");

View File

@@ -0,0 +1,49 @@
/*
Warnings:
- A unique constraint covering the columns `[transactionNumber]` on the table `BudgetTransaction` will be added. If there are existing duplicate values, this will fail.
- Added the required column `amount` to the `BudgetTransaction` table without a default value. This is not possible if the table is not empty.
- Added the required column `category` to the `BudgetTransaction` table without a default value. This is not possible if the table is not empty.
- Added the required column `date` to the `BudgetTransaction` table without a default value. This is not possible if the table is not empty.
- Added the required column `transactionNumber` to the `BudgetTransaction` table without a default value. This is not possible if the table is not empty.
- Added the required column `type` to the `BudgetTransaction` table without a default value. This is not possible if the table is not empty.
- Added the required column `companyName` to the `EmploymentRecord` table without a default value. This is not possible if the table is not empty.
- Added the required column `position` to the `EmploymentRecord` table without a default value. This is not possible if the table is not empty.
- Added the required column `startDate` to the `EmploymentRecord` table without a default value. This is not possible if the table is not empty.
- Added the required column `type` to the `HealthRecord` table without a default value. This is not possible if the table is not empty.
- Added the required column `eventDate` to the `PopulationDynamic` table without a default value. This is not possible if the table is not empty.
- Added the required column `residentName` to the `PopulationDynamic` table without a default value. This is not possible if the table is not empty.
- Added the required column `type` to the `PopulationDynamic` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "BudgetTransaction" ADD COLUMN "amount" DOUBLE PRECISION NOT NULL,
ADD COLUMN "category" TEXT NOT NULL,
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "date" TIMESTAMP(3) NOT NULL,
ADD COLUMN "description" TEXT,
ADD COLUMN "transactionNumber" TEXT NOT NULL,
ADD COLUMN "type" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "EmploymentRecord" ADD COLUMN "companyName" TEXT NOT NULL,
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "endDate" TIMESTAMP(3),
ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "position" TEXT NOT NULL,
ADD COLUMN "startDate" TIMESTAMP(3) NOT NULL;
-- AlterTable
ALTER TABLE "HealthRecord" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "notes" TEXT,
ADD COLUMN "type" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "PopulationDynamic" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "description" TEXT,
ADD COLUMN "eventDate" TIMESTAMP(3) NOT NULL,
ADD COLUMN "residentName" TEXT NOT NULL,
ADD COLUMN "type" TEXT NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "BudgetTransaction_transactionNumber_key" ON "BudgetTransaction"("transactionNumber");

View File

@@ -0,0 +1,44 @@
/*
Warnings:
- A unique constraint covering the columns `[externalId]` on the table `activity` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[externalId]` on the table `discussion` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[externalId]` on the table `division` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[externalId]` on the table `document` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[externalId]` on the table `event` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "activity" ADD COLUMN "externalId" TEXT,
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
-- AlterTable
ALTER TABLE "discussion" ADD COLUMN "externalId" TEXT,
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
-- AlterTable
ALTER TABLE "division" ADD COLUMN "externalId" TEXT,
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
-- AlterTable
ALTER TABLE "document" ADD COLUMN "externalId" TEXT,
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
-- AlterTable
ALTER TABLE "event" ADD COLUMN "externalId" TEXT,
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
-- CreateIndex
CREATE UNIQUE INDEX "activity_externalId_key" ON "activity"("externalId");
-- CreateIndex
CREATE UNIQUE INDEX "discussion_externalId_key" ON "discussion"("externalId");
-- CreateIndex
CREATE UNIQUE INDEX "division_externalId_key" ON "division"("externalId");
-- CreateIndex
CREATE UNIQUE INDEX "document_externalId_key" ON "document"("externalId");
-- CreateIndex
CREATE UNIQUE INDEX "event_externalId_key" ON "event"("externalId");

View File

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

View File

@@ -0,0 +1,15 @@
-- AlterTable
ALTER TABLE "activity" ALTER COLUMN "villageId" SET DEFAULT 'desa1';
-- AlterTable
ALTER TABLE "discussion" ALTER COLUMN "villageId" SET DEFAULT 'desa1';
-- AlterTable
ALTER TABLE "division" ADD COLUMN "externalActivityCount" INTEGER NOT NULL DEFAULT 0,
ALTER COLUMN "villageId" SET DEFAULT 'desa1';
-- AlterTable
ALTER TABLE "document" ALTER COLUMN "villageId" SET DEFAULT 'desa1';
-- AlterTable
ALTER TABLE "event" ALTER COLUMN "villageId" SET DEFAULT 'desa1';

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,545 @@ model User {
sessions Session[]
apiKeys ApiKey[]
// Relations
discussions Discussion[]
events Event[]
complaints Complaint[] @relation("ComplaintReporter")
assignedComplaints Complaint[] @relation("ComplaintAssignee")
complaintUpdates ComplaintUpdate[]
serviceLetters ServiceLetter[]
innovationIdeas InnovationIdea[] @relation("IdeaReviewer")
healthRecords HealthRecord[]
populationDynamics PopulationDynamic[]
budgetTransactions BudgetTransaction[]
posyandus Posyandu[]
securityReports SecurityReport[]
@@map("user")
}
// --- KATEGORI 1: KINERJA DIVISI & AKTIVITAS ---
model Division {
id String @id @default(cuid())
externalId String? @unique // ID asli dari server NOC
villageId String? @default("desa1") // ID Desa dari sistem NOC
name String @unique
description String?
color String @default("#1E3A5F")
isActive Boolean @default(true)
externalActivityCount Int @default(0) // Total kegiatan dari sistem NOC (misal: 47)
lastSyncedAt DateTime? // Terakhir kali sinkronisasi dilakukan
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
activities Activity[]
documents Document[]
discussions Discussion[]
divisionMetrics DivisionMetric[]
@@map("division")
}
model Activity {
id String @id @default(cuid())
externalId String? @unique // ID asli dari server NOC
villageId String? @default("desa1")
title String
description String?
divisionId String
startDate DateTime?
endDate DateTime?
dueDate DateTime?
progress Int @default(0) // 0-100
status ActivityStatus @default(BERJALAN)
priority Priority @default(SEDANG)
assignedTo String? // JSON array of user IDs
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
division Division @relation(fields: [divisionId], references: [id], onDelete: Cascade)
@@index([divisionId])
@@index([status])
@@map("activity")
}
model Document {
id String @id @default(cuid())
externalId String? @unique // ID asli dari server NOC
villageId String? @default("desa1")
title String
category DocumentCategory
type String // "Gambar", "Dokumen", "PDF", etc
fileUrl String
fileSize Int? // in bytes
divisionId String?
uploadedBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
division Division? @relation(fields: [divisionId], references: [id], onDelete: SetNull)
@@index([category])
@@index([divisionId])
@@map("document")
}
model Discussion {
id String @id @default(cuid())
externalId String? @unique // ID asli dari server NOC
villageId String? @default("desa1")
message String
senderId String
parentId String? // For threaded discussions
divisionId String?
isResolved Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
parent Discussion? @relation("DiscussionThread", fields: [parentId], references: [id], onDelete: SetNull)
replies Discussion[] @relation("DiscussionThread")
division Division? @relation(fields: [divisionId], references: [id], onDelete: SetNull)
@@index([divisionId])
@@index([createdAt])
@@map("discussion")
}
model Event {
id String @id @default(cuid())
externalId String? @unique // ID asli dari server NOC
villageId String? @default("desa1")
title String
description String?
eventType EventType
startDate DateTime
endDate DateTime?
location String?
isAllDay Boolean @default(false)
isRecurring Boolean @default(false)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creator User @relation(fields: [createdBy], references: [id], onDelete: Cascade)
@@index([startDate])
@@index([eventType])
@@map("event")
}
model DivisionMetric {
id String @id @default(cuid())
divisionId String
period String // "2025-Q1", "2025-01"
activityCount Int @default(0)
completionRate Float @default(0)
avgProgress Float @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
division Division @relation(fields: [divisionId], references: [id], onDelete: Cascade)
@@unique([divisionId, period])
@@map("division_metric")
}
// --- KATEGORI 2: PENGADUAN & LAYANAN PUBLIK ---
model Complaint {
id String @id @default(cuid())
complaintNumber String @unique // Auto-generated: COMPLAINT-YYYYMMDD-XXX
title String
description String
category ComplaintCategory
status ComplaintStatus @default(BARU)
priority Priority @default(SEDANG)
reporterId String?
reporterPhone String?
reporterEmail String?
isAnonymous Boolean @default(false)
assignedTo String? // User ID
resolvedBy String? // User ID
resolvedAt DateTime?
location String?
imageUrl String[] // Array of image URLs
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
reporter User? @relation("ComplaintReporter", fields: [reporterId], references: [id], onDelete: SetNull)
assignee User? @relation("ComplaintAssignee", fields: [assignedTo], references: [id], onDelete: SetNull)
complaintUpdates ComplaintUpdate[]
@@index([status])
@@index([category])
@@index([createdAt])
@@map("complaint")
}
model ComplaintUpdate {
id String @id @default(cuid())
complaintId String
message String
status ComplaintStatus?
updatedBy String
createdAt DateTime @default(now())
complaint Complaint @relation(fields: [complaintId], references: [id], onDelete: Cascade)
updater User @relation(fields: [updatedBy], references: [id], onDelete: Cascade)
@@index([complaintId])
@@map("complaint_update")
}
model ServiceLetter {
id String @id @default(cuid())
letterNumber String @unique
letterType LetterType
applicantName String
applicantNik String
applicantAddress String
purpose String?
status ServiceStatus @default(BARU)
processedBy String?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
processor User? @relation(fields: [processedBy], references: [id], onDelete: SetNull)
@@index([letterType])
@@index([status])
@@index([createdAt])
@@map("service_letter")
}
model InnovationIdea {
id String @id @default(cuid())
title String
description String
category String // "Teknologi", "Ekonomi", "Kesehatan", "Pendidikan"
submitterName String
submitterContact String?
status IdeaStatus @default(BARU)
reviewedBy String?
reviewedAt DateTime?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
reviewer User? @relation("IdeaReviewer", fields: [reviewedBy], references: [id], onDelete: SetNull)
@@index([category])
@@index([status])
@@map("innovation_idea")
}
// --- KATEGORI 3: DEMOGRAFI & KEPENDUDUKAN ---
model Resident {
id String @id @default(cuid())
nik String @unique
kk String
name String
birthDate DateTime
birthPlace String
gender Gender
religion Religion
maritalStatus MaritalStatus @default(BELUM_KAWIN)
education EducationLevel?
occupation String?
banjarId String
rt String
rw String
address String
isHeadOfHousehold Boolean @default(false)
isPoor Boolean @default(false)
isStunting Boolean @default(false)
deathDate DateTime?
moveInDate DateTime?
moveOutDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
banjar Banjar @relation(fields: [banjarId], references: [id], onDelete: Cascade)
healthRecords HealthRecord[]
employmentRecords EmploymentRecord[]
@@index([banjarId])
@@index([religion])
@@index([occupation])
@@map("resident")
}
model Banjar {
id String @id @default(cuid())
name String @unique
code String @unique
description String?
totalPopulation Int @default(0)
totalKK Int @default(0)
totalPoor Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
residents Resident[]
umkms Umkm[]
@@map("banjar")
}
// --- KATEGORI 4: KEUANGAN & ANGGARAN ---
model Budget {
id String @id @default(cuid())
category String // "Belanja", "Pangan", "Pembiayaan", "Pendapatan"
amount Float @default(0)
percentage Float @default(0)
color String @default("#3B82F6")
fiscalYear Int @default(2025)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([category, fiscalYear])
@@map("budget")
}
// --- KATEGORI 5: METRIK DASHBOARD & SDGS ---
model SdgsScore {
id String @id @default(cuid())
title String @unique
score Float @default(0)
image String? // filename in public folder
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("sdgs_score")
}
model SatisfactionRating {
id String @id @default(cuid())
category String @unique // "Sangat Puas", "Puas", "Cukup", "Kurang"
value Int @default(0)
color String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("satisfaction_rating")
}
// --- STUBS FOR PHASE 2+ (To maintain relations) ---
model HealthRecord {
id String @id @default(cuid())
residentId String
resident Resident @relation(fields: [residentId], references: [id])
recordedBy String
recorder User @relation(fields: [recordedBy], references: [id])
type String // "Pemeriksaan", "Imunisasi", "Ibu Hamil"
notes String?
createdAt DateTime @default(now())
}
model EmploymentRecord {
id String @id @default(cuid())
residentId String
resident Resident @relation(fields: [residentId], references: [id])
companyName String
position String
startDate DateTime
endDate DateTime?
isActive Boolean @default(true)
createdAt DateTime @default(now())
}
model PopulationDynamic {
id String @id @default(cuid())
documentedBy String
documentor User @relation(fields: [documentedBy], references: [id])
type String // "KELAHIRAN", "KEMATIAN", "KEDATANGAN", "KEPERGIAN"
residentName String
eventDate DateTime
description String?
createdAt DateTime @default(now())
}
model BudgetTransaction {
id String @id @default(cuid())
createdBy String
creator User @relation(fields: [createdBy], references: [id])
transactionNumber String @unique
type String // "PENGELUARAN", "PENDAPATAN"
category String
amount Float
description String?
date DateTime
createdAt DateTime @default(now())
}
model Umkm {
id String @id @default(cuid())
banjarId String?
banjar Banjar? @relation(fields: [banjarId], references: [id])
name String
owner String
productType String?
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Posyandu {
id String @id @default(cuid())
coordinatorId String?
coordinator User? @relation(fields: [coordinatorId], references: [id])
name String
location String
schedule String
type String // "Ibu dan Anak", "Lansia", etc.
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model SecurityReport {
id String @id @default(cuid())
assignedTo String?
assignee User? @relation(fields: [assignedTo], references: [id])
reportNumber String @unique
title String
description String
location String?
reportedBy String
status String @default("BARU") // BARU, DIPROSES, SELESAI
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// --- ENUMS ---
enum ActivityStatus {
BERJALAN
SELESAI
TERTUNDA
DIBATALKAN
}
enum Priority {
RENDAH
SEDANG
TINGGI
DARURAT
}
enum DocumentCategory {
SURAT_KEPUTUSAN
DOKUMENTASI
LAPORAN_KEUANGAN
NOTULENSI_RAPAT
UMUM
}
enum EventType {
RAPAT
KEGIATAN
UPACARA
SOSIAL
BUDAYA
LAINNYA
}
enum ComplaintCategory {
KETERTIBAN_UMUM
PELAYANAN_KESEHATAN
INFRASTRUKTUR
ADMINISTRASI
KEAMANAN
LAINNYA
}
enum ComplaintStatus {
BARU
DIPROSES
SELESAI
DITOLAK
}
enum LetterType {
KTP
KK
DOMISILI
USAHA
KETERANGAN_TIDAK_MAMPU
SURAT_PENGANTAR
LAINNYA
}
enum ServiceStatus {
BARU
DIPROSES
SELESAI
DIAMBIL
}
enum IdeaStatus {
BARU
DIKAJI
DISETUJUI
DITOLAK
DIIMPLEMENTASI
}
enum Gender {
LAKI_LAKI
PEREMPUAN
}
enum Religion {
HINDU
ISLAM
KRISTEN
KATOLIK
BUDDHA
KONGHUCU
LAINNYA
}
enum MaritalStatus {
BELUM_KAWIN
KAWIN
CERAI_HIDUP
CERAI_MATI
}
enum EducationLevel {
TIDAK_SEKOLAH
SD
SMP
SMA
D3
S1
S2
S3
}
model Session {
id String @id @default(cuid())
userId String

View File

@@ -1,139 +1,248 @@
import "dotenv/config";
import { hash } from "bcryptjs";
import { generateId } from "better-auth";
import { prisma } from "@/utils/db";
import { PrismaClient } from "../generated/prisma";
async function seedAdminUser() {
// Load environment variables
const adminEmail = process.env.ADMIN_EMAIL;
const adminPassword = process.env.ADMIN_PASSWORD || "admin123";
// Import all seeders
import { seedAdminUser, seedApiKeys, seedDemoUsers } from "./seeders/seed-auth";
import { seedDashboardMetrics } from "./seeders/seed-dashboard-metrics";
import {
getBanjarIds,
seedBanjars,
seedResidents,
} from "./seeders/seed-demographics";
import {
seedDiscussions,
seedDivisionMetrics,
seedDocuments,
} from "./seeders/seed-discussions";
import {
getDivisionIds,
seedActivities,
seedDivisions,
} from "./seeders/seed-division-performance";
import { seedPhase2 } from "./seeders/seed-phase2";
import {
getComplaintIds,
seedComplaints,
seedComplaintUpdates,
seedEvents,
seedInnovationIdeas,
seedServiceLetters,
} from "./seeders/seed-public-services";
if (!adminEmail) {
const prisma = new PrismaClient();
/**
* Check if seed has already been run
* Returns true if core data already exists
*/
export async function hasExistingData(): Promise<boolean> {
// Check for core entities that should always exist after seeding
const [userCount, banjarCount, divisionCount] = await Promise.all([
prisma.user.count(),
prisma.banjar.count(),
prisma.division.count(),
]);
// If we have more than 1 user (admin), 6 banjars, and 4 divisions, assume seeded
return userCount > 1 && banjarCount >= 6 && divisionCount >= 4;
}
/**
* Run All Seeders
* Executes all seeder functions in the correct order
*/
export async function runSeed() {
console.log("🌱 Starting seed...\n");
// Check if data already exists
const existingData = await hasExistingData();
if (existingData) {
console.log(
"No ADMIN_EMAIL environment variable found. Skipping admin user creation.",
"⏭️ Existing data detected. Skipping seed to prevent duplicates.\n",
);
console.log("💡 To re-seed, either:");
console.log(" 1. Run: bun x prisma migrate reset (resets database)");
console.log(" 2. Manually delete data from tables\n");
console.log("✅ Seed skipped successfully!\n");
return;
}
try {
// Check if admin user already exists
const existingUser = await prisma.user.findUnique({
where: { email: adminEmail },
});
if (existingUser) {
// Update existing user to have admin role if they don't already
if (existingUser.role !== "admin") {
await prisma.user.update({
where: { email: adminEmail },
data: { role: "admin" },
});
console.log(`User with email ${adminEmail} updated to admin role.`);
} else {
console.log(`User with email ${adminEmail} already has admin role.`);
}
} else {
// Create new admin user
const hashedPassword = await hash(adminPassword, 12);
const userId = generateId();
await prisma.user.create({
data: {
id: userId,
email: adminEmail,
name: "Admin User",
role: "admin",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
},
});
await prisma.account.create({
data: {
id: generateId(),
userId,
accountId: userId,
providerId: "credential",
password: hashedPassword,
createdAt: new Date(),
updatedAt: new Date(),
},
});
console.log(`Admin user created with email: ${adminEmail}`);
}
} catch (error) {
console.error("Error seeding admin user:", error);
throw error;
}
}
async function seedDemoUsers() {
const demoUsers = [
{ email: "demo1@example.com", name: "Demo User 1", role: "user" },
{ email: "demo2@example.com", name: "Demo User 2", role: "user" },
{
email: "moderator@example.com",
name: "Moderator User",
role: "moderator",
},
];
for (const userData of demoUsers) {
try {
const existingUser = await prisma.user.findUnique({
where: { email: userData.email },
});
if (!existingUser) {
const userId = generateId();
const hashedPassword = await hash("demo123", 12);
await prisma.user.create({
data: {
id: userId,
email: userData.email,
name: userData.name,
role: userData.role,
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
},
});
await prisma.account.create({
data: {
id: generateId(),
userId,
accountId: userId,
providerId: "credential",
password: hashedPassword,
createdAt: new Date(),
updatedAt: new Date(),
},
});
console.log(`Demo user created: ${userData.email}`);
} else {
console.log(`Demo user already exists: ${userData.email}`);
}
} catch (error) {
console.error(`Error seeding user ${userData.email}:`, error);
}
}
}
async function main() {
console.log("Seeding database...");
await seedAdminUser();
// 1. Seed Authentication (Admin & Demo Users)
console.log("📁 [1/7] Authentication & Users");
const adminId = await seedAdminUser();
await seedDemoUsers();
await seedApiKeys(adminId);
console.log();
console.log("Database seeding completed.");
// 2. Seed Demographics (Banjars & Residents)
console.log("📁 [2/7] Demographics & Population");
await seedBanjars();
const banjarIds = await getBanjarIds();
await seedResidents(banjarIds);
console.log();
// 3. Seed Division Performance (Divisions & Activities)
console.log("📁 [3/7] Division Performance");
const divisions = await seedDivisions();
const divisionIds = divisions.map((d) => d.id);
await seedActivities(divisionIds);
await seedDivisionMetrics(divisionIds);
console.log();
// 4. Seed Public Services (Complaints, Service Letters, Events, Innovation)
console.log("📁 [4/7] Public Services");
await seedComplaints(adminId);
await seedServiceLetters(adminId);
await seedEvents(adminId);
await seedInnovationIdeas(adminId);
const complaintIds = await getComplaintIds();
await seedComplaintUpdates(complaintIds, adminId);
console.log();
// 5. Seed Documents & Discussions
console.log("📁 [5/7] Documents & Discussions");
await seedDocuments(divisionIds, adminId);
await seedDiscussions(divisionIds, adminId);
console.log();
// 6. Seed Dashboard Metrics (Budget, SDGs, Satisfaction)
console.log("📁 [6/7] Dashboard Metrics");
await seedDashboardMetrics();
console.log();
// 7. Seed Phase 2+ Features (UMKM, Posyandu, Security, etc.)
console.log("📁 [7/7] Phase 2+ Features");
await seedPhase2(banjarIds, adminId);
console.log();
console.log("✅ Seed finished successfully!\n");
}
main().catch((error) => {
console.error("Error during seeding:", error);
process.exit(1);
});
/**
* Run Specific Seeder
* Allows running individual seeders by name
*/
export async function runSpecificSeeder(name: string) {
console.log(`🌱 Running specific seeder: ${name}\n`);
// Check if data already exists for specific seeder
const existingData = await hasExistingData();
if (existingData && name !== "auth") {
console.log(
"⚠️ Warning: Existing data detected for this seeder category.\n",
);
console.log("💡 To re-seed, either:");
console.log(" 1. Run: bun x prisma migrate reset (resets database)");
console.log(" 2. Manually delete data from tables\n");
console.log("✅ Seeder skipped to prevent duplicates!\n");
return;
}
switch (name) {
case "auth":
case "users": {
console.log("📁 Authentication & Users");
const adminId = await seedAdminUser();
await seedDemoUsers();
await seedApiKeys(adminId);
break;
}
case "demographics":
case "population": {
console.log("📁 Demographics & Population");
await seedBanjars();
const banjarIds = await getBanjarIds();
await seedResidents(banjarIds);
break;
}
case "divisions":
case "performance": {
console.log("📁 Division Performance");
const divisions = await seedDivisions();
const divisionIds = divisions.map((d) => d.id);
await seedActivities(divisionIds);
await seedDivisionMetrics(divisionIds);
break;
}
case "complaints":
case "services":
case "public": {
console.log("📁 Public Services");
const pubAdminId = await seedAdminUser();
await seedComplaints(pubAdminId);
await seedServiceLetters(pubAdminId);
await seedEvents(pubAdminId);
await seedInnovationIdeas(pubAdminId);
const compIds = await getComplaintIds();
await seedComplaintUpdates(compIds, pubAdminId);
break;
}
case "documents":
case "discussions": {
console.log("📁 Documents & Discussions");
const docAdminId = await seedAdminUser();
const divs = await seedDivisions();
const divIds = divs.map((d) => d.id);
await seedDocuments(divIds, docAdminId);
await seedDiscussions(divIds, docAdminId);
break;
}
case "dashboard":
case "metrics":
console.log("📁 Dashboard Metrics");
await seedDashboardMetrics();
break;
case "phase2":
case "features": {
console.log("📁 Phase 2+ Features");
const p2AdminId = await seedAdminUser();
await seedBanjars();
const p2BanjarIds = await getBanjarIds();
await seedPhase2(p2BanjarIds, p2AdminId);
break;
}
default:
console.error(`❌ Unknown seeder: ${name}`);
console.log(
"Available seeders: auth, demographics, divisions, complaints, documents, dashboard, phase2",
);
process.exit(1);
}
console.log("\n✅ Seeder finished successfully!\n");
}
// Main execution
if (import.meta.main) {
const args = process.argv.slice(2);
const seederName = args[0];
if (seederName) {
// Run specific seeder
runSpecificSeeder(seederName)
.catch((e) => {
console.error("❌ Seeder error:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
} else {
// Run all seeders
runSeed()
.catch((e) => {
console.error("❌ Seed error:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
}
}

161
prisma/seeders/seed-auth.ts Normal file
View File

@@ -0,0 +1,161 @@
import "dotenv/config";
import { hash } from "bcryptjs";
import { generateId } from "better-auth";
import { PrismaClient } from "../../generated/prisma";
const prisma = new PrismaClient();
/**
* Seed Admin User
* Creates or updates the admin user account
*/
export async function seedAdminUser() {
const adminEmail = process.env.ADMIN_EMAIL || "admin@example.com";
const adminPassword = process.env.ADMIN_PASSWORD || "admin123";
console.log(`Checking admin user: ${adminEmail}`);
const existingUser = await prisma.user.findUnique({
where: { email: adminEmail },
});
if (existingUser) {
if (existingUser.role !== "admin") {
await prisma.user.update({
where: { email: adminEmail },
data: { role: "admin" },
});
console.log("Updated existing user to admin role.");
}
return existingUser.id;
}
const hashedPassword = await hash(adminPassword, 12);
const userId = generateId();
await prisma.user.create({
data: {
id: userId,
email: adminEmail,
name: "Admin Desa Darmasaba",
role: "admin",
emailVerified: true,
accounts: {
create: {
id: generateId(),
accountId: userId,
providerId: "credential",
password: hashedPassword,
},
},
},
});
console.log(`✅ Admin user created: ${adminEmail}`);
return userId;
}
/**
* Seed Demo Users
* Creates demo users for testing (user, moderator roles)
*/
export async function seedDemoUsers() {
const demoUsers = [
{
email: "demo1@example.com",
name: "Demo User 1",
password: "demo123",
role: "user",
},
{
email: "demo2@example.com",
name: "Demo User 2",
password: "demo123",
role: "user",
},
{
email: "moderator@example.com",
name: "Moderator Desa",
password: "demo123",
role: "moderator",
},
];
console.log("Seeding Demo Users...");
for (const demo of demoUsers) {
const existingUser = await prisma.user.findUnique({
where: { email: demo.email },
});
if (existingUser) {
console.log(`⏭️ Demo user exists: ${demo.email}`);
continue;
}
const hashedPassword = await hash(demo.password, 12);
const userId = generateId();
await prisma.user.create({
data: {
id: userId,
email: demo.email,
name: demo.name,
role: demo.role,
emailVerified: true,
accounts: {
create: {
id: generateId(),
accountId: userId,
providerId: "credential",
password: hashedPassword,
},
},
},
});
console.log(`✅ Demo user created: ${demo.email}`);
}
}
/**
* Seed API Keys
* Creates sample API keys for testing API access
*/
export async function seedApiKeys(adminId: string) {
console.log("Seeding API Keys...");
const existingKeys = await prisma.apiKey.findMany({
where: { userId: adminId },
});
if (existingKeys.length > 0) {
console.log("⏭️ API keys already exist, skipping");
return;
}
const apiKeys = [
{
name: "Development Key",
key: "dev_key_" + generateId(),
userId: adminId,
isActive: true,
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year
},
{
name: "Production Key",
key: "prod_key_" + generateId(),
userId: adminId,
isActive: true,
expiresAt: null,
},
];
for (const apiKey of apiKeys) {
await prisma.apiKey.create({
data: apiKey,
});
}
console.log("✅ API Keys seeded successfully");
}

View File

@@ -0,0 +1,109 @@
import { PrismaClient } from "../../generated/prisma";
const prisma = new PrismaClient();
/**
* Seed Budget (APBDes)
* Creates village budget allocation data
*/
export async function seedBudget() {
console.log("Seeding Budget...");
const budgets = [
{ category: "Belanja", amount: 70, percentage: 70, color: "#3B82F6" },
{ category: "Pangan", amount: 45, percentage: 45, color: "#22C55E" },
{ category: "Pembiayaan", amount: 55, percentage: 55, color: "#FACC15" },
{ category: "Pendapatan", amount: 90, percentage: 90, color: "#3B82F6" },
];
for (const budget of budgets) {
await prisma.budget.upsert({
where: {
category_fiscalYear: {
category: budget.category,
fiscalYear: 2025,
},
},
update: budget,
create: { ...budget, fiscalYear: 2025 },
});
}
console.log("✅ Budget seeded successfully");
}
/**
* Seed SDGs Scores
* Creates Sustainable Development Goals scores for dashboard
*/
export async function seedSdgsScores() {
console.log("Seeding SDGs Scores...");
const sdgs = [
{
title: "Desa Berenergi Bersih dan Terbarukan",
score: 99.64,
image: "SDGS-7.png",
},
{
title: "Desa Damai Berkeadilan",
score: 78.65,
image: "SDGS-16.png",
},
{
title: "Desa Sehat dan Sejahtera",
score: 77.37,
image: "SDGS-3.png",
},
{
title: "Desa Tanpa Kemiskinan",
score: 52.62,
image: "SDGS-1.png",
},
];
for (const sdg of sdgs) {
await prisma.sdgsScore.upsert({
where: { title: sdg.title },
update: sdg,
create: sdg,
});
}
console.log("✅ SDGs Scores seeded successfully");
}
/**
* Seed Satisfaction Ratings
* Creates public satisfaction survey data
*/
export async function seedSatisfactionRatings() {
console.log("Seeding Satisfaction Ratings...");
const satisfactions = [
{ category: "Sangat Puas", value: 25, color: "#4E5BA6" },
{ category: "Puas", value: 25, color: "#F4C542" },
{ category: "Cukup", value: 25, color: "#8CC63F" },
{ category: "Kurang", value: 25, color: "#E57373" },
];
for (const sat of satisfactions) {
await prisma.satisfactionRating.upsert({
where: { category: sat.category },
update: sat,
create: sat,
});
}
console.log("✅ Satisfaction Ratings seeded successfully");
}
/**
* Seed All Dashboard Metrics
* Main function to run all dashboard metrics seeders
*/
export async function seedDashboardMetrics() {
await seedBudget();
await seedSdgsScores();
await seedSatisfactionRatings();
}

View File

@@ -0,0 +1,124 @@
import { Gender, PrismaClient, Religion } from "../../generated/prisma";
const prisma = new PrismaClient();
/**
* Seed Banjars (Village Hamlets)
* Creates 6 banjars in Darmasaba village
*/
export async function seedBanjars() {
const banjars = [
{
name: "Darmasaba",
code: "DSB",
totalPopulation: 1200,
totalKK: 300,
totalPoor: 45,
},
{
name: "Manesa",
code: "MNS",
totalPopulation: 950,
totalKK: 240,
totalPoor: 32,
},
{
name: "Cabe",
code: "CBE",
totalPopulation: 800,
totalKK: 200,
totalPoor: 28,
},
{
name: "Penenjoan",
code: "PNJ",
totalPopulation: 1100,
totalKK: 280,
totalPoor: 50,
},
{
name: "Baler Pasar",
code: "BPS",
totalPopulation: 850,
totalKK: 210,
totalPoor: 35,
},
{
name: "Bucu",
code: "BCU",
totalPopulation: 734,
totalKK: 184,
totalPoor: 24,
},
];
console.log("Seeding Banjars...");
for (const banjar of banjars) {
await prisma.banjar.upsert({
where: { name: banjar.name },
update: banjar,
create: banjar,
});
}
console.log("✅ Banjars seeded successfully");
}
/**
* Get all Banjar IDs
* Helper function to retrieve banjar IDs for other seeders
*/
export async function getBanjarIds(): Promise<string[]> {
const banjars = await prisma.banjar.findMany();
return banjars.map((b) => b.id);
}
/**
* Seed Residents
* Creates sample resident data for demographics
*/
export async function seedResidents(banjarIds: string[]) {
console.log("Seeding Residents...");
const residents = [
{
nik: "5103010101700001",
kk: "5103010101700000",
name: "I Wayan Sudarsana",
birthDate: new Date("1970-05-15"),
birthPlace: "Badung",
gender: Gender.LAKI_LAKI,
religion: Religion.HINDU,
occupation: "Wiraswasta",
banjarId: banjarIds[0] || "",
rt: "001",
rw: "000",
address: "Jl. Raya Darmasaba No. 1",
isHeadOfHousehold: true,
},
{
nik: "5103010101850002",
kk: "5103010101850000",
name: "Ni Made Arianti",
birthDate: new Date("1985-08-20"),
birthPlace: "Denpasar",
gender: Gender.PEREMPUAN,
religion: Religion.HINDU,
occupation: "Guru",
banjarId: banjarIds[1] || banjarIds[0] || "",
rt: "002",
rw: "000",
address: "Gg. Manesa No. 5",
isPoor: true,
},
];
for (const res of residents) {
await prisma.resident.upsert({
where: { nik: res.nik },
update: res,
create: res,
});
}
console.log("✅ Residents seeded successfully");
}

View File

@@ -0,0 +1,203 @@
import {
DocumentCategory,
Priority,
PrismaClient,
} from "../../generated/prisma";
const prisma = new PrismaClient();
/**
* Seed Documents
* Creates sample documents for divisions (SK, laporan, dokumentasi)
*/
export async function seedDocuments(divisionIds: string[], userId: string) {
console.log("Seeding Documents...");
const documents = [
{
title: "SK Kepala Desa No. 1/2025",
category: DocumentCategory.SURAT_KEPUTUSAN,
type: "PDF",
fileUrl: "/documents/sk-kepala-desa-001.pdf",
fileSize: 245000,
divisionId: divisionIds[0] || null,
uploadedBy: userId,
},
{
title: "Laporan Keuangan Q1 2025",
category: DocumentCategory.LAPORAN_KEUANGAN,
type: "PDF",
fileUrl: "/documents/laporan-keuangan-q1-2025.pdf",
fileSize: 512000,
divisionId: divisionIds[0] || null,
uploadedBy: userId,
},
{
title: "Dokumentasi Gotong Royong",
category: DocumentCategory.DOKUMENTASI,
type: "Gambar",
fileUrl: "/images/gotong-royong-2025.jpg",
fileSize: 1024000,
divisionId: divisionIds[3] || null,
uploadedBy: userId,
},
{
title: "Notulensi Rapat Desa",
category: DocumentCategory.NOTULENSI_RAPAT,
type: "Dokumen",
fileUrl: "/documents/notulensi-rapat-desa.pdf",
fileSize: 128000,
divisionId: divisionIds[0] || null,
uploadedBy: userId,
},
{
title: "Data Penduduk 2025",
category: DocumentCategory.UMUM,
type: "Excel",
fileUrl: "/documents/data-penduduk-2025.xlsx",
fileSize: 350000,
divisionId: null,
uploadedBy: userId,
},
];
for (const doc of documents) {
await prisma.document.create({
data: doc,
});
}
console.log("✅ Documents seeded successfully");
}
/**
* Seed Discussions
* Creates sample discussions for divisions and activities
*/
export async function seedDiscussions(divisionIds: string[], userId: string) {
console.log("Seeding Discussions...");
const discussions = [
{
message: "Mohon update progress pembangunan jalan",
senderId: userId,
divisionId: divisionIds[1] || null,
isResolved: false,
},
{
message: "Baik, akan segera kami tindak lanjuti",
senderId: userId,
divisionId: divisionIds[1] || null,
isResolved: false,
parentId: null, // Will be set as reply
},
{
message: "Jadwal rapat koordinasi minggu depan?",
senderId: userId,
divisionId: divisionIds[0] || null,
isResolved: true,
},
{
message: "Rapat dijadwalkan hari Senin, 10:00 WITA",
senderId: userId,
divisionId: divisionIds[0] || null,
isResolved: true,
parentId: null, // Will be set as reply
},
{
message: "Program pemberdayaan UMKM butuh anggaran tambahan",
senderId: userId,
divisionId: divisionIds[2] || null,
isResolved: false,
},
];
// Create parent discussions first
const parentDiscussions = [];
for (let i = 0; i < discussions.length; i += 2) {
const discussion = await prisma.discussion.create({
data: {
message: discussions[i].message,
senderId: discussions[i].senderId,
divisionId: discussions[i].divisionId,
isResolved: discussions[i].isResolved,
},
});
parentDiscussions.push(discussion);
}
// Create replies
for (let i = 1; i < discussions.length; i += 2) {
const parentIndex = Math.floor((i - 1) / 2);
if (parentIndex < parentDiscussions.length) {
await prisma.discussion.update({
where: { id: parentDiscussions[parentIndex].id },
data: {
replies: {
create: {
message: discussions[i].message,
senderId: discussions[i].senderId,
isResolved: discussions[i].isResolved,
},
},
},
});
}
}
console.log("✅ Discussions seeded successfully");
}
/**
* Seed Division Metrics
* Creates performance metrics for each division
*/
export async function seedDivisionMetrics(divisionIds: string[]) {
console.log("Seeding Division Metrics...");
const metrics = [
{
divisionId: divisionIds[0] || "",
period: "2025-Q1",
activityCount: 12,
completionRate: 75.5,
avgProgress: 82.3,
},
{
divisionId: divisionIds[1] || "",
period: "2025-Q1",
activityCount: 8,
completionRate: 62.5,
avgProgress: 65.0,
},
{
divisionId: divisionIds[2] || "",
period: "2025-Q1",
activityCount: 10,
completionRate: 80.0,
avgProgress: 70.5,
},
{
divisionId: divisionIds[3] || "",
period: "2025-Q1",
activityCount: 15,
completionRate: 86.7,
avgProgress: 88.2,
},
];
for (const metric of metrics) {
await prisma.divisionMetric.upsert({
where: {
divisionId_period: {
divisionId: metric.divisionId,
period: metric.period,
},
},
update: metric,
create: metric,
});
}
console.log("✅ Division Metrics seeded successfully");
}

View File

@@ -0,0 +1,97 @@
import { ActivityStatus, Priority, PrismaClient } from "../../generated/prisma";
const prisma = new PrismaClient();
/**
* Seed Divisions
* Creates 4 main village divisions/departments
*/
export async function seedDivisions() {
const divisions = [
{
name: "Pemerintahan",
description: "Urusan administrasi dan tata kelola desa",
color: "#1E3A5F",
},
{
name: "Pembangunan",
description: "Infrastruktur dan sarana prasarana desa",
color: "#2E7D32",
},
{
name: "Pemberdayaan",
description: "Pemberdayaan ekonomi dan masyarakat",
color: "#EF6C00",
},
{
name: "Kesejahteraan",
description: "Kesehatan, pendidikan, dan sosial",
color: "#C62828",
},
];
console.log("Seeding Divisions...");
const createdDivisions = [];
for (const div of divisions) {
const d = await prisma.division.upsert({
where: { name: div.name },
update: div,
create: div,
});
createdDivisions.push(d);
}
console.log("✅ Divisions seeded successfully");
return createdDivisions;
}
/**
* Get all Division IDs
* Helper function to retrieve division IDs for other seeders
*/
export async function getDivisionIds(): Promise<string[]> {
const divisions = await prisma.division.findMany();
return divisions.map((d) => d.id);
}
/**
* Seed Activities
* Creates sample activities for each division
*/
export async function seedActivities(divisionIds: string[]) {
console.log("Seeding Activities...");
const activities = [
{
title: "Rapat Koordinasi 2025",
description: "Penyusunan rencana kerja tahunan",
divisionId: divisionIds[0] || "",
progress: 100,
status: ActivityStatus.SELESAI,
priority: Priority.TINGGI,
},
{
title: "Pemutakhiran Indeks Desa",
description: "Pendataan SDG's Desa 2025",
divisionId: divisionIds[0] || "",
progress: 65,
status: ActivityStatus.BERJALAN,
priority: Priority.SEDANG,
},
{
title: "Pembangunan Jalan Banjar Cabe",
description: "Pengaspalan jalan utama",
divisionId: divisionIds[1] || divisionIds[0] || "",
progress: 40,
status: ActivityStatus.BERJALAN,
priority: Priority.DARURAT,
},
];
for (const act of activities) {
await prisma.activity.create({
data: act,
});
}
console.log("✅ Activities seeded successfully");
}

View File

@@ -0,0 +1,254 @@
import { PrismaClient } from "../../generated/prisma";
const prisma = new PrismaClient();
/**
* Seed UMKM (Usaha Mikro, Kecil, dan Menengah)
* Creates sample local businesses for each banjar
*/
export async function seedUmkm(banjarIds: string[]) {
console.log("Seeding UMKM...");
const umkms = [
{
banjarId: banjarIds[0] || null,
name: "Kerajinan Anyaman Darmasaba",
owner: "Ni Wayan Rajin",
productType: "Kerajinan Tangan",
description: "Produksi anyasan bambu dan rotan",
},
{
banjarId: banjarIds[1] || null,
name: "Warung Makan Manesa",
owner: "Made Sari",
productType: "Kuliner",
description: "Makanan tradisional Bali",
},
{
banjarId: banjarIds[2] || null,
name: "Bengkel Cabe Motor",
owner: "Ketut Arsana",
productType: "Jasa",
description: "Servis motor dan jual sparepart",
},
{
banjarId: banjarIds[3] || null,
name: "Produksi Keripik Pisang Penenjoan",
owner: "Putu Suartika",
productType: "Makanan Ringan",
description: "Keripik pisang dengan berbagai varian rasa",
},
];
for (const umkm of umkms) {
await prisma.umkm.create({
data: umkm,
});
}
console.log("✅ UMKM seeded successfully");
}
/**
* Seed Posyandu (Community Health Post)
* Creates health service schedules and programs
*/
export async function seedPosyandu(userId: string) {
console.log("Seeding Posyandu...");
const posyandus = [
{
name: "Posyandu Mawar",
location: "Banjar Darmasaba",
schedule: "Setiap tanggal 15",
type: "Ibu dan Anak",
coordinatorId: userId,
},
{
name: "Posyandu Melati",
location: "Banjar Manesa",
schedule: "Setiap tanggal 20",
type: "Ibu dan Anak",
coordinatorId: userId,
},
{
name: "Posyandu Lansia Sejahtera",
location: "Balai Desa",
schedule: "Setiap tanggal 25",
type: "Lansia",
coordinatorId: userId,
},
];
for (const posyandu of posyandus) {
await prisma.posyandu.create({
data: posyandu,
});
}
console.log("✅ Posyandu seeded successfully");
}
/**
* Seed Security Reports
* Creates sample security incident reports
*/
export async function seedSecurityReports(userId: string) {
console.log("Seeding Security Reports...");
const securityReports = [
{
reportNumber: "SEC-2025-001",
title: "Pencurian Kendaraan",
description: "Laporan kehilangan motor di area pasar",
location: "Pasar Darmasaba",
reportedBy: "I Wayan Aman",
status: "DIPROSES",
assignedTo: userId,
},
{
reportNumber: "SEC-2025-002",
title: "Gangguan Ketertiban",
description: "Keributan di jalan utama pada malam hari",
location: "Jl. Raya Darmasaba",
reportedBy: "Made Tertib",
status: "SELESAI",
assignedTo: userId,
},
];
for (const report of securityReports) {
await prisma.securityReport.upsert({
where: { reportNumber: report.reportNumber },
update: report,
create: report,
});
}
console.log("✅ Security Reports seeded successfully");
}
/**
* Seed Employment Records
* Creates employment history for residents
*/
export async function seedEmploymentRecords() {
console.log("Seeding Employment Records...");
// Get residents first
const residents = await prisma.resident.findMany({
take: 2,
});
if (residents.length === 0) {
console.log("⏭️ No residents found, skipping employment records");
return;
}
const employmentRecords = residents.map((resident) => ({
residentId: resident.id,
companyName: `PT. Desa Makmur ${resident.name.split(" ")[0]}`,
position: "Staff",
startDate: new Date("2020-01-01"),
endDate: null,
isActive: true,
}));
for (const record of employmentRecords) {
await prisma.employmentRecord.create({
data: record,
});
}
console.log("✅ Employment Records seeded successfully");
}
/**
* Seed Population Dynamics
* Creates population change records (births, deaths, migration)
*/
export async function seedPopulationDynamics(userId: string) {
console.log("Seeding Population Dynamics...");
const populationDynamics = [
{
type: "KELAHIRAN",
residentName: "Anak Baru Darmasaba",
eventDate: new Date("2025-01-15"),
description: "Kelahiran bayi laki-laki",
documentedBy: userId,
},
{
type: "KEMATIAN",
residentName: "Almarhum Warga Desa",
eventDate: new Date("2025-02-20"),
description: "Meninggal dunia karena sakit",
documentedBy: userId,
},
{
type: "KEDATANGAN",
residentName: "Pendatang Baru",
eventDate: new Date("2025-03-01"),
description: "Pindah masuk dari desa lain",
documentedBy: userId,
},
];
for (const dynamic of populationDynamics) {
await prisma.populationDynamic.create({
data: dynamic,
});
}
console.log("✅ Population Dynamics seeded successfully");
}
/**
* Seed Budget Transactions
* Creates sample financial transactions
*/
export async function seedBudgetTransactions(userId: string) {
console.log("Seeding Budget Transactions...");
const transactions = [
{
transactionNumber: "TRX-2025-001",
type: "PENGELUARAN",
category: "Infrastruktur",
amount: 50000000,
description: "Pembangunan jalan desa",
date: new Date("2025-01-10"),
createdBy: userId,
},
{
transactionNumber: "TRX-2025-002",
type: "PENDAPATAN",
category: "Dana Desa",
amount: 500000000,
description: "Penyaluran dana desa Q1",
date: new Date("2025-01-05"),
createdBy: userId,
},
];
for (const transaction of transactions) {
await prisma.budgetTransaction.create({
data: transaction,
});
}
console.log("✅ Budget Transactions seeded successfully");
}
/**
* Seed All Phase 2 Data
* Main function to run all Phase 2 seeders
*/
export async function seedPhase2(banjarIds: string[], userId: string) {
await seedUmkm(banjarIds);
await seedPosyandu(userId);
await seedSecurityReports(userId);
await seedEmploymentRecords();
await seedPopulationDynamics(userId);
await seedBudgetTransactions(userId);
}

View File

@@ -0,0 +1,393 @@
import {
ComplaintCategory,
ComplaintStatus,
EventType,
Priority,
PrismaClient,
} from "../../generated/prisma";
const prisma = new PrismaClient();
/**
* Get Complaint IDs
* Helper function to retrieve complaint IDs for other seeders
*/
export async function getComplaintIds(): Promise<string[]> {
const complaints = await prisma.complaint.findMany();
return complaints.map((c) => c.id);
}
/**
* Seed Complaints
* Creates sample citizen complaints spread across 7 months for trend visualization
*/
export async function seedComplaints(adminId: string) {
console.log("Seeding Complaints...");
const now = new Date();
const complaints = [
// Recent complaints (this month)
{
complaintNumber: `COMP-20260327-001`,
title: "Lampu Jalan Mati",
description:
"Lampu jalan di depan Balai Banjar Manesa mati sejak 3 hari lalu.",
category: ComplaintCategory.INFRASTRUKTUR,
status: ComplaintStatus.BARU,
priority: Priority.SEDANG,
location: "Banjar Manesa",
reporterId: adminId,
createdAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), // 5 days ago
},
{
complaintNumber: `COMP-20260325-002`,
title: "Sampah Menumpuk",
description: "Tumpukan sampah di area pasar Darmasaba belum diangkut.",
category: ComplaintCategory.KETERTIBAN_UMUM,
status: ComplaintStatus.DIPROSES,
priority: Priority.TINGGI,
location: "Pasar Darmasaba",
assignedTo: adminId,
createdAt: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), // 7 days ago
},
{
complaintNumber: `COMP-20260320-003`,
title: "Jalan Rusak",
description: "Jalan di Banjar Cabe rusak dan berlubang.",
category: ComplaintCategory.INFRASTRUKTUR,
status: ComplaintStatus.SELESAI,
priority: Priority.TINGGI,
location: "Banjar Cabe",
assignedTo: adminId,
resolvedBy: adminId,
resolvedAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000),
createdAt: new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000), // 12 days ago
},
// Last month (February 2026)
{
complaintNumber: `COMP-20260215-004`,
title: "Saluran Air Tersumbat",
description: "Saluran air di depan rumah warga tersumbat sampah.",
category: ComplaintCategory.INFRASTRUKTUR,
status: ComplaintStatus.SELESAI,
priority: Priority.SEDANG,
location: "Banjar Darmasaba",
assignedTo: adminId,
resolvedBy: adminId,
createdAt: new Date(now.getTime() - 45 * 24 * 60 * 60 * 1000), // 45 days ago
},
{
complaintNumber: `COMP-20260210-005`,
title: "Parkir Liar",
description: "Parkir liar di depan pasar mengganggu lalu lintas.",
category: ComplaintCategory.KETERTIBAN_UMUM,
status: ComplaintStatus.SELESAI,
priority: Priority.RENDAH,
location: "Pasar Darmasaba",
assignedTo: adminId,
resolvedBy: adminId,
createdAt: new Date(now.getTime() - 50 * 24 * 60 * 60 * 1000), // 50 days ago
},
// January 2026
{
complaintNumber: `COMP-20260120-006`,
title: "Penerangan Jalan Umum Rusak",
description: "5 titik lampu jalan di Jl. Raya Darmasaba tidak menyala.",
category: ComplaintCategory.INFRASTRUKTUR,
status: ComplaintStatus.SELESAI,
priority: Priority.TINGGI,
location: "Jl. Raya Darmasaba",
assignedTo: adminId,
resolvedBy: adminId,
createdAt: new Date(now.getTime() - 70 * 24 * 60 * 60 * 1000), // 70 days ago
},
{
complaintNumber: `COMP-20260115-007`,
title: "Pelayanan Administrasi Lambat",
description: "Proses pembuatan surat keterangan lambat.",
category: ComplaintCategory.ADMINISTRASI,
status: ComplaintStatus.SELESAI,
priority: Priority.SEDANG,
location: "Kantor Desa",
assignedTo: adminId,
resolvedBy: adminId,
createdAt: new Date(now.getTime() - 75 * 24 * 60 * 60 * 1000), // 75 days ago
},
// December 2025
{
complaintNumber: `COMP-20251210-008`,
title: "Jembatan Rusak Ringan",
description: "Pagar jembatan di Banjar Penenjoan rusak.",
category: ComplaintCategory.INFRASTRUKTUR,
status: ComplaintStatus.SELESAI,
priority: Priority.SEDANG,
location: "Banjar Penenjoan",
assignedTo: adminId,
resolvedBy: adminId,
createdAt: new Date(now.getTime() - 110 * 24 * 60 * 60 * 1000), // 110 days ago
},
{
complaintNumber: `COMP-20251205-009`,
title: "Suara Bising Kegiatan Malam",
description: "Kegiatan karaoke malam hari mengganggu ketenangan.",
category: ComplaintCategory.KETERTIBAN_UMUM,
status: ComplaintStatus.SELESAI,
priority: Priority.RENDAH,
location: "Banjar Baler Pasar",
assignedTo: adminId,
resolvedBy: adminId,
createdAt: new Date(now.getTime() - 115 * 24 * 60 * 60 * 1000), // 115 days ago
},
// November 2025
{
complaintNumber: `COMP-20251115-010`,
title: "Genangan Air Saat Hujan",
description: "Jalan utama tergenang air saat hujan deras.",
category: ComplaintCategory.INFRASTRUKTUR,
status: ComplaintStatus.SELESAI,
priority: Priority.TINGGI,
location: "Jl. Raya Cabe",
assignedTo: adminId,
resolvedBy: adminId,
createdAt: new Date(now.getTime() - 135 * 24 * 60 * 60 * 1000), // 135 days ago
},
// October 2025
{
complaintNumber: `COMP-20251020-011`,
title: "Pungli Pelayanan KTP",
description: "Ada oknum yang meminta biaya tambahan untuk KTP.",
category: ComplaintCategory.ADMINISTRASI,
status: ComplaintStatus.SELESAI,
priority: Priority.DARURAT,
location: "Kantor Desa",
assignedTo: adminId,
resolvedBy: adminId,
createdAt: new Date(now.getTime() - 160 * 24 * 60 * 60 * 1000), // 160 days ago
},
// September 2025
{
complaintNumber: `COMP-20250915-012`,
title: "Tanah Longsor",
description: "Tanah longsor di tepi jalan Banjar Bucu.",
category: ComplaintCategory.INFRASTRUKTUR,
status: ComplaintStatus.SELESAI,
priority: Priority.DARURAT,
location: "Banjar Bucu",
assignedTo: adminId,
resolvedBy: adminId,
createdAt: new Date(now.getTime() - 195 * 24 * 60 * 60 * 1000), // 195 days ago
},
];
for (const comp of complaints) {
await prisma.complaint.upsert({
where: { complaintNumber: comp.complaintNumber },
update: comp,
create: comp,
});
}
console.log(
"✅ Complaints seeded successfully (12 complaints across 7 months)",
);
}
/**
* Seed Service Letters
* Creates sample administrative letter requests with dates spread across 6 months
*/
export async function seedServiceLetters(adminId: string) {
console.log("Seeding Service Letters...");
const now = new Date();
const serviceLetters = [
{
letterNumber: "SKT-2025-001",
letterType: "KTP",
applicantName: "I Wayan Sudarsana",
applicantNik: "5103010101700001",
applicantAddress: "Jl. Raya Darmasaba No. 1",
purpose: "Pembuatan KTP baru",
status: "SELESAI",
processedBy: adminId,
completedAt: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000), // 2 days ago
createdAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), // 5 days ago (this week!)
},
{
letterNumber: "SKT-2025-002",
letterType: "KK",
applicantName: "Ni Made Arianti",
applicantNik: "5103010101850002",
applicantAddress: "Gg. Manesa No. 5",
purpose: "Perubahan data KK",
status: "DIPROSES",
processedBy: adminId,
createdAt: new Date(now.getTime() - 45 * 24 * 60 * 60 * 1000), // 45 days ago
},
{
letterNumber: "SKT-2025-003",
letterType: "DOMISILI",
applicantName: "I Ketut Arsana",
applicantNik: "5103010101900003",
applicantAddress: "Jl. Cabe No. 10",
purpose: "Surat keterangan domisili",
status: "BARU",
createdAt: new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000), // 90 days ago
},
{
letterNumber: "SKT-2024-004",
letterType: "USAHA",
applicantName: "Made Wijaya",
applicantNik: "5103010101950004",
applicantAddress: "Jl. Penenjoan No. 15",
purpose: "Surat keterangan usaha",
status: "SELESAI",
processedBy: adminId,
completedAt: new Date(now.getTime() - 120 * 24 * 60 * 60 * 1000), // 120 days ago
createdAt: new Date(now.getTime() - 130 * 24 * 60 * 60 * 1000), // 130 days ago
},
{
letterNumber: "SKT-2024-005",
letterType: "KETERANGAN_TIDAK_MAMPU",
applicantName: "Putu Sari",
applicantNik: "5103010101980005",
applicantAddress: "Gg. Bucu No. 8",
purpose: "Keterangan tidak mampu untuk beasiswa",
status: "SELESAI",
processedBy: adminId,
completedAt: new Date(now.getTime() - 150 * 24 * 60 * 60 * 1000), // 150 days ago
createdAt: new Date(now.getTime() - 160 * 24 * 60 * 60 * 1000), // 160 days ago
},
];
for (const letter of serviceLetters) {
const existing = await prisma.serviceLetter.findUnique({
where: { letterNumber: letter.letterNumber },
});
if (existing) {
await prisma.serviceLetter.update({
where: { letterNumber: letter.letterNumber },
data: letter,
});
} else {
await prisma.serviceLetter.create({
data: letter,
});
}
}
console.log("✅ Service Letters seeded successfully");
}
/**
* Seed Events
* Creates sample village events and meetings
*/
export async function seedEvents(adminId: string) {
console.log("Seeding Events...");
const events = [
{
title: "Rapat Pleno Desa",
description: "Pembahasan anggaran belanja desa",
eventType: EventType.RAPAT,
startDate: new Date(),
location: "Balai Desa Darmasaba",
createdBy: adminId,
},
{
title: "Gotong Royong Kebersihan",
description: "Kegiatan rutin mingguan",
eventType: EventType.SOSIAL,
startDate: new Date(Date.now() + 86400000), // Besok
location: "Seluruh Banjar",
createdBy: adminId,
},
];
for (const event of events) {
await prisma.event.create({
data: event,
});
}
console.log("✅ Events seeded successfully");
}
/**
* Seed Innovation Ideas
* Creates sample citizen innovation submissions
*/
export async function seedInnovationIdeas(adminId: string) {
console.log("Seeding Innovation Ideas...");
const innovationIdeas = [
{
title: "Sistem Informasi Desa Digital",
description: "Platform digital untuk layanan administrasi desa",
category: "Teknologi",
submitterName: "I Made Wijaya",
submitterContact: "081234567890",
status: "DIKAJI",
reviewedBy: adminId,
notes: "Perlu kajian lebih lanjut tentang anggaran",
},
{
title: "Program Bank Sampah",
description: "Pengelolaan sampah berbasis bank sampah",
category: "Lingkungan",
submitterName: "Ni Putu Sari",
submitterContact: "081234567891",
status: "BARU",
},
];
for (const idea of innovationIdeas) {
await prisma.innovationIdea.create({
data: idea,
});
}
console.log("✅ Innovation Ideas seeded successfully");
}
/**
* Seed Complaint Updates
* Creates status update history for complaints
*/
export async function seedComplaintUpdates(
complaintIds: string[],
userId: string,
) {
console.log("Seeding Complaint Updates...");
if (complaintIds.length === 0) {
console.log("⏭️ No complaints found, skipping updates");
return;
}
const updates = [
{
complaintId: complaintIds[0],
message: "Laporan diterima, akan segera ditindaklanjuti",
status: ComplaintStatus.BARU,
updatedBy: userId,
},
{
complaintId: complaintIds[1],
message: "Tim kebersihan telah dikirim ke lokasi",
status: ComplaintStatus.DIPROSES,
updatedBy: userId,
},
];
for (const update of updates) {
await prisma.complaintUpdate.create({
data: update,
});
}
console.log("✅ Complaint Updates seeded successfully");
}

BIN
public/SDGS-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
public/SDGS-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
public/SDGS-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
public/SDGS-7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
public/light-mode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
public/logo-desa-plus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/white.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

58
scripts/build.ts Normal file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bun
/**
* Build script for production
* 1. Build CSS with PostCSS/Tailwind
* 2. Bundle JS with Bun (without CSS)
* 3. Replace CSS reference in HTML
*/
import { $ } from "bun";
import fs from "node:fs";
import postcss from "postcss";
import tailwindcss from "@tailwindcss/postcss";
import autoprefixer from "autoprefixer";
console.log("🔨 Starting production build...");
// Ensure dist directory exists
if (!fs.existsSync("./dist")) {
fs.mkdirSync("./dist", { recursive: true });
}
// Step 1: Build CSS with PostCSS
console.log("🎨 Building CSS...");
const cssInput = fs.readFileSync("./src/index.css", "utf-8");
const cssResult = await postcss([tailwindcss(), autoprefixer()]).process(
cssInput,
{
from: "./src/index.css",
to: "./dist/index.css",
},
);
fs.writeFileSync("./dist/index.css", cssResult.css);
console.log("✅ CSS built successfully!");
// Step 2: Build JS with Bun (build HTML too, we'll fix CSS link later)
console.log("📦 Bundling JavaScript...");
await $`bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='"production"' --env='VITE_*'`;
// Step 3: Copy public assets
console.log("📁 Copying public assets...");
if (fs.existsSync("./public")) {
await $`cp -r public/* dist/ 2>/dev/null || true`;
}
// Step 4: Ensure HTML references the correct CSS
// Bun build might have renamed the CSS, we want to use our own index.css
console.log("🔧 Fixing HTML CSS reference...");
const htmlPath = "./dist/index.html";
if (fs.existsSync(htmlPath)) {
let html = fs.readFileSync(htmlPath, "utf-8");
// Replace any bundled CSS reference with our index.css
html = html.replace(/href="[^"]*\.css"/g, 'href="/index.css"');
fs.writeFileSync(htmlPath, html);
}
console.log("✅ Build completed successfully!");

View File

@@ -0,0 +1,22 @@
import { prisma } from "../src/utils/db";
async function check() {
console.log("--- Checking Division Data in DB ---");
const divisions = await prisma.division.findMany({
select: {
name: true,
externalActivityCount: true,
}
});
console.table(divisions);
console.log("\n--- Checking API Response for /api/division/ ---");
// Mocking the mapping logic from src/api/division.ts
const formatted = divisions.map(d => ({
name: d.name,
activityCount: d.externalActivityCount
}));
console.table(formatted);
}
check().catch(console.error).finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,38 @@
import { nocExternalClient } from "../src/utils/noc-external-client";
async function inspect() {
const ID_DESA = "desa1";
console.log("Checking NOC API Data structure...");
const endpoints = [
"/api/noc/active-divisions",
"/api/noc/latest-projects",
"/api/noc/upcoming-events",
"/api/noc/latest-discussion"
];
for (const endpoint of endpoints) {
console.log(`\n--- Endpoint: ${endpoint} ---`);
try {
const { data, error } = await (nocExternalClient as any).GET(endpoint, {
params: { query: { idDesa: ID_DESA, limit: "1" } }
});
if (error) {
console.error(`Error fetching ${endpoint}:`, error);
continue;
}
if (data && data.data && data.data.length > 0) {
console.log("Sample Data Object Keys:", Object.keys(data.data[0]));
console.log("Sample Data Object Values:", JSON.stringify(data.data[0], null, 2));
} else {
console.log("No data returned or data is empty.");
}
} catch (err) {
console.error(`Failed to fetch ${endpoint}:`, err);
}
}
}
inspect();

38
scripts/reset-noc-data.ts Normal file
View File

@@ -0,0 +1,38 @@
import { prisma } from "../src/utils/db";
import logger from "../src/utils/logger";
async function resetNocData() {
try {
logger.info("Starting NOC Data Reset...");
// Delete in order to respect relations
// 1. Delete Activities (though Division cascade might handle it, let's be explicit)
const deletedActivities = await prisma.activity.deleteMany({});
logger.info(`Deleted ${deletedActivities.count} activities`);
// 2. Delete Documents
const deletedDocuments = await prisma.document.deleteMany({});
logger.info(`Deleted ${deletedDocuments.count} documents`);
// 3. Delete Discussions
const deletedDiscussions = await prisma.discussion.deleteMany({});
logger.info(`Deleted ${deletedDiscussions.count} discussions`);
// 4. Delete Events
const deletedEvents = await prisma.event.deleteMany({});
logger.info(`Deleted ${deletedEvents.count} events`);
// 5. Delete Divisions
const deletedDivisions = await prisma.division.deleteMany({});
logger.info(`Deleted ${deletedDivisions.count} divisions`);
logger.info("NOC Data Reset Completed Successfully");
} catch (err) {
logger.error({ err }, "Error during NOC data reset");
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
resetNocData();

261
scripts/sync-noc.ts Normal file
View File

@@ -0,0 +1,261 @@
import { prisma } from "../src/utils/db";
import { nocExternalClient } from "../src/utils/noc-external-client";
import logger from "../src/utils/logger";
const ID_DESA = "desa1";
/**
* Helper untuk mendapatkan system user ID untuk relasi
*/
async function getSystemUserId() {
const user = await prisma.user.findFirst({
where: { role: "admin" },
});
if (!user) {
// Buat system user jika tidak ada
const newUser = await prisma.user.create({
data: {
email: "system@desa1.id",
name: "System Sync",
role: "admin",
},
});
return newUser.id;
}
return user.id;
}
/**
* 1. Sync Divisions
*/
async function syncActiveDivisions() {
logger.info("Syncing Divisions...");
const { data, error } = await nocExternalClient.GET("/api/noc/active-divisions", {
params: { query: { idDesa: ID_DESA } },
});
if (error || !data) {
logger.error({ error }, "Failed to fetch divisions from NOC");
return;
}
// biome-ignore lint/suspicious/noExplicitAny: External API response is untyped
const resData = (data as any).data;
const divisions = Array.isArray(resData) ? resData : (resData?.divisi || []);
if (!Array.isArray(divisions)) {
logger.warn({ data }, "Divisions data from NOC is not an array");
return;
}
for (const div of divisions) {
const name = div.name || div.division;
const extId = div.id || div.externalId || `div-${name.toLowerCase().replace(/\s+/g, "-")}`;
await prisma.division.upsert({
where: { name: name },
update: {
externalId: extId,
color: div.color || "#1E3A5F",
villageId: ID_DESA,
externalActivityCount: div.totalKegiatan || 0,
},
create: {
externalId: extId,
name: name,
color: div.color || "#1E3A5F",
villageId: ID_DESA,
externalActivityCount: div.totalKegiatan || 0,
},
});
}
logger.info(`Synced ${divisions.length} divisions`);
}
/**
* 2. Sync Activities
*/
async function syncLatestProjects() {
logger.info("Syncing Activities...");
const { data, error } = await nocExternalClient.GET("/api/noc/latest-projects", {
params: { query: { idDesa: ID_DESA, limit: "50" } },
});
if (error || !data) {
logger.error({ error }, "Failed to fetch projects from NOC");
return;
}
// biome-ignore lint/suspicious/noExplicitAny: External API response
const resData = (data as any).data;
const projects = Array.isArray(resData) ? resData : (resData?.projects || []);
if (!Array.isArray(projects)) {
logger.warn({ data }, "Projects data from NOC is not an array");
return;
}
for (const proj of projects) {
const extId = proj.id || proj.externalId || `proj-${proj.title.toLowerCase().replace(/\s+/g, "-")}`;
// Temukan divisi lokal berdasarkan nama atau externalId
const divisionName = proj.divisionName || proj.group;
const division = await prisma.division.findFirst({
where: { name: divisionName },
});
if (!division) continue;
await prisma.activity.upsert({
where: { externalId: extId },
update: {
title: proj.title,
status: (typeof proj.status === 'number' ? (proj.status === 2 ? 'Completed' : 'OnProgress') : proj.status) as any,
progress: proj.progress || (proj.status === 2 ? 100 : 50),
divisionId: division.id,
villageId: ID_DESA,
},
create: {
externalId: extId,
title: proj.title,
status: (typeof proj.status === 'number' ? (proj.status === 2 ? 'Completed' : 'OnProgress') : proj.status) as any,
progress: proj.progress || (proj.status === 2 ? 100 : 50),
divisionId: division.id,
villageId: ID_DESA,
},
});
}
logger.info(`Synced ${projects.length} activities`);
}
/**
* 3. Sync Events
*/
async function syncUpcomingEvents() {
logger.info("Syncing Events...");
const systemUserId = await getSystemUserId();
const { data, error } = await nocExternalClient.GET("/api/noc/upcoming-events", {
params: { query: { idDesa: ID_DESA, limit: "50" } },
});
if (error || !data) {
logger.error({ error }, "Failed to fetch events from NOC");
return;
}
// biome-ignore lint/suspicious/noExplicitAny: External API response
const resData = (data as any).data;
let events: any[] = [];
if (Array.isArray(resData)) {
events = resData;
} else if (resData?.today || resData?.upcoming) {
events = [...(resData.today || []), ...(resData.upcoming || [])];
}
for (const event of events) {
const extId = event.id || event.externalId || `event-${event.title.toLowerCase().replace(/\s+/g, "-")}`;
await prisma.event.upsert({
where: { externalId: extId },
update: {
title: event.title,
startDate: new Date(event.startDate || event.date),
location: event.location || "N/A",
eventType: (event.eventType || "Meeting") as any,
villageId: ID_DESA,
},
create: {
externalId: extId,
title: event.title,
startDate: new Date(event.startDate || event.date),
location: event.location || "N/A",
eventType: (event.eventType || "Meeting") as any,
createdBy: systemUserId,
villageId: ID_DESA,
},
});
}
logger.info(`Synced ${events.length} events`);
}
/**
* 4. Sync Discussions
*/
async function syncLatestDiscussion() {
logger.info("Syncing Discussions...");
const systemUserId = await getSystemUserId();
const { data, error } = await nocExternalClient.GET("/api/noc/latest-discussion", {
params: { query: { idDesa: ID_DESA, limit: "50" } },
});
if (error || !data) {
logger.error({ error }, "Failed to fetch discussions from NOC");
return;
}
// biome-ignore lint/suspicious/noExplicitAny: External API response
const resData = (data as any).data;
const discussions = Array.isArray(resData) ? resData : (resData?.discussions || resData?.data || []);
if (!Array.isArray(discussions)) {
logger.warn({ data }, "Discussions data from NOC is not an array");
return;
}
for (const disc of discussions) {
const division = await prisma.division.findFirst({
where: { name: disc.divisionName || disc.group },
});
await prisma.discussion.upsert({
where: { externalId: disc.id },
update: {
message: disc.message || disc.desc || disc.title,
divisionId: division?.id,
villageId: ID_DESA,
},
create: {
externalId: disc.id,
message: disc.message || disc.desc || disc.title,
senderId: systemUserId,
divisionId: division?.id,
villageId: ID_DESA,
},
});
}
logger.info(`Synced ${discussions.length} discussions`);
}
/**
* 5. Update lastSyncedAt timestamp
*/
async function syncLastTimestamp() {
logger.info("Updating sync timestamp...");
await prisma.division.updateMany({
where: { villageId: ID_DESA },
data: { lastSyncedAt: new Date() },
});
}
/**
* Main Sync Function
*/
async function main() {
try {
logger.info("Starting NOC Data Synchronization...");
await syncActiveDivisions();
await syncLatestProjects();
await syncUpcomingEvents();
await syncLatestDiscussion();
await syncLastTimestamp();
logger.info("NOC Data Synchronization Completed Successfully");
} catch (err) {
logger.error({ err }, "Fatal error during NOC synchronization");
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
main();

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

@@ -0,0 +1,216 @@
import Elysia, { t } from "elysia";
import { prisma } from "../utils/db";
import logger from "../utils/logger";
export const complaint = new Elysia({
prefix: "/complaint",
})
.get(
"/stats",
async ({ set }) => {
try {
const [total, baru, proses, selesai] = await Promise.all([
prisma.complaint.count(),
prisma.complaint.count({ where: { status: "BARU" } }),
prisma.complaint.count({ where: { status: "DIPROSES" } }),
prisma.complaint.count({ where: { status: "SELESAI" } }),
]);
return { data: { total, baru, proses, selesai } };
} catch (error) {
logger.error({ error }, "Failed to fetch complaint stats");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Object({
total: t.Number(),
baru: t.Number(),
proses: t.Number(),
selesai: t.Number(),
}),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get complaint statistics" },
},
)
.get(
"/recent",
async ({ set }) => {
try {
const recent = await prisma.complaint.findMany({
orderBy: { createdAt: "desc" },
take: 10,
});
return { data: recent };
} catch (error) {
logger.error({ error }, "Failed to fetch recent complaints");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Array(t.Any()),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get recent complaints" },
},
)
.get(
"/trends",
async ({ set }) => {
try {
// Get last 7 months complaint trends
const trends = await prisma.$queryRaw<
{ month: string; month_num: number; count: number }[]
>`
SELECT
TO_CHAR("createdAt", 'Mon') as month,
EXTRACT(MONTH FROM "createdAt") as month_num,
COUNT(*)::INTEGER as count
FROM complaint
WHERE "createdAt" > NOW() - INTERVAL '7 months'
GROUP BY month, month_num
ORDER BY month_num ASC
`;
return { data: trends };
} catch (error) {
logger.error({ error }, "Failed to fetch complaint trends");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Array(t.Any()),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get complaint trends for last 7 months" },
},
)
.get(
"/service-stats",
async ({ set }) => {
try {
const serviceStats = await prisma.serviceLetter.groupBy({
by: ["letterType"],
_count: { _all: true },
});
return { data: serviceStats };
} catch (error) {
logger.error({ error }, "Failed to fetch service stats");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Array(t.Any()),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get service letter statistics by type" },
},
)
.get(
"/innovation-ideas",
async ({ set }) => {
try {
const ideas = await prisma.innovationIdea.findMany({
orderBy: { createdAt: "desc" },
take: 5,
});
return { data: ideas };
} catch (error) {
logger.error({ error }, "Failed to fetch innovation ideas");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Array(t.Any()),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get recent innovation ideas" },
},
)
.get(
"/service-trends",
async ({ set }) => {
try {
// Get last 6 months trends for service letters
const trends = await prisma.$queryRaw<
{ month: string; month_num: number; count: number }[]
>`
SELECT
TO_CHAR("createdAt", 'Mon') as month,
EXTRACT(MONTH FROM "createdAt") as month_num,
COUNT(*)::INTEGER as count
FROM service_letter
WHERE "createdAt" > NOW() - INTERVAL '6 months'
GROUP BY month, month_num
ORDER BY month_num ASC
`;
return { data: trends };
} catch (error) {
logger.error({ error }, "Failed to fetch service trends");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Array(t.Any()),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get service letter trends for last 6 months" },
},
)
.get(
"/service-weekly",
async ({ set }) => {
try {
const startOfWeek = new Date();
startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay());
startOfWeek.setHours(0, 0, 0, 0);
const count = await prisma.serviceLetter.count({
where: {
createdAt: {
gte: startOfWeek,
},
},
});
return { data: { count } };
} catch (error) {
logger.error({ error }, "Failed to fetch weekly service stats");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Object({
count: t.Number(),
}),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get service letter count for current week" },
},
);

72
src/api/dashboard.ts Normal file
View File

@@ -0,0 +1,72 @@
import { Elysia, t } from "elysia";
import { prisma } from "../utils/db";
export const dashboard = new Elysia({ prefix: "/dashboard" })
.get(
"/budget",
async () => {
const data = await prisma.budget.findMany({
where: { fiscalYear: 2025 },
orderBy: { category: "asc" },
});
return { data };
},
{
response: {
200: t.Object({
data: t.Array(
t.Object({
category: t.String(),
amount: t.Number(),
percentage: t.Number(),
color: t.String(),
}),
),
}),
},
},
)
.get(
"/sdgs",
async () => {
const data = await prisma.sdgsScore.findMany({
orderBy: { score: "desc" },
});
return { data };
},
{
response: {
200: t.Object({
data: t.Array(
t.Object({
title: t.String(),
score: t.Number(),
image: t.Nullable(t.String()),
}),
),
}),
},
},
)
.get(
"/satisfaction",
async () => {
const data = await prisma.satisfactionRating.findMany({
orderBy: { value: "desc" },
});
return { data };
},
{
response: {
200: t.Object({
data: t.Array(
t.Object({
category: t.String(),
value: t.Number(),
color: t.String(),
}),
),
}),
},
},
);

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

@@ -0,0 +1,222 @@
import Elysia, { t } from "elysia";
import { prisma } from "../utils/db";
import logger from "../utils/logger";
export const division = new Elysia({
prefix: "/division",
})
.get(
"/",
async ({ set }) => {
try {
const divisions = await prisma.division.findMany({
include: {
_count: {
select: { activities: true },
},
},
});
return {
data: divisions.map(d => ({
...d,
activityCount: d.externalActivityCount || d._count.activities
}))
};
} catch (error) {
logger.error({ error }, "Failed to fetch divisions");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Array(t.Any()),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get all divisions" },
},
)
.get(
"/activities",
async ({ set }) => {
try {
const activities = await prisma.activity.findMany({
include: {
division: {
select: { name: true, color: true },
},
},
orderBy: { createdAt: "desc" },
take: 10,
});
return { data: activities };
} catch (error) {
logger.error({ error }, "Failed to fetch activities");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Array(t.Any()),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get recent activities" },
},
)
.get(
"/activities/stats",
async ({ set }) => {
try {
// Get activity count by status
const [selesai, berjalan, tertunda, dibatalkan] = await Promise.all([
prisma.activity.count({ where: { status: "SELESAI" } }),
prisma.activity.count({ where: { status: "BERJALAN" } }),
prisma.activity.count({ where: { status: "TERTUNDA" } }),
prisma.activity.count({ where: { status: "DIBATALKAN" } }),
]);
const total = selesai + berjalan + tertunda + dibatalkan;
// Calculate percentages
const percentages = {
selesai: total > 0 ? (selesai / total) * 100 : 0,
berjalan: total > 0 ? (berjalan / total) * 100 : 0,
tertunda: total > 0 ? (tertunda / total) * 100 : 0,
dibatalkan: total > 0 ? (dibatalkan / total) * 100 : 0,
};
return {
data: {
total,
counts: { selesai, berjalan, tertunda, dibatalkan },
percentages,
},
};
} catch (error) {
logger.error({ error }, "Failed to fetch activity stats");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Object({
total: t.Number(),
counts: t.Object({
selesai: t.Number(),
berjalan: t.Number(),
tertunda: t.Number(),
dibatalkan: t.Number(),
}),
percentages: t.Object({
selesai: t.Number(),
berjalan: t.Number(),
tertunda: t.Number(),
dibatalkan: t.Number(),
}),
}),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get activity statistics by status" },
},
)
.get(
"/documents/stats",
async ({ set }) => {
try {
// Group documents by type
const [gambarCount, dokumenCount] = await Promise.all([
prisma.document.count({ where: { type: "Gambar" } }),
prisma.document.count({ where: { type: "Dokumen" } }),
]);
return {
data: [
{ name: "Gambar", jumlah: gambarCount, color: "#FACC15" },
{ name: "Dokumen", jumlah: dokumenCount, color: "#22C55E" },
],
};
} catch (error) {
logger.error({ error }, "Failed to fetch document stats");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Array(
t.Object({
name: t.String(),
jumlah: t.Number(),
color: t.String(),
}),
),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get document statistics by type" },
},
)
.get(
"/discussions",
async ({ set }) => {
try {
// Get recent discussions with sender info
const discussions = await prisma.discussion.findMany({
where: { parentId: null }, // Only top-level discussions
include: {
sender: {
select: { name: true, email: true },
},
division: {
select: { name: true },
},
},
orderBy: { createdAt: "desc" },
take: 10,
});
// Format for frontend
const formattedDiscussions = discussions.map((d) => ({
id: d.id,
message: d.message,
sender: d.sender.name || d.sender.email,
date: d.createdAt.toISOString(),
division: d.division?.name || null,
isResolved: d.isResolved,
}));
return { data: formattedDiscussions };
} catch (error) {
logger.error({ error }, "Failed to fetch discussions");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Array(
t.Object({
id: t.String(),
message: t.String(),
sender: t.String(),
date: t.String(),
division: t.Nullable(t.String()),
isResolved: t.Boolean(),
}),
),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get recent discussions" },
},
);

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

@@ -0,0 +1,66 @@
import Elysia, { t } from "elysia";
import { prisma } from "../utils/db";
import logger from "../utils/logger";
export const event = new Elysia({
prefix: "/event",
})
.get(
"/",
async ({ set }) => {
try {
const events = await prisma.event.findMany({
orderBy: { startDate: "asc" },
take: 20,
});
return { data: events };
} catch (error) {
logger.error({ error }, "Failed to fetch events");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Array(t.Any()),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get upcoming events" },
},
)
.get(
"/today",
async ({ set }) => {
try {
const start = new Date();
start.setHours(0, 0, 0, 0);
const end = new Date();
end.setHours(23, 59, 59, 999);
const events = await prisma.event.findMany({
where: {
startDate: {
gte: start,
lte: end,
},
},
});
return { data: events };
} catch (error) {
logger.error({ error }, "Failed to fetch today's events");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Array(t.Any()),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get events for today" },
},
);

View File

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

330
src/api/noc.ts Normal file
View File

@@ -0,0 +1,330 @@
import { Elysia, t } from "elysia";
import { prisma } from "../utils/db";
import { $ } from "bun";
export const noc = new Elysia({ prefix: "/noc" })
.post(
"/sync",
async ({ set, user }) => {
if (!user || user.role !== "admin") {
set.status = 401;
return { error: "Unauthorized" };
}
try {
// Jalankan script sinkronisasi
await $`bun run sync:noc`.quiet();
return {
success: true,
message: "Sinkronisasi berhasil diselesaikan",
lastSyncedAt: new Date().toISOString(),
};
} catch (error) {
return { success: false, error: "Sinkronisasi gagal dijalankan" };
}
},
{
response: {
200: t.Object({
success: t.Boolean(),
message: t.Optional(t.String()),
error: t.Optional(t.String()),
lastSyncedAt: t.Optional(t.String()),
}),
401: t.Object({ error: t.String() }),
},
},
)
.get(
"/last-sync",
async ({ query }) => {
const { idDesa } = query;
const latest = await prisma.division.findFirst({
where: { villageId: idDesa },
select: { lastSyncedAt: true },
orderBy: { lastSyncedAt: "desc" },
});
return { lastSyncedAt: latest?.lastSyncedAt?.toISOString() || null };
},
{
query: t.Object({ idDesa: t.String() }),
response: {
200: t.Object({
lastSyncedAt: t.Nullable(t.String()),
}),
},
},
)
.get(
"/active-divisions",
async ({ query }) => {
const { idDesa, limit } = query;
const data = await prisma.division.findMany({
where: { villageId: idDesa },
include: {
_count: {
select: { activities: true },
},
},
orderBy: {
activities: {
_count: "desc",
},
},
take: limit ? Number.parseInt(limit) : 5,
});
return {
data: data.map((d) => ({
id: d.id,
name: d.name,
activityCount: d._count.activities,
color: d.color,
})),
};
},
{
query: t.Object({
idDesa: t.String(),
limit: t.Optional(t.String()),
}),
response: {
200: t.Object({
data: t.Array(
t.Object({
id: t.String(),
name: t.String(),
activityCount: t.Number(),
color: t.String(),
}),
),
}),
},
},
)
.get(
"/latest-projects",
async ({ query }) => {
const { idDesa, limit } = query;
const data = await prisma.activity.findMany({
where: { villageId: idDesa },
orderBy: { createdAt: "desc" },
take: limit ? Number.parseInt(limit) : 5,
include: { division: true },
});
return {
data: data.map((a) => ({
id: a.id,
title: a.title,
status: a.status,
progress: a.progress,
divisionName: a.division.name,
createdAt: a.createdAt.toISOString(),
})),
};
},
{
query: t.Object({
idDesa: t.String(),
limit: t.Optional(t.String()),
}),
response: {
200: t.Object({
data: t.Array(
t.Object({
id: t.String(),
title: t.String(),
status: t.String(),
progress: t.Number(),
divisionName: t.String(),
createdAt: t.String(),
}),
),
}),
},
},
)
.get(
"/upcoming-events",
async ({ query }) => {
const { idDesa, limit, filter } = query;
const now = new Date();
const where: any = { villageId: idDesa };
if (filter === "today") {
const startOfDay = new Date(now.setHours(0, 0, 0, 0));
const endOfDay = new Date(now.setHours(23, 59, 59, 999));
where.startDate = {
gte: startOfDay,
lte: endOfDay,
};
} else {
where.startDate = {
gte: now,
};
}
const data = await prisma.event.findMany({
where,
orderBy: { startDate: "asc" },
take: limit ? Number.parseInt(limit) : 5,
});
return {
data: data.map((e) => ({
id: e.id,
title: e.title,
startDate: e.startDate.toISOString(),
location: e.location,
eventType: e.eventType,
})),
};
},
{
query: t.Object({
idDesa: t.String(),
limit: t.Optional(t.String()),
filter: t.Optional(t.String()), // today/upcoming
}),
response: {
200: t.Object({
data: t.Array(
t.Object({
id: t.String(),
title: t.String(),
startDate: t.String(),
location: t.Nullable(t.String()),
eventType: t.String(),
}),
),
}),
},
},
)
.get(
"/diagram-jumlah-document",
async ({ query }) => {
const { idDesa } = query;
const data = await prisma.document.groupBy({
where: { villageId: idDesa },
by: ["category"],
_count: {
_all: true,
},
});
return {
data: data.map((d) => ({
category: d.category,
count: d._count._all,
})),
};
},
{
query: t.Object({
idDesa: t.String(),
}),
response: {
200: t.Object({
data: t.Array(
t.Object({
category: t.String(),
count: t.Number(),
}),
),
}),
},
},
)
.get(
"/diagram-progres-kegiatan",
async ({ query }) => {
const { idDesa } = query;
const data = await prisma.activity.groupBy({
where: { villageId: idDesa },
by: ["status"],
_avg: {
progress: true,
},
_count: {
_all: true,
},
});
return {
data: data.map((d) => ({
status: d.status,
avgProgress: d._avg.progress || 0,
count: d._count._all,
})),
};
},
{
query: t.Object({
idDesa: t.String(),
}),
response: {
200: t.Object({
data: t.Array(
t.Object({
status: t.String(),
avgProgress: t.Number(),
count: t.Number(),
}),
),
}),
},
},
)
.get(
"/latest-discussion",
async ({ query }) => {
const { idDesa, limit } = query;
const data = await prisma.discussion.findMany({
where: { villageId: idDesa },
orderBy: { createdAt: "desc" },
take: limit ? Number.parseInt(limit) : 5,
include: {
sender: {
select: { name: true, image: true },
},
division: {
select: { name: true },
},
},
});
return {
data: data.map((d) => ({
id: d.id,
message: d.message,
senderName: d.sender.name || "Anonymous",
senderImage: d.sender.image,
divisionName: d.division?.name || "General",
createdAt: d.createdAt.toISOString(),
})),
};
},
{
query: t.Object({
idDesa: t.String(),
limit: t.Optional(t.String()),
}),
response: {
200: t.Object({
data: t.Array(
t.Object({
id: t.String(),
message: t.String(),
senderName: t.String(),
senderImage: t.Nullable(t.String()),
divisionName: t.String(),
createdAt: t.String(),
}),
),
}),
},
},
);

View File

@@ -1,67 +1,69 @@
import Elysia, { t } from "elysia";
import { apiMiddleware } from "../middleware/apiMiddleware";
import { prisma } from "../utils/db";
import logger from "../utils/logger";
export const profile = new Elysia({
prefix: "/profile",
}).post(
"/update",
async (ctx) => {
const { body, set, user } = ctx as any;
try {
if (!user) {
set.status = 401;
return { error: "Unauthorized" };
})
.use(apiMiddleware)
.post(
"/update",
async ({ body, set, user }) => {
try {
if (!user) {
set.status = 401;
return { error: "Unauthorized" };
}
const { name, image } = body;
const updatedUser = await prisma.user.update({
where: { id: user.id },
data: {
name: name || undefined,
image: image || undefined,
},
select: {
id: true,
name: true,
email: true,
image: true,
role: true,
},
});
logger.info({ userId: user.id }, "Profile updated successfully");
return { user: updatedUser };
} catch (error) {
logger.error({ error, userId: user?.id }, "Failed to update profile");
set.status = 500;
return { error: "Failed to update profile" };
}
const { name, image } = body;
const updatedUser = await prisma.user.update({
where: { id: user.id },
data: {
name: name || undefined,
image: image || undefined,
},
select: {
id: true,
name: true,
email: true,
image: true,
role: true,
},
});
logger.info({ userId: user.id }, "Profile updated successfully");
return { user: updatedUser };
} catch (error) {
logger.error({ error, userId: user?.id }, "Failed to update profile");
set.status = 500;
return { error: "Failed to update profile" };
}
},
{
body: t.Object({
name: t.Optional(t.String()),
image: t.Optional(t.String()),
}),
response: {
200: t.Object({
user: t.Object({
id: t.String(),
name: t.Any(),
email: t.String(),
image: t.Any(),
role: t.Any(),
}),
},
{
body: t.Object({
name: t.Optional(t.String()),
image: t.Optional(t.String()),
}),
401: t.Object({ error: t.String() }),
500: t.Object({ error: t.String() }),
},
response: {
200: t.Object({
user: t.Object({
id: t.String(),
name: t.Any(),
email: t.String(),
image: t.Any(),
role: t.Any(),
}),
}),
401: t.Object({ error: t.String() }),
500: t.Object({ error: t.String() }),
},
detail: {
summary: "Update user profile",
description: "Update the authenticated user's name or profile image",
detail: {
summary: "Update user profile",
description: "Update the authenticated user's name or profile image",
},
},
},
);
);

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

@@ -0,0 +1,129 @@
import Elysia, { t } from "elysia";
import { prisma } from "../utils/db";
import logger from "../utils/logger";
export const resident = new Elysia({
prefix: "/resident",
})
.get(
"/stats",
async ({ set }) => {
try {
const [total, heads, poor] = await Promise.all([
prisma.resident.count(),
prisma.resident.count({ where: { isHeadOfHousehold: true } }),
prisma.resident.count({ where: { isPoor: true } }),
]);
return { data: { total, heads, poor } };
} catch (error) {
logger.error({ error }, "Failed to fetch resident stats");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Object({
total: t.Number(),
heads: t.Number(),
poor: t.Number(),
}),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get resident statistics" },
},
)
.get(
"/banjar-stats",
async ({ set }) => {
try {
const banjarStats = await prisma.banjar.findMany({
select: {
id: true,
name: true,
totalPopulation: true,
totalKK: true,
totalPoor: true,
},
});
return { data: banjarStats };
} catch (error) {
logger.error({ error }, "Failed to fetch banjar stats");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Array(t.Any()),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get population data per banjar" },
},
)
.get(
"/demographics",
async ({ set }) => {
try {
const [religion, gender, occupation, ageGroups] = await Promise.all([
prisma.resident.groupBy({
by: ["religion"],
_count: { _all: true },
}),
prisma.resident.groupBy({
by: ["gender"],
_count: { _all: true },
}),
prisma.resident.groupBy({
by: ["occupation"],
_count: { _all: true },
orderBy: { _count: { occupation: "desc" } },
take: 10,
}),
// Group by age ranges (simplified calculation)
prisma.$queryRaw<{ range: string; count: number }[]>`
SELECT
CASE
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 0 AND 16 THEN '0-16'
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 17 AND 25 THEN '17-25'
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 26 AND 35 THEN '26-35'
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 36 AND 45 THEN '36-45'
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 46 AND 55 THEN '46-55'
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 56 AND 65 THEN '56-65'
ELSE '65+'
END as range,
COUNT(*) as count
FROM resident
GROUP BY range
ORDER BY range ASC
`,
]);
return { data: { religion, gender, occupation, ageGroups } };
} catch (error) {
logger.error({ error }, "Failed to fetch demographics");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Object({
religion: t.Array(t.Any()),
gender: t.Array(t.Any()),
occupation: t.Array(t.Any()),
ageGroups: t.Array(t.Any()),
}),
}),
500: t.Object({ error: t.String() }),
},
detail: {
summary:
"Get demographics including religion, gender, occupation and age",
},
},
);

View File

@@ -1,385 +1,38 @@
import {
Badge,
Button,
Card,
Grid,
GridCol,
Group,
Select,
Stack,
Table,
Text,
Title,
useMantineColorScheme,
} from "@mantine/core";
import {
IconBuildingStore,
IconCategory,
IconCurrency,
IconUsers,
} from "@tabler/icons-react";
import { useState } from "react";
import { Grid, GridCol, Stack } from "@mantine/core";
import { HeaderToggle } from "./umkm/header-toggle";
import { ProdukUnggulan } from "./umkm/produk-unggulan";
import type { SalesData } from "./umkm/sales-table";
import { SalesTable } from "./umkm/sales-table";
import { SummaryCards } from "./umkm/summary-cards";
import { TopProducts } from "./umkm/top-products";
const BumdesPage = () => {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const [timeFilter, setTimeFilter] = useState<string>("bulan");
// Sample data for KPI cards
const kpiData = [
{
title: "UMKM Aktif",
value: 45,
icon: <IconUsers size={24} />,
color: "darmasaba-blue",
},
{
title: "UMKM Terdaftar",
value: 68,
icon: <IconBuildingStore size={24} />,
color: "darmasaba-success",
},
{
title: "Omzet",
value: "Rp 48.000.000",
icon: <IconCurrency size={24} />,
color: "darmasaba-warning",
},
{
title: "Kategori UMKM",
value: 34,
icon: <IconCategory size={24} />,
color: "darmasaba-danger",
},
];
// Sample data for top products
const topProducts = [
{
rank: 1,
name: "Beras Premium Organik",
umkmOwner: "Warung Pak Joko",
growth: "+12%",
},
{
rank: 2,
name: "Keripik Singkong",
umkmOwner: "Ibu Sari Snack",
growth: "+8%",
},
{
rank: 3,
name: "Madu Alami",
umkmOwner: "Peternakan Lebah",
growth: "+5%",
},
];
// Sample data for product sales
const productSales = [
{
produk: "Beras Premium Organik",
penjualanBulanIni: "Rp 8.500.000",
bulanLalu: "Rp 8.500.000",
trend: 10,
volume: "650 Kg",
stok: "850 Kg",
},
{
produk: "Keripik Singkong",
penjualanBulanIni: "Rp 4.200.000",
bulanLalu: "Rp 3.800.000",
trend: 10,
volume: "320 Kg",
stok: "120 Kg",
},
{
produk: "Madu Alami",
penjualanBulanIni: "Rp 3.750.000",
bulanLalu: "Rp 4.100.000",
trend: -8,
volume: "150 Liter",
stok: "45 Liter",
},
{
produk: "Kecap Tradisional",
penjualanBulanIni: "Rp 2.800.000",
bulanLalu: "Rp 2.500.000",
trend: 12,
volume: "280 Botol",
stok: "95 Botol",
},
];
const handleDetailClick = (product: SalesData) => {
console.log("Detail clicked for:", product);
// TODO: Open modal or navigate to detail page
};
return (
<Stack gap="lg">
{/* KPI Cards */}
<Grid gutter="md">
{kpiData.map((kpi, index) => (
<GridCol key={index} span={{ base: 12, sm: 6, md: 3 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Group justify="space-between" align="center">
<Stack gap={0}>
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
{kpi.title}
</Text>
<Text size="xl" fw={700} c={dark ? "dark.0" : "black"}>
{typeof kpi.value === "number"
? kpi.value.toLocaleString()
: kpi.value}
</Text>
</Stack>
<Badge variant="light" color={kpi.color} p={8} radius="md">
{kpi.icon}
</Badge>
</Group>
</Card>
</GridCol>
))}
</Grid>
{/* KPI Summary Cards */}
<SummaryCards />
{/* Update Penjualan Produk Header */}
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Group justify="space-between" align="center" px="md" py="xs">
<Title order={3} c={dark ? "dark.0" : "black"}>
Update Penjualan Produk
</Title>
<Group>
<Button
variant={timeFilter === "minggu" ? "filled" : "light"}
onClick={() => setTimeFilter("minggu")}
color="darmasaba-blue"
>
Minggu ini
</Button>
<Button
variant={timeFilter === "bulan" ? "filled" : "light"}
onClick={() => setTimeFilter("bulan")}
color="darmasaba-blue"
>
Bulan ini
</Button>
</Group>
</Group>
</Card>
{/* Header with Time Range Toggle */}
<HeaderToggle />
{/* Main Content - 2 Column Layout */}
<Grid gutter="md">
{/* Produk Unggulan (Left Column) */}
{/* Left Panel - Produk Unggulan */}
<GridCol span={{ base: 12, lg: 4 }}>
<Stack gap="md">
{/* Total Penjualan, Produk Aktif, Total Transaksi */}
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
Total Penjualan
</Text>
<Text size="lg" fw={700} c={dark ? "dark.0" : "black"}>
Rp 28.500.000
</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
Produk Aktif
</Text>
<Text size="lg" fw={700} c={dark ? "dark.0" : "black"}>
124 Produk
</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
Total Transaksi
</Text>
<Text size="lg" fw={700} c={dark ? "dark.0" : "black"}>
1.240 Transaksi
</Text>
</Group>
</Stack>
</Card>
{/* Top 3 Produk Terlaris */}
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Title order={4} mb="md" c={dark ? "dark.0" : "black"}>
Top 3 Produk Terlaris
</Title>
<Stack gap="sm">
{topProducts.map((product) => (
<Group
key={product.rank}
justify="space-between"
align="center"
>
<Group gap="sm">
<Badge
variant="filled"
color={
product.rank === 1
? "gold"
: product.rank === 2
? "gray"
: "bronze"
}
radius="xl"
size="lg"
>
{product.rank}
</Badge>
<Stack gap={0}>
<Text fw={500} c={dark ? "dark.0" : "black"}>
{product.name}
</Text>
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
{product.umkmOwner}
</Text>
</Stack>
</Group>
<Badge
variant="light"
color={product.growth.startsWith("+") ? "green" : "red"}
>
{product.growth}
</Badge>
</Group>
))}
</Stack>
</Card>
<ProdukUnggulan />
<TopProducts />
</Stack>
</GridCol>
{/* Detail Penjualan Produk (Right Column) */}
{/* Right Panel - Detail Penjualan Produk */}
<GridCol span={{ base: 12, lg: 8 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Group justify="space-between" mb="md">
<Title order={4} c={dark ? "dark.0" : "black"}>
Detail Penjualan Produk
</Title>
<Select
placeholder="Filter kategori"
data={[
{ value: "semua", label: "Semua Kategori" },
{ value: "makanan", label: "Makanan" },
{ value: "minuman", label: "Minuman" },
{ value: "kerajinan", label: "Kerajinan" },
]}
defaultValue="semua"
w={200}
/>
</Group>
<Table striped highlightOnHover withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>
<Text c={dark ? "white" : "dimmed"}>Produk</Text>
</Table.Th>
<Table.Th>
<Text c={dark ? "white" : "dimmed"}>
Penjualan Bulan Ini
</Text>
</Table.Th>
<Table.Th>
<Text c={dark ? "white" : "dimmed"}>Bulan Lalu</Text>
</Table.Th>
<Table.Th>
<Text c={dark ? "white" : "dimmed"}>Trend</Text>
</Table.Th>
<Table.Th>
<Text c={dark ? "white" : "dimmed"}>Volume</Text>
</Table.Th>
<Table.Th>
<Text c={dark ? "white" : "dimmed"}>Stok</Text>
</Table.Th>
<Table.Th>
<Text c={dark ? "white" : "dimmed"}>Aksi</Text>
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{productSales.map((product, index) => (
<Table.Tr key={index}>
<Table.Td>
<Text fw={500} c={dark ? "dark.0" : "black"}>
{product.produk}
</Text>
</Table.Td>
<Table.Td>
<Text fz={"sm"} c={dark ? "dark.0" : "black"}>
{product.penjualanBulanIni}
</Text>
</Table.Td>
<Table.Td>
<Text fz={"sm"} c={dark ? "white" : "dimmed"}>
{product.bulanLalu}
</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Text c={product.trend >= 0 ? "green" : "red"}>
{product.trend >= 0 ? "↑" : "↓"}{" "}
{Math.abs(product.trend)}%
</Text>
</Group>
</Table.Td>
<Table.Td>
<Text fz={"sm"} c={dark ? "dark.0" : "black"}>
{product.volume}
</Text>
</Table.Td>
<Table.Td>
<Badge
variant="light"
color={
parseInt(product.stok) > 200 ? "green" : "yellow"
}
>
{product.stok}
</Badge>
</Table.Td>
<Table.Td>
<Button
variant="subtle"
size="compact-sm"
color="darmasaba-blue"
>
Detail
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
<SalesTable onDetailClick={handleDetailClick} />
</GridCol>
</Grid>
</Stack>

View File

@@ -1,510 +1,154 @@
import {
Calendar,
CheckCircle,
FileText,
MessageCircle,
Users,
} from "lucide-react";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Pie,
PieChart,
ResponsiveContainer,
Tooltip, // Added Tooltip import
XAxis,
YAxis,
} from "recharts";
// Import Mantine components
import {
ActionIcon,
Badge,
Box,
Card, // Added for icon containers
Grid,
Group,
Progress,
Stack,
Text,
ThemeIcon,
Title,
useMantineColorScheme, // Add this import
} from "@mantine/core";
const barChartData = [
{ month: "Jan", value: 145 },
{ month: "Feb", value: 165 },
{ month: "Mar", value: 195 },
{ month: "Apr", value: 155 },
{ month: "Mei", value: 205 },
{ month: "Jun", value: 185 },
];
const pieChartData = [
{ name: "Puas", value: 25 },
{ name: "Cukup", value: 25 },
{ name: "Kurang", value: 25 },
{ name: "Sangat puas", value: 25 },
];
const COLORS = ["#4E5BA6", "#F4C542", "#8CC63F", "#E57373"];
const divisiData = [
{ name: "Kesejahteraan", value: 37 },
{ name: "Pemerintahan", value: 26 },
{ name: "Keuangan", value: 17 },
{ name: "Sekretaris Desa", value: 15 },
];
const eventData = [
{ date: "1 Oktober 2025", title: "Hari Kesaktian Pancasila" },
{ date: "15 Oktober 2025", title: "Davest" },
{ date: "19 Oktober 2025", title: "Rapat Koordinasi" },
];
const apbdesData = [
{ name: "Belanja", value: 70, color: "blue" },
{ name: "Pendapatan", value: 90, color: "green" },
{ name: "Pembangunan", value: 50, color: "orange" },
];
import { Center, Grid, Image, Loader, Stack } from "@mantine/core";
import { CheckCircle, FileText, MessageCircle, Users } from "lucide-react";
import { useEffect, useState } from "react";
import { apiClient } from "@/utils/api-client";
import { ActivityList } from "./dashboard/activity-list";
import { ChartAPBDes } from "./dashboard/chart-apbdes";
import { ChartSurat } from "./dashboard/chart-surat";
import { DivisionProgress } from "./dashboard/division-progress";
import { SatisfactionChart } from "./dashboard/satisfaction-chart";
import { SDGSCard } from "./dashboard/sdgs-card";
import { StatCard } from "./dashboard/stat-card";
export function DashboardContent() {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const [stats, setStats] = useState({
complaints: { total: 0, baru: 0, proses: 0, selesai: 0 },
residents: { total: 0, heads: 0, poor: 0 },
weeklyService: 0,
loading: true,
});
const [sdgsData, setSdgsData] = useState<
{ title: string; score: number; image: string | null }[]
>([]);
const [sdgsLoading, setSdgsLoading] = useState(true);
useEffect(() => {
async function fetchStats() {
try {
const [complaintRes, residentRes, weeklyServiceRes, sdgsRes] =
await Promise.all([
apiClient.GET("/api/complaint/stats"),
apiClient.GET("/api/resident/stats"),
apiClient.GET("/api/complaint/service-weekly"),
apiClient.GET("/api/dashboard/sdgs"),
]);
setStats({
complaints: (complaintRes.data as { data: typeof stats.complaints })
?.data || {
total: 0,
baru: 0,
proses: 0,
selesai: 0,
},
residents: (residentRes.data as { data: typeof stats.residents })
?.data || {
total: 0,
heads: 0,
poor: 0,
},
weeklyService:
(weeklyServiceRes.data as { data: { count: number } })?.data
?.count || 0,
loading: false,
});
if (sdgsRes.data?.data) {
setSdgsData(sdgsRes.data.data);
}
setSdgsLoading(false);
} catch (error) {
console.error("Failed to fetch dashboard content", error);
setStats((prev) => ({ ...prev, loading: false }));
setSdgsLoading(false);
}
}
fetchStats();
}, []);
return (
<Stack gap="lg">
{/* Stats Cards */}
{/* Header Metrics - 4 Stat Cards */}
<Grid gutter="md">
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
<Card
p="md"
style={{ borderColor: dark ? "#141D34" : "white" }}
radius="md"
h="100%"
withBorder
bg={dark ? "#141D34" : "white"}
>
<Group justify="space-between" align="flex-start" w="100%">
<Box style={{ flex: 1 }}>
<Text size="sm" c="dimmed" mb="xs">
Surat Minggu Ini
</Text>
<Group align="baseline" gap="xs">
<Text size="xl" fw={700}>
99
</Text>
</Group>
<Text size="sm" c="dimmed" mt="xs">
14 baru, 14 diproses
</Text>
<Text size="sm" c="red" mt="xs">
12% dari minggu lalu +12%
</Text>
</Box>
<ThemeIcon
variant="filled"
size="xl"
radius="xl"
color={dark ? "gray" : "darmasaba-blue"}
>
<FileText style={{ width: "70%", height: "70%" }} />
</ThemeIcon>
</Group>
</Card>
<StatCard
title="Surat Minggu Ini"
value={stats.weeklyService}
detail="Total surat diajukan"
icon={<FileText style={{ width: "70%", height: "70%" }} />}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
<Card
p="md"
style={{ borderColor: dark ? "#141D34" : "white" }}
radius="md"
h="100%"
withBorder
bg={dark ? "#141D34" : "white"}
>
<Group justify="space-between" align="flex-start" w="100%">
<Box style={{ flex: 1 }}>
<Text size="sm" c="dimmed" mb="xs">
Pengaduan Aktif
</Text>
<Group align="baseline" gap="xs">
<Text size="xl" fw={700}>
28
</Text>
</Group>
<Text size="sm" c="dimmed" mt="xs">
14 baru, 14 diproses
</Text>
</Box>
<ThemeIcon
variant="filled"
size="xl"
radius="xl"
color={dark ? "gray" : "darmasaba-blue"}
>
<MessageCircle style={{ width: "70%", height: "70%" }} />
</ThemeIcon>
</Group>
</Card>
<StatCard
title="Pengaduan Aktif"
value={stats.complaints.baru + stats.complaints.proses}
detail={`${stats.complaints.baru} baru, ${stats.complaints.proses} diproses`}
icon={<MessageCircle style={{ width: "70%", height: "70%" }} />}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
<Card
p="md"
style={{ borderColor: dark ? "#141D34" : "white" }}
radius="md"
h="100%"
withBorder
bg={dark ? "#141D34" : "white"}
>
<Group justify="space-between" align="flex-start" w="100%">
<Box style={{ flex: 1 }}>
<Text size="sm" c="dimmed" mb="xs">
Layanan Selesai
</Text>
<Group align="baseline" gap="xs">
<Text size="xl" fw={700}>
156
</Text>
</Group>
<Text size="sm" c="dimmed" mt="xs">
bulan ini
</Text>
<Text size="sm" c="red" mt="xs">
+8%
</Text>
</Box>
<ThemeIcon
variant="filled"
size="xl"
radius="xl"
color={dark ? "gray" : "darmasaba-blue"}
>
<CheckCircle style={{ width: "70%", height: "70%" }} />
</ThemeIcon>
</Group>
</Card>
<StatCard
title="Layanan Selesai"
value={stats.complaints.selesai}
detail="Total diselesaikan"
icon={<CheckCircle style={{ width: "70%", height: "70%" }} />}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
<Card
p="md"
style={{ borderColor: dark ? "#141D34" : "white" }}
radius="md"
h="100%"
withBorder
bg={dark ? "#141D34" : "white"}
>
<Group justify="space-between" align="flex-start" w="100%">
<Box style={{ flex: 1 }}>
<Text size="sm" c="dimmed" mb="xs">
Kepuasan Warga
</Text>
<Group align="baseline" gap="xs">
<Text size="xl" fw={700}>
87.2%
</Text>
</Group>
<Text size="sm" c="dimmed" mt="xs">
dari 482 responden
</Text>
</Box>
<ThemeIcon
variant="filled"
size="xl"
radius="xl"
color={dark ? "gray" : "darmasaba-blue"}
>
<Users style={{ width: "70%", height: "70%" }} />
</ThemeIcon>
</Group>
</Card>
<StatCard
title="Total Penduduk"
value={stats.residents.total.toLocaleString()}
detail={`${stats.residents.heads} Kepala Keluarga`}
icon={<Users style={{ width: "70%", height: "70%" }} />}
/>
</Grid.Col>
</Grid>
{/* Section 2: Chart & Division Progress */}
<Grid gutter="lg">
{/* Bar Chart */}
<Grid.Col span={{ base: 12, lg: 6 }}>
<Card
p="md"
style={{ borderColor: dark ? "#141D34" : "white" }}
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
>
<Group justify="space-between" mb="md">
<Box>
<Title order={4} mb={5}>
Statistik Pengajuan Surat
</Title>
<Text size="sm" c="dimmed">
Trend pengajuan surat 6 bulan terakhir
</Text>
</Box>
<ActionIcon variant="subtle" size="lg" radius="md">
{/* Original SVG converted to a generic Icon placeholder */}
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 5L13 10L8 15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</ActionIcon>
</Group>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={barChartData}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke="var(--mantine-color-gray-3)"
/>
<XAxis
dataKey="month"
axisLine={false}
tickLine={false}
tick={{ fill: "var(--mantine-color-text)" }}
/>
<YAxis
axisLine={false}
tickLine={false}
ticks={[0, 55, 110, 165, 220]}
tick={{ fill: "var(--mantine-color-text)" }}
/>
<Tooltip />
<Bar
dataKey="value"
fill="var(--mantine-color-blue-filled)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</Card>
<Grid.Col span={{ base: 12, lg: 7 }}>
<ChartSurat />
</Grid.Col>
{/* Pie Chart */}
<Grid.Col span={{ base: 12, lg: 6 }}>
<Card
p="md"
style={{ borderColor: dark ? "#141D34" : "white" }}
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
>
<Title order={4} mb={5}>
Tingkat Kepuasan
</Title>
<Text size="sm" c="dimmed" mb="md">
Tingkat kepuasan layanan
</Text>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={pieChartData}
cx="50%"
cy="50%"
innerRadius={80}
outerRadius={120}
paddingAngle={2}
dataKey="value"
>
{pieChartData.map((_entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
<Group justify="center" gap="md" mt="md">
<Group gap="xs">
<Box
w={12}
h={12}
style={{ backgroundColor: COLORS[0], borderRadius: "50%" }}
/>
<Text size="sm">Sangat puas (0%)</Text>
</Group>
<Group gap="xs">
<Box
w={12}
h={12}
style={{ backgroundColor: COLORS[1], borderRadius: "50%" }}
/>
<Text size="sm">Puas (0%)</Text>
</Group>
<Group gap="xs">
<Box
w={12}
h={12}
style={{ backgroundColor: COLORS[2], borderRadius: "50%" }}
/>
<Text size="sm">Cukup (0%)</Text>
</Group>
<Group gap="xs">
<Box
w={12}
h={12}
style={{ backgroundColor: COLORS[3], borderRadius: "50%" }}
/>
<Text size="sm">Kurang (0%)</Text>
</Group>
</Group>
</Card>
<Grid.Col span={{ base: 12, lg: 5 }}>
<SatisfactionChart />
</Grid.Col>
</Grid>
{/* Bottom Section */}
{/* Section 3: APBDes Chart */}
<Grid gutter="lg">
{/* Divisi Teraktif */}
<Grid.Col span={{ base: 12, lg: 6 }}>
<Card
p="md"
style={{ borderColor: dark ? "#141D34" : "white" }}
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
>
<Group gap="xs" mb="lg">
<Box>
{/* Original SVG icon */}
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="3"
width="7"
height="7"
rx="1"
fill="currentColor"
/>
<rect
x="3"
y="14"
width="7"
height="7"
rx="1"
fill="currentColor"
/>
<rect
x="14"
y="3"
width="7"
height="7"
rx="1"
fill="currentColor"
/>
<rect
x="14"
y="14"
width="7"
height="7"
rx="1"
fill="currentColor"
/>
</svg>
</Box>
<Title order={4}>Divisi Teraktif</Title>
</Group>
<Stack gap="sm">
{divisiData.map((divisi, index) => (
<Box key={index}>
<Group justify="space-between" mb={5}>
<Text size="sm" fw={500}>
{divisi.name}
</Text>
<Text size="sm" fw={600}>
{divisi.value} Kegiatan
</Text>
</Group>
<Progress
value={(divisi.value / 37) * 100}
size="sm"
radius="xl"
color="blue"
/>
</Box>
))}
</Stack>
</Card>
<Grid.Col span={{ base: 12, lg: 7 }}>
<DivisionProgress />
</Grid.Col>
{/* Kalender */}
<Grid.Col span={{ base: 12, lg: 6 }}>
<Card
p="md"
style={{ borderColor: dark ? "#141D34" : "white" }}
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
>
<Group gap="xs" mb="lg">
<Calendar style={{ width: 20, height: 20 }} />
<Title order={4}>Kalender & Kegiatan Mendatang</Title>
</Group>
<Stack gap="md">
{eventData.map((event, index) => (
<Box
key={index}
style={{
borderLeft: "4px solid var(--mantine-color-blue-filled)",
paddingLeft: 12,
}}
>
<Text size="sm" c="dimmed">
{event.date}
</Text>
<Text fw={500}>{event.title}</Text>
</Box>
))}
</Stack>
</Card>
<Grid.Col span={{ base: 12, lg: 5 }}>
<ActivityList />
{/* <SatisfactionChart /> */}
</Grid.Col>
</Grid>
{/* APBDes Chart */}
<Card
p="md"
style={{ borderColor: dark ? "#141D34" : "white" }}
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
>
<Title order={4} mb="lg">
Grafik APBDes
</Title>
<Stack gap="xs">
{apbdesData.map((data, index) => (
<Grid key={index} align="center">
<Grid.Col span={3}>
<Text size="sm" fw={500}>
{data.name}
</Text>
</Grid.Col>
<Grid.Col span={9}>
<Progress
value={data.value}
size="lg"
radius="xl"
color={data.color}
/>
</Grid.Col>
</Grid>
<ChartAPBDes />
{/* Section 6: SDGs Desa Cards */}
{sdgsLoading ? (
<Center py="xl">
<Loader />
</Center>
) : (
<Grid gutter="md">
{sdgsData.map((sdg) => (
<Grid.Col key={sdg.title} span={{ base: 9, md: 3 }}>
<SDGSCard
image={
sdg.image ? <Image src={sdg.image} alt={sdg.title} /> : null
}
title={sdg.title}
score={sdg.score}
/>
</Grid.Col>
))}
</Stack>
</Card>
</Grid>
)}
</Stack>
);
}

View File

@@ -0,0 +1,105 @@
import {
Box,
Card,
Group,
Loader,
Stack,
Text,
Title,
useMantineColorScheme,
} from "@mantine/core";
import dayjs from "dayjs";
import { Calendar } from "lucide-react";
import { useEffect, useState } from "react";
import { apiClient } from "@/utils/api-client";
interface EventData {
date: string;
title: string;
}
export function ActivityList() {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const [data, setData] = useState<EventData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchEvents() {
try {
const res = await apiClient.GET("/api/event/");
if (res.data?.data) {
setData(
(res.data.data as { startDate: string; title: string }[]).map(
(e) => ({
date: dayjs(e.startDate).format("D MMMM YYYY"),
title: e.title,
}),
),
);
}
} catch (error) {
console.error("Failed to fetch events", error);
} finally {
setLoading(false);
}
}
fetchEvents();
}, []);
return (
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: dark
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
h="100%"
>
<Group gap="xs" mb="lg">
<Calendar
style={{ width: 20, height: 20 }}
color={dark ? "#E2E8F0" : "#1E3A5F"}
/>
<Title order={4} c={dark ? "white" : "gray.9"}>
Kalender & Kegiatan Mendatang
</Title>
</Group>
<Stack gap="md">
{loading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : data.length > 0 ? (
data.map((event) => (
<Box
key={`${event.title}-${event.date}`}
style={{
borderLeft: "4px solid var(--mantine-color-blue-filled)",
paddingLeft: 12,
}}
>
<Text size="sm" c="dimmed">
{event.date}
</Text>
<Text fw={500} c={dark ? "white" : "gray.9"}>
{event.title}
</Text>
</Box>
))
) : (
<Text size="sm" c="dimmed" ta="center">
Tidak ada kegiatan mendatang
</Text>
)}
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,118 @@
import {
Card,
Group,
Loader,
Stack,
Text,
Title,
useMantineColorScheme,
} from "@mantine/core";
import { useEffect, useState } from "react";
import {
Bar,
BarChart,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { apiClient } from "@/utils/api-client";
interface ApbdesData {
name: string;
value: number;
color: string;
}
export function ChartAPBDes() {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const [data, setData] = useState<ApbdesData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchApbdes() {
try {
const res = await apiClient.GET("/api/dashboard/budget");
if (res.data?.data) {
setData(
res.data.data.map((d) => ({
name: d.category,
value: d.percentage,
color: d.color,
})),
);
}
} catch (error) {
console.error("Failed to fetch APBDes data", error);
} finally {
setLoading(false);
}
}
fetchApbdes();
}, []);
return (
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: dark
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
h="100%"
>
<Title order={4} c={dark ? "white" : "gray.9"} mb="lg">
Grafik APBDes
</Title>
<Stack gap="xs">
{loading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : data.length > 0 ? (
data.map((item) => (
<Group key={item.name} align="center" gap="md">
<Text size="sm" fw={500} w={100} c={dark ? "white" : "gray.7"}>
{item.name}
</Text>
<ResponsiveContainer width="100%" height={12} style={{ flex: 1 }}>
<BarChart
layout="vertical"
data={[item]}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
>
<XAxis type="number" hide domain={[0, 100]} />
<YAxis type="category" hide dataKey="name" />
<Bar dataKey="value" radius={[10, 10, 10, 10]} barSize={12}>
<Cell fill={item.color} />
</Bar>
</BarChart>
</ResponsiveContainer>
<Text
size="sm"
fw={600}
w={40}
ta="right"
c={dark ? "white" : "gray.9"}
>
{item.value}%
</Text>
</Group>
))
) : (
<Text size="sm" c="dimmed" ta="center">
Tidak ada data APBDes
</Text>
)}
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,179 @@
import {
ActionIcon,
Box,
Card,
Group,
Loader,
Text,
Title,
useMantineColorScheme,
} from "@mantine/core";
import { useEffect, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { apiClient } from "@/utils/api-client";
interface ChartData {
month: string;
value: number;
}
export function ChartSurat() {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const [data, setData] = useState<ChartData[]>([]);
const [loading, setLoading] = useState(true);
// DEBUG: Uncomment to test chart rendering with sample data
// useEffect(() => {
// setData([
// { month: "Oct", value: 1 },
// { month: "Nov", value: 1 },
// { month: "Dec", value: 1 },
// { month: "Feb", value: 1 },
// { month: "Mar", value: 1 },
// ]);
// setLoading(false);
// }, []);
useEffect(() => {
async function fetchTrends() {
try {
const res = await apiClient.GET("/api/complaint/service-trends");
console.log("📊 Service trends response:", res);
// Check if response has data
if (
res.data?.data &&
Array.isArray(res.data.data) &&
res.data.data.length > 0
) {
const chartData = (
res.data.data as { month: string; count: number }[]
).map((d) => ({
month: d.month,
value: Number(d.count),
}));
console.log("📈 Mapped chart data:", chartData);
console.log("✅ Chart data count:", chartData.length);
setData(chartData);
} else {
console.warn("⚠️ No data in response or empty array");
console.log("Response structure:", JSON.stringify(res, null, 2));
setData([]);
}
} catch (error) {
console.error("❌ Failed to fetch service trends", error);
console.log("Error details:", error);
} finally {
setLoading(false);
}
}
fetchTrends();
}, []);
return (
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: dark
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
h="100%"
>
<Group justify="space-between" mb="md">
<Box>
<Title order={4} c={dark ? "white" : "gray.9"} mb={5}>
Statistik Pengajuan Surat
</Title>
<Text size="sm" c="dimmed">
Trend pengajuan surat 6 bulan terakhir
</Text>
</Box>
<ActionIcon variant="subtle" size="lg" radius="md">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Tampilkan Detail"
>
<title>Tampilkan Detail</title>
<path
d="M8 5L13 10L8 15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</ActionIcon>
</Group>
<Box style={{ width: "100%", height: 300 }}>
{loading ? (
<Group justify="center" align="center" h="100%">
<Loader />
</Group>
) : data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke={dark ? "#334155" : "#e5e7eb"}
/>
<XAxis
dataKey="month"
axisLine={false}
tickLine={false}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: dark ? "#1E293B" : "white",
borderColor: dark ? "#334155" : "#e5e7eb",
borderRadius: "8px",
boxShadow: "0 4px 6px -1px rgb(0 0 0 / 0.1)",
}}
labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
/>
<Bar
dataKey="value"
fill={dark ? "#60A5FA" : "#3B82F6"}
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
) : (
<Group justify="center" align="center" h="100%">
<Text size="sm" c="dimmed">
Tidak ada data pengajuan surat 6 bulan terakhir
</Text>
</Group>
)}
</Box>
</Card>
);
}

View File

@@ -0,0 +1,110 @@
import {
Box,
Card,
Group,
Loader,
Progress,
Stack,
Text,
Title,
useMantineColorScheme,
} from "@mantine/core";
import { useEffect, useState } from "react";
import { apiClient } from "@/utils/api-client";
interface DivisionData {
name: string;
value: number;
}
interface DivisionApiResponse {
id: string;
name: string;
activityCount: number;
_count?: {
activities: number;
};
}
export function DivisionProgress() {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const [data, setData] = useState<DivisionData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchDivisions() {
try {
const res = await apiClient.GET("/api/division/");
if (res.data?.data) {
setData(
(res.data.data as DivisionApiResponse[]).map((d) => ({
name: d.name,
value: d.activityCount || 0,
})),
);
}
} catch (error) {
console.error("Failed to fetch division stats", error);
} finally {
setLoading(false);
}
}
fetchDivisions();
}, []);
const max_value = Math.max(...data.map((d) => d.value), 1);
return (
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: dark
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
h="100%"
>
<Title order={4} c={dark ? "white" : "gray.9"} mb="lg">
Divisi Teraktif
</Title>
<Stack gap="sm">
{loading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : data.length > 0 ? (
data.map((divisi) => (
<Box key={divisi.name}>
<Group justify="space-between" mb={5}>
<Text size="sm" fw={500} c={dark ? "white" : "gray.7"}>
{divisi.name}
</Text>
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}>
{divisi.value} Kegiatan
</Text>
</Group>
<Progress
value={(divisi.value / max_value) * 100}
size="sm"
radius="xl"
color="blue"
animated
/>
</Box>
))
) : (
<Text size="sm" c="dimmed" ta="center">
Tidak ada data divisi
</Text>
)}
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,7 @@
export { ActivityList } from "./activity-list";
export { ChartAPBDes } from "./chart-apbdes";
export { ChartSurat } from "./chart-surat";
export { DivisionProgress } from "./division-progress";
export { SatisfactionChart } from "./satisfaction-chart";
export { SDGSCard } from "./sdgs-card";
export { StatCard } from "./stat-card";

View File

@@ -0,0 +1,116 @@
import {
Box,
Card,
Group,
Loader,
Text,
Title,
useMantineColorScheme,
} from "@mantine/core";
import { useEffect, useState } from "react";
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
import { apiClient } from "@/utils/api-client";
interface SatisfactionData {
name: string;
value: number;
color: string;
}
export function SatisfactionChart() {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const [data, setData] = useState<SatisfactionData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchSatisfaction() {
try {
const res = await apiClient.GET("/api/dashboard/satisfaction");
if (res.data?.data) {
setData(
res.data.data.map((d) => ({
name: d.category,
value: d.value,
color: d.color,
})),
);
}
} catch (error) {
console.error("Failed to fetch satisfaction data", error);
} finally {
setLoading(false);
}
}
fetchSatisfaction();
}, []);
return (
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: dark
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
h="100%"
>
<Title order={4} c={dark ? "white" : "gray.9"} mb={5}>
Tingkat Kepuasan
</Title>
<Text size="sm" c="dimmed" mb="md">
Tingkat kepuasan layanan
</Text>
<ResponsiveContainer width="100%" height={300}>
{loading ? (
<Group justify="center" align="center" h="100%">
<Loader />
</Group>
) : (
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={80}
outerRadius={120}
paddingAngle={2}
dataKey="value"
>
{data.map((entry) => (
<Cell key={`cell-${entry.name}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: dark ? "#1E293B" : "white",
borderColor: dark ? "#334155" : "#e5e7eb",
borderRadius: "8px",
}}
/>
</PieChart>
)}
</ResponsiveContainer>
<Group justify="center" gap="md" mt="md">
{data.map((item) => (
<Group key={item.name} gap="xs">
<Box
w={12}
h={12}
style={{ backgroundColor: item.color, borderRadius: "50%" }}
/>
<Text size="sm" c={dark ? "white" : "gray.7"}>
{item.name}
</Text>
</Group>
))}
</Group>
</Card>
);
}

View File

@@ -0,0 +1,45 @@
import { Box, Card, Group, Text, useMantineColorScheme } from "@mantine/core";
import type { ReactNode } from "react";
interface SDGSCardProps {
title: string;
score: number;
image: ReactNode;
}
export function SDGSCard({ title, score, image }: SDGSCardProps) {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
return (
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
h="100%"
>
<Group justify="space-between" align="flex-start" w="100%">
<Box>{image}</Box>
<Box style={{ flex: 1 }}>
<Text
ta={"center"}
size="sm"
c={dark ? "white" : "gray.8"}
fw={500}
mb="xs"
>
{title}
</Text>
<Text ta={"center"} size="xl" c={dark ? "white" : "gray.8"} fw={700}>
{score.toFixed(2)}
</Text>
</Box>
</Group>
</Card>
);
}

View File

@@ -0,0 +1,87 @@
import {
Box,
Card,
Group,
Text,
ThemeIcon,
useMantineColorScheme,
} from "@mantine/core";
import type { ReactNode } from "react";
interface StatCardProps {
title: string;
value: string | number;
detail?: string;
trend?: string;
trendValue?: number;
icon: ReactNode;
iconColor?: string;
}
export function StatCard({
title,
value,
detail,
trend,
trendValue,
icon,
iconColor = "#1E3A5F",
}: StatCardProps) {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const isPositiveTrend = trendValue ? trendValue >= 0 : true;
return (
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: dark
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
h="100%"
>
<Group justify="space-between" align="flex-start" w="100%">
<Box style={{ flex: 1 }}>
<Text size="sm" c="dimmed" mb="xs">
{title}
</Text>
<Group align="baseline" gap="xs">
<Text size="xl" fw={700} c={dark ? "white" : "gray.9"}>
{value}
</Text>
</Group>
{detail && (
<Text size="sm" c="dimmed" mt="xs">
{detail}
</Text>
)}
{trend && (
<Text
size="sm"
c={isPositiveTrend ? "green" : "red"}
mt="xs"
fw={500}
>
{trend}
</Text>
)}
</Box>
<ThemeIcon
variant="filled"
size="xl"
radius="xl"
color={dark ? "gray" : iconColor}
bg={dark ? "gray" : iconColor}
>
{icon}
</ThemeIcon>
</Group>
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,219 @@
import { useCallback, useEffect, useRef, useState } from "react";
interface CodeInfo {
relativePath: string;
line: string;
column: string;
}
/**
* Extracts data-inspector-* from fiber props or DOM attributes.
* Handles React 19 fiber tree walk-up and DOM attribute fallbacks.
*/
function getCodeInfoFromElement(element: HTMLElement): CodeInfo | null {
// Strategy 1: React internal props __reactProps$ (most accurate in R19)
for (const key of Object.keys(element)) {
if (key.startsWith("__reactProps$")) {
// biome-ignore lint/suspicious/noExplicitAny: React internals
const props = (element as any)[key];
if (props?.["data-inspector-relative-path"]) {
return {
relativePath: props["data-inspector-relative-path"],
line: props["data-inspector-line"] || "1",
column: props["data-inspector-column"] || "1",
};
}
}
// Strategy 2: Walk fiber tree __reactFiber$
if (key.startsWith("__reactFiber$")) {
// biome-ignore lint/suspicious/noExplicitAny: React internals
let f = (element as any)[key];
while (f) {
const p = f.pendingProps || f.memoizedProps;
if (p?.["data-inspector-relative-path"]) {
return {
relativePath: p["data-inspector-relative-path"],
line: p["data-inspector-line"] || "1",
column: p["data-inspector-column"] || "1",
};
}
// Fallback: _debugSource (React < 19)
const src = f._debugSource ?? f._debugOwner?._debugSource;
if (src?.fileName && src?.lineNumber) {
return {
relativePath: src.fileName,
line: String(src.lineNumber),
column: String(src.columnNumber ?? 1),
};
}
f = f.return;
}
}
}
// Strategy 3: Universal DOM attribute fallback
const rp = element.getAttribute("data-inspector-relative-path");
if (rp) {
return {
relativePath: rp,
line: element.getAttribute("data-inspector-line") || "1",
column: element.getAttribute("data-inspector-column") || "1",
};
}
return null;
}
/** Walks up DOM tree until source info is found. */
function findCodeInfo(target: HTMLElement): CodeInfo | null {
let el: HTMLElement | null = target;
while (el) {
const info = getCodeInfoFromElement(el);
if (info) return info;
el = el.parentElement;
}
return null;
}
function openInEditor(info: CodeInfo) {
fetch("/__open-in-editor", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
relativePath: info.relativePath,
lineNumber: info.line,
columnNumber: info.column,
}),
});
}
export function DevInspector({ children }: { children: React.ReactNode }) {
const [active, setActive] = useState(false);
const overlayRef = useRef<HTMLDivElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const lastInfoRef = useRef<CodeInfo | null>(null);
const updateOverlay = useCallback((target: HTMLElement | null) => {
const ov = overlayRef.current;
const tt = tooltipRef.current;
if (!ov || !tt) return;
if (!target) {
ov.style.display = "none";
tt.style.display = "none";
lastInfoRef.current = null;
return;
}
const info = findCodeInfo(target);
if (!info) {
ov.style.display = "none";
tt.style.display = "none";
lastInfoRef.current = null;
return;
}
lastInfoRef.current = info;
const rect = target.getBoundingClientRect();
ov.style.display = "block";
ov.style.top = `${rect.top + window.scrollY}px`;
ov.style.left = `${rect.left + window.scrollX}px`;
ov.style.width = `${rect.width}px`;
ov.style.height = `${rect.height}px`;
tt.style.display = "block";
tt.textContent = `${info.relativePath}:${info.line}`;
const ttTop = rect.top + window.scrollY - 24;
tt.style.top = `${ttTop > 0 ? ttTop : rect.bottom + window.scrollY + 4}px`;
tt.style.left = `${rect.left + window.scrollX}px`;
}, []);
useEffect(() => {
if (!active) return;
const onMouseOver = (e: MouseEvent) =>
updateOverlay(e.target as HTMLElement);
const onClick = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const info = lastInfoRef.current ?? findCodeInfo(e.target as HTMLElement);
if (info) {
const loc = `${info.relativePath}:${info.line}:${info.column}`;
console.log("[DevInspector] Open:", loc);
openInEditor(info);
}
setActive(false);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setActive(false);
};
document.addEventListener("mouseover", onMouseOver, true);
document.addEventListener("click", onClick, true);
document.addEventListener("keydown", onKeyDown);
document.body.style.cursor = "crosshair";
return () => {
document.removeEventListener("mouseover", onMouseOver, true);
document.removeEventListener("click", onClick, true);
document.removeEventListener("keydown", onKeyDown);
document.body.style.cursor = "";
if (overlayRef.current) overlayRef.current.style.display = "none";
if (tooltipRef.current) tooltipRef.current.style.display = "none";
};
}, [active, updateOverlay]);
// Hotkey: Ctrl+Shift+Cmd+C (macOS) / Ctrl+Shift+Alt+C
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (
e.key.toLowerCase() === "c" &&
e.ctrlKey &&
e.shiftKey &&
(e.metaKey || e.altKey)
) {
e.preventDefault();
setActive((prev) => !prev);
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
return (
<>
{children}
<div
ref={overlayRef}
style={{
display: "none",
position: "absolute",
pointerEvents: "none",
border: "2px solid #3b82f6",
backgroundColor: "rgba(59,130,246,0.1)",
zIndex: 99999,
transition: "all 0.05s ease",
}}
/>
<div
ref={tooltipRef}
style={{
display: "none",
position: "absolute",
pointerEvents: "none",
backgroundColor: "#1e293b",
color: "#e2e8f0",
fontSize: "12px",
fontFamily: "monospace",
padding: "2px 6px",
borderRadius: "3px",
zIndex: 100000,
whiteSpace: "nowrap",
}}
/>
</>
);
}

View File

@@ -23,10 +23,10 @@ export function ImageWithFallback(
<div className="flex items-center justify-center w-full h-full">
<img
src={ERROR_IMG_SRC}
alt="Error loading image"
alt="Error loading content"
{...rest}
data-original-url={src}
/>
/>{" "}
</div>
</div>
) : (

View File

@@ -1,72 +1,175 @@
import {
ActionIcon,
Anchor,
Avatar,
Badge,
Box,
Breadcrumbs,
Divider,
Group,
Text,
useMantineColorScheme,
} from "@mantine/core";
import { useLocation } from "@tanstack/react-router";
import { Bell, Moon, Sun } from "lucide-react";
import {
IconLayoutSidebarLeftCollapse,
IconUserShield,
} from "@tabler/icons-react";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { Bell, Moon, Sun, User as UserIcon } from "lucide-react";
export function Header() {
interface HeaderProps {
onSidebarToggle?: () => void;
}
export function Header({ onSidebarToggle }: HeaderProps) {
const location = useLocation();
const navigate = useNavigate();
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const title =
location.pathname === "/"
? "Desa Darmasaba"
: "Desa Darmasaba";
const pathnames = location.pathname.split("/").filter((x) => x);
const breadcrumbItems = [
<Anchor
key="home"
onClick={() => navigate({ to: "/" })}
c="white"
size="sm"
underline="hover"
>
Desa Darmasaba
</Anchor>,
...pathnames.map((value, index) => {
const to = `/${pathnames.slice(0, index + 1).join("/")}`;
const isLast = index === pathnames.length - 1;
// Map route path to human-readable label
const labelMap: Record<string, string> = {
"kinerja-divisi": "Kinerja Divisi",
"pengaduan-layanan-publik": "Pengaduan & Layanan Publik",
"jenna-analytic": "Jenna Analytic",
"demografi-pekerjaan": "Demografi & Kependudukan",
"keuangan-anggaran": "Keuangan & Anggaran",
bumdes: "Bumdes & UMKM",
sosial: "Sosial",
keamanan: "Keamanan",
bantuan: "Bantuan",
pengaturan: "Pengaturan",
umum: "Umum",
notifikasi: "Notifikasi",
"akses-dan-tim": "Akses & Tim",
profile: "Profil",
edit: "Edit",
};
const label =
labelMap[value] || value.charAt(0).toUpperCase() + value.slice(1);
return isLast ? (
<Text key={to} c="white" size="sm" fw={600}>
{label}
</Text>
) : (
<Anchor
key={to}
onClick={() => navigate({ to })}
c="white"
size="sm"
underline="hover"
>
{label}
</Anchor>
);
}),
];
return (
<Box
style={{
display: "grid",
gridTemplateColumns: "1fr auto 1fr",
alignItems: "center",
width: "100%",
}}
>
{/* LEFT SPACER (burger sudah di luar) */}
<Box />
{/* CENTER TITLE */}
<Text
c="white"
fw={600}
size="md"
style={{
textAlign: "center",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{title}
</Text>
{/* RIGHT ICONS */}
<Group gap="xs" justify="flex-end">
<Group justify="space-between" w="100%">
{/* Title & Breadcrumbs */}
<Group gap="md">
<ActionIcon
onClick={toggleColorScheme}
onClick={onSidebarToggle}
variant="subtle"
size="lg"
radius="xl"
visibleFrom="sm"
aria-label="Toggle sidebar"
>
{dark ? <Sun size={18} /> : <Moon size={18} />}
</ActionIcon>
<ActionIcon variant="subtle" radius="xl" pos="relative">
<Bell size={18} />
<Badge
size="xs"
color="red"
style={{ position: "absolute", top: -4, right: -4 }}
>
10
</Badge>
<IconLayoutSidebarLeftCollapse
color="white"
style={{ width: "70%", height: "70%" }}
/>
</ActionIcon>
<Breadcrumbs
separator={
<Text c="white" size="xs">
/
</Text>
}
styles={{
separator: { color: "white" },
}}
>
{breadcrumbItems}
</Breadcrumbs>
</Group>
</Box>
{/* Right Section */}
<Group gap="md">
{/* User Info */}
<Group gap="sm">
<Box ta="right">
<Text c={"white"} size="sm" fw={500}>
I. B. Surya Prabhawa M...
</Text>
<Text c={"white"} size="xs">
Kepala Desa
</Text>
</Box>
<Avatar color="blue" radius="xl">
<UserIcon color="white" style={{ width: "70%", height: "70%" }} />
</Avatar>
</Group>
{/* Divider */}
<Divider orientation="vertical" h={30} />
{/* Icons */}
<Group gap="sm">
<ActionIcon
onClick={() => toggleColorScheme()}
variant="subtle"
size="lg"
radius="xl"
aria-label="Toggle color scheme"
>
{dark ? (
<Sun color="white" style={{ width: "70%", height: "70%" }} />
) : (
<Moon color="white" style={{ width: "70%", height: "70%" }} />
)}
</ActionIcon>
<ActionIcon variant="subtle" size="lg" radius="xl" pos="relative">
<Bell color="white" style={{ width: "70%", height: "70%" }} />
<Badge
size="xs"
color="red"
variant="filled"
style={{ position: "absolute", top: 0, right: 0 }}
radius={"xl"}
>
10
</Badge>
</ActionIcon>
<ActionIcon variant="subtle" size="lg" radius="xl">
<IconUserShield
color="white"
style={{ width: "70%", height: "70%" }}
onClick={() => navigate({ to: "/admin" })}
/>
</ActionIcon>
</Group>
</Group>
</Group>
);
}

View File

@@ -143,16 +143,25 @@ const HelpPage = () => {
return (
<Container size="lg" py="xl">
<Title order={1} mb="xl" ta="center">
Pusat Bantuan
</Title>
<Text size="lg" color="dimmed" ta="center" mb="xl">
Temukan jawaban untuk pertanyaan Anda atau hubungi tim support kami
</Text>
{/* Statistics Section */}
<SimpleGrid cols={3} spacing="lg" mb="xl">
{stats.map((stat, index) => (
{stats.map((stat) => (
<HelpCard
key={index}
bg={dark ? "#141D34" : "white"}
key={stat.label}
bg={dark ? "#1E293B" : "white"}
p="lg"
style={{
textAlign: "center",
borderColor: dark ? "#141D34" : "white",
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
h="100%"
>
@@ -172,16 +181,20 @@ const HelpPage = () => {
{/* Panduan Memulai */}
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
<HelpCard
style={{ borderColor: dark ? "#141D34" : "white" }}
bg={dark ? "#141D34" : "white"}
icon={<IconBook size={24} />}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
bg={dark ? "#1E293B" : "white"}
icon={<IconBook size={24} color="white" />}
title="Panduan Memulai"
h="100%"
>
<Box>
{guideItems.map((item, index) => (
{guideItems.map((item) => (
<Box
key={index}
key={item.title}
py="sm"
style={{
borderBottom: "1px solid #eee",
@@ -202,16 +215,20 @@ const HelpPage = () => {
{/* Video Tutorial */}
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
<HelpCard
style={{ borderColor: dark ? "#141D34" : "white" }}
bg={dark ? "#141D34" : "white"}
icon={<IconVideo size={24} />}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
bg={dark ? "#1E293B" : "white"}
icon={<IconVideo size={24} color="white" />}
title="Video Tutorial"
h="100%"
>
<Box>
{videoItems.map((item, index) => (
{videoItems.map((item) => (
<Box
key={index}
key={item.title}
py="sm"
style={{
borderBottom: "1px solid #eee",
@@ -232,20 +249,24 @@ const HelpPage = () => {
{/* FAQ */}
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
<HelpCard
style={{ borderColor: dark ? "#141D34" : "white" }}
bg={dark ? "#141D34" : "white"}
icon={<IconHelpCircle size={24} />}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
bg={dark ? "#1E293B" : "white"}
icon={<IconHelpCircle size={24} color="white" />}
title="FAQ"
h="100%"
>
<Accordion variant="separated">
{faqItems.map((item, index) => (
{faqItems.map((item) => (
<Accordion.Item
style={{
backgroundColor: dark ? "#263852ff" : "#F1F5F9",
}}
key={index}
value={`faq-${index}`}
key={item.question}
value={item.question}
>
<Accordion.Control>{item.question}</Accordion.Control>
<Accordion.Panel>
@@ -264,9 +285,13 @@ const HelpPage = () => {
{/* Hubungi Support */}
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
<HelpCard
style={{ borderColor: dark ? "#141D34" : "white" }}
bg={dark ? "#141D34" : "white"}
icon={<IconHeadphones size={24} />}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
bg={dark ? "#1E293B" : "white"}
icon={<IconHeadphones size={24} color="white" />}
title="Hubungi Support"
h="100%"
>
@@ -299,16 +324,20 @@ const HelpPage = () => {
{/* Dokumentasi */}
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
<HelpCard
style={{ borderColor: dark ? "#141D34" : "white" }}
bg={dark ? "#141D34" : "white"}
icon={<IconFileText size={24} />}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
bg={dark ? "#1E293B" : "white"}
icon={<IconFileText size={24} color="white" />}
title="Dokumentasi"
h="100%"
>
<Box>
{documentationItems.map((item, index) => (
{documentationItems.map((item) => (
<Box
key={index}
key={item.title}
py="sm"
style={{
borderBottom: "1px solid #eee",
@@ -331,9 +360,13 @@ const HelpPage = () => {
{/* Jenna - Virtual Assistant */}
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
<HelpCard
style={{ borderColor: dark ? "#141D34" : "white" }}
bg={dark ? "#141D34" : "white"}
icon={<IconMessage size={24} />}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
bg={dark ? "#1E293B" : "white"}
icon={<IconMessage size={24} color="white" />}
title="Jenna - Virtual Assistant"
h="100%"
>
@@ -401,6 +434,7 @@ const HelpPage = () => {
disabled={isLoading}
/>
<button
type="button"
onClick={handleSendMessage}
disabled={isLoading || inputValue.trim() === ""}
style={{

View File

@@ -1,123 +1,78 @@
import { BarChart } from "@mantine/charts";
import {
Badge,
Box,
Button,
Card,
Grid,
Group,
Progress,
Stack,
Text,
ThemeIcon,
Title,
useMantineColorScheme,
} from "@mantine/core";
import React from "react";
import {
AlertTriangle,
CheckCircle,
Clock,
MessageCircle,
TrendingUp,
} from "lucide-react";
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
// Sample Data
// KPI Data
const kpiData = [
{
id: 1,
title: "Interaksi Hari Ini",
value: "61",
delta: "+15% dari kemarin",
deltaType: "positive",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6 text-muted-foreground"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H16.5m-13.5 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z"
/>
</svg>
),
subtitle: "+15% dari kemarin",
trend: "positive",
icon: MessageCircle,
},
{
id: 2,
title: "Jawaban Otomatis",
value: "87%",
sub: "53 dari 61 interaksi",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6 text-muted-foreground"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.473-1.688 3.342-.48.485-.926.97-1.378 1.44c-1.472 1.58-2.306 2.787-2.91 3.514-.15.18-.207.33-.207.33A.75.75 0 0 1 15 21h-3c-1.104 0-2.08-.542-2.657-1.455-.139-.201-.264-.406-.38-.614l-.014-.025C8.85 18.067 8.156 17.2 7.5 16.325.728 12.56.728 7.44 7.5 3.675c3.04-.482 5.584.47 7.042 1.956.674.672 1.228 1.462 1.696 2.307.426.786.793 1.582 1.113 2.392h.001Z"
/>
</svg>
),
subtitle: "53 dari 61 interaksi",
icon: CheckCircle,
},
{
id: 3,
title: "Belum Ditindak",
value: "8",
sub: "Perlu respon manual",
deltaType: "negative",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6 text-muted-foreground"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
/>
</svg>
),
subtitle: "Perlu respon manual",
icon: AlertTriangle,
},
{
id: 4,
title: "Waktu Respon",
value: "2.3 sec",
sub: "Rata-rata",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6 text-muted-foreground"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
),
subtitle: "Rata-rata",
icon: Clock,
},
];
// Chart Data
const chartData = [
{ day: "Sen", total: 100 },
{ day: "Sel", total: 120 },
{ day: "Rab", total: 90 },
{ day: "Kam", total: 150 },
{ day: "Jum", total: 110 },
{ day: "Sab", total: 80 },
{ day: "Min", total: 130 },
{ day: "Sen", total: 45 },
{ day: "Sel", total: 62 },
{ day: "Rab", total: 38 },
{ day: "Kam", total: 75 },
{ day: "Jum", total: 58 },
{ day: "Sab", total: 32 },
{ day: "Min", total: 51 },
];
// Top Topics Data
const topTopics = [
{ topic: "Cara mengurus KTP", count: 89 },
{ topic: "Syarat Kartu Keluarga", count: 76 },
@@ -126,6 +81,7 @@ const topTopics = [
{ topic: "Info program bansos", count: 48 },
];
// Busy Hours Data
const busyHours = [
{ period: "Pagi (0812)", percentage: 30 },
{ period: "Siang (1216)", percentage: 40 },
@@ -138,146 +94,206 @@ const JennaAnalytic = () => {
const dark = colorScheme === "dark";
return (
<Box className="space-y-6">
<Stack gap="xl">
{/* KPI Cards */}
<Grid gutter="lg">
{kpiData.map((kpi) => (
<Grid.Col key={kpi.id} span={{ base: 12, md: 6, lg: 3 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Group justify="space-between" align="flex-start" mb="xs">
<Text size="sm" fw={500} c="dimmed">
{kpi.title}
</Text>
{React.cloneElement(kpi.icon, {
className: "h-6 w-6", // Keeping classes for now, can be replaced by Mantine Icon component if available or styled with sx prop
color: "var(--mantine-color-dimmed)", // Set color via prop
})}
</Group>
<Title order={3} fw={700} mt="xs">
{kpi.value}
</Title>
{kpi.delta && (
<Text
size="xs"
c={
kpi.deltaType === "positive"
? "green"
: kpi.deltaType === "negative"
? "red"
: "dimmed"
}
mt={4}
>
{kpi.delta}
</Text>
)}
{kpi.sub && (
<Text size="xs" c="dimmed" mt={2}>
{kpi.sub}
</Text>
)}
</Card>
</Grid.Col>
))}
</Grid>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Title order={3} fw={500} mb="md">
Interaksi Chatbot
</Title>
<BarChart
h={300}
data={chartData}
dataKey="day"
series={[{ name: "total", color: "blue" }]}
withLegend
/>
</Card>
{/* Charts and Lists Section */}
<Grid gutter="lg">
{/* Grafik Interaksi Chatbot (now Bar Chart) */}
<Grid.Col span={{ base: 12, lg: 6 }}>
<Stack gap="lg">
{/* TOP SECTION - 4 STAT CARDS */}
<Grid gutter="md">
{kpiData.map((item) => (
<Grid.Col key={item.id} span={{ base: 12, sm: 6, lg: 3 }}>
<Card
p="md"
radius="md"
radius="xl"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
h="100%"
>
<Title order={3} fw={500} mb="md">
Jam Tersibuk
</Title>
<Stack gap="sm">
{busyHours.map((item, index) => (
<Box key={index}>
<Text size="sm">{item.period}</Text>
<Group align="center">
<Progress value={item.percentage} flex={1} />
<Text size="sm" fw={500}>
{item.percentage}%
</Text>
</Group>
</Box>
))}
</Stack>
<Group justify="space-between" align="flex-start" w="100%">
<Stack gap={2}>
<Text size="sm" c="dimmed">
{item.title}
</Text>
<Text size="xl" fw={700} c={dark ? "white" : "gray.9"}>
{item.value}
</Text>
<Group gap={4} align="flex-start">
{item.trend === "positive" && (
<TrendingUp size={14} color="#22C55E" />
)}
<Text
size="xs"
c={
item.trend === "positive"
? "green"
: dark
? "gray.4"
: "gray.5"
}
>
{item.subtitle}
</Text>
</Group>
</Stack>
<ThemeIcon
color="#1E3A5F"
variant="filled"
size="lg"
radius="xl"
>
<item.icon style={{ width: "60%", height: "60%" }} />
</ThemeIcon>
</Group>
</Card>
</Grid.Col>
))}
</Grid>
{/* Topik Pertanyaan Terbanyak & Jam Tersibuk */}
<Grid.Col span={{ base: 12, lg: 6 }}>
<Stack gap="lg">
{/* Topik Pertanyaan Terbanyak */}
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
h="100%"
>
<Title order={3} fw={500} mb="md">
Topik Pertanyaan Terbanyak
</Title>
<Stack gap="xs">
{topTopics.map((item, index) => (
<Group
key={index}
justify="space-between"
align="center"
p="xs"
{/* MAIN CHART - INTERAKSI CHATBOT */}
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
>
<Group justify="space-between" mb="md">
<Title order={4} c={dark ? "white" : "gray.9"}>
Interaksi Chatbot
</Title>
</Group>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke={dark ? "#334155" : "#e5e7eb"}
/>
<XAxis
dataKey="day"
axisLine={false}
tickLine={false}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
/>
<Tooltip
contentStyle={{
backgroundColor: dark ? "#1E293B" : "white",
borderColor: dark ? "#334155" : "#e5e7eb",
borderRadius: "8px",
}}
labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
cursor={{ fill: dark ? "#334155" : "#f3f4f6" }}
/>
<Bar
dataKey="total"
fill="#396aaaff"
radius={[8, 8, 0, 0]}
maxBarSize={60}
/>
</BarChart>
</ResponsiveContainer>
</Card>
{/* BOTTOM SECTION - 2 COLUMNS */}
<Grid gutter="lg">
{/* LEFT: TOPIK PERTANYAAN TERBANYAK */}
<Grid.Col span={{ base: 12, lg: 6 }}>
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
h="100%"
>
<Title order={4} c={dark ? "white" : "gray.9"} mb="md">
Topik Pertanyaan Terbanyak
</Title>
<Stack gap="xs">
{topTopics.map((item) => (
<Box
key={item.topic}
p="sm"
bg={dark ? "#334155" : "#F1F5F9"}
style={{
transition: "background-color 0.15s ease",
cursor: "pointer",
}}
>
<Group justify="space-between">
<Text size="sm" fw={500} c={dark ? "white" : "gray.9"}>
{item.topic}
</Text>
<Badge
variant="light"
color="darmasaba-blue"
radius="sm"
fw={600}
>
<Text size="sm" fw={500}>
{item.topic}
</Text>
<Badge variant="light" color="gray">
{item.count}x
</Badge>
</Group>
))}
</Stack>
</Card>
{/* Jam Tersibuk */}
{item.count}x
</Badge>
</Group>
</Box>
))}
</Stack>
</Grid.Col>
</Grid>
</Stack>
</Box>
</Card>
</Grid.Col>
{/* RIGHT: JAM TERSIBUK */}
<Grid.Col span={{ base: 12, lg: 6 }}>
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
h="100%"
>
<Title order={4} c={dark ? "white" : "gray.9"} mb="md">
Jam Tersibuk
</Title>
<Stack gap="md">
{busyHours.map((item) => (
<Box key={item.period}>
<Group justify="space-between" mb={5}>
<Text size="sm" fw={500} c={dark ? "white" : "gray.9"}>
{item.period}
</Text>
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}>
{item.percentage}%
</Text>
</Group>
<Progress
value={item.percentage}
size="lg"
radius="xl"
color="#1E3A5F"
animated
/>
</Box>
))}
</Stack>
</Card>
</Grid.Col>
</Grid>
</Stack>
);
};
export default JennaAnalytic;

View File

@@ -5,7 +5,6 @@ import {
Grid,
GridCol,
Group,
List,
Stack,
Text,
ThemeIcon,
@@ -16,11 +15,8 @@ import {
IconAlertTriangle,
IconCamera,
IconClock,
IconEye,
IconMapPin,
IconShieldLock,
} from "@tabler/icons-react";
import { useState } from "react";
const KeamananPage = () => {
const { colorScheme } = useMantineColorScheme();
@@ -118,138 +114,144 @@ const KeamananPage = () => {
return (
<Stack gap="lg">
{/* Page Header */}
<Group justify="space-between" align="center">
<Title order={2} c={dark ? "dark.0" : "black"}>
Keamanan Lingkungan Desa
</Title>
</Group>
{/* KPI Cards */}
<Grid gutter="md">
{kpiData.map((kpi, index) => (
<GridCol key={index} span={{ base: 12, sm: 6, md: 6 }}>
{/* Peta Keamanan CCTV */}
<GridCol span={{ base: 12, lg: 6 }}>
<Stack gap={"xs"}>
{/* KPI Cards */}
<Grid gutter="md">
{kpiData.map((kpi) => (
<GridCol key={kpi.title} span={{ base: 12, sm: 6, md: 6 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
h="100%"
>
<Group justify="space-between" align="center">
<Stack gap={0}>
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
{kpi.subtitle}
</Text>
<Group gap="xs" align="center">
<Text
size="xl"
fw={700}
c={dark ? "dark.0" : "black"}
>
{kpi.value}
</Text>
<Text size="sm" c={dark ? "white" : "dimmed"}>
{kpi.title}
</Text>
</Group>
</Stack>
<ThemeIcon
variant="light"
color={kpi.color}
size="xl"
radius="xl"
>
{kpi.icon}
</ThemeIcon>
</Group>
</Card>
</GridCol>
))}
</Grid>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
h="100%"
>
<Group justify="space-between" align="center">
<Stack gap={0}>
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
{kpi.subtitle}
</Text>
<Group gap="xs" align="center">
<Text size="xl" fw={700} c={dark ? "dark.0" : "black"}>
{kpi.value}
</Text>
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
{kpi.title}
</Text>
</Group>
</Stack>
<ThemeIcon
variant="light"
color={kpi.color}
size="xl"
radius="xl"
>
{kpi.icon}
</ThemeIcon>
</Group>
</Card>
</GridCol>
))}
</Grid>
<Grid gutter="md">
{/* Peta Keamanan CCTV */}
<GridCol span={{ base: 12, lg: 6 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
h="100%"
>
<Title order={3} mb="md" c={dark ? "dark.0" : "black"}>
Peta Keamanan CCTV
</Title>
<Text size="sm" c={dark ? "dark.3" : "dimmed"} mb="md">
Titik Lokasi CCTV
</Text>
{/* Placeholder for map */}
<Box
style={{
backgroundColor: dark ? "#2d3748" : "#e2e8f0",
borderRadius: "8px",
height: "400px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Stack align="center">
<IconMapPin
size={48}
stroke={1.5}
color={dark ? "#94a3b8" : "#64748b"}
/>
<Text c={dark ? "dark.3" : "dimmed"}>Peta Lokasi CCTV</Text>
<Text size="sm" c={dark ? "dark.3" : "dimmed"} ta="center">
Integrasi dengan Google Maps atau Mapbox akan ditampilkan di
sini
</Text>
</Stack>
</Box>
{/* CCTV Locations List */}
<Stack mt="md" gap="sm">
<Title order={4} c={dark ? "dark.0" : "black"}>
Daftar CCTV
<Title order={3} mb="md" c={dark ? "dark.0" : "black"}>
Peta Keamanan CCTV
</Title>
{cctvLocations.map((cctv, index) => (
<Card
key={index}
p="md"
radius="md"
withBorder
bg={dark ? "#263852ff" : "#F1F5F9"}
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
>
<Group justify="space-between">
<Stack gap={0}>
<Group gap="xs">
<Text fw={500} c={dark ? "dark.0" : "black"}>
{cctv.id}
<Text size="sm" c={dark ? "white" : "dimmed"} mb="md">
Titik Lokasi CCTV
</Text>
{/* Placeholder for map */}
<Box
style={{
backgroundColor: dark ? "#2d3748" : "#e2e8f0",
borderRadius: "8px",
height: "400px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Stack align="center">
<IconMapPin
size={48}
stroke={1.5}
color={dark ? "#94a3b8" : "#64748b"}
/>
<Text c={dark ? "dark.3" : "dimmed"}>Peta Lokasi CCTV</Text>
<Text size="sm" c={dark ? "dark.3" : "dimmed"} ta="center">
Integrasi dengan Google Maps atau Mapbox akan ditampilkan di
sini
</Text>
</Stack>
</Box>
{/* CCTV Locations List */}
<Stack mt="md" gap="sm">
<Title order={4} c={dark ? "dark.0" : "black"}>
Daftar CCTV
</Title>
{cctvLocations.map((cctv) => (
<Card
key={cctv.id}
p="md"
radius="md"
withBorder
bg={dark ? "#263852ff" : "#F1F5F9"}
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
>
<Group justify="space-between">
<Stack gap={0}>
<Group gap="xs">
<Text fw={500} c={dark ? "dark.0" : "black"}>
{cctv.id}
</Text>
<Badge
variant="dot"
color={cctv.status === "active" ? "green" : "gray"}
>
{cctv.status === "active" ? "Online" : "Offline"}
</Badge>
</Group>
<Text size="sm" c={dark ? "white" : "dimmed"}>
{cctv.location}
</Text>
</Stack>
<Group gap="xs">
<IconClock size={16} stroke={1.5} />
<Text size="sm" c={dark ? "white" : "dimmed"}>
{cctv.lastSeen}
</Text>
<Badge
variant="dot"
color={cctv.status === "active" ? "green" : "gray"}
>
{cctv.status === "active" ? "Online" : "Offline"}
</Badge>
</Group>
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
{cctv.location}
</Text>
</Stack>
<Group gap="xs">
<IconClock size={16} stroke={1.5} />
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
{cctv.lastSeen}
</Text>
</Group>
</Group>
</Card>
))}
</Stack>
</Card>
</Card>
))}
</Stack>
</Card>
</Stack>
</GridCol>
{/* Daftar Laporan Keamanan */}
@@ -258,18 +260,18 @@ const KeamananPage = () => {
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
h="100%"
>
<Title order={3} mb="md" c={dark ? "dark.0" : "black"}>
Laporan Keamanan Lingkungan
</Title>
<Stack gap="sm">
{securityReports.map((report, index) => (
{securityReports.map((report) => (
<Card
key={index}
key={report.id}
p="md"
radius="md"
withBorder
@@ -297,19 +299,19 @@ const KeamananPage = () => {
<Group justify="space-between">
<Group gap="xs">
<IconMapPin size={16} stroke={1.5} />
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
<Text size="sm" c={dark ? "white" : "dimmed"}>
{report.location}
</Text>
</Group>
<Group gap="xs">
<IconClock size={16} stroke={1.5} />
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
<Text size="sm" c={dark ? "white" : "dimmed"}>
{report.reportedAt}
</Text>
</Group>
</Group>
<Text size="sm" c={dark ? "dark.3" : "dimmed"} mt="sm">
<Text size="sm" c={dark ? "white" : "dimmed"} mt="sm">
{report.date}
</Text>
</Card>

View File

@@ -1,73 +1,69 @@
import { BarChart } from "@mantine/charts";
import {
Badge,
Box,
Button,
Card,
Grid,
Group,
Progress,
Stack,
Text,
ThemeIcon,
Title,
useMantineColorScheme,
} from "@mantine/core";
import {
IconCurrency,
IconTrendingDown,
IconTrendingUp,
} from "@tabler/icons-react";
import React from "react";
CheckCircle,
Coins,
PieChart as PieChartIcon,
Receipt,
TrendingDown,
TrendingUp,
} from "lucide-react";
import {
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
// Sample Data
// KPI Data
const kpiData = [
{
id: 1,
title: "Total APBDes",
value: "Rp 5.2M",
sub: "Tahun 2025",
icon: <IconCurrency className="h-6 w-6 text-muted-foreground" />,
subtitle: "Tahun 2025",
icon: Coins,
},
{
id: 2,
title: "Realisasi",
value: "68%",
sub: "Rp 3.5M dari 5.2M",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6 text-muted-foreground"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.473-1.688 3.342-.48.485-.926.97-1.378 1.44c-1.472 1.58-2.306 2.787-2.91 3.514-.15.18-.207.33-.207.33A.75.75 0 0 1 15 21h-3c-1.104 0-2.08-.542-2.657-1.455-.139-.201-.264-.406-.38-.614l-.014-.025C8.85 18.067 8.156 17.2 7.5 16.325.728 12.56.728 7.44 7.5 3.675c3.04-.482 5.584.47 7.042 1.956.674.672 1.228 1.462 1.696 2.307.426.786.793 1.582 1.113 2.392h.001Z"
/>
</svg>
),
subtitle: "Rp 3.5M dari 5.2M",
icon: CheckCircle,
},
{
id: 3,
title: "Pemasukan",
value: "Rp 580jt",
sub: "Bulan ini",
delta: "+8%",
deltaType: "positive",
icon: <IconTrendingUp className="h-6 w-6 text-muted-foreground" />,
subtitle: "Bulan ini",
trend: "+8%",
icon: TrendingUp,
},
{
id: 4,
title: "Pengeluaran",
value: "Rp 520jt",
sub: "Bulan ini",
icon: <IconTrendingDown className="h-6 w-6 text-muted-foreground" />,
subtitle: "Bulan ini",
icon: TrendingDown,
},
];
// Income & Expense Data
const incomeExpenseData = [
{ month: "Apr", income: 450, expense: 380 },
{ month: "Mei", income: 520, expense: 420 },
@@ -78,6 +74,7 @@ const incomeExpenseData = [
{ month: "Okt", income: 580, expense: 520 },
];
// Sector Allocation Data
const allocationData = [
{ sector: "Pembangunan", amount: 1200 },
{ sector: "Kesehatan", amount: 800 },
@@ -87,13 +84,7 @@ const allocationData = [
{ sector: "Teknologi", amount: 300 },
];
const assistanceFundData = [
{ source: "Dana Desa (DD)", amount: 1800, status: "cair" },
{ source: "Alokasi Dana Desa (ADD)", amount: 950, status: "cair" },
{ source: "Bagi Hasil Pajak", amount: 450, status: "cair" },
{ source: "Hibah Provinsi", amount: 300, status: "proses" },
];
// APBDes Report Data
const apbdReport = {
income: [
{ category: "Dana Desa", amount: 1800 },
@@ -113,244 +104,410 @@ const apbdReport = {
totalExpenses: 2155,
};
// Aid & Grants Data
const assistanceFundData = [
{ source: "Dana Desa (DD)", amount: 1800, status: "cair" },
{ source: "Alokasi Dana Desa (ADD)", amount: 950, status: "cair" },
{ source: "Bagi Hasil Pajak", amount: 450, status: "cair" },
{ source: "Hibah Provinsi", amount: 300, status: "proses" },
];
const KeuanganAnggaran = () => {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
return (
<Box>
<Stack gap="xl">
{/* KPI Cards */}
<Grid gutter="lg">
{kpiData.map((kpi) => (
<Grid.Col key={kpi.id} span={{ base: 12, md: 6, lg: 3 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
h="100%"
>
<Group justify="space-between" align="flex-start" mb="xs">
<Text size="sm" fw={500} c="dimmed">
{kpi.title}
</Text>
{React.cloneElement(kpi.icon, {
className: "h-6 w-6",
color: "var(--mantine-color-dimmed)",
})}
</Group>
<Title order={3} fw={700} mt="xs">
{kpi.value}
</Title>
{kpi.delta && (
<Text
size="xs"
c={
kpi.deltaType === "positive"
? "green"
: kpi.deltaType === "negative"
? "red"
: "dimmed"
}
mt={4}
>
{kpi.delta}
</Text>
)}
{kpi.sub && (
<Text size="xs" c="dimmed" mt="auto">
{kpi.sub}
</Text>
)}
</Card>
</Grid.Col>
))}
</Grid>
{/* Charts Section */}
<Grid gutter="lg">
{/* Grafik Pemasukan vs Pengeluaran */}
<Grid.Col span={{ base: 12, lg: 6 }}>
return (
<Stack gap="lg">
{/* TOP SECTION - 4 STAT CARDS */}
<Grid gutter="md">
{kpiData.map((item) => (
<Grid.Col key={item.id} span={{ base: 12, sm: 6, lg: 3 }}>
<Card
p="md"
radius="md"
radius="xl"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
h="100%"
>
<Title order={3} fw={500} mb="md">
Pemasukan vs Pengeluaran
</Title>
<BarChart
h={300}
data={incomeExpenseData}
dataKey="month"
series={[
{ name: "income", color: "green", label: "Pemasukan" },
{ name: "expense", color: "red", label: "Pengeluaran" },
]}
withLegend
/>
<Group justify="space-between" align="flex-start" w="100%">
<Stack gap={2}>
<Text size="sm" c="dimmed">
{item.title}
</Text>
<Text size="xl" fw={700} c={dark ? "white" : "gray.9"}>
{item.value}
</Text>
<Group gap={4} align="flex-start">
{item.trend && <TrendingUp size={14} color="#22C55E" />}
<Text
size="xs"
c={item.trend ? "green" : dark ? "gray.4" : "gray.5"}
>
{item.subtitle}
</Text>
</Group>
</Stack>
<ThemeIcon
color="#1E3A5F"
variant="filled"
size="lg"
radius="xl"
>
<item.icon style={{ width: "60%", height: "60%" }} />
</ThemeIcon>
</Group>
</Card>
</Grid.Col>
))}
</Grid>
{/* Alokasi Anggaran Per Sektor */}
<Grid.Col span={{ base: 12, lg: 6 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Title order={3} fw={500} mb="md">
{/* MAIN CHART SECTION */}
<Grid gutter="lg">
{/* LEFT: PEMASUKAN DAN PENGELUARAN (70%) */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
h="100%"
>
<Group gap="xs" mb="md">
<ThemeIcon color="#1E3A5F" variant="filled" size="sm" radius="sm">
<PieChartIcon size={14} />
</ThemeIcon>
<Title order={4} c={dark ? "white" : "gray.9"}>
Pemasukan dan Pengeluaran
</Title>
</Group>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={incomeExpenseData}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke={dark ? "#334155" : "#e5e7eb"}
/>
<XAxis
dataKey="month"
axisLine={false}
tickLine={false}
tick={{
fill: dark ? "#E2E8F0" : "#374151",
fontSize: 12,
}}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{
fill: dark ? "#E2E8F0" : "#374151",
fontSize: 12,
}}
tickFormatter={(value) => `Rp ${value}jt`}
/>
<Tooltip
contentStyle={{
backgroundColor: dark ? "#1E293B" : "white",
borderColor: dark ? "#334155" : "#e5e7eb",
borderRadius: "8px",
}}
labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
formatter={(value: number | undefined) => [
`Rp ${value}jt`,
"",
]}
/>
<Line
type="monotone"
dataKey="income"
stroke="#22C55E"
strokeWidth={2}
dot={{ fill: "#22C55E", strokeWidth: 2, r: 4 }}
activeDot={{ r: 6 }}
name="Pemasukan"
/>
<Line
type="monotone"
dataKey="expense"
stroke="#EF4444"
strokeWidth={2}
dot={{ fill: "#EF4444", strokeWidth: 2, r: 4 }}
activeDot={{ r: 6 }}
name="Pengeluaran"
/>
</LineChart>
</ResponsiveContainer>
</Card>
</Grid.Col>
{/* RIGHT: ALOKASI ANGGARAN PER SEKTOR (30%) */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
h="100%"
>
<Group gap="xs" mb="md">
<ThemeIcon color="#1E3A5F" variant="filled" size="sm" radius="sm">
<PieChartIcon size={14} />
</ThemeIcon>
<Title order={4} c={dark ? "white" : "gray.9"}>
Alokasi Anggaran Per Sektor
</Title>
<BarChart
h={300}
data={allocationData}
dataKey="sector"
series={[
{ name: "amount", color: "darmasaba-navy", label: "Jumlah" },
]}
withLegend
orientation="horizontal"
/>
</Card>
</Grid.Col>
</Grid>
</Group>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={allocationData} layout="vertical">
<CartesianGrid
strokeDasharray="3 3"
horizontal={false}
stroke={dark ? "#334155" : "#e5e7eb"}
/>
<XAxis
type="number"
axisLine={false}
tickLine={false}
tick={{
fill: dark ? "#E2E8F0" : "#374151",
fontSize: 12,
}}
tickFormatter={(value) => `${value}`}
/>
<YAxis
type="category"
dataKey="sector"
axisLine={false}
tickLine={false}
tick={{
fill: dark ? "#E2E8F0" : "#374151",
fontSize: 11,
}}
width={100}
/>
<Tooltip
contentStyle={{
backgroundColor: dark ? "#1E293B" : "white",
borderColor: dark ? "#334155" : "#e5e7eb",
borderRadius: "8px",
}}
formatter={(value: number | undefined) => [
`Rp ${value}jt`,
"Jumlah",
]}
/>
<Bar
dataKey="amount"
fill="#396aaaff"
radius={[0, 8, 8, 0]}
maxBarSize={30}
/>
</BarChart>
</ResponsiveContainer>
</Card>
</Grid.Col>
</Grid>
<Grid gutter="lg">
{/* Dana Bantuan & Hibah */}
<Grid.Col span={{ base: 12, lg: 6 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Title order={3} fw={500} mb="md">
Dana Bantuan & Hibah
{/* BOTTOM SECTION */}
<Grid gutter="lg">
{/* LEFT: LAPORAN APBDES */}
<Grid.Col span={{ base: 12, lg: 6 }}>
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
h="100%"
>
<Group gap="xs" mb="md">
<ThemeIcon color="#1E3A5F" variant="filled" size="sm" radius="sm">
<Receipt size={14} />
</ThemeIcon>
<Title order={4} c={dark ? "white" : "gray.9"}>
Laporan APBDes
</Title>
<Stack gap="sm">
{assistanceFundData.map((fund, index) => (
<Group
key={index}
justify="space-between"
align="center"
p="sm"
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-sm)",
}}
>
</Group>
<Grid gutter="md">
{/* Pendapatan */}
<Grid.Col span={6}>
<Card p="sm" radius="lg" bg={dark ? "#064E3B" : "#DCFCE7"}>
<Title order={5} c="#22C55E" mb="sm">
Pendapatan
</Title>
<Stack gap="xs">
{apbdReport.income.map((item) => (
<Group key={item.category} justify="space-between">
<Text size="sm" c={dark ? "gray.3" : "gray.7"}>
{item.category}
</Text>
<Text size="sm" fw={600} c="#22C55E">
Rp {item.amount.toLocaleString()}jt
</Text>
</Group>
))}
<Group
justify="space-between"
mt="sm"
pt="sm"
style={{
borderTop: `1px solid ${dark ? "#065F46" : "#86EFAC"}`,
}}
>
<Text fw={700} c="#22C55E">
Total:
</Text>
<Text fw={700} c="#22C55E">
Rp {apbdReport.totalIncome.toLocaleString()}jt
</Text>
</Group>
</Stack>
</Card>
</Grid.Col>
{/* Belanja */}
<Grid.Col span={6}>
<Card p="sm" radius="lg" bg={dark ? "#7F1D1D" : "#FEE2E2"}>
<Title order={5} c="#EF4444" mb="sm">
Belanja
</Title>
<Stack gap="xs">
{apbdReport.expenses.map((item) => (
<Group key={item.category} justify="space-between">
<Text size="sm" c={dark ? "gray.3" : "gray.7"}>
{item.category}
</Text>
<Text size="sm" fw={600} c="#EF4444">
Rp {item.amount.toLocaleString()}jt
</Text>
</Group>
))}
<Group
justify="space-between"
mt="sm"
pt="sm"
style={{
borderTop: `1px solid ${dark ? "#991B1B" : "#FCA5A5"}`,
}}
>
<Text fw={700} c="#EF4444">
Total:
</Text>
<Text fw={700} c="#EF4444">
Rp {apbdReport.totalExpenses.toLocaleString()}jt
</Text>
</Group>
</Stack>
</Card>
</Grid.Col>
</Grid>
{/* Saldo */}
<Group
justify="space-between"
mt="md"
pt="md"
style={{
borderTop: `1px solid ${dark ? "#334155" : "#e5e7eb"}`,
}}
>
<Text fw={700} c={dark ? "white" : "gray.9"}>
Saldo:
</Text>
<Text
fw={700}
size="lg"
c={
apbdReport.totalIncome > apbdReport.totalExpenses
? "#22C55E"
: "#EF4444"
}
>
Rp{" "}
{(
apbdReport.totalIncome - apbdReport.totalExpenses
).toLocaleString()}
jt
</Text>
</Group>
</Card>
</Grid.Col>
{/* RIGHT: DANA BANTUAN DAN HIBAH */}
<Grid.Col span={{ base: 12, lg: 6 }}>
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
h="100%"
>
<Group gap="xs" mb="md">
<ThemeIcon color="#1E3A5F" variant="filled" size="sm" radius="sm">
<Coins size={14} />
</ThemeIcon>
<Title order={4} c={dark ? "white" : "gray.9"}>
Dana Bantuan dan Hibah
</Title>
</Group>
<Stack gap="sm">
{assistanceFundData.map((fund) => (
<Card
key={fund.source}
p="sm"
radius="lg"
bg={dark ? "#334155" : "#F1F5F9"}
style={{
borderColor: "transparent",
transition: "background-color 0.15s ease",
}}
>
<Group justify="space-between" align="center">
<Box>
<Text size="sm" fw={500}>
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}>
{fund.source}
</Text>
<Text size="sm" c="dimmed">
<Text size="xs" c="dimmed">
Rp {fund.amount.toLocaleString()}jt
</Text>
</Box>
<Badge
variant="light"
color={fund.status === "cair" ? "green" : "yellow"}
radius="sm"
fw={600}
>
{fund.status}
{fund.status === "cair" ? "Cair" : "Proses"}
</Badge>
</Group>
))}
</Stack>
</Card>
</Grid.Col>
{/* Laporan APBDes */}
<Grid.Col span={{ base: 12, lg: 6 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Title order={3} fw={500} mb="md">
Laporan APBDes
</Title>
<Box mb="md">
<Title order={4} mb="sm">
Pendapatan
</Title>
<Stack gap="xs">
{apbdReport.income.map((item, index) => (
<Group key={index} justify="space-between">
<Text size="sm">{item.category}</Text>
<Text size="sm" c="green">
Rp {item.amount.toLocaleString()}jt
</Text>
</Group>
))}
<Group justify="space-between" mt="sm">
<Text fw={700}>Total Pendapatan:</Text>
<Text fw={700} c="green">
Rp {apbdReport.totalIncome.toLocaleString()}jt
</Text>
</Group>
</Stack>
</Box>
<Box>
<Title order={4} mb="sm">
Belanja
</Title>
<Stack gap="xs">
{apbdReport.expenses.map((item, index) => (
<Group key={index} justify="space-between">
<Text size="sm">{item.category}</Text>
<Text size="sm" c="red">
Rp {item.amount.toLocaleString()}jt
</Text>
</Group>
))}
<Group justify="space-between" mt="sm">
<Text fw={700}>Total Belanja:</Text>
<Text fw={700} c="red">
Rp {apbdReport.totalExpenses.toLocaleString()}jt
</Text>
</Group>
</Stack>
</Box>
<Box
mt="md"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-gray-3)" }}
>
<Group justify="space-between">
<Text fw={700}>Saldo:</Text>
<Text
fw={700}
c={
apbdReport.totalIncome > apbdReport.totalExpenses
? "green"
: "red"
}
>
Rp{" "}
{(
apbdReport.totalIncome - apbdReport.totalExpenses
).toLocaleString()}
jt
</Text>
</Group>
</Box>
</Card>
</Grid.Col>
</Grid>
</Stack>
</Box>
</Card>
))}
</Stack>
</Card>
</Grid.Col>
</Grid>
</Stack>
);
};

View File

@@ -1,537 +1,133 @@
import {
ActionIcon,
Box,
Card,
Divider,
Grid,
GridCol,
Group,
List,
Badge as MantineBadge,
Progress as MantineProgress,
Skeleton,
Stack,
Text,
ThemeIcon,
Title,
useMantineColorScheme,
} from "@mantine/core";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Button } from "@/components/ui/button";
import { Card, Grid, Stack } from "@mantine/core";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { apiClient } from "@/utils/api-client";
import { ActivityCard } from "./kinerja-divisi/activity-card";
import { ArchiveCard } from "./kinerja-divisi/archive-card";
import { DiscussionPanel } from "./kinerja-divisi/discussion-panel";
import { DivisionList } from "./kinerja-divisi/division-list";
import { DocumentChart } from "./kinerja-divisi/document-chart";
import { EventCard } from "./kinerja-divisi/event-card";
import { ProgressChart } from "./kinerja-divisi/progress-chart";
// Data for arsip digital (Section 5)
const archiveData = [
{ name: "Surat Keputusan" },
{ name: "Dokumentasi" },
{ name: "Laporan Keuangan" },
{ name: "Notulensi Rapat" },
];
interface Activity {
id: string;
title: string;
createdAt: string;
progress: number;
status: "SELESAI" | "BERJALAN" | "TERTUNDA";
}
interface EventData {
id: string;
title: string;
startDate: string;
}
const KinerjaDivisi = () => {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const [activities, setActivities] = useState<Activity[]>([]);
const [todayEvents, setTodayEvents] = useState<EventData[]>([]);
const [loading, setLoading] = useState(true);
// Data for division progress chart
const divisionProgressData = [
{ name: "Sekretariat", selesai: 12, berjalan: 5, tertunda: 2 },
{ name: "Keuangan", selesai: 8, berjalan: 7, tertunda: 1 },
{ name: "Sosial", selesai: 10, berjalan: 3, tertunda: 4 },
{ name: "Humas", selesai: 6, berjalan: 9, tertunda: 3 },
];
useEffect(() => {
async function fetchData() {
try {
const [activityRes, eventRes] = await Promise.all([
apiClient.GET("/api/division/activities"),
apiClient.GET("/api/event/today"),
]);
// Division task summaries
const divisionTasks = [
{
name: "Sekretariat",
tasks: [
{ title: "Laporan Bulanan", status: "selesai" },
{ title: "Arsip Dokumen", status: "berjalan" },
{ title: "Undangan Rapat", status: "tertunda" },
],
},
{
name: "Keuangan",
tasks: [
{ title: "Laporan APBDes", status: "selesai" },
{ title: "Verifikasi Dana", status: "tertunda" },
{ title: "Pengeluaran Harian", status: "berjalan" },
],
},
{
name: "Sosial",
tasks: [
{ title: "Program Bantuan", status: "selesai" },
{ title: "Kegiatan Posyandu", status: "berjalan" },
{ title: "Monitoring Stunting", status: "tertunda" },
],
},
{
name: "Humas",
tasks: [
{ title: "Publikasi Kegiatan", status: "selesai" },
{ title: "Koordinasi Media", status: "berjalan" },
{ title: "Laporan Kegiatan", status: "tertunda" },
],
},
];
if (activityRes.data?.data) {
setActivities(activityRes.data.data as Activity[]);
}
if (eventRes.data?.data) {
setTodayEvents(eventRes.data.data as EventData[]);
}
} catch (error) {
console.error("Failed to fetch kinerja divisi data", error);
} finally {
setLoading(false);
}
}
// Archive items
const archiveItems = [
{ name: "Surat Keputusan", count: 12 },
{ name: "Laporan Keuangan", count: 8 },
{ name: "Dokumentasi", count: 24 },
{ name: "Notulensi Rapat", count: 15 },
];
fetchData();
}, []);
// Activity progress
const activityProgress = [
{
name: "Pembangunan Jalan",
progress: 75,
date: "15 Feb 2026",
status: "berjalan",
},
{
name: "Posyandu Bulanan",
progress: 100,
date: "10 Feb 2026",
status: "selesai",
},
{
name: "Vaksinasi Massal",
progress: 45,
date: "20 Feb 2026",
status: "berjalan",
},
{
name: "Festival Budaya",
progress: 20,
date: "5 Mar 2026",
status: "berjalan",
},
];
// Document statistics
const documentStats = [
{ name: "Gambar", value: 42 },
{ name: "Dokumen", value: 87 },
];
// Activity progress statistics
const activityProgressStats = [
{ name: "Selesai", value: 12, fill: "#10B981" },
{ name: "Dikerjakan", value: 8, fill: "#F59E0B" },
{ name: "Segera Dikerjakan", value: 5, fill: "#EF4444" },
{ name: "Dibatalkan", value: 2, fill: "#6B7280" },
];
const COLORS = ["#10B981", "#F59E0B", "#EF4444", "#6B7280"];
const STATUS_COLORS: Record<string, string> = {
selesai: "green",
berjalan: "blue",
tertunda: "red",
proses: "yellow",
};
// Discussion data
const discussions = [
{
title: "Pembahasan APBDes 2026",
sender: "Kepala Desa",
timestamp: "2 jam yang lalu",
},
{
title: "Kegiatan Posyandu",
sender: "Divisi Sosial",
timestamp: "5 jam yang lalu",
},
{
title: "Festival Budaya",
sender: "Divisi Humas",
timestamp: "1 hari yang lalu",
},
];
// Today's agenda
const todayAgenda = [
{ time: "09:00", event: "Rapat Evaluasi Bulanan" },
{ time: "14:00", event: "Koordinasi Program Bantuan" },
];
// Format events for EventCard
const formattedEvents = todayEvents.map((event) => ({
time: dayjs(event.startDate).format("HH:mm"),
event: event.title,
}));
return (
<Stack gap="lg">
{/* Grafik Progres Tugas per Divisi */}
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Title order={4} mb="md" c={dark ? "white" : "darmasaba-navy"}>
Grafik Progres Tugas per Divisi
</Title>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={divisionProgressData}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke={dark ? "#141D34" : "white"}
/>
<XAxis
dataKey="name"
axisLine={false}
tickLine={false}
tick={{
fill: dark
? "var(--mantine-color-text)"
: "var(--mantine-color-text)",
}}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{
fill: dark
? "var(--mantine-color-text)"
: "var(--mantine-color-text)",
}}
/>
<Tooltip
contentStyle={
dark
? {
backgroundColor: "var(--mantine-color-dark-7)",
borderColor: "var(--mantine-color-dark-6)",
}
: {}
{/* SECTION 1 — PROGRAM KEGIATAN */}
<Grid gutter="md">
{activities.slice(0, 4).map((kegiatan) => (
<Grid.Col key={kegiatan.id} span={{ base: 12, md: 6, lg: 3 }}>
<ActivityCard
title={kegiatan.title}
date={dayjs(kegiatan.createdAt).format("D MMMM YYYY")}
progress={kegiatan.progress}
status={
kegiatan.status === "SELESAI"
? "Selesai"
: kegiatan.status === "BERJALAN"
? "Berjalan"
: "Tertunda"
}
/>
<Bar
dataKey="selesai"
stackId="a"
fill="#10B981"
name="Selesai"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="berjalan"
stackId="a"
fill="#3B82F6"
name="Berjalan"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="tertunda"
stackId="a"
fill="#EF4444"
name="Tertunda"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</Card>
{/* Ringkasan Tugas per Divisi */}
<Grid gutter="md">
{divisionTasks.map((division, index) => (
<GridCol key={index} span={{ base: 12, md: 6, lg: 3 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
h="100%"
>
<Title order={4} mb="sm" c={dark ? "white" : "darmasaba-navy"}>
{division.name}
</Title>
<Stack gap="sm">
{division.tasks.map((task, taskIndex) => (
<Box key={taskIndex}>
<Group justify="space-between">
<Text size="sm" c={dark ? "white" : "darmasaba-navy"}>
{task.title}
</Text>
<MantineBadge
color={STATUS_COLORS[task.status] || "gray"}
variant="light"
>
{task.status}
</MantineBadge>
</Group>
</Box>
))}
</Stack>
</Grid.Col>
))}
{!loading && activities.length === 0 && (
<Grid.Col span={12}>
<Card p="md" radius="xl" withBorder ta="center" c="dimmed">
Tidak ada aktivitas terbaru
</Card>
</GridCol>
</Grid.Col>
)}
</Grid>
{/* SECTION 2 — GRID DASHBOARD (3 Columns) */}
<Grid gutter="lg">
{/* Left Column - Division List */}
<Grid.Col span={{ base: 12, lg: 3 }}>
<DivisionList />
</Grid.Col>
{/* Middle Column - Document Chart */}
<Grid.Col span={{ base: 12, lg: 5 }}>
<DocumentChart />
</Grid.Col>
{/* Right Column - Progress Chart */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<ProgressChart />
</Grid.Col>
</Grid>
{/* SECTION 3 — DISCUSSION PANEL */}
<DiscussionPanel />
{/* SECTION 4 — ACARA HARI INI */}
<EventCard agendas={formattedEvents} />
{/* SECTION 5 — ARSIP DIGITAL PERANGKAT DESA */}
<Grid gutter="md">
{archiveData.map((item) => (
<Grid.Col key={item.name} span={{ base: 12, md: 6 }}>
<ArchiveCard item={item} />
</Grid.Col>
))}
</Grid>
{/* Arsip Digital Perangkat Desa */}
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Title order={4} mb="md" c={dark ? "white" : "darmasaba-navy"}>
Arsip Digital Perangkat Desa
</Title>
<Grid gutter="md">
{archiveItems.map((item, index) => (
<GridCol key={index} span={{ base: 12, md: 6, lg: 3 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#263852ff" : "#F1F5F9"}
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
>
<Group justify="space-between">
<Text c={dark ? "white" : "darmasaba-navy"} fw={500}>
{item.name}
</Text>
<Text c={dark ? "white" : "darmasaba-navy"} fw={700}>
{item.count}
</Text>
</Group>
</Card>
</GridCol>
))}
</Grid>
</Card>
{/* Kartu Progres Kegiatan */}
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Title order={4} mb="md" c={dark ? "white" : "darmasaba-navy"}>
Progres Kegiatan / Program
</Title>
<Stack gap="md">
{activityProgress.map((activity, index) => (
<Card
key={index}
p="md"
radius="md"
withBorder
bg={dark ? "#263852ff" : "#F1F5F9"}
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
>
<Group justify="space-between" mb="sm">
<Text c={dark ? "white" : "darmasaba-navy"} fw={500}>
{activity.name}
</Text>
<MantineBadge
color={STATUS_COLORS[activity.status] || "gray"}
variant="light"
>
{activity.status}
</MantineBadge>
</Group>
<Group justify="space-between">
<MantineProgress
value={activity.progress}
size="sm"
radius="xl"
color={activity.progress === 100 ? "green" : "blue"}
w="calc(100% - 80px)"
/>
<Text size="sm" c={dark ? "white" : "darmasaba-navy"}>
{activity.progress}%
</Text>
</Group>
<Text size="sm" c="dimmed" mt="sm">
{activity.date}
</Text>
</Card>
))}
</Stack>
</Card>
{/* Statistik Dokumen & Progres Kegiatan */}
<Grid gutter="md">
<GridCol span={{ base: 12, lg: 6 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Title order={4} mb="md" c={dark ? "white" : "darmasaba-navy"}>
Jumlah Dokumen
</Title>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={documentStats}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke={dark ? "#141D34" : "white"}
/>
<XAxis
dataKey="name"
axisLine={false}
tickLine={false}
tick={{
fill: dark
? "var(--mantine-color-text)"
: "var(--mantine-color-text)",
}}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{
fill: dark
? "var(--mantine-color-text)"
: "var(--mantine-color-text)",
}}
/>
<Tooltip
contentStyle={
dark
? {
backgroundColor: "var(--mantine-color-dark-7)",
borderColor: "var(--mantine-color-dark-6)",
}
: {}
}
/>
<Bar
dataKey="value"
fill={
dark
? "var(--mantine-color-blue-6)"
: "var(--mantine-color-blue-filled)"
}
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</Card>
</GridCol>
<GridCol span={{ base: 12, lg: 6 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Title order={4} mb="md" c={dark ? "white" : "darmasaba-navy"}>
Progres Kegiatan
</Title>
<ResponsiveContainer width="100%" height={200}>
<PieChart
margin={{ top: 20, right: 80, bottom: 20, left: 80 }}
>
<Pie
data={activityProgressStats}
cx="50%"
cy="50%"
labelLine
outerRadius={65}
dataKey="value"
label={({ name, percent }) =>
`${name}: ${percent ? (percent * 100).toFixed(0) : "0"}%`
}
/>
<Tooltip
contentStyle={
dark
? {
backgroundColor: "var(--mantine-color-dark-7)",
borderColor: "var(--mantine-color-dark-6)",
}
: {}
}
/>
</PieChart>
</ResponsiveContainer>
</Card>
</GridCol>
</Grid>
{/* Diskusi Internal */}
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Title order={4} mb="md" c={dark ? "white" : "darmasaba-navy"}>
Diskusi Internal
</Title>
<Stack gap="sm">
{discussions.map((discussion, index) => (
<Card
key={index}
p="md"
radius="md"
withBorder
bg={dark ? "#263852ff" : "#F1F5F9"}
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
>
<Group justify="space-between">
<Text c={dark ? "white" : "darmasaba-navy"} fw={500}>
{discussion.title}
</Text>
<Text size="sm" c="dimmed">
{discussion.timestamp}
</Text>
</Group>
<Text size="sm" c="dimmed">
{discussion.sender}
</Text>
</Card>
))}
</Stack>
</Card>
{/* Agenda / Acara Hari Ini */}
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Title order={4} mb="md" c={dark ? "white" : "darmasaba-navy"}>
Agenda / Acara Hari Ini
</Title>
{todayAgenda.length > 0 ? (
<Stack gap="sm">
{todayAgenda.map((agenda, index) => (
<Group key={index} align="flex-start">
<Box w={60}>
<Text c="dimmed">{agenda.time}</Text>
</Box>
<Divider orientation="vertical" mx="sm" />
<Text c={dark ? "white" : "darmasaba-navy"}>
{agenda.event}
</Text>
</Group>
))}
</Stack>
) : (
<Text c="dimmed" ta="center" py="md">
Tidak ada acara hari ini
</Text>
)}
</Card>
</Stack>
);
};

View File

@@ -0,0 +1,100 @@
import {
Box,
Card,
Group,
Progress,
Text,
useMantineColorScheme,
} from "@mantine/core";
interface ActivityCardProps {
title: string;
date: string;
progress: number;
status: "Selesai" | "Berjalan" | "Tertunda";
}
export function ActivityCard({
title,
date,
progress,
status,
}: ActivityCardProps) {
const getStatusColor = () => {
switch (status) {
case "Selesai":
return "#22C55E";
case "Berjalan":
return "#3B82F6";
case "Tertunda":
return "#EF4444";
default:
return "#9CA3AF";
}
};
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
return (
<Card
radius="xl"
p={0}
withBorder={false}
style={{
backgroundColor: dark ? "#334155" : "white",
overflow: "hidden",
}}
h={"100%"}
>
{/* 🔵 HEADER */}
<Box
style={{
backgroundColor: "#1E3A5F",
padding: "16px",
textAlign: "center",
}}
>
<Text c="white" fw={700} size="md">
{title}
</Text>
</Box>
{/* CONTENT */}
<Box p="md">
{/* PROGRESS */}
<Progress
value={progress}
radius="xl"
size="lg"
color="orange"
styles={{
root: {
height: 16,
},
}}
/>
{/* FOOTER */}
<Group justify="space-between" mt="md">
<Text size="sm" fw={500}>
{date}
</Text>
<Box
style={{
backgroundColor: getStatusColor(),
color: "white",
padding: "4px 12px",
borderRadius: 999,
fontSize: 12,
fontWeight: 600,
}}
>
{status}
</Box>
</Group>
</Box>
</Card>
);
}

View File

@@ -0,0 +1,42 @@
import { Card, Group, Text, useMantineColorScheme } from "@mantine/core";
import { FileText } from "lucide-react";
interface ArchiveItem {
name: string;
}
interface ArchiveCardProps {
item: ArchiveItem;
onClick?: () => void;
}
export function ArchiveCard({ item, onClick }: ArchiveCardProps) {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
return (
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "#e5e7eb",
boxShadow: dark
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
cursor: "pointer",
transition: "transform 0.2s, box-shadow 0.2s",
}}
h="100%"
onClick={onClick}
>
<Group gap="md">
<FileText size={32} color={dark ? "#60A5FA" : "#3B82F6"} />
<Text size="sm" fw={500} c={dark ? "white" : "#1E3A5F"}>
{item.name}
</Text>
</Group>
</Card>
);
}

View File

@@ -0,0 +1,125 @@
import {
Card,
Group,
Loader,
Stack,
Text,
useMantineColorScheme,
} from "@mantine/core";
import { format } from "date-fns";
import { id } from "date-fns/locale";
import { MessageCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { apiClient } from "@/utils/api-client";
interface DiscussionItem {
id: string;
message: string;
sender: string;
date: string;
division: string | null;
isResolved: boolean;
}
export function DiscussionPanel() {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const [discussions, setDiscussions] = useState<DiscussionItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchDiscussions() {
try {
const res = await apiClient.GET("/api/division/discussions");
if (res.data?.data) {
setDiscussions(res.data.data);
}
} catch (error) {
console.error("Failed to fetch discussions", error);
} finally {
setLoading(false);
}
}
fetchDiscussions();
}, []);
const formatDate = (dateString: string) => {
try {
return format(new Date(dateString), "dd MMM yyyy", { locale: id });
} catch {
return dateString;
}
};
return (
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: dark
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
h="100%"
>
<Group gap="xs" mb="md">
<MessageCircle size={20} color={dark ? "#E2E8F0" : "#1E3A5F"} />
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"}>
Diskusi
</Text>
</Group>
<Stack gap="sm">
{loading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : discussions.length > 0 ? (
discussions.map((discussion) => (
<Card
key={discussion.id}
p="sm"
radius="md"
withBorder
bg={dark ? "#334155" : "#F1F5F9"}
style={{
borderColor: dark ? "#334155" : "#F1F5F9",
}}
>
<Text
size="sm"
c={dark ? "white" : "#1E3A5F"}
fw={500}
mb="xs"
lineClamp={2}
>
{discussion.message}
</Text>
<Group justify="space-between">
<Text size="xs" c="dimmed">
{discussion.sender}
{discussion.division && (
<Text span size="xs" c="dimmed" ml="xs">
{discussion.division}
</Text>
)}
</Text>
<Text size="xs" c="dimmed">
{formatDate(discussion.date)}
</Text>
</Group>
</Card>
))
) : (
<Text size="sm" c="dimmed" ta="center" py="xl">
Tidak ada diskusi
</Text>
)}
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,109 @@
import {
Card,
Group,
Loader,
Stack,
Text,
useMantineColorScheme,
} from "@mantine/core";
import { ChevronRight } from "lucide-react";
import { useEffect, useState } from "react";
import { apiClient } from "@/utils/api-client";
interface DivisionItem {
name: string;
count: number;
}
interface DivisionApiResponse {
name: string;
activityCount: number;
_count?: {
activities: number;
};
}
export function DivisionList() {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const [divisions, setDivisions] = useState<DivisionItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchDivisions() {
try {
const { data } = await apiClient.GET("/api/division/");
if (data?.data) {
const mapped = (data.data as DivisionApiResponse[]).map((div) => ({
name: div.name,
count: div.activityCount || 0,
}));
setDivisions(mapped);
}
} catch (error) {
console.error("Failed to fetch divisions", error);
} finally {
setLoading(false);
}
}
fetchDivisions();
}, []);
return (
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: dark
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
h="100%"
>
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"} mb="md">
Divisi Teraktif
</Text>
<Stack gap="xs">
{loading ? (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
) : divisions.length > 0 ? (
divisions.map((division) => (
<Group
key={division.name}
justify="space-between"
align="center"
style={{
padding: "8px 12px",
borderRadius: 8,
backgroundColor: dark ? "#334155" : "#F1F5F9",
transition: "background-color 0.2s",
cursor: "pointer",
}}
>
<Text size="sm" c={dark ? "white" : "#1E3A5F"}>
{division.name}
</Text>
<Group gap="xs">
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"}>
{division.count}
</Text>
<ChevronRight size={16} color={dark ? "#94A3B8" : "#64748B"} />
</Group>
</Group>
))
) : (
<Text size="xs" c="dimmed" ta="center">
Tidak ada data divisi
</Text>
)}
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,116 @@
import {
Card,
Group,
Loader,
Text,
useMantineColorScheme,
} from "@mantine/core";
import { useEffect, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { apiClient } from "@/utils/api-client";
interface DocumentData {
name: string;
jumlah: number;
color: string;
}
export function DocumentChart() {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const [data, setData] = useState<DocumentData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchDocumentStats() {
try {
const res = await apiClient.GET("/api/division/documents/stats");
if (res.data?.data) {
setData(res.data.data);
}
} catch (error) {
console.error("Failed to fetch document stats", error);
} finally {
setLoading(false);
}
}
fetchDocumentStats();
}, []);
return (
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: dark
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
h="100%"
>
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"} mb="md">
Jumlah Dokumen
</Text>
{loading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : data.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={data}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke={dark ? "#334155" : "#e5e7eb"}
/>
<XAxis
dataKey="name"
axisLine={false}
tickLine={false}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: dark ? "#1E293B" : "white",
borderColor: dark ? "#334155" : "#e5e7eb",
borderRadius: "8px",
}}
labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
/>
<Bar dataKey="jumlah" radius={[4, 4, 0, 0]}>
{data.map((entry) => (
<Cell key={`cell-${entry.name}`} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<Group justify="center" py="xl">
<Text size="sm" c="dimmed">
Tidak ada dokumen
</Text>
</Group>
)}
</Card>
);
}

View File

@@ -0,0 +1,70 @@
import {
Box,
Card,
Group,
Stack,
Text,
useMantineColorScheme,
} from "@mantine/core";
import { Calendar } from "lucide-react";
interface AgendaItem {
time: string;
event: string;
}
interface EventCardProps {
agendas?: AgendaItem[];
}
export function EventCard({ agendas = [] }: EventCardProps) {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
return (
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: dark
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
h="100%"
>
<Group gap="xs" mb="md">
<Calendar size={20} color={dark ? "#E2E8F0" : "#1E3A5F"} />
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"}>
Acara Hari Ini
</Text>
</Group>
{agendas.length > 0 ? (
<Stack gap="sm">
{agendas.map((agenda) => (
<Group
key={`${agenda.time}-${agenda.event}`}
align="flex-start"
gap="md"
>
<Box w={60}>
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"}>
{agenda.time}
</Text>
</Box>
<Text size="sm" c={dark ? "white" : "#1E3A5F"}>
{agenda.event}
</Text>
</Group>
))}
</Stack>
) : (
<Text c="dimmed" ta="center" py="md">
Tidak ada acara hari ini
</Text>
)}
</Card>
);
}

View File

@@ -0,0 +1,7 @@
export { ActivityCard } from "./activity-card";
export { ArchiveCard } from "./archive-card";
export { DiscussionPanel } from "./discussion-panel";
export { DivisionList } from "./division-list";
export { DocumentChart } from "./document-chart";
export { EventCard } from "./event-card";
export { ProgressChart } from "./progress-chart";

View File

@@ -0,0 +1,153 @@
import {
Box,
Card,
Group,
Loader,
Stack,
Text,
useMantineColorScheme,
} from "@mantine/core";
import { useEffect, useState } from "react";
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
import { apiClient } from "@/utils/api-client";
interface ProgressData {
name: string;
value: number;
color: string;
}
interface ActivityStats {
total: number;
counts: {
selesai: number;
berjalan: number;
tertunda: number;
dibatalkan: number;
};
percentages: {
selesai: number;
berjalan: number;
tertunda: number;
dibatalkan: number;
};
}
export function ProgressChart() {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const [data, setData] = useState<ProgressData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchActivityStats() {
try {
const res = await apiClient.GET("/api/division/activities/stats");
if (res.data?.data) {
const stats = res.data.data as ActivityStats;
const chartData: ProgressData[] = [
{
name: "Selesai",
value: stats.percentages.selesai,
color: "#22C55E",
},
{
name: "Dikerjakan",
value: stats.percentages.berjalan,
color: "#F59E0B",
},
{
name: "Segera Dikerjakan",
value: stats.percentages.tertunda,
color: "#3B82F6",
},
{
name: "Dibatalkan",
value: stats.percentages.dibatalkan,
color: "#EF4444",
},
];
setData(chartData);
}
} catch (error) {
console.error("Failed to fetch activity stats", error);
} finally {
setLoading(false);
}
}
fetchActivityStats();
}, []);
return (
<Card
p="md"
radius="xl"
withBorder
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: dark
? "0 1px 3px 0 rgb(0 0 0 / 0.1)"
: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}}
h="100%"
>
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"} mb="md">
Progres Kegiatan
</Text>
{loading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : (
<>
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={2}
dataKey="value"
>
{data.map((entry) => (
<Cell key={`cell-${entry.name}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: dark ? "#1E293B" : "white",
borderColor: dark ? "#334155" : "#e5e7eb",
borderRadius: "8px",
}}
/>
</PieChart>
</ResponsiveContainer>
<Stack gap="xs" mt="md">
{data.map((item) => (
<Group key={item.name} justify="space-between">
<Group gap="xs">
<Box
w={12}
h={12}
style={{ backgroundColor: item.color, borderRadius: 2 }}
/>
<Text size="sm" c={dark ? "white" : "gray.7"}>
{item.name}
</Text>
</Group>
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}>
{item.value.toFixed(2)}%
</Text>
</Group>
))}
</Stack>
</>
)}
</Card>
);
}

View File

@@ -0,0 +1,66 @@
import { AppShell, Burger, Group, useMantineColorScheme } from "@mantine/core";
import type React from "react";
import { Header } from "@/components/header";
import { Sidebar } from "@/components/sidebar";
import { useSidebarFullscreen } from "@/hooks/use-sidebar-fullscreen";
interface MainLayoutProps {
children: React.ReactNode;
}
export function MainLayout({ children }: MainLayoutProps) {
const {
opened,
toggleMobile,
sidebarCollapsed,
toggleSidebar,
handleMainClick,
} = useSidebarFullscreen();
const { colorScheme } = useMantineColorScheme();
const headerBgColor = colorScheme === "dark" ? "#11192D" : "#19355E";
const navbarBgColor = colorScheme === "dark" ? "#11192D" : "white";
const mainBgColor = colorScheme === "dark" ? "#11192D" : "#edf3f8ff";
return (
<AppShell
header={{ height: 60 }}
navbar={{
width: 300,
breakpoint: "sm",
collapsed: { mobile: !opened, desktop: sidebarCollapsed },
}}
padding="md"
>
<AppShell.Header bg={headerBgColor}>
<Group h="100%" px="md">
<Burger
opened={opened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
<Header onSidebarToggle={toggleSidebar} />
</Group>
</AppShell.Header>
<AppShell.Navbar
p="md"
bg={navbarBgColor}
style={{ display: "flex", flexDirection: "column" }}
>
<div style={{ flex: 1, overflowY: "auto" }}>
<Sidebar />
</div>
</AppShell.Navbar>
<AppShell.Main
bg={mainBgColor}
onClick={handleMainClick}
style={{ cursor: sidebarCollapsed ? "default" : "pointer" }}
>
{children}
</AppShell.Main>
</AppShell>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,189 +1,78 @@
import {
ActionIcon,
Alert,
Button,
Card,
Group,
Modal,
Select,
Space,
Table,
Text,
TextInput,
Title,
useMantineColorScheme,
} from "@mantine/core";
import {
IconEdit,
IconInfoCircle,
IconTrash,
IconUser,
IconUserPlus,
} from "@tabler/icons-react";
import { useState } from "react";
import { Box, Button, Group, Stack, Switch, Text, Title } from "@mantine/core";
const AksesDanTimSettings = () => {
const [opened, setOpened] = useState(false);
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
// Sample team members data
const teamMembers = [
{
id: 1,
name: "Admin Utama",
email: "admin@desa.go.id",
role: "Administrator",
status: "Aktif",
},
{
id: 2,
name: "Operator Desa",
email: "operator@desa.go.id",
role: "Operator",
status: "Aktif",
},
{
id: 3,
name: "Staff Keuangan",
email: "keuangan@desa.go.id",
role: "Keuangan",
status: "Aktif",
},
{
id: 4,
name: "Staff Umum",
email: "umum@desa.go.id",
role: "Umum",
status: "Nonaktif",
},
];
const roles = [
{ value: "administrator", label: "Administrator" },
{ value: "operator", label: "Operator" },
{ value: "keuangan", label: "Keuangan" },
{ value: "umum", label: "Umum" },
{ value: "keamanan", label: "Keamanan" },
];
return (
<Card
withBorder
radius="md"
p="xl"
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title="Tambah Anggota Tim"
size="lg"
>
<TextInput
label="Nama Lengkap"
placeholder="Masukkan nama lengkap anggota tim"
mb="md"
/>
<TextInput
label="Alamat Email"
placeholder="Masukkan alamat email"
mb="md"
/>
<Select
label="Peran"
placeholder="Pilih peran anggota tim"
data={roles}
mb="md"
/>
<Group justify="flex-end" mt="xl">
<Button variant="outline" onClick={() => setOpened(false)}>
Batal
<Stack pr={"50%"} gap={"xl"}>
<Box>
<Stack gap={"xs"}>
<Title order={2}>Manajemen Tim</Title>
<Button bg={"#1E3A5F"} radius={"md"} c={"white"} fullWidth>
Undangan Anggota Baru
</Button>
<Button>Undang Anggota</Button>
</Group>
</Modal>
<Title order={2} mb="lg">
Akses & Tim
</Title>
<Text color="dimmed" mb="xl">
Kelola akses dan anggota tim Anda
</Text>
<Space h="lg" />
<Group justify="space-between" mb="md">
<Title order={4}>Anggota Tim</Title>
<Button
leftSection={<IconUserPlus size={16} />}
onClick={() => setOpened(true)}
>
Tambah Anggota
</Button>
</Group>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Nama</Table.Th>
<Table.Th>Email</Table.Th>
<Table.Th>Peran</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{teamMembers.map((member) => (
<Table.Tr key={member.id}>
<Table.Td>
<Group gap="sm">
<IconUser size={20} />
<Text>{member.name}</Text>
</Group>
</Table.Td>
<Table.Td>{member.email}</Table.Td>
<Table.Td>
<Text fw={500}>{member.role}</Text>
</Table.Td>
<Table.Td>
<Text c={member.status === "Aktif" ? "green" : "red"} fw={500}>
{member.status}
</Text>
</Table.Td>
<Table.Td>
<Group>
<ActionIcon variant="subtle" color="blue">
<IconEdit size={16} />
</ActionIcon>
<ActionIcon variant="subtle" color="red">
<IconTrash size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Space h="xl" />
<Alert
icon={<IconInfoCircle size={16} />}
title="Informasi"
color="blue"
mb="md"
>
Administrator memiliki akses penuh ke semua fitur. Peran lainnya
memiliki akses terbatas sesuai kebutuhan.
</Alert>
<Group justify="flex-end" mt="xl">
<Button bg={"#1E3A5F"} radius={"md"} c={"white"} fullWidth>
Kelola Role & Permission
</Button>
<Group justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Daftar Anggota Teraktif
</Text>
<Text fw={"bold"} fz={"sm"}>
12 Anggota
</Text>
</Group>
</Stack>
</Box>
<Box>
<Stack gap={"xs"}>
<Title order={2}>Hak Akses</Title>
<Group justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Administrator
</Text>
<Text fw={"bold"} fz={"sm"}>
2 Orang
</Text>
</Group>
<Group justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Editor
</Text>
<Text fw={"bold"} fz={"sm"}>
5 Orang
</Text>
</Group>
<Group justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Viewer
</Text>
<Text fw={"bold"} fz={"sm"}>
5 Orang
</Text>
</Group>
</Stack>
</Box>
<Box>
<Stack gap={"xs"}>
<Title order={2}>Kolaborasi</Title>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Izin Export Data
</Text>
<Switch defaultChecked />
</Group>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Require Approval Untuk Perubahan
</Text>
<Switch defaultChecked />
</Group>
</Stack>
</Box>
<Group justify="flex-start" mt="xl">
<Button variant="outline">Batal</Button>
<Button>Simpan Perubahan</Button>
</Group>
</Card>
</Stack>
);
};

View File

@@ -1,89 +1,64 @@
import {
Alert,
Button,
Card,
Group,
PasswordInput,
Space,
Switch,
Text,
Title,
useMantineColorScheme,
} from "@mantine/core";
import { IconInfoCircle, IconLock } from "@tabler/icons-react";
import { Box, Button, Group, Stack, Switch, Text, Title } from "@mantine/core";
const KeamananSettings = () => {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
return (
<Card
withBorder
radius="md"
p="xl"
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Title order={2} mb="lg">
Pengaturan Keamanan
</Title>
<Text color="dimmed" mb="xl">
Kelola keamanan akun Anda
</Text>
<Space h="lg" />
<PasswordInput
label="Kata Sandi Saat Ini"
placeholder="Masukkan kata sandi saat ini"
mb="md"
/>
<PasswordInput
label="Kata Sandi Baru"
placeholder="Masukkan kata sandi baru"
mb="md"
/>
<PasswordInput
label="Konfirmasi Kata Sandi Baru"
placeholder="Konfirmasi kata sandi baru"
mb="md"
/>
<Space h="md" />
<Group mb="md">
<Switch label="Verifikasi Dua Langkah" />
<Switch label="Login Otentikasi Aplikasi" />
</Group>
<Space h="md" />
<Alert
icon={<IconLock size={16} />}
title="Keamanan"
color="orange"
mb="md"
>
Gunakan kata sandi yang kuat dan unik. Hindari menggunakan kata sandi
yang sama di banyak layanan.
</Alert>
<Alert
icon={<IconInfoCircle size={16} />}
title="Informasi"
color="blue"
mb="md"
>
Setelah mengganti kata sandi, Anda akan diminta logout dari semua
perangkat.
</Alert>
<Group justify="flex-end" mt="xl">
<Stack pr={"50%"} gap={"xl"}>
<Box>
<Stack gap={"xs"}>
<Title order={2}>Autentikasi</Title>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Two-Factor Authentication
</Text>
<Switch defaultChecked />
</Group>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Biometrik Login
</Text>
<Switch defaultChecked />
</Group>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
IP Whitelist
</Text>
<Switch defaultChecked />
</Group>
</Stack>
</Box>
<Box>
<Stack gap={"xs"}>
<Title order={2}>Password</Title>
<Button bg={"#1E3A5F"} radius={"md"} c={"white"} fullWidth>
Ubah Password
</Button>
<Button bg={"#1E3A5F"} radius={"md"} c={"white"} fullWidth>
Riwayat Login
</Button>
<Button bg={"#1E3A5F"} radius={"md"} c={"white"} fullWidth>
Perangkat Terdaftar
</Button>
</Stack>
</Box>
<Box>
<Stack gap={"xs"}>
<Title order={2}>Audit & Log</Title>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Log Aktivitas
</Text>
<Switch defaultChecked />
</Group>
<Button bg={"#1E3A5F"} radius={"md"} c={"white"} fullWidth>
Download Log
</Button>
</Stack>
</Box>
<Group justify="flex-start" mt="xl">
<Button variant="outline">Batal</Button>
<Button>Perbarui Kata Sandi</Button>
<Button>Simpan Perubahan</Button>
</Group>
</Card>
</Stack>
);
};

View File

@@ -1,85 +1,114 @@
import {
Alert,
Button,
Card,
Checkbox,
Grid,
GridCol,
Group,
Space,
Stack,
Switch,
Text,
Title,
useMantineColorScheme,
} from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
const NotifikasiSettings = () => {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const _dark = colorScheme === "dark";
return (
<Card
withBorder
radius="md"
p="xl"
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Title order={2} mb="lg">
Pengaturan Notifikasi
</Title>
<Text color="dimmed" mb="xl">
Kelola preferensi notifikasi Anda
</Text>
<Space h="lg" />
<Checkbox.Group defaultValue={["email", "push"]} mb="md">
<Title order={4} mb="sm">
Metode Notifikasi
</Title>
<Group>
<Checkbox value="email" label="Email" />
<Checkbox value="push" label="Notifikasi Push" />
<Checkbox value="sms" label="SMS" />
</Group>
</Checkbox.Group>
<Space h="md" />
<Group mb="md">
<Switch label="Notifikasi Email" defaultChecked />
<Switch label="Notifikasi Push" defaultChecked />
</Group>
<Space h="md" />
<Title order={4} mb="sm">
Jenis Notifikasi
</Title>
<Group align="start">
<Switch label="Pengaduan Baru" defaultChecked />
<Switch label="Update Status Pengaduan" defaultChecked />
<Switch label="Laporan Mingguan" />
<Switch label="Pemberitahuan Keamanan" defaultChecked />
<Switch label="Aktivitas Akun" defaultChecked />
</Group>
<Space h="md" />
<Alert
icon={<IconInfoCircle size={16} />}
title="Tip"
color="blue"
mb="md"
>
Anda dapat menyesuaikan frekuensi notifikasi mingguan sesuai kebutuhan
Anda.
</Alert>
<Group justify="flex-end" mt="xl">
<Stack pr={"20%"} gap={"xs"}>
<Grid gutter={{ base: 5, xs: "md", md: "xl", xl: 50 }}>
<GridCol span={6}>
<Stack gap={"xs"}>
<Title order={3} mb="sm">
Metode Notifikasi
</Title>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Laporan Harian
</Text>
<Switch defaultChecked />
</Group>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Alert Sistem
</Text>
<Switch defaultChecked />
</Group>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Update Keamanan
</Text>
<Switch defaultChecked />
</Group>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Newsletter Bulanan
</Text>
<Switch defaultChecked />
</Group>
</Stack>
</GridCol>
<GridCol span={6}>
<Stack gap={"xs"}>
<Title order={3} mb="sm">
Preferensi Alert
</Title>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Treshold Memori
</Text>
<Switch defaultChecked />
</Group>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Treshold CPU
</Text>
<Switch defaultChecked />
</Group>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Treshold Disk
</Text>
<Switch defaultChecked />
</Group>
</Stack>
</GridCol>
<GridCol span={6}>
<Stack gap={"xs"}>
<Title order={3} mb="sm">
Notifikasi Push
</Title>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Alert Kritis
</Text>
<Switch defaultChecked />
</Group>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Aktivitas Tim
</Text>
<Switch defaultChecked />
</Group>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Komentar & Mention
</Text>
<Switch defaultChecked />
</Group>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Bunyi Notifikasi
</Text>
<Switch defaultChecked />
</Group>
</Stack>
</GridCol>
</Grid>
<Group justify="flex-start" mt="xl">
<Button variant="outline">Batal</Button>
<Button>Simpan Preferensi</Button>
</Group>
</Card>
</Stack>
);
};

View File

@@ -0,0 +1,176 @@
import {
Box,
Button,
Card,
Group,
Stack,
Text,
Title,
Alert,
Loader,
Badge,
Divider,
} from "@mantine/core";
import { IconRefresh, IconCheck, IconAlertCircle, IconClock } from "@tabler/icons-react";
import { useState, useEffect } from "react";
import { apiClient } from "@/utils/api-client";
import dayjs from "dayjs";
import "dayjs/locale/id";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
dayjs.locale("id");
const SinkronisasiSettings = () => {
const [loading, setLoading] = useState(false);
const [lastSync, setLastSync] = useState<string | null>(null);
const [status, setStatus] = useState<{
type: "success" | "error" | null;
message: string;
}>({ type: null, message: "" });
const fetchLastSync = async () => {
const { data } = await apiClient.GET("/api/noc/last-sync", {
params: { query: { idDesa: "desa1" } },
});
if (data?.lastSyncedAt) {
setLastSync(data.lastSyncedAt);
}
};
useEffect(() => {
fetchLastSync();
}, []);
const handleSync = async () => {
setLoading(true);
setStatus({ type: null, message: "" });
try {
const { data, error } = await apiClient.POST("/api/noc/sync");
if (error) {
setStatus({
type: "error",
message: (error as any).error || "Gagal melakukan sinkronisasi",
});
} else if (data?.success) {
setStatus({
type: "success",
message: data.message || "Sinkronisasi berhasil dilakukan",
});
if (data.lastSyncedAt) {
setLastSync(data.lastSyncedAt);
}
}
} catch (err) {
setStatus({
type: "error",
message: "Terjadi kesalahan sistem saat sinkronisasi",
});
} finally {
setLoading(false);
}
};
return (
<Box pr={"50%"}>
<Title order={2} mb="lg">
Sinkronisasi Data NOC
</Title>
<Text c="dimmed" mb="xl">
Gunakan fitur ini untuk memperbarui data dashboard dengan data terbaru dari
server Network Operation Center (NOC) darmasaba.muku.id.
</Text>
<Card withBorder padding="lg" radius="md" mb="xl">
<Stack gap="md">
<Group justify="space-between">
<Group>
<IconClock size={20} color="gray" />
<Text fw={500}>Status Terakhir</Text>
</Group>
<Badge color={lastSync ? "green" : "gray"} variant="light">
{lastSync ? "Terkoneksi" : "Belum Pernah Sinkron"}
</Badge>
</Group>
<Divider />
<Box>
<Text size="sm" c="dimmed">
Waktu Sinkronisasi Terakhir:
</Text>
<Text fw={700} size="lg">
{lastSync
? dayjs(lastSync).format("DD MMMM YYYY, HH:mm:ss")
: "Belum pernah dilakukan"}
</Text>
{lastSync && (
<Text size="xs" c="dimmed" mt={4}>
({dayjs(lastSync).fromNow()})
</Text>
)}
</Box>
{status.type && (
<Alert
icon={
status.type === "success" ? (
<IconCheck size={16} />
) : (
<IconAlertCircle size={16} />
)
}
title={status.type === "success" ? "Berhasil" : "Kesalahan"}
color={status.type === "success" ? "green" : "red"}
onClose={() => setStatus({ type: null, message: "" })}
withCloseButton
>
{status.message}
</Alert>
)}
<Button
leftSection={
loading ? <Loader size={16} color="white" /> : <IconRefresh size={16} />
}
onClick={handleSync}
loading={loading}
fullWidth
mt="md"
>
Sinkronkan Sekarang
</Button>
</Stack>
</Card>
<Title order={2} mb="lg">
Informasi API
</Title>
<Card withBorder padding="md" radius="md" bg="gray.0">
<Stack gap="xs">
<Group>
<Text fw={600} size="sm" w={100}>URL Sumber:</Text>
<Text size="sm" style={{ wordBreak: 'break-all' }}>https://darmasaba.muku.id/api/noc/</Text>
</Group>
<Group>
<Text fw={600} size="sm" w={100}>ID Desa:</Text>
<Text size="sm">desa1</Text>
</Group>
<Group>
<Text fw={600} size="sm" w={100}>Model Data:</Text>
<Badge size="xs" variant="outline">Divisi</Badge>
<Badge size="xs" variant="outline">Kegiatan</Badge>
<Badge size="xs" variant="outline">Event</Badge>
<Badge size="xs" variant="outline">Diskusi</Badge>
</Group>
</Stack>
</Card>
</Box>
);
};
export default SinkronisasiSettings;

View File

@@ -1,44 +1,12 @@
import {
Alert,
Button,
Card,
Group,
Select,
Space,
Switch,
Text,
TextInput,
Title,
useMantineColorScheme,
} from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { Box, Button, Group, Select, Switch, Text, Title } from "@mantine/core";
import { DateInput } from "@mantine/dates";
const UmumSettings = () => {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
return (
<Card
withBorder
radius="md"
p="xl"
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Box pr={"50%"}>
<Title order={2} mb="lg">
Pengaturan Umum
Preferensi Tampilan
</Title>
<Text color="dimmed" mb="xl">
Kelola pengaturan umum aplikasi Anda
</Text>
<Space h="lg" />
<TextInput
label="Nama Aplikasi"
placeholder="Masukkan nama aplikasi"
defaultValue="Dashboard Desa Plus"
mb="md"
/>
<Select
label="Bahasa Aplikasi"
@@ -61,25 +29,53 @@ const UmumSettings = () => {
mb="md"
/>
<Group mb="md">
<Switch label="Notifikasi Email" defaultChecked />
<DateInput label="Format Tanggal" mb={"xl"} />
<Title order={2} mb="lg">
Dashboard
</Title>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Refresh Otomatis
</Text>
<Switch defaultChecked />
</Group>
<Alert
icon={<IconInfoCircle size={16} />}
title="Informasi"
color="blue"
mb="md"
>
Beberapa pengaturan mungkin memerlukan restart aplikasi untuk diterapkan
sepenuhnya.
</Alert>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Interval Refresh
</Text>
<Select
data={[
{ value: "1", label: "30d" },
{ value: "2", label: "60d" },
{ value: "3", label: "90d" },
]}
defaultValue="1"
w={90}
/>
</Group>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Tampilkan Grid
</Text>
<Switch defaultChecked />
</Group>
<Group mb="md" justify="space-between">
<Text fw={"bold"} fz={"sm"}>
Animasi Transisi
</Text>
<Switch defaultChecked />
</Group>
<Group justify="flex-end" mt="xl">
<Button variant="outline">Batal</Button>
<Button>Simpan Perubahan</Button>
</Group>
</Card>
</Box>
);
};

View File

@@ -1,12 +1,10 @@
import {
Badge,
Box,
Collapse,
Group,
Image,
Input,
NavLink as MantineNavLink,
Stack,
Text,
useMantineColorScheme,
} from "@mantine/core";
import { useLocation, useNavigate } from "@tanstack/react-router";
@@ -27,35 +25,30 @@ export function Sidebar({ className }: SidebarProps) {
// State for settings submenu collapse
const [settingsOpen, setSettingsOpen] = useState(
location.pathname.startsWith("/dashboard/pengaturan"),
location.pathname.startsWith("/pengaturan"),
);
// Define menu items with their paths
const menuItems = [
{ name: "Beranda", path: "/dashboard" },
{ name: "Kinerja Divisi", path: "/dashboard/kinerja-divisi" },
{
name: "Pengaduan & Layanan Publik",
path: "/dashboard/pengaduan-layanan-publik",
},
{ name: "Jenna Analytic", path: "/dashboard/jenna-analytic" },
{
name: "Demografi & Kependudukan",
path: "/dashboard/demografi-pekerjaan",
},
{ name: "Keuangan & Anggaran", path: "/dashboard/keuangan-anggaran" },
{ name: "Bumdes & UMKM Desa", path: "/dashboard/bumdes" },
{ name: "Sosial", path: "/dashboard/sosial" },
{ name: "Keamanan", path: "/dashboard/keamanan" },
{ name: "Bantuan", path: "/dashboard/bantuan" },
{ name: "Beranda", path: "/" },
{ name: "Kinerja Divisi", path: "/kinerja-divisi" },
{ name: "Pengaduan & Layanan Publik", path: "/pengaduan-layanan-publik" },
{ name: "Jenna Analytic", path: "/jenna-analytic" },
{ name: "Demografi & Kependudukan", path: "/demografi-pekerjaan" },
{ name: "Keuangan & Anggaran", path: "/keuangan-anggaran" },
{ name: "Bumdes & UMKM Desa", path: "/bumdes" },
{ name: "Sosial", path: "/sosial" },
{ name: "Keamanan", path: "/keamanan" },
{ name: "Bantuan", path: "/bantuan" },
];
// Settings submenu items
const settingsItems = [
{ name: "Umum", path: "/dashboard/pengaturan/umum" },
{ name: "Notifikasi", path: "/dashboard/pengaturan/notifikasi" },
{ name: "Keamanan", path: "/dashboard/pengaturan/keamanan" },
{ name: "Akses & Tim", path: "/dashboard/pengaturan/akses-dan-tim" },
{ name: "Umum", path: "/pengaturan/umum" },
{ name: "Notifikasi", path: "/pengaturan/notifikasi" },
{ name: "Keamanan", path: "/pengaturan/keamanan" },
{ name: "Akses & Tim", path: "/pengaturan/akses-dan-tim" },
{ name: "Sinkronisasi NOC", path: "/pengaturan/sinkronisasi" },
];
// Check if any settings submenu is active
@@ -66,30 +59,7 @@ export function Sidebar({ className }: SidebarProps) {
return (
<Box className={className}>
{/* Logo */}
<Box
p="md"
style={{ borderBottom: "1px solid var(--mantine-color-gray-3)" }}
>
<Group gap="xs">
<Badge
color="dark"
variant="filled"
size="xl"
radius="md"
py="xs"
px="md"
style={{ fontSize: "1.5rem", fontWeight: "bold" }}
>
DESA
</Badge>
<Badge color="green" variant="filled" size="md" radius="md">
+
</Badge>
</Group>
<Text size="xs" c="dimmed" mt="xs">
Digitalisasi Desa Transparansi Kerja
</Text>
</Box>
<Image src={dark ? "/white.png" : "/light-mode.png"} alt="Logo" />
{/* Search */}
<Box p="md">
@@ -108,11 +78,11 @@ export function Sidebar({ className }: SidebarProps) {
{/* Menu Items */}
<Stack gap={0} px="xs" style={{ overflowY: "auto" }}>
{menuItems.map((item, index) => {
{menuItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<MantineNavLink
key={index}
key={item.path}
onClick={() => navigate({ to: item.path })}
label={item.name}
active={isActive}
@@ -174,11 +144,11 @@ export function Sidebar({ className }: SidebarProps) {
ml="lg"
style={{ overflowY: "auto", maxHeight: "200px" }}
>
{settingsItems.map((item, index) => {
{settingsItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<MantineNavLink
key={index}
key={item.path}
onClick={() => navigate({ to: item.path })}
label={item.name}
active={isActive}

View File

@@ -1,463 +1,45 @@
import {
Badge,
Card,
Grid,
GridCol,
Group,
List,
Progress,
Stack,
Text,
ThemeIcon,
Title,
useMantineColorScheme,
} from "@mantine/core";
import {
IconAward,
IconBabyCarriage,
IconBook,
IconCalendarEvent,
IconHeartbeat,
IconMedicalCross,
IconSchool,
IconStethoscope,
} from "@tabler/icons-react";
import { useState } from "react";
import { Grid, GridCol, Stack } from "@mantine/core";
import { Beasiswa } from "./sosial/beasiswa";
import { EventCalendar } from "./sosial/event-calendar";
import { HealthStats } from "./sosial/health-stats";
import { Pendidikan } from "./sosial/pendidikan";
import { PosyanduSchedule } from "./sosial/posyandu-schedule";
import { SummaryCards } from "./sosial/summary-cards";
const SosialPage = () => {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
// Sample data for health statistics
const healthStats = {
ibuHamil: 87,
balita: 342,
alertStunting: 12,
posyanduAktif: 8,
};
// Sample data for health progress
const healthProgress = [
{ label: "Imunisasi Lengkap", value: 92, color: "green" },
{ label: "Pemeriksaan Rutin", value: 88, color: "blue" },
{ label: "Gizi Baik", value: 86, color: "teal" },
{ label: "Target Stunting", value: 14, color: "red" },
];
// Sample data for posyandu schedule
const posyanduSchedule = [
{
nama: "Posyandu Mawar",
tanggal: "Senin, 15 Feb 2026",
jam: "08:00 - 11:00",
},
{
nama: "Posyandu Melati",
tanggal: "Selasa, 16 Feb 2026",
jam: "08:00 - 11:00",
},
{
nama: "Posyandu Dahlia",
tanggal: "Rabu, 17 Feb 2026",
jam: "08:00 - 11:00",
},
{
nama: "Posyandu Anggrek",
tanggal: "Kamis, 18 Feb 2026",
jam: "08:00 - 11:00",
},
];
// Sample data for education stats
const educationStats = {
siswa: {
tk: 125,
sd: 480,
smp: 210,
sma: 150,
},
sekolah: {
jumlah: 8,
guru: 42,
},
};
// Sample data for scholarships
const scholarshipData = {
penerima: 45,
dana: "Rp 1.200.000.000",
tahunAjaran: "2025/2026",
};
// Sample data for cultural events
const culturalEvents = [
{
nama: "Hari Kesaktian Pancasila",
tanggal: "1 Oktober 2025",
lokasi: "Balai Desa",
},
{
nama: "Festival Budaya Desa",
tanggal: "20 Mei 2026",
lokasi: "Lapangan Desa",
},
{
nama: "Perayaan HUT Desa",
tanggal: "17 Agustus 2026",
lokasi: "Balai Desa",
},
];
return (
<Stack gap="lg">
{/* Health Statistics Cards */}
{/* Top Summary Cards - 4 Grid */}
<SummaryCards />
{/* Second Row - 2 Column Grid */}
<Grid gutter="md">
<GridCol span={{ base: 12, sm: 6, md: 3 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Group justify="space-between" align="center">
<Stack gap={0}>
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
Ibu Hamil Aktif
</Text>
<Text size="xl" fw={700} c={dark ? "dark.0" : "black"}>
{healthStats.ibuHamil}
</Text>
</Stack>
<ThemeIcon
variant="light"
color="darmasaba-blue"
size="xl"
radius="xl"
>
<IconHeartbeat size={24} />
</ThemeIcon>
</Group>
</Card>
{/* Left - Statistik Kesehatan */}
<GridCol span={{ base: 12, lg: 6 }}>
<HealthStats />
</GridCol>
<GridCol span={{ base: 12, sm: 6, md: 3 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Group justify="space-between" align="center">
<Stack gap={0}>
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
Balita Terdaftar
</Text>
<Text size="xl" fw={700} c={dark ? "dark.0" : "black"}>
{healthStats.balita}
</Text>
</Stack>
<ThemeIcon
variant="light"
color="darmasaba-success"
size="xl"
radius="xl"
>
<IconBabyCarriage size={24} />
</ThemeIcon>
</Group>
</Card>
</GridCol>
<GridCol span={{ base: 12, sm: 6, md: 3 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Group justify="space-between" align="center">
<Stack gap={0}>
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
Alert Stunting
</Text>
<Text size="xl" fw={700} c="red">
{healthStats.alertStunting}
</Text>
</Stack>
<ThemeIcon variant="light" color="red" size="xl" radius="xl">
<IconStethoscope size={24} />
</ThemeIcon>
</Group>
</Card>
</GridCol>
<GridCol span={{ base: 12, sm: 6, md: 3 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Group justify="space-between" align="center">
<Stack gap={0}>
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
Posyandu Aktif
</Text>
<Text size="xl" fw={700} c={dark ? "dark.0" : "black"}>
{healthStats.posyanduAktif}
</Text>
</Stack>
<ThemeIcon
variant="light"
color="darmasaba-warning"
size="xl"
radius="xl"
>
<IconMedicalCross size={24} />
</ThemeIcon>
</Group>
</Card>
{/* Right - Jadwal Posyandu */}
<GridCol span={{ base: 12, lg: 6 }}>
<PosyanduSchedule />
</GridCol>
</Grid>
{/* Health Progress Bars */}
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Title order={3} mb="md" c={dark ? "dark.0" : "black"}>
Statistik Kesehatan
</Title>
<Stack gap="md">
{healthProgress.map((item, index) => (
<div key={index}>
<Group justify="space-between" mb={5}>
<Text size="sm" fw={500} c={dark ? "dark.0" : "black"}>
{item.label}
</Text>
<Text size="sm" fw={600} c={dark ? "dark.0" : "black"}>
{item.value}%
</Text>
</Group>
<Progress
value={item.value}
size="lg"
radius="xl"
color={item.color}
/>
</div>
))}
</Stack>
</Card>
{/* Third Row - 2 Column Grid */}
<Grid gutter="md">
{/* Jadwal Posyandu */}
{/* Left - Pendidikan */}
<GridCol span={{ base: 12, lg: 6 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
>
<Title order={3} mb="md" c={dark ? "dark.0" : "black"}>
Jadwal Posyandu
</Title>
<Stack gap="sm">
{posyanduSchedule.map((item, index) => (
<Card
key={index}
p="md"
radius="md"
withBorder
bg={dark ? "#263852ff" : "#F1F5F9"}
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
h="100%"
>
<Group justify="space-between">
<Stack gap={0}>
<Text fw={500} c={dark ? "dark.0" : "black"}>
{item.nama}
</Text>
<Text size="sm" c={dark ? "dark.0" : "black"}>
{item.tanggal}
</Text>
</Stack>
<Badge variant="light" color="darmasaba-blue">
{item.jam}
</Badge>
</Group>
</Card>
))}
</Stack>
</Card>
<Pendidikan />
</GridCol>
{/* Pendidikan */}
{/* Right - Beasiswa Desa */}
<GridCol span={{ base: 12, lg: 6 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
h="100%"
>
<Title order={3} mb="md" c={dark ? "dark.0" : "black"}>
Pendidikan
</Title>
<Stack gap="md">
<Group justify="space-between">
<Text fw={500} c={dark ? "dark.0" : "black"}>
TK / PAUD
</Text>
<Text fw={700} c={dark ? "dark.0" : "black"}>
{educationStats.siswa.tk}
</Text>
</Group>
<Group justify="space-between">
<Text fw={500} c={dark ? "dark.0" : "black"}>
SD
</Text>
<Text fw={700} c={dark ? "dark.0" : "black"}>
{educationStats.siswa.sd}
</Text>
</Group>
<Group justify="space-between">
<Text fw={500} c={dark ? "dark.0" : "black"}>
SMP
</Text>
<Text fw={700} c={dark ? "dark.0" : "black"}>
{educationStats.siswa.smp}
</Text>
</Group>
<Group justify="space-between">
<Text fw={500} c={dark ? "dark.0" : "black"}>
SMA
</Text>
<Text fw={700} c={dark ? "dark.0" : "black"}>
{educationStats.siswa.sma}
</Text>
</Group>
<Card
withBorder
radius="md"
p="md"
mt="md"
bg={dark ? "#263852ff" : "#F1F5F9"}
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
>
<Group justify="space-between">
<Text fw={500} c={dark ? "dark.0" : "black"}>
Jumlah Lembaga Pendidikan
</Text>
<Text fw={700} c={dark ? "dark.0" : "black"}>
{educationStats.sekolah.jumlah}
</Text>
</Group>
<Group justify="space-between" mt="sm">
<Text fw={500} c={dark ? "dark.0" : "black"}>
Jumlah Tenaga Pengajar
</Text>
<Text fw={700} c={dark ? "dark.0" : "black"}>
{educationStats.sekolah.guru}
</Text>
</Group>
</Card>
</Stack>
</Card>
<Beasiswa />
</GridCol>
</Grid>
<Grid gutter="md">
{/* Beasiswa Desa */}
<GridCol span={{ base: 12, lg: 6 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
h="100%"
>
<Group justify="space-between" align="center">
<Stack gap={0}>
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
Beasiswa Desa
</Text>
<Text size="xl" fw={700} c={dark ? "dark.0" : "black"}>
Penerima: {scholarshipData.penerima}
</Text>
</Stack>
<ThemeIcon
variant="light"
color="darmasaba-success"
size="xl"
radius="xl"
>
<IconAward size={24} />
</ThemeIcon>
</Group>
<Text mt="md" c={dark ? "dark.0" : "black"}>
Dana Tersalurkan:{" "}
<Text span fw={700}>
{scholarshipData.dana}
</Text>
</Text>
<Text mt="sm" c={dark ? "dark.3" : "dimmed"}>
Tahun Ajaran: {scholarshipData.tahunAjaran}
</Text>
</Card>
</GridCol>
{/* Kalender Event Budaya */}
<GridCol span={{ base: 12, lg: 6 }}>
<Card
p="md"
radius="md"
withBorder
bg={dark ? "#141D34" : "white"}
style={{ borderColor: dark ? "#141D34" : "white" }}
h="100%"
>
<Title order={3} mb="md" c={dark ? "dark.0" : "black"}>
Kalender Event Budaya
</Title>
<List spacing="sm">
{culturalEvents.map((event, index) => (
<List.Item
key={index}
icon={
<ThemeIcon color="darmasaba-blue" size={24} radius="xl">
<IconCalendarEvent size={12} />
</ThemeIcon>
}
>
<Group justify="space-between">
<Text fw={500} c={dark ? "dark.0" : "black"}>
{event.nama}
</Text>
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
{event.lokasi}
</Text>
</Group>
<Text size="sm" c={dark ? "dark.3" : "dimmed"}>
{event.tanggal}
</Text>
</List.Item>
))}
</List>
</Card>
</GridCol>
</Grid>
{/* Bottom Section - Event Budaya */}
<EventCalendar />
</Stack>
);
};

View File

@@ -0,0 +1,79 @@
import {
Card,
Group,
Stack,
Text,
ThemeIcon,
useMantineColorScheme,
} from "@mantine/core";
import { IconAward } from "@tabler/icons-react";
interface ScholarshipData {
penerima: number;
dana: string;
tahunAjaran: string;
}
interface BeasiswaProps {
data?: ScholarshipData;
}
export const Beasiswa = ({ data }: BeasiswaProps) => {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const defaultData: ScholarshipData = {
penerima: 45,
dana: "Rp 1.200.000.000",
tahunAjaran: "2025/2026",
};
const displayData = data || defaultData;
return (
<Card
p="md"
radius="xl"
withBorder
shadow="sm"
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
h={"100%"}
>
<Group justify="space-between" align="center">
<Stack gap={2}>
<Text size="sm" c={dark ? "white" : "dimmed"} fw={500}>
Beasiswa Desa
</Text>
<Text size="xl" fw={700} c={dark ? "white" : "#1e3a5f"}>
Penerima: {displayData.penerima}
</Text>
</Stack>
<ThemeIcon
variant="light"
color="darmasaba-success"
size="xl"
radius="xl"
>
<IconAward size={24} />
</ThemeIcon>
</Group>
<Stack gap="xs" mt="md">
<Group justify="space-between">
<Text c={dark ? "white" : "dimmed"}>Dana Tersalurkan:</Text>
<Text fw={700} c={dark ? "white" : "#1e3a5f"}>
{displayData.dana}
</Text>
</Group>
<Group justify="space-between">
<Text c={dark ? "white" : "dimmed"}>Tahun Ajaran:</Text>
<Text c={dark ? "white" : "#1e3a5f"}>{displayData.tahunAjaran}</Text>
</Group>
</Stack>
</Card>
);
};

View File

@@ -0,0 +1,104 @@
import {
Card,
Group,
Stack,
Text,
ThemeIcon,
Title,
useMantineColorScheme,
} from "@mantine/core";
import { IconCalendarEvent } from "@tabler/icons-react";
interface EventItem {
id: string;
nama: string;
tanggal: string;
lokasi: string;
}
interface EventCalendarProps {
data?: EventItem[];
}
export const EventCalendar = ({ data }: EventCalendarProps) => {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const defaultData: EventItem[] = [
{
id: "1",
nama: "Hari Kesaktian Pancasila",
tanggal: "1 Oktober 2025",
lokasi: "Balai Desa",
},
{
id: "2",
nama: "Festival Budaya Desa",
tanggal: "20 Mei 2026",
lokasi: "Lapangan Desa",
},
{
id: "3",
nama: "Perayaan HUT Desa",
tanggal: "17 Agustus 2026",
lokasi: "Balai Desa",
},
];
const displayData = data || defaultData;
return (
<Card
p="md"
radius="xl"
withBorder
shadow="sm"
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
>
<Title order={3} mb="md" c={dark ? "dark.0" : "#1e3a5f"}>
Kalender Event Budaya
</Title>
<Stack gap="sm">
{displayData.map((event) => (
<Card
key={event.id}
p="md"
radius="md"
withBorder
bg={dark ? "#263852ff" : "#F1F5F9"}
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
>
<Group justify="space-between" mb="xs">
<Group gap="sm" align="center">
<ThemeIcon
color="darmasaba-blue"
size="md"
radius="xl"
variant="light"
>
<IconCalendarEvent size={16} />
</ThemeIcon>
<Text fw={600} c={dark ? "dark.0" : "#1e3a5f"}>
{event.nama}
</Text>
</Group>
<Text size="sm" c={dark ? "dark.3" : "dimmed"} fw={500}>
{event.lokasi}
</Text>
</Group>
<Group pl={36}>
<Text size="sm" c={dark ? "white" : "gray.6"}>
{event.tanggal}
</Text>
</Group>
</Card>
))}
</Stack>
</Card>
);
};

View File

@@ -0,0 +1,77 @@
import {
Card,
Group,
Progress,
Stack,
Text,
Title,
useMantineColorScheme,
} from "@mantine/core";
interface HealthProgressItem {
label: string;
value: number;
color: string;
}
interface HealthStatsProps {
data?: HealthProgressItem[];
}
export const HealthStats = ({ data }: HealthStatsProps) => {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const defaultData: HealthProgressItem[] = [
{ label: "Imunisasi Lengkap", value: 92, color: "green" },
{ label: "Pemeriksaan Rutin", value: 88, color: "blue" },
{ label: "Gizi Baik", value: 86, color: "teal" },
{ label: "Target Stunting", value: 14, color: "red" },
];
const displayData = data || defaultData;
return (
<Card
p="md"
radius="xl"
withBorder
shadow="sm"
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
h={"100%"}
>
<Title order={3} mb="md" c={dark ? "dark.0" : "#1e3a5f"}>
Statistik Kesehatan
</Title>
<Stack gap="md">
{displayData.map((item) => (
<div key={item.label}>
<Group justify="space-between" mb={5}>
<Text size="sm" fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
{item.label}
</Text>
<Text
size="sm"
fw={600}
c={item.color === "red" ? "red" : dark ? "dark.0" : "#1e3a5f"}
>
{item.value}%
</Text>
</Group>
<Progress
value={item.value}
size="lg"
radius="xl"
color={item.color}
/>
</div>
))}
</Stack>
</Card>
);
};

View File

@@ -0,0 +1,124 @@
import {
Card,
Group,
Stack,
Text,
Title,
useMantineColorScheme,
} from "@mantine/core";
interface EducationData {
siswa: {
tk: number;
sd: number;
smp: number;
sma: number;
};
sekolah: {
jumlah: number;
guru: number;
};
}
interface PendidikanProps {
data?: EducationData;
}
export const Pendidikan = ({ data }: PendidikanProps) => {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const defaultData: EducationData = {
siswa: {
tk: 125,
sd: 480,
smp: 210,
sma: 150,
},
sekolah: {
jumlah: 8,
guru: 42,
},
};
const displayData = data || defaultData;
return (
<Card
p="md"
radius="xl"
withBorder
shadow="sm"
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
>
<Title order={3} mb="md" c={dark ? "dark.0" : "#1e3a5f"}>
Pendidikan
</Title>
<Stack gap="md">
<Group justify="space-between">
<Text fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
TK / PAUD
</Text>
<Text fw={700} c={dark ? "dark.0" : "#1e3a5f"}>
{displayData.siswa.tk}
</Text>
</Group>
<Group justify="space-between">
<Text fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
SD
</Text>
<Text fw={700} c={dark ? "dark.0" : "#1e3a5f"}>
{displayData.siswa.sd}
</Text>
</Group>
<Group justify="space-between">
<Text fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
SMP
</Text>
<Text fw={700} c={dark ? "dark.0" : "#1e3a5f"}>
{displayData.siswa.smp}
</Text>
</Group>
<Group justify="space-between">
<Text fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
SMA
</Text>
<Text fw={700} c={dark ? "dark.0" : "#1e3a5f"}>
{displayData.siswa.sma}
</Text>
</Group>
<Card
withBorder
radius="md"
p="md"
mt="md"
bg={dark ? "#263852ff" : "#F1F5F9"}
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
>
<Group justify="space-between">
<Text fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
Jumlah Lembaga Pendidikan
</Text>
<Text fw={700} c={dark ? "dark.0" : "#1e3a5f"}>
{displayData.sekolah.jumlah}
</Text>
</Group>
<Group justify="space-between" mt="sm">
<Text fw={500} c={dark ? "dark.0" : "#1e3a5f"}>
Jumlah Tenaga Pengajar
</Text>
<Text fw={700} c={dark ? "dark.0" : "#1e3a5f"}>
{displayData.sekolah.guru}
</Text>
</Group>
</Card>
</Stack>
</Card>
);
};

View File

@@ -0,0 +1,99 @@
import {
Badge,
Card,
Group,
Stack,
Text,
Title,
useMantineColorScheme,
} from "@mantine/core";
interface PosyanduItem {
id: string;
nama: string;
tanggal: string;
jam: string;
}
interface PosyanduScheduleProps {
data?: PosyanduItem[];
}
export const PosyanduSchedule = ({ data }: PosyanduScheduleProps) => {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const defaultData: PosyanduItem[] = [
{
id: "1",
nama: "Posyandu Mawar",
tanggal: "Senin, 15 Feb 2026",
jam: "08:00 - 11:00",
},
{
id: "2",
nama: "Posyandu Melati",
tanggal: "Selasa, 16 Feb 2026",
jam: "08:00 - 11:00",
},
{
id: "3",
nama: "Posyandu Dahlia",
tanggal: "Rabu, 17 Feb 2026",
jam: "08:00 - 11:00",
},
{
id: "4",
nama: "Posyandu Anggrek",
tanggal: "Kamis, 18 Feb 2026",
jam: "08:00 - 11:00",
},
];
const displayData = data || defaultData;
return (
<Card
p="md"
radius="xl"
withBorder
shadow="sm"
bg={dark ? "#1E293B" : "white"}
style={{
borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
>
<Title order={3} mb="md" c={dark ? "dark.0" : "#1e3a5f"}>
Jadwal Posyandu
</Title>
<Stack gap="sm">
{displayData.map((item) => (
<Card
key={item.id}
p="md"
radius="md"
withBorder
bg={dark ? "#263852ff" : "#F1F5F9"}
style={{ borderColor: dark ? "#263852ff" : "#F1F5F9" }}
>
<Group justify="space-between">
<Stack gap={0}>
<Text fw={600} c={dark ? "white" : "#1e3a5f"}>
{item.nama}
</Text>
<Text size="sm" c={dark ? "white" : "dimmed"}>
{item.tanggal}
</Text>
</Stack>
<Badge variant="light" color="darmasaba-blue" size="md">
{item.jam}
</Badge>
</Group>
</Card>
))}
</Stack>
</Card>
);
};

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