Compare commits

...

26 Commits

Author SHA1 Message Date
5e822f0b05 feat: implement Kependudukan menu with CRUD admin pages
- Add Distribusi Umur admin pages (list, create, edit)
- Add Data Banjar admin pages (list, create, edit)
- Add Migrasi Penduduk admin pages (list, create, edit)
- Update state management with full CRUD operations for all modules
- Add Kependudukan menu to admin sidebar (devBar, navBar, role1)
- Add public pages for Distribusi Umur with age range sorting
- Update Dinamika Penduduk to use real-time birth/death data
- Add Biome configuration for code linting
- Create API routes for all Kependudukan modules

Features:
- Pagination and search for all admin list pages
- Responsive design (table for desktop, cards for mobile)
- Delete confirmation modal
- Toast notifications for user feedback
- Zod validation for all forms
- Age range auto-sorting in public Distribusi Umur chart

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-09 17:10:29 +08:00
34a37dc63b chore(dev-dependency): add playwright-mcp package for testing
- Add playwright-mcp v0.0.19 as dev dependency
- Update bun.lock with new dependency tree
- Add .qwen configuration files

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-09 11:33:19 +08:00
0e6f7e1769 fix(landing-page): remove incorrect national holiday entry for April 9, 2026
- Remove '2026-04-09' from national holidays list as it's not an actual holiday
- Fix office status showing 'Tutup' and 'Tidak Beroperasi' incorrectly on regular workday
- Office now correctly shows 'Buka' status during working hours (07:30-15:30 on Thursday)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-09 10:49:10 +08:00
feb853d06e fix(auth): improve OTP error handling and add health check endpoint
Option 2 - Improve Error Handling:
- Track WA success status and error messages in login route
- Return debug info (including OTP code) only in non-production
- Show descriptive message when WhatsApp fails to send
- Better error categorization (HTTP error vs logic error vs connection)

Option 3 - Health Check Endpoint:
- Create /api/health/otp endpoint for OTP service diagnostics
- Support test mode with query params: ?test=true&number=6281234567890
- Check token configuration (configured vs placeholder)
- Measure response time and validate service response
- Return comprehensive status for debugging OTP issues

Usage:
- Basic check: GET /api/health/otp
- Test send: GET /api/health/otp?test=true&number=6281234567890

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-06 16:27:29 +08:00
3de412afe0 fix(dockerfile): remove duplicate prisma copy in runner stage
- Remove unnecessary COPY of src/prisma as prisma is already copied
- Simplify Dockerfile by avoiding redundant file copies

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-06 15:05:03 +08:00
87d234e57f fix(dockerfile): optimize build and improve security
- Add blank line before COPY for readability
- Add PORT and HOSTNAME env vars in runner stage
- Use --chown flag instead of separate chown RUN layer
- Copy only src/prisma instead of entire src directory
- Use glob pattern for next.config.* files
- Move PORT and HOSTNAME before EXPOSE
- Add newline at end of file

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-06 14:48:14 +08:00
fd18a22834 merge: resolve Dockerfile conflicts between stg and deploy/stg
- Keep optimized multi-stage build from stg
- Add gen:api step from deploy/stg
- Maintain security best practices (non-root user, minimal deps)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-06 14:33:30 +08:00
3e8b961e52 chore: remove .env from git tracking and simplify .gitignore rules
- Remove .env file from git tracking (was accidentally committed)
- Simplify .gitignore to use .env* pattern instead of multiple specific patterns
- Update Dockerfile for optimized multi-stage build
- Add gen:api script placeholder to package.json

Security: Ensure sensitive environment variables are not exposed in repository

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-06 14:30:38 +08:00
82d779e5e0 Update Dockerfile 2026-04-06 14:00:17 +08:00
a6517166cb Merge pull request #19 from bipprojectbali/revert-11-tasks/fix-docker-build/optimize-config-and-eslint-ignore/2026-04-02-15-45
Revert 11 tasks/fix docker build/optimize config and eslint ignore/2026 04 02 15 45
2026-04-06 12:27:17 +08:00
483b6be677 Revert "fix(build): ignore ESLint and TypeScript errors during build for CI/CD" 2026-04-06 12:23:07 +08:00
f8dad0dbcd Merge pull request #17 from bipprojectbali/stg
Stg
2026-04-06 12:14:49 +08:00
74301fe074 Merge pull request #16 from bipprojectbali/tasks/prisma/regenerate-client-fix-types/06-04-2026-1500
Tasks/prisma/regenerate client fix types/06 04 2026 1500
2026-04-06 12:14:23 +08:00
8b19abc628 fix(prisma): regenerate Prisma client to resolve TypeScript type errors
- Regenerate Prisma client to fix missing GetPayload types
- Resolve RespondenGetPayload, JenisKelaminRespondenGetPayload errors
- Resolve PilihanRatingRespondenGetPayload and UmurRespondenGetPayload errors
- Add initial migration files
- Update bun lockfile

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-06 12:08:49 +08:00
1a91f3c9ad Merge pull request #13 from bipprojectbali/stg
Stg
2026-04-06 11:34:48 +08:00
9b74592101 Merge branch 'main' into stg 2026-04-06 11:34:38 +08:00
55f4b94082 Merge pull request #12 from bipprojectbali/tasks/auth/implement-otp-whatsapp-function/06-04-2026-1430
feat(auth): implement WhatsApp OTP sending function for login
2026-04-06 11:31:30 +08:00
59ae8ad039 Merge pull request #11 from bipprojectbali/tasks/fix-docker-build/optimize-config-and-eslint-ignore/2026-04-02-15-45
fix(build): ignore ESLint and TypeScript errors during build for CI/CD
2026-04-02 16:20:32 +08:00
c012d5778c fix(build): ignore ESLint and TypeScript errors during build for CI/CD 2026-04-02 16:16:17 +08:00
af31bd8aef Merge pull request #10 from bipprojectbali/nico/2-apr-26/default-docker-setting
Ganti ke settingan awal Docker
2026-04-02 15:55:01 +08:00
721357adcf Ganti ke settingan awal Docker 2026-04-02 15:54:27 +08:00
39776ec355 Merge pull request #9 from bipprojectbali/tasks/fix-build-and-lint/resolve-errors-in-auth-and-apbdes/2026-04-02-15-15
fix(apbdes): remove redundant eslint-disable and improve type safety …
2026-04-02 13:03:29 +08:00
50a7356618 fix(apbdes): remove redundant eslint-disable and improve type safety in GrafikRealisasi 2026-04-02 12:47:28 +08:00
4494dd98ef Merge pull request #8 from bipprojectbali/tasks/fix-docker-build/optimize-config-and-prisma-handlers/02-04-2026-15-00
Tasks/fix docker build/optimize config and prisma handlers/02 04 2026 15 00
2026-04-02 11:26:13 +08:00
970949a68b fix: resolve Docker build failure by optimizing configuration and prisma signal handling
- Added .dockerignore to prevent build poisoning from local artifacts.
- Updated Dockerfile with stable Bun version, memory limits, and missing config files.
- Refined prisma.ts signal handlers to avoid process termination during Next.js build phases.
- Synchronized eslint-config-next with Next.js version.
2026-04-02 11:24:49 +08:00
42bcba6c96 Merge pull request #7 from bipprojectbali/stg
Stg
2026-04-01 17:10:09 +08:00
68 changed files with 9441 additions and 96 deletions

47
.dockerignore Normal file
View File

@@ -0,0 +1,47 @@
node_modules
.next
.git
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
bun-debug.log*
# Docker files
Dockerfile
.dockerignore
# OS files
.DS_Store
Thumbs.db
# Markdown/Documentation
README.md
GEMINI.md
AGENTS.md
AUDIT_REPORT.md
QWEN.md
NOTE.md
task-project-apbdes.md
MUSIK_CREATE_ANALYSIS.md
darkMode.md
/test-results
/playwright-report
/tmp_assets
/foldergambar
/googleapi
/xx
/xx.ts
/xx.txt
/test.txt
/x.json
/x.sh
/xcoba.ts
/xcoba2.ts
/gambar.ttx
/test-berita-state.ts

19
.env
View File

@@ -1,19 +0,0 @@
DATABASE_URL="postgresql://bip:Production_123@localhost:5433/desa-darmasaba-v0.0.1?schema=public"
# Seafile
SEAFILE_TOKEN=20a19f4a04032215d50ce53292e6abdd38b9f806
SEAFILE_REPO_ID=f0e9ee4a-fd13-49a2-81c0-f253951d063a
SEAFILE_URL=https://cld-dkr-makuro-seafile.wibudev.com
SEAFILE_PUBLIC_SHARE_TOKEN=3a9a9ecb5e244f4da8ae
# Upload
WIBU_UPLOAD_DIR=uploads
WIBU_DOWNLOAD_DIR="./download"
NEXT_PUBLIC_BASE_URL="http://localhost:3000"
EMAIL_USER=nicoarya20@gmail.com
EMAIL_PASS=hymmfpcaqzqkfgbh
BASE_SESSION_KEY=kp9sGx91as0Kj2Ls81nAsl2Kdj13KsxP
BASE_TOKEN_KEY=Qm82JsA92lMnKw0291mxKaaP02KjslaA
# BOT-TELE
BOT_TOKEN=8479423145:AAE9ArrOgTD3DyVxYSVs3IXN40u_sL6c9sw
CHAT_ID=-1003368982298

5
.gitignore vendored
View File

@@ -30,10 +30,7 @@ yarn-error.log*
# env
# env local files (keep .env.example)
.env.local
.env*.local
.env.production
.env.development
.env*
!.env.example
# QC

13
.qwen/settings.json Normal file
View File

@@ -0,0 +1,13 @@
{
"mcpServers": {
"playwright-mcp": {
"command": "npx",
"args": [
"-y",
"playwright-mcp@latest"
],
"timeout": 60000
}
},
"$version": 3
}

9
.qwen/settings.json.orig Normal file
View File

@@ -0,0 +1,9 @@
{
"mcpServers": {
"playwright-mcp": {
"command": "npx",
"args": ["-y", "playwright-mcp@latest"],
"timeout": 60000
}
}
}

191
DEV-INSPECTOR-ANALYSIS.md Normal file
View File

@@ -0,0 +1,191 @@
# Dev Inspector - Analisis & Rekomendasi untuk Project Desa Darmasaba
## 📋 Ringkasan Analisis
Dokumen `dev-inspector-click-to-source.md` **TIDAK dapat diterapkan langsung** ke project ini karena perbedaan arsitektur fundamental.
## 🔍 Perbedaan Arsitektur
| Syarat di Dokumen | Project Desa Darmasaba | Status |
|-------------------|------------------------|--------|
| **Vite sebagai bundler** | Next.js 15 (Webpack/Turbopack) | ❌ Tidak kompatibel |
| **Elysia + Vite middlewareMode** | Next.js App Router + Elysia sebagai API handler | ❌ Berbeda |
| **React** | ✅ React 19 | ✅ Kompatibel |
| **Bun runtime** | ✅ Bun | ✅ Kompatibel |
## ✅ Solusi: Next.js Sudah Punya Built-in Click-to-Source
Next.js memiliki fitur **click-to-source bawaan** yang bekerja tanpa setup tambahan:
### Cara Menggunakan
1. **Pastikan dalam development mode:**
```bash
bun run dev
```
2. **Klik elemen dengan modifier key:**
- **macOS**: `Option` + `Click` (atau `` + `Click`)
- **Windows/Linux**: `Alt` + `Click`
3. **File akan terbuka di editor** pada baris dan kolom yang tepat
### Syarat Agar Berfungsi
1. **Editor harus ada di PATH**
VS Code biasanya sudah terdaftar. Jika menggunakan editor lain, set:
```bash
# Untuk Cursor
export EDITOR=cursor
# Untuk Windsurf
export EDITOR=windsurf
# Untuk Sublime Text
export EDITOR=subl
```
2. **Hanya berfungsi di development mode**
- Fitur ini otomatis tree-shaken di production
- Zero overhead di production build
3. **Browser DevTools harus terbuka** (beberapa browser memerlukan ini)
## 🎯 Rekomendasi untuk Project Ini
### Opsi 1: Gunakan Built-in Next.js (DIREKOMENDASIKAN)
**Kelebihan:**
- ✅ Zero setup
- ✅ Maintain oleh Vercel
- ✅ Otomatis compatible dengan Next.js updates
- ✅ Zero production overhead
**Kekurangan:**
- ⚠️ Hotkey berbeda (`Option+Click` vs `Ctrl+Shift+Cmd+C`)
- ⚠️ Tidak ada visual overlay/tooltip seperti di dokumen
**Cara:**
Tidak perlu melakukan apapun - fitur sudah aktif saat `bun run dev`.
### Opsi 2: Custom Implementation (JIKA DIPERLUKAN)
Jika ingin visual overlay dan tooltip seperti di dokumen, bisa dibuat custom component dengan pendekatan berbeda:
#### Arsitektur Alternatif untuk Next.js
```
BUILD TIME (Next.js/Webpack):
.tsx/.jsx file
→ [Custom Webpack Loader] inject data-inspector-* attributes
→ [Next.js internal transform] JSX to React.createElement
→ Browser menerima elemen dengan attributes
RUNTIME (Browser):
[SAMA seperti dokumen - DevInspector component]
BACKEND (Next.js API Route):
/__open-in-editor → Bun.spawn([editor, '--goto', 'file:line:col'])
```
#### Komponen yang Dibutuhkan:
1. **Custom Webpack Loader** (bukan Vite Plugin)
- Inject attributes via webpack transform
- Taruh di `next.config.ts` webpack config
2. **DevInspector Component** (sama seperti dokumen)
- Browser runtime untuk handle hotkey & klik
3. **API Route `/__open-in-editor`**
- Buat sebagai Next.js API route: `src/app/api/__open-in-editor/route.ts`
- HARUS bypass auth middleware
4. **Conditional Import** (sama seperti dokumen)
```tsx
const InspectorWrapper = process.env.NODE_ENV === 'development'
? (await import('./DevInspector')).DevInspector
: ({ children }) => <>{children}</>
```
#### Implementasi Steps:
Jika Anda ingin melanjutkan dengan custom implementation, berikut steps:
1. ✅ Buat `src/components/DevInspector.tsx` (copy dari dokumen)
2. ⚠️ Buat webpack loader untuk inject attributes (perlu research)
3. ✅ Buat API route `src/app/api/__open-in-editor/route.ts`
4. ✅ Wrap root layout dengan DevInspector
5. ✅ Set `REACT_EDITOR` di `.env`
**Peringatan:**
- Webpack loader lebih kompleks daripada Vite plugin
- Mungkin ada edge cases dengan Next.js internals
- Perlu maintenance ekstra saat Next.js update
## 📊 Perbandingan
| Fitur | Built-in Next.js | Custom Implementation |
|-------|------------------|----------------------|
| Setup | ✅ Zero | ⚠️ Medium |
| Visual Overlay | ❌ Tidak ada | ✅ Ada |
| Tooltip | ❌ Tidak ada | ✅ Ada |
| Hotkey | `Option+Click` | Custom (bisa disesuaikan) |
| Maintenance | ✅ Vercel | ⚠️ Manual |
| Compatibility | ✅ Guaranteed | ⚠️ Perlu testing |
| Production Impact | ✅ Zero | ✅ Zero (dengan conditional import) |
## 🎯 Kesimpulan
**Rekomendasi: Gunakan Built-in Next.js**
Alasan:
1. ✅ Sudah tersedia - tidak perlu setup
2. ✅ Lebih stabil - maintain oleh Vercel
3. ✅ Lebih simple - tidak ada custom code
4. ✅ Future-proof - otomatis update dengan Next.js
**Custom implementation hanya diperlukan jika:**
- Anda sangat membutuhkan visual overlay & tooltip
- Anda ingin hotkey yang sama persis (`Ctrl+Shift+Cmd+C`)
- Anda punya waktu untuk maintenance
## 🚀 Quick Start - Built-in Feature
Untuk menggunakan click-to-source bawaan Next.js:
1. Jalankan development server:
```bash
bun run dev
```
2. Buka browser ke `http://localhost:3000`
3. Tahan `Option` (macOS) atau `Alt` (Windows/Linux)
4. Cursor akan berubah menjadi crosshair
5. Klik elemen mana pun - file akan terbuka di editor
6. **Opsional**: Set editor di `.env`:
```env
# .env.local
EDITOR=code # atau cursor, windsurf, subl
```
## 📝 Notes
- Fitur ini hanya aktif di development mode (`NODE_ENV=development`)
- Production build (`bun run build`) otomatis menghilangkan fitur ini
- Next.js menggunakan mekanisme yang mirip (source mapping) untuk menentukan lokasi component
- Jika editor tidak terbuka, pastikan:
- Editor sudah terinstall dan ada di PATH
- Browser DevTools terbuka (beberapa browser require ini)
- Anda menggunakan development server, bukan production
## 🔗 Referensi
- [Next.js Documentation - Launching Editor](https://nextjs.org/docs/app/api-reference/config/next-config-js/reactStrictMode)
- [React DevTools - Component Inspection](https://react.dev/learn/react-developer-tools)
- [Original Dev Inspector Document](./dev-inspector-click-to-source.md)

View File

@@ -1,60 +1,67 @@
# Stage 1: Build
FROM oven/bun:1.3 AS build
# ==============================
# Stage 1: Builder
# ==============================
FROM oven/bun:1-debian AS builder
# 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* ./
RUN apt-get update && apt-get install -y --no-install-recommends \
libc6 \
git \
openssl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package.json bun.lockb* ./
ENV SHARP_IGNORE_GLOBAL_LIBVIPS=1
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_OPTIONS="--max-old-space-size=4096"
# 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
RUN cp .env.example .env || true
# Generate Prisma client
RUN bun x prisma generate
ENV PRISMA_CLI_BINARY_TARGETS=debian-openssl-3.0.x
RUN bunx prisma generate
# Generate API types (opsional)
RUN bun run gen:api || echo "tidak ada gen api"
# Build the application frontend
ENV NODE_ENV=production
RUN bun run build
# Stage 2: Runtime
FROM oven/bun:1.3-slim AS runtime
# ==============================
# Stage 2: Runner (Production)
# ==============================
FROM oven/bun:1-debian AS runner
# 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/.next ./.next
COPY --from=build /app/public ./public
COPY --from=build /app/src ./src
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/prisma ./prisma
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PRISMA_CLI_BINARY_TARGETS=debian-openssl-3.0.x
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
RUN apt-get update && apt-get install -y --no-install-recommends \
openssl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs nextjs
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/next.config.* ./
USER nextjs
# Expose the port
EXPOSE 3000
# Start the application
CMD ["bun", "start"]

49
biome.json Normal file
View File

@@ -0,0 +1,49 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"experimentalScannerIgnores": [
"node_modules",
".next",
"out",
"public"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedVariables": "warn",
"noUnusedImports": "warn"
},
"suspicious": {
"noExplicitAny": "warn"
},
"style": {
"noNonNullAssertion": "warn"
},
"complexity": {
"noForEach": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "all",
"semicolons": "always"
}
}
}

2302
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,553 @@
# Skill: Dev Inspector — Click-to-Source untuk Bun + Elysia + Vite + React
## Ringkasan
Fitur development: klik elemen UI di browser → langsung buka source code di editor (VS Code, Cursor, dll) pada baris dan kolom yang tepat. Zero overhead di production.
**Hotkey**: `Ctrl+Shift+Cmd+C` (macOS) / `Ctrl+Shift+Alt+C` → aktifkan mode inspect → klik elemen → file terbuka.
## Kenapa Tidak Pakai Library
`react-dev-inspector` crash di React 19 karena:
- `fiber.return.child.sibling` bisa null di React 19
- `_debugSource` dihapus dari React 19
- Walking fiber tree tidak stabil antar versi React
Solusi ini **regex-based + multi-fallback**, tidak bergantung pada React internals.
## Syarat Arsitektur
Fitur ini bekerja karena 4 syarat struktural terpenuhi. Jika salah satu tidak ada, fitur tidak bisa diimplementasi atau perlu adaptasi signifikan.
### 1. Vite sebagai Bundler (Wajib)
Seluruh mekanisme bergantung pada **Vite plugin transform pipeline**:
- `inspectorPlugin()` inject attributes ke JSX saat build/HMR
- `enforce: 'pre'` memastikan plugin jalan sebelum OXC/Babel transform JSX
- `import.meta.env?.DEV` sebagai compile-time constant untuk tree-shaking
**Tidak bisa diganti dengan**: esbuild standalone, webpack (perlu loader berbeda), SWC standalone.
**Bisa diganti dengan**: framework yang pakai Vite di dalamnya (Remix Vite, TanStack Start, Astro).
### 2. Server dan Frontend dalam Satu Proses (Wajib)
Endpoint `/__open-in-editor` harus **satu proses dengan dev server** yang melayani frontend:
- Browser POST ke origin yang sama (no CORS)
- Server punya akses ke filesystem lokal untuk `Bun.spawn(editor)`
- Endpoint harus bisa ditangani **sebelum routing & middleware** (auth, tenant, dll)
**Pola yang memenuhi syarat:**
- Elysia + Vite middlewareMode (project ini) — `onRequest` intercept sebelum route matching
- Express/Fastify + Vite middlewareMode — middleware biasa sebelum auth
- Vite dev server standalone (`vite dev`) — pakai `configureServer` hook
**Tidak memenuhi syarat:**
- Frontend dan backend di proses/port terpisah (misal: CRA + separate API server) — perlu proxy atau CORS config tambahan
- Serverless/edge deployment — tidak bisa `spawn` editor
### 3. React sebagai UI Framework (Wajib untuk Multi-Fallback)
Strategi extraction source info bergantung pada React internals:
1. `__reactProps$*` — React menyimpan props di DOM element
2. `__reactFiber$*` — React fiber tree untuk walk-up
3. DOM attribute — fallback universal
**Jika pakai framework lain** (Vue, Svelte, Solid):
- Hanya strategi 3 (DOM attribute) yang berfungsi — tetap cukup
- Hapus strategi 1 & 2 dari `getCodeInfoFromElement()`
- Inject attributes tetap via Vite plugin (framework-agnostic)
### 4. Bun sebagai Runtime (Direkomendasikan, Bukan Wajib)
Bun memberikan API yang lebih clean:
- `Bun.spawn()` — fire-and-forget tanpa import
- `Bun.which()` — cek executable ada di PATH (mencegah uncatchable error)
**Jika pakai Node.js:**
- `Bun.spawn()``child_process.spawn(editor, args, { detached: true, stdio: 'ignore' }).unref()`
- `Bun.which()``const which = require('which'); which.sync(editor, { nothrow: true })`
### Ringkasan Syarat
| Syarat | Wajib? | Alternatif |
|-------------------------------|----------|------------------------------------------------------|
| Vite sebagai bundler | Ya | Framework berbasis Vite (Remix, Astro, dll) |
| Server + frontend satu proses | Ya | Bisa diakali dengan proxy, tapi tambah kompleksitas |
| React | Sebagian | Framework lain bisa, hanya fallback ke DOM attribute |
| Bun runtime | Tidak | Node.js dengan `child_process` + `which` package |
## Arsitektur
```
BUILD TIME (Vite Plugin):
.tsx/.jsx file
→ [inspectorPlugin enforce:'pre'] inject data-inspector-* attributes ke JSX
→ [react() OXC] transform JSX ke createElement
→ Browser menerima elemen dengan attributes
RUNTIME (Browser):
Hotkey → aktifkan mode → hover elemen → baca attributes → klik
→ POST /__open-in-editor {relativePath, line, column}
BACKEND (Elysia onRequest):
/__open-in-editor → Bun.spawn([editor, '--goto', 'file:line:col'])
→ Editor terbuka di lokasi tepat
```
## Komponen yang Dibutuhkan
### 1. Vite Plugin — `inspectorPlugin()` (enforce: 'pre')
Inject `data-inspector-*` ke setiap JSX opening tag via regex.
**HARUS `enforce: 'pre'`** — kalau tidak, OXC transform JSX duluan dan regex tidak bisa menemukan `<Component`.
```typescript
// Taruh di file vite config (misal: src/vite.ts atau vite.config.ts)
import path from 'node:path'
import type { Plugin } from 'vite'
function inspectorPlugin(): Plugin {
const rootDir = process.cwd()
return {
name: 'inspector-inject',
enforce: 'pre',
transform(code, id) {
// Hanya .tsx/.jsx, skip node_modules
if (!/\.[jt]sx(\?|$)/.test(id) || id.includes('node_modules')) return null
if (!code.includes('<')) return null
const relativePath = path.relative(rootDir, id)
let modified = false
const lines = code.split('\n')
const result: string[] = []
for (let i = 0; i < lines.length; i++) {
let line = lines[i]
// Match JSX opening tags: <Component atau <div
// Skip TypeScript generics (Record<string>) via charBefore check
const jsxPattern = /(<(?:[A-Z][a-zA-Z0-9.]*|[a-z][a-zA-Z0-9-]*))\b/g
let match: RegExpExecArray | null = null
while ((match = jsxPattern.exec(line)) !== null) {
// Skip jika karakter sebelum `<` adalah identifier char (TypeScript generic)
const charBefore = match.index > 0 ? line[match.index - 1] : ''
if (/[a-zA-Z0-9_$.]/.test(charBefore)) continue
const col = match.index + 1
const attr = ` data-inspector-line="${i + 1}" data-inspector-column="${col}" data-inspector-relative-path="${relativePath}"`
const insertPos = match.index + match[0].length
line = line.slice(0, insertPos) + attr + line.slice(insertPos)
modified = true
jsxPattern.lastIndex += attr.length
}
result.push(line)
}
if (!modified) return null
return result.join('\n')
},
}
}
```
**Mengapa regex, bukan Babel?**
- `@vitejs/plugin-react` v6+ pakai OXC (Rust), bukan Babel
- Config `babel: { plugins: [...] }` di plugin-react **DIABAIKAN**
- Regex jalan sebelum OXC via `enforce: 'pre'`
**Gotcha: TypeScript generics**
- `Record<string>` → karakter sebelum `<` adalah `d` (identifier) → SKIP
- `<Button` → karakter sebelum `<` adalah space/newline → MATCH
### 2. Vite Plugin Order (KRITIS)
```typescript
plugins: [
// 1. Route generation (jika pakai TanStack Router)
TanStackRouterVite({ ... }),
// 2. Inspector inject — HARUS sebelum react()
inspectorPlugin(),
// 3. React OXC transform
react(),
// 4. (Opsional) Dedupe React Refresh untuk middlewareMode
dedupeRefreshPlugin(),
]
```
**Jika urutan salah (inspectorPlugin setelah react):**
- OXC transform `<Button>``React.createElement(Button, ...)`
- Regex tidak menemukan `<Button` → attributes TIDAK ter-inject
- Fitur tidak berfungsi, tanpa error
### 3. DevInspector Component (Browser Runtime)
Komponen React yang handle hotkey, overlay, dan klik.
```tsx
// src/frontend/DevInspector.tsx
import { useCallback, useEffect, useRef, useState } from 'react'
interface CodeInfo {
relativePath: string
line: string
column: string
}
/** Baca data-inspector-* dari fiber props atau DOM attributes */
function getCodeInfoFromElement(element: HTMLElement): CodeInfo | null {
// Strategi 1: React internal props __reactProps$ (paling akurat)
for (const key of Object.keys(element)) {
if (key.startsWith('__reactProps$')) {
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',
}
}
}
// Strategi 2: Walk fiber tree __reactFiber$
if (key.startsWith('__reactFiber$')) {
const fiber = (element as any)[key]
let f = fiber
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
}
}
}
// Strategi 3: Fallback DOM attribute langsung
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
}
/** Walk up DOM tree sampai ketemu elemen yang punya source info */
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`
}, [])
// Activate/deactivate event listeners
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)
navigator.clipboard.writeText(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',
}}
/>
</>
)
}
```
### 4. Backend Endpoint — `/__open-in-editor`
**HARUS ditangani di `onRequest` / sebelum middleware**, bukan sebagai route biasa. Kalau jadi route, akan kena auth middleware dan gagal.
```typescript
// Di entry point server (src/index.tsx), dalam onRequest handler:
if (!isProduction && pathname === '/__open-in-editor' && request.method === 'POST') {
const { relativePath, lineNumber, columnNumber } = (await request.json()) as {
relativePath: string
lineNumber: string
columnNumber: string
}
const file = `${process.cwd()}/${relativePath}`
const editor = process.env.REACT_EDITOR || 'code'
const loc = `${file}:${lineNumber}:${columnNumber}`
const args = editor === 'subl' ? [loc] : ['--goto', loc]
const editorPath = Bun.which(editor)
console.log(`[inspector] ${editor}${editorPath ?? 'NOT FOUND'}${loc}`)
if (editorPath) {
Bun.spawn([editor, ...args], { stdio: ['ignore', 'ignore', 'ignore'] })
} else {
console.error(`[inspector] Editor "${editor}" not found in PATH. Set REACT_EDITOR in .env`)
}
return new Response('ok')
}
```
**Penting — `Bun.which()` sebelum `Bun.spawn()`:**
- `Bun.spawn()` throw native error yang TIDAK bisa di-catch jika executable tidak ada
- `Bun.which()` return null dengan aman → cek dulu sebelum spawn
**Editor yang didukung:**
| REACT_EDITOR | Editor | Args |
|------------------|--------------|--------------------------------|
| `code` (default) | VS Code | `--goto file:line:col` |
| `cursor` | Cursor | `--goto file:line:col` |
| `windsurf` | Windsurf | `--goto file:line:col` |
| `subl` | Sublime Text | `file:line:col` (tanpa --goto) |
### 5. Frontend Entry — Conditional Import (Zero Production Overhead)
```tsx
// src/frontend.tsx (atau entry point React)
import type { ReactNode } from 'react'
const InspectorWrapper = import.meta.env?.DEV
? (await import('./frontend/DevInspector')).DevInspector
: ({ children }: { children: ReactNode }) => <>{children}</>
const app = (
<InspectorWrapper>
<App />
</InspectorWrapper>
)
```
**Bagaimana zero overhead tercapai:**
- `import.meta.env?.DEV` adalah compile-time constant
- Production build: `false` → dynamic import TIDAK dieksekusi
- Tree-shaking menghapus seluruh `DevInspector.tsx` dari bundle
- Tidak ada runtime check, tidak ada dead code di bundle
### 6. (Opsional) Dedupe React Refresh — Workaround Vite middlewareMode
Jika pakai Vite dalam `middlewareMode` (seperti di Elysia/Express), `@vitejs/plugin-react` v6 bisa inject React Refresh footer dua kali → error "already declared".
```typescript
function dedupeRefreshPlugin(): Plugin {
return {
name: 'dedupe-react-refresh',
enforce: 'post',
transform(code, id) {
if (!/\.[jt]sx(\?|$)/.test(id) || id.includes('node_modules')) return null
const marker = 'import * as RefreshRuntime from "/@react-refresh"'
const firstIdx = code.indexOf(marker)
if (firstIdx === -1) return null
const secondIdx = code.indexOf(marker, firstIdx + marker.length)
if (secondIdx === -1) return null
const sourcemapIdx = code.indexOf('\n//# sourceMappingURL=', secondIdx)
const endIdx = sourcemapIdx !== -1 ? sourcemapIdx : code.length
const cleaned = code.slice(0, secondIdx) + code.slice(endIdx)
return { code: cleaned, map: null }
},
}
}
```
## Langkah Implementasi di Project Baru
### Prasyarat
- Runtime: Bun
- Server: Elysia (atau framework lain dengan onRequest/beforeHandle)
- Frontend: React + Vite
- `@vitejs/plugin-react` (OXC)
### Step-by-step
1. **Buat `DevInspector.tsx`** — copy komponen dari Bagian 3 ke folder frontend
2. **Tambah `inspectorPlugin()`** — copy fungsi dari Bagian 1 ke file vite config
3. **Atur plugin order**`inspectorPlugin()` SEBELUM `react()` (Bagian 2)
4. **Tambah endpoint `/__open-in-editor`** — di `onRequest` handler (Bagian 4)
5. **Wrap root app** — conditional import di entry point (Bagian 5)
6. **Set env**`REACT_EDITOR=code` (atau cursor/windsurf/subl) di `.env`
7. **(Opsional)** Tambah `dedupeRefreshPlugin()` jika pakai Vite `middlewareMode`
### Checklist Verifikasi
- [ ] `inspectorPlugin` punya `enforce: 'pre'`
- [ ] Plugin order: inspector → react (bukan sebaliknya)
- [ ] Endpoint `/__open-in-editor` di LUAR middleware auth
- [ ] `Bun.which(editor)` dipanggil SEBELUM `Bun.spawn()`
- [ ] Conditional import pakai `import.meta.env?.DEV`
- [ ] `REACT_EDITOR` di `.env` sesuai editor yang dipakai
- [ ] Hotkey berfungsi: `Ctrl+Shift+Cmd+C` / `Ctrl+Shift+Alt+C`
## Gotcha & Pelajaran
| Masalah | Penyebab | Solusi |
|----------------------------------|---------------------------------------------|-----------------------------------------------|
| Attributes tidak ter-inject | Plugin order salah | `enforce: 'pre'`, taruh sebelum `react()` |
| `Record<string>` ikut ter-inject | Regex match TypeScript generics | Cek `charBefore` — skip jika identifier char |
| `Bun.spawn` crash | Editor tidak ada di PATH | Selalu `Bun.which()` dulu |
| Hotkey tidak response | `e.key` return 'C' (uppercase) karena Shift | Pakai `e.key.toLowerCase()` |
| React Refresh duplicate | Vite middlewareMode bug | `dedupeRefreshPlugin()` enforce: 'post' |
| Endpoint kena auth middleware | Didaftarkan sebagai route biasa | Tangani di `onRequest` sebelum routing |
| `_debugSource` undefined | React 19 menghapusnya | Multi-fallback: reactProps → fiber → DOM attr |
## Adaptasi untuk Framework Lain
### Express/Fastify (bukan Elysia)
- Endpoint `/__open-in-editor`: gunakan middleware biasa SEBELUM auth
- `Bun.spawn``child_process.spawn` jika pakai Node.js
- `Bun.which``which` npm package jika pakai Node.js
### Next.js
- Tidak perlu — Next.js punya built-in click-to-source
- Tapi jika ingin custom: taruh endpoint di `middleware.ts`, plugin di `next.config.js`
### Remix/Tanstack Start (SSR)
- Plugin tetap sama (Vite-based)
- Endpoint perlu di server entry, bukan di route loader

1
eror.md Normal file

File diff suppressed because one or more lines are too long

View File

@@ -8,7 +8,8 @@
"start": "next start",
"test:api": "vitest run",
"test:e2e": "playwright test",
"test": "bun run test:api && bun run test:e2e"
"test": "bun run test:api && bun run test:e2e",
"gen:api": ""
},
"prisma": {
"seed": "bun run prisma/seed.ts"
@@ -120,10 +121,11 @@
"@types/react-dom": "^19",
"@vitest/ui": "^4.0.18",
"eslint": "^9",
"eslint-config-next": "15.1.6",
"eslint-config-next": "15.5.12",
"jsdom": "^28.0.0",
"msw": "^2.12.9",
"parcel": "^2.6.2",
"playwright-mcp": "^0.0.19",
"postcss": "^8.5.1",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",

View File

@@ -0,0 +1,94 @@
/*
Warnings:
- You are about to drop the column `realisasi` on the `APBDesItem` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "APBDesItem" DROP COLUMN "realisasi",
ADD COLUMN "totalRealisasi" DOUBLE PRECISION NOT NULL DEFAULT 0,
ALTER COLUMN "selisih" SET DEFAULT 0,
ALTER COLUMN "persentase" SET DEFAULT 0;
-- AlterTable
ALTER TABLE "Berita" ADD COLUMN "linkVideo" VARCHAR(500);
-- CreateTable
CREATE TABLE "RealisasiItem" (
"id" TEXT NOT NULL,
"kode" TEXT,
"apbdesItemId" TEXT NOT NULL,
"jumlah" DOUBLE PRECISION NOT NULL,
"tanggal" DATE NOT NULL,
"keterangan" TEXT,
"buktiFileId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "RealisasiItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MusikDesa" (
"id" TEXT NOT NULL,
"judul" VARCHAR(255) NOT NULL,
"artis" VARCHAR(255) NOT NULL,
"deskripsi" TEXT,
"durasi" VARCHAR(20) NOT NULL,
"audioFileId" TEXT,
"coverImageId" TEXT,
"genre" VARCHAR(100),
"tahunRilis" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "MusikDesa_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_BeritaImages" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_BeritaImages_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE INDEX "RealisasiItem_kode_idx" ON "RealisasiItem"("kode");
-- CreateIndex
CREATE INDEX "RealisasiItem_apbdesItemId_idx" ON "RealisasiItem"("apbdesItemId");
-- CreateIndex
CREATE INDEX "RealisasiItem_tanggal_idx" ON "RealisasiItem"("tanggal");
-- CreateIndex
CREATE INDEX "MusikDesa_judul_idx" ON "MusikDesa"("judul");
-- CreateIndex
CREATE INDEX "MusikDesa_artis_idx" ON "MusikDesa"("artis");
-- CreateIndex
CREATE INDEX "_BeritaImages_B_index" ON "_BeritaImages"("B");
-- CreateIndex
CREATE INDEX "Berita_kategoriBeritaId_idx" ON "Berita"("kategoriBeritaId");
-- AddForeignKey
ALTER TABLE "RealisasiItem" ADD CONSTRAINT "RealisasiItem_apbdesItemId_fkey" FOREIGN KEY ("apbdesItemId") REFERENCES "APBDesItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MusikDesa" ADD CONSTRAINT "MusikDesa_audioFileId_fkey" FOREIGN KEY ("audioFileId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MusikDesa" ADD CONSTRAINT "MusikDesa_coverImageId_fkey" FOREIGN KEY ("coverImageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_BeritaImages" ADD CONSTRAINT "_BeritaImages_A_fkey" FOREIGN KEY ("A") REFERENCES "Berita"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_BeritaImages" ADD CONSTRAINT "_BeritaImages_B_fkey" FOREIGN KEY ("B") REFERENCES "FileStorage"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

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

View File

@@ -0,0 +1,27 @@
import ApiFetch from "@/lib/api-fetch";
import { proxy } from "valtio";
const kependudukanDashboard = proxy({
summary: {
data: null as any,
loading: false,
async load() {
kependudukanDashboard.summary.loading = true;
try {
const res = await ApiFetch.api.kependudukan.dashboard.summary.get();
if (res.status === 200 && res.data?.success) {
kependudukanDashboard.summary.data = res.data.data;
} else {
kependudukanDashboard.summary.data = null;
}
} catch (err) {
console.error("Gagal fetch dashboard summary:", err);
kependudukanDashboard.summary.data = null;
} finally {
kependudukanDashboard.summary.loading = false;
}
},
},
});
export default kependudukanDashboard;

View File

@@ -0,0 +1,205 @@
import ApiFetch from "@/lib/api-fetch";
import { proxy } from "valtio";
import { toast } from "react-toastify";
import { z } from "zod";
const templateDataBanjar = z.object({
nama: z.string().min(1, "Nama banjar harus diisi"),
penduduk: z.number().min(0, "Jumlah penduduk harus diisi"),
kk: z.number().min(0, "Jumlah KK harus diisi"),
miskin: z.number().min(0, "Jumlah penduduk miskin harus diisi"),
tahun: z.number().min(2000, "Tahun harus diisi"),
});
const dataBanjar = proxy({
create: {
form: {
nama: "",
penduduk: 0,
kk: 0,
miskin: 0,
tahun: new Date().getFullYear(),
},
loading: false,
async create() {
const cek = templateDataBanjar.safeParse(dataBanjar.create.form);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
toast.error(err);
return null;
}
try {
dataBanjar.create.loading = true;
const res = await ApiFetch.api.kependudukan.databanjar["create"].post(dataBanjar.create.form);
if (res.status === 200) {
const id = res.data?.data?.id;
if (id) {
toast.success("Sukses menambahkan data banjar");
dataBanjar.create.form = { nama: "", penduduk: 0, kk: 0, miskin: 0, tahun: new Date().getFullYear() };
dataBanjar.findMany.load();
return id;
}
}
toast.error("Gagal menambahkan data");
return null;
} catch (error) {
console.log((error as Error).message);
return null;
} finally {
dataBanjar.create.loading = false;
}
},
},
findMany: {
data: null as any[] | null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "", tahun = new Date().getFullYear()) => {
dataBanjar.findMany.loading = true;
dataBanjar.findMany.page = page;
dataBanjar.findMany.search = search;
try {
const query: any = { page, limit, tahun };
if (search) query.search = search;
const res = await ApiFetch.api.kependudukan.databanjar["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
dataBanjar.findMany.data = res.data.data ?? [];
dataBanjar.findMany.totalPages = res.data.totalPages ?? 1;
} else {
dataBanjar.findMany.data = [];
dataBanjar.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch data banjar paginated:", err);
dataBanjar.findMany.data = [];
dataBanjar.findMany.totalPages = 1;
} finally {
dataBanjar.findMany.loading = false;
}
},
},
findUnique: {
data: null as any | null,
async load(id: string) {
try {
const res = await fetch(`/api/kependudukan/databanjar/${id}`);
if (res.ok) {
const data = await res.json();
dataBanjar.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch data banjar:", res.statusText);
dataBanjar.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching data banjar:", error);
dataBanjar.findUnique.data = null;
}
},
},
update: {
id: "",
form: {
nama: "",
penduduk: 0,
kk: 0,
miskin: 0,
tahun: new Date().getFullYear(),
},
loading: false,
async submit() {
const id = this.id;
if (!id) {
toast.warn("ID tidak valid");
return null;
}
const formData = {
nama: this.form.nama,
penduduk: this.form.penduduk,
kk: this.form.kk,
miskin: this.form.miskin,
tahun: this.form.tahun,
};
const cek = templateDataBanjar.safeParse(formData);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
toast.error(err);
return null;
}
try {
this.loading = true;
const res = await fetch(`/api/kependudukan/databanjar/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
const result = await res.json();
if (!res.ok || !result?.success) {
throw new Error(result?.message || "Gagal update data");
}
toast.success("Berhasil update data!");
await dataBanjar.findMany.load();
return result.data;
} catch (error) {
console.error("Update error:", error);
toast.error("Gagal update data banjar");
throw error;
} finally {
this.loading = false;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
dataBanjar.delete.loading = true;
const response = await fetch(
`/api/kependudukan/databanjar/del/${id}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Data banjar berhasil dihapus");
await dataBanjar.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus data banjar");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus data banjar");
} finally {
dataBanjar.delete.loading = false;
}
},
},
});
export default dataBanjar;

View File

@@ -0,0 +1,197 @@
import ApiFetch from "@/lib/api-fetch";
import { proxy } from "valtio";
import { toast } from "react-toastify";
import { z } from "zod";
const templateDistribusiAgama = z.object({
agama: z.string().min(1, "Agama harus diisi"),
jumlah: z.number().min(0, "Jumlah harus diisi"),
tahun: z.number().min(2000, "Tahun harus diisi"),
});
const distribusiAgama = proxy({
create: {
form: {
agama: "",
jumlah: 0,
tahun: new Date().getFullYear(),
},
loading: false,
async create() {
const cek = templateDistribusiAgama.safeParse(distribusiAgama.create.form);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
toast.error(err);
return null;
}
try {
distribusiAgama.create.loading = true;
const res = await ApiFetch.api.kependudukan.distribusiagama["create"].post(distribusiAgama.create.form);
if (res.status === 200) {
const id = res.data?.data?.id;
if (id) {
toast.success("Sukses menambahkan distribusi agama");
distribusiAgama.create.form = { agama: "", jumlah: 0, tahun: new Date().getFullYear() };
distribusiAgama.findMany.load();
return id;
}
}
toast.error("Gagal menambahkan data");
return null;
} catch (error) {
console.log((error as Error).message);
return null;
} finally {
distribusiAgama.create.loading = false;
}
},
},
findMany: {
data: null as any[] | null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "", tahun = new Date().getFullYear()) => {
distribusiAgama.findMany.loading = true;
distribusiAgama.findMany.page = page;
distribusiAgama.findMany.search = search;
try {
const query: any = { page, limit, tahun };
if (search) query.search = search;
const res = await ApiFetch.api.kependudukan.distribusiagama["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
distribusiAgama.findMany.data = res.data.data ?? [];
distribusiAgama.findMany.totalPages = res.data.totalPages ?? 1;
} else {
distribusiAgama.findMany.data = [];
distribusiAgama.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch distribusi agama paginated:", err);
distribusiAgama.findMany.data = [];
distribusiAgama.findMany.totalPages = 1;
} finally {
distribusiAgama.findMany.loading = false;
}
},
},
findUnique: {
data: null as any | null,
async load(id: string) {
try {
const res = await fetch(`/api/kependudukan/distribusiagama/${id}`);
if (res.ok) {
const data = await res.json();
distribusiAgama.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch distribusiAgama:", res.statusText);
distribusiAgama.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching distribusiAgama:", error);
distribusiAgama.findUnique.data = null;
}
},
},
update: {
id: "",
form: {
agama: "",
jumlah: 0,
tahun: new Date().getFullYear(),
},
loading: false,
async submit() {
const id = this.id;
if (!id) {
toast.warn("ID tidak valid");
return null;
}
const formData = {
agama: this.form.agama,
jumlah: this.form.jumlah,
tahun: this.form.tahun,
};
const cek = templateDistribusiAgama.safeParse(formData);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
toast.error(err);
return null;
}
try {
this.loading = true;
const res = await fetch(`/api/kependudukan/distribusiagama/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
const result = await res.json();
if (!res.ok || !result?.success) {
throw new Error(result?.message || "Gagal update data");
}
toast.success("Berhasil update data!");
await distribusiAgama.findMany.load();
return result.data;
} catch (error) {
console.error("Update error:", error);
toast.error("Gagal update data distribusi agama");
throw error;
} finally {
this.loading = false;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
distribusiAgama.delete.loading = true;
const response = await fetch(
`/api/kependudukan/distribusiagama/del/${id}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Distribusi agama berhasil dihapus");
await distribusiAgama.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus distribusi agama");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus distribusi agama");
} finally {
distribusiAgama.delete.loading = false;
}
},
},
});
export default distribusiAgama;

View File

@@ -0,0 +1,197 @@
import ApiFetch from "@/lib/api-fetch";
import { proxy } from "valtio";
import { toast } from "react-toastify";
import { z } from "zod";
const templateDistribusiUmur = z.object({
rentangUmur: z.string().min(1, "Rentang umur harus diisi"),
jumlah: z.number().min(0, "Jumlah harus diisi"),
tahun: z.number().min(2000, "Tahun harus diisi"),
});
const distribusiUmur = proxy({
create: {
form: {
rentangUmur: "",
jumlah: 0,
tahun: new Date().getFullYear(),
},
loading: false,
async create() {
const cek = templateDistribusiUmur.safeParse(distribusiUmur.create.form);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
toast.error(err);
return null;
}
try {
distribusiUmur.create.loading = true;
const res = await ApiFetch.api.kependudukan.distribusiumur["create"].post(distribusiUmur.create.form);
if (res.status === 200) {
const id = res.data?.data?.id;
if (id) {
toast.success("Sukses menambahkan distribusi umur");
distribusiUmur.create.form = { rentangUmur: "", jumlah: 0, tahun: new Date().getFullYear() };
distribusiUmur.findMany.load();
return id;
}
}
toast.error("Gagal menambahkan data");
return null;
} catch (error) {
console.log((error as Error).message);
return null;
} finally {
distribusiUmur.create.loading = false;
}
},
},
findMany: {
data: null as any[] | null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "", tahun = new Date().getFullYear()) => {
distribusiUmur.findMany.loading = true;
distribusiUmur.findMany.page = page;
distribusiUmur.findMany.search = search;
try {
const query: any = { page, limit, tahun };
if (search) query.search = search;
const res = await ApiFetch.api.kependudukan.distribusiumur["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
distribusiUmur.findMany.data = res.data.data ?? [];
distribusiUmur.findMany.totalPages = res.data.totalPages ?? 1;
} else {
distribusiUmur.findMany.data = [];
distribusiUmur.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch distribusi umur paginated:", err);
distribusiUmur.findMany.data = [];
distribusiUmur.findMany.totalPages = 1;
} finally {
distribusiUmur.findMany.loading = false;
}
},
},
findUnique: {
data: null as any | null,
async load(id: string) {
try {
const res = await fetch(`/api/kependudukan/distribusiumur/${id}`);
if (res.ok) {
const data = await res.json();
distribusiUmur.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch distribusi umur:", res.statusText);
distribusiUmur.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching distribusi umur:", error);
distribusiUmur.findUnique.data = null;
}
},
},
update: {
id: "",
form: {
rentangUmur: "",
jumlah: 0,
tahun: new Date().getFullYear(),
},
loading: false,
async submit() {
const id = this.id;
if (!id) {
toast.warn("ID tidak valid");
return null;
}
const formData = {
rentangUmur: this.form.rentangUmur,
jumlah: this.form.jumlah,
tahun: this.form.tahun,
};
const cek = templateDistribusiUmur.safeParse(formData);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
toast.error(err);
return null;
}
try {
this.loading = true;
const res = await fetch(`/api/kependudukan/distribusiumur/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
const result = await res.json();
if (!res.ok || !result?.success) {
throw new Error(result?.message || "Gagal update data");
}
toast.success("Berhasil update data!");
await distribusiUmur.findMany.load();
return result.data;
} catch (error) {
console.error("Update error:", error);
toast.error("Gagal update data distribusi umur");
throw error;
} finally {
this.loading = false;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
distribusiUmur.delete.loading = true;
const response = await fetch(
`/api/kependudukan/distribusiumur/del/${id}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Distribusi umur berhasil dihapus");
await distribusiUmur.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus distribusi umur");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus distribusi umur");
} finally {
distribusiUmur.delete.loading = false;
}
},
},
});
export default distribusiUmur;

View File

@@ -0,0 +1,209 @@
import ApiFetch from "@/lib/api-fetch";
import { proxy } from "valtio";
import { toast } from "react-toastify";
import { z } from "zod";
const templateMigrasiPenduduk = z.object({
jenis: z.string().min(1, "Jenis migrasi harus diisi"),
nama: z.string().min(1, "Nama harus diisi"),
tanggal: z.string().min(1, "Tanggal harus diisi"),
asalTujuan: z.string().min(1, "Asal/Tujuan harus diisi"),
alasan: z.string().optional(),
jenisKelamin: z.string().optional(),
});
const migrasiPenduduk = proxy({
create: {
form: {
jenis: "",
nama: "",
tanggal: "",
asalTujuan: "",
alasan: "",
jenisKelamin: "",
},
loading: false,
async create() {
const cek = templateMigrasiPenduduk.safeParse(migrasiPenduduk.create.form);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
toast.error(err);
return null;
}
try {
migrasiPenduduk.create.loading = true;
const res = await ApiFetch.api.kependudukan.migrasipenduduk["create"].post(migrasiPenduduk.create.form);
if (res.status === 200) {
const id = res.data?.data?.id;
if (id) {
toast.success("Sukses menambahkan data migrasi penduduk");
migrasiPenduduk.create.form = { jenis: "", nama: "", tanggal: "", asalTujuan: "", alasan: "", jenisKelamin: "" };
migrasiPenduduk.findMany.load();
return id;
}
}
toast.error("Gagal menambahkan data");
return null;
} catch (error) {
console.log((error as Error).message);
return null;
} finally {
migrasiPenduduk.create.loading = false;
}
},
},
findMany: {
data: null as any[] | null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "", tahun = new Date().getFullYear()) => {
migrasiPenduduk.findMany.loading = true;
migrasiPenduduk.findMany.page = page;
migrasiPenduduk.findMany.search = search;
try {
const query: any = { page, limit, tahun };
if (search) query.search = search;
const res = await ApiFetch.api.kependudukan.migrasipenduduk["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
migrasiPenduduk.findMany.data = res.data.data ?? [];
migrasiPenduduk.findMany.totalPages = res.data.totalPages ?? 1;
} else {
migrasiPenduduk.findMany.data = [];
migrasiPenduduk.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch migrasi penduduk paginated:", err);
migrasiPenduduk.findMany.data = [];
migrasiPenduduk.findMany.totalPages = 1;
} finally {
migrasiPenduduk.findMany.loading = false;
}
},
},
findUnique: {
data: null as any | null,
async load(id: string) {
try {
const res = await fetch(`/api/kependudukan/migrasipenduduk/${id}`);
if (res.ok) {
const data = await res.json();
migrasiPenduduk.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch migrasi penduduk:", res.statusText);
migrasiPenduduk.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching migrasi penduduk:", error);
migrasiPenduduk.findUnique.data = null;
}
},
},
update: {
id: "",
form: {
jenis: "",
nama: "",
tanggal: "",
asalTujuan: "",
alasan: "",
jenisKelamin: "",
},
loading: false,
async submit() {
const id = this.id;
if (!id) {
toast.warn("ID tidak valid");
return null;
}
const formData = {
jenis: this.form.jenis,
nama: this.form.nama,
tanggal: this.form.tanggal,
asalTujuan: this.form.asalTujuan,
alasan: this.form.alasan,
jenisKelamin: this.form.jenisKelamin,
};
const cek = templateMigrasiPenduduk.safeParse(formData);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
toast.error(err);
return null;
}
try {
this.loading = true;
const res = await fetch(`/api/kependudukan/migrasipenduduk/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
const result = await res.json();
if (!res.ok || !result?.success) {
throw new Error(result?.message || "Gagal update data");
}
toast.success("Berhasil update data!");
await migrasiPenduduk.findMany.load();
return result.data;
} catch (error) {
console.error("Update error:", error);
toast.error("Gagal update data migrasi penduduk");
throw error;
} finally {
this.loading = false;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
migrasiPenduduk.delete.loading = true;
const response = await fetch(
`/api/kependudukan/migrasipenduduk/del/${id}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Data migrasi penduduk berhasil dihapus");
await migrasiPenduduk.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus data migrasi penduduk");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus data migrasi penduduk");
} finally {
migrasiPenduduk.delete.loading = false;
}
},
},
});
export default migrasiPenduduk;

View File

@@ -0,0 +1,249 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Title,
NumberInput,
TextInput
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import dataBanjar from '../../../_state/kependudukan/data-banjar';
interface FormData {
nama: string;
penduduk: number;
kk: number;
miskin: number;
tahun: number;
}
export default function EditDataBanjar() {
const router = useRouter();
const { id } = useParams() as { id: string };
const stateDataBanjar = useProxy(dataBanjar);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<FormData>({
nama: '',
penduduk: 0,
kk: 0,
miskin: 0,
tahun: new Date().getFullYear(),
});
const [originalData, setOriginalData] = useState<FormData>({
nama: '',
penduduk: 0,
kk: 0,
miskin: 0,
tahun: new Date().getFullYear(),
});
const currentYear = new Date().getFullYear();
const isFormValid = () => {
return (
formData.nama?.trim() !== '' &&
formData.penduduk !== null &&
formData.penduduk >= 0 &&
formData.kk !== null &&
formData.kk >= 0 &&
formData.miskin !== null &&
formData.miskin >= 0 &&
formData.tahun !== null
);
};
useEffect(() => {
if (!id) return;
const loadData = async () => {
try {
setIsSubmitting(true);
stateDataBanjar.update.id = id;
await stateDataBanjar.findUnique.load(id);
const data = stateDataBanjar.findUnique.data;
if (data) {
setFormData({
nama: data.nama ?? '',
penduduk: Number(data.penduduk ?? 0),
kk: Number(data.kk ?? 0),
miskin: Number(data.miskin ?? 0),
tahun: Number(data.tahun ?? currentYear),
});
setOriginalData({
nama: data.nama ?? '',
penduduk: Number(data.penduduk ?? 0),
kk: Number(data.kk ?? 0),
miskin: Number(data.miskin ?? 0),
tahun: Number(data.tahun ?? currentYear),
});
}
} catch (error) {
console.error('Error loading data:', error);
toast.error('Gagal memuat data');
} finally {
setIsSubmitting(false);
}
};
loadData();
}, [id]);
const handleChange = useCallback(
(field: keyof FormData) =>
(value: any) => {
const val =
field === 'penduduk' || field === 'kk' || field === 'miskin' || field === 'tahun'
? Number(value || 0)
: value;
setFormData((prev) => ({ ...prev, [field]: val }));
},
[]
);
const handleResetForm = () => {
setFormData({
nama: originalData.nama,
penduduk: Number(originalData.penduduk),
kk: Number(originalData.kk),
miskin: Number(originalData.miskin),
tahun: Number(originalData.tahun),
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
stateDataBanjar.update.id = id;
stateDataBanjar.update.form = { ...formData };
await stateDataBanjar.update.submit();
toast.success('Data berhasil diperbarui');
router.push('/admin/kependudukan/data-banjar');
} catch (error) {
console.error('Error updating data:', error);
toast.error('Gagal memperbarui data');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Data Banjar
</Title>
</Group>
{/* Form Card */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Nama Banjar"
placeholder="Masukkan nama banjar"
value={formData.nama}
onChange={handleChange('nama')}
required
/>
<NumberInput
label="Jumlah Penduduk"
placeholder="Masukkan jumlah penduduk"
value={formData.penduduk}
onChange={handleChange('penduduk')}
min={0}
required
/>
<NumberInput
label="Jumlah KK"
placeholder="Masukkan jumlah KK"
value={formData.kk}
onChange={handleChange('kk')}
min={0}
required
/>
<NumberInput
label="Jumlah Penduduk Miskin"
placeholder="Masukkan jumlah penduduk miskin"
value={formData.miskin}
onChange={handleChange('miskin')}
min={0}
required
/>
<NumberInput
label="Tahun"
placeholder="Masukkan tahun"
value={formData.tahun}
onChange={handleChange('tahun')}
min={2000}
max={currentYear + 1}
required
/>
<Group justify="flex-end">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,189 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Title,
NumberInput,
TextInput
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import dataBanjar from '../../../_state/kependudukan/data-banjar';
import { toast } from 'react-toastify';
function CreateDataBanjar() {
const stateDataBanjar = useProxy(dataBanjar);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 10 }, (_, i) => ({
value: String(currentYear - i),
label: String(currentYear - i),
}));
const isFormValid = () => {
return (
stateDataBanjar.create.form.nama?.trim() !== '' &&
stateDataBanjar.create.form.penduduk !== null &&
stateDataBanjar.create.form.penduduk >= 0 &&
stateDataBanjar.create.form.kk !== null &&
stateDataBanjar.create.form.kk >= 0 &&
stateDataBanjar.create.form.miskin !== null &&
stateDataBanjar.create.form.miskin >= 0 &&
stateDataBanjar.create.form.tahun !== null
);
};
const resetForm = () => {
stateDataBanjar.create.form = {
nama: '',
penduduk: 0,
kk: 0,
miskin: 0,
tahun: currentYear,
};
};
const handleSubmit = async () => {
try {
const id = await stateDataBanjar.create.create();
if (id) {
resetForm();
router.push('/admin/kependudukan/data-banjar');
}
} catch (error) {
console.error('Error creating data banjar:', error);
toast.error('Terjadi kesalahan saat menambah data banjar');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Data Banjar
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Nama Banjar"
placeholder="Masukkan nama banjar"
value={stateDataBanjar.create.form.nama}
onChange={(e) => {
stateDataBanjar.create.form.nama = e.currentTarget.value;
}}
required
/>
<NumberInput
label="Jumlah Penduduk"
placeholder="Masukkan jumlah penduduk"
value={stateDataBanjar.create.form.penduduk}
onChange={(val) => {
stateDataBanjar.create.form.penduduk = Number(val || 0);
}}
min={0}
required
/>
<NumberInput
label="Jumlah KK"
placeholder="Masukkan jumlah KK"
value={stateDataBanjar.create.form.kk}
onChange={(val) => {
stateDataBanjar.create.form.kk = Number(val || 0);
}}
min={0}
required
/>
<NumberInput
label="Jumlah Penduduk Miskin"
placeholder="Masukkan jumlah penduduk miskin"
value={stateDataBanjar.create.form.miskin}
onChange={(val) => {
stateDataBanjar.create.form.miskin = Number(val || 0);
}}
min={0}
required
/>
<NumberInput
label="Tahun"
placeholder="Masukkan tahun"
value={stateDataBanjar.create.form.tahun}
onChange={(val) => {
stateDataBanjar.create.form.tahun = Number(val || currentYear);
}}
min={2000}
max={currentYear + 1}
required
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateDataBanjar;

View File

@@ -0,0 +1,304 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
import dataBanjar from '../../_state/kependudukan/data-banjar';
function DataBanjarAdmin() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title='Data Banjar'
placeholder='Cari nama banjar...'
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
/>
<ListDataBanjar search={search} />
</Box>
);
}
function ListDataBanjar({ search }: { search: string }) {
type DataBanjarType = {
id: string;
nama: string;
penduduk: number;
kk: number;
miskin: number;
tahun: number;
};
const router = useRouter();
const stateDataBanjar = useProxy(dataBanjar);
const [modalHapus, setModalHapus] = useState(false);
const [debouncedSearch] = useDebouncedValue(search, 1000);
const [selectedId, setSelectedId] = useState<string | null>(null);
const {
data,
page,
totalPages,
loading,
load,
} = stateDataBanjar.findMany;
const handleDelete = () => {
if (selectedId) {
stateDataBanjar.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
}
};
useShallowEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
);
}
return (
<Box py={{ base: 'sm', md: 'md' }}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
<Title order={4} lh={{ base: 1.2, md: 1.15 }}>
List Data Banjar
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/kependudukan/data-banjar/create')}
fz={{ base: 'sm', md: 'md' }}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table
highlightOnHover
miw={0}
style={{
tableLayout: 'fixed',
width: '100%',
}}
>
<TableThead>
<TableTr>
<TableTh style={{ width: '25%' }}>Nama Banjar</TableTh>
<TableTh style={{ width: '15%' }}>Penduduk</TableTh>
<TableTh style={{ width: '15%' }}>KK</TableTh>
<TableTh style={{ width: '15%' }}>Miskin</TableTh>
<TableTh style={{ width: '10%' }}>Tahun</TableTh>
<TableTh style={{ width: '10%' }}>Edit</TableTh>
<TableTh style={{ width: '10%' }}>Hapus</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item: DataBanjarType) => (
<TableTr key={item.id}>
<TableTd>{item.nama}</TableTd>
<TableTd>{item.penduduk.toLocaleString('id-ID')}</TableTd>
<TableTd>{item.kk.toLocaleString('id-ID')}</TableTd>
<TableTd>{item.miskin.toLocaleString('id-ID')}</TableTd>
<TableTd>{item.tahun}</TableTd>
<TableTd>
<Button
variant="light"
color="green"
onClick={() =>
router.push(`/admin/kependudukan/data-banjar/${item.id}`)
}
fz="sm"
px="xs"
py="xs"
>
<IconEdit size={16} />
</Button>
</TableTd>
<TableTd>
<Button
variant="light"
color="red"
disabled={stateDataBanjar.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
fz="sm"
px="xs"
py="xs"
>
<IconTrash size={16} />
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={7}>
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data banjar yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Card */}
<Box hiddenFrom="md">
<Stack gap="xs">
{filteredData.length > 0 ? (
filteredData.map((item: DataBanjarType) => (
<Paper key={item.id} withBorder p="sm" radius="sm">
<Stack gap={"xs"}>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Nama Banjar
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.nama}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Jumlah Penduduk
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.penduduk.toLocaleString('id-ID')}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
KK
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.kk.toLocaleString('id-ID')}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Penduduk Miskin
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.miskin.toLocaleString('id-ID')}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Tahun
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.tahun}
</Text>
</Box>
<Group justify="flex-end" gap="xs">
<Button
variant="light"
color="green"
onClick={() =>
router.push(`/admin/kependudukan/data-banjar/${item.id}`)
}
fz="xs"
px="xs"
py="xs"
>
<IconEdit size={14} />
</Button>
<Button
variant="light"
color="red"
disabled={stateDataBanjar.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
fz="xs"
px="xs"
py="xs"
>
<IconTrash size={14} />
</Button>
</Group>
</Stack>
</Paper>
))
) : (
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data banjar yang cocok
</Text>
</Center>
)}
</Stack>
</Box>
</Paper>
{/* Pagination */}
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleDelete}
text="Apakah anda yakin ingin menghapus data banjar ini?"
/>
</Box>
);
}
export default DataBanjarAdmin;

View File

@@ -0,0 +1,232 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title,
NumberInput,
Select
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import distribusiAgama from '../../../_state/kependudukan/distribusi-agama';
interface FormData {
agama: string;
jumlah: number;
tahun: number;
}
export default function EditDistribusiAgama() {
const router = useRouter();
const { id } = useParams() as { id: string };
const stateDistribusiAgama = useProxy(distribusiAgama);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<FormData>({
agama: '',
jumlah: 0,
tahun: new Date().getFullYear(),
});
const [originalData, setOriginalData] = useState<FormData>({
agama: '',
jumlah: 0,
tahun: new Date().getFullYear(),
});
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 10 }, (_, i) => ({
value: String(currentYear - i),
label: String(currentYear - i),
}));
const agamaOptions = [
{ value: 'HINDU', label: 'Hindu' },
{ value: 'ISLAM', label: 'Islam' },
{ value: 'KRISTEN', label: 'Kristen' },
{ value: 'KRISTEN_PROTESTAN', label: 'Kristen Protestan' },
{ value: 'KRISTEN_KATOLIK', label: 'Kristen Katolik' },
{ value: 'BUDDHA', label: 'Buddha' },
{ value: 'KONGHUCU', label: 'Konghucu' },
{ value: 'LAINNYA', label: 'Lainnya' },
];
const isFormValid = () => {
return (
formData.agama?.trim() !== '' &&
formData.jumlah !== null &&
formData.jumlah >= 0 &&
formData.tahun !== null
);
};
useEffect(() => {
if (!id) return;
const loadData = async () => {
try {
setIsSubmitting(true);
stateDistribusiAgama.update.id = id;
await stateDistribusiAgama.findUnique.load(id);
const data = stateDistribusiAgama.findUnique.data;
if (data) {
setFormData({
agama: data.agama ?? '',
jumlah: Number(data.jumlah ?? 0),
tahun: Number(data.tahun ?? currentYear),
});
setOriginalData({
agama: data.agama ?? '',
jumlah: Number(data.jumlah ?? 0),
tahun: Number(data.tahun ?? currentYear),
});
}
} catch (error) {
console.error('Error loading data:', error);
toast.error('Gagal memuat data');
} finally {
setIsSubmitting(false);
}
};
loadData();
}, [id]);
const handleChange = useCallback(
(field: keyof FormData) =>
(value: any) => {
const val =
field === 'jumlah' || field === 'tahun'
? Number(value || 0)
: value;
setFormData((prev) => ({ ...prev, [field]: val }));
},
[]
);
const handleResetForm = () => {
setFormData({
agama: originalData.agama,
jumlah: Number(originalData.jumlah),
tahun: Number(originalData.tahun),
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
stateDistribusiAgama.update.id = id;
stateDistribusiAgama.update.form = { ...formData };
await stateDistribusiAgama.update.submit();
toast.success('Data berhasil diperbarui');
router.push('/admin/kependudukan/distribusi-agama');
} catch (error) {
console.error('Error updating data:', error);
toast.error('Gagal memperbarui data');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Distribusi Agama
</Title>
</Group>
{/* Form Card */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<Select
label="Agama"
placeholder="Pilih agama"
data={agamaOptions}
value={formData.agama}
onChange={handleChange('agama')}
required
searchable
/>
<NumberInput
label="Jumlah"
placeholder="Masukkan jumlah"
value={formData.jumlah}
onChange={handleChange('jumlah')}
min={0}
required
/>
<Select
label="Tahun"
placeholder="Pilih tahun"
data={yearOptions}
value={String(formData.tahun)}
onChange={handleChange('tahun')}
required
/>
<Group justify="flex-end">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,174 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title,
NumberInput,
Select
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import distribusiAgama from '../../../_state/kependudukan/distribusi-agama';
import { toast } from 'react-toastify';
function CreateDistribusiAgama() {
const stateDistribusiAgama = useProxy(distribusiAgama);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const agamaOptions = [
{ value: 'HINDU', label: 'Hindu' },
{ value: 'ISLAM', label: 'Islam' },
{ value: 'KRISTEN', label: 'Kristen' },
{ value: 'KRISTEN_PROTESTAN', label: 'Kristen Protestan' },
{ value: 'KRISTEN_KATOLIK', label: 'Kristen Katolik' },
{ value: 'BUDDHA', label: 'Buddha' },
{ value: 'KONGHUCU', label: 'Konghucu' },
{ value: 'LAINNYA', label: 'Lainnya' },
];
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 10 }, (_, i) => ({
value: String(currentYear - i),
label: String(currentYear - i),
}));
const isFormValid = () => {
return (
stateDistribusiAgama.create.form.agama?.trim() !== '' &&
stateDistribusiAgama.create.form.jumlah !== null &&
stateDistribusiAgama.create.form.jumlah >= 0 &&
stateDistribusiAgama.create.form.tahun !== null
);
};
const resetForm = () => {
stateDistribusiAgama.create.form = {
agama: '',
jumlah: 0,
tahun: currentYear,
};
};
const handleSubmit = async () => {
try {
const id = await stateDistribusiAgama.create.create();
if (id) {
resetForm();
router.push('/admin/kependudukan/distribusi-agama');
}
} catch (error) {
console.error('Error creating distribusi agama:', error);
toast.error('Terjadi kesalahan saat menambah distribusi agama');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Distribusi Agama
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<Select
label="Agama"
placeholder="Pilih agama"
data={agamaOptions}
value={stateDistribusiAgama.create.form.agama}
onChange={(val) => {
stateDistribusiAgama.create.form.agama = val || '';
}}
required
searchable
/>
<NumberInput
label="Jumlah"
placeholder="Masukkan jumlah"
value={stateDistribusiAgama.create.form.jumlah}
onChange={(val) => {
stateDistribusiAgama.create.form.jumlah = Number(val || 0);
}}
min={0}
required
/>
<Select
label="Tahun"
placeholder="Pilih tahun"
data={yearOptions}
value={String(stateDistribusiAgama.create.form.tahun)}
onChange={(val) => {
stateDistribusiAgama.create.form.tahun = Number(val || currentYear);
}}
required
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateDistribusiAgama;

View File

@@ -0,0 +1,283 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Flex,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
import distribusiAgama from '../../_state/kependudukan/distribusi-agama';
function DistribusiAgamaAdmin() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title='Distribusi Agama'
placeholder='Cari agama...'
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
/>
<ListDistribusiAgama search={search} />
</Box>
);
}
function ListDistribusiAgama({ search }: { search: string }) {
type DistribusiAgamaType = {
id: string;
agama: string;
jumlah: number;
tahun: number;
};
const router = useRouter();
const stateDistribusiAgama = useProxy(distribusiAgama);
const [modalHapus, setModalHapus] = useState(false);
const [debouncedSearch] = useDebouncedValue(search, 1000);
const [selectedId, setSelectedId] = useState<string | null>(null);
const {
data,
page,
totalPages,
loading,
load,
} = stateDistribusiAgama.findMany;
const handleDelete = () => {
if (selectedId) {
stateDistribusiAgama.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
}
};
useShallowEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
);
}
return (
<Box py={{ base: 'sm', md: 'md' }}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
<Title order={4} lh={{ base: 1.2, md: 1.15 }}>
List Distribusi Agama
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/kependudukan/distribusi-agama/create')}
fz={{ base: 'sm', md: 'md' }}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table
highlightOnHover
miw={0}
style={{
tableLayout: 'fixed',
width: '100%',
}}
>
<TableThead>
<TableTr>
<TableTh style={{ width: '40%' }}>Agama</TableTh>
<TableTh style={{ width: '20%' }}>Jumlah</TableTh>
<TableTh style={{ width: '20%' }}>Tahun</TableTh>
<TableTh style={{ width: '10%' }}>Edit</TableTh>
<TableTh style={{ width: '10%' }}>Hapus</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item: DistribusiAgamaType) => (
<TableTr key={item.id}>
<TableTd>{item.agama}</TableTd>
<TableTd>{item.jumlah.toLocaleString('id-ID')}</TableTd>
<TableTd>{item.tahun}</TableTd>
<TableTd>
<Button
variant="light"
color="green"
onClick={() =>
router.push(`/admin/kependudukan/distribusi-agama/${item.id}`)
}
fz="sm"
px="xs"
py="xs"
>
<IconEdit size={16} />
</Button>
</TableTd>
<TableTd>
<Button
variant="light"
color="red"
disabled={stateDistribusiAgama.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
fz="sm"
px="xs"
py="xs"
>
<IconTrash size={16} />
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={5}>
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data distribusi agama yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Card */}
<Box hiddenFrom="md">
<Stack gap="xs">
{filteredData.length > 0 ? (
filteredData.map((item: DistribusiAgamaType) => (
<Paper key={item.id} withBorder p="sm" radius="sm">
<Stack gap={"xs"}>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Agama
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.agama}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Jumlah
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.jumlah.toLocaleString('id-ID')}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Tahun
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.tahun}
</Text>
</Box>
<Group justify="flex-end" gap="xs">
<Button
variant="light"
color="green"
onClick={() =>
router.push(`/admin/kependudukan/distribusi-agama/${item.id}`)
}
fz="xs"
px="xs"
py="xs"
>
<IconEdit size={14} />
</Button>
<Button
variant="light"
color="red"
disabled={stateDistribusiAgama.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
fz="xs"
px="xs"
py="xs"
>
<IconTrash size={14} />
</Button>
</Group>
</Stack>
</Paper>
))
) : (
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data distribusi agama yang cocok
</Text>
</Center>
)}
</Stack>
</Box>
</Paper>
{/* Pagination */}
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleDelete}
text="Apakah anda yakin ingin menghapus data distribusi agama ini?"
/>
</Box>
);
}
export default DistribusiAgamaAdmin;

View File

@@ -0,0 +1,232 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Title,
NumberInput,
Select
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import distribusiUmur from '../../../_state/kependudukan/distribusi-umur';
interface FormData {
rentangUmur: string;
jumlah: number;
tahun: number;
}
export default function EditDistribusiUmur() {
const router = useRouter();
const { id } = useParams() as { id: string };
const stateDistribusiUmur = useProxy(distribusiUmur);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<FormData>({
rentangUmur: '',
jumlah: 0,
tahun: new Date().getFullYear(),
});
const [originalData, setOriginalData] = useState<FormData>({
rentangUmur: '',
jumlah: 0,
tahun: new Date().getFullYear(),
});
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 10 }, (_, i) => ({
value: String(currentYear - i),
label: String(currentYear - i),
}));
const rentangUmurOptions = [
{ value: '0-5', label: '0-5 Tahun' },
{ value: '6-12', label: '6-12 Tahun' },
{ value: '13-17', label: '13-17 Tahun' },
{ value: '18-25', label: '18-25 Tahun' },
{ value: '26-35', label: '26-35 Tahun' },
{ value: '36-45', label: '36-45 Tahun' },
{ value: '46-55', label: '46-55 Tahun' },
{ value: '56-65', label: '56-65 Tahun' },
{ value: '65+', label: '65+ Tahun' },
];
const isFormValid = () => {
return (
formData.rentangUmur?.trim() !== '' &&
formData.jumlah !== null &&
formData.jumlah >= 0 &&
formData.tahun !== null
);
};
useEffect(() => {
if (!id) return;
const loadData = async () => {
try {
setIsSubmitting(true);
stateDistribusiUmur.update.id = id;
await stateDistribusiUmur.findUnique.load(id);
const data = stateDistribusiUmur.findUnique.data;
if (data) {
setFormData({
rentangUmur: data.rentangUmur ?? '',
jumlah: Number(data.jumlah ?? 0),
tahun: Number(data.tahun ?? currentYear),
});
setOriginalData({
rentangUmur: data.rentangUmur ?? '',
jumlah: Number(data.jumlah ?? 0),
tahun: Number(data.tahun ?? currentYear),
});
}
} catch (error) {
console.error('Error loading data:', error);
toast.error('Gagal memuat data');
} finally {
setIsSubmitting(false);
}
};
loadData();
}, [id]);
const handleChange = useCallback(
(field: keyof FormData) =>
(value: any) => {
const val =
field === 'jumlah' || field === 'tahun'
? Number(value || 0)
: value;
setFormData((prev) => ({ ...prev, [field]: val }));
},
[]
);
const handleResetForm = () => {
setFormData({
rentangUmur: originalData.rentangUmur,
jumlah: Number(originalData.jumlah),
tahun: Number(originalData.tahun),
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
stateDistribusiUmur.update.id = id;
stateDistribusiUmur.update.form = { ...formData };
await stateDistribusiUmur.update.submit();
toast.success('Data berhasil diperbarui');
router.push('/admin/kependudukan/distribusi-umur');
} catch (error) {
console.error('Error updating data:', error);
toast.error('Gagal memperbarui data');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Distribusi Umur
</Title>
</Group>
{/* Form Card */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<Select
label="Rentang Umur"
placeholder="Pilih rentang umur"
data={rentangUmurOptions}
value={formData.rentangUmur}
onChange={handleChange('rentangUmur')}
required
searchable
/>
<NumberInput
label="Jumlah"
placeholder="Masukkan jumlah"
value={formData.jumlah}
onChange={handleChange('jumlah')}
min={0}
required
/>
<Select
label="Tahun"
placeholder="Pilih tahun"
data={yearOptions}
value={String(formData.tahun)}
onChange={handleChange('tahun')}
required
/>
<Group justify="flex-end">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,174 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Title,
NumberInput,
Select
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import distribusiUmur from '../../../_state/kependudukan/distribusi-umur';
import { toast } from 'react-toastify';
function CreateDistribusiUmur() {
const stateDistribusiUmur = useProxy(distribusiUmur);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const rentangUmurOptions = [
{ value: '0-5', label: '0-5 Tahun' },
{ value: '6-12', label: '6-12 Tahun' },
{ value: '13-17', label: '13-17 Tahun' },
{ value: '18-25', label: '18-25 Tahun' },
{ value: '26-35', label: '26-35 Tahun' },
{ value: '36-45', label: '36-45 Tahun' },
{ value: '46-55', label: '46-55 Tahun' },
{ value: '56-65', label: '56-65 Tahun' },
{ value: '65+', label: '65+ Tahun' },
];
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 10 }, (_, i) => ({
value: String(currentYear - i),
label: String(currentYear - i),
}));
const isFormValid = () => {
return (
stateDistribusiUmur.create.form.rentangUmur?.trim() !== '' &&
stateDistribusiUmur.create.form.jumlah !== null &&
stateDistribusiUmur.create.form.jumlah >= 0 &&
stateDistribusiUmur.create.form.tahun !== null
);
};
const resetForm = () => {
stateDistribusiUmur.create.form = {
rentangUmur: '',
jumlah: 0,
tahun: currentYear,
};
};
const handleSubmit = async () => {
try {
const id = await stateDistribusiUmur.create.create();
if (id) {
resetForm();
router.push('/admin/kependudukan/distribusi-umur');
}
} catch (error) {
console.error('Error creating distribusi umur:', error);
toast.error('Terjadi kesalahan saat menambah distribusi umur');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Distribusi Umur
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<Select
label="Rentang Umur"
placeholder="Pilih rentang umur"
data={rentangUmurOptions}
value={stateDistribusiUmur.create.form.rentangUmur}
onChange={(val) => {
stateDistribusiUmur.create.form.rentangUmur = val || '';
}}
required
searchable
/>
<NumberInput
label="Jumlah"
placeholder="Masukkan jumlah"
value={stateDistribusiUmur.create.form.jumlah}
onChange={(val) => {
stateDistribusiUmur.create.form.jumlah = Number(val || 0);
}}
min={0}
required
/>
<Select
label="Tahun"
placeholder="Pilih tahun"
data={yearOptions}
value={String(stateDistribusiUmur.create.form.tahun)}
onChange={(val) => {
stateDistribusiUmur.create.form.tahun = Number(val || currentYear);
}}
required
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateDistribusiUmur;

View File

@@ -0,0 +1,284 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Flex,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
import distribusiUmur from '../../_state/kependudukan/distribusi-umur';
function DistribusiUmurAdmin() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title='Distribusi Umur'
placeholder='Cari rentang umur...'
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
/>
<ListDistribusiUmur search={search} />
</Box>
);
}
function ListDistribusiUmur({ search }: { search: string }) {
type DistribusiUmurType = {
id: string;
rentangUmur: string;
jumlah: number;
tahun: number;
};
const router = useRouter();
const stateDistribusiUmur = useProxy(distribusiUmur);
const [modalHapus, setModalHapus] = useState(false);
const [debouncedSearch] = useDebouncedValue(search, 1000);
const [selectedId, setSelectedId] = useState<string | null>(null);
const {
data,
page,
totalPages,
loading,
load,
} = stateDistribusiUmur.findMany;
const handleDelete = () => {
if (selectedId) {
stateDistribusiUmur.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
}
};
useShallowEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
);
}
return (
<Box py={{ base: 'sm', md: 'md' }}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
<Title order={4} lh={{ base: 1.2, md: 1.15 }}>
List Distribusi Umur
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/kependudukan/distribusi-umur/create')}
fz={{ base: 'sm', md: 'md' }}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table
highlightOnHover
miw={0}
style={{
tableLayout: 'fixed',
width: '100%',
}}
>
<TableThead>
<TableTr>
<TableTh style={{ width: '40%' }}>Rentang Umur</TableTh>
<TableTh style={{ width: '20%' }}>Jumlah</TableTh>
<TableTh style={{ width: '20%' }}>Tahun</TableTh>
<TableTh style={{ width: '10%' }}>Edit</TableTh>
<TableTh style={{ width: '10%' }}>Hapus</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item: DistribusiUmurType) => (
<TableTr key={item.id}>
<TableTd>{item.rentangUmur}</TableTd>
<TableTd>{item.jumlah.toLocaleString('id-ID')}</TableTd>
<TableTd>{item.tahun}</TableTd>
<TableTd>
<Button
variant="light"
color="green"
onClick={() =>
router.push(`/admin/kependudukan/distribusi-umur/${item.id}`)
}
fz="sm"
px="xs"
py="xs"
>
<IconEdit size={16} />
</Button>
</TableTd>
<TableTd>
<Button
variant="light"
color="red"
disabled={stateDistribusiUmur.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
fz="sm"
px="xs"
py="xs"
>
<IconTrash size={16} />
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={5}>
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data distribusi umur yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Card */}
<Box hiddenFrom="md">
<Stack gap="xs">
{filteredData.length > 0 ? (
filteredData.map((item: DistribusiUmurType) => (
<Paper key={item.id} withBorder p="sm" radius="sm">
<Stack gap={"xs"}>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Rentang Umur
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.rentangUmur}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Jumlah
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.jumlah.toLocaleString('id-ID')}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Tahun
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.tahun}
</Text>
</Box>
<Group justify="flex-end" gap="xs">
<Button
variant="light"
color="green"
onClick={() =>
router.push(`/admin/kependudukan/distribusi-umur/${item.id}`)
}
fz="xs"
px="xs"
py="xs"
>
<IconEdit size={14} />
</Button>
<Button
variant="light"
color="red"
disabled={stateDistribusiUmur.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
fz="xs"
px="xs"
py="xs"
>
<IconTrash size={14} />
</Button>
</Group>
</Stack>
</Paper>
))
) : (
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data distribusi umur yang cocok
</Text>
</Center>
)}
</Stack>
</Box>
</Paper>
{/* Pagination */}
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleDelete}
text="Apakah anda yakin ingin menghapus data distribusi umur ini?"
/>
</Box>
);
}
export default DistribusiUmurAdmin;

View File

@@ -0,0 +1,267 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Title,
TextInput,
Select,
Textarea
} from '@mantine/core';
import { DatePickerInput } from '@mantine/dates';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import migrasiPenduduk from '../../../_state/kependudukan/migrasi-penduduk';
interface FormData {
jenis: string;
nama: string;
tanggal: string;
asalTujuan: string;
alasan: string;
jenisKelamin: string;
}
export default function EditMigrasiPenduduk() {
const router = useRouter();
const { id } = useParams() as { id: string };
const stateMigrasiPenduduk = useProxy(migrasiPenduduk);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<FormData>({
jenis: '',
nama: '',
tanggal: '',
asalTujuan: '',
alasan: '',
jenisKelamin: '',
});
const [originalData, setOriginalData] = useState<FormData>({
jenis: '',
nama: '',
tanggal: '',
asalTujuan: '',
alasan: '',
jenisKelamin: '',
});
const jenisOptions = [
{ value: 'MASUK', label: 'Masuk' },
{ value: 'KELUAR', label: 'Keluar' },
];
const jenisKelaminOptions = [
{ value: 'L', label: 'Laki-laki' },
{ value: 'P', label: 'Perempuan' },
];
const isFormValid = () => {
return (
formData.jenis?.trim() !== '' &&
formData.nama?.trim() !== '' &&
formData.tanggal?.trim() !== '' &&
formData.asalTujuan?.trim() !== ''
);
};
useEffect(() => {
if (!id) return;
const loadData = async () => {
try {
setIsSubmitting(true);
stateMigrasiPenduduk.update.id = id;
await stateMigrasiPenduduk.findUnique.load(id);
const data = stateMigrasiPenduduk.findUnique.data;
if (data) {
setFormData({
jenis: data.jenis ?? '',
nama: data.nama ?? '',
tanggal: data.tanggal ?? '',
asalTujuan: data.asalTujuan ?? '',
alasan: data.alasan ?? '',
jenisKelamin: data.jenisKelamin ?? '',
});
setOriginalData({
jenis: data.jenis ?? '',
nama: data.nama ?? '',
tanggal: data.tanggal ?? '',
asalTujuan: data.asalTujuan ?? '',
alasan: data.alasan ?? '',
jenisKelamin: data.jenisKelamin ?? '',
});
}
} catch (error) {
console.error('Error loading data:', error);
toast.error('Gagal memuat data');
} finally {
setIsSubmitting(false);
}
};
loadData();
}, [id]);
const handleChange = useCallback(
(field: keyof FormData) =>
(value: any) => {
const val = value || '';
setFormData((prev) => ({ ...prev, [field]: val }));
},
[]
);
const handleResetForm = () => {
setFormData({
jenis: originalData.jenis,
nama: originalData.nama,
tanggal: originalData.tanggal,
asalTujuan: originalData.asalTujuan,
alasan: originalData.alasan,
jenisKelamin: originalData.jenisKelamin,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
stateMigrasiPenduduk.update.id = id;
stateMigrasiPenduduk.update.form = { ...formData };
await stateMigrasiPenduduk.update.submit();
toast.success('Data berhasil diperbarui');
router.push('/admin/kependudukan/migrasi-penduduk');
} catch (error) {
console.error('Error updating data:', error);
toast.error('Gagal memperbarui data');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Migrasi Penduduk
</Title>
</Group>
{/* Form Card */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<Select
label="Jenis Migrasi"
placeholder="Pilih jenis migrasi"
data={jenisOptions}
value={formData.jenis}
onChange={handleChange('jenis')}
required
/>
<TextInput
label="Nama"
placeholder="Masukkan nama lengkap"
value={formData.nama}
onChange={handleChange('nama')}
required
/>
<DatePickerInput
label="Tanggal"
placeholder="Pilih tanggal"
value={formData.tanggal ? new Date(formData.tanggal) : null}
onChange={(val: string | null) => {
setFormData((prev) => ({
...prev,
tanggal: val || '',
}));
}}
required
/>
<TextInput
label={formData.jenis === 'MASUK' ? 'Asal' : 'Tujuan'}
placeholder={formData.jenis === 'MASUK' ? 'Masukkan asal' : 'Masukkan tujuan'}
value={formData.asalTujuan}
onChange={handleChange('asalTujuan')}
required
/>
<Textarea
label="Alasan"
placeholder="Masukkan alasan (opsional)"
value={formData.alasan}
onChange={handleChange('alasan')}
autosize
minRows={2}
/>
<Select
label="Jenis Kelamin"
placeholder="Pilih jenis kelamin"
data={jenisKelaminOptions}
value={formData.jenisKelamin}
onChange={handleChange('jenisKelamin')}
/>
<Group justify="flex-end">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,199 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Title,
TextInput,
Select,
Textarea
} from '@mantine/core';
import { DatePickerInput } from '@mantine/dates';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import migrasiPenduduk from '../../../_state/kependudukan/migrasi-penduduk';
import { toast } from 'react-toastify';
function CreateMigrasiPenduduk() {
const stateMigrasiPenduduk = useProxy(migrasiPenduduk);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const jenisOptions = [
{ value: 'MASUK', label: 'Masuk' },
{ value: 'KELUAR', label: 'Keluar' },
];
const jenisKelaminOptions = [
{ value: 'L', label: 'Laki-laki' },
{ value: 'P', label: 'Perempuan' },
];
const isFormValid = () => {
return (
stateMigrasiPenduduk.create.form.jenis?.trim() !== '' &&
stateMigrasiPenduduk.create.form.nama?.trim() !== '' &&
stateMigrasiPenduduk.create.form.tanggal?.trim() !== '' &&
stateMigrasiPenduduk.create.form.asalTujuan?.trim() !== ''
);
};
const resetForm = () => {
stateMigrasiPenduduk.create.form = {
jenis: '',
nama: '',
tanggal: '',
asalTujuan: '',
alasan: '',
jenisKelamin: '',
};
};
const handleSubmit = async () => {
try {
const id = await stateMigrasiPenduduk.create.create();
if (id) {
resetForm();
router.push('/admin/kependudukan/migrasi-penduduk');
}
} catch (error) {
console.error('Error creating migrasi penduduk:', error);
toast.error('Terjadi kesalahan saat menambah data migrasi penduduk');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Migrasi Penduduk
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<Select
label="Jenis Migrasi"
placeholder="Pilih jenis migrasi"
data={jenisOptions}
value={stateMigrasiPenduduk.create.form.jenis}
onChange={(val) => {
stateMigrasiPenduduk.create.form.jenis = val || '';
}}
required
/>
<TextInput
label="Nama"
placeholder="Masukkan nama lengkap"
value={stateMigrasiPenduduk.create.form.nama}
onChange={(e) => {
stateMigrasiPenduduk.create.form.nama = e.currentTarget.value;
}}
required
/>
<DatePickerInput
label="Tanggal"
placeholder="Pilih tanggal"
value={stateMigrasiPenduduk.create.form.tanggal ? new Date(stateMigrasiPenduduk.create.form.tanggal) : null}
onChange={(val: string | null) => {
stateMigrasiPenduduk.create.form.tanggal = val || '';
}}
required
/>
<TextInput
label={stateMigrasiPenduduk.create.form.jenis === 'MASUK' ? 'Asal' : 'Tujuan'}
placeholder={stateMigrasiPenduduk.create.form.jenis === 'MASUK' ? 'Masukkan asal' : 'Masukkan tujuan'}
value={stateMigrasiPenduduk.create.form.asalTujuan}
onChange={(e) => {
stateMigrasiPenduduk.create.form.asalTujuan = e.currentTarget.value;
}}
required
/>
<Textarea
label="Alasan"
placeholder="Masukkan alasan (opsional)"
value={stateMigrasiPenduduk.create.form.alasan}
onChange={(e) => {
stateMigrasiPenduduk.create.form.alasan = e.currentTarget.value;
}}
autosize
minRows={2}
/>
<Select
label="Jenis Kelamin"
placeholder="Pilih jenis kelamin"
data={jenisKelaminOptions}
value={stateMigrasiPenduduk.create.form.jenisKelamin}
onChange={(val) => {
stateMigrasiPenduduk.create.form.jenisKelamin = val || '';
}}
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateMigrasiPenduduk;

View File

@@ -0,0 +1,339 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
import migrasiPenduduk from '../../_state/kependudukan/migrasi-penduduk';
function MigrasiPendudukAdmin() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title='Migrasi Penduduk'
placeholder='Cari nama...'
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
/>
<ListMigrasiPenduduk search={search} />
</Box>
);
}
function ListMigrasiPenduduk({ search }: { search: string }) {
type MigrasiPendudukType = {
id: string;
jenis: string;
nama: string;
tanggal: string;
asalTujuan: string;
alasan: string | null;
jenisKelamin: string | null;
};
const router = useRouter();
const stateMigrasiPenduduk = useProxy(migrasiPenduduk);
const [modalHapus, setModalHapus] = useState(false);
const [debouncedSearch] = useDebouncedValue(search, 1000);
const [selectedId, setSelectedId] = useState<string | null>(null);
const {
data,
page,
totalPages,
loading,
load,
} = stateMigrasiPenduduk.findMany;
const handleDelete = () => {
if (selectedId) {
stateMigrasiPenduduk.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
}
};
useShallowEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
);
}
const formatTanggal = (tanggal: string) => {
try {
return new Date(tanggal).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
} catch {
return tanggal;
}
};
return (
<Box py={{ base: 'sm', md: 'md' }}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
<Title order={4} lh={{ base: 1.2, md: 1.15 }}>
List Migrasi Penduduk
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/kependudukan/migrasi-penduduk/create')}
fz={{ base: 'sm', md: 'md' }}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table
highlightOnHover
miw={0}
style={{
tableLayout: 'fixed',
width: '100%',
}}
>
<TableThead>
<TableTr>
<TableTh style={{ width: '10%' }}>Jenis</TableTh>
<TableTh style={{ width: '20%' }}>Nama</TableTh>
<TableTh style={{ width: '12%' }}>Tanggal</TableTh>
<TableTh style={{ width: '20%' }}>Asal/Tujuan</TableTh>
<TableTh style={{ width: '10%' }}>L/P</TableTh>
<TableTh style={{ width: '10%' }}>Edit</TableTh>
<TableTh style={{ width: '10%' }}>Hapus</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item: MigrasiPendudukType) => (
<TableTr key={item.id}>
<TableTd>
<Text
fz="sm"
fw={500}
c={item.jenis === 'MASUK' ? 'green' : 'red'}
>
{item.jenis}
</Text>
</TableTd>
<TableTd>{item.nama}</TableTd>
<TableTd>{formatTanggal(item.tanggal)}</TableTd>
<TableTd>{item.asalTujuan}</TableTd>
<TableTd>{item.jenisKelamin || '-'}</TableTd>
<TableTd>
<Button
variant="light"
color="green"
onClick={() =>
router.push(`/admin/kependudukan/migrasi-penduduk/${item.id}`)
}
fz="sm"
px="xs"
py="xs"
>
<IconEdit size={16} />
</Button>
</TableTd>
<TableTd>
<Button
variant="light"
color="red"
disabled={stateMigrasiPenduduk.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
fz="sm"
px="xs"
py="xs"
>
<IconTrash size={16} />
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={7}>
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data migrasi penduduk yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Card */}
<Box hiddenFrom="md">
<Stack gap="xs">
{filteredData.length > 0 ? (
filteredData.map((item: MigrasiPendudukType) => (
<Paper key={item.id} withBorder p="sm" radius="sm">
<Stack gap={"xs"}>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Jenis Migrasi
</Text>
<Text
fz="sm"
fw={500}
c={item.jenis === 'MASUK' ? 'green' : 'red'}
>
{item.jenis}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Nama
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.nama}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Tanggal
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{formatTanggal(item.tanggal)}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Asal/Tujuan
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.asalTujuan}
</Text>
</Box>
{item.alasan && (
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Alasan
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.alasan}
</Text>
</Box>
)}
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Jenis Kelamin
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.jenisKelamin || '-'}
</Text>
</Box>
<Group justify="flex-end" gap="xs">
<Button
variant="light"
color="green"
onClick={() =>
router.push(`/admin/kependudukan/migrasi-penduduk/${item.id}`)
}
fz="xs"
px="xs"
py="xs"
>
<IconEdit size={14} />
</Button>
<Button
variant="light"
color="red"
disabled={stateMigrasiPenduduk.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
fz="xs"
px="xs"
py="xs"
>
<IconTrash size={14} />
</Button>
</Group>
</Stack>
</Paper>
))
) : (
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data migrasi penduduk yang cocok
</Text>
</Center>
)}
</Stack>
</Box>
</Paper>
{/* Pagination */}
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleDelete}
text="Apakah anda yakin ingin menghapus data migrasi penduduk ini?"
/>
</Box>
);
}
export default MigrasiPendudukAdmin;

View File

@@ -373,6 +373,33 @@ export const devBar = [
}
]
},
{
id: "Kependudukan",
name: "Kependudukan",
path: "",
children: [
{
id: "Kependudukan_1",
name: "Distribusi Agama",
path: "/admin/kependudukan/distribusi-agama"
},
{
id: "Kependudukan_2",
name: "Distribusi Umur",
path: "/admin/kependudukan/distribusi-umur"
},
{
id: "Kependudukan_3",
name: "Data Banjar",
path: "/admin/kependudukan/data-banjar"
},
{
id: "Kependudukan_4",
name: "Migrasi Penduduk",
path: "/admin/kependudukan/migrasi-penduduk"
}
]
},
{
id: "Musik",
name: "Musik",
@@ -777,6 +804,33 @@ export const navBar = [
}
]
},
{
id: "Kependudukan",
name: "Kependudukan",
path: "",
children: [
{
id: "Kependudukan_1",
name: "Distribusi Agama",
path: "/admin/kependudukan/distribusi-agama"
},
{
id: "Kependudukan_2",
name: "Distribusi Umur",
path: "/admin/kependudukan/distribusi-umur"
},
{
id: "Kependudukan_3",
name: "Data Banjar",
path: "/admin/kependudukan/data-banjar"
},
{
id: "Kependudukan_4",
name: "Migrasi Penduduk",
path: "/admin/kependudukan/migrasi-penduduk"
}
]
},
{
id: "Musik",
name: "Musik",
@@ -1098,6 +1152,33 @@ export const role1 = [
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
}
]
},
{
id: "Kependudukan",
name: "Kependudukan",
path: "",
children: [
{
id: "Kependudukan_1",
name: "Distribusi Agama",
path: "/admin/kependudukan/distribusi-agama"
},
{
id: "Kependudukan_2",
name: "Distribusi Umur",
path: "/admin/kependudukan/distribusi-umur"
},
{
id: "Kependudukan_3",
name: "Data Banjar",
path: "/admin/kependudukan/data-banjar"
},
{
id: "Kependudukan_4",
name: "Migrasi Penduduk",
path: "/admin/kependudukan/migrasi-penduduk"
}
]
},
{
id: "Musik",

View File

@@ -0,0 +1,10 @@
import Elysia from "elysia";
import dashboardSummary from "./summary";
const DashboardKependudukan = new Elysia({
prefix: "/dashboard",
tags: ["Kependudukan/Dashboard"],
})
.get("/summary", dashboardSummary)
export default DashboardKependudukan;

View File

@@ -0,0 +1,147 @@
import prisma from "@/lib/prisma";
export default async function dashboardSummary() {
try {
const currentYear = new Date().getFullYear();
// Get dashboard summary
const [
totalPenduduk,
totalKK,
totalKelahiran,
totalKemiskinan,
kelahiranData,
kematianData,
pindahMasukData,
pindahKeluarData,
agamaData,
umurData,
banjarData
] = await Promise.all([
// Total penduduk - hitung dari data banjar
prisma.dataBanjar.aggregate({
where: { isActive: true, tahun: currentYear },
_sum: { penduduk: true }
}),
// Total KK
prisma.dataBanjar.aggregate({
where: { isActive: true, tahun: currentYear },
_sum: { kk: true }
}),
// Total kelahiran tahun ini
prisma.kelahiran.count({
where: {
isActive: true,
tanggal: {
gte: new Date(`${currentYear}-01-01`),
lte: new Date(`${currentYear}-12-31`),
}
}
}),
// Total penduduk miskin
prisma.dataBanjar.aggregate({
where: { isActive: true, tahun: currentYear },
_sum: { miskin: true }
}),
// Kelahiran data
prisma.kelahiran.findMany({
where: {
isActive: true,
tanggal: {
gte: new Date(`${currentYear}-01-01`),
lte: new Date(`${currentYear}-12-31`),
}
},
orderBy: { tanggal: 'asc' }
}),
// Kematian data
prisma.kematian.findMany({
where: {
isActive: true,
tanggal: {
gte: new Date(`${currentYear}-01-01`),
lte: new Date(`${currentYear}-12-31`),
}
},
orderBy: { tanggal: 'asc' }
}),
// Pindah masuk
prisma.migrasiPenduduk.count({
where: {
isActive: true,
jenis: 'MASUK',
tanggal: {
gte: new Date(`${currentYear}-01-01`),
lte: new Date(`${currentYear}-12-31`),
}
}
}),
// Pindah keluar
prisma.migrasiPenduduk.count({
where: {
isActive: true,
jenis: 'KELUAR',
tanggal: {
gte: new Date(`${currentYear}-01-01`),
lte: new Date(`${currentYear}-12-31`),
}
}
}),
// Data agama
prisma.distribusiAgama.findMany({
where: { isActive: true, tahun: currentYear },
orderBy: { jumlah: 'desc' }
}),
// Data umur
prisma.distribusiUmur.findMany({
where: { isActive: true, tahun: currentYear },
orderBy: { createdAt: 'asc' }
}),
// Data banjar
prisma.dataBanjar.findMany({
where: { isActive: true, tahun: currentYear },
orderBy: { nama: 'asc' }
})
]);
return {
success: true,
message: "Dashboard summary berhasil diambil",
data: {
tahun: currentYear,
summary: {
totalPenduduk: totalPenduduk._sum.penduduk || 0,
totalKK: totalKK._sum.kk || 0,
totalKelahiran: totalKelahiran,
totalKemiskinan: totalKemiskinan._sum.miskin || 0,
},
dinamika: {
kelahiran: totalKelahiran,
kematian: kematianData.length,
pindahMasuk: pindahMasukData,
pindahKeluar: pindahKeluarData,
},
agama: agamaData,
umur: umurData,
banjar: banjarData,
}
};
} catch (error) {
console.error("Error fetching dashboard summary:", error);
return {
success: false,
message: "Terjadi kesalahan saat mengambil data dashboard",
data: null,
};
}
}

View File

@@ -0,0 +1,40 @@
import prisma from "@/lib/prisma";
import { Prisma } from "@prisma/client";
import { Context } from "elysia";
type FormCreate = Prisma.DataBanjarGetPayload<{
select: {
nama: true;
penduduk: true;
kk: true;
miskin: true;
tahun: true;
}
}>
export default async function dataBanjarCreate(context: Context) {
const body = context.body as FormCreate;
const created = await prisma.dataBanjar.create({
data: {
nama: body.nama,
penduduk: body.penduduk,
kk: body.kk,
miskin: body.miskin,
tahun: body.tahun,
},
select: {
id: true,
nama: true,
penduduk: true,
kk: true,
miskin: true,
tahun: true,
}
});
return {
success: true,
message: "Sukses menambahkan data banjar",
data: created,
};
}

View File

@@ -0,0 +1,36 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function dataBanjarDelete(context: Context) {
const id = context.params?.id;
if (!id) {
return {
success: false,
message: "ID tidak ditemukan",
}
}
const existing = await prisma.dataBanjar.findUnique({
where: {
id: id,
},
})
if (!existing) {
return {
success: false,
message: "Data tidak ditemukan",
}
}
const deleted = await prisma.dataBanjar.delete({
where: { id },
})
return {
success: true,
message: "Data berhasil dihapus",
data: deleted,
}
}

View File

@@ -0,0 +1,49 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function dataBanjarFindMany(context: Context) {
const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 100;
const search = (context.query.search as string) || '';
const tahun = Number(context.query.tahun) || new Date().getFullYear();
const skip = (page - 1) * limit;
const where: any = { isActive: true, tahun };
if (search) {
where.OR = [
{ nama: { contains: search, mode: "insensitive" } },
];
}
try {
const [data, total] = await Promise.all([
prisma.dataBanjar.findMany({
where,
skip,
take: limit,
orderBy: { nama: "asc" },
}),
prisma.dataBanjar.count({
where,
}),
]);
return {
success: true,
message: "Success fetch data banjar with pagination",
data,
page,
totalPages: Math.ceil(total / limit),
total,
};
} catch (e) {
console.error(e);
return {
success: false,
message: "Failed to fetch data banjar with pagination",
data: null,
};
}
}

View File

@@ -0,0 +1,46 @@
import prisma from "@/lib/prisma";
export default async function dataBanjarFindUnique(request: Request) {
const url = new URL(request.url);
const pathSegments = url.pathname.split('/');
const id = pathSegments[pathSegments.length - 1];
if (!id) {
return Response.json({
success: false,
message: "ID tidak boleh kosong",
}, { status: 400 });
}
try {
if (typeof id !== 'string') {
return Response.json({
success: false,
message: "ID tidak valid",
}, { status: 400 });
}
const data = await prisma.dataBanjar.findUnique({
where: { id },
});
if (!data) {
return Response.json({
success: false,
message: "Data tidak ditemukan",
}, { status: 404 });
}
return Response.json({
success: true,
message: "Data ditemukan",
data: data,
}, { status: 200 });
} catch (error) {
console.error("Error fetching data:", error);
return Response.json({
success: false,
message: "Terjadi kesalahan saat mengambil data",
}, { status: 500 });
}
}

View File

@@ -0,0 +1,43 @@
import Elysia, { t } from "elysia";
import dataBanjarFindUnique from "./findUnique";
import dataBanjarUpdate from "./updt";
import dataBanjarFindMany from "./findMany";
import dataBanjarCreate from "./create";
import dataBanjarDelete from "./del";
const DataBanjar = new Elysia({
prefix: "/databanjar",
tags: ["Kependudukan/Data Banjar"],
})
.get("/:id", async (context) => {
const response = await dataBanjarFindUnique(new Request(context.request))
return response
})
.get("/find-many", dataBanjarFindMany)
.post("/create", dataBanjarCreate, {
body: t.Object({
nama: t.String(),
penduduk: t.Number(),
kk: t.Number(),
miskin: t.Number(),
tahun: t.Number(),
}),
})
.put("/:id", dataBanjarUpdate, {
params: t.Object({
id: t.String(),
}),
body: t.Object({
nama: t.String(),
penduduk: t.Number(),
kk: t.Number(),
miskin: t.Number(),
tahun: t.Number(),
}),
})
.delete("/del/:id", dataBanjarDelete, {
params: t.Object({
id: t.String(),
}),
})
export default DataBanjar;

View File

@@ -0,0 +1,51 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function dataBanjarUpdate(context: Context) {
const id = context.params?.id;
if (!id) {
return {
success: false,
message: "ID tidak ditemukan",
}
}
const {nama, penduduk, kk, miskin, tahun} = context.body as {
nama: string;
penduduk: number;
kk: number;
miskin: number;
tahun: number;
}
const existing = await prisma.dataBanjar.findUnique({
where: {
id: id,
},
})
if (!existing) {
return {
success: false,
message: "Data tidak ditemukan",
}
}
const updated = await prisma.dataBanjar.update({
where: { id },
data: {
nama,
penduduk,
kk,
miskin,
tahun,
},
})
return {
success: true,
message: "Data berhasil diupdate",
data: updated,
}
}

View File

@@ -0,0 +1,34 @@
import prisma from "@/lib/prisma";
import { Prisma } from "@prisma/client";
import { Context } from "elysia";
type FormCreate = Prisma.DistribusiAgamaGetPayload<{
select: {
agama: true;
jumlah: true;
tahun: true;
}
}>
export default async function distribusiAgamaCreate(context: Context) {
const body = context.body as FormCreate;
const created = await prisma.distribusiAgama.create({
data: {
agama: body.agama,
jumlah: body.jumlah,
tahun: body.tahun,
},
select: {
id: true,
agama: true,
jumlah: true,
tahun: true,
}
});
return {
success: true,
message: "Sukses menambahkan distribusi agama",
data: created,
};
}

View File

@@ -0,0 +1,36 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function distribusiAgamaDelete(context: Context) {
const id = context.params?.id;
if (!id) {
return {
success: false,
message: "ID tidak ditemukan",
}
}
const existing = await prisma.distribusiAgama.findUnique({
where: {
id: id,
},
})
if (!existing) {
return {
success: false,
message: "Data tidak ditemukan",
}
}
const deleted = await prisma.distribusiAgama.delete({
where: { id },
})
return {
success: true,
message: "Data berhasil dihapus",
data: deleted,
}
}

View File

@@ -0,0 +1,50 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function distribusiAgamaFindMany(context: Context) {
const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 100;
const search = (context.query.search as string) || '';
const tahun = Number(context.query.tahun) || new Date().getFullYear();
const skip = (page - 1) * limit;
const where: any = { isActive: true, tahun };
// Tambahkan pencarian (jika ada)
if (search) {
where.OR = [
{ agama: { contains: search, mode: "insensitive" } },
];
}
try {
const [data, total] = await Promise.all([
prisma.distribusiAgama.findMany({
where,
skip,
take: limit,
orderBy: { jumlah: "desc" },
}),
prisma.distribusiAgama.count({
where,
}),
]);
return {
success: true,
message: "Success fetch distribusi agama with pagination",
data,
page,
totalPages: Math.ceil(total / limit),
total,
};
} catch (e) {
console.error(e);
return {
success: false,
message: "Failed to fetch distribusi agama with pagination",
data: null,
};
}
}

View File

@@ -0,0 +1,46 @@
import prisma from "@/lib/prisma";
export default async function distribusiAgamaFindUnique(request: Request) {
const url = new URL(request.url);
const pathSegments = url.pathname.split('/');
const id = pathSegments[pathSegments.length - 1];
if (!id) {
return Response.json({
success: false,
message: "ID tidak boleh kosong",
}, { status: 400 });
}
try {
if (typeof id !== 'string') {
return Response.json({
success: false,
message: "ID tidak valid",
}, { status: 400 });
}
const data = await prisma.distribusiAgama.findUnique({
where: { id },
});
if (!data) {
return Response.json({
success: false,
message: "Data tidak ditemukan",
}, { status: 404 });
}
return Response.json({
success: true,
message: "Data ditemukan",
data: data,
}, { status: 200 });
} catch (error) {
console.error("Error fetching data:", error);
return Response.json({
success: false,
message: "Terjadi kesalahan saat mengambil data",
}, { status: 500 });
}
}

View File

@@ -0,0 +1,39 @@
import Elysia, { t } from "elysia";
import distribusiAgamaFindUnique from "./findUnique";
import distribusiAgamaUpdate from "./updt";
import distribusiAgamaFindMany from "./findMany";
import distribusiAgamaCreate from "./create";
import distribusiAgamaDelete from "./del";
const DistribusiAgama = new Elysia({
prefix: "/distribusiagama",
tags: ["Kependudukan/Distribusi Agama"],
})
.get("/:id", async (context) => {
const response = await distribusiAgamaFindUnique(new Request(context.request))
return response
})
.get("/find-many", distribusiAgamaFindMany)
.post("/create", distribusiAgamaCreate, {
body: t.Object({
agama: t.String(),
jumlah: t.Number(),
tahun: t.Number(),
}),
})
.put("/:id", distribusiAgamaUpdate, {
params: t.Object({
id: t.String(),
}),
body: t.Object({
agama: t.String(),
jumlah: t.Number(),
tahun: t.Number(),
}),
})
.delete("/del/:id", distribusiAgamaDelete, {
params: t.Object({
id: t.String(),
}),
})
export default DistribusiAgama;

View File

@@ -0,0 +1,47 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function distribusiAgamaUpdate(context: Context) {
const id = context.params?.id;
if (!id) {
return {
success: false,
message: "ID tidak ditemukan",
}
}
const {agama, jumlah, tahun} = context.body as {
agama: string;
jumlah: number;
tahun: number;
}
const existing = await prisma.distribusiAgama.findUnique({
where: {
id: id,
},
})
if (!existing) {
return {
success: false,
message: "Data tidak ditemukan",
}
}
const updated = await prisma.distribusiAgama.update({
where: { id },
data: {
agama,
jumlah,
tahun,
},
})
return {
success: true,
message: "Data berhasil diupdate",
data: updated,
}
}

View File

@@ -0,0 +1,34 @@
import prisma from "@/lib/prisma";
import { Prisma } from "@prisma/client";
import { Context } from "elysia";
type FormCreate = Prisma.DistribusiUmurGetPayload<{
select: {
rentangUmur: true;
jumlah: true;
tahun: true;
}
}>
export default async function distribusiUmurCreate(context: Context) {
const body = context.body as FormCreate;
const created = await prisma.distribusiUmur.create({
data: {
rentangUmur: body.rentangUmur,
jumlah: body.jumlah,
tahun: body.tahun,
},
select: {
id: true,
rentangUmur: true,
jumlah: true,
tahun: true,
}
});
return {
success: true,
message: "Sukses menambahkan distribusi umur",
data: created,
};
}

View File

@@ -0,0 +1,36 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function distribusiUmurDelete(context: Context) {
const id = context.params?.id;
if (!id) {
return {
success: false,
message: "ID tidak ditemukan",
}
}
const existing = await prisma.distribusiUmur.findUnique({
where: {
id: id,
},
})
if (!existing) {
return {
success: false,
message: "Data tidak ditemukan",
}
}
const deleted = await prisma.distribusiUmur.delete({
where: { id },
})
return {
success: true,
message: "Data berhasil dihapus",
data: deleted,
}
}

View File

@@ -0,0 +1,42 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function distribusiUmurFindMany(context: Context) {
const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 100;
const tahun = Number(context.query.tahun) || new Date().getFullYear();
const skip = (page - 1) * limit;
const where: any = { isActive: true, tahun };
try {
const [data, total] = await Promise.all([
prisma.distribusiUmur.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: "desc" },
}),
prisma.distribusiUmur.count({
where,
}),
]);
return {
success: true,
message: "Success fetch distribusi umur with pagination",
data,
page,
totalPages: Math.ceil(total / limit),
total,
};
} catch (e) {
console.error(e);
return {
success: false,
message: "Failed to fetch distribusi umur with pagination",
data: null,
};
}
}

View File

@@ -0,0 +1,46 @@
import prisma from "@/lib/prisma";
export default async function distribusiUmurFindUnique(request: Request) {
const url = new URL(request.url);
const pathSegments = url.pathname.split('/');
const id = pathSegments[pathSegments.length - 1];
if (!id) {
return Response.json({
success: false,
message: "ID tidak boleh kosong",
}, { status: 400 });
}
try {
if (typeof id !== 'string') {
return Response.json({
success: false,
message: "ID tidak valid",
}, { status: 400 });
}
const data = await prisma.distribusiUmur.findUnique({
where: { id },
});
if (!data) {
return Response.json({
success: false,
message: "Data tidak ditemukan",
}, { status: 404 });
}
return Response.json({
success: true,
message: "Data ditemukan",
data: data,
}, { status: 200 });
} catch (error) {
console.error("Error fetching data:", error);
return Response.json({
success: false,
message: "Terjadi kesalahan saat mengambil data",
}, { status: 500 });
}
}

View File

@@ -0,0 +1,39 @@
import Elysia, { t } from "elysia";
import distribusiUmurFindUnique from "./findUnique";
import distribusiUmurUpdate from "./updt";
import distribusiUmurFindMany from "./findMany";
import distribusiUmurCreate from "./create";
import distribusiUmurDelete from "./del";
const DistribusiUmur = new Elysia({
prefix: "/distribusiumur",
tags: ["Kependudukan/Distribusi Umur"],
})
.get("/:id", async (context) => {
const response = await distribusiUmurFindUnique(new Request(context.request))
return response
})
.get("/find-many", distribusiUmurFindMany)
.post("/create", distribusiUmurCreate, {
body: t.Object({
rentangUmur: t.String(),
jumlah: t.Number(),
tahun: t.Number(),
}),
})
.put("/:id", distribusiUmurUpdate, {
params: t.Object({
id: t.String(),
}),
body: t.Object({
rentangUmur: t.String(),
jumlah: t.Number(),
tahun: t.Number(),
}),
})
.delete("/del/:id", distribusiUmurDelete, {
params: t.Object({
id: t.String(),
}),
})
export default DistribusiUmur;

View File

@@ -0,0 +1,47 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function distribusiUmurUpdate(context: Context) {
const id = context.params?.id;
if (!id) {
return {
success: false,
message: "ID tidak ditemukan",
}
}
const {rentangUmur, jumlah, tahun} = context.body as {
rentangUmur: string;
jumlah: number;
tahun: number;
}
const existing = await prisma.distribusiUmur.findUnique({
where: {
id: id,
},
})
if (!existing) {
return {
success: false,
message: "Data tidak ditemukan",
}
}
const updated = await prisma.distribusiUmur.update({
where: { id },
data: {
rentangUmur,
jumlah,
tahun,
},
})
return {
success: true,
message: "Data berhasil diupdate",
data: updated,
}
}

View File

@@ -0,0 +1,18 @@
import Elysia from "elysia";
import DistribusiAgama from "./distribusi-agama";
import DistribusiUmur from "./distribusi-umur";
import DataBanjar from "./data-banjar";
import MigrasiPenduduk from "./migrasi-penduduk";
import DashboardKependudukan from "./dashboard";
const Kependudukan = new Elysia({
prefix: "/kependudukan",
tags: ["Kependudukan"],
})
.use(DashboardKependudukan)
.use(DistribusiAgama)
.use(DistribusiUmur)
.use(DataBanjar)
.use(MigrasiPenduduk)
export default Kependudukan;

View File

@@ -0,0 +1,43 @@
import prisma from "@/lib/prisma";
import { Prisma } from "@prisma/client";
import { Context } from "elysia";
type FormCreate = Prisma.MigrasiPendudukGetPayload<{
select: {
jenis: true;
nama: true;
tanggal: true;
asalTujuan: true;
alasan: true;
jenisKelamin: true;
}
}>
export default async function migrasiPendudukCreate(context: Context) {
const body = context.body as FormCreate;
const created = await prisma.migrasiPenduduk.create({
data: {
jenis: body.jenis,
nama: body.nama,
tanggal: new Date(body.tanggal),
asalTujuan: body.asalTujuan,
alasan: body.alasan,
jenisKelamin: body.jenisKelamin,
},
select: {
id: true,
jenis: true,
nama: true,
tanggal: true,
asalTujuan: true,
alasan: true,
jenisKelamin: true,
}
});
return {
success: true,
message: "Sukses menambahkan migrasi penduduk",
data: created,
};
}

View File

@@ -0,0 +1,36 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function migrasiPendudukDelete(context: Context) {
const id = context.params?.id;
if (!id) {
return {
success: false,
message: "ID tidak ditemukan",
}
}
const existing = await prisma.migrasiPenduduk.findUnique({
where: {
id: id,
},
})
if (!existing) {
return {
success: false,
message: "Data tidak ditemukan",
}
}
const deleted = await prisma.migrasiPenduduk.delete({
where: { id },
})
return {
success: true,
message: "Data berhasil dihapus",
data: deleted,
}
}

View File

@@ -0,0 +1,62 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function migrasiPendudukFindMany(context: Context) {
const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 10;
const search = (context.query.search as string) || '';
const jenis = (context.query.jenis as string) || '';
const tahun = Number(context.query.tahun) || new Date().getFullYear();
const skip = (page - 1) * limit;
const where: any = { isActive: true };
if (jenis) {
where.jenis = jenis;
}
if (tahun) {
where.tanggal = {
gte: new Date(`${tahun}-01-01`),
lte: new Date(`${tahun}-12-31`),
};
}
if (search) {
where.OR = [
{ nama: { contains: search, mode: "insensitive" } },
{ asalTujuan: { contains: search, mode: "insensitive" } },
];
}
try {
const [data, total] = await Promise.all([
prisma.migrasiPenduduk.findMany({
where,
skip,
take: limit,
orderBy: { tanggal: "desc" },
}),
prisma.migrasiPenduduk.count({
where,
}),
]);
return {
success: true,
message: "Success fetch migrasi penduduk with pagination",
data,
page,
totalPages: Math.ceil(total / limit),
total,
};
} catch (e) {
console.error(e);
return {
success: false,
message: "Failed to fetch migrasi penduduk with pagination",
data: null,
};
}
}

View File

@@ -0,0 +1,46 @@
import prisma from "@/lib/prisma";
export default async function migrasiPendudukFindUnique(request: Request) {
const url = new URL(request.url);
const pathSegments = url.pathname.split('/');
const id = pathSegments[pathSegments.length - 1];
if (!id) {
return Response.json({
success: false,
message: "ID tidak boleh kosong",
}, { status: 400 });
}
try {
if (typeof id !== 'string') {
return Response.json({
success: false,
message: "ID tidak valid",
}, { status: 400 });
}
const data = await prisma.migrasiPenduduk.findUnique({
where: { id },
});
if (!data) {
return Response.json({
success: false,
message: "Data tidak ditemukan",
}, { status: 404 });
}
return Response.json({
success: true,
message: "Data ditemukan",
data: data,
}, { status: 200 });
} catch (error) {
console.error("Error fetching data:", error);
return Response.json({
success: false,
message: "Terjadi kesalahan saat mengambil data",
}, { status: 500 });
}
}

View File

@@ -0,0 +1,45 @@
import Elysia, { t } from "elysia";
import migrasiPendudukFindUnique from "./findUnique";
import migrasiPendudukUpdate from "./updt";
import migrasiPendudukFindMany from "./findMany";
import migrasiPendudukCreate from "./create";
import migrasiPendudukDelete from "./del";
const MigrasiPenduduk = new Elysia({
prefix: "/migrasipenduduk",
tags: ["Kependudukan/Migrasi Penduduk"],
})
.get("/:id", async (context) => {
const response = await migrasiPendudukFindUnique(new Request(context.request))
return response
})
.get("/find-many", migrasiPendudukFindMany)
.post("/create", migrasiPendudukCreate, {
body: t.Object({
jenis: t.String(),
nama: t.String(),
tanggal: t.String(),
asalTujuan: t.String(),
alasan: t.Optional(t.String()),
jenisKelamin: t.Optional(t.String()),
}),
})
.put("/:id", migrasiPendudukUpdate, {
params: t.Object({
id: t.String(),
}),
body: t.Object({
jenis: t.String(),
nama: t.String(),
tanggal: t.String(),
asalTujuan: t.String(),
alasan: t.Optional(t.String()),
jenisKelamin: t.Optional(t.String()),
}),
})
.delete("/del/:id", migrasiPendudukDelete, {
params: t.Object({
id: t.String(),
}),
})
export default MigrasiPenduduk;

View File

@@ -0,0 +1,53 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function migrasiPendudukUpdate(context: Context) {
const id = context.params?.id;
if (!id) {
return {
success: false,
message: "ID tidak ditemukan",
}
}
const {jenis, nama, tanggal, asalTujuan, alasan, jenisKelamin} = context.body as {
jenis: string;
nama: string;
tanggal: string;
asalTujuan: string;
alasan?: string;
jenisKelamin?: string;
}
const existing = await prisma.migrasiPenduduk.findUnique({
where: {
id: id,
},
})
if (!existing) {
return {
success: false,
message: "Data tidak ditemukan",
}
}
const updated = await prisma.migrasiPenduduk.update({
where: { id },
data: {
jenis,
nama,
tanggal: new Date(tanggal),
asalTujuan,
alasan,
jenisKelamin,
},
})
return {
success: true,
message: "Data berhasil diupdate",
data: updated,
}
}

View File

@@ -6,13 +6,6 @@ import { sendCodeOtp } from "../_lib/sendCodeOtp";
import { cookies } from "next/headers";
export async function POST(req: Request) {
if (req.method !== "POST") {
return NextResponse.json(
{ success: false, message: "Method Not Allowed" },
{ status: 405 }
);
}
try {
const { nomor } = await req.json();
@@ -36,6 +29,9 @@ export async function POST(req: Request) {
console.log(`🔑 DEBUG OTP [${nomor}]: ${codeOtp}`);
let waSuccess = false;
let waErrorMessage = "";
try {
const waResponse = await sendCodeOtp({
nomor,
@@ -43,18 +39,30 @@ export async function POST(req: Request) {
});
if (!waResponse.ok) {
console.error(`⚠️ WA Service HTTP Error: ${waResponse.status} ${waResponse.statusText}. Continuing since OTP is logged.`);
waErrorMessage = `WA Service HTTP Error: ${waResponse.status} ${waResponse.statusText}`;
console.error(`⚠️ ${waErrorMessage}. Continuing since OTP is logged.`);
console.log(`💡 Use this OTP to login: ${codeOtp}`);
} else {
const sendWa = await waResponse.json();
console.log("📱 WA Response:", sendWa);
if (sendWa.status !== "success") {
console.error("⚠️ WA Service Logic Error:", sendWa);
waErrorMessage = `WA Service Logic Error: ${JSON.stringify(sendWa)}`;
console.error("⚠️", waErrorMessage);
} else {
waSuccess = true;
}
}
} catch (waError: unknown) {
const errorMessage = waError instanceof Error ? waError.message : String(waError);
console.error("⚠️ WA Connection Exception. Continuing since OTP is logged.", errorMessage);
const errorMessage =
waError instanceof Error ? waError.message : String(waError);
waErrorMessage = `WA Connection Exception: ${errorMessage}`;
console.error(
"⚠️",
waErrorMessage,
". Continuing since OTP is logged."
);
}
const createOtpId = await prisma.kodeOtp.create({
@@ -62,19 +70,33 @@ export async function POST(req: Request) {
});
const cookieStore = await cookies();
cookieStore.set('auth_flow', 'login', {
cookieStore.set("auth_flow", "login", {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 5, // 5 menit
path: '/'
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 5,
path: "/",
});
// Include debug info for non-production environments when WA fails
const isNonProd = process.env.NODE_ENV !== "production";
const includeDebug = isNonProd && !waSuccess;
return NextResponse.json({
success: true,
message: "Kode verifikasi dikirim",
message: waSuccess
? "Kode verifikasi dikirim"
: "Kode verifikasi dibuat (WhatsApp gagal terkirim)",
kodeId: createOtpId.id,
isRegistered: true,
waSuccess,
...(includeDebug && {
debug: {
codeOtp,
waErrorMessage,
note: "Hanya muncul di development/staging. Hapus di production.",
},
}),
});
} else {
return NextResponse.json({
@@ -85,11 +107,12 @@ export async function POST(req: Request) {
}
} catch (error) {
console.error("❌ Error Login:", error);
return NextResponse.json(
{ success: false, message: "Terjadi kesalahan saat login" },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
await prisma.$disconnect();
}
}

View File

@@ -0,0 +1,160 @@
// app/api/health/otp/route.ts
import { NextResponse } from "next/server";
import { randomOTP } from "@/app/api/auth/_lib/randomOTP";
import { sendCodeOtp } from "@/app/api/auth/_lib/sendCodeOtp";
/**
* Health check endpoint untuk OTP WhatsApp service
*
* GET /api/health/otp?test=true&number=6281234567890
*
* Query parameters:
* - test: Set "true" untuk kirim test OTP (optional)
* - number: Nomor tujuan test (required jika test=true)
*
* Response:
* - Status OTP service (available/unavailable)
* - Token configuration status
* - Test message results (jika test=true)
*/
export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url);
const isTest = searchParams.get("test") === "true";
const testNumber = searchParams.get("number");
// Check token configuration
const tokenConfigured = Boolean(process.env.WA_SERVER_TOKEN);
const isPlaceholder =
process.env.WA_SERVER_TOKEN === "your_whatsapp_server_token";
const healthCheck = {
service: "OTP WhatsApp",
status: "ok" as "ok" | "degraded" | "unavailable",
token: {
configured: tokenConfigured,
isPlaceholder,
},
timestamp: new Date().toISOString(),
};
// If test mode, actually try to send OTP
if (isTest) {
if (!testNumber) {
return NextResponse.json(
{
success: false,
message: "Nomor diperlukan untuk test mode",
error: "Tambahkan query parameter: ?number=6281234567890",
},
{ status: 400 }
);
}
const testOtp = randomOTP();
console.log(`🧪 OTP HEALTH CHECK - Testing with number: ${testNumber}`);
console.log(`🧪 Test OTP code: ${testOtp}`);
try {
const startTime = Date.now();
const waResponse = await sendCodeOtp({
nomor: testNumber,
codeOtp: testOtp,
});
const responseTime = Date.now() - startTime;
if (!waResponse.ok) {
healthCheck.status = "unavailable";
return NextResponse.json({
success: false,
message: "Gagal mengirim OTP test",
health: healthCheck,
test: {
number: testNumber,
httpStatus: waResponse.status,
statusText: waResponse.statusText,
responseTime: `${responseTime}ms`,
note: "Cek apakah WA_SERVER_TOKEN sudah benar di Portainer",
},
});
}
const responseData = await waResponse.json();
if (responseData.status !== "success") {
healthCheck.status = "degraded";
return NextResponse.json({
success: false,
message: "OTP test terkirim tapi service merespon error",
health: healthCheck,
test: {
number: testNumber,
httpStatus: waResponse.status,
responseTime: `${responseTime}ms`,
serviceResponse: responseData,
note: "Cek apakah WA_SERVER_TOKEN sudah benar di Portainer",
},
});
}
// Success
healthCheck.status = "ok";
return NextResponse.json({
success: true,
message: "OTP test berhasil dikirim",
health: healthCheck,
test: {
number: testNumber,
httpStatus: waResponse.status,
responseTime: `${responseTime}ms`,
serviceResponse: responseData,
otpCode: testOtp,
note: "Gunakan kode ini untuk verifikasi test",
},
});
} catch (error) {
healthCheck.status = "unavailable";
const errorMessage =
error instanceof Error ? error.message : String(error);
return NextResponse.json({
success: false,
message: "Exception saat mengirim OTP test",
health: healthCheck,
test: {
number: testNumber,
error: errorMessage,
note: "Cek network connectivity ke otp.wibudev.com",
},
});
}
}
// Basic health check without sending OTP
if (!tokenConfigured || isPlaceholder) {
healthCheck.status = "unavailable";
}
return NextResponse.json({
success: true,
message:
healthCheck.status === "ok"
? "OTP service tersedia"
: "OTP service tidak tersedia - cek WA_SERVER_TOKEN",
health: healthCheck,
usage: "Tambahkan ?test=true&number=6281234567890 untuk test kirim OTP",
});
} catch (error) {
console.error("❌ OTP Health Check Error:", error);
return NextResponse.json(
{
success: false,
message: "Health check gagal",
error: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,196 @@
'use client'
import colors from '@/con/colors';
import { Stack, Box, Paper, Text, Title, SimpleGrid, Skeleton, Group, Badge, Center, Image } from '@mantine/core';
import { IconUsers, IconHome, IconBasket, IconCoin, IconDatabaseOff } from '@tabler/icons-react';
import React from 'react';
import BackButton from '../../desa/layanan/_com/BackButto';
import { useProxy } from 'valtio/utils';
import kependudukanDashboard from '@/app/admin/(dashboard)/_state/kependudukan/dashboard';
import { useShallowEffect } from '@mantine/hooks';
function Page() {
const state = useProxy(kependudukanDashboard)
useShallowEffect(() => {
state.summary.load()
}, [])
const summary = state.summary.data?.summary;
if (state.summary.loading) {
return (
<Stack py={10}>
<Skeleton h={200} />
</Stack>
)
}
if (!summary) {
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"lg"}>
<Box px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
<Box px={{ base: 'md', md: 100 }}>
<Title
order={1}
ta="center"
c={colors["blue-button"]}
fw="bold"
lh={1.2}
>
Dashboard Kependudukan
</Title>
<Text
ta="center"
fz={{ base: 'sm', md: 'md' }}
lh={1.5}
c="black"
>
Ringkasan data kependudukan Desa Darmasaba
</Text>
</Box>
<Box px={{ base: "md", md: 100 }}>
<Paper p="xl" withBorder>
<Center py="xl">
<Stack align="center" gap="md">
<IconDatabaseOff size={80} color={colors.grey['2']} />
<Text ta="center" fz="lg" fw={500} c="dimmed">
Data Belum Tersedia
</Text>
<Text ta="center" fz="sm" c="dimmed" maw={400}>
Data kependudukan untuk tahun ini belum diperbarui.
Silakan hubungi administrator desa untuk informasi lebih lanjut.
</Text>
</Stack>
</Center>
</Paper>
</Box>
</Stack>
)
}
const stats = [
{
title: 'Total Penduduk',
value: summary.totalPenduduk,
icon: IconUsers,
color: colors['blue-button'],
},
{
title: 'Kepala Keluarga',
value: summary.totalKK,
icon: IconHome,
color: '#6EDF9C',
},
{
title: 'Kelahiran Tahun Ini',
value: summary.totalKelahiran,
icon: IconBasket,
color: '#FF9F43',
},
{
title: 'Penduduk Miskin',
value: summary.totalKemiskinan,
icon: IconCoin,
color: '#EE5050',
},
];
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"lg"}>
<Box px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
<Box px={{ base: 'md', md: 100 }}>
<Title
order={1}
ta="center"
c={colors["blue-button"]}
fw="bold"
lh={1.2}
>
Dashboard Kependudukan
</Title>
<Text
ta="center"
fz={{ base: 'sm', md: 'md' }}
lh={1.5}
c="black"
>
Ringkasan data kependudukan Desa Darmasaba
</Text>
</Box>
<Box px={{ base: "md", md: 100 }}>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<Paper key={stat.title} p="xl" withBorder>
<Group justify="space-between">
<div>
<Text c="dimmed" fz="sm" fw={500}>
{stat.title}
</Text>
<Text fz={28} fw={700} mt={5}>
{stat.value.toLocaleString('id-ID')}
</Text>
</div>
<Badge
size="xl"
color={stat.color}
variant="light"
>
<Icon size={32} />
</Badge>
</Group>
</Paper>
);
})}
</SimpleGrid>
</Box>
{state.summary.data?.dinamika && (
<Box px={{ base: "md", md: 100 }}>
<Paper p="xl" withBorder>
<Title order={3} mb="md" c={colors["blue-button"]}>
Dinamika Penduduk
</Title>
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
<Paper p="md" bg="#6EDF9C">
<Text fz="sm" c="white">Kelahiran</Text>
<Text fz={24} fw={700} c="white">
{state.summary.data.dinamika.kelahiran}
</Text>
</Paper>
<Paper p="md" bg="#EE5050">
<Text fz="sm" c="white">Kematian</Text>
<Text fz={24} fw={700} c="white">
{state.summary.data.dinamika.kematian}
</Text>
</Paper>
<Paper p="md" bg="#5082EE">
<Text fz="sm" c="white">Pindah Masuk</Text>
<Text fz={24} fw={700} c="white">
{state.summary.data.dinamika.pindahMasuk}
</Text>
</Paper>
<Paper p="md" bg="#FF9F43">
<Text fz="sm" c="white">Pindah Keluar</Text>
<Text fz={24} fw={700} c="white">
{state.summary.data.dinamika.pindahKeluar}
</Text>
</Paper>
</SimpleGrid>
</Paper>
</Box>
)}
</Stack>
);
}
export default Page;

View File

@@ -0,0 +1,147 @@
'use client'
import colors from '@/con/colors';
import { Stack, Box, Paper, Text, Title, Skeleton, Table, Center } from '@mantine/core';
import { IconDatabaseOff } from '@tabler/icons-react';
import React from 'react';
import BackButton from '../../desa/layanan/_com/BackButto';
import { useProxy } from 'valtio/utils';
import dataBanjar from '@/app/admin/(dashboard)/_state/kependudukan/data-banjar';
import { useShallowEffect } from '@mantine/hooks';
function Page() {
const state = useProxy(dataBanjar)
useShallowEffect(() => {
state.findMany.load()
}, [])
const data = state.findMany.data || [];
if (state.findMany.loading) {
return (
<Stack py={10}>
<Skeleton h={500} />
</Stack>
)
}
if (!data || data.length === 0) {
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"lg"}>
<Box px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
<Box px={{ base: 'md', md: 100 }}>
<Title
order={1}
ta="center"
c={colors["blue-button"]}
fw="bold"
lh={1.2}
>
Data per Banjar
</Title>
<Text
ta="center"
fz={{ base: 'sm', md: 'md' }}
lh={1.5}
c="black"
>
Statistik kependudukan per banjar di Desa Darmasaba
</Text>
</Box>
<Box px={{ base: "md", md: 100 }}>
<Paper p="xl" withBorder>
<Center py="xl">
<Stack align="center" gap="md">
<IconDatabaseOff size={80} color={colors.grey['2']} />
<Text ta="center" fz="lg" fw={500} c="dimmed">
Data Belum Tersedia
</Text>
<Text ta="center" fz="sm" c="dimmed" maw={400}>
Data kependudukan per banjar untuk tahun ini belum diperbarui.
Silakan hubungi administrator desa untuk informasi lebih lanjut.
</Text>
</Stack>
</Center>
</Paper>
</Box>
</Stack>
)
}
const rows = data.map((item) => (
<Table.Tr key={item.id}>
<Table.Td>{item.nama}</Table.Td>
<Table.Td ta="right">{item.penduduk.toLocaleString('id-ID')}</Table.Td>
<Table.Td ta="right">{item.kk.toLocaleString('id-ID')}</Table.Td>
<Table.Td ta="right">{item.miskin.toLocaleString('id-ID')}</Table.Td>
</Table.Tr>
));
const totalPenduduk = data.reduce((sum, item) => sum + item.penduduk, 0);
const totalKK = data.reduce((sum, item) => sum + item.kk, 0);
const totalMiskin = data.reduce((sum, item) => sum + item.miskin, 0);
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"lg"}>
<Box px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
<Box px={{ base: 'md', md: 100 }}>
<Title
order={1}
ta="center"
c={colors["blue-button"]}
fw="bold"
lh={1.2}
>
Data per Banjar
</Title>
<Text
ta="center"
fz={{ base: 'sm', md: 'md' }}
lh={1.5}
c="black"
>
Statistik kependudukan per banjar di Desa Darmasaba
</Text>
</Box>
<Box px={{ base: "md", md: 100 }}>
<Paper p="xl" withBorder>
<Text fw={700} fz="lg" mb="md" c="black">
Data Kependudukan per Banjar
</Text>
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Banjar</Table.Th>
<Table.Th ta="right">Penduduk</Table.Th>
<Table.Th ta="right">Kepala Keluarga</Table.Th>
<Table.Th ta="right">Penduduk Miskin</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows}
<Table.Tr fw={700} bg={colors.grey[1]}>
<Table.Td>Total</Table.Td>
<Table.Td ta="right">{totalPenduduk.toLocaleString('id-ID')}</Table.Td>
<Table.Td ta="right">{totalKK.toLocaleString('id-ID')}</Table.Td>
<Table.Td ta="right">{totalMiskin.toLocaleString('id-ID')}</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Paper>
</Box>
</Stack>
);
}
export default Page;

View File

@@ -0,0 +1,189 @@
'use client'
import colors from '@/con/colors';
import { Stack, Box, Paper, Text, Title, Skeleton, SimpleGrid, Center } from '@mantine/core';
import { IconDatabaseOff } from '@tabler/icons-react';
import React from 'react';
import BackButton from '../../desa/layanan/_com/BackButto';
import { useProxy } from 'valtio/utils';
import kependudukanDashboard from '@/app/admin/(dashboard)/_state/kependudukan/dashboard';
import persentaseKelahiranKematian from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/persentaseKelahiran';
import { useShallowEffect } from '@mantine/hooks';
function Page() {
const stateKependudukan = useProxy(kependudukanDashboard);
const statePersentaseKelahiranKematian = useProxy(persentaseKelahiranKematian);
// Load migration data from kependudukan dashboard
useShallowEffect(() => {
stateKependudukan.summary.load();
}, []);
// Load birth and death data
useShallowEffect(() => {
statePersentaseKelahiranKematian.kelahiran.findMany.load();
statePersentaseKelahiranKematian.kematian.findMany.load();
}, []);
const dinamika = stateKependudukan.summary.data?.dinamika;
// Calculate birth and death counts from detailed data
const kelahiranCount = statePersentaseKelahiranKematian.kelahiran.findMany.data?.length || 0;
const kematianCount = statePersentaseKelahiranKematian.kematian.findMany.data?.length || 0;
const isLoading = stateKependudukan.summary.loading ||
statePersentaseKelahiranKematian.kelahiran.findMany.loading ||
statePersentaseKelahiranKematian.kematian.findMany.loading;
if (isLoading) {
return (
<Stack py={10}>
<Skeleton h={500} />
</Stack>
)
}
if (!dinamika || (kelahiranCount === 0 && kematianCount === 0 && (!dinamika.pindahMasuk || dinamika.pindahMasuk === 0))) {
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"lg"}>
<Box px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
<Box px={{ base: 'md', md: 100 }}>
<Title
order={1}
ta="center"
c={colors["blue-button"]}
fw="bold"
lh={1.2}
>
Dinamika Penduduk
</Title>
<Text
ta="center"
fz={{ base: 'sm', md: 'md' }}
lh={1.5}
c="black"
>
Statistik kelahiran, kematian, dan migrasi penduduk
</Text>
</Box>
<Box px={{ base: "md", md: 100 }}>
<Paper p="xl" withBorder>
<Center py="xl">
<Stack align="center" gap="md">
<IconDatabaseOff size={80} color={colors.grey['2']} />
<Text ta="center" fz="lg" fw={500} c="dimmed">
Data Belum Tersedia
</Text>
<Text ta="center" fz="sm" c="dimmed" maw={400}>
Data dinamika penduduk untuk tahun ini belum diperbarui.
Silakan hubungi administrator desa untuk informasi lebih lanjut.
</Text>
</Stack>
</Center>
</Paper>
</Box>
</Stack>
)
}
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"lg"}>
<Box px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
<Box px={{ base: 'md', md: 100 }}>
<Title
order={1}
ta="center"
c={colors["blue-button"]}
fw="bold"
lh={1.2}
>
Dinamika Penduduk
</Title>
<Text
ta="center"
fz={{ base: 'sm', md: 'md' }}
lh={1.5}
c="black"
>
Statistik kelahiran, kematian, dan migrasi penduduk
</Text>
</Box>
<Box px={{ base: "md", md: 100 }}>
<Paper p="xl" withBorder>
<Title order={3} mb="md" c={colors["blue-button"]}>
Statistik Dinamika Penduduk
</Title>
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
<Paper p="lg" bg="#6EDF9C">
<Text fz="sm" c="white" fw={500}>
Kelahiran
</Text>
<Text fz={36} fw={700} c="white" mt={10}>
{kelahiranCount}
</Text>
</Paper>
<Paper p="lg" bg="#EE5050">
<Text fz="sm" c="white" fw={500}>
Kematian
</Text>
<Text fz={36} fw={700} c="white" mt={10}>
{kematianCount}
</Text>
</Paper>
<Paper p="lg" bg="#5082EE">
<Text fz="sm" c="white" fw={500}>
Pindah Masuk
</Text>
<Text fz={36} fw={700} c="white" mt={10}>
{dinamika?.pindahMasuk || 0}
</Text>
</Paper>
<Paper p="lg" bg="#FF9F43">
<Text fz="sm" c="white" fw={500}>
Pindah Keluar
</Text>
<Text fz={36} fw={700} c="white" mt={10}>
{dinamika?.pindahKeluar || 0}
</Text>
</Paper>
</SimpleGrid>
<Box mt="xl" pt="xl" style={{ borderTop: `1px solid ${colors.grey['2']}` }}>
<Text fz="md" c="dimmed" fw={500} mb="md">
Ringkasan:
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Paper p="md" bg={colors.grey[1]}>
<Text fz="sm" c="dimmed">Pertumbuhan Alami</Text>
<Text fz={24} fw={700} c={kelahiranCount - kematianCount >= 0 ? '#6EDF9C' : '#EE5050'}>
{kelahiranCount - kematianCount > 0 ? '+' : ''}{kelahiranCount - kematianCount}
</Text>
<Text fz="xs" c="dimmed">(Kelahiran - Kematian)</Text>
</Paper>
<Paper p="md" bg={colors.grey[1]}>
<Text fz="sm" c="dimmed">Migrasi Bersih</Text>
<Text fz={24} fw={700} c={(dinamika?.pindahMasuk || 0) - (dinamika?.pindahKeluar || 0) >= 0 ? colors['blue-button'] : '#FF9F43'}>
{(dinamika?.pindahMasuk || 0) - (dinamika?.pindahKeluar || 0) > 0 ? '+' : ''}{(dinamika?.pindahMasuk || 0) - (dinamika?.pindahKeluar || 0)}
</Text>
<Text fz="xs" c="dimmed">(Pindah Masuk - Pindah Keluar)</Text>
</Paper>
</SimpleGrid>
</Box>
</Paper>
</Box>
</Stack>
);
}
export default Page;

View File

@@ -0,0 +1,170 @@
'use client'
import colors from '@/con/colors';
import { Stack, Box, Paper, Text, Title, Skeleton, Flex, ColorSwatch, Center } from '@mantine/core';
import { IconDatabaseOff } from '@tabler/icons-react';
import React from 'react';
import BackButton from '../../desa/layanan/_com/BackButto';
import { useProxy } from 'valtio/utils';
import distribusiAgama from '@/app/admin/(dashboard)/_state/kependudukan/distribusi-agama';
import { useShallowEffect } from '@mantine/hooks';
import { PieChart } from '@mantine/charts';
function Page() {
const state = useProxy(distribusiAgama)
useShallowEffect(() => {
state.findMany.load()
}, [])
const data = state.findMany.data || [];
if (state.findMany.loading) {
return (
<Stack py={10}>
<Skeleton h={500} />
</Stack>
)
}
if (!data || data.length === 0) {
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"lg"}>
<Box px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
<Box px={{ base: 'md', md: 100 }}>
<Title
order={1}
ta="center"
c={colors["blue-button"]}
fw="bold"
lh={1.2}
>
Distribusi Agama
</Title>
<Text
ta="center"
fz={{ base: 'sm', md: 'md' }}
lh={1.5}
c="black"
>
Komposisi agama penduduk Desa Darmasaba
</Text>
</Box>
<Box px={{ base: "md", md: 100 }}>
<Paper p="xl" withBorder>
<Center py="xl">
<Stack align="center" gap="md">
<IconDatabaseOff size={80} color={colors.grey['2']} />
<Text ta="center" fz="lg" fw={500} c="dimmed">
Data Belum Tersedia
</Text>
<Text ta="center" fz="sm" c="dimmed" maw={400}>
Data distribusi agama untuk tahun ini belum diperbarui.
Silakan hubungi administrator desa untuk informasi lebih lanjut.
</Text>
</Stack>
</Center>
</Paper>
</Box>
</Stack>
)
}
const chartData = data.map(item => ({
name: item.agama,
value: item.jumlah,
color: getColorForAgama(item.agama),
}));
const total = data.reduce((sum, item) => sum + item.jumlah, 0);
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"lg"}>
<Box px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
<Box px={{ base: 'md', md: 100 }}>
<Title
order={1}
ta="center"
c={colors["blue-button"]}
fw="bold"
lh={1.2}
>
Distribusi Agama
</Title>
<Text
ta="center"
fz={{ base: 'sm', md: 'md' }}
lh={1.5}
c="black"
>
Komposisi agama penduduk Desa Darmasaba
</Text>
</Box>
<Box px={{ base: "md", md: 100 }}>
<Paper p="xl" withBorder>
<Text fw={700} fz="lg" mb="md" c="black">
Statistik Distribusi Agama
</Text>
<Flex direction={{ base: 'column', md: 'row' }} gap="xl" align="center">
<Box style={{ flex: 1 }}>
<PieChart
data={chartData}
size={300}
labelsPosition="inside"
withLabels
withLabelsLine
/>
</Box>
<Stack style={{ flex: 1 }} gap="md">
{data.map((item) => (
<Flex key={item.id} align="center" gap="sm">
<ColorSwatch color={getColorForAgama(item.agama)} size={20} />
<Text fz="sm" c="black" style={{ flex: 1 }}>
{item.agama}
</Text>
<Text fz="sm" fw={700} c="black">
{item.jumlah.toLocaleString('id-ID')}
</Text>
<Text fz="xs" c="dimmed">
({((item.jumlah / total) * 100).toFixed(1)}%)
</Text>
</Flex>
))}
<Box mt="md" pt="md" style={{ borderTop: `1px solid ${colors.grey['2']}` }}>
<Flex justify="space-between" align="center">
<Text fw={700} c="black">Total</Text>
<Text fw={700} c="black">{total.toLocaleString('id-ID')}</Text>
</Flex>
</Box>
</Stack>
</Flex>
</Paper>
</Box>
</Stack>
);
}
function getColorForAgama(agama: string): string {
const colors: Record<string, string> = {
'HINDU': '#FF9F43',
'ISLAM': '#6EDF9C',
'KRISTEN': '#5082EE',
'KRISTEN_PROTESTAN': '#5082EE',
'KRISTEN_KATOLIK': '#4263D1',
'BUDDHA': '#FFD43B',
'KONGHUCU': '#EE5050',
'LAINNYA': '#868E96',
};
return colors[agama] || '#868E96';
}
export default Page;

View File

@@ -0,0 +1,150 @@
'use client'
import colors from '@/con/colors';
import { Stack, Box, Paper, Text, Title, Skeleton, Center } from '@mantine/core';
import { IconDatabaseOff } from '@tabler/icons-react';
import React from 'react';
import BackButton from '../../desa/layanan/_com/BackButto';
import { useProxy } from 'valtio/utils';
import distribusiUmur from '@/app/admin/(dashboard)/_state/kependudukan/distribusi-umur';
import { useShallowEffect } from '@mantine/hooks';
import { BarChart } from '@mantine/charts';
function Page() {
const state = useProxy(distribusiUmur)
useShallowEffect(() => {
state.findMany.load()
}, [])
const data = state.findMany.data || [];
if (state.findMany.loading) {
return (
<Stack py={10}>
<Skeleton h={500} />
</Stack>
)
}
if (!data || data.length === 0) {
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"lg"}>
<Box px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
<Box px={{ base: 'md', md: 100 }}>
<Title
order={1}
ta="center"
c={colors["blue-button"]}
fw="bold"
lh={1.2}
>
Distribusi Umur
</Title>
<Text
ta="center"
fz={{ base: 'sm', md: 'md' }}
lh={1.5}
c="black"
>
Komposisi penduduk berdasarkan kelompok umur
</Text>
</Box>
<Box px={{ base: "md", md: 100 }}>
<Paper p="xl" withBorder>
<Center py="xl">
<Stack align="center" gap="md">
<IconDatabaseOff size={80} color={colors.grey['2']} />
<Text ta="center" fz="lg" fw={500} c="dimmed">
Data Belum Tersedia
</Text>
<Text ta="center" fz="sm" c="dimmed" maw={400}>
Data distribusi umur untuk tahun ini belum diperbarui.
Silakan hubungi administrator desa untuk informasi lebih lanjut.
</Text>
</Stack>
</Center>
</Paper>
</Box>
</Stack>
)
}
// Sort data by age range (extract the first number from rentangUmur)
const sortedData = [...data].sort((a, b) => {
const extractMinAge = (range: string) => {
const match = range.match(/^(\d+)/);
return match ? parseInt(match[1], 10) : 999;
};
return extractMinAge(a.rentangUmur) - extractMinAge(b.rentangUmur);
});
const chartData = sortedData.map(item => ({
umur: item.rentangUmur,
jumlah: item.jumlah,
}));
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"lg"}>
<Box px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
<Box px={{ base: 'md', md: 100 }}>
<Title
order={1}
ta="center"
c={colors["blue-button"]}
fw="bold"
lh={1.2}
>
Distribusi Umur
</Title>
<Text
ta="center"
fz={{ base: 'sm', md: 'md' }}
lh={1.5}
c="black"
>
Komposisi penduduk berdasarkan kelompok umur
</Text>
</Box>
<Box px={{ base: "md", md: 100 }}>
<Paper p="xl" withBorder>
<Text fw={700} fz="lg" mb="md" c="black">
Statistik Distribusi Umur
</Text>
<BarChart
h={400}
data={chartData}
dataKey="umur"
series={[{ name: 'jumlah', color: colors['blue-button'] }]}
tickLine="y"
yAxisProps={{
width: 80,
}}
xAxisProps={{
angle: -45,
textAnchor: 'end',
height: 100,
interval: 0,
style: {
fontSize: '12px',
}
}}
gridAxis="y"
withXAxis
withYAxis
/>
</Paper>
</Box>
</Stack>
);
}
export default Page;

View File

@@ -70,7 +70,6 @@ const isNationalHoliday = (date: string): boolean => {
// Hari libur pengganti
'2026-04-08', // Hari Libur Pengganti Idul Fitri
'2026-04-09', // Hari Libur Pengganti Idul Fitri
];
return holidays.includes(date);

View File

@@ -29,16 +29,18 @@ process.on('unhandledRejection', async (error) => {
});
// Handle graceful shutdown
process.on('SIGINT', async () => {
console.log('Received SIGINT signal. Closing database connections...');
await prisma.$disconnect();
process.exit(0);
});
if (process.env.NODE_ENV === 'production' && !process.env.NEXT_PHASE) {
process.on('SIGINT', async () => {
console.log('Received SIGINT signal. Closing database connections...');
await prisma.$disconnect();
// Allow natural exit
});
process.on('SIGTERM', async () => {
console.log('Received SIGTERM signal. Closing database connections...');
await prisma.$disconnect();
process.exit(0);
});
process.on('SIGTERM', async () => {
console.log('Received SIGTERM signal. Closing database connections...');
await prisma.$disconnect();
// Allow natural exit
});
}
export default prisma;