Compare commits

...

87 Commits

Author SHA1 Message Date
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
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
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
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
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
39 changed files with 1554 additions and 718 deletions

19
.env Normal file
View File

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

41
.env.example Normal file
View File

@@ -0,0 +1,41 @@
# 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
# 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

9
.gitignore vendored
View File

@@ -29,7 +29,12 @@ yarn-error.log*
.pnpm-debug.log*
# env
.env*
# env local files (keep .env.example)
.env.local
.env*.local
.env.production
.env.development
!.env.example
# QC
QC
@@ -52,7 +57,5 @@ next-env.d.ts
.github/
.env.*
*.tar.gz

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.).

60
Dockerfile Normal file
View File

@@ -0,0 +1,60 @@
# Stage 1: Build
FROM oven/bun:1.3 AS build
# Install build dependencies for native modules
RUN apt-get update && apt-get install -y \
python3 \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Set the working directory
WORKDIR /app
# Copy package files
COPY package.json bun.lock* ./
# Install dependencies
RUN bun install --frozen-lockfile
# Copy the rest of the application code
COPY . .
# Use .env.example as default env for build
RUN cp .env.example .env
# Generate Prisma client
RUN bun x prisma generate
# Build the application frontend
ENV NODE_ENV=production
RUN bun run build
# Stage 2: Runtime
FROM oven/bun:1.3-slim AS runtime
# Set environment variables
ENV NODE_ENV=production
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Set the working directory
WORKDIR /app
# Copy necessary files from build stage
COPY --from=build /app/package.json ./
COPY --from=build /app/tsconfig.json ./
COPY --from=build /app/.next ./.next
COPY --from=build /app/public ./public
COPY --from=build /app/src ./src
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/prisma ./prisma
# Expose the port
EXPOSE 3000
# Start the application
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

BIN
bun.lockb

Binary file not shown.

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

@@ -33,7 +33,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",
@@ -89,7 +89,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",

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

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

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

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

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

View File

@@ -0,0 +1,320 @@
'use client';
import {
createContext,
useContext,
useState,
useRef,
useEffect,
useCallback,
ReactNode,
} from 'react';
interface MusicFile {
id: string;
name: string;
realName: string;
path: string;
mimeType: string;
link: string;
}
export interface Musik {
id: string;
judul: string;
artis: string;
deskripsi: string | null;
durasi: string;
genre: string | null;
tahunRilis: number | null;
audioFile: MusicFile | null;
coverImage: MusicFile | null;
isActive: boolean;
}
interface MusicContextType {
// State
isPlaying: boolean;
currentSong: Musik | null;
currentSongIndex: number;
musikData: Musik[];
currentTime: number;
duration: number;
volume: number;
isMuted: boolean;
isRepeat: boolean;
isShuffle: boolean;
isLoading: boolean;
isPlayerOpen: boolean;
// Actions
playSong: (song: Musik) => void;
togglePlayPause: () => void;
playNext: () => void;
playPrev: () => void;
seek: (time: number) => void;
setVolume: (volume: number) => void;
toggleMute: () => void;
toggleRepeat: () => void;
toggleShuffle: () => void;
togglePlayer: () => void;
loadMusikData: () => Promise<void>;
}
const MusicContext = createContext<MusicContextType | undefined>(undefined);
export function MusicProvider({ children }: { children: ReactNode }) {
// State
const [isPlaying, setIsPlaying] = useState(false);
const [currentSong, setCurrentSong] = useState<Musik | null>(null);
const [currentSongIndex, setCurrentSongIndex] = useState(-1);
const [musikData, setMusikData] = useState<Musik[]>([]);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolumeState] = useState(70);
const [isMuted, setIsMuted] = useState(false);
const [isRepeat, setIsRepeat] = useState(false);
const [isShuffle, setIsShuffle] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
// Refs
const audioRef = useRef<HTMLAudioElement | null>(null);
const isSeekingRef = useRef(false);
const animationFrameRef = useRef<number | null>(null);
const isRepeatRef = useRef(false); // Ref untuk avoid stale closure
// Sync ref dengan state
useEffect(() => {
isRepeatRef.current = isRepeat;
}, [isRepeat]);
// Load musik data
const loadMusikData = useCallback(async () => {
try {
setIsLoading(true);
const res = await fetch('/api/desa/musik/find-many?page=1&limit=50');
const data = await res.json();
if (data.success && data.data) {
const activeMusik = data.data.filter((m: Musik) => m.isActive);
setMusikData(activeMusik);
}
} catch (error) {
console.error('Error fetching musik:', error);
} finally {
setIsLoading(false);
}
}, []);
// Initialize audio element
useEffect(() => {
audioRef.current = new Audio();
audioRef.current.preload = 'metadata';
// Event listeners
audioRef.current.addEventListener('loadedmetadata', () => {
setDuration(Math.floor(audioRef.current!.duration));
});
audioRef.current.addEventListener('ended', () => {
// Gunakan ref untuk avoid stale closure
if (isRepeatRef.current) {
audioRef.current!.currentTime = 0;
audioRef.current!.play();
} else {
playNext();
}
});
// Load initial data
loadMusikData();
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- playNext is intentionally not in deps to avoid circular dependency
}, [loadMusikData]); // Remove isRepeat dari deps karena sudah pakai ref
// Update time with requestAnimationFrame for smooth progress
const updateTime = useCallback(() => {
if (audioRef.current && !audioRef.current.paused && !isSeekingRef.current) {
setCurrentTime(Math.floor(audioRef.current.currentTime));
animationFrameRef.current = requestAnimationFrame(updateTime);
}
}, []);
useEffect(() => {
if (isPlaying) {
animationFrameRef.current = requestAnimationFrame(updateTime);
} else {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
}
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [isPlaying, updateTime]);
// Play song
const playSong = useCallback(
(song: Musik) => {
if (!song?.audioFile?.link || !audioRef.current) return;
const songIndex = musikData.findIndex(m => m.id === song.id);
setCurrentSongIndex(songIndex);
setCurrentSong(song);
setIsPlaying(true);
audioRef.current.src = song.audioFile.link;
audioRef.current.load();
audioRef.current
.play()
.catch((err) => console.error('Error playing audio:', err));
},
[musikData]
);
// Toggle play/pause
const togglePlayPause = useCallback(() => {
if (!audioRef.current || !currentSong) return;
if (isPlaying) {
audioRef.current.pause();
setIsPlaying(false);
} else {
audioRef.current
.play()
.then(() => setIsPlaying(true))
.catch((err) => console.error('Error playing audio:', err));
}
}, [isPlaying, currentSong]);
// Play next
const playNext = useCallback(() => {
if (musikData.length === 0) return;
let nextIndex: number;
if (isShuffle) {
nextIndex = Math.floor(Math.random() * musikData.length);
} else {
nextIndex = (currentSongIndex + 1) % musikData.length;
}
const nextSong = musikData[nextIndex];
if (nextSong) {
playSong(nextSong);
}
}, [musikData, isShuffle, currentSongIndex, playSong]);
// Play previous
const playPrev = useCallback(() => {
if (musikData.length === 0) return;
// If more than 3 seconds into song, restart it
if (currentTime > 3) {
if (audioRef.current) {
audioRef.current.currentTime = 0;
}
return;
}
const prevIndex =
currentSongIndex <= 0 ? musikData.length - 1 : currentSongIndex - 1;
const prevSong = musikData[prevIndex];
if (prevSong) {
playSong(prevSong);
}
}, [musikData, currentSongIndex, currentTime, playSong]);
// Seek
const seek = useCallback((time: number) => {
if (!audioRef.current) return;
audioRef.current.currentTime = time;
setCurrentTime(time);
}, []);
// Set volume
const setVolume = useCallback((vol: number) => {
if (!audioRef.current) return;
const normalizedVol = Math.max(0, Math.min(100, vol)) / 100;
audioRef.current.volume = normalizedVol;
setVolumeState(Math.max(0, Math.min(100, vol)));
setIsMuted(normalizedVol === 0);
}, []);
// Toggle mute
const toggleMute = useCallback(() => {
if (!audioRef.current) return;
const newMuted = !isMuted;
audioRef.current.muted = newMuted;
setIsMuted(newMuted);
if (newMuted && volume > 0) {
audioRef.current.volume = 0;
} else if (!newMuted && volume > 0) {
audioRef.current.volume = volume / 100;
}
}, [isMuted, volume]);
// Toggle repeat
const toggleRepeat = useCallback(() => {
setIsRepeat((prev) => !prev);
}, []);
// Toggle shuffle
const toggleShuffle = useCallback(() => {
setIsShuffle((prev) => !prev);
}, []);
// Toggle player
const togglePlayer = useCallback(() => {
setIsPlayerOpen((prev) => !prev);
}, []);
const value: MusicContextType = {
isPlaying,
currentSong,
currentSongIndex,
musikData,
currentTime,
duration,
volume,
isMuted,
isRepeat,
isShuffle,
isLoading,
isPlayerOpen,
playSong,
togglePlayPause,
playNext,
playPrev,
seek,
setVolume,
toggleMute,
toggleRepeat,
toggleShuffle,
togglePlayer,
loadMusikData,
};
return (
<MusicContext.Provider value={value}>{children}</MusicContext.Provider>
);
}
export function useMusic() {
const context = useContext(MusicContext);
if (context === undefined) {
throw new Error('useMusic must be used within a MusicProvider');
}
return context;
}

View File

@@ -1,77 +1,41 @@
'use client'
import { useMusic } from '@/app/context/MusicContext';
import { ActionIcon, Avatar, Badge, Box, Card, Flex, Grid, Group, Paper, ScrollArea, Slider, Stack, Text, TextInput } from '@mantine/core';
import { IconArrowsShuffle, IconPlayerPauseFilled, IconPlayerPlayFilled, IconPlayerSkipBackFilled, IconPlayerSkipForwardFilled, IconRepeat, IconRepeatOff, IconSearch, IconVolume, IconVolumeOff, IconX } from '@tabler/icons-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import BackButton from '../../desa/layanan/_com/BackButto';
import { togglePlayPause } from '../lib/playPause';
import { getNextIndex, getPrevIndex } from '../lib/nextPrev';
import { handleRepeatOrNext } from '../lib/repeat';
import { toggleShuffle } from '../lib/shuffle';
import { setAudioVolume, toggleMute as toggleMuteUtil } from '../lib/volume';
import { useAudioProgress } from '../lib/useAudioProgress';
interface MusicFile {
id: string;
name: string;
realName: string;
path: string;
mimeType: string;
link: string;
}
interface Musik {
id: string;
judul: string;
artis: string;
deskripsi: string | null;
durasi: string;
genre: string | null;
tahunRilis: number | null;
audioFile: MusicFile | null;
coverImage: MusicFile | null;
isActive: boolean;
}
const MusicPlayer = () => {
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(70);
const [isMuted, setIsMuted] = useState(false);
const [isRepeat, setIsRepeat] = useState(false);
const [isShuffle, setIsShuffle] = useState(false);
const {
isPlaying,
currentSong,
currentTime,
duration,
volume,
isMuted,
isRepeat,
isShuffle,
isLoading,
musikData,
playSong,
togglePlayPause,
playNext,
playPrev,
seek,
setVolume,
toggleMute,
toggleRepeat,
toggleShuffle,
} = useMusic();
const [search, setSearch] = useState('');
const [musikData, setMusikData] = useState<Musik[]>([]);
const [loading, setLoading] = useState(true);
const [currentSongIndex, setCurrentSongIndex] = useState(-1);
const audioRef = useRef<HTMLAudioElement | null>(null);
const isSeekingRef = useRef(false);
const lastPlayedSongIdRef = useRef<string | null>(null);
const lastSeekTimeRef = useRef<number>(0); // Track last seek time
// Smooth progress update dengan requestAnimationFrame
useAudioProgress(audioRef as React.RefObject<HTMLAudioElement>, isPlaying, setCurrentTime, isSeekingRef);
// Fetch musik data from global state
const { loadMusikData } = useMusic();
// Fetch musik data from API
useEffect(() => {
const fetchMusik = async () => {
try {
setLoading(true);
const res = await fetch('/api/desa/musik/find-many?page=1&limit=50');
const data = await res.json();
if (data.success && data.data) {
const activeMusik = data.data.filter((m: Musik) => m.isActive);
setMusikData(activeMusik);
}
} catch (error) {
console.error('Error fetching musik:', error);
} finally {
setLoading(false);
}
};
fetchMusik();
}, []);
loadMusikData();
}, [loadMusikData]);
// Filter musik based on search - gunakan useMemo untuk mencegah re-calculate setiap render
const filteredMusik = useMemo(() => {
@@ -82,146 +46,42 @@ const MusicPlayer = () => {
);
}, [musikData, search]);
const currentSong = currentSongIndex >= 0 && currentSongIndex < filteredMusik.length
? filteredMusik[currentSongIndex]
: null;
// // Update progress bar
// useEffect(() => {
// if (isPlaying && audioRef.current) {
// progressInterval.current = window.setInterval(() => {
// if (audioRef.current) {
// setCurrentTime(Math.floor(audioRef.current.currentTime));
// }
// }, 1000);
// } else {
// if (progressInterval.current) {
// clearInterval(progressInterval.current);
// }
// }
// return () => {
// if (progressInterval.current) {
// clearInterval(progressInterval.current);
// }
// };
// }, [isPlaying]);
// Update duration when song changes (HANYA saat ganti lagu, bukan saat isPlaying berubah)
useEffect(() => {
if (currentSong && audioRef.current) {
// Cek apakah ini benar-benar lagu baru
const isNewSong = lastPlayedSongIdRef.current !== currentSong.id;
if (isNewSong) {
// Gunakan durasi dari database sebagai acuan utama
const durationParts = currentSong.durasi.split(':');
const durationInSeconds = parseInt(durationParts[0]) * 60 + parseInt(durationParts[1]);
setDuration(durationInSeconds);
// Reset audio currentTime ke 0 untuk lagu baru
audioRef.current.currentTime = 0;
setCurrentTime(0);
// Update ref
lastPlayedSongIdRef.current = currentSong.id;
if (isPlaying) {
audioRef.current.play().catch(err => {
console.error('Error playing audio:', err);
setIsPlaying(false);
});
}
}
// Jika bukan lagu baru, jangan reset currentTime (biar seek tidak kembali ke 0)
}
}, [currentSong?.id]); // Intentional: hanya depend on song ID, bukan isPlaying
// Sync duration dari audio element jika berbeda signifikan (> 1 detik)
useEffect(() => {
const audio = audioRef.current;
if (!audio || !currentSong) return;
const handleLoadedMetadata = () => {
const audioDuration = Math.floor(audio.duration);
const durationParts = currentSong.durasi.split(':');
const dbDuration = parseInt(durationParts[0]) * 60 + parseInt(durationParts[1]);
// Jika perbedaan > 2 detik, gunakan audio duration (lebih akurat)
if (Math.abs(audioDuration - dbDuration) > 2) {
setDuration(audioDuration);
}
};
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
return () => audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
}, [currentSong?.id]); // Intentional: hanya depend on song ID
// Format time helper
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const playSong = (index: number) => {
if (index < 0 || index >= filteredMusik.length) return;
setCurrentSongIndex(index);
setIsPlaying(true);
const handleVolumeChange = (value: number) => {
setVolume(value);
};
const handleSongEnd = () => {
const playNext = () => {
let nextIndex: number;
if (isShuffle) {
nextIndex = Math.floor(Math.random() * filteredMusik.length);
} else {
nextIndex = (currentSongIndex + 1) % filteredMusik.length;
}
if (filteredMusik.length > 1) {
playSong(nextIndex);
} else {
setIsPlaying(false);
setCurrentTime(0);
}
};
handleRepeatOrNext(audioRef, isRepeat, playNext);
};
const toggleMute = () => {
toggleMuteUtil(audioRef, isMuted, setIsMuted);
};
const handleVolumeChange = (val: number) => {
setAudioVolume(audioRef, val, setVolume, setIsMuted);
};
const skipBack = () => {
const prevIndex = getPrevIndex(currentSongIndex, filteredMusik.length, isShuffle);
if (prevIndex >= 0) {
playSong(prevIndex);
}
};
const skipForward = () => {
const nextIndex = getNextIndex(currentSongIndex, filteredMusik.length, isShuffle);
if (nextIndex >= 0) {
playSong(nextIndex);
}
};
const toggleShuffleHandler = () => {
toggleShuffle(isShuffle, setIsShuffle);
const toggleMuteHandler = () => {
toggleMute();
};
const togglePlayPauseHandler = () => {
if (!currentSong) return;
togglePlayPause(audioRef, isPlaying, setIsPlaying);
togglePlayPause();
};
if (loading) {
const skipBack = () => {
playPrev();
};
const skipForward = () => {
playNext();
};
const toggleShuffleHandler = () => {
toggleShuffle();
};
const toggleRepeatHandler = () => {
toggleRepeat();
};
if (isLoading) {
return (
<Box px={{ base: 'md', md: 100 }} py="xl">
<Paper mx="auto" p="xl" radius="lg" shadow="sm" bg="white">
@@ -233,16 +93,6 @@ const MusicPlayer = () => {
return (
<Box px={{ base: 'md', md: 100 }} py="xl">
{/* Hidden audio element - gunakan key yang stabil untuk mencegah remount */}
{currentSong?.audioFile && (
<audio
ref={audioRef}
src={currentSong?.audioFile?.link}
muted={isMuted}
onEnded={handleSongEnd}
/>
)}
<Paper
mx="auto"
p="xl"
@@ -279,7 +129,7 @@ const MusicPlayer = () => {
<Card radius="md" p="xl" shadow="md">
<Group align="center" gap="xl">
<Avatar
src={currentSong.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
src={currentSong.coverImage?.link || '/mp3-logo.png'}
size={180}
radius="md"
/>
@@ -295,38 +145,14 @@ const MusicPlayer = () => {
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text>
<Slider
value={currentTime}
max={duration}
onChange={(v) => {
isSeekingRef.current = true;
setCurrentTime(v);
}}
onChangeEnd={(v) => {
// Validasi: jangan seek melebihi durasi
const seekTime = Math.min(Math.max(0, v), duration);
if (audioRef.current) {
// Set audio currentTime
audioRef.current.currentTime = seekTime;
setCurrentTime(seekTime);
lastSeekTimeRef.current = seekTime;
// Jika audio tidak sedang playing, mainkan
if (!audioRef.current.paused && !isPlaying) {
audioRef.current.play().catch(console.error);
}
}
// Set seeking false SETELAH semua operasi selesai
setTimeout(() => {
isSeekingRef.current = false;
}, 0);
}}
max={duration || 100}
onChange={(v) => seek(v)}
color="#0B4F78"
size="sm"
style={{ flex: 1 }}
styles={{ thumb: { borderWidth: 2 } }}
/>
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration)}</Text>
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration || 0)}</Text>
</Group>
</Stack>
</Group>
@@ -345,7 +171,7 @@ const MusicPlayer = () => {
) : (
<ScrollArea.Autosize mah={400}>
<Grid gutter="md">
{filteredMusik.map((song, index) => (
{filteredMusik.map((song) => (
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}>
<Card
radius="md"
@@ -356,7 +182,7 @@ const MusicPlayer = () => {
border: currentSong?.id === song.id ? '2px solid #0B4F78' : '2px solid transparent',
transition: 'all 0.2s'
}}
onClick={() => playSong(index)}
onClick={() => playSong(song)}
>
<Group gap="md" align="center">
<Avatar
@@ -444,7 +270,7 @@ const MusicPlayer = () => {
<ActionIcon
variant={isRepeat ? 'filled' : 'subtle'}
color="#0B4F78"
onClick={() => setIsRepeat(!isRepeat)}
onClick={toggleRepeatHandler}
radius="xl"
>
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
@@ -454,42 +280,18 @@ const MusicPlayer = () => {
<Text size="xs" c="#5A6C7D" w={40} ta="right">{formatTime(currentTime)}</Text>
<Slider
value={currentTime}
max={duration}
onChange={(v) => {
isSeekingRef.current = true;
setCurrentTime(v); // preview - update UI saja
}}
onChangeEnd={(v) => {
// Validasi: jangan seek melebihi durasi
const seekTime = Math.min(Math.max(0, v), duration);
if (audioRef.current) {
// Set audio currentTime
audioRef.current.currentTime = seekTime;
setCurrentTime(seekTime);
lastSeekTimeRef.current = seekTime;
// Jika audio tidak sedang playing, mainkan
if (!audioRef.current.paused && !isPlaying) {
audioRef.current.play().catch(console.error);
}
}
// Set seeking false SETELAH semua operasi selesai
setTimeout(() => {
isSeekingRef.current = false;
}, 0);
}}
max={duration || 100}
onChange={(v) => seek(v)}
color="#0B4F78"
size="xs"
style={{ flex: 1 }}
/>
<Text size="xs" c="#5A6C7D" w={40}>{formatTime(duration)}</Text>
<Text size="xs" c="#5A6C7D" w={40}>{formatTime(duration || 0)}</Text>
</Group>
</Stack>
<Group gap="xs" style={{ flex: 1 }} justify="flex-end">
<ActionIcon variant="subtle" color="gray" onClick={toggleMute}>
<ActionIcon variant="subtle" color="gray" onClick={toggleMuteHandler}>
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
</ActionIcon>
<Slider
@@ -507,86 +309,4 @@ const MusicPlayer = () => {
);
};
export default MusicPlayer;
// 'use client'
// import {
// Box, Paper, Group, Stack, Text, Slider, ActionIcon
// } from '@mantine/core';
// import {
// IconPlayerPlayFilled,
// IconPlayerPauseFilled
// } from '@tabler/icons-react';
// import { useEffect, useState } from 'react';
// import { useAudioEngine } from '../lib/useAudioProgress';
// interface Musik {
// id: string;
// judul: string;
// artis: string;
// audioFile: { link: string };
// }
// export default function MusicPlayer() {
// const {
// audioRef,
// isPlaying,
// currentTime,
// duration,
// load,
// toggle,
// seek,
// } = useAudioEngine();
// const [songs, setSongs] = useState<Musik[]>([]);
// const [index, setIndex] = useState(0);
// useEffect(() => {
// fetch('/api/desa/musik/find-many?page=1&limit=50')
// .then(r => r.json())
// .then(r => setSongs(r.data ?? []));
// }, []);
// useEffect(() => {
// if (!songs[index]) return;
// load(songs[index].audioFile.link);
// }, [songs, index, load]);
// const format = (n: number) => {
// const m = Math.floor(n / 60);
// const s = Math.floor(n % 60);
// return `${m}:${s.toString().padStart(2, '0')}`;
// };
// return (
// <Box p="xl">
// <audio ref={audioRef} />
// <Paper p="lg">
// <Stack>
// <Text fw={700}>{songs[index]?.judul}</Text>
// <Text size="sm">{songs[index]?.artis}</Text>
// <Group>
// <Text size="xs">{format(currentTime)}</Text>
// <Slider
// value={currentTime}
// max={duration}
// onChange={seek}
// style={{ flex: 1 }}
// />
// <Text size="xs">{format(duration)}</Text>
// </Group>
// <ActionIcon size={56} radius="xl" onClick={toggle}>
// {isPlaying
// ? <IconPlayerPauseFilled />
// : <IconPlayerPlayFilled />}
// </ActionIcon>
// </Stack>
// </Paper>
// </Box>
// );
// }
export default MusicPlayer;

View File

@@ -78,7 +78,8 @@ function APBDesProgress({ apbdesData }: APBDesProgressProps) {
// Hitung total per kategori
const calcTotal = (items: { anggaran: number; realisasi: number }[]) => {
const anggaran = items.reduce((sum, item) => sum + item.anggaran, 0);
const realisasi = items.reduce((sum, item) => sum + item.realisasi, 0);
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
const realisasi = items.reduce((sum, item) => sum + (item.realisasi || 0), 0);
const persen = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
return { anggaran, realisasi, persen };
};

View File

@@ -68,6 +68,7 @@ function APBDesTable({ apbdesData }: APBDesTableProps) {
// Calculate totals
const totalAnggaran = items.reduce((sum, item) => sum + (item.anggaran || 0), 0);
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
const totalRealisasi = items.reduce((sum, item) => sum + (item.realisasi || 0), 0);
const totalSelisih = totalAnggaran - totalRealisasi;
const totalPersentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;

View File

@@ -51,7 +51,8 @@ export function transformAPBDesData(data: any): APBDesData {
kode: item.kode || '',
uraian: item.uraian || '',
anggaran: typeof item.anggaran === 'number' ? item.anggaran : 0,
realisasi: typeof item.realisasi === 'number' ? item.realisasi : 0,
// Map totalRealisasi from backend to realisasi field
realisasi: typeof item.totalRealisasi === 'number' ? item.totalRealisasi : (typeof item.realisasi === 'number' ? item.realisasi : 0),
selisih: typeof item.selisih === 'number' ? item.selisih : 0,
persentase: typeof item.persentase === 'number' ? item.persentase : 0,
level: typeof item.level === 'number' ? item.level : 1,

View File

@@ -0,0 +1,317 @@
import { useMusic } from '@/app/context/MusicContext';
import {
ActionIcon,
Avatar,
Box,
Button,
Flex,
Group,
Paper,
Slider,
Text,
Transition
} from '@mantine/core';
import {
IconArrowsShuffle,
IconMusic,
IconPlayerPauseFilled,
IconPlayerPlayFilled,
IconPlayerSkipBackFilled,
IconPlayerSkipForwardFilled,
IconRepeat,
IconRepeatOff,
IconVolume,
IconVolumeOff,
IconX,
} from '@tabler/icons-react';
import { useState } from 'react';
export default function FixedPlayerBar() {
const {
isPlaying,
currentSong,
currentTime,
duration,
volume,
isMuted,
isRepeat,
isShuffle,
togglePlayPause,
playNext,
playPrev,
seek,
setVolume,
toggleMute,
toggleRepeat,
toggleShuffle,
} = useMusic();
const [showVolume, setShowVolume] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
// Format time
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// Handle seek
const handleSeek = (value: number) => {
seek(value);
};
// Handle volume change
const handleVolumeChange = (value: number) => {
setVolume(value);
};
// Handle shuffle toggle
const handleToggleShuffle = () => {
toggleShuffle();
};
// Handle minimize player (show floating icon)
const handleMinimizePlayer = () => {
setIsMinimized(true);
};
// Handle restore player from floating icon
const handleRestorePlayer = () => {
setIsMinimized(false);
};
// If minimized, show floating icon instead of player bar
if (isMinimized) {
return (
<>
{/* Floating Music Icon - Shows when player is minimized */}
<Button
color="#0B4F78"
variant="filled"
size="md"
mt="md"
style={{
position: 'fixed',
top: '50%', // Menempatkan titik atas ikon di tengah layar
left: '0px',
transform: 'translateY(-50%)', // Menggeser ikon ke atas sebesar setengah tingginya sendiri agar benar-benar di tengah
borderBottomRightRadius: '20px',
borderTopRightRadius: '20px',
cursor: 'pointer',
transition: 'transform 0.2s ease',
zIndex: 1
}}
onClick={handleRestorePlayer}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-50%) scale(1.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(-50%)';
}}
>
<IconMusic size={28} color="white" />
</Button>
{/* Spacer to prevent content from being hidden behind player */}
<Box h={20} />
</>
);
}
if (!currentSong) {
return null;
}
return (
<>
{/* Mini Player Bar - Always visible when song is playing */}
<Paper
pos="fixed"
bottom={0}
left={0}
right={0}
p="sm"
shadow="lg"
style={{
zIndex: 1,
borderTop: '1px solid rgba(0,0,0,0.1)',
}}
>
<Flex align="center" gap="md" justify="space-between">
{/* Song Info - Left */}
<Group gap="sm" flex={1} style={{ minWidth: 0 }}>
<Avatar
src={currentSong.coverImage?.link || ''}
alt={currentSong.judul}
size={40}
radius="sm"
imageProps={{ loading: 'lazy' }}
/>
<Box style={{ minWidth: 0 }}>
<Text fz="sm" fw={600} truncate>
{currentSong.judul}
</Text>
<Text fz="xs" c="dimmed" truncate>
{currentSong.artis}
</Text>
</Box>
</Group>
{/* Controls + Progress - Center */}
<Group gap="xs" flex={2} justify="center">
{/* Control Buttons */}
<Group gap="xs">
<ActionIcon
variant={isShuffle ? 'filled' : 'subtle'}
color={isShuffle ? 'blue' : 'gray'}
size="lg"
onClick={handleToggleShuffle}
title="Shuffle"
>
<IconArrowsShuffle size={18} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
onClick={playPrev}
title="Previous"
>
<IconPlayerSkipBackFilled size={20} />
</ActionIcon>
<ActionIcon
variant="filled"
color={isPlaying ? 'blue' : 'gray'}
size="xl"
radius="xl"
onClick={togglePlayPause}
title={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? (
<IconPlayerPauseFilled size={24} />
) : (
<IconPlayerPlayFilled size={24} />
)}
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
onClick={playNext}
title="Next"
>
<IconPlayerSkipForwardFilled size={20} />
</ActionIcon>
<ActionIcon
variant="subtle"
color={isRepeat ? 'blue' : 'gray'}
size="lg"
onClick={toggleRepeat}
title={isRepeat ? 'Repeat On' : 'Repeat Off'}
>
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
</ActionIcon>
</Group>
{/* Progress Bar - Desktop */}
<Box w={200} display={{ base: 'none', md: 'block' }}>
<Slider
value={currentTime}
max={duration || 100}
onChange={handleSeek}
size="sm"
color="blue"
label={(value) => formatTime(value)}
/>
</Box>
</Group>
{/* Right Controls - Volume + Close */}
<Group gap="xs" flex={1} justify="flex-end">
<Box
onMouseEnter={() => setShowVolume(true)}
onMouseLeave={() => setShowVolume(false)}
pos="relative"
>
<ActionIcon
variant="subtle"
color={isMuted ? 'red' : 'gray'}
size="lg"
onClick={toggleMute}
title={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted ? (
<IconVolumeOff size={18} />
) : (
<IconVolume size={18} />
)}
</ActionIcon>
<Transition
mounted={showVolume}
transition="scale-y"
duration={200}
timingFunction="ease"
>
{(style) => (
<Paper
style={{
...style,
position: 'absolute',
bottom: '100%',
right: 0,
mb: 'xs',
p: 'sm',
zIndex: 1001,
}}
shadow="md"
withBorder
>
<Slider
value={isMuted ? 0 : volume}
max={100}
onChange={handleVolumeChange}
h={100}
color="blue"
size="sm"
/>
</Paper>
)}
</Transition>
</Box>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
onClick={handleMinimizePlayer}
title="Minimize player"
>
<IconX size={18} />
</ActionIcon>
</Group>
</Flex>
{/* Progress Bar - Mobile */}
<Box mt="xs" display={{ base: 'block', md: 'none' }}>
<Slider
value={currentTime}
max={duration || 100}
onChange={handleSeek}
size="sm"
color="blue"
label={(value) => formatTime(value)}
/>
</Box>
</Paper>
{/* Spacer to prevent content from being hidden behind player */}
<Box h={80} />
</>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { Button } from '@mantine/core';
import { IconMusic, IconMusicOff } from '@tabler/icons-react';
import { IconDisabled, IconDisabledOff } from '@tabler/icons-react';
import { useEffect, useRef, useState } from 'react';
const NewsReaderLanding = () => {
@@ -95,15 +95,17 @@ const NewsReaderLanding = () => {
mt="md"
style={{
position: 'fixed',
bottom: '350px',
top: '50%', // Menempatkan titik atas ikon di tengah layar
left: '0px',
transform: 'translateY(80%)', // Menggeser ikon ke atas sebesar setengah tingginya sendiri agar benar-benar di tengah
borderBottomRightRadius: '20px',
borderTopRightRadius: '20px',
transition: 'all 0.3s ease',
cursor: 'pointer',
transition: 'transform 0.2s',
zIndex: 1
}}
>
{isPointerMode ? <IconMusicOff /> : <IconMusic />}
{isPointerMode ? <IconDisabledOff /> : <IconDisabled />}
</Button>
);
};

View File

@@ -4,29 +4,25 @@
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
import colors from '@/con/colors'
import {
ActionIcon,
Box,
Button,
Center,
Divider,
Group,
Loader,
Select,
SimpleGrid,
Stack,
Text,
Title,
Title
} from '@mantine/core'
import { IconDownload } from '@tabler/icons-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useProxy } from 'valtio/utils'
import GrafikRealisasi from './lib/grafikRealisasi'
import PaguTable from './lib/paguTable'
import RealisasiTable from './lib/realisasiTable'
import GrafikRealisasi from './lib/grafikRealisasi'
function Apbdes() {
const state = useProxy(apbdes)
const [loading, setLoading] = useState(false)
const [selectedYear, setSelectedYear] = useState<string | null>(null)
const textHeading = {
@@ -37,12 +33,9 @@ function Apbdes() {
useEffect(() => {
const loadData = async () => {
try {
setLoading(true)
await state.findMany.load()
} catch (error) {
console.error('Error loading data:', error)
} finally {
setLoading(false)
}
}
loadData()
@@ -73,10 +66,12 @@ function Apbdes() {
? dataAPBDes.find((item: any) => item?.tahun?.toString() === selectedYear) || dataAPBDes[0]
: null
const data = (state.findMany.data || []).slice(0, 3)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const previewData = (state.findMany.data || []).slice(0, 3)
return (
<Stack p="sm" gap="xl" bg={colors.Bg}>
<Divider c="gray.3" size="sm" />
{/* 📌 HEADING */}
<Box mt="xl">
<Stack gap="sm">
@@ -116,7 +111,7 @@ function Apbdes() {
</Group>
{/* COMBOBOX */}
<Box px={{ base: 'md', md: 100 }}>
<Box px={{ base: 'md', md: "sm" }}>
<Select
label={<Text fw={600} fz="sm">Pilih Tahun APBDes</Text>}
placeholder="Pilih tahun"
@@ -132,7 +127,7 @@ function Apbdes() {
{/* Tabel & Grafik - Hanya tampilkan jika ada data */}
{currentApbdes && currentApbdes.items?.length > 0 ? (
<Box px={{ base: 'md', md: 100 }}>
<Box px={{ base: 'md', md: 'sm' }} mb="xl">
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<PaguTable apbdesData={currentApbdes} />
<RealisasiTable apbdesData={currentApbdes} />
@@ -140,19 +135,19 @@ function Apbdes() {
</SimpleGrid>
</Box>
) : currentApbdes ? (
<Box px={{ base: 'md', md: 100 }} py="md">
<Box px={{ base: 'md', md: 100 }} py="md" mb="xl">
<Text fz="sm" c="dimmed" ta="center" lh={1.5}>
Tidak ada data item untuk tahun yang dipilih.
</Text>
</Box>
) : null}
{/* GRID - Card Preview */}
{loading ? (
{/* GRID - Card Preview
{state.findMany.loading ? (
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
<Loader size="lg" color="blue" />
</Center>
) : data.length === 0 ? (
) : previewData.length === 0 ? (
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
<Stack align="center" gap="xs">
<Text fz="lg" c="dimmed" lh={1.4}>
@@ -170,7 +165,7 @@ function Apbdes() {
spacing="lg"
pb="xl"
>
{data.map((v: any, k: number) => (
{previewData.map((v, k) => (
<Box
key={k}
pos="relative"
@@ -224,7 +219,7 @@ function Apbdes() {
</Box>
))}
</SimpleGrid>
)}
)} */}
</Stack>
)
}

View File

@@ -1,11 +1,27 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Title, Progress, Stack, Text, Group, Box } from '@mantine/core';
function Summary({ title, data }: any) {
interface APBDesItem {
tipe: string | null;
anggaran: number;
realisasi?: number;
totalRealisasi?: number;
}
interface SummaryProps {
title: string;
data: APBDesItem[];
}
function Summary({ title, data }: SummaryProps) {
if (!data || data.length === 0) return null;
const totalAnggaran = data.reduce((s: number, i: any) => s + i.anggaran, 0);
const totalRealisasi = data.reduce((s: number, i: any) => s + i.totalRealisasi, 0);
const totalAnggaran = data.reduce((s: number, i: APBDesItem) => s + i.anggaran, 0);
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
const totalRealisasi = data.reduce(
(s: number, i: APBDesItem) => s + (i.realisasi || i.totalRealisasi || 0),
0
);
const persen =
totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
@@ -77,27 +93,21 @@ function Summary({ title, data }: any) {
);
}
export default function GrafikRealisasi({ apbdesData }: any) {
const items = apbdesData.items || [];
const tahun = apbdesData.tahun || new Date().getFullYear();
const pendapatan = items.filter((i: any) => i.tipe === 'pendapatan');
const belanja = items.filter((i: any) => i.tipe === 'belanja');
const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan');
// Hitung total keseluruhan
const totalAnggaranSemua = items.reduce((s: number, i: any) => s + i.anggaran, 0);
const totalRealisasiSemua = items.reduce((s: number, i: any) => s + i.totalRealisasi, 0);
const persenSemua = totalAnggaranSemua > 0 ? (totalRealisasiSemua / totalAnggaranSemua) * 100 : 0;
const formatRupiah = (angka: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(angka);
export default function GrafikRealisasi({
apbdesData,
}: {
apbdesData: {
tahun?: number | null;
items?: APBDesItem[] | null;
[key: string]: any;
};
}) {
const items = apbdesData?.items || [];
const tahun = apbdesData?.tahun || new Date().getFullYear();
const pendapatan = items.filter((i: APBDesItem) => i.tipe === 'pendapatan');
const belanja = items.filter((i: APBDesItem) => i.tipe === 'belanja');
const pembiayaan = items.filter((i: APBDesItem) => i.tipe === 'pembiayaan');
return (
<Paper withBorder p="md" radius="md">
@@ -105,28 +115,7 @@ export default function GrafikRealisasi({ apbdesData }: any) {
GRAFIK REALISASI APBDes {tahun}
</Title>
{/* Summary Total Keseluruhan */}
<Box mb="lg" p="md" bg="gray.0" radius="md">
<>
<Group justify="space-between" mb="xs">
<Text fw={700} fz="lg">TOTAL KESELURUHAN</Text>
<Text fw={700} fz="xl" c={persenSemua >= 100 ? 'teal' : persenSemua >= 80 ? 'blue' : 'red'}>
{persenSemua.toFixed(2)}%
</Text>
</Group>
<Text fz="sm" c="dimmed" mb="xs">
{formatRupiah(totalRealisasiSemua)} / {formatRupiah(totalAnggaranSemua)}
</Text>
<Progress
value={persenSemua}
size="lg"
radius="xl"
color={persenSemua >= 100 ? 'teal' : persenSemua >= 80 ? 'blue' : 'red'}
/>
</>
</Box>
<Stack gap="lg">
<Stack gap="lg" mb="lg">
<Summary title="💰 Pendapatan" data={pendapatan} />
<Summary title="💸 Belanja" data={belanja} />
<Summary title="📊 Pembiayaan" data={pembiayaan} />

View File

@@ -1,146 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Table, Title, Badge, Group, Text, Box, Collapse } from '@mantine/core';
import { useState } from 'react';
import { IconChevronDown, IconChevronRight } from '@tabler/icons-react';
function RealisasiDetail({ realisasiItems }: { realisasiItems: any[] }) {
if (!realisasiItems || realisasiItems.length === 0) {
return (
<Text fz="xs" c="dimmed" py="xs">
Belum ada realisasi
</Text>
);
}
const formatRupiah = (amount: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
};
return (
<Box mt="xs" p="xs" bg="gray.0" radius="md">
<Text fz="xs" fw={600} mb="xs">
Rincian Realisasi ({realisasiItems.length}):
</Text>
<>
{realisasiItems.map((realisasi: any, idx: number) => (
<Box key={realisasi.id} mb={idx < realisasiItems.length - 1 ? 'xs' : 0}>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text fz="xs" style={{ flex: 1 }}>
{realisasi.keterangan || `Realisasi ${idx + 1}`}
</Text>
<Text fz="xs" fw={600} c="blue">
{formatRupiah(realisasi.jumlah)}
</Text>
<Text fz="xs" c="dimmed">
{formatDate(realisasi.tanggal)}
</Text>
</Group>
</Box>
))}
</>
</Box>
);
}
function ItemRow({ item, expanded, onToggle }: any) {
const formatRupiah = (amount: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
const hasRealisasi = item.realisasiItems && item.realisasiItems.length > 0;
return (
<>
<Table.Tr
onClick={onToggle}
style={{ cursor: 'pointer', '&:hover': { backgroundColor: 'var(--mantine-color-gray-0)' } }}
>
<Table.Td>
<Group gap="xs">
{hasRealisasi ? (
expanded ? (
<IconChevronDown size={16} />
) : (
<IconChevronRight size={16} />
)
) : (
<Box w={16} />
)}
<Text fw={500}>{item.kode} - {item.uraian}</Text>
</Group>
</Table.Td>
<Table.Td ta="right">
<Text fw={600} c="blue">
{formatRupiah(item.totalRealisasi)}
</Text>
</Table.Td>
<Table.Td ta="center">
<Badge
color={
item.persentase >= 100
? 'teal'
: item.persentase >= 60
? 'yellow'
: 'red'
}
>
{item.persentase.toFixed(2)}%
</Badge>
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td colSpan={3} p={0}>
<Collapse in={expanded}>
{hasRealisasi && <RealisasiDetail realisasiItems={item.realisasiItems} />}
</Collapse>
</Table.Td>
</Table.Tr>
</>
);
}
function Section({ title, data }: any) {
if (!data || data.length === 0) return null;
const [expandedId, setExpandedId] = useState<string | null>(null);
return (
<>
<Table.Tr>
<Table.Td colSpan={3}>
<Text fw={700} fz="sm">{title}</Text>
</Table.Td>
</Table.Tr>
{data.map((item: any) => (
<ItemRow
key={item.id}
item={item}
expanded={expandedId === item.id}
onToggle={() => setExpandedId(expandedId === item.id ? null : item.id)}
/>
))}
</>
);
}
import { Paper, Table, Title, Badge, Text } from '@mantine/core';
export default function RealisasiTable({ apbdesData }: any) {
const items = apbdesData.items || [];
@@ -150,32 +9,78 @@ export default function RealisasiTable({ apbdesData }: any) {
? `REALISASI APBDes Tahun ${apbdesData.tahun}`
: 'REALISASI APBDes';
const pendapatan = items.filter((i: any) => i.tipe === 'pendapatan');
const belanja = items.filter((i: any) => i.tipe === 'belanja');
const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan');
// Flatten: kumpulkan semua realisasi items
const allRealisasiRows: Array<{ realisasi: any; parentItem: any }> = [];
items.forEach((item: any) => {
if (item.realisasiItems && item.realisasiItems.length > 0) {
item.realisasiItems.forEach((realisasi: any) => {
allRealisasiRows.push({ realisasi, parentItem: item });
});
}
});
const formatRupiah = (amount: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
return (
<Paper withBorder p="md" radius="md">
<Title order={5} mb="md">{title}</Title>
<Text fz="xs" c="dimmed" mb="sm">
💡 Klik pada item untuk melihat detail realisasi
</Text>
{allRealisasiRows.length === 0 ? (
<Text fz="sm" c="dimmed" ta="center" py="md">
Belum ada data realisasi
</Text>
) : (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Uraian</Table.Th>
<Table.Th ta="right">Realisasi (Rp)</Table.Th>
<Table.Th ta="center">%</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{allRealisasiRows.map(({ realisasi, parentItem }) => {
const persentase = parentItem.anggaran > 0
? (realisasi.jumlah / parentItem.anggaran) * 100
: 0;
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Uraian</Table.Th>
<Table.Th ta="right">Total Realisasi (Rp)</Table.Th>
<Table.Th ta="center">%</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Section title="1) PENDAPATAN" data={pendapatan} />
<Section title="2) BELANJA" data={belanja} />
<Section title="3) PEMBIAYAAN" data={pembiayaan} />
</Table.Tbody>
</Table>
return (
<Table.Tr key={realisasi.id}>
<Table.Td>
<Text>{realisasi.kode || '-'} - {realisasi.keterangan || '-'}</Text>
</Table.Td>
<Table.Td ta="right">
<Text fw={600} c="blue">
{formatRupiah(realisasi.jumlah || 0)}
</Text>
</Table.Td>
<Table.Td ta="center">
<Badge
color={
persentase >= 100
? 'teal'
: persentase >= 60
? 'yellow'
: 'red'
}
>
{persentase.toFixed(2)}%
</Badge>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
)}
</Paper>
);
}

View File

@@ -5,6 +5,7 @@ import { Box, Space, Stack } from "@mantine/core";
import { Navbar } from "@/app/darmasaba/_com/Navbar";
import Footer from "./_com/Footer";
import FixedPlayerBar from "./_com/FixedPlayerBar";
export default function Layout({ children }: { children: React.ReactNode }) {
@@ -21,6 +22,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
{children}
</Box>
<Footer />
<FixedPlayerBar />
</Stack>
)
}

View File

@@ -1,12 +1,14 @@
import "@mantine/core/styles.css";
import "./globals.css";
import "./globals.css"; // Sisanya import di globals.css
import LoadDataFirstClient from "@/app/darmasaba/_com/LoadDataFirstClient";
import { MusicProvider } from "@/app/context/MusicContext";
import {
ColorSchemeScript,
MantineProvider,
createTheme,
mantineHtmlProps,
// mantineHtmlProps,
} from "@mantine/core";
import { Metadata, Viewport } from "next";
import { ViewTransitions } from "next-view-transitions";
@@ -97,24 +99,26 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<ViewTransitions>
<html lang="id" {...mantineHtmlProps}>
<head>
<meta charSet="utf-8" />
<ColorSchemeScript defaultColorScheme="light" />
</head>
<body>
<MantineProvider theme={theme} defaultColorScheme="light">
{children}
<LoadDataFirstClient />
<ToastContainer
position="bottom-center"
hideProgressBar
style={{ zIndex: 9999 }}
/>
</MantineProvider>
</body>
</html>
</ViewTransitions>
<html lang="id" {...mantineHtmlProps}>
<head>
<meta charSet="utf-8" />
<ColorSchemeScript defaultColorScheme="light" />
</head>
<body>
<ViewTransitions>
<MusicProvider>
<MantineProvider theme={theme} defaultColorScheme="light">
{children}
<LoadDataFirstClient />
<ToastContainer
position="bottom-center"
hideProgressBar
style={{ zIndex: 9999 }}
/>
</MantineProvider>
</MusicProvider>
</ViewTransitions>
</body>
</html>
);
}

View File

@@ -16,6 +16,7 @@ function Page() {
dengan ketentuan ini, harap jangan gunakan Website.
</Text>
</Paper>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">

View File

@@ -1,8 +1,9 @@
import { AppServer } from '@/app/api/[[...slugs]]/route'
import { treaty } from '@elysiajs/eden'
// const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'localhost:3000'
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
// Use relative URL '/' for better deployment flexibility
// This allows the API to work correctly in both development and staging/production
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || '/'
const ApiFetch = treaty<AppServer>(BASE_URL)