Compare commits
26 Commits
tasks/auth
...
tasks/impl
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e822f0b05 | |||
| 34a37dc63b | |||
| 0e6f7e1769 | |||
| feb853d06e | |||
| 3de412afe0 | |||
| 87d234e57f | |||
| fd18a22834 | |||
| 3e8b961e52 | |||
| 82d779e5e0 | |||
| a6517166cb | |||
| 483b6be677 | |||
| f8dad0dbcd | |||
| 74301fe074 | |||
| 8b19abc628 | |||
| 1a91f3c9ad | |||
| 9b74592101 | |||
| 55f4b94082 | |||
| 59ae8ad039 | |||
| c012d5778c | |||
| af31bd8aef | |||
| 721357adcf | |||
| 39776ec355 | |||
| 50a7356618 | |||
| 4494dd98ef | |||
| 970949a68b | |||
| 42bcba6c96 |
47
.dockerignore
Normal file
47
.dockerignore
Normal 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
19
.env
@@ -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
5
.gitignore
vendored
@@ -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
13
.qwen/settings.json
Normal 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
9
.qwen/settings.json.orig
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "playwright-mcp@latest"],
|
||||
"timeout": 60000
|
||||
}
|
||||
}
|
||||
}
|
||||
191
DEV-INSPECTOR-ANALYSIS.md
Normal file
191
DEV-INSPECTOR-ANALYSIS.md
Normal 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)
|
||||
89
Dockerfile
89
Dockerfile
@@ -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
49
biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
553
dev-inspector-click-to-source.md
Normal file
553
dev-inspector-click-to-source.md
Normal 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
|
||||
@@ -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",
|
||||
|
||||
94
prisma/migrations/20260406032433_init/migration.sql
Normal file
94
prisma/migrations/20260406032433_init/migration.sql
Normal 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;
|
||||
@@ -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"
|
||||
27
src/app/admin/(dashboard)/_state/kependudukan/dashboard.ts
Normal file
27
src/app/admin/(dashboard)/_state/kependudukan/dashboard.ts
Normal 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;
|
||||
205
src/app/admin/(dashboard)/_state/kependudukan/data-banjar.ts
Normal file
205
src/app/admin/(dashboard)/_state/kependudukan/data-banjar.ts
Normal 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;
|
||||
@@ -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;
|
||||
197
src/app/admin/(dashboard)/_state/kependudukan/distribusi-umur.ts
Normal file
197
src/app/admin/(dashboard)/_state/kependudukan/distribusi-umur.ts
Normal 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;
|
||||
@@ -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;
|
||||
249
src/app/admin/(dashboard)/kependudukan/data-banjar/[id]/page.tsx
Normal file
249
src/app/admin/(dashboard)/kependudukan/data-banjar/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
304
src/app/admin/(dashboard)/kependudukan/data-banjar/page.tsx
Normal file
304
src/app/admin/(dashboard)/kependudukan/data-banjar/page.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
283
src/app/admin/(dashboard)/kependudukan/distribusi-agama/page.tsx
Normal file
283
src/app/admin/(dashboard)/kependudukan/distribusi-agama/page.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
284
src/app/admin/(dashboard)/kependudukan/distribusi-umur/page.tsx
Normal file
284
src/app/admin/(dashboard)/kependudukan/distribusi-umur/page.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
339
src/app/admin/(dashboard)/kependudukan/migrasi-penduduk/page.tsx
Normal file
339
src/app/admin/(dashboard)/kependudukan/migrasi-penduduk/page.tsx
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
147
src/app/api/[[...slugs]]/_lib/kependudukan/dashboard/summary.ts
Normal file
147
src/app/api/[[...slugs]]/_lib/kependudukan/dashboard/summary.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
18
src/app/api/[[...slugs]]/_lib/kependudukan/index.ts
Normal file
18
src/app/api/[[...slugs]]/_lib/kependudukan/index.ts
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
160
src/app/api/health/otp/route.ts
Normal file
160
src/app/api/health/otp/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
196
src/app/darmasaba/(pages)/kependudukan/dashboard/page.tsx
Normal file
196
src/app/darmasaba/(pages)/kependudukan/dashboard/page.tsx
Normal 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;
|
||||
147
src/app/darmasaba/(pages)/kependudukan/data-per-banjar/page.tsx
Normal file
147
src/app/darmasaba/(pages)/kependudukan/data-per-banjar/page.tsx
Normal 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;
|
||||
@@ -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;
|
||||
170
src/app/darmasaba/(pages)/kependudukan/distribusi-agama/page.tsx
Normal file
170
src/app/darmasaba/(pages)/kependudukan/distribusi-agama/page.tsx
Normal 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;
|
||||
150
src/app/darmasaba/(pages)/kependudukan/distribusi-umur/page.tsx
Normal file
150
src/app/darmasaba/(pages)/kependudukan/distribusi-umur/page.tsx
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user