Compare commits

..

140 Commits

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

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

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

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

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

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

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

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

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

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

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

Security: Ensure sensitive environment variables are not exposed in repository

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

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-06 12:08:49 +08:00
e73798a0f2 chore: regenerate bun lockfile 2026-04-06 11:52:47 +08:00
1a91f3c9ad Merge pull request #13 from bipprojectbali/stg
Stg
2026-04-06 11:34:48 +08:00
9b74592101 Merge branch 'main' into stg 2026-04-06 11:34:38 +08:00
55f4b94082 Merge pull request #12 from bipprojectbali/tasks/auth/implement-otp-whatsapp-function/06-04-2026-1430
feat(auth): implement WhatsApp OTP sending function for login
2026-04-06 11:31:30 +08:00
b403bc754c feat(auth): implement WhatsApp OTP sending function for login
- Create sendCodeOtp utility function using otp.wibudev.com API
- Update login route to use new sendCodeOtp function
- Replace old URL-based WhatsApp approach with authenticated API call
- Add WA_SERVER_TOKEN environment variable documentation
- Support flexible codeOtp type (string | number) for better reusability

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-06 11:27:55 +08:00
67b87f145e Merge remote-tracking branch 'deploy/stg' into stg 2026-04-04 08:39:01 +08:00
dd09d7c90a fix(build): remove eslint-disable and replace 'any' with MantineColor in grafikRealisasi 2026-04-04 08:36:41 +08:00
59ae8ad039 Merge pull request #11 from bipprojectbali/tasks/fix-docker-build/optimize-config-and-eslint-ignore/2026-04-02-15-45
fix(build): ignore ESLint and TypeScript errors during build for CI/CD
2026-04-02 16:20:32 +08:00
c012d5778c fix(build): ignore ESLint and TypeScript errors during build for CI/CD 2026-04-02 16:16:17 +08:00
af31bd8aef Merge pull request #10 from bipprojectbali/nico/2-apr-26/default-docker-setting
Ganti ke settingan awal Docker
2026-04-02 15:55:01 +08:00
721357adcf Ganti ke settingan awal Docker 2026-04-02 15:54:27 +08:00
39776ec355 Merge pull request #9 from bipprojectbali/tasks/fix-build-and-lint/resolve-errors-in-auth-and-apbdes/2026-04-02-15-15
fix(apbdes): remove redundant eslint-disable and improve type safety …
2026-04-02 13:03:29 +08:00
50a7356618 fix(apbdes): remove redundant eslint-disable and improve type safety in GrafikRealisasi 2026-04-02 12:47:28 +08:00
4494dd98ef Merge pull request #8 from bipprojectbali/tasks/fix-docker-build/optimize-config-and-prisma-handlers/02-04-2026-15-00
Tasks/fix docker build/optimize config and prisma handlers/02 04 2026 15 00
2026-04-02 11:26:13 +08:00
970949a68b fix: resolve Docker build failure by optimizing configuration and prisma signal handling
- Added .dockerignore to prevent build poisoning from local artifacts.
- Updated Dockerfile with stable Bun version, memory limits, and missing config files.
- Refined prisma.ts signal handlers to avoid process termination during Next.js build phases.
- Synchronized eslint-config-next with Next.js version.
2026-04-02 11:24:49 +08:00
8777c45a44 fix(build): resolve ESLint and type errors causing build failure 2026-04-01 17:44:48 +08:00
42bcba6c96 Merge pull request #7 from bipprojectbali/stg
Stg
2026-04-01 17:10:09 +08:00
d1d54e5c25 Merge branch 'main' into stg 2026-04-01 17:09:58 +08:00
0a4b85fd82 Merge pull request #6 from bipprojectbali/tasks/api/fix-swagger-path-group/01-04-2026-1215
Tasks/api/fix swagger path group/01 04 2026 1215
2026-04-01 17:07:25 +08:00
b751f031cd fix(auth/swagger): make WA failure non-fatal and include /api prefix in docs 2026-04-01 17:04:25 +08:00
a3940321a7 fix(api): move swagger to /api group to prevent double prefixing 2026-04-01 15:29:26 +08:00
3cd6fcbd81 fix(api): clean up redundant /api prefixes and fix swagger documentation 2026-04-01 15:24:12 +08:00
7d9b7b0c60 feat(apbdes): finalize modernization and update config 2026-04-01 15:15:01 +08:00
0806eb2308 feat(apbdes): modernize ui, charts and refactor (Phase 1, 2, 4) 2026-04-01 15:09:40 +08:00
github-actions[bot]
6064ef0759 chore: sync workflows from base-template 2026-03-12 06:48:18 +00:00
1c00c326c9 Merge pull request #5 from bipprojectbali/fix-error-music-stg
Fix: Use window.location.origin for API base URL in browser
2026-03-12 14:16:32 +08:00
51ce823b45 Fix: Use window.location.origin for API base URL in browser
treaty from @elysiajs/eden doesn't support relative URLs like '/'
This caused 'ERR_NAME_NOT_RESOLVED' when trying to access 'https://api/fileStorage/create'

Solution:
- Client-side: Use window.location.origin (e.g., https://desa-darmasaba-stg.wibudev.com)
- Server-side dev: Use localhost:3000
- Server-side prod: Use NEXT_PUBLIC_BASE_URL env var

This ensures the API calls use the correct domain in all environments.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-12 14:14:55 +08:00
4eba96140d Merge pull request #4 from bipprojectbali/fix-error-music-stg
Fix error music stg
2026-03-12 12:13:50 +08:00
f6f0e10935 Fix Url API Route 2026-03-12 12:11:10 +08:00
2108f403aa Update .env.example to use relative URL '/' as default for NEXT_PUBLIC_BASE_URL
This ensures the API uses the same protocol and domain as the frontend,
preventing mixed content blocking in staging/production environments.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-12 12:06:16 +08:00
4dfcf20322 Merge pull request #3 from bipprojectbali/fix-error-music-stg
Fix: CORS and API base URL for music create in staging
2026-03-12 11:37:19 +08:00
c6c3eebadf Fix: CORS and API base URL for music create in staging
- Update CORS config to allow all origins (wildcard first) for better staging support
- Change API fetch base URL from absolute to relative (/) to prevent mixed content blocking
- Add detailed logging in music create page for better debugging
- Update .env.example with better NEXT_PUBLIC_BASE_URL documentation
- Add MUSIK_CREATE_ANALYSIS.md with comprehensive error analysis

Fixes ERR_BLOCKED_BY_CLIENT error when creating music in staging environment

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-12 11:27:51 +08:00
github-actions[bot]
6d26ace8ab chore: sync workflows from base-template 2026-03-10 08:16:42 +00:00
bipproduction
0dabc204bc Revert standalone output, keep serverExternalPackages fix
- Remove output: standalone to keep migration/seed workflow
- Restore original Dockerfile structure with full node_modules copy
- Keep serverExternalPackages for @elysiajs/static and elysia to fix
  the Html prerender error caused by dynamic import in @elysiajs/static
- Keep NODE_ENV=production before build step

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:53:18 +08:00
bipproduction
e8f8b51686 Fix build: externalize elysia packages, use standalone output, fix NODE_ENV
- Add serverExternalPackages for @elysiajs/static and elysia to prevent
  webpack from bundling dynamic imports that cause Html prerender error
- Use output: standalone for proper Docker deployment
- Comment out NODE_ENV=development in .env.example to avoid conflict
  with next build which requires NODE_ENV=production
- Set NODE_ENV=production before build step in Dockerfile
- Update runtime stage to use standalone output structure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:48:05 +08:00
bipproduction
a4db3a149d Fix build error: move ViewTransitions inside body to fix 404 prerendering
ViewTransitions was wrapping the html element, which violates Next.js App
Router requirement that html and body be returned directly from root layout.
This caused prerendering of /404 to fail with Html import error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:29:29 +08:00
bipproduction
fece983ac5 Fix Dockerfile: remove non-existent gen:api script and fix build output paths
- Remove bun run gen:api which does not exist in package.json
- Change dist to .next for correct Next.js build output
- Replace non-existent generated/ with public/ for static assets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:12:33 +08:00
8b7eef5fee New bunlock.b 2026-03-10 11:04:44 +08:00
8b22d01e0d FIx Docker File 2026-03-10 10:56:15 +08:00
dc13e37a02 Add .env.example and fix .gitignore to allow it
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-10 10:50:52 +08:00
2d2cbef29b Tambah File Docker 2026-03-10 10:42:51 +08:00
8c8a96b830 First Stg 2026-03-10 10:07:14 +08:00
dc3eccacbf First Stg 2026-03-10 10:03:33 +08:00
ffe94992e5 StaggingWeb 2026-03-09 16:44:42 +08:00
github-actions[bot]
f5566bca2c chore: sync workflows from base-template 2026-03-09 07:53:36 +00:00
github-actions[bot]
ba964df32c chore: sync workflows from base-template 2026-03-09 07:45:35 +00:00
github-actions[bot]
df3f382a97 chore: sync workflows from base-template 2026-03-09 07:05:55 +00:00
4fb522f88f Fix Eror Grafik Realisasi-3 2026-03-06 12:03:22 +08:00
85332a8225 Merge pull request 'Fix Eror Grafik Realisasi-2' (#78) from nico/6-mar-26/fix-container-portainer-1 into staggingweb
Reviewed-on: #78
2026-03-06 11:24:46 +08:00
3fe2a5ccab Fix Eror Grafik Realisasi-2 2026-03-06 11:19:45 +08:00
363bfa65fb Merge pull request 'Fix Eror Grafik Realisasi' (#77) from nico/6-mar-26/fix-container-portainer-1 into staggingweb
Reviewed-on: #77
2026-03-06 10:53:19 +08:00
dccf590cbf Fix Eror Grafik Realisasi 2026-03-06 10:52:10 +08:00
f076b81d14 Merge pull request 'Fix Prisma 1' (#76) from nico/6-mar-26/fix-container-portainer-1 into staggingweb
Reviewed-on: #76
2026-03-06 10:35:54 +08:00
b5ea3216e0 Fix Prisma 1 2026-03-06 10:31:19 +08:00
64b116588b Merge pull request 'nico/5-mar-26/fix-musik-fix-apbdes' (#75) from nico/5-mar-26/fix-musik-fix-apbdes into staggingweb
Reviewed-on: #75
2026-03-05 16:38:07 +08:00
63161e1a39 Fix tombolreplay, posisi tombol, posisi icon music. Fix create & edit apbdes upload image dan file optional 2026-03-05 16:36:12 +08:00
8b8c65dd1e fix(apbdes-edit): clear imageId/fileId when user removes preview
Problem:
- Saat user klik X button untuk hapus preview image/file
- Form state masih menyimpan imageId/fileId lama
- Saat submit, data lama tetap terkirim
- User tidak bisa benar-benar menghapus image/file

Solution:
- Clear apbdesState.edit.form.imageId saat hapus preview gambar
- Clear apbdesState.edit.form.fileId saat hapus preview dokumen
- Now user can truly make image/file empty

Files changed:
- src/app/admin/(dashboard)/landing-page/apbdes/[id]/edit/page.tsx

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-05 16:26:34 +08:00
159fb3cec6 feat(apbdes): make image and file optional for edit page too
Changes:

Backend (updt.ts, index.ts):
- Update FormUpdateBody: imageId?: string | null
- Update Elysia schema: t.Optional(t.String())
- Handle null/undefined values when updating

UI (edit/page.tsx):
- Remove mandatory validation for imageId and fileId
- Update labels to show '(Opsional)'
- Simplify handleSubmit logic (no validation check)
- Keep existing file IDs if no new upload

User Flow:
Before: Edit required imageId and fileId to be present
After: Can update APBDes without files, preserve existing or set to null

Files changed:
- src/app/api/[[...slugs]]/_lib/landing_page/apbdes/updt.ts
- src/app/api/[[...slugs]]/_lib/landing_page/apbdes/index.ts
- src/app/admin/(dashboard)/landing-page/apbdes/[id]/edit/page.tsx

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-05 15:53:26 +08:00
4821934224 fix(music-player): fix floating icon position shift on hover
Problem:
- Icon bergeser ke bawah saat hover
- transform: 'scale(1.1)' mengganti transform: 'translateY(-80%)'
- CSS transform property di-replace, bukan di-mix

Solution:
- Gabungkan kedua transform dalam satu string
- Hover: 'translateY(-80%) scale(1.1)' - maintain posisi + scale
- Leave: 'translateY(-80%)' - kembali ke posisi semula

Changes:
- onMouseEnter: transform = 'translateY(-80%) scale(1.1)'
- onMouseLeave: transform = 'translateY(-80%)'
- Added 'ease' timing function for smoother transition

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-05 14:53:38 +08:00
ee39b88b00 feat(music-player): add minimize feature with floating icon and centered controls
Layout Changes:
- Center all control buttons (shuffle, prev, play/pause, next, repeat)
- Center progress bar alongside controls
- Keep volume control + close button on the right
- Song info remains on the left

New Feature - Minimize Player:
- Add isMinimized state to track player visibility
- Replace close button with minimize functionality
- Show floating music icon when minimized (bottom-right corner)
- Click floating icon to restore player bar
- Floating icon has hover scale animation for better UX

UI/UX Improvements:
- Better visual hierarchy with centered controls
- Floating icon uses blue bg with white music icon
- Smooth transitions between states
- Icon scales on hover for interactive feedback
- Persistent player state (song continues playing when minimized)

Files changed:
- src/app/darmasaba/_com/FixedPlayerBar.tsx: Complete redesign

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-05 14:14:46 +08:00
ce46d3b5f7 fix(music-player): fix repeat button not working due to stale closure
Problem:
- Tombol repeat tidak berfungsi saat lagu selesai
- Event listener 'ended' menggunakan variabel state 'isRepeat' dari closure yang lama
- Meskipun state sudah di-toggle, event listener masih menggunakan nilai lama

Solution:
- Tambahkan isRepeatRef untuk menyimpan nilai terbaru dari isRepeat
- Sync ref dengan state menggunakan useEffect
- Gunakan isRepeatRef.current di event listener 'ended'
- Remove isRepeat dari dependency array useEffect

Files changed:
- src/app/context/MusicContext.tsx: Add isRepeatRef and sync with state

This ensures the repeat functionality works correctly when the song ends.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-05 13:54:15 +08:00
144ac37e12 fix(public-apbdes): fix realisasi display on public APBDes page
- Fix types.ts transformAPBDesData to map totalRealisasi → realisasi
  - Backend returns totalRealisasi, frontend expects realisasi
  - Add fallback to use item.realisasi if totalRealisasi not available

- Fix grafikRealisasi.tsx to use realisasi field
  - Update Summary component to use i.realisasi || i.totalRealisasi
  - Update total calculation to use realisasi field

- Fix apbDesaTable.tsx to use realisasi field
  - Update total calculation to use item.realisasi

- Fix apbDesaProgress.tsx to use realisasi field
  - Update calcTotal to use item.realisasi with fallback

Root cause: Backend Prisma schema uses 'totalRealisasi' field, but public
page components were expecting 'realisasi' field.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-05 11:27:28 +08:00
f90477ed63 fix(apbdes-edit): preserve realisasi data when editing APBDes
- Fix backend updt.ts to preserve realisasiItems from old items
  - Load existing items with realisasiItems before delete
  - Re-create realisasiItems for new items based on kode match
  - Recalculate totalRealisasi, selisih, persentase after restore

- Update frontend state to handle realisasi fields
  - Add realisasi, selisih, persentase to ApbdesItemSchema
  - Fix edit.load() to map totalRealisasi → realisasi
  - Fix edit.update() to omit calculated fields when sending to backend

- Update edit page.tsx to display realisasi data
  - Fix load data to use item.totalRealisasi (not item.realisasi)
  - Add Realisasi, Selisih, % columns to items table
  - Update handleAddItem and handleReset to preserve realisasi fields

Root cause: Backend was resetting totalRealisasi=0 for all items on update,
and frontend was accessing wrong field name (realisasi vs totalRealisasi)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-05 11:20:45 +08:00
4a7811e06f Merge pull request 'Fix Tabel Apbdes, & fix muciplayer in background' (#74) from nico/4-mar-26/fix-musik-play-bg into staggingweb
Reviewed-on: #74
2026-03-04 16:41:25 +08:00
f63aaf916d Fix Tabel Apbdes, & fix muciplayer in background 2026-03-04 16:28:06 +08:00
3803c79c95 Merge pull request 'nico/4-mar-26/realiasasi-apbdes' (#73) from nico/4-mar-26/realiasasi-apbdes into staggingweb
Reviewed-on: #73
2026-03-04 12:03:46 +08:00
2d901912ea fix(realisasi): add kode field to RealisasiItem and simplify table display
- Add kode field to RealisasiItem model in Prisma schema
- Update API endpoints (create, update) to accept kode parameter
- Update state management with proper type definitions
- Add kode input field in RealisasiManager component
- Simplify realisasiTable to show flat list (Kode, Uraian, Realisasi, %)
- Remove section grouping and expandable details
- Fix race condition in findUnique.load() with loading guard
- Fix linting errors across multiple files

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-04 11:51:58 +08:00
bipproduction
7368a367f4 chore: tambah publish.yml workflow_dispatch ke main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 15:38:49 +08:00
bipproduction
ed664d5b10 deploy docker ghcr 2026-03-02 15:09:46 +08:00
bipproduction
0ba30aa5b2 tambahan 2026-03-02 13:20:05 +08:00
bipproduction
790d6535e5 fix: perbaiki Dockerfile - lockfile, bunfig.toml, dan path build output
- Ganti bun.lock → bun.lockb (nama file yang benar)
- Hapus bunfig.toml dari COPY (file tidak ada)
- Ganti /app/dist → /app/.next (output Next.js build)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 13:15:54 +08:00
bipproduction
46ce16ae97 tambahanya 2026-03-02 12:58:42 +08:00
0160fa636d Merge pull request 'Fix Login KodeOtp WA' (#72) from nico/26-feb-26/fix-auth-wa-login into staggingweb
Reviewed-on: #72
2026-02-26 14:14:29 +08:00
3684e83187 Merge pull request 'Fix CORS config for staging environment' (#71) from nico/25-feb-26 into staggingweb
Reviewed-on: #71
2026-02-25 23:15:18 +08:00
77c54b5c8a Merge pull request 'nico/25-feb-26' (#70) from nico/25-feb-26 into staggingweb
Reviewed-on: #70
2026-02-25 21:34:28 +08:00
bb80b0ecc1 Merge pull request 'fix/admin/menu-desa/berita' (#69) from fix/admin/menu-desa/berita into staggingweb
Reviewed-on: #69
2026-02-25 16:27:35 +08:00
1b59d6bf09 Merge pull request 'Fix Coba lagi image stagging' (#66) from nico/5-feb-26-2 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/66
2026-02-05 14:31:53 +08:00
eb1ad54db6 Merge pull request 'Seed create' (#65) from nico/5-feb-26-1 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/65
2026-02-05 14:04:09 +08:00
21ec3ad1c1 Merge pull request 'nico / 5-feb-26 (3)' (#64) from nico/5-feb-26 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/64
2026-02-05 12:35:21 +08:00
3a115908c4 Merge pull request 'nico / 5-feb-26(2)' (#63) from nico/5-feb-26 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/63
2026-02-05 12:07:17 +08:00
5ff791642c Merge pull request 'nico / 5-feb-26' (#62) from nico/5-feb-26 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/62
2026-02-05 11:14:42 +08:00
b803c7a90c Merge pull request 'nico / 4-feb-26' (#61) from nico/4-feb-26 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/61
2026-02-04 17:10:27 +08:00
fb2fe67c23 Merge pull request 'Nico / 4-Feb-2026' (#60) from nico/4-feb-26 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/60
2026-02-04 11:49:22 +08:00
51460558d4 Merge pull request 'Seeder Menu Lingkungan dan Pendidikan' (#59) from nico/3-feb-26 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/59
2026-02-03 17:12:03 +08:00
d105ceeb6b Merge pull request 'nico/2-feb-26' (#58) from nico/2-feb-26 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/58
2026-02-02 17:32:10 +08:00
c865aee766 Merge pull request 'Fix Eror Code get_images.ts (1)' (#57) from nico/30-jan-26 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/57
2026-01-30 17:16:42 +08:00
273dfdfd09 Merge pull request 'Fix Seeder Image, Menu Landing Page - Desa' (#56) from nico/30-jan-26 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/56
2026-01-30 15:57:16 +08:00
1d1d8e50dc Merge pull request 'Fix Seed Image 27 Jan' (#55) from nico/27-jan-26 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/55
2026-01-27 10:51:22 +08:00
092afe67d2 Merge pull request 'Seed Pendidikan' (#54) from nico/23-jan-26 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/54
2026-01-23 16:52:25 +08:00
2d9170705d Merge pull request 'Fix seeder statistik kemiskinan' (#53) from nico/21-jan-26 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/53
2026-01-21 14:27:32 +08:00
fdf9a951a4 Merge pull request 'Fix uploads -1' (#52) from nico/21-jan-26 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/52
2026-01-21 14:10:42 +08:00
ca74029688 Merge pull request 'nico/21-jan-26' (#51) from nico/21-jan-26 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/51
2026-01-21 12:10:18 +08:00
1a8fc1a670 Merge pull request 'nico/17-jan-26' (#49) from nico/17-jan-26 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/49
> Add Layanan Polsek submenu polsek terdekat
> Seeder menu keamanan -> menu ekonomi submenu : demografi pekerjaan, junlah pengangguran, lowongan kerja lokal, pasar desa, program kemiskinan, sektor unggulan, struktur organisasi
2026-01-17 10:36:11 +08:00
19235f0791 Merge pull request 'Fix All Search Admin' (#48) from nico/5-jan-26 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/48
2026-01-05 17:12:29 +08:00
61de7d8d33 Merge pull request 'Fix QC Kak Inno Mobile Done' (#47) from nico/1-jan-26 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/47
2026-01-02 16:42:08 +08:00
8fb85ce56c Merge pull request 'Fix QC Kak Inno 23 Des' (#46) from nico/24-des-25 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/46
2025-12-24 14:38:12 +08:00
1f98b6993d Merge pull request 'nico/23-des-25' (#45) from nico/23-des-25 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/45
2025-12-23 17:19:57 +08:00
f3a10d63d1 Merge pull request 'nico/19-des-25' (#44) from nico/19-des-25 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/44
2025-12-19 15:44:53 +08:00
7a42bec63b Merge pull request 'nico/17-des-25' (#43) from nico/17-des-25 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/43
2025-12-17 17:39:29 +08:00
44c421129e Merge pull request 'nico/16-des-25' (#42) from nico/16-des-25 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/42
2025-12-16 16:38:42 +08:00
ddff427926 Merge pull request 'nico/12-des-25' (#41) from nico/12-des-25 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/41
2025-12-12 17:07:31 +08:00
00c8caade4 Merge pull request 'nico/10-des-25' (#40) from nico/10-des-25 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/40
2025-12-10 17:45:16 +08:00
0209f49449 Merge pull request 'nico/9-des-25' (#39) from nico/9-des-25 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/39
2025-12-09 17:40:16 +08:00
344c6ada6d Merge pull request 'nico/5-des-25' (#38) from nico/5-des-25 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/38
2025-12-05 14:32:12 +08:00
11acd04419 Merge pull request 'Fix Error Build Staging' (#37) from nico/3-des-25 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/37
2025-12-04 11:59:43 +08:00
8d49213b68 Merge pull request 'Menambahkan menu dokter dan tenaga medis, admin bisa create, edit, delet dokter' (#36) from nico/3-des-25 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/36
2025-12-03 17:57:33 +08:00
96911e3cf1 Merge pull request 'Fix UI Sosial Media Landing Page in User' (#35) from nico/2-des-25 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/35
2025-12-02 16:46:49 +08:00
9950c28b9b Merge pull request 'Fix menu admin landing page, submenu sosial media' (#34) from nico/2-des-25 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/34
2025-12-02 16:09:19 +08:00
fa0f3538d1 Merge pull request 'Tambahan filter data sesuai tahun, di landing page apbdes' (#33) from nico/1-des-25 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/33
2025-12-01 17:12:34 +08:00
2778f53aff Merge pull request 'Tambah Term of Service di Registrasi' (#32) from nico/1-des-25 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/32
2025-12-01 14:02:11 +08:00
37ac91d4f4 Push Conflict 2025-12-01 13:58:27 +08:00
217f4a9a3b Tambah Term of Service di Registrasi 2025-12-01 13:53:39 +08:00
5d6a7437ed Merge branch 'nico/1-des-25' into staggingweb 2025-12-01 13:52:11 +08:00
752a6cabee Merge pull request 'Meta-data' (#29) from nico/1-des-25-1 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/29
2025-12-01 10:22:10 +08:00
134ddc6154 Meta-data 2025-12-01 10:21:00 +08:00
28979c6b49 Merge pull request 'Test Google Insight' (#28) from nico/28-nov-25-1 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/28
2025-11-28 19:24:18 +08:00
b2066caa13 Test Google Insight 2025-11-28 17:54:18 +08:00
023c77d636 Merge pull request 'Add <meta charSet=utf-8 />' (#27) from nico/28-nov-25 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/27
2025-11-28 17:05:14 +08:00
9bf3ec72cf Add <meta charSet=utf-8 /> 2025-11-28 17:04:35 +08:00
f359f5b1ce Merge pull request 'nico/28-nov-25' (#26) from nico/28-nov-25 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/26
2025-11-28 15:39:08 +08:00
1c1e8fb190 fix ganti role, user menu access ikut ke create fix 2025-11-28 15:38:07 +08:00
54f83da3b8 fix ganti role, user menu access ikut ke create 2025-11-28 15:35:21 +08:00
f8985c550f Merge branch 'nico/28-nov-25' of https://wibugit.wibudev.com/wibu/desa-darmasaba into nico/28-nov-25 2025-11-28 15:32:19 +08:00
e3d909e760 fix ganti role, user menu access ikut ke create 2025-11-28 15:31:10 +08:00
16a8df50c1 Merge pull request 'staggingweb' (#25) from staggingweb into nico/28-nov-25
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/25
2025-11-28 15:04:30 +08:00
d66a952d4c Merge pull request 'nico/27-okt-25' (#1) from nico/27-okt-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/1
2025-10-27 22:17:59 +08:00
132 changed files with 13052 additions and 1458 deletions

47
.dockerignore Normal file
View File

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

44
.env.example Normal file
View File

@@ -0,0 +1,44 @@
# Database Configuration
DATABASE_URL="postgresql://username:password@localhost:5432/desa-darmasaba?schema=public"
# Seafile Configuration (File Storage)
SEAFILE_TOKEN=your_seafile_token
SEAFILE_REPO_ID=your_seafile_repo_id
SEAFILE_URL=https://your-seafile-instance.com
SEAFILE_PUBLIC_SHARE_TOKEN=your_seafile_public_share_token
# Upload Configuration
WIBU_UPLOAD_DIR=uploads
WIBU_DOWNLOAD_DIR=./download
# WhatsApp Server Configuration
WA_SERVER_TOKEN=your_whatsapp_server_token
# Application Configuration
# IMPORTANT: For staging/production, set this to your actual domain
# Local development: NEXT_PUBLIC_BASE_URL=http://localhost:3000
# Staging: NEXT_PUBLIC_BASE_URL=https://desa-darmasaba-stg.wibudev.com
# Production: NEXT_PUBLIC_BASE_URL=https://your-production-domain.com
# Or use relative URL '/' for automatic protocol/domain detection (recommended)
NEXT_PUBLIC_BASE_URL=/
# Email Configuration (for notifications/subscriptions)
EMAIL_USER=your_email@gmail.com
EMAIL_PASS=your_email_app_password
# Session Configuration
BASE_SESSION_KEY=your_session_key_generate_secure_random_string
BASE_TOKEN_KEY=your_jwt_secret_key_generate_secure_random_string
# Telegram Bot Configuration (for notifications)
BOT_TOKEN=your_telegram_bot_token
CHAT_ID=your_telegram_chat_id
# Session Password (for iron-session)
SESSION_PASSWORD="your_session_password_min_32_characters_long_secure"
# ElevenLabs API Key (for TTS features - optional)
ELEVENLABS_API_KEY=your_elevenlabs_api_key
# Environment (optional, defaults to development)
# NODE_ENV=development

View File

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

View File

@@ -1,219 +0,0 @@
name: Build And Save Log
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment (e.g., staging, production)"
required: true
default: "staging"
version:
description: "Version to deploy"
required: false
default: "latest"
env:
APP_NAME: desa-darmasaba-action
WA_PHONE: "6289697338821,6289697338822"
jobs:
build:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14
env:
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
# Checkout kode sumber
- name: Checkout code
uses: actions/checkout@v3
# Setup Bun
- name: Setup Bun
uses: oven-sh/setup-bun@v2
# Cache dependencies
- name: Cache dependencies
uses: actions/cache@v3
with:
path: .bun
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
# Step 1: Set BRANCH_NAME based on event type
- name: Set BRANCH_NAME
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "BRANCH_NAME=${{ github.head_ref }}" >> $GITHUB_ENV
else
echo "BRANCH_NAME=${{ github.ref_name }}" >> $GITHUB_ENV
fi
# Step 2: Generate APP_VERSION dynamically
- name: Set APP_VERSION
run: echo "APP_VERSION=${{ github.sha }}---$(date +%Y%m%d%H%M%S)" >> $GITHUB_ENV
# Step 3: Kirim notifikasi ke API build Start
- name: Notify start build
run: |
IFS=',' read -ra PHONES <<< "${{ env.WA_PHONE }}"
for PHONE in "${PHONES[@]}"; do
ENCODED_TEXT=$(bun -e "console.log(encodeURIComponent('Build:start\nApp:${{ env.APP_NAME }}\nBranch:${{ env.BRANCH_NAME }}\nVersion:${{ env.APP_VERSION }}'))")
curl -X GET "https://wa.wibudev.com/code?text=$ENCODED_TEXT&nom=$PHONE"
done
# Install dependencies
- name: Install dependencies
run: bun install
# Konfigurasi environment variable untuk PostgreSQL dan variabel tambahan
- name: Set up environment variables
run: |
echo "DATABASE_URL=postgresql://${{ secrets.POSTGRES_USER }}:${{ secrets.POSTGRES_PASSWORD }}@localhost:5432/${{ secrets.POSTGRES_DB }}?schema=public" >> .env
echo "PORT=3000" >> .env
echo "NEXT_PUBLIC_WIBU_URL=localhost:3000" >> .env
echo "WIBU_UPLOAD_DIR=/uploads" >> .env
# Create log file
- name: Create log file
run: touch build.txt
# Migrasi database menggunakan Prisma
- name: Apply Prisma schema to database
run: bun prisma db push >> build.txt 2>&1
# Seed database (opsional)
- name: Seed database
run: |
bun prisma db seed >> build.txt 2>&1 || echo "Seed failed or no seed data found. Continuing without seed." >> build.txt
# Build project
- name: Build project
run: bun run build >> build.txt 2>&1
# Ensure project directory exists
- name: Ensure /var/www/projects/${{ env.APP_NAME }} exists
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USERNAME }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
mkdir -p /var/www/projects/${{ env.APP_NAME }}
# Deploy to a new version directory
- name: Deploy to VPS (New Version)
uses: appleboy/scp-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USERNAME }}
key: ${{ secrets.VPS_SSH_KEY }}
source: "."
target: "/var/www/projects/${{ env.APP_NAME }}/releases/${{ env.APP_VERSION }}"
# Set up environment variables
- name: Set up environment variables
run: |
rm -r .env
echo "DATABASE_URL=postgresql://${{ secrets.POSTGRES_USER }}:${{ secrets.POSTGRES_PASSWORD }}@localhost:5433/${{ secrets.POSTGRES_DB }}?schema=public" >> .env
echo "NEXT_PUBLIC_WIBU_URL=${{ env.APP_NAME }}" >> .env
echo "WIBU_UPLOAD_DIR=/var/www/projects/${{ env.APP_NAME }}/uploads" >> .env
# Kirim file .env ke server
- name: Upload .env to server
uses: appleboy/scp-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USERNAME }}
key: ${{ secrets.VPS_SSH_KEY }}
source: ".env"
target: "/var/www/projects/${{ env.APP_NAME }}/releases/${{ env.APP_VERSION }}/"
# manage deployment
- name: manage deployment
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USERNAME }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
# Source ~/.bashrc
source ~/.bashrc
# Find an available port
PORT=$(curl -s -X GET https://wibu-bot.wibudev.com/api/find-port | jq -r '.[0]')
if [ -z "$PORT" ] || ! [[ "$PORT" =~ ^[0-9]+$ ]]; then
echo "Invalid or missing port from API."
exit 1
fi
# manage deployment
cd /var/www/projects/${{ env.APP_NAME }}/releases/${{ env.APP_VERSION }}
# Create uploads directory
mkdir -p /var/www/projects/${{ env.APP_NAME }}/uploads
# Install dependencies
bun install --production
# Apply database schema
if ! bun prisma db push; then
echo "Database migration failed."
exit 1
fi
# Seed database (optional)
bun prisma db seed || echo "tidak membutuhkan seed"
# Restart the application
pm2 reload ${{ env.APP_NAME }} || pm2 start "bun run start --port $PORT" --name "${{ env.APP_NAME }}-$PORT" --namespace "${{ env.APP_NAME }}"
# Step 4: Set BUILD_STATUS based on success or failure
- name: Set BUILD_STATUS
if: success()
run: echo "BUILD_STATUS=success" >> $GITHUB_ENV
- name: Set BUILD_STATUS on failure
if: failure()
run: echo "BUILD_STATUS=failed" >> $GITHUB_ENV
# Update status log
- name: Update status log
if: always()
run: |
echo "=====================" >> build.txt
echo "BUILD_STATUS=${{ env.BUILD_STATUS }}" >> build.txt
echo "APP_NAME=${{ env.APP_NAME }}" >> build.txt
echo "APP_VERSION=${{ env.APP_VERSION }}" >> build.txt
echo "=====================" >> build.txt
# Upload log to 0x0.st
- name: Upload log to 0x0.st
id: upload_log
if: always()
run: |
LOG_URL=$(curl -F "file=@build.txt" https://wibu-bot.wibudev.com/api/file )
echo "LOG_URL=$LOG_URL" >> $GITHUB_ENV
# Kirim notifikasi ke API
- name: Notify build success via API
if: always()
run: |
IFS=',' read -ra PHONES <<< "${{ env.WA_PHONE }}"
for PHONE in "${PHONES[@]}"; do
ENCODED_TEXT=$(bun -e "console.log(encodeURIComponent('Build:${{ env.BUILD_STATUS }}\nApp:${{ env.APP_NAME }}\nBranch:${{ env.BRANCH_NAME }}\nVersion:${{ env.APP_VERSION }}\nLog:${{ env.LOG_URL }}'))")
curl -X GET "https://wa.wibudev.com/code?text=$ENCODED_TEXT&nom=$PHONE"
done

56
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: Publish Docker to GHCR
on:
push:
tags:
- "v*"
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
publish:
name: Build & Push to GHCR
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
sudo docker image prune --all --force
df -h
- name: Checkout repository
uses: actions/checkout@v4
- name: Extract tag name
id: meta
run: echo "tag=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.tag }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

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

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

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

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

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

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

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

@@ -0,0 +1,120 @@
#!/bin/bash
: "${PORTAINER_URL:?PORTAINER_URL tidak di-set}"
: "${PORTAINER_USERNAME:?PORTAINER_USERNAME tidak di-set}"
: "${PORTAINER_PASSWORD:?PORTAINER_PASSWORD tidak di-set}"
: "${STACK_NAME:?STACK_NAME tidak di-set}"
# Timeout total: MAX_RETRY * SLEEP_INTERVAL detik
MAX_RETRY=60 # 60 × 10s = 10 menit
SLEEP_INTERVAL=10
echo "🔐 Autentikasi ke Portainer..."
TOKEN=$(curl -s -X POST "https://${PORTAINER_URL}/api/auth" \
-H "Content-Type: application/json" \
-d "{\"username\": \"${PORTAINER_USERNAME}\", \"password\": \"${PORTAINER_PASSWORD}\"}" \
| jq -r .jwt)
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "❌ Autentikasi gagal! Cek PORTAINER_URL, USERNAME, dan PASSWORD."
exit 1
fi
echo "🔍 Mencari stack: $STACK_NAME..."
STACK=$(curl -s -X GET "https://${PORTAINER_URL}/api/stacks" \
-H "Authorization: Bearer ${TOKEN}" \
| jq ".[] | select(.Name == \"$STACK_NAME\")")
if [ -z "$STACK" ]; then
echo "❌ Stack '$STACK_NAME' tidak ditemukan di Portainer!"
exit 1
fi
STACK_ID=$(echo "$STACK" | jq -r .Id)
ENDPOINT_ID=$(echo "$STACK" | jq -r .EndpointId)
ENV=$(echo "$STACK" | jq '.Env // []')
# ── Catat container ID lama sebelum redeploy ──────────────────────────────────
echo "📸 Mencatat container aktif sebelum redeploy..."
CONTAINERS_BEFORE=$(curl -s -X GET \
"https://${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/json?all=true&filters=%7B%22label%22%3A%5B%22com.docker.compose.project%3D${STACK_NAME}%22%5D%7D" \
-H "Authorization: Bearer ${TOKEN}")
OLD_IDS=$(echo "$CONTAINERS_BEFORE" | jq -r '[.[] | .Id] | join(",")')
echo " Container lama: $(echo "$CONTAINERS_BEFORE" | jq -r '[.[] | .Names[0]] | join(", ")')"
# ── Ambil compose file lalu trigger redeploy ─────────────────────────────────
echo "📄 Mengambil compose file..."
STACK_FILE=$(curl -s -X GET "https://${PORTAINER_URL}/api/stacks/${STACK_ID}/file" \
-H "Authorization: Bearer ${TOKEN}" \
| jq -r .StackFileContent)
PAYLOAD=$(jq -n \
--arg content "$STACK_FILE" \
--argjson env "$ENV" \
'{stackFileContent: $content, env: $env, pullImage: true}')
echo "🚀 Triggering redeploy $STACK_NAME (pull latest image)..."
HTTP_STATUS=$(curl -s -o /tmp/portainer_response.json -w "%{http_code}" \
-X PUT "https://${PORTAINER_URL}/api/stacks/${STACK_ID}?endpointId=${ENDPOINT_ID}" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
if [ "$HTTP_STATUS" != "200" ]; then
echo "❌ Redeploy gagal! HTTP Status: $HTTP_STATUS"
cat /tmp/portainer_response.json | jq .
exit 1
fi
echo "⏳ Menunggu image selesai di-pull dan container baru running..."
echo " (Timeout: $((MAX_RETRY * SLEEP_INTERVAL)) detik)"
COUNT=0
while [ $COUNT -lt $MAX_RETRY ]; do
sleep $SLEEP_INTERVAL
COUNT=$((COUNT + 1))
CONTAINERS=$(curl -s -X GET \
"https://${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/json?all=true&filters=%7B%22label%22%3A%5B%22com.docker.compose.project%3D${STACK_NAME}%22%5D%7D" \
-H "Authorization: Bearer ${TOKEN}")
# Container baru = ID tidak ada di daftar container lama
NEW_RUNNING=$(echo "$CONTAINERS" | jq \
--arg old "$OLD_IDS" \
'[.[] | select(.State == "running" and ((.Id) as $id | ($old | split(",") | index($id)) == null))] | length')
FAILED=$(echo "$CONTAINERS" | jq \
'[.[] | select(.State == "exited" and (.Status | test("Exited \\(0\\)") | not) and (.Names[0] | test("seed") | not))] | length')
echo "🔄 [$((COUNT * SLEEP_INTERVAL))s / $((MAX_RETRY * SLEEP_INTERVAL))s] Container baru running: ${NEW_RUNNING} | Gagal: ${FAILED}"
echo "$CONTAINERS" | jq -r '.[] | " → \(.Names[0]) | \(.State) | \(.Status) | id: \(.Id[:12])"'
if [ "$FAILED" -gt "0" ]; then
echo ""
echo "❌ Ada container yang crash!"
echo "$CONTAINERS" | jq -r '.[] | select(.State == "exited" and (.Status | test("Exited \\(0\\)") | not) and (.Names[0] | test("seed") | not)) | " → \(.Names[0]) | \(.Status)"'
exit 1
fi
if [ "$NEW_RUNNING" -gt "0" ]; then
# Cleanup dangling images setelah redeploy sukses
echo "🧹 Membersihkan dangling images..."
curl -s -X POST "https://${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/images/prune" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"filters":{"dangling":["true"]}}' | jq -r '" Reclaimed: \(.SpaceReclaimed // 0 | . / 1073741824 | tostring | .[0:5]) GB"'
echo "✅ Cleanup selesai!"
echo ""
echo "✅ Stack $STACK_NAME berhasil di-redeploy dengan image baru dan running!"
exit 0
fi
done
echo ""
echo "❌ Timeout $((MAX_RETRY * SLEEP_INTERVAL))s! Container baru tidak kunjung running."
echo " Kemungkinan image masih dalam proses pull atau ada error di server."
exit 1

View File

@@ -1,55 +0,0 @@
name: test workflows
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment (e.g., staging, production)"
required: true
default: "staging"
version:
description: "Version to deploy"
required: false
default: "latest"
env:
APP_NAME: desa-darmasaba-action
WA_PHONE: "6289697338821,6289697338822"
jobs:
build:
runs-on: ubuntu-latest
steps:
# Checkout kode sumber
- name: Checkout code
uses: actions/checkout@v3
# Setup Bun
- name: Setup Bun
uses: oven-sh/setup-bun@v2
# Create log file
- name: Create log file
run: touch build.txt
# Step 1: Set BRANCH_NAME based on event type
- name: Set BRANCH_NAME
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "BRANCH_NAME=${{ github.head_ref }}" >> $GITHUB_ENV
else
echo "BRANCH_NAME=${{ github.ref_name }}" >> $GITHUB_ENV
fi
# Step 2: Generate APP_VERSION dynamically
- name: Set APP_VERSION
run: echo "APP_VERSION=${{ github.sha }}---$(date +%Y%m%d%H%M%S)" >> $GITHUB_ENV
# Step 3: Kirim notifikasi ke API build Start
- name: Notify start build
run: |
IFS=',' read -ra PHONES <<< "${{ env.WA_PHONE }}"
for PHONE in "${PHONES[@]}"; do
ENCODED_TEXT=$(bun -e "console.log(encodeURIComponent('Build:start\nApp:${{ env.APP_NAME }}\nenv:${{ inputs.environment }}\nBranch:${{ env.BRANCH_NAME }}\nVersion:${{ env.APP_VERSION }}'))")
curl -X GET "https://wa.wibudev.com/code?text=$ENCODED_TEXT&nom=$PHONE"
done

5
.gitignore vendored
View File

@@ -29,7 +29,9 @@ yarn-error.log*
.pnpm-debug.log*
# env
# env local files (keep .env.example)
.env*
!.env.example
# QC
QC
@@ -50,9 +52,6 @@ next-env.d.ts
# cache
/cache
.github/
.env.*
*.tar.gz

13
.qwen/settings.json Normal file
View File

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

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

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

73
AUDIT_REPORT.md Normal file
View File

@@ -0,0 +1,73 @@
# Engineering Audit Report: Desa Darmasaba
**Status:** Production Readiness Review (Critical)
**Auditor:** Staff Technical Architect
---
## 📊 Executive Summary & Scores
| Category | Score | Status |
| :--- | :---: | :--- |
| **Project Architecture** | 3/10 | 🔴 Critical Failure |
| **Code Quality** | 4/10 | 🟠 Poor |
| **Performance** | 5/10 | 🟡 Mediocre |
| **Security** | 5/10 | 🟠 Risk Detected |
| **Production Readiness** | 2/10 | 🔴 Not Ready |
---
## 🏗️ 1. Project Architecture
The project suffers from a **"Frankenstein Architecture"**. It attempts to run a full Elysia.js instance inside a Next.js Catch-All route.
- **Fractured Backend:** Logic is split between standard Next.js routes (`/api/auth`) and embedded Elysia modules.
- **Stateful Dependency:** Reliance on local filesystem (`WIBU_UPLOAD_DIR`) makes the application impossible to deploy on modern serverless platforms like Vercel.
- **Polluted Namespace:** Routing tree contains "test/coba" folders (`src/app/coba`, `src/app/percobaan`) that would be accessible in production.
## ⚛️ 2. Frontend Engineering (React / Next.js)
- **State Management Chaos:** Simultaneous use of `Valtio`, `Jotai`, `React Context`, and `localStorage`.
- **Tight Coupling:** Public pages (`/darmasaba`) import state directly from Admin internal states (`/admin/(dashboard)/_state`).
- **Heavy Client-Side Logic:** Logic that belongs in Server Actions or Hooks is embedded in presentational components (e.g., `Footer.tsx`).
## 📡 3. Backend / API Design
- **Framework Overhead:** Running Elysia inside Next.js adds unnecessary cold-boot overhead and complexity.
- **Weak Validation:** Widespread use of `as Type` casting in API handlers instead of runtime validation (Zod/Schema).
- **Service Integration:** OTP codes are sent via external `GET` requests with sensitive data in the query string—a major logging risk.
## 🗄️ 4. Database & Data Modeling (Prisma)
- **Schema Over-Normalization:** ~2000 lines of schema. Every minor content type (e.g., `LambangDesa`) is a separate table instead of a unified CMS model.
- **Polymorphic Monolith:** `FileStorage` is a "god table" with optional relations to ~40 other tables, creating a massive bottleneck and data integrity risk.
- **Connection Mismanagement:** Manual `prisma.$disconnect()` in API routes kills connection pooling performance.
## 🚀 5. Performance Engineering
- **Bypassing Optimization:** Custom `/api/utils/img` endpoint bypasses `next/image` optimization, serving uncompressed assets.
- **Aggressive Polling:** Client-side 30s polling for notifications is battery-draining and inefficient compared to SSE or SWR.
## 🔒 6. Security Audit
- **Insecure OTP Delivery:** Credentials passed as URL parameters to the WhatsApp service.
- **File Upload Risks:** Potential for Arbitrary File Upload due to direct local filesystem writes without rigorous sanitization.
## 🧹 7. Code Quality
- **Inconsistency:** Mixed English/Indonesian naming (e.g., `nomor` vs `createdAt`).
- **Artifacts:** Root directory is littered with scratch files: `xcoba.ts`, `xx.ts`, `test.txt`.
---
## 🚩 Top 10 Critical Problems
1. **Architectural Fracture:** Embedding Elysia inside Next.js creates a "split-brain" system.
2. **Serverless Incompatibility:** Dependency on local disk storage for uploads.
3. **Database Bloat:** Over-complicated schema with a fragile `FileStorage` monolith.
4. **State Fragmentation:** Mixed usage of Jotai and Valtio without a clear standard.
5. **Credential Leakage:** OTP codes sent via GET query parameters.
6. **Poor Cleanup:** Trial/Test folders and files committed to the production source.
7. **Asset Performance:** Bypassing Next.js image optimization.
8. **Coupling:** High dependency between public UI and internal Admin state.
9. **Type Safety:** Manual casting in APIs instead of runtime validation.
10. **Connection Pooling:** Inefficient Prisma connection management.
---
## 🛠️ Tech Lead Refactoring Priorities
1. **Unify the API:** Decommission the Elysia wrapper. Port all logic to standard Next.js Route Handlers with Zod validation.
2. **Stateless Storage:** Implement an S3-compatible adapter for all file uploads. Remove `fs` usage.
3. **Schema Consolidation:** Refactor the schema to use generic content models where possible.
4. **Standardize State:** Choose one global state manager and migrate all components.
5. **Project Sanitization:** Delete all `coba`, `percobaan`, and scratch files (`xcoba.ts`, etc.).

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

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

67
Dockerfile Normal file
View File

@@ -0,0 +1,67 @@
# ==============================
# Stage 1: Builder
# ==============================
FROM oven/bun:1-debian AS builder
WORKDIR /app
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"
RUN bun install --frozen-lockfile
COPY . .
RUN cp .env.example .env || true
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"
RUN bun run build
# ==============================
# Stage 2: Runner (Production)
# ==============================
FROM oven/bun:1-debian AS runner
WORKDIR /app
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 3000
CMD ["bun", "start"]

173
MUSIK_CREATE_ANALYSIS.md Normal file
View File

@@ -0,0 +1,173 @@
# Musik Desa - Create Feature Analysis
## Error Summary
**Error**: `ERR_BLOCKED_BY_CLIENT` saat create musik di staging environment
## Root Cause Analysis
### 1. **CORS Configuration Issue** (Primary)
File: `src/app/api/[[...slugs]]/route.ts`
The CORS configuration has specific origins listed:
```typescript
const corsConfig = {
origin: [
"http://localhost:3000",
"http://localhost:3001",
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
"*",
],
// ...
}
```
**Problem**: The wildcard `*` is at the end, but some browsers don't respect it when `credentials: true` is set.
### 2. **API Fetch Base URL** (Secondary)
File: `src/lib/api-fetch.ts`
```typescript
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
```
**Problem**:
- In staging, this might still default to `http://localhost:3000`
- Mixed content (HTTPS frontend → HTTP API) gets blocked by browsers
- The `NEXT_PUBLIC_BASE_URL` environment variable might not be set in staging
### 3. **File Storage Upload Path** (Tertiary)
File: `src/app/api/[[...slugs]]/_lib/fileStorage/_lib/create.ts`
```typescript
const UPLOAD_DIR = process.env.WIBU_UPLOAD_DIR;
```
**Problem**: If `WIBU_UPLOAD_DIR` is not set or points to a non-writable location, uploads will fail silently.
## Solution
### Fix 1: Update CORS Configuration
**File**: `src/app/api/[[...slugs]]/route.ts`
```typescript
// Move wildcard to first position and ensure it works with credentials
const corsConfig = {
origin: [
"*", // Allow all origins (for staging flexibility)
"http://localhost:3000",
"http://localhost:3001",
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
"https://desa-darmasaba-stg.wibudev.com"
],
methods: ["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] as HTTPMethod[],
allowedHeaders: ["Content-Type", "Authorization", "Accept"],
exposedHeaders: ["Content-Range", "X-Content-Range"],
maxAge: 86400, // 24 hours
credentials: true,
};
```
### Fix 2: Add Environment Variable Validation
**File**: `.env.example` (update)
```bash
# Application Configuration
NEXT_PUBLIC_BASE_URL=http://localhost:3000
# For staging/production, set this to your actual domain
# NEXT_PUBLIC_BASE_URL=https://cld-dkr-desa-darmasaba-stg.wibudev.com
```
### Fix 3: Update API Fetch to Handle Relative URLs
**File**: `src/lib/api-fetch.ts`
```typescript
import { AppServer } from '@/app/api/[[...slugs]]/route'
import { treaty } from '@elysiajs/eden'
// Use relative URL for better deployment flexibility
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || '/'
const ApiFetch = treaty<AppServer>(BASE_URL)
export default ApiFetch
```
### Fix 4: Add Error Handling in Create Page
**File**: `src/app/admin/(dashboard)/musik/create/page.tsx`
Add better error logging to diagnose issues:
```typescript
const handleSubmit = async () => {
// ... validation ...
try {
setIsSubmitting(true);
// Upload cover image
const coverRes = await ApiFetch.api.fileStorage.create.post({
file: coverFile,
name: coverFile.name,
});
if (!coverRes.data?.data?.id) {
console.error('Cover upload failed:', coverRes);
return toast.error('Gagal mengunggah cover, silakan coba lagi');
}
// ... rest of the code ...
} catch (error) {
console.error('Error creating musik:', {
error,
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
});
toast.error('Terjadi kesalahan saat membuat musik');
} finally {
setIsSubmitting(false);
}
};
```
## Testing Checklist
### Local Development
- [ ] Test create musik with cover image and audio file
- [ ] Verify CORS headers in browser DevTools Network tab
- [ ] Check that file uploads are saved to correct directory
### Staging Environment
- [ ] Set `NEXT_PUBLIC_BASE_URL` to staging domain
- [ ] Verify HTTPS is used for all API calls
- [ ] Check browser console for mixed content warnings
- [ ] Verify `WIBU_UPLOAD_DIR` is set and writable
- [ ] Test create musik end-to-end
## Additional Notes
### ERR_BLOCKED_BY_CLIENT Common Causes:
1. **CORS policy blocking** - Most likely cause
2. **Ad blockers** - Can block certain API endpoints
3. **Mixed content** - HTTPS page making HTTP requests
4. **Content Security Policy (CSP)** - Restrictive CSP headers
5. **Browser extensions** - Privacy/security extensions blocking requests
### Debugging Steps:
1. Open browser DevTools → Network tab
2. Try to create musik
3. Look for failed requests (red status)
4. Check the "Headers" tab for:
- Request URL (should be correct domain)
- Response headers (should have `Access-Control-Allow-Origin`)
- Status code (4xx/5xx indicates server-side issue)
5. Check browser console for CORS errors
## Recommended Next Steps
1. **Immediate**: Update CORS configuration to allow staging domain
2. **Short-term**: Add proper environment variable validation
3. **Long-term**: Implement proper error boundaries and logging

49
biome.json Normal file
View File

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

2302
bun.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
bun.lockb

Binary file not shown.

View File

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

1
eror.md Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ['@elysiajs/static', 'elysia'],
experimental: {},
allowedDevOrigins: [
"http://192.168.1.82:3000", // buat akses dari HP/device lain

View File

@@ -8,7 +8,8 @@
"start": "next start",
"test:api": "vitest run",
"test:e2e": "playwright test",
"test": "bun run test:api && bun run test:e2e"
"test": "bun run test:api && bun run test:e2e",
"gen:api": ""
},
"prisma": {
"seed": "bun run prisma/seed.ts"
@@ -33,7 +34,7 @@
"@mantine/modals": "^8.3.6",
"@mantine/tiptap": "^7.17.4",
"@paljs/types": "^8.1.0",
"@prisma/client": "^6.3.1",
"@prisma/client": "6.3.1",
"@tabler/icons-react": "^3.30.0",
"@tiptap/extension-highlight": "^2.11.7",
"@tiptap/extension-link": "^2.11.7",
@@ -70,7 +71,7 @@
"embla-carousel-react": "^8.6.0",
"extract-zip": "^2.0.1",
"form-data": "^4.0.2",
"framer-motion": "^12.23.5",
"framer-motion": "^12.38.0",
"get-port": "^7.1.0",
"iron-session": "^8.0.4",
"jose": "^6.1.0",
@@ -89,7 +90,7 @@
"p-limit": "^6.2.0",
"primeicons": "^7.0.0",
"primereact": "^10.9.6",
"prisma": "^6.3.1",
"prisma": "6.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-exif-orientation-img": "^0.1.5",
@@ -100,7 +101,7 @@
"react-transition-group": "^4.4.5",
"react-zoom-pan-pinch": "^3.7.0",
"readdirp": "^4.1.1",
"recharts": "^2.15.3",
"recharts": "^3.8.0",
"sharp": "^0.34.3",
"swr": "^2.3.2",
"uuid": "^11.1.0",
@@ -120,10 +121,11 @@
"@types/react-dom": "^19",
"@vitest/ui": "^4.0.18",
"eslint": "^9",
"eslint-config-next": "15.1.6",
"eslint-config-next": "15.5.12",
"jsdom": "^28.0.0",
"msw": "^2.12.9",
"parcel": "^2.6.2",
"playwright-mcp": "^0.0.19",
"postcss": "^8.5.1",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",

View File

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

View File

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

View File

@@ -238,19 +238,21 @@ model APBDesItem {
// Model baru untuk multiple realisasi per item
model RealisasiItem {
id String @id @default(cuid())
kode String? // Kode realisasi, mirip dengan APBDesItem
apbdesItemId String
apbdesItem APBDesItem @relation(fields: [apbdesItemId], references: [id], onDelete: Cascade)
jumlah Float // Jumlah realisasi dalam Rupiah
tanggal DateTime @db.Date // Tanggal realisasi
keterangan String? @db.Text // Keterangan tambahan (opsional)
buktiFileId String? // FileStorage ID untuk bukti/foto (opsional)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
isActive Boolean @default(true)
@@index([kode])
@@index([apbdesItemId])
@@index([tanggal])
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,21 +5,17 @@ import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
// --- Zod Schema untuk APBDes Item (tanpa field kalkulasi) ---
// --- Zod Schema untuk APBDes Item (dengan field kalkulasi) ---
const ApbdesItemSchema = z.object({
kode: z.string().min(1, "Kode wajib diisi"),
uraian: z.string().min(1, "Uraian wajib diisi"),
anggaran: z.number().min(0, "Anggaran tidak boleh negatif"),
level: z.number().int().min(1).max(3),
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
});
// --- Zod Schema untuk Realisasi Item ---
const RealisasiItemSchema = z.object({
jumlah: z.number().min(0, "Jumlah tidak boleh negatif"),
tanggal: z.string(),
keterangan: z.string().optional(),
buktiFileId: z.string().optional(),
// Field kalkulasi dari realisasiItems (auto-calculated di backend)
realisasi: z.number().min(0).default(0),
selisih: z.number().default(0),
persentase: z.number().default(0),
});
const ApbdesFormSchema = z.object({
@@ -27,8 +23,9 @@ const ApbdesFormSchema = z.object({
name: z.string().optional(),
deskripsi: z.string().optional(),
jumlah: z.string().optional(),
imageId: z.string().min(1, "Gambar wajib diunggah"),
fileId: z.string().min(1, "File wajib diunggah"),
// Image dan file opsional (bisa kosong)
imageId: z.string().optional(),
fileId: z.string().optional(),
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
});
@@ -43,7 +40,7 @@ const defaultApbdesForm = {
items: [] as z.infer<typeof ApbdesItemSchema>[],
};
// --- Helper: Normalize item (tanpa kalkulasi, backend yang hitung) ---
// --- Helper: Normalize item (dengan field kalkulasi) ---
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
return {
kode: item.kode || "",
@@ -51,6 +48,9 @@ function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer
anggaran: item.anggaran ?? 0,
level: item.level || 1,
tipe: item.tipe ?? null,
realisasi: item.realisasi ?? 0,
selisih: item.selisih ?? 0,
persentase: item.persentase ?? 0,
};
}
@@ -112,7 +112,7 @@ const apbdes = proxy({
findMany: {
data: null as
| Prisma.APBDesGetPayload<{
include: { image: true; file: true; items: true };
include: { image: true; file: true; items: { include: { realisasiItems: true } } };
}>[]
| null,
page: 1,
@@ -157,33 +157,37 @@ const apbdes = proxy({
findUnique: {
data: null as
| Prisma.APBDesGetPayload<{
include: { image: true; file: true; items: true };
include: { image: true; file: true; items: { include: { realisasiItems: true } } };
}>
| null,
loading: false,
error: null as string | null,
async load(id: string) {
if (!id || id.trim() === '') {
this.data = null;
this.error = "ID tidak valid";
return;
}
// Prevent multiple simultaneous loads
if (this.loading) {
console.log("⚠️ Already loading, skipping...");
return;
}
this.loading = true;
this.error = null;
try {
// Pastikan URL-nya benar
const url = `/api/landingpage/apbdes/${id}`;
console.log("🌐 Fetching:", url);
// Gunakan fetch biasa atau ApiFetch dengan cara yang benar
const response = await fetch(url);
const res = await response.json();
console.log("📦 Response:", res);
if (res.success && res.data) {
this.data = res.data;
} else {
@@ -252,6 +256,9 @@ const apbdes = proxy({
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
realisasi: item.totalRealisasi || 0,
selisih: item.selisih || 0,
persentase: item.persentase || 0,
level: item.level,
tipe: item.tipe || 'pendapatan',
})),
@@ -279,11 +286,24 @@ const apbdes = proxy({
try {
this.loading = true;
// Include the ID in the request body
// Omit realisasi, selisih, persentase karena itu calculated fields di backend
const requestData = {
...parsed.data,
id: this.id, // Add the ID to the request body
tahun: parsed.data.tahun,
name: parsed.data.name,
deskripsi: parsed.data.deskripsi,
jumlah: parsed.data.jumlah,
imageId: parsed.data.imageId,
fileId: parsed.data.fileId,
id: this.id,
items: parsed.data.items.map(item => ({
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
level: item.level,
tipe: item.tipe ?? null,
})),
};
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
if (res.data?.success) {
@@ -322,15 +342,16 @@ const apbdes = proxy({
// =========================================
realisasi: {
// Create realisasi
async create(itemId: string, data: { jumlah: number; tanggal: string; keterangan?: string; buktiFileId?: string }) {
async create(itemId: string, data: { kode: string; jumlah: number; tanggal: string; keterangan?: string; buktiFileId?: string }) {
try {
const res = await (ApiFetch.api.landingpage.apbdes as any)[itemId].realisasi.post(data);
if (res.data?.success) {
toast.success("Realisasi berhasil ditambahkan");
// Reload findUnique untuk update data
if (apbdes.findUnique.data) {
await apbdes.findUnique.load(apbdes.findUnique.data.id);
const currentId = apbdes.findUnique.data?.id;
if (currentId) {
await apbdes.findUnique.load(currentId);
}
return true;
} else {
@@ -345,15 +366,16 @@ const apbdes = proxy({
},
// Update realisasi
async update(realisasiId: string, data: { jumlah?: number; tanggal?: string; keterangan?: string; buktiFileId?: string }) {
async update(realisasiId: string, data: { kode?: string; jumlah?: number; tanggal?: string; keterangan?: string; buktiFileId?: string }) {
try {
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].put(data);
if (res.data?.success) {
toast.success("Realisasi berhasil diperbarui");
// Reload findUnique untuk update data
if (apbdes.findUnique.data) {
await apbdes.findUnique.load(apbdes.findUnique.data.id);
const currentId = apbdes.findUnique.data?.id;
if (currentId) {
await apbdes.findUnique.load(currentId);
}
return true;
} else {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,8 @@
import { useProxy } from 'valtio/utils';
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
import { useState } from 'react';
import { toast } from 'react-toastify';
import {
Box,
Button,
@@ -23,7 +25,6 @@ import {
Badge,
Modal,
Divider,
Loader,
Center,
} from '@mantine/core';
import {
@@ -33,9 +34,6 @@ import {
IconCalendar,
IconCoin,
} from '@tabler/icons-react';
import { useState } from 'react';
import { toast } from 'react-toastify';
import colors from '@/con/colors';
interface RealisasiManagerProps {
itemId: string;
@@ -63,6 +61,7 @@ export default function RealisasiManager({
// Form state
const [formData, setFormData] = useState({
kode: '',
jumlah: 0,
tanggal: new Date().toISOString().split('T')[0], // YYYY-MM-DD format for input
keterangan: '',
@@ -70,6 +69,7 @@ export default function RealisasiManager({
const resetForm = () => {
setFormData({
kode: '',
jumlah: 0,
tanggal: new Date().toISOString().split('T')[0],
keterangan: '',
@@ -85,8 +85,9 @@ export default function RealisasiManager({
const handleOpenEdit = (realisasi: any) => {
const tanggal = new Date(realisasi.tanggal);
const tanggalStr = tanggal.toISOString().split('T')[0]; // YYYY-MM-DD
setFormData({
kode: realisasi.kode || '',
jumlah: realisasi.jumlah,
tanggal: tanggalStr,
keterangan: realisasi.keterangan || '',
@@ -100,12 +101,17 @@ export default function RealisasiManager({
return toast.warn('Jumlah realisasi harus lebih dari 0');
}
if (!formData.kode || formData.kode.trim() === '') {
return toast.warn('Kode realisasi wajib diisi');
}
try {
setLoading(true);
if (editingId) {
// Update existing realisasi
const success = await state.realisasi.update(editingId, {
kode: formData.kode,
jumlah: formData.jumlah,
tanggal: new Date(formData.tanggal).toISOString(),
keterangan: formData.keterangan,
@@ -117,6 +123,7 @@ export default function RealisasiManager({
} else {
// Create new realisasi
const success = await state.realisasi.create(itemId, {
kode: formData.kode,
jumlah: formData.jumlah,
tanggal: new Date(formData.tanggal).toISOString(),
keterangan: formData.keterangan,
@@ -257,6 +264,7 @@ export default function RealisasiManager({
<Table striped highlightOnHover fz="sm">
<TableThead>
<TableTr>
<TableTh>Kode</TableTh>
<TableTh>Tanggal</TableTh>
<TableTh>Uraian</TableTh>
<TableTh ta="right">Jumlah</TableTh>
@@ -266,6 +274,11 @@ export default function RealisasiManager({
<TableTbody>
{realisasiItems.map((realisasi) => (
<TableTr key={realisasi.id}>
<TableTd>
<Badge variant="light" color="blue" size="sm">
{realisasi.kode || '-'}
</Badge>
</TableTd>
<TableTd>
<Group gap="xs">
<IconCalendar size={16} />
@@ -314,7 +327,7 @@ export default function RealisasiManager({
Belum ada realisasi untuk item ini
</Text>
<Text fz="xs" c="dimmed">
Klik tombol "Tambah Realisasi" untuk menambahkan
Klik tombol &quot;Tambah Realisasi&quot; untuk menambahkan
</Text>
</Stack>
</Center>
@@ -349,6 +362,15 @@ export default function RealisasiManager({
</Text>
</Paper>
<TextInput
label="Kode Realisasi"
placeholder="Contoh: 4.1.1-R1"
value={formData.kode}
onChange={(e) => setFormData({ ...formData, kode: e.target.value })}
description="Kode unik untuk realisasi ini"
required
/>
<NumberInput
label="Jumlah Realisasi (Rp)"
value={formData.jumlah}

View File

@@ -44,6 +44,9 @@ type ItemForm = {
anggaran: number;
level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
realisasi?: number;
selisih?: number;
persentase?: number;
};
function EditAPBDes() {
@@ -72,6 +75,9 @@ function EditAPBDes() {
anggaran: 0,
level: 1,
tipe: 'pendapatan',
realisasi: 0,
selisih: 0,
persentase: 0,
});
// Simpan data original untuk reset form
@@ -125,9 +131,9 @@ function EditAPBDes() {
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
realisasi: item.realisasi,
selisih: item.selisih,
persentase: item.persentase,
realisasi: item.totalRealisasi || 0,
selisih: item.selisih || 0,
persentase: item.persentase || 0,
level: item.level,
tipe: item.tipe || 'pendapatan',
})),
@@ -155,7 +161,7 @@ function EditAPBDes() {
};
const handleAddItem = () => {
const { kode, uraian, anggaran, level, tipe } = newItem;
const { kode, uraian, anggaran, level, tipe, realisasi, selisih, persentase } = newItem;
if (!kode || !uraian) {
return toast.warn('Kode dan uraian wajib diisi');
}
@@ -166,6 +172,9 @@ function EditAPBDes() {
kode,
uraian,
anggaran,
realisasi: realisasi || 0,
selisih: selisih || 0,
persentase: persentase || 0,
level,
tipe: finalTipe,
});
@@ -176,6 +185,9 @@ function EditAPBDes() {
anggaran: 0,
level: 1,
tipe: 'pendapatan',
realisasi: 0,
selisih: 0,
persentase: 0,
});
};
@@ -193,7 +205,6 @@ function EditAPBDes() {
// Upload file baru jika ada perubahan
if (imageFile) {
// Hapus file lama dari form jika ada file baru
const res = await ApiFetch.api.fileStorage.create.post({
file: imageFile,
name: imageFile.name,
@@ -205,7 +216,6 @@ function EditAPBDes() {
}
if (docFile) {
// Hapus file lama dari form jika ada file baru
const res = await ApiFetch.api.fileStorage.create.post({
file: docFile,
name: docFile.name,
@@ -216,15 +226,7 @@ function EditAPBDes() {
}
}
// Jika tidak ada file baru, gunakan ID lama (sudah ada di form)
// Pastikan imageId dan fileId tetap ada
if (!apbdesState.edit.form.imageId) {
return toast.warn('Gambar wajib diunggah');
}
if (!apbdesState.edit.form.fileId) {
return toast.warn('Dokumen wajib diunggah');
}
// Image dan file sekarang opsional, tidak perlu validasi
const success = await apbdesState.edit.update();
if (success) {
router.push('/admin/landing-page/apbdes');
@@ -264,6 +266,9 @@ function EditAPBDes() {
anggaran: 0,
level: 1,
tipe: 'pendapatan',
realisasi: 0,
selisih: 0,
persentase: 0,
});
toast.info('Form dikembalikan ke data awal');
@@ -328,11 +333,11 @@ function EditAPBDes() {
required
/>
{/* Gambar & Dokumen */}
{/* Gambar & Dokumen (Opsional) */}
<Stack gap="xs">
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar APBDes
Gambar APBDes (Opsional)
</Text>
<Dropzone
onDrop={handleDrop('image')}
@@ -372,6 +377,7 @@ function EditAPBDes() {
onClick={() => {
setPreviewImage(null);
setImageFile(null);
apbdesState.edit.form.imageId = ''; // Clear imageId from form
}}
>
<IconX size={14} />
@@ -382,7 +388,7 @@ function EditAPBDes() {
<Box>
<Text fw="bold" fz="sm" mb={6}>
Dokumen APBDes
Dokumen APBDes (Opsional)
</Text>
<Dropzone
onDrop={handleDrop('doc')}
@@ -431,6 +437,7 @@ function EditAPBDes() {
onClick={() => {
setPreviewDoc(null);
setDocFile(null);
apbdesState.edit.form.fileId = ''; // Clear fileId from form
}}
>
<IconX size={14} />
@@ -527,6 +534,9 @@ function EditAPBDes() {
<th>Kode</th>
<th>Uraian</th>
<th>Anggaran</th>
<th>Realisasi</th>
<th>Selisih</th>
<th>%</th>
<th>Level</th>
<th>Tipe</th>
<th style={{ width: '50px' }}>Aksi</th>
@@ -542,6 +552,11 @@ function EditAPBDes() {
</td>
<td>{item.uraian}</td>
<td>{item.anggaran.toLocaleString('id-ID')}</td>
<td>{item.realisasi?.toLocaleString('id-ID') || '0'}</td>
<td style={{ color: item.selisih && item.selisih > 0 ? 'red' : 'green' }}>
{item.selisih?.toLocaleString('id-ID') || '0'}
</td>
<td>{item.persentase?.toFixed(2) || '0'}%</td>
<td>
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
L{item.level}

View File

@@ -233,7 +233,7 @@ function DetailAPBDes() {
</Table>
{/* Realisasi Manager untuk setiap item */}
{data.items.map((item: any) => (
{data.items.map((item) => (
<RealisasiManager
key={item.id}
itemId={item.id}

View File

@@ -46,13 +46,9 @@ function CreateAPBDes() {
const [docFile, setDocFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
// Check if form is valid - hanya cek items, gambar dan file opsional
const isFormValid = () => {
return (
imageFile !== null &&
docFile !== null &&
stateAPBDes.create.form.items.length > 0
);
return stateAPBDes.create.form.items.length > 0;
};
// Form sementara untuk input item baru
@@ -84,28 +80,34 @@ function CreateAPBDes() {
};
const handleSubmit = async () => {
if (!imageFile || !docFile) {
return toast.warn("Pilih gambar dan dokumen terlebih dahulu");
}
if (stateAPBDes.create.form.items.length === 0) {
return toast.warn("Minimal tambahkan 1 item APBDes");
}
try {
setIsSubmitting(true);
const [uploadImageRes, uploadDocRes] = await Promise.all([
ApiFetch.api.fileStorage.create.post({ file: imageFile, name: imageFile.name }),
ApiFetch.api.fileStorage.create.post({ file: docFile, name: docFile.name }),
]);
const imageId = uploadImageRes?.data?.data?.id;
const fileId = uploadDocRes?.data?.data?.id;
// Upload files hanya jika ada file yang dipilih
let imageId = '';
let fileId = '';
if (!imageId || !fileId) {
return toast.error("Gagal mengupload file");
if (imageFile) {
const uploadImageRes = await ApiFetch.api.fileStorage.create.post({
file: imageFile,
name: imageFile.name,
});
imageId = uploadImageRes?.data?.data?.id || '';
}
// Update form dengan ID file
if (docFile) {
const uploadDocRes = await ApiFetch.api.fileStorage.create.post({
file: docFile,
name: docFile.name,
});
fileId = uploadDocRes?.data?.data?.id || '';
}
// Update form dengan ID file (bisa kosong)
stateAPBDes.create.form.imageId = imageId;
stateAPBDes.create.form.fileId = fileId;
@@ -174,12 +176,16 @@ function CreateAPBDes() {
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
{/* Gambar & Dokumen (dipendekkan untuk fokus pada items) */}
{/* Info: File opsional */}
<Text fz="sm" c="dimmed" mb="xs">
* Upload gambar dan dokumen bersifat opsional. Bisa dikosongkan jika belum ada.
</Text>
<Stack gap={"xs"}>
{/* Gambar APBDes */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar APBDes
Gambar APBDes (Opsional)
</Text>
<Dropzone
onDrop={(files) => {
@@ -249,10 +255,10 @@ function CreateAPBDes() {
)}
</Box>
{/* Dokumen APBDes */}
{/* Dokumen APBDes (Opsional) */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Dokumen APBDes
Dokumen APBDes (Opsional)
</Text>
<Dropzone
onDrop={(files) => {

View File

@@ -211,6 +211,9 @@ function ListKategoriPrestasi({ search }: { search: string }) {
</Stack>
</Box>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}

View File

@@ -123,37 +123,51 @@ export default function CreateMusik() {
setIsSubmitting(true);
// Upload cover image
console.log('Uploading cover image:', coverFile.name);
const coverRes = await ApiFetch.api.fileStorage.create.post({
file: coverFile,
name: coverFile.name,
});
console.log('Cover upload response:', coverRes);
const coverUploaded = coverRes.data?.data;
if (!coverUploaded?.id) {
return toast.error('Gagal mengunggah cover, silakan coba lagi');
console.error('Cover upload failed:', coverRes);
toast.error('Gagal mengunggah cover, silakan coba lagi');
return;
}
musikState.musik.create.form.coverImageId = coverUploaded.id;
// Upload audio file
console.log('Uploading audio file:', audioFile.name);
const audioRes = await ApiFetch.api.fileStorage.create.post({
file: audioFile,
name: audioFile.name,
});
console.log('Audio upload response:', audioRes);
const audioUploaded = audioRes.data?.data;
if (!audioUploaded?.id) {
return toast.error('Gagal mengunggah audio, silakan coba lagi');
console.error('Audio upload failed:', audioRes);
toast.error('Gagal mengunggah audio, silakan coba lagi');
return;
}
musikState.musik.create.form.audioFileId = audioUploaded.id;
// Create musik entry
console.log('Creating musik entry with form:', musikState.musik.create.form);
await musikState.musik.create.create();
resetForm();
router.push('/admin/musik');
} catch (error) {
console.error('Error creating musik:', error);
console.error('Error creating musik:', {
error,
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
});
toast.error('Terjadi kesalahan saat membuat musik');
} finally {
setIsSubmitting(false);

View File

@@ -0,0 +1,25 @@
// src/app/admin/_com/getMenuIdsByRoleId.ts
import { navBar, role1, role2, role3 } from '@/app/admin/_com/list_PageAdmin';
/**
* Mengembalikan daftar ID menu (string[]) berdasarkan roleId
*/
export function getMenuIdsByRoleId(roleId: string | number): string[] {
const id = typeof roleId === 'string' ? parseInt(roleId, 10) : roleId;
switch (id) {
case 0:
// Asumsikan devBar ada dan punya struktur sama
return []; // atau sesuaikan jika ada devBar
case 1:
return navBar.map(section => section.id);
case 2:
return role1.map(section => section.id);
case 3:
return role2.map(section => section.id);
case 4:
return role3.map(section => section.id);
default:
return [];
}
}

View File

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

View File

@@ -15,7 +15,7 @@ import AjukanPermohonan from "./layanan/ajukan_permohonan";
import Musik from "./musik";
const Desa = new Elysia({ prefix: "/api/desa", tags: ["Desa"] })
const Desa = new Elysia({ prefix: "/desa", tags: ["Desa"] })
.use(Berita)
.use(Pengumuman)
.use(ProfileDesa)

View File

@@ -13,7 +13,7 @@ import PendapatanAsliDesa from "./pendapatan-asli-desa";
import StrukturOrganisasi from "./struktur-bumdes";
const Ekonomi = new Elysia({
prefix: "/api/ekonomi",
prefix: "/ekonomi",
tags: ["Ekonomi"],
})
.use(PasarDesa)

View File

@@ -5,7 +5,7 @@ import { fileStorageFindMany } from "./_lib/findMany";
import fileStorageDelete from "./_lib/del";
const FileStorage = new Elysia({
prefix: "/api/fileStorage",
prefix: "/fileStorage",
tags: ["FileStorage"],
})
.post("/create", fileStorageCreate, {

View File

@@ -8,7 +8,7 @@ import LayananOnlineDesa from "./layanan-online-desa";
import MitraKolaborasi from "./kolaborasi-inovasi/mitra-kolaborasi";
const Inovasi = new Elysia({
prefix: "/api/inovasi",
prefix: "/inovasi",
tags: ["Inovasi"],
})
.use(DesaDigital)

View File

@@ -9,7 +9,7 @@ import KontakDaruratKeamanan from "./kontak-darurat-keamanan";
import KontakItem from "./kontak-darurat-keamanan/kontak-item";
import LayananPolsek from "./polsek-terdekat/layanan-polsek";
const Keamanan = new Elysia({ prefix: "/api/keamanan", tags: ["Keamanan"] })
const Keamanan = new Elysia({ prefix: "/keamanan", tags: ["Keamanan"] })
.use(KeamananLingkungan)
.use(PolsekTerdekat)
.use(PencegahanKriminalitas)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ import TarifLayanan from "./data_kesehatan_warga/fasilitas_kesehatan/tarif-layan
const Kesehatan = new Elysia({
prefix: "/api/kesehatan",
prefix: "/kesehatan",
tags: ["Kesehatan"],
})
.use(PersentaseKelahiranKematian)

View File

@@ -17,8 +17,8 @@ type FormCreate = {
name?: string;
deskripsi?: string;
jumlah?: string;
imageId: string;
fileId: string;
imageId?: string | null; // Opsional
fileId?: string | null; // Opsional
items: APBDesItemInput[];
};
@@ -32,12 +32,7 @@ export default async function apbdesCreate(context: Context) {
if (!body.tahun) {
throw new Error('Tahun is required');
}
if (!body.imageId) {
throw new Error('Image ID is required');
}
if (!body.fileId) {
throw new Error('File ID is required');
}
// Image dan file sekarang opsional
if (!body.items || body.items.length === 0) {
throw new Error('At least one item is required');
}
@@ -50,8 +45,8 @@ export default async function apbdesCreate(context: Context) {
name: body.name || `APBDes Tahun ${body.tahun}`,
deskripsi: body.deskripsi,
jumlah: body.jumlah,
imageId: body.imageId,
fileId: body.fileId,
imageId: body.imageId || null, // null jika tidak ada
fileId: body.fileId || null, // null jika tidak ada
},
});

View File

@@ -36,8 +36,8 @@ const APBDes = new Elysia({
name: t.Optional(t.String()),
deskripsi: t.Optional(t.String()),
jumlah: t.Optional(t.String()),
imageId: t.String(),
fileId: t.String(),
imageId: t.Optional(t.String()),
fileId: t.Optional(t.String()),
items: t.Array(ApbdesItemSchema),
}),
})
@@ -50,8 +50,8 @@ const APBDes = new Elysia({
name: t.Optional(t.String()),
deskripsi: t.Optional(t.String()),
jumlah: t.Optional(t.String()),
imageId: t.String(),
fileId: t.String(),
imageId: t.Optional(t.String()),
fileId: t.Optional(t.String()),
items: t.Array(ApbdesItemSchema),
}),
})
@@ -69,6 +69,7 @@ const APBDes = new Elysia({
.post("/:itemId/realisasi", realisasiCreate, {
params: t.Object({ itemId: t.String() }),
body: t.Object({
kode: t.String(),
jumlah: t.Number(),
tanggal: t.String(),
keterangan: t.Optional(t.String()),
@@ -80,6 +81,7 @@ const APBDes = new Elysia({
.put("/realisasi/:realisasiId", realisasiUpdate, {
params: t.Object({ realisasiId: t.String() }),
body: t.Object({
kode: t.Optional(t.String()),
jumlah: t.Optional(t.Number()),
tanggal: t.Optional(t.String()),
keterangan: t.Optional(t.String()),

View File

@@ -3,6 +3,7 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia";
type RealisasiCreateBody = {
kode: string;
jumlah: number;
tanggal: string; // ISO format
keterangan?: string;
@@ -33,6 +34,7 @@ export default async function realisasiCreate(context: Context) {
const realisasi = await prisma.realisasiItem.create({
data: {
apbdesItemId: itemId,
kode: body.kode,
jumlah: body.jumlah,
tanggal: new Date(body.tanggal),
keterangan: body.keterangan,

View File

@@ -3,6 +3,7 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia";
type RealisasiUpdateBody = {
kode?: string;
jumlah?: number;
tanggal?: string;
keterangan?: string;
@@ -33,6 +34,7 @@ export default async function realisasiUpdate(context: Context) {
const updated = await prisma.realisasiItem.update({
where: { id: realisasiId },
data: {
...(body.kode !== undefined && { kode: body.kode }),
...(body.jumlah !== undefined && { jumlah: body.jumlah }),
...(body.tanggal !== undefined && { tanggal: new Date(body.tanggal) }),
...(body.keterangan !== undefined && { keterangan: body.keterangan }),

View File

@@ -1,6 +1,7 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
import { assignParentIdsToApbdesItems } from "./lib/getParentsID";
import { RealisasiItem } from "@prisma/client";
type APBDesItemInput = {
kode: string;
@@ -15,8 +16,8 @@ type FormUpdateBody = {
name?: string;
deskripsi?: string;
jumlah?: string;
imageId: string;
fileId: string;
imageId?: string | null;
fileId?: string | null;
items: APBDesItemInput[];
};
@@ -28,6 +29,16 @@ export default async function apbdesUpdate(context: Context) {
// 1. Pastikan APBDes ada
const existing = await prisma.aPBDes.findUnique({
where: { id },
include: {
items: {
where: { isActive: true },
include: {
realisasiItems: {
where: { isActive: true },
},
},
},
},
});
if (!existing) {
@@ -38,17 +49,35 @@ export default async function apbdesUpdate(context: Context) {
};
}
// 2. Hapus semua item lama (cascade akan menghapus realisasiItems juga)
// 2. Build map untuk preserve realisasiItems berdasarkan kode
const existingItemsMap = new Map<string, {
id: string;
realisasiItems: RealisasiItem[];
}>();
existing.items.forEach(item => {
existingItemsMap.set(item.kode, {
id: item.id,
realisasiItems: item.realisasiItems,
});
});
// 3. Hapus semua item lama (cascade akan menghapus realisasiItems juga)
// TAPI kita sudah save realisasiItems di map atas
await prisma.aPBDesItem.deleteMany({
where: { apbdesId: id },
});
// 3. Buat item baru dengan auto-calculate fields
// 4. Buat item baru dengan preserve realisasiItems
await prisma.aPBDesItem.createMany({
data: body.items.map((item) => {
data: await Promise.all(body.items.map(async (item) => {
const anggaran = item.anggaran;
const totalRealisasi = 0; // Reset karena items baru
const selisih = anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
// Check apakah item ini punya realisasiItems lama
const existingItem = existingItemsMap.get(item.kode);
const realisasiItemsData = existingItem?.realisasiItems || [];
const totalRealisasi = realisasiItemsData.reduce((sum, r) => sum + r.jumlah, 0);
const selisih = anggaran - totalRealisasi;
const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0;
return {
@@ -63,16 +92,68 @@ export default async function apbdesUpdate(context: Context) {
persentase,
isActive: true,
};
}),
})),
});
// 4. Dapatkan semua item yang baru dibuat untuk mendapatkan ID-nya
// 5. Dapatkan semua item yang baru dibuat untuk mendapatkan ID-nya
const allItems = await prisma.aPBDesItem.findMany({
where: { apbdesId: id },
select: { id: true, kode: true },
});
// 5. Update parentId untuk setiap item
// 6. Build map baru untuk item IDs
const newItemIdsMap = new Map<string, string>();
allItems.forEach(item => {
newItemIdsMap.set(item.kode, item.id);
});
// 7. Re-create realisasiItems dengan link ke item IDs yang baru
for (const [oldKode, oldItemData] of existingItemsMap.entries()) {
if (oldItemData.realisasiItems.length > 0) {
const newItemId = newItemIdsMap.get(oldKode);
if (newItemId) {
// Re-create realisasiItems untuk item ini
await prisma.realisasiItem.createMany({
data: oldItemData.realisasiItems.map(r => ({
apbdesItemId: newItemId,
kode: r.kode,
jumlah: r.jumlah,
tanggal: r.tanggal,
keterangan: r.keterangan,
buktiFileId: r.buktiFileId,
isActive: true,
})),
});
}
}
}
// 8. Recalculate totalRealisasi setelah re-create realisasiItems
for (const kode of existingItemsMap.keys()) {
const newItemId = newItemIdsMap.get(kode);
if (newItemId) {
const realisasiItems = await prisma.realisasiItem.findMany({
where: { apbdesItemId: newItemId, isActive: true },
});
const totalRealisasi = realisasiItems.reduce((sum, r) => sum + r.jumlah, 0);
const item = await prisma.aPBDesItem.findUnique({
where: { id: newItemId },
});
if (item) {
const selisih = item.anggaran - totalRealisasi;
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
await prisma.aPBDesItem.update({
where: { id: newItemId },
data: { totalRealisasi, selisih, persentase },
});
}
}
}
// 9. Update parentId untuk setiap item
const itemsForParentUpdate = allItems.map(item => ({
id: item.id,
kode: item.kode,
@@ -80,7 +161,7 @@ export default async function apbdesUpdate(context: Context) {
await assignParentIdsToApbdesItems(itemsForParentUpdate);
// 6. Update data APBDes
// 10. Update data APBDes
await prisma.aPBDes.update({
where: { id },
data: {
@@ -88,12 +169,12 @@ export default async function apbdesUpdate(context: Context) {
name: body.name || `APBDes Tahun ${body.tahun}`,
deskripsi: body.deskripsi,
jumlah: body.jumlah,
imageId: body.imageId,
fileId: body.fileId,
imageId: body.imageId === '' ? null : body.imageId,
fileId: body.fileId === '' ? null : body.fileId,
},
});
// 7. Ambil data lengkap untuk response (include realisasiItems)
// 11. Ambil data lengkap untuk response (include realisasiItems)
const result = await prisma.aPBDes.findUnique({
where: { id },
include: {

View File

@@ -14,7 +14,7 @@ import UmurResponden from "./indeks_kepuasan/umur-responden";
import Responden from "./indeks_kepuasan/responden";
const LandingPage = new Elysia({
prefix: "/api/landingpage",
prefix: "/landingpage",
tags: ["Landing Page/Profile"]
})

View File

@@ -9,7 +9,7 @@ import KategoriKegiatan from "./gotong-royong/kategori-kegiatan";
import KeteranganBankSampahTerdekat from "./pengelolaan-sampah/keterangan-bank-sampah";
const Lingkungan = new Elysia({
prefix: "/api/lingkungan",
prefix: "/lingkungan",
tags: ["Lingkungan"],
})

View File

@@ -8,7 +8,7 @@ import Beasiswa from "./beasiswa-desa";
import PerpustakaanDigital from "./perpustakaan-digital";
const Pendidikan = new Elysia({
prefix: "/api/pendidikan",
prefix: "/pendidikan",
tags: ["Pendidikan"]
})

View File

@@ -14,7 +14,7 @@ import GrafikHasilKepuasanMasyarakat from "./ikm/grafik_hasil_kepuasan_masyaraka
const PPID = new Elysia({ prefix: "/api/ppid", tags: ["PPID"] })
const PPID = new Elysia({ prefix: "/ppid", tags: ["PPID"] })
.use(ProfilePPID)
.use(DaftarInformasiPublik)
.use(GrafikHasilKepuasanMasyarakat)

View File

@@ -2,7 +2,7 @@ import Elysia from "elysia";
import searchFindMany from "./findMany";
const Search = new Elysia({
prefix: "/api/search",
prefix: "/search",
tags: ["Search"],
})
.get("/findMany", searchFindMany);

View File

@@ -7,7 +7,7 @@ import userDelete from "./del"; // `delete` nggak boleh jadi nama file JS langsu
import userUpdate from "./updt";
import userDeleteAccount from "./delUser";
const User = new Elysia({ prefix: "/api/user" })
const User = new Elysia({ prefix: "/user" })
.get("/findMany", userFindMany)
.get("/findUnique/:id", userFindUnique)
.put("/del/:id", userDelete, {

View File

@@ -6,7 +6,7 @@ import roleFindUnique from "./findUnique";
import roleUpdate from "./updt";
const Role = new Elysia({
prefix: "/api/role",
prefix: "/role",
tags: ["User / Role"],
})

View File

@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { getMenuIdsByRoleId } from "@/app/admin/(dashboard)/user&role/_com/getMenuIdByRole";
import prisma from "@/lib/prisma";
import { Context } from "elysia";
@@ -34,11 +35,25 @@ export default async function userUpdate(context: Context) {
const isActiveChanged =
isActive !== undefined && currentUser.isActive !== isActive;
// ✅ Jika role berubah, hapus semua akses menu yang ada
if (isRoleChanged) {
// ✅ Jika role berubah, reset dan set ulang akses menu
if (isRoleChanged && roleId) {
// Hapus akses lama
await prisma.userMenuAccess.deleteMany({
where: { userId: id }
});
// Ambil menu default untuk role baru
const menuIds = getMenuIdsByRoleId(roleId);
if (menuIds.length > 0) {
// Buat akses baru
await prisma.userMenuAccess.createMany({
data: menuIds.map(menuId => ({
userId: id,
menuId
}))
});
}
}
// Update user

View File

@@ -47,15 +47,16 @@ fs.mkdir(UPLOAD_DIR_IMAGE, {
const corsConfig = {
origin: [
"*", // Allow all origins - must be first when using credentials: true
"http://localhost:3000",
"http://localhost:3001",
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
"*", // Allow all origins in development
"https://desa-darmasaba-stg.wibudev.com",
],
methods: ["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] as HTTPMethod[],
allowedHeaders: ["Content-Type", "Authorization", "*"],
exposedHeaders: "*",
allowedHeaders: ["Content-Type", "Authorization", "Accept", "*"],
exposedHeaders: ["Content-Range", "X-Content-Range", "*"],
maxAge: 86400, // 24 hours
credentials: true,
};
@@ -66,7 +67,7 @@ async function layanan() {
}
const Utils = new Elysia({
prefix: "/api/utils",
prefix: "/utils",
tags: ["Utils"],
}).get("/version", async () => {
const packageJson = await fs.readFile(
@@ -80,8 +81,7 @@ const Utils = new Elysia({
if (!process.env.WIBU_UPLOAD_DIR)
throw new Error("WIBU_UPLOAD_DIR is not defined");
const ApiServer = new Elysia()
.use(swagger({ path: "/api/docs" }))
const ApiServer = new Elysia({ prefix: "/api" })
.use(
staticPlugin({
assets: UPLOAD_DIR,
@@ -89,6 +89,25 @@ const ApiServer = new Elysia()
}),
)
.use(cors(corsConfig))
.use(
swagger({
path: "/docs",
documentation: {
info: {
title: "Desa Darmasaba API Documentation",
version: "1.0.0",
},
},
}),
)
.onError(({ code }) => {
if (code === "NOT_FOUND") {
return {
status: 404,
body: "Route not found :(",
};
}
})
.use(Utils)
.use(FileStorage)
.use(LandingPage)
@@ -103,126 +122,114 @@ const ApiServer = new Elysia()
.use(User)
.use(Role)
.use(Search)
.onError(({ code }) => {
if (code === "NOT_FOUND") {
return {
status: 404,
body: "Route not found :(",
};
}
})
.group("/api", (app) =>
app
.get("/layanan", layanan)
.get("/potensi", getPotensi)
.get(
"/img/:name",
({ params, query }) => {
return img({
name: params.name,
UPLOAD_DIR_IMAGE,
ROOT,
size: query.size,
});
},
{
params: t.Object({
name: t.String(),
}),
query: t.Optional(
t.Object({
size: t.Optional(t.Number()),
}),
),
},
)
.delete(
"/img/:name",
({ params }) => {
return imgDel({
name: params.name,
UPLOAD_DIR_IMAGE,
});
},
{
params: t.Object({
name: t.String(),
}),
},
)
.get(
"/imgs",
({ query }) => {
return imgs({
search: query.search,
page: query.page,
count: query.count,
UPLOAD_DIR_IMAGE,
});
},
{
query: t.Optional(
t.Object({
page: t.Number({ default: 1 }),
count: t.Number({ default: 10 }),
search: t.String({ default: "" }),
}),
),
},
)
.post(
"/upl-img",
({ body }) => {
console.log(body.title);
return uplImg({ files: body.files, UPLOAD_DIR_IMAGE });
},
{
body: t.Object({
title: t.String(),
files: t.Files({ multiple: true }),
}),
},
)
.post(
"/upl-img-single",
({ body }) => {
return uplImgSingle({
fileName: body.name,
file: body.file,
UPLOAD_DIR_IMAGE,
});
},
{
body: t.Object({
name: t.String(),
file: t.File(),
}),
},
)
.post(
"/upl-csv-single",
({ body }) => {
return uplCsvSingle({ fileName: body.name, file: body.file });
},
{
body: t.Object({
name: t.String(),
file: t.File(),
}),
},
)
.post(
"/upl-csv",
({ body }) => {
return uplCsv({ files: body.files });
},
{
body: t.Object({
files: t.Files(),
}),
},
.get("/layanan", layanan)
.get("/potensi", getPotensi)
.get(
"/img/:name",
({ params, query }) => {
return img({
name: params.name,
UPLOAD_DIR_IMAGE,
ROOT,
size: query.size,
});
},
{
params: t.Object({
name: t.String(),
}),
query: t.Optional(
t.Object({
size: t.Optional(t.Number()),
}),
),
},
)
.delete(
"/img/:name",
({ params }) => {
return imgDel({
name: params.name,
UPLOAD_DIR_IMAGE,
});
},
{
params: t.Object({
name: t.String(),
}),
},
)
.get(
"/imgs",
({ query }) => {
return imgs({
search: query.search,
page: query.page,
count: query.count,
UPLOAD_DIR_IMAGE,
});
},
{
query: t.Optional(
t.Object({
page: t.Number({ default: 1 }),
count: t.Number({ default: 10 }),
search: t.String({ default: "" }),
}),
),
},
)
.post(
"/upl-img",
({ body }) => {
console.log(body.title);
return uplImg({ files: body.files, UPLOAD_DIR_IMAGE });
},
{
body: t.Object({
title: t.String(),
files: t.Files({ multiple: true }),
}),
},
)
.post(
"/upl-img-single",
({ body }) => {
return uplImgSingle({
fileName: body.name,
file: body.file,
UPLOAD_DIR_IMAGE,
});
},
{
body: t.Object({
name: t.String(),
file: t.File(),
}),
},
)
.post(
"/upl-csv-single",
({ body }) => {
return uplCsvSingle({ fileName: body.name, file: body.file });
},
{
body: t.Object({
name: t.String(),
file: t.File(),
}),
},
)
.post(
"/upl-csv",
({ body }) => {
return uplCsv({ files: body.files });
},
{
body: t.Object({
files: t.Files(),
}),
},
);
export const GET = ApiServer.handle;

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