Compare commits

...

70 Commits

Author SHA1 Message Date
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
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
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
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
19 changed files with 463 additions and 291 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/modals": "^8.3.6",
"@mantine/tiptap": "^7.17.4", "@mantine/tiptap": "^7.17.4",
"@paljs/types": "^8.1.0", "@paljs/types": "^8.1.0",
"@prisma/client": "^6.3.1", "@prisma/client": "6.3.1",
"@tabler/icons-react": "^3.30.0", "@tabler/icons-react": "^3.30.0",
"@tiptap/extension-highlight": "^2.11.7", "@tiptap/extension-highlight": "^2.11.7",
"@tiptap/extension-link": "^2.11.7", "@tiptap/extension-link": "^2.11.7",
@@ -89,7 +89,7 @@
"p-limit": "^6.2.0", "p-limit": "^6.2.0",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primereact": "^10.9.6", "primereact": "^10.9.6",
"prisma": "^6.3.1", "prisma": "6.3.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-exif-orientation-img": "^0.1.5", "react-exif-orientation-img": "^0.1.5",

View File

@@ -23,8 +23,9 @@ const ApbdesFormSchema = z.object({
name: z.string().optional(), name: z.string().optional(),
deskripsi: z.string().optional(), deskripsi: z.string().optional(),
jumlah: z.string().optional(), jumlah: z.string().optional(),
imageId: z.string().min(1, "Gambar wajib diunggah"), // Image dan file opsional (bisa kosong)
fileId: z.string().min(1, "File wajib diunggah"), imageId: z.string().optional(),
fileId: z.string().optional(),
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"), items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
}); });

View File

@@ -205,7 +205,6 @@ function EditAPBDes() {
// Upload file baru jika ada perubahan // Upload file baru jika ada perubahan
if (imageFile) { if (imageFile) {
// Hapus file lama dari form jika ada file baru
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
file: imageFile, file: imageFile,
name: imageFile.name, name: imageFile.name,
@@ -217,7 +216,6 @@ function EditAPBDes() {
} }
if (docFile) { if (docFile) {
// Hapus file lama dari form jika ada file baru
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
file: docFile, file: docFile,
name: docFile.name, name: docFile.name,
@@ -228,15 +226,7 @@ function EditAPBDes() {
} }
} }
// Jika tidak ada file baru, gunakan ID lama (sudah ada di form) // Image dan file sekarang opsional, tidak perlu validasi
// 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');
}
const success = await apbdesState.edit.update(); const success = await apbdesState.edit.update();
if (success) { if (success) {
router.push('/admin/landing-page/apbdes'); router.push('/admin/landing-page/apbdes');
@@ -343,11 +333,11 @@ function EditAPBDes() {
required required
/> />
{/* Gambar & Dokumen */} {/* Gambar & Dokumen (Opsional) */}
<Stack gap="xs"> <Stack gap="xs">
<Box> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
Gambar APBDes Gambar APBDes (Opsional)
</Text> </Text>
<Dropzone <Dropzone
onDrop={handleDrop('image')} onDrop={handleDrop('image')}
@@ -387,6 +377,7 @@ function EditAPBDes() {
onClick={() => { onClick={() => {
setPreviewImage(null); setPreviewImage(null);
setImageFile(null); setImageFile(null);
apbdesState.edit.form.imageId = ''; // Clear imageId from form
}} }}
> >
<IconX size={14} /> <IconX size={14} />
@@ -397,7 +388,7 @@ function EditAPBDes() {
<Box> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
Dokumen APBDes Dokumen APBDes (Opsional)
</Text> </Text>
<Dropzone <Dropzone
onDrop={handleDrop('doc')} onDrop={handleDrop('doc')}
@@ -446,6 +437,7 @@ function EditAPBDes() {
onClick={() => { onClick={() => {
setPreviewDoc(null); setPreviewDoc(null);
setDocFile(null); setDocFile(null);
apbdesState.edit.form.fileId = ''; // Clear fileId from form
}} }}
> >
<IconX size={14} /> <IconX size={14} />

View File

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

View File

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

View File

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

View File

@@ -36,8 +36,8 @@ const APBDes = new Elysia({
name: t.Optional(t.String()), name: t.Optional(t.String()),
deskripsi: t.Optional(t.String()), deskripsi: t.Optional(t.String()),
jumlah: t.Optional(t.String()), jumlah: t.Optional(t.String()),
imageId: t.String(), imageId: t.Optional(t.String()),
fileId: t.String(), fileId: t.Optional(t.String()),
items: t.Array(ApbdesItemSchema), items: t.Array(ApbdesItemSchema),
}), }),
}) })
@@ -50,8 +50,8 @@ const APBDes = new Elysia({
name: t.Optional(t.String()), name: t.Optional(t.String()),
deskripsi: t.Optional(t.String()), deskripsi: t.Optional(t.String()),
jumlah: t.Optional(t.String()), jumlah: t.Optional(t.String()),
imageId: t.String(), imageId: t.Optional(t.String()),
fileId: t.String(), fileId: t.Optional(t.String()),
items: t.Array(ApbdesItemSchema), items: t.Array(ApbdesItemSchema),
}), }),
}) })

View File

@@ -1,6 +1,7 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
import { assignParentIdsToApbdesItems } from "./lib/getParentsID"; import { assignParentIdsToApbdesItems } from "./lib/getParentsID";
import { RealisasiItem } from "@prisma/client";
type APBDesItemInput = { type APBDesItemInput = {
kode: string; kode: string;
@@ -15,8 +16,8 @@ type FormUpdateBody = {
name?: string; name?: string;
deskripsi?: string; deskripsi?: string;
jumlah?: string; jumlah?: string;
imageId: string; imageId?: string | null;
fileId: string; fileId?: string | null;
items: APBDesItemInput[]; items: APBDesItemInput[];
}; };
@@ -51,7 +52,7 @@ export default async function apbdesUpdate(context: Context) {
// 2. Build map untuk preserve realisasiItems berdasarkan kode // 2. Build map untuk preserve realisasiItems berdasarkan kode
const existingItemsMap = new Map<string, { const existingItemsMap = new Map<string, {
id: string; id: string;
realisasiItems: any[]; realisasiItems: RealisasiItem[];
}>(); }>();
existing.items.forEach(item => { existing.items.forEach(item => {
@@ -128,7 +129,7 @@ export default async function apbdesUpdate(context: Context) {
} }
// 8. Recalculate totalRealisasi setelah re-create realisasiItems // 8. Recalculate totalRealisasi setelah re-create realisasiItems
for (const [kode, _] of existingItemsMap.entries()) { for (const kode of existingItemsMap.keys()) {
const newItemId = newItemIdsMap.get(kode); const newItemId = newItemIdsMap.get(kode);
if (newItemId) { if (newItemId) {
const realisasiItems = await prisma.realisasiItem.findMany({ const realisasiItems = await prisma.realisasiItem.findMany({
@@ -168,8 +169,8 @@ export default async function apbdesUpdate(context: Context) {
name: body.name || `APBDes Tahun ${body.tahun}`, name: body.name || `APBDes Tahun ${body.tahun}`,
deskripsi: body.deskripsi, deskripsi: body.deskripsi,
jumlah: body.jumlah, jumlah: body.jumlah,
imageId: body.imageId, imageId: body.imageId === '' ? null : body.imageId,
fileId: body.fileId, fileId: body.fileId === '' ? null : body.fileId,
}, },
}); });

View File

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

View File

@@ -82,6 +82,12 @@ export function MusicProvider({ children }: { children: ReactNode }) {
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const isSeekingRef = useRef(false); const isSeekingRef = useRef(false);
const animationFrameRef = useRef<number | null>(null); 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 // Load musik data
const loadMusikData = useCallback(async () => { const loadMusikData = useCallback(async () => {
@@ -111,7 +117,8 @@ export function MusicProvider({ children }: { children: ReactNode }) {
}); });
audioRef.current.addEventListener('ended', () => { audioRef.current.addEventListener('ended', () => {
if (isRepeat) { // Gunakan ref untuk avoid stale closure
if (isRepeatRef.current) {
audioRef.current!.currentTime = 0; audioRef.current!.currentTime = 0;
audioRef.current!.play(); audioRef.current!.play();
} else { } else {
@@ -132,7 +139,7 @@ export function MusicProvider({ children }: { children: ReactNode }) {
} }
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps -- playNext is intentionally not in deps to avoid circular dependency // eslint-disable-next-line react-hooks/exhaustive-deps -- playNext is intentionally not in deps to avoid circular dependency
}, [loadMusikData, isRepeat]); }, [loadMusikData]); // Remove isRepeat dari deps karena sudah pakai ref
// Update time with requestAnimationFrame for smooth progress // Update time with requestAnimationFrame for smooth progress
const updateTime = useCallback(() => { const updateTime = useCallback(() => {

View File

@@ -92,10 +92,10 @@ const MusicPlayer = () => {
} }
return ( return (
<Box px={{ base: 'md', md: 100 }} py="xl"> <Box px={{ base: 'xs', sm: 'md', md: 100 }} py="xl">
<Paper <Paper
mx="auto" mx="auto"
p="xl" p={{ base: 'md', sm: 'xl' }}
radius="lg" radius="lg"
shadow="sm" shadow="sm"
bg="white" bg="white"
@@ -105,42 +105,52 @@ const MusicPlayer = () => {
> >
<Stack gap="md"> <Stack gap="md">
<BackButton /> <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> <div>
<Text size="32px" fw={700} c="#0B4F78">Selamat Datang Kembali</Text> <Text fz={{ base: '24px', sm: '32px' }} fw={700} c="#0B4F78" lh={1.2}>Selamat Datang Kembali</Text>
<Text size="md" c="#5A6C7D">Temukan musik favorit Anda hari ini</Text> <Text size="sm" c="#5A6C7D">Temukan musik favorit Anda hari ini</Text>
</div> </div>
<Group gap="md"> <TextInput
<TextInput placeholder="Cari lagu..."
placeholder="Cari lagu..." leftSection={<IconSearch size={18} />}
leftSection={<IconSearch size={18} />} radius="xl"
radius="xl" w={{ base: '100%', sm: 280 }}
w={280} value={search}
value={search} onChange={(e) => setSearch(e.target.value)}
onChange={(e) => setSearch(e.target.value)} styles={{ input: { backgroundColor: '#fff' } }}
styles={{ input: { backgroundColor: '#fff' } }} />
/> </Flex>
</Group>
</Group>
<Stack gap="xl"> <Stack gap="xl">
<div> <div>
<Text size="xl" fw={700} c="#0B4F78" mb="md">Sedang Diputar</Text> <Text size="xl" fw={700} c="#0B4F78" mb="md">Sedang Diputar</Text>
{currentSong ? ( {currentSong ? (
<Card radius="md" p="xl" shadow="md"> <Card radius="md" p={{ base: 'md', sm: 'xl' }} shadow="md" withBorder>
<Group align="center" gap="xl"> <Flex
direction={{ base: 'column', sm: 'row' }}
align="center"
gap={{ base: 'md', sm: 'xl' }}
>
<Avatar <Avatar
src={currentSong.coverImage?.link || '/mp3-logo.png'} src={currentSong.coverImage?.link || '/mp3-logo.png'}
size={180} size={120}
radius="md" radius="md"
/> />
<Stack gap="md" style={{ flex: 1 }}> <Stack gap="md" style={{ flex: 1, width: '100%' }}>
<div> <Box ta={{ base: 'center', sm: 'left' }}>
<Text size="28px" fw={700} c="#0B4F78">{currentSong.judul}</Text> <Text fz={{ base: '20px', sm: '28px' }} fw={700} c="#0B4F78" lineClamp={1}>{currentSong.judul}</Text>
<Text size="lg" c="#5A6C7D">{currentSong.artis}</Text> <Text size="lg" c="#5A6C7D">{currentSong.artis}</Text>
{currentSong.genre && ( {currentSong.genre && (
<Badge mt="xs" color="#0B4F78" variant="light">{currentSong.genre}</Badge> <Badge mt="xs" color="#0B4F78" variant="light">{currentSong.genre}</Badge>
)} )}
</div> </Box>
<Group gap="xs" align="center"> <Group gap="xs" align="center">
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text> <Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text>
<Slider <Slider
@@ -155,7 +165,7 @@ const MusicPlayer = () => {
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration || 0)}</Text> <Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration || 0)}</Text>
</Group> </Group>
</Stack> </Stack>
</Group> </Flex>
</Card> </Card>
) : ( ) : (
<Card radius="md" p="xl" shadow="md"> <Card radius="md" p="xl" shadow="md">
@@ -175,28 +185,29 @@ const MusicPlayer = () => {
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}> <Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}>
<Card <Card
radius="md" radius="md"
p="md" p="sm"
shadow="sm" shadow="sm"
withBorder
style={{ style={{
cursor: 'pointer', 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' transition: 'all 0.2s'
}} }}
onClick={() => playSong(song)} onClick={() => playSong(song)}
> >
<Group gap="md" align="center"> <Group gap="sm" align="center" wrap="nowrap">
<Avatar <Avatar
src={song.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'} src={song.coverImage?.link || '/mp3-logo.png'}
size={64} size={50}
radius="md" 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="sm" fw={600} c="#0B4F78" truncate>{song.judul}</Text>
<Text size="xs" c="#5A6C7D">{song.artis}</Text> <Text size="xs" c="#5A6C7D" truncate>{song.artis}</Text>
<Text size="xs" c="#8A9BA8">{song.durasi}</Text>
</Stack> </Stack>
{currentSong?.id === song.id && isPlaying && ( {currentSong?.id === song.id && isPlaying && (
<Badge color="#0B4F78" variant="filled">Memutar</Badge> <Badge color="#0B4F78" variant="filled" size="xs">Playing</Badge>
)} )}
</Group> </Group>
</Card> </Card>
@@ -207,34 +218,42 @@ const MusicPlayer = () => {
)} )}
</div> </div>
</Stack> </Stack>
</Stack> </Stack>
</Paper> </Paper>
{/* Control Player Section */}
<Paper <Paper
mt="xl" mt="xl"
mx="auto" mx="auto"
p="xl" p={{ base: 'md', sm: 'xl' }}
radius="lg" radius="lg"
shadow="sm" shadow="sm"
bg="white" bg="white"
style={{ style={{
border: '1px solid #eaeaea', border: '1px solid #eaeaea',
position: 'sticky',
bottom: 20,
zIndex: 10
}} }}
> >
<Flex align="center" justify="space-between" gap="xl" h="100%"> <Flex
<Group gap="md" style={{ flex: 1 }}> 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 <Avatar
src={currentSong?.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'} src={currentSong?.coverImage?.link || '/mp3-logo.png'}
size={56} size={48}
radius="md" radius="md"
/> />
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{currentSong ? ( {currentSong ? (
<> <>
<Text size="sm" fw={600} c="#0B4F78" truncate>{currentSong.judul}</Text> <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> <Text size="sm" c="dimmed">Tidak ada lagu</Text>
@@ -242,29 +261,31 @@ const MusicPlayer = () => {
</div> </div>
</Group> </Group>
<Stack gap="xs" style={{ flex: 1 }} align="center"> {/* Controls + Progress */}
<Group gap="md"> <Stack gap="xs" style={{ flex: 2, width: '100%' }} align="center">
<Group gap="sm">
<ActionIcon <ActionIcon
variant={isShuffle ? 'filled' : 'subtle'} variant={isShuffle ? 'filled' : 'subtle'}
color="#0B4F78" color="#0B4F78"
onClick={toggleShuffleHandler} onClick={toggleShuffleHandler}
radius="xl" radius="xl"
size={48}
> >
{isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />} {isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />}
</ActionIcon> </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} /> <IconPlayerSkipBackFilled size={20} />
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon
variant="filled" variant="filled"
color="#0B4F78" color="#0B4F78"
size={56} size={48}
radius="xl" radius="xl"
onClick={togglePlayPauseHandler} onClick={togglePlayPauseHandler}
> >
{isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />} {isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />}
</ActionIcon> </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} /> <IconPlayerSkipForwardFilled size={20} />
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon
@@ -272,6 +293,7 @@ const MusicPlayer = () => {
color="#0B4F78" color="#0B4F78"
onClick={toggleRepeatHandler} onClick={toggleRepeatHandler}
radius="xl" radius="xl"
size="md"
> >
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />} {isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
</ActionIcon> </ActionIcon>
@@ -290,7 +312,8 @@ const MusicPlayer = () => {
</Group> </Group>
</Stack> </Stack>
<Group gap="xs" style={{ flex: 1 }} justify="flex-end"> {/* 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}> <ActionIcon variant="subtle" color="gray" onClick={toggleMuteHandler}>
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />} {isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
</ActionIcon> </ActionIcon>

View File

@@ -3,6 +3,7 @@ import {
ActionIcon, ActionIcon,
Avatar, Avatar,
Box, Box,
Button,
Flex, Flex,
Group, Group,
Paper, Paper,
@@ -12,6 +13,7 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconArrowsShuffle, IconArrowsShuffle,
IconMusic,
IconPlayerPauseFilled, IconPlayerPauseFilled,
IconPlayerPlayFilled, IconPlayerPlayFilled,
IconPlayerSkipBackFilled, IconPlayerSkipBackFilled,
@@ -45,7 +47,7 @@ export default function FixedPlayerBar() {
} = useMusic(); } = useMusic();
const [showVolume, setShowVolume] = useState(false); const [showVolume, setShowVolume] = useState(false);
const [isPlayerVisible, setIsPlayerVisible] = useState(true); const [isMinimized, setIsMinimized] = useState(false);
// Format time // Format time
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
@@ -69,12 +71,46 @@ export default function FixedPlayerBar() {
toggleShuffle(); toggleShuffle();
}; };
// Handle close player // Handle minimize player (show floating icon)
const handleClosePlayer = () => { const handleMinimizePlayer = () => {
setIsPlayerVisible(false); setIsMinimized(true);
}; };
if (!currentSong || !isPlayerVisible) { // 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 null;
} }
@@ -86,41 +122,43 @@ export default function FixedPlayerBar() {
bottom={0} bottom={0}
left={0} left={0}
right={0} right={0}
p="sm" p={{ base: 'xs', sm: 'sm' }}
shadow="lg" shadow="xl"
style={{ style={{
zIndex: 1000, zIndex: 1000,
borderTop: '1px solid rgba(0,0,0,0.1)', borderTop: '1px solid rgba(0,0,0,0.1)',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
}} }}
> >
<Flex align="center" gap="md" justify="space-between"> <Flex align="center" gap={{ base: 'xs', sm: 'md' }} justify="space-between">
{/* Song Info */} {/* Song Info - Left */}
<Group gap="sm" flex={1} style={{ minWidth: 0 }}> <Group gap="xs" flex={{ base: 2, sm: 1 }} style={{ minWidth: 0 }} wrap="nowrap">
<Avatar <Avatar
src={currentSong.coverImage?.link || ''} src={currentSong.coverImage?.link || ''}
alt={currentSong.judul} alt={currentSong.judul}
size={40} size={"36"}
radius="sm" radius="sm"
imageProps={{ loading: 'lazy' }}
/> />
<Box style={{ minWidth: 0 }}> <Box style={{ minWidth: 0, flex: 1 }}>
<Text fz="sm" fw={600} truncate> <Text fz={{ base: 'xs', sm: 'sm' }} fw={600} truncate>
{currentSong.judul} {currentSong.judul}
</Text> </Text>
<Text fz="xs" c="dimmed" truncate> <Text fz="10px" c="dimmed" truncate>
{currentSong.artis} {currentSong.artis}
</Text> </Text>
</Box> </Box>
</Group> </Group>
{/* Controls */} {/* Controls - Center */}
<Group gap="xs"> <Group gap={"xs"} flex={{ base: 1, sm: 2 }} justify="center" wrap="nowrap">
{/* Shuffle - Desktop Only */}
<ActionIcon <ActionIcon
variant={isShuffle ? 'filled' : 'subtle'} variant={isShuffle ? 'filled' : 'subtle'}
color={isShuffle ? 'blue' : 'gray'} color={isShuffle ? '#0B4F78' : 'gray'}
size="lg" size={"md"}
onClick={handleToggleShuffle} onClick={handleToggleShuffle}
title="Shuffle" visibleFrom="sm"
> >
<IconArrowsShuffle size={18} /> <IconArrowsShuffle size={18} />
</ActionIcon> </ActionIcon>
@@ -128,20 +166,18 @@ export default function FixedPlayerBar() {
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
size="lg" size={"md"}
onClick={playPrev} onClick={playPrev}
title="Previous"
> >
<IconPlayerSkipBackFilled size={20} /> <IconPlayerSkipBackFilled size={20} />
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon
variant="filled" variant="filled"
color={isPlaying ? 'blue' : 'gray'} color="#0B4F78"
size="xl" size={"lg"}
radius="xl" radius="xl"
onClick={togglePlayPause} onClick={togglePlayPause}
title={isPlaying ? 'Pause' : 'Play'}
> >
{isPlaying ? ( {isPlaying ? (
<IconPlayerPauseFilled size={24} /> <IconPlayerPauseFilled size={24} />
@@ -153,62 +189,58 @@ export default function FixedPlayerBar() {
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
size="lg" size={"md"}
onClick={playNext} onClick={playNext}
title="Next"
> >
<IconPlayerSkipForwardFilled size={20} /> <IconPlayerSkipForwardFilled size={20} />
</ActionIcon> </ActionIcon>
{/* Repeat - Desktop Only */}
<ActionIcon <ActionIcon
variant="subtle" variant={isRepeat ? 'filled' : 'subtle'}
color={isRepeat ? 'blue' : 'gray'} color={isRepeat ? '#0B4F78' : 'gray'}
size="lg" size={"md"}
onClick={toggleRepeat} onClick={toggleRepeat}
title={isRepeat ? 'Repeat On' : 'Repeat Off'} visibleFrom="sm"
> >
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />} {isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
</ActionIcon> </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> </Group>
{/* Progress Bar - Desktop */} {/* Right Controls - Volume + Close */}
<Box w={200} display={{ base: 'none', md: 'block' }}> <Group gap={4} flex={1} justify="flex-end" wrap="nowrap">
<Slider {/* Volume Control - Tablet/Desktop */}
value={currentTime}
max={duration || 100}
onChange={handleSeek}
size="sm"
color="blue"
label={(value) => formatTime(value)}
/>
</Box>
{/* Right Controls */}
<Group gap="xs">
<Box <Box
onMouseEnter={() => setShowVolume(true)} onMouseEnter={() => setShowVolume(true)}
onMouseLeave={() => setShowVolume(false)} onMouseLeave={() => setShowVolume(false)}
pos="relative" pos="relative"
visibleFrom="sm"
> >
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color={isMuted ? 'red' : 'gray'} color={isMuted ? 'red' : 'gray'}
size="lg" size="lg"
onClick={toggleMute} onClick={toggleMute}
title={isMuted ? 'Unmute' : 'Mute'}
> >
{isMuted ? ( {isMuted ? <IconVolumeOff size={18} /> : <IconVolume size={18} />}
<IconVolumeOff size={18} />
) : (
<IconVolume size={18} />
)}
</ActionIcon> </ActionIcon>
<Transition <Transition
mounted={showVolume} mounted={showVolume}
transition="scale-y" transition="scale-y"
duration={200} duration={200}
timingFunction="ease"
> >
{(style) => ( {(style) => (
<Paper <Paper
@@ -217,8 +249,8 @@ export default function FixedPlayerBar() {
position: 'absolute', position: 'absolute',
bottom: '100%', bottom: '100%',
right: 0, right: 0,
mb: 'xs', marginBottom: '10px',
p: 'sm', padding: '10px',
zIndex: 1001, zIndex: 1001,
}} }}
shadow="md" shadow="md"
@@ -228,8 +260,8 @@ export default function FixedPlayerBar() {
value={isMuted ? 0 : volume} value={isMuted ? 0 : volume}
max={100} max={100}
onChange={handleVolumeChange} onChange={handleVolumeChange}
h={100} h={80}
color="blue" color="#0B4F78"
size="sm" size="sm"
/> />
</Paper> </Paper>
@@ -240,30 +272,29 @@ export default function FixedPlayerBar() {
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
size="lg" size={"md"}
onClick={handleClosePlayer} onClick={handleMinimizePlayer}
title="Close player"
> >
<IconX size={18} /> <IconX size={18} />
</ActionIcon> </ActionIcon>
</Group> </Group>
</Flex> </Flex>
{/* Progress Bar - Mobile */} {/* Progress Bar - Mobile (Base) */}
<Box mt="xs" display={{ base: 'block', md: 'none' }}> <Box px="xs" mt={4} hiddenFrom="md">
<Slider <Slider
value={currentTime} value={currentTime}
max={duration || 100} max={duration || 100}
onChange={handleSeek} onChange={handleSeek}
size="sm" size="xs"
color="blue" color="#0B4F78"
label={(value) => formatTime(value)} label={(value) => formatTime(value)}
/> />
</Box> </Box>
</Paper> </Paper>
{/* Spacer to prevent content from being hidden behind player */} {/* Spacer to prevent content from being hidden behind player */}
<Box h={80} /> <Box h={{ base: 70, sm: 80 }} />
</> </>
); );
} }

View File

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

View File

@@ -1,12 +1,27 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Title, Progress, Stack, Text, Group, Box } from '@mantine/core'; 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; if (!data || data.length === 0) return null;
const totalAnggaran = data.reduce((s: number, i: any) => s + i.anggaran, 0); const totalAnggaran = data.reduce((s: number, i: APBDesItem) => s + i.anggaran, 0);
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData) // Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
const totalRealisasi = data.reduce((s: number, i: any) => s + (i.realisasi || i.totalRealisasi || 0), 0); const totalRealisasi = data.reduce(
(s: number, i: APBDesItem) => s + (i.realisasi || i.totalRealisasi || 0),
0
);
const persen = const persen =
totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0; totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
@@ -78,28 +93,21 @@ function Summary({ title, data }: any) {
); );
} }
export default function GrafikRealisasi({ apbdesData }: any) { export default function GrafikRealisasi({
const items = apbdesData.items || []; apbdesData,
const tahun = apbdesData.tahun || new Date().getFullYear(); }: {
apbdesData: {
const pendapatan = items.filter((i: any) => i.tipe === 'pendapatan'); tahun?: number | null;
const belanja = items.filter((i: any) => i.tipe === 'belanja'); items?: APBDesItem[] | null;
const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan'); [key: string]: any;
// Hitung total keseluruhan
const totalAnggaranSemua = items.reduce((s: number, i: any) => s + i.anggaran, 0);
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
const totalRealisasiSemua = items.reduce((s: number, i: any) => s + (i.realisasi || i.totalRealisasi || 0), 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);
}; };
}) {
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 ( return (
<Paper withBorder p="md" radius="md"> <Paper withBorder p="md" radius="md">
@@ -112,27 +120,6 @@ export default function GrafikRealisasi({ apbdesData }: any) {
<Summary title="💸 Belanja" data={belanja} /> <Summary title="💸 Belanja" data={belanja} />
<Summary title="📊 Pembiayaan" data={pembiayaan} /> <Summary title="📊 Pembiayaan" data={pembiayaan} />
</Stack> </Stack>
{/* Summary Total Keseluruhan */}
<Box 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> </Paper>
); );
} }

View File

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

View File

@@ -30,56 +30,62 @@ export default function RealisasiTable({ apbdesData }: any) {
}; };
return ( return (
<Paper withBorder p="md" radius="md"> <Paper withBorder p={{ base: 'sm', sm: 'md' }} radius="md">
<Title order={5} mb="md">{title}</Title> <Title order={5} mb="md" fz={{ base: 'sm', sm: 'md' }}>{title}</Title>
{allRealisasiRows.length === 0 ? ( {allRealisasiRows.length === 0 ? (
<Text fz="sm" c="dimmed" ta="center" py="md"> <Text fz="sm" c="dimmed" ta="center" py="md">
Belum ada data realisasi Belum ada data realisasi
</Text> </Text>
) : ( ) : (
<Table> <Table.ScrollContainer minWidth={300}>
<Table.Thead> <Table verticalSpacing="xs">
<Table.Tr> <Table.Thead>
<Table.Th>Uraian</Table.Th> <Table.Tr>
<Table.Th ta="right">Realisasi (Rp)</Table.Th> <Table.Th fz={{ base: 'xs', sm: 'sm' }}>Uraian</Table.Th>
<Table.Th ta="center">%</Table.Th> <Table.Th ta="right" fz={{ base: 'xs', sm: 'sm' }}>Realisasi (Rp)</Table.Th>
</Table.Tr> <Table.Th ta="center" fz={{ base: 'xs', sm: 'sm' }}>%</Table.Th>
</Table.Thead> </Table.Tr>
<Table.Tbody> </Table.Thead>
{allRealisasiRows.map(({ realisasi, parentItem }) => { <Table.Tbody>
const persentase = parentItem.anggaran > 0 {allRealisasiRows.map(({ realisasi, parentItem }) => {
? (realisasi.jumlah / parentItem.anggaran) * 100 const persentase = parentItem.anggaran > 0
: 0; ? (realisasi.jumlah / parentItem.anggaran) * 100
: 0;
return ( return (
<Table.Tr key={realisasi.id}> <Table.Tr key={realisasi.id}>
<Table.Td> <Table.Td>
<Text>{realisasi.kode || '-'} - {realisasi.keterangan || '-'}</Text> <Text fz={{ base: 'xs', sm: 'sm' }} lineClamp={2}>
</Table.Td> {realisasi.kode || '-'} - {realisasi.keterangan || '-'}
<Table.Td ta="right"> </Text>
<Text fw={600} c="blue"> </Table.Td>
{formatRupiah(realisasi.jumlah || 0)} <Table.Td ta="right">
</Text> <Text fw={600} c="blue" fz={{ base: 'xs', sm: 'sm' }} style={{ whiteSpace: 'nowrap' }}>
</Table.Td> {formatRupiah(realisasi.jumlah || 0)}
<Table.Td ta="center"> </Text>
<Badge </Table.Td>
color={ <Table.Td ta="center">
persentase >= 100 <Badge
? 'teal' size="sm"
: persentase >= 60 variant="light"
? 'yellow' color={
: 'red' persentase >= 100
} ? 'teal'
> : persentase >= 60
{persentase.toFixed(2)}% ? 'yellow'
</Badge> : 'red'
</Table.Td> }
</Table.Tr> >
); {persentase.toFixed(1)}%
})} </Badge>
</Table.Tbody> </Table.Td>
</Table> </Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)} )}
</Paper> </Paper>
); );

View File

@@ -1,5 +1,5 @@
import "@mantine/core/styles.css"; import "@mantine/core/styles.css";
import "./globals.css"; import "./globals.css"; // Sisanya import di globals.css
import LoadDataFirstClient from "@/app/darmasaba/_com/LoadDataFirstClient"; import LoadDataFirstClient from "@/app/darmasaba/_com/LoadDataFirstClient";
import { MusicProvider } from "@/app/context/MusicContext"; import { MusicProvider } from "@/app/context/MusicContext";
@@ -8,6 +8,7 @@ import {
MantineProvider, MantineProvider,
createTheme, createTheme,
mantineHtmlProps, mantineHtmlProps,
// mantineHtmlProps,
} from "@mantine/core"; } from "@mantine/core";
import { Metadata, Viewport } from "next"; import { Metadata, Viewport } from "next";
import { ViewTransitions } from "next-view-transitions"; import { ViewTransitions } from "next-view-transitions";

View File

@@ -17,6 +17,7 @@ function Page() {
</Text> </Text>
</Paper> </Paper>
<Box> <Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md"> <Title order={2} size="h2" fw={700} c="blue.9" mb="md">
1. Definisi 1. Definisi