Compare commits

..

84 Commits

Author SHA1 Message Date
40f9284969 Merge pull request 'Fix Responsive' (#80) from fix-responsive into staggingweb
Reviewed-on: #80
2026-03-06 16:39:47 +08:00
7bc546e985 Fix Responsive 2026-03-06 16:19:01 +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
a791efe76c fix(grafik): sync grafikRealisasi with totalRealisasi field
Fix:
- Change i.realisasi to i.totalRealisasi in Summary component
- Change i.realisasi to i.totalRealisasi in total calculation
- Add fragment wrapper <> to fix Box children type error
- Reorder Total Keseluruhan section to top (before category breakdown)

Now grafik shows correct data from multiple realisasi items.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 16:47:26 +08:00
e9f7bc2043 feat(ui landing): add expandable realisasi detail in RealisasiTable
Features:
- Add expandable rows for each APBDes item
- Show detailed realisasi breakdown per item
- Each realisasi shows:
  * Keterangan/Uraian
  * Jumlah (formatted in Rupiah)
  * Tanggal (formatted date)
- Chevron icon indicator (right/down)
- Click row to expand/collapse
- Hover effect on clickable rows
- Info text: "Klik pada item untuk melihat detail realisasi"

UI Components:
- RealisasiDetail: Component to display list of realisasi
- ItemRow: Expandable row with click handler
- Updated Section: Manage expanded state per item

Styling:
- Gray background for detail section
- Blue color for amount
- Dimmed color for date
- Responsive layout with wrap="nowrap"
- Proper spacing between items

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 16:38:15 +08:00
6712da9ac2 fix(ui): replace Title with Text in Modal title to avoid h5 in h2 nesting
Fix:
- Change Modal title from <Title order={5}> to <Text fz='lg' fw={600}>
- Avoids invalid HTML nesting (<h5> cannot be child of <h2>)
- Maintains same visual appearance

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 16:21:27 +08:00
ac11a9367c fix(api): correct selisih calculation formula
Bug Fix:
- Change selisih formula from: totalRealisasi - anggaran
- To: anggaran - totalRealisasi

Reason:
- Selisih positif = Sisa anggaran (belum digunakan)
- Selisih negatif = Over budget (melebihi anggaran)

Example:
- Anggaran: Rp 30.000.000
- Realisasi: Rp 5.000.000
- Selisih (OLD): 5jt - 30jt = -25jt  Wrong
- Selisih (NEW): 30jt - 5jt = 25jt  Correct (sisa anggaran)

Files Updated:
- create.ts: Fix initial item creation
- updt.ts: Fix item update
- realisasi/create.ts: Fix after adding realisasi
- realisasi/update.ts: Fix after updating realisasi
- realisasi/delete.ts: Fix after deleting realisasi

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 16:16:38 +08:00
67e5ceb254 feat(ui): add Realisasi Manager component for multiple realisasi CRUD
New Component - RealisasiManager:
- Add modal form for create/edit realisasi
- Input fields: Jumlah (Rp), Tanggal, Keterangan/Uraian
- Display list of existing realisasi with edit/delete actions
- Summary cards showing: Anggaran, Total Realisasi, Sisa Anggaran, Persentase
- Color-coded percentage badges (teal ≥100%, blue ≥80%, yellow ≥60%, red <60%)
- Auto-reload data after create/update/delete operations

Features:
- Multiple realisasi per APBDes item
- Each realisasi has its own description (uraian)
- Date picker for realisasi tanggal
- Format currency in IDR (Rupiah)
- Responsive table layout
- Empty state when no realisasi exists

Integration:
- Integrated with existing state.realisasi CRUD functions
- Auto-calculate totalRealisasi and persentase (handled by backend)
- Display realisasi items from API response
- Works with existing APBDes detail page

UI/UX:
- Clean modal design with form validation
- Summary cards with color-coded backgrounds
- Icon indicators for date and currency
- Confirmation dialog before delete
- Loading states during async operations

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 15:41:43 +08:00
65942ac9d2 refactor(create): remove realisasiAwal, simplify to anggaran-only input
Refactoring:
- Remove realisasiAwal field from ItemForm type
- Remove NumberInput for realisasi awal from UI
- Remove realisasiAwal column from preview table
- Simplify state management (no realisasiAwal mapping)
- API create: Create items with totalRealisasi=0 (no auto-create realisasi)

Rationale:
- Cleaner separation: Anggaran dan Realisasi adalah entitas terpisah
- User create item untuk ANGGARAN dulu
- Setelah item dibuat, user bisa add MULTIPLE REALISASI dengan:
  * Uraian yang jelas untuk setiap realisasi
  * Tanggal yang spesifik
  * Keterangan detail
  * Bukti file attachment
- Follows the original schema design more closely

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 15:30:33 +08:00
e0436cc384 feat(create): add realisasi awal input di create page
Features:
- Add realisasiAwal field to ItemForm type
- Add NumberInput for realisasi awal (optional)
- Update table preview to show realisasi awal
- Update state to send realisasiAwal to API
- Update API create to handle realisasiAwal:
  * Create APBDesItem with totalRealisasi = realisasiAwal
  * Auto-create first RealisasiItem if realisasiAwal > 0
  * Auto-calculate selisih and persentase

UX Improvements:
- User can input initial realization during create
- Optional field with clear label and description
- Auto-calculation of percentages on backend
- Single transaction for item + first realisasi

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 15:13:58 +08:00
63682e47b6 feat(state): update APBDes state management for multiple realisasi
State Changes:
- Update ApbdesItemSchema: Remove realisasi, selisih, persentase fields
- Add RealisasiItemSchema for realisasi CRUD operations
- Update normalizeItem: Remove manual calculations (backend handles it)
- Update edit.load: Map items without realisasi fields
- Add realisasi state: create, update, delete functions

UI Changes:
- Update create/page.tsx: Remove realisasi input field and column
- Update edit/page.tsx: Remove realisasi input field and column
- Update ItemForm type: Remove realisasi property
- Simplify forms to only input anggaran, realisasi added separately

Features:
- Support for multiple realisasi per item
- Realisasi CRUD via dedicated state functions
- Auto-reload findUnique after realisasi operations
- Backend auto-calculates totalRealisasi, selisih, persentase

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 14:55:06 +08:00
f4705690a9 feat(api): implement multiple realisasi for APBDes
Schema Changes:
- Add RealisasiItem model for multiple realizations per APBDesItem
- Replace realisasi field with totalRealisasi (auto-calculated sum)
- Add selisih and persentase as auto-calculated fields
- Cascade delete realisasi when item is deleted

API Changes:
- Update index.ts: Add realisasi CRUD endpoints
  - POST /:itemId/realisasi - Create realisasi
  - PUT /realisasi/:realisasiId - Update realisasi
  - DELETE /realisasi/:realisasiId - Delete realisasi
- Update create.ts: Auto-calculate totalRealisasi=0, selisih, persentase
- Update updt.ts: Reset calculations when items updated
- Update findUnique.ts: Include realisasiItems in response
- Update findMany.ts: Include realisasiItems in response
- Remove realisasi field from ApbdesItemSchema

New Files:
- realisasi/create.ts - Create realisasi with auto-calculation
- realisasi/update.ts - Update realisasi with recalculation
- realisasi/delete.ts - Soft delete with recalculation

Features:
- Auto-calculate totalRealisasi from sum of all realisasiItems
- Auto-calculate selisih = totalRealisasi - anggaran
- Auto-calculate persentase = (totalRealisasi / anggaran) * 100
- Support for bukti file attachment
- Support for keterangan (notes) per realisasi
- Soft delete support for audit trail

UI Updates:
- Update admin detail page to use totalRealisasi instead of realisasi
- Update landing page realisasiTable to use totalRealisasi

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 14:45:21 +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
32 changed files with 2222 additions and 813 deletions

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

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

@@ -209,16 +209,22 @@ model APBDesItem {
kode String // contoh: "4", "4.1", "4.1.2"
uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha"
anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS)
realisasi Float
selisih Float // realisasi - anggaran
persentase Float
tipe String? // (realisasi / anggaran) * 100
tipe String? // "pendapatan" | "belanja" | "pembiayaan" | null
level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail
parentId String? // untuk relasi hierarki
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
children APBDesItem[] @relation("APBDesItemParent")
apbdesId String
apbdes APBDes @relation(fields: [apbdesId], references: [id])
// Field kalkulasi (auto-calculated dari realisasi items)
totalRealisasi Float @default(0) // Sum dari semua realisasi
selisih Float @default(0) // totalRealisasi - anggaran
persentase Float @default(0) // (totalRealisasi / anggaran) * 100
// Relasi ke realisasi items
realisasiItems RealisasiItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@ -229,6 +235,28 @@ model APBDesItem {
@@index([apbdesId])
}
// 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])
}
//========================================= PRESTASI DESA ========================================= //
model PrestasiDesa {
id String @id @default(cuid())

View File

@@ -5,16 +5,17 @@ import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
// --- Zod Schema ---
// --- 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),
realisasi: z.number().min(0),
selisih: z.number(),
persentase: z.number(),
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(),
// 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({
@@ -22,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"),
});
@@ -38,26 +40,17 @@ const defaultApbdesForm = {
items: [] as z.infer<typeof ApbdesItemSchema>[],
};
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
// --- Helper: Normalize item (dengan field kalkulasi) ---
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
const anggaran = item.anggaran ?? 0;
const realisasi = item.realisasi ?? 0;
// ✅ Formula yang benar
const selisih = realisasi - anggaran; // positif = sisa anggaran, negatif = over budget
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran
return {
kode: item.kode || "",
uraian: item.uraian || "",
anggaran,
realisasi,
selisih,
persentase,
anggaran: item.anggaran ?? 0,
level: item.level || 1,
tipe: item.tipe, // biarkan null jika memang null
tipe: item.tipe ?? null,
realisasi: item.realisasi ?? 0,
selisih: item.selisih ?? 0,
persentase: item.persentase ?? 0,
};
}
@@ -119,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,
@@ -164,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 {
@@ -259,9 +256,9 @@ const apbdes = proxy({
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',
})),
@@ -289,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) {
@@ -326,6 +336,82 @@ const apbdes = proxy({
this.form = { ...defaultApbdesForm };
},
},
// =========================================
// REALISASI STATE MANAGEMENT
// =========================================
realisasi: {
// Create realisasi
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
const currentId = apbdes.findUnique.data?.id;
if (currentId) {
await apbdes.findUnique.load(currentId);
}
return true;
} else {
toast.error(res.data?.message || "Gagal menambahkan realisasi");
return false;
}
} catch (error: any) {
console.error("Create realisasi error:", error);
toast.error(error?.message || "Terjadi kesalahan saat menambahkan realisasi");
return false;
}
},
// Update realisasi
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
const currentId = apbdes.findUnique.data?.id;
if (currentId) {
await apbdes.findUnique.load(currentId);
}
return true;
} else {
toast.error(res.data?.message || "Gagal memperbarui realisasi");
return false;
}
} catch (error: any) {
console.error("Update realisasi error:", error);
toast.error(error?.message || "Terjadi kesalahan saat memperbarui realisasi");
return false;
}
},
// Delete realisasi
async delete(realisasiId: string) {
try {
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].delete();
if (res.data?.success) {
toast.success("Realisasi berhasil dihapus");
// Reload findUnique untuk update data
if (apbdes.findUnique.data) {
await apbdes.findUnique.load(apbdes.findUnique.data.id);
}
return true;
} else {
toast.error(res.data?.message || "Gagal menghapus realisasi");
return false;
}
} catch (error: any) {
console.error("Delete realisasi error:", error);
toast.error(error?.message || "Terjadi kesalahan saat menghapus realisasi");
return false;
}
},
},
});
export default apbdes;

View File

@@ -0,0 +1,429 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
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,
Group,
Paper,
Stack,
Text,
TextInput,
NumberInput,
Title,
Table,
TableThead,
TableTbody,
TableTr,
TableTh,
TableTd,
ActionIcon,
Badge,
Modal,
Divider,
Center,
} from '@mantine/core';
import {
IconPlus,
IconEdit,
IconTrash,
IconCalendar,
IconCoin,
} from '@tabler/icons-react';
interface RealisasiManagerProps {
itemId: string;
itemKode: string;
itemUraian: string;
itemAnggaran: number;
itemTotalRealisasi: number;
itemPersentase: number;
realisasiItems: any[];
}
export default function RealisasiManager({
itemId,
itemKode,
itemUraian,
itemAnggaran,
itemTotalRealisasi,
itemPersentase,
realisasiItems,
}: RealisasiManagerProps) {
const state = useProxy(apbdes);
const [modalOpened, setModalOpened] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Form state
const [formData, setFormData] = useState({
kode: '',
jumlah: 0,
tanggal: new Date().toISOString().split('T')[0], // YYYY-MM-DD format for input
keterangan: '',
});
const resetForm = () => {
setFormData({
kode: '',
jumlah: 0,
tanggal: new Date().toISOString().split('T')[0],
keterangan: '',
});
setEditingId(null);
};
const handleOpenCreate = () => {
resetForm();
setModalOpened(true);
};
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 || '',
});
setEditingId(realisasi.id);
setModalOpened(true);
};
const handleSubmit = async () => {
if (formData.jumlah <= 0) {
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,
});
if (success) {
toast.success('Realisasi berhasil diperbarui');
}
} 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,
});
if (success) {
toast.success('Realisasi berhasil ditambahkan');
}
}
setModalOpened(false);
resetForm();
} catch (error: any) {
console.error('Error saving realisasi:', error);
toast.error(error?.message || 'Gagal menyimpan realisasi');
} finally {
setLoading(false);
}
};
const handleDelete = async (realisasiId: string) => {
if (!confirm('Apakah Anda yakin ingin menghapus realisasi ini?')) {
return;
}
try {
setLoading(true);
const success = await state.realisasi.delete(realisasiId);
if (success) {
toast.success('Realisasi berhasil dihapus');
}
} catch (error: any) {
console.error('Error deleting realisasi:', error);
toast.error(error?.message || 'Gagal menghapus realisasi');
} finally {
setLoading(false);
}
};
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', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const getSisaAnggaran = () => {
return itemAnggaran - itemTotalRealisasi;
};
const getPersentaseColor = (persen: number) => {
if (persen >= 100) return 'teal';
if (persen >= 80) return 'blue';
if (persen >= 60) return 'yellow';
return 'red';
};
return (
<Paper withBorder p="md" radius="md" mt="md">
{/* Header */}
<Group justify="space-between" mb="md">
<Stack gap="xs">
<Title order={6}>
{itemKode} - {itemUraian}
</Title>
<Text fz="sm" c="dimmed">
Kelola realisasi untuk item ini
</Text>
</Stack>
<Button
leftSection={<IconPlus size={18} />}
onClick={handleOpenCreate}
color="blue"
variant="light"
radius="md"
>
Tambah Realisasi
</Button>
</Group>
{/* Summary Cards */}
<Group grow mb="md">
<Paper withBorder p="md" radius="md" bg="blue.0">
<Text fz="xs" c="blue.9" fw={600}>
ANGGARAN
</Text>
<Text fz="lg" c="blue.9" fw={700}>
{formatRupiah(itemAnggaran)}
</Text>
</Paper>
<Paper withBorder p="md" radius="md" bg="teal.0">
<Text fz="xs" c="teal.9" fw={600}>
TOTAL REALISASI
</Text>
<Text fz="lg" c="teal.9" fw={700}>
{formatRupiah(itemTotalRealisasi)}
</Text>
</Paper>
<Paper withBorder p="md" radius="md" bg={getSisaAnggaran() >= 0 ? 'green.0' : 'red.0'}>
<Text fz="xs" c={getSisaAnggaran() >= 0 ? 'green.9' : 'red.9'} fw={600}>
SISA ANGGARAN
</Text>
<Text fz="lg" c={getSisaAnggaran() >= 0 ? 'green.9' : 'red.9'} fw={700}>
{formatRupiah(getSisaAnggaran())}
</Text>
</Paper>
<Paper withBorder p="md" radius="md" bg={getPersentaseColor(itemPersentase) + '.0'}>
<Text fz="xs" c={getPersentaseColor(itemPersentase) + '.9'} fw={600}>
PERSENTASE
</Text>
<Text fz="lg" c={getPersentaseColor(itemPersentase) + '.9'} fw={700}>
{itemPersentase.toFixed(2)}%
</Text>
</Paper>
</Group>
{/* Realisasi List */}
{realisasiItems && realisasiItems.length > 0 ? (
<Box>
<Text fz="sm" fw={600} mb="xs">
Daftar Realisasi ({realisasiItems.length})
</Text>
<Box style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover fz="sm">
<TableThead>
<TableTr>
<TableTh>Kode</TableTh>
<TableTh>Tanggal</TableTh>
<TableTh>Uraian</TableTh>
<TableTh ta="right">Jumlah</TableTh>
<TableTh ta="center">Aksi</TableTh>
</TableTr>
</TableThead>
<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} />
<Text fz="sm">{formatDate(realisasi.tanggal)}</Text>
</Group>
</TableTd>
<TableTd>
<Text fz="sm">{realisasi.keterangan || '-'}</Text>
</TableTd>
<TableTd ta="right">
<Text fz="sm" fw={600} c="blue">
{formatRupiah(realisasi.jumlah)}
</Text>
</TableTd>
<TableTd ta="center">
<Group gap="xs" justify="center">
<ActionIcon
variant="light"
color="blue"
size="sm"
onClick={() => handleOpenEdit(realisasi)}
>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
size="sm"
onClick={() => handleDelete(realisasi.id)}
disabled={loading}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</Box>
</Box>
) : (
<Center py="xl">
<Stack align="center" gap="xs">
<Text fz="sm" c="dimmed">
Belum ada realisasi untuk item ini
</Text>
<Text fz="xs" c="dimmed">
Klik tombol &quot;Tambah Realisasi&quot; untuk menambahkan
</Text>
</Stack>
</Center>
)}
{/* Modal Create/Edit */}
<Modal
opened={modalOpened}
onClose={() => {
setModalOpened(false);
resetForm();
}}
title={
<Text fz="lg" fw={600}>
{editingId ? 'Edit Realisasi' : 'Tambah Realisasi Baru'}
</Text>
}
size="md"
centered
>
<Stack gap="md">
{/* Info Item */}
<Paper p="sm" bg="gray.0" radius="md">
<Text fz="xs" c="dimmed">
Item: {itemKode} - {itemUraian}
</Text>
<Text fz="xs" c="dimmed">
Anggaran: {formatRupiah(itemAnggaran)}
</Text>
<Text fz="xs" c="dimmed">
Sudah terealisasi: {formatRupiah(itemTotalRealisasi)}
</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}
onChange={(val) => setFormData({ ...formData, jumlah: Number(val) || 0 })}
leftSection={<IconCoin size={16} />}
thousandSeparator
min={0}
step={100000}
required
/>
<TextInput
label="Tanggal Realisasi"
type="date"
value={formData.tanggal}
onChange={(e) => setFormData({ ...formData, tanggal: e.target.value })}
leftSection={<IconCalendar size={18} />}
required
/>
<TextInput
label="Keterangan / Uraian"
placeholder="Contoh: Penyaluran BLT Tahap 1"
value={formData.keterangan}
onChange={(e) => setFormData({ ...formData, keterangan: e.target.value })}
description="Deskripsi singkat tentang realisasi ini"
/>
<Divider my="xs" />
<Group justify="right">
<Button
variant="outline"
color="gray"
onClick={() => {
setModalOpened(false);
resetForm();
}}
disabled={loading}
>
Batal
</Button>
<Button
onClick={handleSubmit}
loading={loading}
color="blue"
leftSection={editingId ? <IconEdit size={16} /> : <IconPlus size={16} />}
>
{editingId ? 'Perbarui' : 'Tambah'} Realisasi
</Button>
</Group>
</Stack>
</Modal>
</Paper>
);
}

View File

@@ -42,9 +42,11 @@ type ItemForm = {
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
realisasi?: number;
selisih?: number;
persentase?: number;
};
function EditAPBDes() {
@@ -71,9 +73,11 @@ function EditAPBDes() {
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
realisasi: 0,
selisih: 0,
persentase: 0,
});
// Simpan data original untuk reset form
@@ -127,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',
})),
@@ -157,34 +161,33 @@ function EditAPBDes() {
};
const handleAddItem = () => {
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
const { kode, uraian, anggaran, level, tipe, realisasi, selisih, persentase } = newItem;
if (!kode || !uraian) {
return toast.warn('Kode dan uraian wajib diisi');
}
const finalTipe = level === 1 ? null : tipe;
const selisih = realisasi - anggaran;
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
apbdesState.edit.addItem({
kode,
uraian,
anggaran,
realisasi,
selisih,
persentase,
realisasi: realisasi || 0,
selisih: selisih || 0,
persentase: persentase || 0,
level,
tipe: finalTipe, // ✅ Tidak akan undefined
tipe: finalTipe,
});
setNewItem({
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
realisasi: 0,
selisih: 0,
persentase: 0,
});
};
@@ -202,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,
@@ -214,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,
@@ -225,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');
@@ -271,9 +264,11 @@ function EditAPBDes() {
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
realisasi: 0,
selisih: 0,
persentase: 0,
});
toast.info('Form dikembalikan ke data awal');
@@ -338,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')}
@@ -382,6 +377,7 @@ function EditAPBDes() {
onClick={() => {
setPreviewImage(null);
setImageFile(null);
apbdesState.edit.form.imageId = ''; // Clear imageId from form
}}
>
<IconX size={14} />
@@ -392,7 +388,7 @@ function EditAPBDes() {
<Box>
<Text fw="bold" fz="sm" mb={6}>
Dokumen APBDes
Dokumen APBDes (Opsional)
</Text>
<Dropzone
onDrop={handleDrop('doc')}
@@ -441,6 +437,7 @@ function EditAPBDes() {
onClick={() => {
setPreviewDoc(null);
setDocFile(null);
apbdesState.edit.form.fileId = ''; // Clear fileId from form
}}
>
<IconX size={14} />
@@ -514,13 +511,6 @@ function EditAPBDes() {
thousandSeparator
min={0}
/>
<NumberInput
label="Realisasi (Rp)"
value={newItem.realisasi}
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
thousandSeparator
min={0}
/>
</Group>
<Button
leftSection={<IconPlus size={16} />}
@@ -545,6 +535,8 @@ function EditAPBDes() {
<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>
@@ -560,7 +552,11 @@ function EditAPBDes() {
</td>
<td>{item.uraian}</td>
<td>{item.anggaran.toLocaleString('id-ID')}</td>
<td>{item.realisasi.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}
@@ -572,7 +568,7 @@ function EditAPBDes() {
{item.tipe}
</Badge>
) : (
'-'
<Text size="sm" c="dimmed">-</Text>
)}
</td>
<td>

View File

@@ -25,6 +25,7 @@ import { useEffect, useState } from 'react';
import colors from '@/con/colors';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import apbdes from '../../../_state/landing-page/apbdes';
import RealisasiManager from './RealisasiManager';
@@ -191,48 +192,60 @@ function DetailAPBDes() {
{/* Tabel Items */}
{data.items && data.items.length > 0 ? (
<Paper withBorder p="md" radius="md">
<Text fz="lg" fw="bold" mb="sm">
<Stack gap="md">
<Text fz="lg" fw="bold">
Rincian Pendapatan & Belanja ({data.items.length} item)
</Text>
<Box style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Uraian</TableTh>
<TableTh>Anggaran (Rp)</TableTh>
<TableTh>Realisasi (Rp)</TableTh>
<TableTh>Selisih (Rp)</TableTh>
<TableTh>Persentase (%)</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{[...data.items] // Create a new array before sorting
.sort((a, b) => a.kode.localeCompare(b.kode))
.map((item) => (
<TableTr key={item.id}>
<TableTd style={getIndent(item.level)}>
<Group>
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
<Text fz="sm" c="dimmed">{item.uraian}</Text>
</Group>
</TableTd>
<TableTd>{item.anggaran.toLocaleString('id-ID')}</TableTd>
<TableTd>{item.realisasi.toLocaleString('id-ID')}</TableTd>
<TableTd>
<Text c={item.selisih >= 0 ? 'green' : 'red'}>
{item.selisih.toLocaleString('id-ID')}
</Text>
</TableTd>
<TableTd>
<Text fw={500}>{item.persentase.toFixed(2)}%</Text>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</Box>
</Paper>
<Table striped highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Uraian</TableTh>
<TableTh>Anggaran (Rp)</TableTh>
<TableTh>Realisasi (Rp)</TableTh>
<TableTh>Selisih (Rp)</TableTh>
<TableTh>Persentase (%)</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{[...data.items]
.sort((a, b) => a.kode.localeCompare(b.kode))
.map((item) => (
<TableTr key={item.id}>
<TableTd style={getIndent(item.level)}>
<Group>
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
<Text fz="sm" c="dimmed">{item.uraian}</Text>
</Group>
</TableTd>
<TableTd>{item.anggaran.toLocaleString('id-ID')}</TableTd>
<TableTd>{item.totalRealisasi.toLocaleString('id-ID')}</TableTd>
<TableTd>
<Text c={item.selisih >= 0 ? 'green' : 'red'}>
{item.selisih.toLocaleString('id-ID')}
</Text>
</TableTd>
<TableTd>
<Text fw={500}>{item.persentase.toFixed(2)}%</Text>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
{/* Realisasi Manager untuk setiap item */}
{data.items.map((item) => (
<RealisasiManager
key={item.id}
itemId={item.id}
itemKode={item.kode}
itemUraian={item.uraian}
itemAnggaran={item.anggaran}
itemTotalRealisasi={item.totalRealisasi}
itemPersentase={item.persentase}
realisasiItems={item.realisasiItems || []}
/>
))}
</Stack>
) : (
<Text>Belum ada data item</Text>
)}

View File

@@ -33,7 +33,6 @@ type ItemForm = {
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
};
@@ -47,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
@@ -61,7 +56,6 @@ function CreateAPBDes() {
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
@@ -80,35 +74,40 @@ function CreateAPBDes() {
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
};
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;
@@ -127,22 +126,17 @@ function CreateAPBDes() {
// Tambahkan item ke state
const handleAddItem = () => {
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
const { kode, uraian, anggaran, level, tipe } = newItem;
if (!kode || !uraian) {
return toast.warn("Kode dan uraian wajib diisi");
}
const finalTipe = level === 1 ? null : tipe;
const selisih = realisasi - anggaran;
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
stateAPBDes.create.addItem({
kode,
uraian,
anggaran,
realisasi,
selisih,
persentase,
level,
tipe: finalTipe,
});
@@ -152,7 +146,6 @@ function CreateAPBDes() {
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
@@ -183,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) => {
@@ -258,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) => {
@@ -427,13 +424,6 @@ function CreateAPBDes() {
thousandSeparator
min={0}
/>
<NumberInput
label="Realisasi (Rp)"
value={newItem.realisasi}
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
thousandSeparator
min={0}
/>
</Group>
<Button
leftSection={<IconPlus size={16} />}
@@ -455,28 +445,30 @@ function CreateAPBDes() {
<th>Kode</th>
<th>Uraian</th>
<th>Anggaran</th>
<th>Realisasi</th>
<th>Level</th>
<th>Tipe</th>
<th style={{ width: 50 }}>Aksi</th>
</tr>
</thead>
<tbody>
{stateAPBDes.create.form.items.map((item, idx) => (
{stateAPBDes.create.form.items.map((item: any, idx) => (
<tr key={idx}>
<td><Text size="sm" fw={500}>{item.kode}</Text></td>
<td>{item.uraian}</td>
<td>{item.anggaran.toLocaleString('id-ID')}</td>
<td>{item.realisasi.toLocaleString('id-ID')}</td>
<td>
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
L{item.level}
</Badge>
</td>
<td>
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
{item.tipe}
</Badge>
{item.tipe ? (
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
{item.tipe}
</Badge>
) : (
<Text size="sm" c="dimmed">-</Text>
)}
</td>
<td>
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>

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

@@ -8,9 +8,6 @@ type APBDesItemInput = {
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
selisih: number;
persentase: number;
level: number;
tipe?: string | null;
};
@@ -20,15 +17,14 @@ type FormCreate = {
name?: string;
deskripsi?: string;
jumlah?: string;
imageId: string;
fileId: string;
imageId?: string | null; // Opsional
fileId?: string | null; // Opsional
items: APBDesItemInput[];
};
export default async function apbdesCreate(context: Context) {
const body = context.body as FormCreate;
// Log the incoming request for debugging
console.log('Incoming request body:', JSON.stringify(body, null, 2));
try {
@@ -36,17 +32,12 @@ 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');
}
// 1. Buat APBDes + items (tanpa parentId dulu)
// 1. Buat APBDes + items dengan auto-calculate fields
const created = await prisma.$transaction(async (prisma) => {
const apbdes = await prisma.aPBDes.create({
data: {
@@ -54,27 +45,31 @@ 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
},
});
// Create items in a batch
// Create items dengan auto-calculate totalRealisasi=0, selisih, persentase
const items = await Promise.all(
body.items.map(item => {
// Create a new object with only the fields that exist in the APBDesItem model
body.items.map(async item => {
const anggaran = item.anggaran;
const totalRealisasi = 0; // Belum ada realisasi saat create
const selisih = anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0;
const itemData = {
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
realisasi: item.realisasi,
selisih: item.selisih,
persentase: item.persentase,
anggaran: anggaran,
level: item.level,
tipe: item.tipe, // ✅ sertakan, biar null
tipe: item.tipe || null,
totalRealisasi,
selisih,
persentase,
apbdesId: apbdes.id,
};
return prisma.aPBDesItem.create({
data: itemData,
select: { id: true, kode: true },
@@ -89,20 +84,27 @@ export default async function apbdesCreate(context: Context) {
// 2. Isi parentId berdasarkan kode
await assignParentIdsToApbdesItems(created.items);
// 3. Ambil ulang data lengkap untuk response
// 3. Ambil ulang data lengkap untuk response (include realisasiItems)
const result = await prisma.aPBDes.findUnique({
where: { id: created.id },
include: {
image: true,
file: true,
items: {
where: { isActive: true },
orderBy: { kode: 'asc' },
include: {
realisasiItems: {
where: { isActive: true },
orderBy: { tanggal: 'asc' },
},
},
},
},
});
console.log('APBDes created successfully:', JSON.stringify(result, null, 2));
return {
success: true,
message: "Berhasil membuat APBDes",
@@ -110,7 +112,6 @@ export default async function apbdesCreate(context: Context) {
};
} catch (innerError) {
console.error('Error in post-creation steps:', innerError);
// Even if post-creation steps fail, we still return success since the main record was created
return {
success: true,
message: "APBDes berhasil dibuat, tetapi ada masalah dengan pemrosesan tambahan",
@@ -120,13 +121,12 @@ export default async function apbdesCreate(context: Context) {
}
} catch (error: any) {
console.error("Error creating APBDes:", error);
// Log the full error for debugging
if (error.code) console.error('Prisma error code:', error.code);
if (error.meta) console.error('Prisma error meta:', error.meta);
const errorMessage = error.message || 'Unknown error';
context.set.status = 500;
return {
success: false,

View File

@@ -21,7 +21,7 @@ export default async function apbdesFindMany(context: Context) {
try {
const where: any = { isActive: true };
if (search) {
where.OR = [
{ name: { contains: search, mode: "insensitive" } },
@@ -51,7 +51,10 @@ export default async function apbdesFindMany(context: Context) {
where: { isActive: true },
orderBy: { kode: "asc" },
include: {
parent: true,
realisasiItems: {
where: { isActive: true },
orderBy: { tanggal: 'asc' },
},
},
},
},

View File

@@ -2,15 +2,9 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function apbdesFindUnique(context: Context) {
// ✅ Parse URL secara manual
const url = new URL(context.request.url);
const pathSegments = url.pathname.split('/').filter(Boolean);
console.log("🔍 DEBUG INFO:");
console.log("- Full URL:", context.request.url);
console.log("- Pathname:", url.pathname);
console.log("- Path segments:", pathSegments);
// Expected: ['api', 'landingpage', 'apbdes', 'ID']
if (pathSegments.length < 4) {
context.set.status = 400;
@@ -20,9 +14,9 @@ export default async function apbdesFindUnique(context: Context) {
debug: { pathSegments }
};
}
if (pathSegments[0] !== 'api' ||
pathSegments[1] !== 'landingpage' ||
if (pathSegments[0] !== 'api' ||
pathSegments[1] !== 'landingpage' ||
pathSegments[2] !== 'apbdes') {
context.set.status = 400;
return {
@@ -31,9 +25,9 @@ export default async function apbdesFindUnique(context: Context) {
debug: { pathSegments }
};
}
const id = pathSegments[3]; // ✅ ID ada di index ke-3
const id = pathSegments[3];
if (!id || id.trim() === '') {
context.set.status = 400;
return {
@@ -50,7 +44,10 @@ export default async function apbdesFindUnique(context: Context) {
where: { isActive: true },
orderBy: { kode: 'asc' },
include: {
parent: true, // Include parent item for hierarchy
realisasiItems: {
where: { isActive: true },
orderBy: { tanggal: 'asc' },
},
},
},
image: true,

View File

@@ -5,17 +5,17 @@ import apbdesDelete from "./del";
import apbdesFindMany from "./findMany";
import apbdesFindUnique from "./findUnique";
import apbdesUpdate from "./updt";
import realisasiCreate from "./realisasi/create";
import realisasiUpdate from "./realisasi/update";
import realisasiDelete from "./realisasi/delete";
// Definisikan skema untuk item APBDes
// Definisikan skema untuk item APBDes (tanpa realisasi field)
const ApbdesItemSchema = t.Object({
kode: t.String(),
uraian: t.String(),
anggaran: t.Number(),
realisasi: t.Number(),
selisih: t.Number(),
persentase: t.Number(),
level: t.Number(),
tipe: t.Optional(t.Union([t.String(), t.Null()])) // misal: "pendapatan" atau "belanja"
tipe: t.Optional(t.Union([t.String(), t.Null()])), // "pendapatan" | "belanja" | "pembiayaan" | null
});
const APBDes = new Elysia({
@@ -26,23 +26,23 @@ const APBDes = new Elysia({
// ✅ Find all (dengan query opsional: page, limit, tahun)
.get("/findMany", apbdesFindMany)
// ✅ Find by ID
// ✅ Find by ID (include realisasiItems)
.get("/:id", apbdesFindUnique)
// ✅ Create
// ✅ Create APBDes dengan items (tanpa realisasi)
.post("/create", apbdesCreate, {
body: t.Object({
tahun: t.Number(),
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),
}),
})
// ✅ Update
// ✅ Update APBDes dengan items (tanpa realisasi)
.put("/:id", apbdesUpdate, {
params: t.Object({ id: t.String() }),
body: t.Object({
@@ -50,15 +50,48 @@ 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),
}),
})
// ✅ Delete
// ✅ Delete APBDes
.delete("/del/:id", apbdesDelete, {
params: t.Object({ id: t.String() }),
})
// =========================================
// REALISASI ENDPOINTS
// =========================================
// ✅ Create realisasi untuk item tertentu
.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()),
buktiFileId: t.Optional(t.String()),
}),
})
// ✅ Update realisasi
.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()),
buktiFileId: t.Optional(t.String()),
}),
})
// ✅ Delete realisasi
.delete("/realisasi/:realisasiId", realisasiDelete, {
params: t.Object({ realisasiId: t.String() }),
});
export default APBDes;

View File

@@ -0,0 +1,84 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
type RealisasiCreateBody = {
kode: string;
jumlah: number;
tanggal: string; // ISO format
keterangan?: string;
buktiFileId?: string;
};
export default async function realisasiCreate(context: Context) {
const { itemId } = context.params as { itemId: string };
const body = context.body as RealisasiCreateBody;
console.log('Creating realisasi:', JSON.stringify(body, null, 2));
try {
// 1. Pastikan APBDesItem ada
const item = await prisma.aPBDesItem.findUnique({
where: { id: itemId },
});
if (!item) {
context.set.status = 404;
return {
success: false,
message: "Item APBDes tidak ditemukan",
};
}
// 2. Create realisasi item
const realisasi = await prisma.realisasiItem.create({
data: {
apbdesItemId: itemId,
kode: body.kode,
jumlah: body.jumlah,
tanggal: new Date(body.tanggal),
keterangan: body.keterangan,
buktiFileId: body.buktiFileId,
},
});
// 3. Update totalRealisasi, selisih, persentase di APBDesItem
const allRealisasi = await prisma.realisasiItem.findMany({
where: { apbdesItemId: itemId, isActive: true },
select: { jumlah: true },
});
const totalRealisasi = allRealisasi.reduce((sum, r) => sum + r.jumlah, 0);
const selisih = item.anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
await prisma.aPBDesItem.update({
where: { id: itemId },
data: {
totalRealisasi,
selisih,
persentase,
},
});
// 4. Return response
return {
success: true,
message: "Realisasi berhasil ditambahkan",
data: realisasi,
meta: {
totalRealisasi,
selisih,
persentase,
},
};
} catch (error: any) {
console.error("Error creating realisasi:", error);
context.set.status = 500;
return {
success: false,
message: `Gagal menambahkan realisasi: ${error.message}`,
error: process.env.NODE_ENV === 'development' ? error : undefined,
};
}
}

View File

@@ -0,0 +1,73 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function realisasiDelete(context: Context) {
const { realisasiId } = context.params as { realisasiId: string };
console.log('Deleting realisasi:', realisasiId);
try {
// 1. Pastikan realisasi ada
const existing = await prisma.realisasiItem.findUnique({
where: { id: realisasiId },
});
if (!existing) {
context.set.status = 404;
return {
success: false,
message: "Realisasi tidak ditemukan",
};
}
const apbdesItemId = existing.apbdesItemId;
// 2. Soft delete realisasi (set isActive = false)
await prisma.realisasiItem.update({
where: { id: realisasiId },
data: {
isActive: false,
deletedAt: new Date(),
},
});
// 3. Recalculate totalRealisasi, selisih, persentase di APBDesItem
const allRealisasi = await prisma.realisasiItem.findMany({
where: { apbdesItemId, isActive: true },
select: { jumlah: true },
});
const item = await prisma.aPBDesItem.findUnique({
where: { id: apbdesItemId },
});
if (item) {
const totalRealisasi = allRealisasi.reduce((sum, r) => sum + r.jumlah, 0);
const selisih = item.anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
await prisma.aPBDesItem.update({
where: { id: apbdesItemId },
data: {
totalRealisasi,
selisih,
persentase,
},
});
}
return {
success: true,
message: "Realisasi berhasil dihapus",
};
} catch (error: any) {
console.error("Error deleting realisasi:", error);
context.set.status = 500;
return {
success: false,
message: `Gagal menghapus realisasi: ${error.message}`,
error: process.env.NODE_ENV === 'development' ? error : undefined,
};
}
}

View File

@@ -0,0 +1,87 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
type RealisasiUpdateBody = {
kode?: string;
jumlah?: number;
tanggal?: string;
keterangan?: string;
buktiFileId?: string;
};
export default async function realisasiUpdate(context: Context) {
const { realisasiId } = context.params as { realisasiId: string };
const body = context.body as RealisasiUpdateBody;
console.log('Updating realisasi:', JSON.stringify(body, null, 2));
try {
// 1. Pastikan realisasi ada
const existing = await prisma.realisasiItem.findUnique({
where: { id: realisasiId },
});
if (!existing) {
context.set.status = 404;
return {
success: false,
message: "Realisasi tidak ditemukan",
};
}
// 2. Update realisasi
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 }),
...(body.buktiFileId !== undefined && { buktiFileId: body.buktiFileId }),
},
});
// 3. Recalculate totalRealisasi, selisih, persentase di APBDesItem
const allRealisasi = await prisma.realisasiItem.findMany({
where: { apbdesItemId: existing.apbdesItemId, isActive: true },
select: { jumlah: true },
});
const item = await prisma.aPBDesItem.findUnique({
where: { id: existing.apbdesItemId },
});
if (item) {
const totalRealisasi = allRealisasi.reduce((sum, r) => sum + r.jumlah, 0);
const selisih = item.anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
await prisma.aPBDesItem.update({
where: { id: existing.apbdesItemId },
data: {
totalRealisasi,
selisih,
persentase,
},
});
}
return {
success: true,
message: "Realisasi berhasil diperbarui",
data: updated,
meta: {
totalRealisasi: allRealisasi.reduce((sum, r) => sum + r.jumlah, 0),
},
};
} catch (error: any) {
console.error("Error updating realisasi:", error);
context.set.status = 500;
return {
success: false,
message: `Gagal memperbarui realisasi: ${error.message}`,
error: process.env.NODE_ENV === 'development' ? error : undefined,
};
}
}

View File

@@ -1,14 +1,12 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
import { assignParentIdsToApbdesItems } from "./lib/getParentsID";
import { RealisasiItem } from "@prisma/client";
type APBDesItemInput = {
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
selisih: number;
persentase: number;
level: number;
tipe?: string | null;
};
@@ -18,8 +16,8 @@ type FormUpdateBody = {
name?: string;
deskripsi?: string;
jumlah?: string;
imageId: string;
fileId: string;
imageId?: string | null;
fileId?: string | null;
items: APBDesItemInput[];
};
@@ -31,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) {
@@ -41,43 +49,119 @@ export default async function apbdesUpdate(context: Context) {
};
}
// 2. Hapus semua item lama
// 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 tanpa parentId terlebih dahulu
// 4. Buat item baru dengan preserve realisasiItems
await prisma.aPBDesItem.createMany({
data: body.items.map((item) => ({
apbdesId: id,
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
realisasi: item.realisasi,
selisih: item.anggaran - item.realisasi,
persentase: item.anggaran > 0 ? (item.realisasi / item.anggaran) * 100 : 0,
level: item.level,
tipe: item.tipe || null,
isActive: true,
data: await Promise.all(body.items.map(async (item) => {
const anggaran = item.anggaran;
// 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 {
apbdesId: id,
kode: item.kode,
uraian: item.uraian,
anggaran: anggaran,
level: item.level,
tipe: item.tipe || null,
totalRealisasi,
selisih,
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
// Pastikan allItems memiliki tipe yang benar
// 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,
}));
await assignParentIdsToApbdesItems(itemsForParentUpdate);
// 6. Update data APBDes
// 10. Update data APBDes
await prisma.aPBDes.update({
where: { id },
data: {
@@ -85,18 +169,24 @@ 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,
},
});
// 5. Ambil data lengkap untuk response
// 11. Ambil data lengkap untuk response (include realisasiItems)
const result = await prisma.aPBDes.findUnique({
where: { id },
include: {
items: {
where: { isActive: true },
orderBy: { kode: 'asc' }
orderBy: { kode: 'asc' },
include: {
realisasiItems: {
where: { isActive: true },
orderBy: { tanggal: 'asc' },
},
},
},
image: true,
file: true,

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

@@ -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">
@@ -232,20 +92,10 @@ 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}
/>
)}
<Box px={{ base: 'xs', sm: 'md', md: 100 }} py="xl">
<Paper
mx="auto"
p="xl"
p={{ base: 'md', sm: 'xl' }}
radius="lg"
shadow="sm"
bg="white"
@@ -255,81 +105,67 @@ const MusicPlayer = () => {
>
<Stack gap="md">
<BackButton />
<Group justify="space-between" mb="xl" mt={"md"}>
<Flex
justify="space-between"
align={{ base: 'flex-start', sm: 'center' }}
direction={{ base: 'column', sm: 'row' }}
gap="md"
mb="xl"
mt="md"
>
<div>
<Text size="32px" fw={700} c="#0B4F78">Selamat Datang Kembali</Text>
<Text size="md" c="#5A6C7D">Temukan musik favorit Anda hari ini</Text>
<Text fz={{ base: '24px', sm: '32px' }} fw={700} c="#0B4F78" lh={1.2}>Selamat Datang Kembali</Text>
<Text size="sm" c="#5A6C7D">Temukan musik favorit Anda hari ini</Text>
</div>
<Group gap="md">
<TextInput
placeholder="Cari lagu..."
leftSection={<IconSearch size={18} />}
radius="xl"
w={280}
value={search}
onChange={(e) => setSearch(e.target.value)}
styles={{ input: { backgroundColor: '#fff' } }}
/>
</Group>
</Group>
<TextInput
placeholder="Cari lagu..."
leftSection={<IconSearch size={18} />}
radius="xl"
w={{ base: '100%', sm: 280 }}
value={search}
onChange={(e) => setSearch(e.target.value)}
styles={{ input: { backgroundColor: '#fff' } }}
/>
</Flex>
<Stack gap="xl">
<div>
<Text size="xl" fw={700} c="#0B4F78" mb="md">Sedang Diputar</Text>
{currentSong ? (
<Card radius="md" p="xl" shadow="md">
<Group align="center" gap="xl">
<Card radius="md" p={{ base: 'md', sm: 'xl' }} shadow="md" withBorder>
<Flex
direction={{ base: 'column', sm: 'row' }}
align="center"
gap={{ base: 'md', sm: 'xl' }}
>
<Avatar
src={currentSong.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
size={180}
src={currentSong.coverImage?.link || '/mp3-logo.png'}
size={120}
radius="md"
/>
<Stack gap="md" style={{ flex: 1 }}>
<div>
<Text size="28px" fw={700} c="#0B4F78">{currentSong.judul}</Text>
<Stack gap="md" style={{ flex: 1, width: '100%' }}>
<Box ta={{ base: 'center', sm: 'left' }}>
<Text fz={{ base: '20px', sm: '28px' }} fw={700} c="#0B4F78" lineClamp={1}>{currentSong.judul}</Text>
<Text size="lg" c="#5A6C7D">{currentSong.artis}</Text>
{currentSong.genre && (
<Badge mt="xs" color="#0B4F78" variant="light">{currentSong.genre}</Badge>
)}
</div>
</Box>
<Group gap="xs" align="center">
<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>
</Flex>
</Card>
) : (
<Card radius="md" p="xl" shadow="md">
@@ -345,32 +181,33 @@ 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"
p="md"
p="sm"
shadow="sm"
withBorder
style={{
cursor: 'pointer',
border: currentSong?.id === song.id ? '2px solid #0B4F78' : '2px solid transparent',
borderColor: currentSong?.id === song.id ? '#0B4F78' : 'transparent',
backgroundColor: currentSong?.id === song.id ? '#F0F7FA' : 'white',
transition: 'all 0.2s'
}}
onClick={() => playSong(index)}
onClick={() => playSong(song)}
>
<Group gap="md" align="center">
<Group gap="sm" align="center" wrap="nowrap">
<Avatar
src={song.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
size={64}
src={song.coverImage?.link || '/mp3-logo.png'}
size={50}
radius="md"
/>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} c="#0B4F78" truncate>{song.judul}</Text>
<Text size="xs" c="#5A6C7D">{song.artis}</Text>
<Text size="xs" c="#8A9BA8">{song.durasi}</Text>
<Text size="xs" c="#5A6C7D" truncate>{song.artis}</Text>
</Stack>
{currentSong?.id === song.id && isPlaying && (
<Badge color="#0B4F78" variant="filled">Memutar</Badge>
<Badge color="#0B4F78" variant="filled" size="xs">Playing</Badge>
)}
</Group>
</Card>
@@ -381,34 +218,42 @@ const MusicPlayer = () => {
)}
</div>
</Stack>
</Stack>
</Paper>
{/* Control Player Section */}
<Paper
mt="xl"
mx="auto"
p="xl"
p={{ base: 'md', sm: 'xl' }}
radius="lg"
shadow="sm"
bg="white"
style={{
border: '1px solid #eaeaea',
position: 'sticky',
bottom: 20,
zIndex: 10
}}
>
<Flex align="center" justify="space-between" gap="xl" h="100%">
<Group gap="md" style={{ flex: 1 }}>
<Flex
direction={{ base: 'column', md: 'row' }}
align="center"
justify="space-between"
gap={{ base: 'md', md: 'xl' }}
>
{/* Song Info */}
<Group gap="md" style={{ flex: 1, width: '100%' }} wrap="nowrap">
<Avatar
src={currentSong?.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
size={56}
src={currentSong?.coverImage?.link || '/mp3-logo.png'}
size={48}
radius="md"
/>
<div style={{ flex: 1, minWidth: 0 }}>
{currentSong ? (
<>
<Text size="sm" fw={600} c="#0B4F78" truncate>{currentSong.judul}</Text>
<Text size="xs" c="#5A6C7D">{currentSong.artis}</Text>
<Text size="xs" c="#5A6C7D" truncate>{currentSong.artis}</Text>
</>
) : (
<Text size="sm" c="dimmed">Tidak ada lagu</Text>
@@ -416,36 +261,39 @@ const MusicPlayer = () => {
</div>
</Group>
<Stack gap="xs" style={{ flex: 1 }} align="center">
<Group gap="md">
{/* Controls + Progress */}
<Stack gap="xs" style={{ flex: 2, width: '100%' }} align="center">
<Group gap="sm">
<ActionIcon
variant={isShuffle ? 'filled' : 'subtle'}
color="#0B4F78"
onClick={toggleShuffleHandler}
radius="xl"
size={48}
>
{isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />}
</ActionIcon>
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl" onClick={skipBack}>
<ActionIcon variant="light" color="#0B4F78" size={48} radius="xl" onClick={skipBack}>
<IconPlayerSkipBackFilled size={20} />
</ActionIcon>
<ActionIcon
variant="filled"
color="#0B4F78"
size={56}
size={48}
radius="xl"
onClick={togglePlayPauseHandler}
>
{isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />}
</ActionIcon>
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl" onClick={skipForward}>
<ActionIcon variant="light" color="#0B4F78" size={48} radius="xl" onClick={skipForward}>
<IconPlayerSkipForwardFilled size={20} />
</ActionIcon>
<ActionIcon
variant={isRepeat ? 'filled' : 'subtle'}
color="#0B4F78"
onClick={() => setIsRepeat(!isRepeat)}
onClick={toggleRepeatHandler}
radius="xl"
size="md"
>
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
</ActionIcon>
@@ -454,42 +302,19 @@ 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}>
{/* Volume Control - Hidden on mobile, shown on md and up */}
<Group gap="xs" style={{ flex: 1 }} justify="flex-end" visibleFrom="md">
<ActionIcon variant="subtle" color="gray" onClick={toggleMuteHandler}>
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
</ActionIcon>
<Slider
@@ -507,86 +332,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,300 @@
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%',
left: '0px',
transform: 'translateY(-50%)',
borderBottomRightRadius: '20px',
borderTopRightRadius: '20px',
cursor: 'pointer',
transition: 'transform 0.2s ease',
zIndex: 1000 // Higher z-index
}}
onClick={handleRestorePlayer}
>
<IconMusic size={24} color="white" />
</Button>
</>
);
}
if (!currentSong) {
return null;
}
return (
<>
{/* Mini Player Bar - Always visible when song is playing */}
<Paper
pos="fixed"
bottom={0}
left={0}
right={0}
p={{ base: 'xs', sm: 'sm' }}
shadow="xl"
style={{
zIndex: 1000,
borderTop: '1px solid rgba(0,0,0,0.1)',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
}}
>
<Flex align="center" gap={{ base: 'xs', sm: 'md' }} justify="space-between">
{/* Song Info - Left */}
<Group gap="xs" flex={{ base: 2, sm: 1 }} style={{ minWidth: 0 }} wrap="nowrap">
<Avatar
src={currentSong.coverImage?.link || ''}
alt={currentSong.judul}
size={"36"}
radius="sm"
/>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fz={{ base: 'xs', sm: 'sm' }} fw={600} truncate>
{currentSong.judul}
</Text>
<Text fz="10px" c="dimmed" truncate>
{currentSong.artis}
</Text>
</Box>
</Group>
{/* Controls - Center */}
<Group gap={"xs"} flex={{ base: 1, sm: 2 }} justify="center" wrap="nowrap">
{/* Shuffle - Desktop Only */}
<ActionIcon
variant={isShuffle ? 'filled' : 'subtle'}
color={isShuffle ? '#0B4F78' : 'gray'}
size={"md"}
onClick={handleToggleShuffle}
visibleFrom="sm"
>
<IconArrowsShuffle size={18} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size={"md"}
onClick={playPrev}
>
<IconPlayerSkipBackFilled size={20} />
</ActionIcon>
<ActionIcon
variant="filled"
color="#0B4F78"
size={"lg"}
radius="xl"
onClick={togglePlayPause}
>
{isPlaying ? (
<IconPlayerPauseFilled size={24} />
) : (
<IconPlayerPlayFilled size={24} />
)}
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size={"md"}
onClick={playNext}
>
<IconPlayerSkipForwardFilled size={20} />
</ActionIcon>
{/* Repeat - Desktop Only */}
<ActionIcon
variant={isRepeat ? 'filled' : 'subtle'}
color={isRepeat ? '#0B4F78' : 'gray'}
size={"md"}
onClick={toggleRepeat}
visibleFrom="sm"
>
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
</ActionIcon>
{/* Progress Bar - Desktop Only */}
<Box w={150} ml="md" visibleFrom="md">
<Slider
value={currentTime}
max={duration || 100}
onChange={handleSeek}
size="xs"
color="#0B4F78"
label={(value) => formatTime(value)}
/>
</Box>
</Group>
{/* Right Controls - Volume + Close */}
<Group gap={4} flex={1} justify="flex-end" wrap="nowrap">
{/* Volume Control - Tablet/Desktop */}
<Box
onMouseEnter={() => setShowVolume(true)}
onMouseLeave={() => setShowVolume(false)}
pos="relative"
visibleFrom="sm"
>
<ActionIcon
variant="subtle"
color={isMuted ? 'red' : 'gray'}
size="lg"
onClick={toggleMute}
>
{isMuted ? <IconVolumeOff size={18} /> : <IconVolume size={18} />}
</ActionIcon>
<Transition
mounted={showVolume}
transition="scale-y"
duration={200}
>
{(style) => (
<Paper
style={{
...style,
position: 'absolute',
bottom: '100%',
right: 0,
marginBottom: '10px',
padding: '10px',
zIndex: 1001,
}}
shadow="md"
withBorder
>
<Slider
value={isMuted ? 0 : volume}
max={100}
onChange={handleVolumeChange}
h={80}
color="#0B4F78"
size="sm"
/>
</Paper>
)}
</Transition>
</Box>
<ActionIcon
variant="subtle"
color="gray"
size={"md"}
onClick={handleMinimizePlayer}
>
<IconX size={18} />
</ActionIcon>
</Group>
</Flex>
{/* Progress Bar - Mobile (Base) */}
<Box px="xs" mt={4} hiddenFrom="md">
<Slider
value={currentTime}
max={duration || 100}
onChange={handleSeek}
size="xs"
color="#0B4F78"
label={(value) => formatTime(value)}
/>
</Box>
</Paper>
{/* Spacer to prevent content from being hidden behind player */}
<Box h={{ base: 70, sm: 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.realisasi, 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;
@@ -36,38 +52,38 @@ function Summary({ title, data }: any) {
{persen.toFixed(2)}%
</Text>
</Group>
<Text fz="sm" c="dimmed" mb="xs">
Realisasi: {formatRupiah(totalRealisasi)} / Anggaran: {formatRupiah(totalAnggaran)}
</Text>
<Progress
value={persen}
size="xl"
<Progress
value={persen}
size="xl"
radius="xl"
color={getProgressColor(persen)}
striped={persen < 100}
animated={persen < 100}
/>
{persen >= 100 && (
<Text fz="xs" c="teal" mt="xs" fw={500}>
Realisasi mencapai 100% dari anggaran
</Text>
)}
{persen < 100 && persen >= 80 && (
<Text fz="xs" c="blue" mt="xs" fw={500}>
Realisasi baik, mendekati target
</Text>
)}
{persen < 80 && persen >= 60 && (
<Text fz="xs" c="yellow" mt="xs" fw={500}>
Realisasi cukup, perlu ditingkatkan
</Text>
)}
{persen < 60 && (
<Text fz="xs" c="red" mt="xs" fw={500}>
Realisasi rendah, perlu perhatian khusus
@@ -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.realisasi, 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,30 +115,11 @@ export default function GrafikRealisasi({ apbdesData }: any) {
GRAFIK REALISASI APBDes {tahun}
</Title>
<Stack gap="lg">
<Stack gap="lg" mb="lg">
<Summary title="💰 Pendapatan" data={pendapatan} />
<Summary title="💸 Belanja" data={belanja} />
<Summary title="📊 Pembiayaan" data={pembiayaan} />
</Stack>
{/* Summary Total Keseluruhan */}
<Box mb="lg" p="md" bg="gray.0">
<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>
</Paper>
);
}

View File

@@ -1,24 +1,28 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Table, Title } from '@mantine/core';
import { Paper, Table, Title, Text } from '@mantine/core';
function Section({ title, data }: any) {
if (!data || data.length === 0) return null;
return (
<>
<Table.Tr>
<Table.Tr bg="gray.0">
<Table.Td colSpan={2}>
<strong>{title}</strong>
<Text fw={700} fz={{ base: 'xs', sm: 'sm' }}>{title}</Text>
</Table.Td>
</Table.Tr>
{data.map((item: any) => (
<Table.Tr key={item.id}>
<Table.Td>
{item.kode} - {item.uraian}
<Text fz={{ base: 'xs', sm: 'sm' }} lineClamp={2}>
{item.kode} - {item.uraian}
</Text>
</Table.Td>
<Table.Td ta="right">
Rp {item.anggaran.toLocaleString('id-ID')}
<Text fz={{ base: 'xs', sm: 'sm' }} fw={500} style={{ whiteSpace: 'nowrap' }}>
Rp {item.anggaran.toLocaleString('id-ID')}
</Text>
</Table.Td>
</Table.Tr>
))}
@@ -39,22 +43,24 @@ export default function PaguTable({ apbdesData }: any) {
const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan');
return (
<Paper withBorder p="md" radius="md">
<Title order={5} mb="md">{title}</Title>
<Paper withBorder p={{ base: 'sm', sm: 'md' }} radius="md">
<Title order={5} mb="md" fz={{ base: 'sm', sm: 'md' }}>{title}</Title>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Uraian</Table.Th>
<Table.Th ta="right">Anggaran (Rp)</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>
<Table.ScrollContainer minWidth={280}>
<Table verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th fz={{ base: 'xs', sm: 'sm' }}>Uraian</Table.Th>
<Table.Th ta="right" fz={{ base: 'xs', sm: 'sm' }}>Anggaran (Rp)</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>
</Table.ScrollContainer>
</Paper>
);
}

View File

@@ -1,43 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Table, Title, Badge } from '@mantine/core';
function Section({ title, data }: any) {
if (!data || data.length === 0) return null;
return (
<>
<Table.Tr>
<Table.Td colSpan={3}>
<strong>{title}</strong>
</Table.Td>
</Table.Tr>
{data.map((item: any) => (
<Table.Tr key={item.id}>
<Table.Td>
{item.kode} - {item.uraian}
</Table.Td>
<Table.Td ta="right">
Rp {item.realisasi.toLocaleString('id-ID')}
</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>
))}
</>
);
}
import { Paper, Table, Title, Badge, Text } from '@mantine/core';
export default function RealisasiTable({ apbdesData }: any) {
const items = apbdesData.items || [];
@@ -47,28 +9,84 @@ 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>
<Paper withBorder p={{ base: 'sm', sm: 'md' }} radius="md">
<Title order={5} mb="md" fz={{ base: 'sm', sm: 'md' }}>{title}</Title>
<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>
<Section title="1) PENDAPATAN" data={pendapatan} />
<Section title="2) BELANJA" data={belanja} />
<Section title="3) PEMBIAYAAN" data={pembiayaan} />
</Table.Tbody>
</Table>
{allRealisasiRows.length === 0 ? (
<Text fz="sm" c="dimmed" ta="center" py="md">
Belum ada data realisasi
</Text>
) : (
<Table.ScrollContainer minWidth={300}>
<Table verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th fz={{ base: 'xs', sm: 'sm' }}>Uraian</Table.Th>
<Table.Th ta="right" fz={{ base: 'xs', sm: 'sm' }}>Realisasi (Rp)</Table.Th>
<Table.Th ta="center" fz={{ base: 'xs', sm: 'sm' }}>%</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{allRealisasiRows.map(({ realisasi, parentItem }) => {
const persentase = parentItem.anggaran > 0
? (realisasi.jumlah / parentItem.anggaran) * 100
: 0;
return (
<Table.Tr key={realisasi.id}>
<Table.Td>
<Text fz={{ base: 'xs', sm: 'sm' }} lineClamp={2}>
{realisasi.kode || '-'} - {realisasi.keterangan || '-'}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text fw={600} c="blue" fz={{ base: 'xs', sm: 'sm' }} style={{ whiteSpace: 'nowrap' }}>
{formatRupiah(realisasi.jumlah || 0)}
</Text>
</Table.Td>
<Table.Td ta="center">
<Badge
size="sm"
variant="light"
color={
persentase >= 100
? 'teal'
: persentase >= 60
? 'yellow'
: 'red'
}
>
{persentase.toFixed(1)}%
</Badge>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</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";
@@ -104,15 +106,17 @@ export default function RootLayout({
<ColorSchemeScript defaultColorScheme="light" />
</head>
<body>
<MantineProvider theme={theme} defaultColorScheme="light">
{children}
<LoadDataFirstClient />
<ToastContainer
position="bottom-center"
hideProgressBar
style={{ zIndex: 9999 }}
/>
</MantineProvider>
<MusicProvider>
<MantineProvider theme={theme} defaultColorScheme="light">
{children}
<LoadDataFirstClient />
<ToastContainer
position="bottom-center"
hideProgressBar
style={{ zIndex: 9999 }}
/>
</MantineProvider>
</MusicProvider>
</body>
</html>
</ViewTransitions>

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