Compare commits
76 Commits
test-model
...
fix-respon
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bc546e985 | |||
| 4fb522f88f | |||
| 85332a8225 | |||
| 3fe2a5ccab | |||
| 363bfa65fb | |||
| dccf590cbf | |||
| f076b81d14 | |||
| b5ea3216e0 | |||
| 64b116588b | |||
| 63161e1a39 | |||
| 8b8c65dd1e | |||
| 159fb3cec6 | |||
| 4821934224 | |||
| ee39b88b00 | |||
| ce46d3b5f7 | |||
| 144ac37e12 | |||
| f90477ed63 | |||
| 4a7811e06f | |||
| f63aaf916d | |||
| 3803c79c95 | |||
| 2d901912ea | |||
| a791efe76c | |||
| e9f7bc2043 | |||
| 0160fa636d | |||
| 3684e83187 | |||
| 77c54b5c8a | |||
| bb80b0ecc1 | |||
| 1b59d6bf09 | |||
| eb1ad54db6 | |||
| 21ec3ad1c1 | |||
| 3a115908c4 | |||
| 5ff791642c | |||
| b803c7a90c | |||
| fb2fe67c23 | |||
| 51460558d4 | |||
| d105ceeb6b | |||
| c865aee766 | |||
| 273dfdfd09 | |||
| 1d1d8e50dc | |||
| 092afe67d2 | |||
| 2d9170705d | |||
| fdf9a951a4 | |||
| ca74029688 | |||
| 1a8fc1a670 | |||
| 19235f0791 | |||
| 61de7d8d33 | |||
| 8fb85ce56c | |||
| 1f98b6993d | |||
| f3a10d63d1 | |||
| 7a42bec63b | |||
| 44c421129e | |||
| ddff427926 | |||
| 00c8caade4 | |||
| 0209f49449 | |||
| 344c6ada6d | |||
| 11acd04419 | |||
| 8d49213b68 | |||
| 96911e3cf1 | |||
| 9950c28b9b | |||
| fa0f3538d1 | |||
| 2778f53aff | |||
| 37ac91d4f4 | |||
| 217f4a9a3b | |||
| 5d6a7437ed | |||
| 752a6cabee | |||
| 134ddc6154 | |||
| 28979c6b49 | |||
| b2066caa13 | |||
| 023c77d636 | |||
| 9bf3ec72cf | |||
| f359f5b1ce | |||
| 1c1e8fb190 | |||
| 54f83da3b8 | |||
| f8985c550f | |||
| e3d909e760 | |||
| 16a8df50c1 |
73
AUDIT_REPORT.md
Normal file
73
AUDIT_REPORT.md
Normal 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.).
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -238,19 +238,21 @@ model APBDesItem {
|
|||||||
// Model baru untuk multiple realisasi per item
|
// Model baru untuk multiple realisasi per item
|
||||||
model RealisasiItem {
|
model RealisasiItem {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
kode String? // Kode realisasi, mirip dengan APBDesItem
|
||||||
apbdesItemId String
|
apbdesItemId String
|
||||||
apbdesItem APBDesItem @relation(fields: [apbdesItemId], references: [id], onDelete: Cascade)
|
apbdesItem APBDesItem @relation(fields: [apbdesItemId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
jumlah Float // Jumlah realisasi dalam Rupiah
|
jumlah Float // Jumlah realisasi dalam Rupiah
|
||||||
tanggal DateTime @db.Date // Tanggal realisasi
|
tanggal DateTime @db.Date // Tanggal realisasi
|
||||||
keterangan String? @db.Text // Keterangan tambahan (opsional)
|
keterangan String? @db.Text // Keterangan tambahan (opsional)
|
||||||
buktiFileId String? // FileStorage ID untuk bukti/foto (opsional)
|
buktiFileId String? // FileStorage ID untuk bukti/foto (opsional)
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
@@index([kode])
|
||||||
@@index([apbdesItemId])
|
@@index([apbdesItemId])
|
||||||
@@index([tanggal])
|
@@index([tanggal])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,21 +5,17 @@ import { toast } from "react-toastify";
|
|||||||
import { proxy } from "valtio";
|
import { proxy } from "valtio";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
// --- Zod Schema untuk APBDes Item (tanpa field kalkulasi) ---
|
// --- Zod Schema untuk APBDes Item (dengan field kalkulasi) ---
|
||||||
const ApbdesItemSchema = z.object({
|
const ApbdesItemSchema = z.object({
|
||||||
kode: z.string().min(1, "Kode wajib diisi"),
|
kode: z.string().min(1, "Kode wajib diisi"),
|
||||||
uraian: z.string().min(1, "Uraian wajib diisi"),
|
uraian: z.string().min(1, "Uraian wajib diisi"),
|
||||||
anggaran: z.number().min(0, "Anggaran tidak boleh negatif"),
|
anggaran: z.number().min(0, "Anggaran tidak boleh negatif"),
|
||||||
level: z.number().int().min(1).max(3),
|
level: z.number().int().min(1).max(3),
|
||||||
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
|
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
|
||||||
});
|
// Field kalkulasi dari realisasiItems (auto-calculated di backend)
|
||||||
|
realisasi: z.number().min(0).default(0),
|
||||||
// --- Zod Schema untuk Realisasi Item ---
|
selisih: z.number().default(0),
|
||||||
const RealisasiItemSchema = z.object({
|
persentase: z.number().default(0),
|
||||||
jumlah: z.number().min(0, "Jumlah tidak boleh negatif"),
|
|
||||||
tanggal: z.string(),
|
|
||||||
keterangan: z.string().optional(),
|
|
||||||
buktiFileId: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const ApbdesFormSchema = z.object({
|
const ApbdesFormSchema = z.object({
|
||||||
@@ -27,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"),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,7 +40,7 @@ const defaultApbdesForm = {
|
|||||||
items: [] as z.infer<typeof ApbdesItemSchema>[],
|
items: [] as z.infer<typeof ApbdesItemSchema>[],
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Helper: Normalize item (tanpa kalkulasi, backend yang hitung) ---
|
// --- Helper: Normalize item (dengan field kalkulasi) ---
|
||||||
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
|
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
|
||||||
return {
|
return {
|
||||||
kode: item.kode || "",
|
kode: item.kode || "",
|
||||||
@@ -51,6 +48,9 @@ function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer
|
|||||||
anggaran: item.anggaran ?? 0,
|
anggaran: item.anggaran ?? 0,
|
||||||
level: item.level || 1,
|
level: item.level || 1,
|
||||||
tipe: item.tipe ?? null,
|
tipe: item.tipe ?? null,
|
||||||
|
realisasi: item.realisasi ?? 0,
|
||||||
|
selisih: item.selisih ?? 0,
|
||||||
|
persentase: item.persentase ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ const apbdes = proxy({
|
|||||||
findMany: {
|
findMany: {
|
||||||
data: null as
|
data: null as
|
||||||
| Prisma.APBDesGetPayload<{
|
| Prisma.APBDesGetPayload<{
|
||||||
include: { image: true; file: true; items: true };
|
include: { image: true; file: true; items: { include: { realisasiItems: true } } };
|
||||||
}>[]
|
}>[]
|
||||||
| null,
|
| null,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -157,33 +157,37 @@ const apbdes = proxy({
|
|||||||
findUnique: {
|
findUnique: {
|
||||||
data: null as
|
data: null as
|
||||||
| Prisma.APBDesGetPayload<{
|
| Prisma.APBDesGetPayload<{
|
||||||
include: { image: true; file: true; items: true };
|
include: { image: true; file: true; items: { include: { realisasiItems: true } } };
|
||||||
}>
|
}>
|
||||||
| null,
|
| null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null as string | null,
|
error: null as string | null,
|
||||||
|
|
||||||
async load(id: string) {
|
async load(id: string) {
|
||||||
if (!id || id.trim() === '') {
|
if (!id || id.trim() === '') {
|
||||||
this.data = null;
|
this.data = null;
|
||||||
this.error = "ID tidak valid";
|
this.error = "ID tidak valid";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent multiple simultaneous loads
|
||||||
|
if (this.loading) {
|
||||||
|
console.log("⚠️ Already loading, skipping...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Pastikan URL-nya benar
|
|
||||||
const url = `/api/landingpage/apbdes/${id}`;
|
const url = `/api/landingpage/apbdes/${id}`;
|
||||||
console.log("🌐 Fetching:", url);
|
console.log("🌐 Fetching:", url);
|
||||||
|
|
||||||
// Gunakan fetch biasa atau ApiFetch dengan cara yang benar
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const res = await response.json();
|
const res = await response.json();
|
||||||
|
|
||||||
console.log("📦 Response:", res);
|
console.log("📦 Response:", res);
|
||||||
|
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
this.data = res.data;
|
this.data = res.data;
|
||||||
} else {
|
} else {
|
||||||
@@ -252,6 +256,9 @@ const apbdes = proxy({
|
|||||||
kode: item.kode,
|
kode: item.kode,
|
||||||
uraian: item.uraian,
|
uraian: item.uraian,
|
||||||
anggaran: item.anggaran,
|
anggaran: item.anggaran,
|
||||||
|
realisasi: item.totalRealisasi || 0,
|
||||||
|
selisih: item.selisih || 0,
|
||||||
|
persentase: item.persentase || 0,
|
||||||
level: item.level,
|
level: item.level,
|
||||||
tipe: item.tipe || 'pendapatan',
|
tipe: item.tipe || 'pendapatan',
|
||||||
})),
|
})),
|
||||||
@@ -279,11 +286,24 @@ const apbdes = proxy({
|
|||||||
try {
|
try {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
// Include the ID in the request body
|
// Include the ID in the request body
|
||||||
|
// Omit realisasi, selisih, persentase karena itu calculated fields di backend
|
||||||
const requestData = {
|
const requestData = {
|
||||||
...parsed.data,
|
tahun: parsed.data.tahun,
|
||||||
id: this.id, // Add the ID to the request body
|
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);
|
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
|
||||||
|
|
||||||
if (res.data?.success) {
|
if (res.data?.success) {
|
||||||
@@ -322,15 +342,16 @@ const apbdes = proxy({
|
|||||||
// =========================================
|
// =========================================
|
||||||
realisasi: {
|
realisasi: {
|
||||||
// Create realisasi
|
// Create realisasi
|
||||||
async create(itemId: string, data: { jumlah: number; tanggal: string; keterangan?: string; buktiFileId?: string }) {
|
async create(itemId: string, data: { kode: string; jumlah: number; tanggal: string; keterangan?: string; buktiFileId?: string }) {
|
||||||
try {
|
try {
|
||||||
const res = await (ApiFetch.api.landingpage.apbdes as any)[itemId].realisasi.post(data);
|
const res = await (ApiFetch.api.landingpage.apbdes as any)[itemId].realisasi.post(data);
|
||||||
|
|
||||||
if (res.data?.success) {
|
if (res.data?.success) {
|
||||||
toast.success("Realisasi berhasil ditambahkan");
|
toast.success("Realisasi berhasil ditambahkan");
|
||||||
// Reload findUnique untuk update data
|
// Reload findUnique untuk update data
|
||||||
if (apbdes.findUnique.data) {
|
const currentId = apbdes.findUnique.data?.id;
|
||||||
await apbdes.findUnique.load(apbdes.findUnique.data.id);
|
if (currentId) {
|
||||||
|
await apbdes.findUnique.load(currentId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
@@ -345,15 +366,16 @@ const apbdes = proxy({
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Update realisasi
|
// Update realisasi
|
||||||
async update(realisasiId: string, data: { jumlah?: number; tanggal?: string; keterangan?: string; buktiFileId?: string }) {
|
async update(realisasiId: string, data: { kode?: string; jumlah?: number; tanggal?: string; keterangan?: string; buktiFileId?: string }) {
|
||||||
try {
|
try {
|
||||||
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].put(data);
|
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].put(data);
|
||||||
|
|
||||||
if (res.data?.success) {
|
if (res.data?.success) {
|
||||||
toast.success("Realisasi berhasil diperbarui");
|
toast.success("Realisasi berhasil diperbarui");
|
||||||
// Reload findUnique untuk update data
|
// Reload findUnique untuk update data
|
||||||
if (apbdes.findUnique.data) {
|
const currentId = apbdes.findUnique.data?.id;
|
||||||
await apbdes.findUnique.load(apbdes.findUnique.data.id);
|
if (currentId) {
|
||||||
|
await apbdes.findUnique.load(currentId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
import { useProxy } from 'valtio/utils';
|
import { useProxy } from 'valtio/utils';
|
||||||
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
|
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -23,7 +25,6 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Modal,
|
Modal,
|
||||||
Divider,
|
Divider,
|
||||||
Loader,
|
|
||||||
Center,
|
Center,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
@@ -33,9 +34,6 @@ import {
|
|||||||
IconCalendar,
|
IconCalendar,
|
||||||
IconCoin,
|
IconCoin,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useState } from 'react';
|
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
import colors from '@/con/colors';
|
|
||||||
|
|
||||||
interface RealisasiManagerProps {
|
interface RealisasiManagerProps {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -63,6 +61,7 @@ export default function RealisasiManager({
|
|||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
|
kode: '',
|
||||||
jumlah: 0,
|
jumlah: 0,
|
||||||
tanggal: new Date().toISOString().split('T')[0], // YYYY-MM-DD format for input
|
tanggal: new Date().toISOString().split('T')[0], // YYYY-MM-DD format for input
|
||||||
keterangan: '',
|
keterangan: '',
|
||||||
@@ -70,6 +69,7 @@ export default function RealisasiManager({
|
|||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setFormData({
|
setFormData({
|
||||||
|
kode: '',
|
||||||
jumlah: 0,
|
jumlah: 0,
|
||||||
tanggal: new Date().toISOString().split('T')[0],
|
tanggal: new Date().toISOString().split('T')[0],
|
||||||
keterangan: '',
|
keterangan: '',
|
||||||
@@ -85,8 +85,9 @@ export default function RealisasiManager({
|
|||||||
const handleOpenEdit = (realisasi: any) => {
|
const handleOpenEdit = (realisasi: any) => {
|
||||||
const tanggal = new Date(realisasi.tanggal);
|
const tanggal = new Date(realisasi.tanggal);
|
||||||
const tanggalStr = tanggal.toISOString().split('T')[0]; // YYYY-MM-DD
|
const tanggalStr = tanggal.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
|
kode: realisasi.kode || '',
|
||||||
jumlah: realisasi.jumlah,
|
jumlah: realisasi.jumlah,
|
||||||
tanggal: tanggalStr,
|
tanggal: tanggalStr,
|
||||||
keterangan: realisasi.keterangan || '',
|
keterangan: realisasi.keterangan || '',
|
||||||
@@ -100,12 +101,17 @@ export default function RealisasiManager({
|
|||||||
return toast.warn('Jumlah realisasi harus lebih dari 0');
|
return toast.warn('Jumlah realisasi harus lebih dari 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!formData.kode || formData.kode.trim() === '') {
|
||||||
|
return toast.warn('Kode realisasi wajib diisi');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
// Update existing realisasi
|
// Update existing realisasi
|
||||||
const success = await state.realisasi.update(editingId, {
|
const success = await state.realisasi.update(editingId, {
|
||||||
|
kode: formData.kode,
|
||||||
jumlah: formData.jumlah,
|
jumlah: formData.jumlah,
|
||||||
tanggal: new Date(formData.tanggal).toISOString(),
|
tanggal: new Date(formData.tanggal).toISOString(),
|
||||||
keterangan: formData.keterangan,
|
keterangan: formData.keterangan,
|
||||||
@@ -117,6 +123,7 @@ export default function RealisasiManager({
|
|||||||
} else {
|
} else {
|
||||||
// Create new realisasi
|
// Create new realisasi
|
||||||
const success = await state.realisasi.create(itemId, {
|
const success = await state.realisasi.create(itemId, {
|
||||||
|
kode: formData.kode,
|
||||||
jumlah: formData.jumlah,
|
jumlah: formData.jumlah,
|
||||||
tanggal: new Date(formData.tanggal).toISOString(),
|
tanggal: new Date(formData.tanggal).toISOString(),
|
||||||
keterangan: formData.keterangan,
|
keterangan: formData.keterangan,
|
||||||
@@ -257,6 +264,7 @@ export default function RealisasiManager({
|
|||||||
<Table striped highlightOnHover fz="sm">
|
<Table striped highlightOnHover fz="sm">
|
||||||
<TableThead>
|
<TableThead>
|
||||||
<TableTr>
|
<TableTr>
|
||||||
|
<TableTh>Kode</TableTh>
|
||||||
<TableTh>Tanggal</TableTh>
|
<TableTh>Tanggal</TableTh>
|
||||||
<TableTh>Uraian</TableTh>
|
<TableTh>Uraian</TableTh>
|
||||||
<TableTh ta="right">Jumlah</TableTh>
|
<TableTh ta="right">Jumlah</TableTh>
|
||||||
@@ -266,6 +274,11 @@ export default function RealisasiManager({
|
|||||||
<TableTbody>
|
<TableTbody>
|
||||||
{realisasiItems.map((realisasi) => (
|
{realisasiItems.map((realisasi) => (
|
||||||
<TableTr key={realisasi.id}>
|
<TableTr key={realisasi.id}>
|
||||||
|
<TableTd>
|
||||||
|
<Badge variant="light" color="blue" size="sm">
|
||||||
|
{realisasi.kode || '-'}
|
||||||
|
</Badge>
|
||||||
|
</TableTd>
|
||||||
<TableTd>
|
<TableTd>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<IconCalendar size={16} />
|
<IconCalendar size={16} />
|
||||||
@@ -314,7 +327,7 @@ export default function RealisasiManager({
|
|||||||
Belum ada realisasi untuk item ini
|
Belum ada realisasi untuk item ini
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz="xs" c="dimmed">
|
<Text fz="xs" c="dimmed">
|
||||||
Klik tombol "Tambah Realisasi" untuk menambahkan
|
Klik tombol "Tambah Realisasi" untuk menambahkan
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
@@ -349,6 +362,15 @@ export default function RealisasiManager({
|
|||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</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
|
<NumberInput
|
||||||
label="Jumlah Realisasi (Rp)"
|
label="Jumlah Realisasi (Rp)"
|
||||||
value={formData.jumlah}
|
value={formData.jumlah}
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ type ItemForm = {
|
|||||||
anggaran: number;
|
anggaran: number;
|
||||||
level: number;
|
level: number;
|
||||||
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
|
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
|
||||||
|
realisasi?: number;
|
||||||
|
selisih?: number;
|
||||||
|
persentase?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function EditAPBDes() {
|
function EditAPBDes() {
|
||||||
@@ -72,6 +75,9 @@ function EditAPBDes() {
|
|||||||
anggaran: 0,
|
anggaran: 0,
|
||||||
level: 1,
|
level: 1,
|
||||||
tipe: 'pendapatan',
|
tipe: 'pendapatan',
|
||||||
|
realisasi: 0,
|
||||||
|
selisih: 0,
|
||||||
|
persentase: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simpan data original untuk reset form
|
// Simpan data original untuk reset form
|
||||||
@@ -125,9 +131,9 @@ function EditAPBDes() {
|
|||||||
kode: item.kode,
|
kode: item.kode,
|
||||||
uraian: item.uraian,
|
uraian: item.uraian,
|
||||||
anggaran: item.anggaran,
|
anggaran: item.anggaran,
|
||||||
realisasi: item.realisasi,
|
realisasi: item.totalRealisasi || 0,
|
||||||
selisih: item.selisih,
|
selisih: item.selisih || 0,
|
||||||
persentase: item.persentase,
|
persentase: item.persentase || 0,
|
||||||
level: item.level,
|
level: item.level,
|
||||||
tipe: item.tipe || 'pendapatan',
|
tipe: item.tipe || 'pendapatan',
|
||||||
})),
|
})),
|
||||||
@@ -155,7 +161,7 @@ function EditAPBDes() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddItem = () => {
|
const handleAddItem = () => {
|
||||||
const { kode, uraian, anggaran, level, tipe } = newItem;
|
const { kode, uraian, anggaran, level, tipe, realisasi, selisih, persentase } = newItem;
|
||||||
if (!kode || !uraian) {
|
if (!kode || !uraian) {
|
||||||
return toast.warn('Kode dan uraian wajib diisi');
|
return toast.warn('Kode dan uraian wajib diisi');
|
||||||
}
|
}
|
||||||
@@ -166,6 +172,9 @@ function EditAPBDes() {
|
|||||||
kode,
|
kode,
|
||||||
uraian,
|
uraian,
|
||||||
anggaran,
|
anggaran,
|
||||||
|
realisasi: realisasi || 0,
|
||||||
|
selisih: selisih || 0,
|
||||||
|
persentase: persentase || 0,
|
||||||
level,
|
level,
|
||||||
tipe: finalTipe,
|
tipe: finalTipe,
|
||||||
});
|
});
|
||||||
@@ -176,6 +185,9 @@ function EditAPBDes() {
|
|||||||
anggaran: 0,
|
anggaran: 0,
|
||||||
level: 1,
|
level: 1,
|
||||||
tipe: 'pendapatan',
|
tipe: 'pendapatan',
|
||||||
|
realisasi: 0,
|
||||||
|
selisih: 0,
|
||||||
|
persentase: 0,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -193,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,
|
||||||
@@ -205,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,
|
||||||
@@ -216,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');
|
||||||
@@ -264,6 +266,9 @@ function EditAPBDes() {
|
|||||||
anggaran: 0,
|
anggaran: 0,
|
||||||
level: 1,
|
level: 1,
|
||||||
tipe: 'pendapatan',
|
tipe: 'pendapatan',
|
||||||
|
realisasi: 0,
|
||||||
|
selisih: 0,
|
||||||
|
persentase: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.info('Form dikembalikan ke data awal');
|
toast.info('Form dikembalikan ke data awal');
|
||||||
@@ -328,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')}
|
||||||
@@ -372,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} />
|
||||||
@@ -382,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')}
|
||||||
@@ -431,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} />
|
||||||
@@ -527,6 +534,9 @@ function EditAPBDes() {
|
|||||||
<th>Kode</th>
|
<th>Kode</th>
|
||||||
<th>Uraian</th>
|
<th>Uraian</th>
|
||||||
<th>Anggaran</th>
|
<th>Anggaran</th>
|
||||||
|
<th>Realisasi</th>
|
||||||
|
<th>Selisih</th>
|
||||||
|
<th>%</th>
|
||||||
<th>Level</th>
|
<th>Level</th>
|
||||||
<th>Tipe</th>
|
<th>Tipe</th>
|
||||||
<th style={{ width: '50px' }}>Aksi</th>
|
<th style={{ width: '50px' }}>Aksi</th>
|
||||||
@@ -542,6 +552,11 @@ function EditAPBDes() {
|
|||||||
</td>
|
</td>
|
||||||
<td>{item.uraian}</td>
|
<td>{item.uraian}</td>
|
||||||
<td>{item.anggaran.toLocaleString('id-ID')}</td>
|
<td>{item.anggaran.toLocaleString('id-ID')}</td>
|
||||||
|
<td>{item.realisasi?.toLocaleString('id-ID') || '0'}</td>
|
||||||
|
<td style={{ color: item.selisih && item.selisih > 0 ? 'red' : 'green' }}>
|
||||||
|
{item.selisih?.toLocaleString('id-ID') || '0'}
|
||||||
|
</td>
|
||||||
|
<td>{item.persentase?.toFixed(2) || '0'}%</td>
|
||||||
<td>
|
<td>
|
||||||
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
|
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
|
||||||
L{item.level}
|
L{item.level}
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ function DetailAPBDes() {
|
|||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{/* Realisasi Manager untuk setiap item */}
|
{/* Realisasi Manager untuk setiap item */}
|
||||||
{data.items.map((item: any) => (
|
{data.items.map((item) => (
|
||||||
<RealisasiManager
|
<RealisasiManager
|
||||||
key={item.id}
|
key={item.id}
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
25
src/app/admin/(dashboard)/user&role/_com/getMenuIdByRole.ts
Normal file
25
src/app/admin/(dashboard)/user&role/_com/getMenuIdByRole.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -69,6 +69,7 @@ const APBDes = new Elysia({
|
|||||||
.post("/:itemId/realisasi", realisasiCreate, {
|
.post("/:itemId/realisasi", realisasiCreate, {
|
||||||
params: t.Object({ itemId: t.String() }),
|
params: t.Object({ itemId: t.String() }),
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
|
kode: t.String(),
|
||||||
jumlah: t.Number(),
|
jumlah: t.Number(),
|
||||||
tanggal: t.String(),
|
tanggal: t.String(),
|
||||||
keterangan: t.Optional(t.String()),
|
keterangan: t.Optional(t.String()),
|
||||||
@@ -80,6 +81,7 @@ const APBDes = new Elysia({
|
|||||||
.put("/realisasi/:realisasiId", realisasiUpdate, {
|
.put("/realisasi/:realisasiId", realisasiUpdate, {
|
||||||
params: t.Object({ realisasiId: t.String() }),
|
params: t.Object({ realisasiId: t.String() }),
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
|
kode: t.Optional(t.String()),
|
||||||
jumlah: t.Optional(t.Number()),
|
jumlah: t.Optional(t.Number()),
|
||||||
tanggal: t.Optional(t.String()),
|
tanggal: t.Optional(t.String()),
|
||||||
keterangan: t.Optional(t.String()),
|
keterangan: t.Optional(t.String()),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import prisma from "@/lib/prisma";
|
|||||||
import { Context } from "elysia";
|
import { Context } from "elysia";
|
||||||
|
|
||||||
type RealisasiCreateBody = {
|
type RealisasiCreateBody = {
|
||||||
|
kode: string;
|
||||||
jumlah: number;
|
jumlah: number;
|
||||||
tanggal: string; // ISO format
|
tanggal: string; // ISO format
|
||||||
keterangan?: string;
|
keterangan?: string;
|
||||||
@@ -33,6 +34,7 @@ export default async function realisasiCreate(context: Context) {
|
|||||||
const realisasi = await prisma.realisasiItem.create({
|
const realisasi = await prisma.realisasiItem.create({
|
||||||
data: {
|
data: {
|
||||||
apbdesItemId: itemId,
|
apbdesItemId: itemId,
|
||||||
|
kode: body.kode,
|
||||||
jumlah: body.jumlah,
|
jumlah: body.jumlah,
|
||||||
tanggal: new Date(body.tanggal),
|
tanggal: new Date(body.tanggal),
|
||||||
keterangan: body.keterangan,
|
keterangan: body.keterangan,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import prisma from "@/lib/prisma";
|
|||||||
import { Context } from "elysia";
|
import { Context } from "elysia";
|
||||||
|
|
||||||
type RealisasiUpdateBody = {
|
type RealisasiUpdateBody = {
|
||||||
|
kode?: string;
|
||||||
jumlah?: number;
|
jumlah?: number;
|
||||||
tanggal?: string;
|
tanggal?: string;
|
||||||
keterangan?: string;
|
keterangan?: string;
|
||||||
@@ -33,6 +34,7 @@ export default async function realisasiUpdate(context: Context) {
|
|||||||
const updated = await prisma.realisasiItem.update({
|
const updated = await prisma.realisasiItem.update({
|
||||||
where: { id: realisasiId },
|
where: { id: realisasiId },
|
||||||
data: {
|
data: {
|
||||||
|
...(body.kode !== undefined && { kode: body.kode }),
|
||||||
...(body.jumlah !== undefined && { jumlah: body.jumlah }),
|
...(body.jumlah !== undefined && { jumlah: body.jumlah }),
|
||||||
...(body.tanggal !== undefined && { tanggal: new Date(body.tanggal) }),
|
...(body.tanggal !== undefined && { tanggal: new Date(body.tanggal) }),
|
||||||
...(body.keterangan !== undefined && { keterangan: body.keterangan }),
|
...(body.keterangan !== undefined && { keterangan: body.keterangan }),
|
||||||
|
|||||||
@@ -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[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,6 +29,16 @@ export default async function apbdesUpdate(context: Context) {
|
|||||||
// 1. Pastikan APBDes ada
|
// 1. Pastikan APBDes ada
|
||||||
const existing = await prisma.aPBDes.findUnique({
|
const existing = await prisma.aPBDes.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
where: { isActive: true },
|
||||||
|
include: {
|
||||||
|
realisasiItems: {
|
||||||
|
where: { isActive: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -38,17 +49,35 @@ export default async function apbdesUpdate(context: Context) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Hapus semua item lama (cascade akan menghapus realisasiItems juga)
|
// 2. Build map untuk preserve realisasiItems berdasarkan kode
|
||||||
|
const existingItemsMap = new Map<string, {
|
||||||
|
id: string;
|
||||||
|
realisasiItems: RealisasiItem[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
existing.items.forEach(item => {
|
||||||
|
existingItemsMap.set(item.kode, {
|
||||||
|
id: item.id,
|
||||||
|
realisasiItems: item.realisasiItems,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Hapus semua item lama (cascade akan menghapus realisasiItems juga)
|
||||||
|
// TAPI kita sudah save realisasiItems di map atas
|
||||||
await prisma.aPBDesItem.deleteMany({
|
await prisma.aPBDesItem.deleteMany({
|
||||||
where: { apbdesId: id },
|
where: { apbdesId: id },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Buat item baru dengan auto-calculate fields
|
// 4. Buat item baru dengan preserve realisasiItems
|
||||||
await prisma.aPBDesItem.createMany({
|
await prisma.aPBDesItem.createMany({
|
||||||
data: body.items.map((item) => {
|
data: await Promise.all(body.items.map(async (item) => {
|
||||||
const anggaran = item.anggaran;
|
const anggaran = item.anggaran;
|
||||||
const totalRealisasi = 0; // Reset karena items baru
|
|
||||||
const selisih = anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
|
// Check apakah item ini punya realisasiItems lama
|
||||||
|
const existingItem = existingItemsMap.get(item.kode);
|
||||||
|
const realisasiItemsData = existingItem?.realisasiItems || [];
|
||||||
|
const totalRealisasi = realisasiItemsData.reduce((sum, r) => sum + r.jumlah, 0);
|
||||||
|
const selisih = anggaran - totalRealisasi;
|
||||||
const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0;
|
const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -63,16 +92,68 @@ export default async function apbdesUpdate(context: Context) {
|
|||||||
persentase,
|
persentase,
|
||||||
isActive: true,
|
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({
|
const allItems = await prisma.aPBDesItem.findMany({
|
||||||
where: { apbdesId: id },
|
where: { apbdesId: id },
|
||||||
select: { id: true, kode: true },
|
select: { id: true, kode: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Update parentId untuk setiap item
|
// 6. Build map baru untuk item IDs
|
||||||
|
const newItemIdsMap = new Map<string, string>();
|
||||||
|
allItems.forEach(item => {
|
||||||
|
newItemIdsMap.set(item.kode, item.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Re-create realisasiItems dengan link ke item IDs yang baru
|
||||||
|
for (const [oldKode, oldItemData] of existingItemsMap.entries()) {
|
||||||
|
if (oldItemData.realisasiItems.length > 0) {
|
||||||
|
const newItemId = newItemIdsMap.get(oldKode);
|
||||||
|
if (newItemId) {
|
||||||
|
// Re-create realisasiItems untuk item ini
|
||||||
|
await prisma.realisasiItem.createMany({
|
||||||
|
data: oldItemData.realisasiItems.map(r => ({
|
||||||
|
apbdesItemId: newItemId,
|
||||||
|
kode: r.kode,
|
||||||
|
jumlah: r.jumlah,
|
||||||
|
tanggal: r.tanggal,
|
||||||
|
keterangan: r.keterangan,
|
||||||
|
buktiFileId: r.buktiFileId,
|
||||||
|
isActive: true,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Recalculate totalRealisasi setelah re-create realisasiItems
|
||||||
|
for (const kode of existingItemsMap.keys()) {
|
||||||
|
const newItemId = newItemIdsMap.get(kode);
|
||||||
|
if (newItemId) {
|
||||||
|
const realisasiItems = await prisma.realisasiItem.findMany({
|
||||||
|
where: { apbdesItemId: newItemId, isActive: true },
|
||||||
|
});
|
||||||
|
const totalRealisasi = realisasiItems.reduce((sum, r) => sum + r.jumlah, 0);
|
||||||
|
|
||||||
|
const item = await prisma.aPBDesItem.findUnique({
|
||||||
|
where: { id: newItemId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
const selisih = item.anggaran - totalRealisasi;
|
||||||
|
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
|
||||||
|
|
||||||
|
await prisma.aPBDesItem.update({
|
||||||
|
where: { id: newItemId },
|
||||||
|
data: { totalRealisasi, selisih, persentase },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Update parentId untuk setiap item
|
||||||
const itemsForParentUpdate = allItems.map(item => ({
|
const itemsForParentUpdate = allItems.map(item => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
kode: item.kode,
|
kode: item.kode,
|
||||||
@@ -80,7 +161,7 @@ export default async function apbdesUpdate(context: Context) {
|
|||||||
|
|
||||||
await assignParentIdsToApbdesItems(itemsForParentUpdate);
|
await assignParentIdsToApbdesItems(itemsForParentUpdate);
|
||||||
|
|
||||||
// 6. Update data APBDes
|
// 10. Update data APBDes
|
||||||
await prisma.aPBDes.update({
|
await prisma.aPBDes.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -88,12 +169,12 @@ 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 7. Ambil data lengkap untuk response (include realisasiItems)
|
// 11. Ambil data lengkap untuk response (include realisasiItems)
|
||||||
const result = await prisma.aPBDes.findUnique({
|
const result = await prisma.aPBDes.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
320
src/app/context/MusicContext.tsx
Normal file
320
src/app/context/MusicContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,77 +1,41 @@
|
|||||||
'use client'
|
'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 { 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 { 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 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 MusicPlayer = () => {
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const {
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
isPlaying,
|
||||||
const [duration, setDuration] = useState(0);
|
currentSong,
|
||||||
const [volume, setVolume] = useState(70);
|
currentTime,
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
duration,
|
||||||
const [isRepeat, setIsRepeat] = useState(false);
|
volume,
|
||||||
const [isShuffle, setIsShuffle] = useState(false);
|
isMuted,
|
||||||
|
isRepeat,
|
||||||
|
isShuffle,
|
||||||
|
isLoading,
|
||||||
|
musikData,
|
||||||
|
playSong,
|
||||||
|
togglePlayPause,
|
||||||
|
playNext,
|
||||||
|
playPrev,
|
||||||
|
seek,
|
||||||
|
setVolume,
|
||||||
|
toggleMute,
|
||||||
|
toggleRepeat,
|
||||||
|
toggleShuffle,
|
||||||
|
} = useMusic();
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
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
|
// Fetch musik data from global state
|
||||||
useAudioProgress(audioRef as React.RefObject<HTMLAudioElement>, isPlaying, setCurrentTime, isSeekingRef);
|
const { loadMusikData } = useMusic();
|
||||||
|
|
||||||
// Fetch musik data from API
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchMusik = async () => {
|
loadMusikData();
|
||||||
try {
|
}, [loadMusikData]);
|
||||||
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();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Filter musik based on search - gunakan useMemo untuk mencegah re-calculate setiap render
|
// Filter musik based on search - gunakan useMemo untuk mencegah re-calculate setiap render
|
||||||
const filteredMusik = useMemo(() => {
|
const filteredMusik = useMemo(() => {
|
||||||
@@ -82,146 +46,42 @@ const MusicPlayer = () => {
|
|||||||
);
|
);
|
||||||
}, [musikData, search]);
|
}, [musikData, search]);
|
||||||
|
|
||||||
const currentSong = currentSongIndex >= 0 && currentSongIndex < filteredMusik.length
|
// Format time helper
|
||||||
? 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
|
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
const formatTime = (seconds: number) => {
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
const secs = Math.floor(seconds % 60);
|
const secs = Math.floor(seconds % 60);
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const playSong = (index: number) => {
|
const handleVolumeChange = (value: number) => {
|
||||||
if (index < 0 || index >= filteredMusik.length) return;
|
setVolume(value);
|
||||||
|
|
||||||
setCurrentSongIndex(index);
|
|
||||||
setIsPlaying(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSongEnd = () => {
|
const toggleMuteHandler = () => {
|
||||||
const playNext = () => {
|
toggleMute();
|
||||||
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 togglePlayPauseHandler = () => {
|
const togglePlayPauseHandler = () => {
|
||||||
if (!currentSong) return;
|
togglePlayPause();
|
||||||
togglePlayPause(audioRef, isPlaying, setIsPlaying);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
const skipBack = () => {
|
||||||
|
playPrev();
|
||||||
|
};
|
||||||
|
|
||||||
|
const skipForward = () => {
|
||||||
|
playNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleShuffleHandler = () => {
|
||||||
|
toggleShuffle();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRepeatHandler = () => {
|
||||||
|
toggleRepeat();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 'md', md: 100 }} py="xl">
|
<Box px={{ base: 'md', md: 100 }} py="xl">
|
||||||
<Paper mx="auto" p="xl" radius="lg" shadow="sm" bg="white">
|
<Paper mx="auto" p="xl" radius="lg" shadow="sm" bg="white">
|
||||||
@@ -232,20 +92,10 @@ const MusicPlayer = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 'md', md: 100 }} py="xl">
|
<Box px={{ base: 'xs', sm: 'md', md: 100 }} py="xl">
|
||||||
{/* Hidden audio element - gunakan key yang stabil untuk mencegah remount */}
|
|
||||||
{currentSong?.audioFile && (
|
|
||||||
<audio
|
|
||||||
ref={audioRef}
|
|
||||||
src={currentSong?.audioFile?.link}
|
|
||||||
muted={isMuted}
|
|
||||||
onEnded={handleSongEnd}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Paper
|
<Paper
|
||||||
mx="auto"
|
mx="auto"
|
||||||
p="xl"
|
p={{ base: 'md', sm: 'xl' }}
|
||||||
radius="lg"
|
radius="lg"
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
bg="white"
|
bg="white"
|
||||||
@@ -255,81 +105,67 @@ 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 || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
|
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
|
||||||
value={currentTime}
|
value={currentTime}
|
||||||
max={duration}
|
max={duration || 100}
|
||||||
onChange={(v) => {
|
onChange={(v) => seek(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);
|
|
||||||
}}
|
|
||||||
color="#0B4F78"
|
color="#0B4F78"
|
||||||
size="sm"
|
size="sm"
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
styles={{ thumb: { borderWidth: 2 } }}
|
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>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Flex>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card radius="md" p="xl" shadow="md">
|
<Card radius="md" p="xl" shadow="md">
|
||||||
@@ -345,32 +181,33 @@ const MusicPlayer = () => {
|
|||||||
) : (
|
) : (
|
||||||
<ScrollArea.Autosize mah={400}>
|
<ScrollArea.Autosize mah={400}>
|
||||||
<Grid gutter="md">
|
<Grid gutter="md">
|
||||||
{filteredMusik.map((song, index) => (
|
{filteredMusik.map((song) => (
|
||||||
<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(index)}
|
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>
|
||||||
@@ -381,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>
|
||||||
@@ -416,36 +261,39 @@ 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
|
||||||
variant={isRepeat ? 'filled' : 'subtle'}
|
variant={isRepeat ? 'filled' : 'subtle'}
|
||||||
color="#0B4F78"
|
color="#0B4F78"
|
||||||
onClick={() => setIsRepeat(!isRepeat)}
|
onClick={toggleRepeatHandler}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
|
size="md"
|
||||||
>
|
>
|
||||||
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -454,42 +302,19 @@ const MusicPlayer = () => {
|
|||||||
<Text size="xs" c="#5A6C7D" w={40} ta="right">{formatTime(currentTime)}</Text>
|
<Text size="xs" c="#5A6C7D" w={40} ta="right">{formatTime(currentTime)}</Text>
|
||||||
<Slider
|
<Slider
|
||||||
value={currentTime}
|
value={currentTime}
|
||||||
max={duration}
|
max={duration || 100}
|
||||||
onChange={(v) => {
|
onChange={(v) => seek(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);
|
|
||||||
}}
|
|
||||||
color="#0B4F78"
|
color="#0B4F78"
|
||||||
size="xs"
|
size="xs"
|
||||||
style={{ flex: 1 }}
|
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>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Group gap="xs" style={{ flex: 1 }} justify="flex-end">
|
{/* Volume Control - Hidden on mobile, shown on md and up */}
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={toggleMute}>
|
<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} />}
|
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<Slider
|
<Slider
|
||||||
@@ -507,86 +332,4 @@ const MusicPlayer = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default 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>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
@@ -78,7 +78,8 @@ function APBDesProgress({ apbdesData }: APBDesProgressProps) {
|
|||||||
// Hitung total per kategori
|
// Hitung total per kategori
|
||||||
const calcTotal = (items: { anggaran: number; realisasi: number }[]) => {
|
const calcTotal = (items: { anggaran: number; realisasi: number }[]) => {
|
||||||
const anggaran = items.reduce((sum, item) => sum + item.anggaran, 0);
|
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;
|
const persen = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
|
||||||
return { anggaran, realisasi, persen };
|
return { anggaran, realisasi, persen };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ function APBDesTable({ apbdesData }: APBDesTableProps) {
|
|||||||
|
|
||||||
// Calculate totals
|
// Calculate totals
|
||||||
const totalAnggaran = items.reduce((sum, item) => sum + (item.anggaran || 0), 0);
|
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 totalRealisasi = items.reduce((sum, item) => sum + (item.realisasi || 0), 0);
|
||||||
const totalSelisih = totalAnggaran - totalRealisasi;
|
const totalSelisih = totalAnggaran - totalRealisasi;
|
||||||
const totalPersentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
|
const totalPersentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ export function transformAPBDesData(data: any): APBDesData {
|
|||||||
kode: item.kode || '',
|
kode: item.kode || '',
|
||||||
uraian: item.uraian || '',
|
uraian: item.uraian || '',
|
||||||
anggaran: typeof item.anggaran === 'number' ? item.anggaran : 0,
|
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,
|
selisih: typeof item.selisih === 'number' ? item.selisih : 0,
|
||||||
persentase: typeof item.persentase === 'number' ? item.persentase : 0,
|
persentase: typeof item.persentase === 'number' ? item.persentase : 0,
|
||||||
level: typeof item.level === 'number' ? item.level : 1,
|
level: typeof item.level === 'number' ? item.level : 1,
|
||||||
|
|||||||
300
src/app/darmasaba/_com/FixedPlayerBar.tsx
Normal file
300
src/app/darmasaba/_com/FixedPlayerBar.tsx
Normal 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 }} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,29 +4,25 @@
|
|||||||
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
|
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
|
||||||
import colors from '@/con/colors'
|
import colors from '@/con/colors'
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
|
||||||
Select,
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { IconDownload } from '@tabler/icons-react'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useProxy } from 'valtio/utils'
|
import { useProxy } from 'valtio/utils'
|
||||||
|
import GrafikRealisasi from './lib/grafikRealisasi'
|
||||||
import PaguTable from './lib/paguTable'
|
import PaguTable from './lib/paguTable'
|
||||||
import RealisasiTable from './lib/realisasiTable'
|
import RealisasiTable from './lib/realisasiTable'
|
||||||
import GrafikRealisasi from './lib/grafikRealisasi'
|
|
||||||
|
|
||||||
function Apbdes() {
|
function Apbdes() {
|
||||||
const state = useProxy(apbdes)
|
const state = useProxy(apbdes)
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [selectedYear, setSelectedYear] = useState<string | null>(null)
|
const [selectedYear, setSelectedYear] = useState<string | null>(null)
|
||||||
|
|
||||||
const textHeading = {
|
const textHeading = {
|
||||||
@@ -37,12 +33,9 @@ function Apbdes() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
|
||||||
await state.findMany.load()
|
await state.findMany.load()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading data:', error)
|
console.error('Error loading data:', error)
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadData()
|
loadData()
|
||||||
@@ -73,10 +66,12 @@ function Apbdes() {
|
|||||||
? dataAPBDes.find((item: any) => item?.tahun?.toString() === selectedYear) || dataAPBDes[0]
|
? dataAPBDes.find((item: any) => item?.tahun?.toString() === selectedYear) || dataAPBDes[0]
|
||||||
: null
|
: 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 (
|
return (
|
||||||
<Stack p="sm" gap="xl" bg={colors.Bg}>
|
<Stack p="sm" gap="xl" bg={colors.Bg}>
|
||||||
|
<Divider c="gray.3" size="sm" />
|
||||||
{/* 📌 HEADING */}
|
{/* 📌 HEADING */}
|
||||||
<Box mt="xl">
|
<Box mt="xl">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
@@ -116,7 +111,7 @@ function Apbdes() {
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* COMBOBOX */}
|
{/* COMBOBOX */}
|
||||||
<Box px={{ base: 'md', md: 100 }}>
|
<Box px={{ base: 'md', md: "sm" }}>
|
||||||
<Select
|
<Select
|
||||||
label={<Text fw={600} fz="sm">Pilih Tahun APBDes</Text>}
|
label={<Text fw={600} fz="sm">Pilih Tahun APBDes</Text>}
|
||||||
placeholder="Pilih tahun"
|
placeholder="Pilih tahun"
|
||||||
@@ -132,7 +127,7 @@ function Apbdes() {
|
|||||||
|
|
||||||
{/* Tabel & Grafik - Hanya tampilkan jika ada data */}
|
{/* Tabel & Grafik - Hanya tampilkan jika ada data */}
|
||||||
{currentApbdes && currentApbdes.items?.length > 0 ? (
|
{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 }}>
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
<PaguTable apbdesData={currentApbdes} />
|
<PaguTable apbdesData={currentApbdes} />
|
||||||
<RealisasiTable apbdesData={currentApbdes} />
|
<RealisasiTable apbdesData={currentApbdes} />
|
||||||
@@ -140,19 +135,19 @@ function Apbdes() {
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Box>
|
</Box>
|
||||||
) : currentApbdes ? (
|
) : 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}>
|
<Text fz="sm" c="dimmed" ta="center" lh={1.5}>
|
||||||
Tidak ada data item untuk tahun yang dipilih.
|
Tidak ada data item untuk tahun yang dipilih.
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* GRID - Card Preview */}
|
{/* GRID - Card Preview
|
||||||
{loading ? (
|
{state.findMany.loading ? (
|
||||||
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
|
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
|
||||||
<Loader size="lg" color="blue" />
|
<Loader size="lg" color="blue" />
|
||||||
</Center>
|
</Center>
|
||||||
) : data.length === 0 ? (
|
) : previewData.length === 0 ? (
|
||||||
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
|
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
|
||||||
<Stack align="center" gap="xs">
|
<Stack align="center" gap="xs">
|
||||||
<Text fz="lg" c="dimmed" lh={1.4}>
|
<Text fz="lg" c="dimmed" lh={1.4}>
|
||||||
@@ -170,7 +165,7 @@ function Apbdes() {
|
|||||||
spacing="lg"
|
spacing="lg"
|
||||||
pb="xl"
|
pb="xl"
|
||||||
>
|
>
|
||||||
{data.map((v: any, k: number) => (
|
{previewData.map((v, k) => (
|
||||||
<Box
|
<Box
|
||||||
key={k}
|
key={k}
|
||||||
pos="relative"
|
pos="relative"
|
||||||
@@ -224,7 +219,7 @@ function Apbdes() {
|
|||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
)}
|
)} */}
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +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);
|
||||||
const totalRealisasi = data.reduce((s: number, i: any) => s + i.realisasi, 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 =
|
const persen =
|
||||||
totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
|
totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
|
||||||
@@ -36,38 +52,38 @@ function Summary({ title, data }: any) {
|
|||||||
{persen.toFixed(2)}%
|
{persen.toFixed(2)}%
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Text fz="sm" c="dimmed" mb="xs">
|
<Text fz="sm" c="dimmed" mb="xs">
|
||||||
Realisasi: {formatRupiah(totalRealisasi)} / Anggaran: {formatRupiah(totalAnggaran)}
|
Realisasi: {formatRupiah(totalRealisasi)} / Anggaran: {formatRupiah(totalAnggaran)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Progress
|
<Progress
|
||||||
value={persen}
|
value={persen}
|
||||||
size="xl"
|
size="xl"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
color={getProgressColor(persen)}
|
color={getProgressColor(persen)}
|
||||||
striped={persen < 100}
|
striped={persen < 100}
|
||||||
animated={persen < 100}
|
animated={persen < 100}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{persen >= 100 && (
|
{persen >= 100 && (
|
||||||
<Text fz="xs" c="teal" mt="xs" fw={500}>
|
<Text fz="xs" c="teal" mt="xs" fw={500}>
|
||||||
✓ Realisasi mencapai 100% dari anggaran
|
✓ Realisasi mencapai 100% dari anggaran
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{persen < 100 && persen >= 80 && (
|
{persen < 100 && persen >= 80 && (
|
||||||
<Text fz="xs" c="blue" mt="xs" fw={500}>
|
<Text fz="xs" c="blue" mt="xs" fw={500}>
|
||||||
⚡ Realisasi baik, mendekati target
|
⚡ Realisasi baik, mendekati target
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{persen < 80 && persen >= 60 && (
|
{persen < 80 && persen >= 60 && (
|
||||||
<Text fz="xs" c="yellow" mt="xs" fw={500}>
|
<Text fz="xs" c="yellow" mt="xs" fw={500}>
|
||||||
⚠️ Realisasi cukup, perlu ditingkatkan
|
⚠️ Realisasi cukup, perlu ditingkatkan
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{persen < 60 && (
|
{persen < 60 && (
|
||||||
<Text fz="xs" c="red" mt="xs" fw={500}>
|
<Text fz="xs" c="red" mt="xs" fw={500}>
|
||||||
⚠️ Realisasi rendah, perlu perhatian khusus
|
⚠️ Realisasi rendah, perlu perhatian khusus
|
||||||
@@ -77,27 +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);
|
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
}) {
|
||||||
|
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">
|
||||||
@@ -105,30 +115,11 @@ export default function GrafikRealisasi({ apbdesData }: any) {
|
|||||||
GRAFIK REALISASI APBDes {tahun}
|
GRAFIK REALISASI APBDes {tahun}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<Stack gap="lg">
|
<Stack gap="lg" mb="lg">
|
||||||
<Summary title="💰 Pendapatan" data={pendapatan} />
|
<Summary title="💰 Pendapatan" data={pendapatan} />
|
||||||
<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 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>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,43 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { Paper, Table, Title, Badge } from '@mantine/core';
|
import { Paper, Table, Title, Badge, Text } 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.totalRealisasi.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>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RealisasiTable({ apbdesData }: any) {
|
export default function RealisasiTable({ apbdesData }: any) {
|
||||||
const items = apbdesData.items || [];
|
const items = apbdesData.items || [];
|
||||||
@@ -47,28 +9,84 @@ export default function RealisasiTable({ apbdesData }: any) {
|
|||||||
? `REALISASI APBDes Tahun ${apbdesData.tahun}`
|
? `REALISASI APBDes Tahun ${apbdesData.tahun}`
|
||||||
: 'REALISASI APBDes';
|
: 'REALISASI APBDes';
|
||||||
|
|
||||||
const pendapatan = items.filter((i: any) => i.tipe === 'pendapatan');
|
// Flatten: kumpulkan semua realisasi items
|
||||||
const belanja = items.filter((i: any) => i.tipe === 'belanja');
|
const allRealisasiRows: Array<{ realisasi: any; parentItem: any }> = [];
|
||||||
const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan');
|
|
||||||
|
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 (
|
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>
|
{allRealisasiRows.length === 0 ? (
|
||||||
<Table.Thead>
|
<Text fz="sm" c="dimmed" ta="center" py="md">
|
||||||
<Table.Tr>
|
Belum ada data realisasi
|
||||||
<Table.Th>Uraian</Table.Th>
|
</Text>
|
||||||
<Table.Th ta="right">Realisasi (Rp)</Table.Th>
|
) : (
|
||||||
<Table.Th ta="center">%</Table.Th>
|
<Table.ScrollContainer minWidth={300}>
|
||||||
</Table.Tr>
|
<Table verticalSpacing="xs">
|
||||||
</Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tr>
|
||||||
<Section title="1) PENDAPATAN" data={pendapatan} />
|
<Table.Th fz={{ base: 'xs', sm: 'sm' }}>Uraian</Table.Th>
|
||||||
<Section title="2) BELANJA" data={belanja} />
|
<Table.Th ta="right" fz={{ base: 'xs', sm: 'sm' }}>Realisasi (Rp)</Table.Th>
|
||||||
<Section title="3) PEMBIAYAAN" data={pembiayaan} />
|
<Table.Th ta="center" fz={{ base: 'xs', sm: 'sm' }}>%</Table.Th>
|
||||||
</Table.Tbody>
|
</Table.Tr>
|
||||||
</Table>
|
</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>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Box, Space, Stack } from "@mantine/core";
|
|||||||
|
|
||||||
import { Navbar } from "@/app/darmasaba/_com/Navbar";
|
import { Navbar } from "@/app/darmasaba/_com/Navbar";
|
||||||
import Footer from "./_com/Footer";
|
import Footer from "./_com/Footer";
|
||||||
|
import FixedPlayerBar from "./_com/FixedPlayerBar";
|
||||||
|
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
@@ -21,6 +22,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<FixedPlayerBar />
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
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 {
|
import {
|
||||||
ColorSchemeScript,
|
ColorSchemeScript,
|
||||||
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";
|
||||||
@@ -104,15 +106,17 @@ export default function RootLayout({
|
|||||||
<ColorSchemeScript defaultColorScheme="light" />
|
<ColorSchemeScript defaultColorScheme="light" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<MantineProvider theme={theme} defaultColorScheme="light">
|
<MusicProvider>
|
||||||
{children}
|
<MantineProvider theme={theme} defaultColorScheme="light">
|
||||||
<LoadDataFirstClient />
|
{children}
|
||||||
<ToastContainer
|
<LoadDataFirstClient />
|
||||||
position="bottom-center"
|
<ToastContainer
|
||||||
hideProgressBar
|
position="bottom-center"
|
||||||
style={{ zIndex: 9999 }}
|
hideProgressBar
|
||||||
/>
|
style={{ zIndex: 9999 }}
|
||||||
</MantineProvider>
|
/>
|
||||||
|
</MantineProvider>
|
||||||
|
</MusicProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
</ViewTransitions>
|
</ViewTransitions>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function Page() {
|
|||||||
dengan ketentuan ini, harap jangan gunakan Website.
|
dengan ketentuan ini, harap jangan gunakan Website.
|
||||||
</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">
|
||||||
|
|||||||
Reference in New Issue
Block a user