Compare commits
2 Commits
fix-respon
...
fix-wa-otp
| Author | SHA1 | Date | |
|---|---|---|---|
| 9190840c48 | |||
| 781d125d4c |
@@ -1,73 +0,0 @@
|
|||||||
# 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",
|
||||||
|
|||||||
@@ -23,9 +23,8 @@ 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(),
|
||||||
// Image dan file opsional (bisa kosong)
|
imageId: z.string().min(1, "Gambar wajib diunggah"),
|
||||||
imageId: z.string().optional(),
|
fileId: z.string().min(1, "File wajib diunggah"),
|
||||||
fileId: z.string().optional(),
|
|
||||||
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
|
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ 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,
|
||||||
@@ -216,6 +217,7 @@ 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,
|
||||||
@@ -226,7 +228,15 @@ function EditAPBDes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image dan file sekarang opsional, tidak perlu validasi
|
// Jika tidak ada file baru, gunakan ID lama (sudah ada di form)
|
||||||
|
// Pastikan imageId dan fileId tetap ada
|
||||||
|
if (!apbdesState.edit.form.imageId) {
|
||||||
|
return toast.warn('Gambar wajib diunggah');
|
||||||
|
}
|
||||||
|
if (!apbdesState.edit.form.fileId) {
|
||||||
|
return toast.warn('Dokumen wajib diunggah');
|
||||||
|
}
|
||||||
|
|
||||||
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');
|
||||||
@@ -333,11 +343,11 @@ function EditAPBDes() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Gambar & Dokumen (Opsional) */}
|
{/* Gambar & Dokumen */}
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Gambar APBDes (Opsional)
|
Gambar APBDes
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={handleDrop('image')}
|
onDrop={handleDrop('image')}
|
||||||
@@ -377,7 +387,6 @@ 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} />
|
||||||
@@ -388,7 +397,7 @@ function EditAPBDes() {
|
|||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Dokumen APBDes (Opsional)
|
Dokumen APBDes
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={handleDrop('doc')}
|
onDrop={handleDrop('doc')}
|
||||||
@@ -437,7 +446,6 @@ 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} />
|
||||||
|
|||||||
@@ -46,9 +46,13 @@ 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 - hanya cek items, gambar dan file opsional
|
// Check if form is valid
|
||||||
const isFormValid = () => {
|
const isFormValid = () => {
|
||||||
return stateAPBDes.create.form.items.length > 0;
|
return (
|
||||||
|
imageFile !== null &&
|
||||||
|
docFile !== null &&
|
||||||
|
stateAPBDes.create.form.items.length > 0
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Form sementara untuk input item baru
|
// Form sementara untuk input item baru
|
||||||
@@ -80,34 +84,28 @@ 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 }),
|
||||||
|
]);
|
||||||
|
|
||||||
// Upload files hanya jika ada file yang dipilih
|
const imageId = uploadImageRes?.data?.data?.id;
|
||||||
let imageId = '';
|
const fileId = uploadDocRes?.data?.data?.id;
|
||||||
let fileId = '';
|
|
||||||
|
|
||||||
if (imageFile) {
|
if (!imageId || !fileId) {
|
||||||
const uploadImageRes = await ApiFetch.api.fileStorage.create.post({
|
return toast.error("Gagal mengupload file");
|
||||||
file: imageFile,
|
|
||||||
name: imageFile.name,
|
|
||||||
});
|
|
||||||
imageId = uploadImageRes?.data?.data?.id || '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (docFile) {
|
// Update form dengan ID file
|
||||||
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;
|
||||||
|
|
||||||
@@ -176,16 +174,12 @@ function CreateAPBDes() {
|
|||||||
style={{ border: '1px solid #e0e0e0' }}
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{/* Info: File opsional */}
|
{/* Gambar & Dokumen (dipendekkan untuk fokus pada items) */}
|
||||||
<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 (Opsional)
|
Gambar APBDes
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={(files) => {
|
onDrop={(files) => {
|
||||||
@@ -255,10 +249,10 @@ function CreateAPBDes() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Dokumen APBDes (Opsional) */}
|
{/* Dokumen APBDes */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Dokumen APBDes (Opsional)
|
Dokumen APBDes
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={(files) => {
|
onDrop={(files) => {
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
// 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 | null; // Opsional
|
imageId: string;
|
||||||
fileId?: string | null; // Opsional
|
fileId: string;
|
||||||
items: APBDesItemInput[];
|
items: APBDesItemInput[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,7 +32,12 @@ 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');
|
||||||
}
|
}
|
||||||
// Image dan file sekarang opsional
|
if (!body.imageId) {
|
||||||
|
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');
|
||||||
}
|
}
|
||||||
@@ -45,8 +50,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 || null, // null jika tidak ada
|
imageId: body.imageId,
|
||||||
fileId: body.fileId || null, // null jika tidak ada
|
fileId: body.fileId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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.Optional(t.String()),
|
imageId: t.String(),
|
||||||
fileId: t.Optional(t.String()),
|
fileId: 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.Optional(t.String()),
|
imageId: t.String(),
|
||||||
fileId: t.Optional(t.String()),
|
fileId: t.String(),
|
||||||
items: t.Array(ApbdesItemSchema),
|
items: t.Array(ApbdesItemSchema),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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;
|
||||||
@@ -16,8 +15,8 @@ type FormUpdateBody = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
deskripsi?: string;
|
deskripsi?: string;
|
||||||
jumlah?: string;
|
jumlah?: string;
|
||||||
imageId?: string | null;
|
imageId: string;
|
||||||
fileId?: string | null;
|
fileId: string;
|
||||||
items: APBDesItemInput[];
|
items: APBDesItemInput[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,9 +49,9 @@ export default async function apbdesUpdate(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Build map untuk preserve realisasiItems berdasarkan kode
|
// 2. Build map untuk preserve realisasiItems berdasarkan kode
|
||||||
const existingItemsMap = new Map<string, {
|
const existingItemsMap = new Map<string, {
|
||||||
id: string;
|
id: string;
|
||||||
realisasiItems: RealisasiItem[];
|
realisasiItems: any[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
existing.items.forEach(item => {
|
existing.items.forEach(item => {
|
||||||
@@ -129,7 +128,7 @@ export default async function apbdesUpdate(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8. Recalculate totalRealisasi setelah re-create realisasiItems
|
// 8. Recalculate totalRealisasi setelah re-create realisasiItems
|
||||||
for (const kode of existingItemsMap.keys()) {
|
for (const [kode, _] of existingItemsMap.entries()) {
|
||||||
const newItemId = newItemIdsMap.get(kode);
|
const newItemId = newItemIdsMap.get(kode);
|
||||||
if (newItemId) {
|
if (newItemId) {
|
||||||
const realisasiItems = await prisma.realisasiItem.findMany({
|
const realisasiItems = await prisma.realisasiItem.findMany({
|
||||||
@@ -169,8 +168,8 @@ export default async function apbdesUpdate(context: Context) {
|
|||||||
name: body.name || `APBDes Tahun ${body.tahun}`,
|
name: body.name || `APBDes Tahun ${body.tahun}`,
|
||||||
deskripsi: body.deskripsi,
|
deskripsi: body.deskripsi,
|
||||||
jumlah: body.jumlah,
|
jumlah: body.jumlah,
|
||||||
imageId: body.imageId === '' ? null : body.imageId,
|
imageId: body.imageId,
|
||||||
fileId: body.fileId === '' ? null : body.fileId,
|
fileId: body.fileId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
/* 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";
|
||||||
|
|
||||||
@@ -35,25 +34,11 @@ export default async function userUpdate(context: Context) {
|
|||||||
const isActiveChanged =
|
const isActiveChanged =
|
||||||
isActive !== undefined && currentUser.isActive !== isActive;
|
isActive !== undefined && currentUser.isActive !== isActive;
|
||||||
|
|
||||||
// ✅ Jika role berubah, reset dan set ulang akses menu
|
// ✅ Jika role berubah, hapus semua akses menu yang ada
|
||||||
if (isRoleChanged && roleId) {
|
if (isRoleChanged) {
|
||||||
// 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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import prisma from "@/lib/prisma";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { randomOTP } from "../_lib/randomOTP";
|
import { randomOTP } from "../_lib/randomOTP";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { sendWhatsAppOtp } from "@/lib/wa-service";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
@@ -34,31 +35,22 @@ export async function POST(req: Request) {
|
|||||||
const otpNumber = Number(codeOtp);
|
const otpNumber = Number(codeOtp);
|
||||||
|
|
||||||
const waMessage = `Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.\n\n>> Kode OTP anda: ${codeOtp}.`;
|
const waMessage = `Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.\n\n>> Kode OTP anda: ${codeOtp}.`;
|
||||||
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
|
|
||||||
|
|
||||||
console.log("🔍 Debug WA URL:", waUrl);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(waUrl);
|
|
||||||
const sendWa = await res.json();
|
|
||||||
console.log("📱 WA Response:", sendWa);
|
|
||||||
|
|
||||||
if (sendWa.status !== "success") {
|
// Send OTP via WhatsApp using authenticated API
|
||||||
console.error("❌ WA Service Error:", sendWa);
|
const waResult = await sendWhatsAppOtp({
|
||||||
return NextResponse.json(
|
nomor,
|
||||||
{
|
message: waMessage,
|
||||||
success: false,
|
});
|
||||||
message: "Gagal mengirim OTP via WhatsApp",
|
|
||||||
debug: sendWa
|
if (!waResult.success) {
|
||||||
},
|
console.error("❌ WA Service Error:", waResult);
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (waError) {
|
|
||||||
console.error("❌ Fetch WA Error:", waError);
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, message: "Terjadi kesalahan saat mengirim WA" },
|
{
|
||||||
{ status: 500 }
|
success: false,
|
||||||
|
message: waResult.message || "Gagal mengirim OTP via WhatsApp",
|
||||||
|
debug: waResult.data
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
import { randomOTP } from '../_lib/randomOTP';
|
import { randomOTP } from '../_lib/randomOTP';
|
||||||
|
import { sendWhatsAppOtp } from '@/lib/wa-service';
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
@@ -24,12 +25,18 @@ export async function POST(req: Request) {
|
|||||||
const otpNumber = Number(codeOtp);
|
const otpNumber = Number(codeOtp);
|
||||||
|
|
||||||
const waMessage = `Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
|
const waMessage = `Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
|
||||||
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
|
|
||||||
const waRes = await fetch(waUrl);
|
|
||||||
const waData = await waRes.json();
|
|
||||||
|
|
||||||
if (waData.status !== "success") {
|
// Send OTP via WhatsApp using authenticated API
|
||||||
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP via WhatsApp' }, { status: 400 });
|
const waResult = await sendWhatsAppOtp({
|
||||||
|
nomor,
|
||||||
|
message: waMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!waResult.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: waResult.message || 'Gagal mengirim OTP via WhatsApp', debug: waResult.data },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Simpan OTP ke database
|
// ✅ Simpan OTP ke database
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import prisma from "@/lib/prisma";
|
|||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { randomOTP } from "../_lib/randomOTP";
|
import { randomOTP } from "../_lib/randomOTP";
|
||||||
|
import { sendWhatsAppOtp } from "@/lib/wa-service";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
@@ -18,15 +19,17 @@ export async function POST(req: Request) {
|
|||||||
const codeOtp = randomOTP();
|
const codeOtp = randomOTP();
|
||||||
const otpNumber = Number(codeOtp);
|
const otpNumber = Number(codeOtp);
|
||||||
|
|
||||||
// Kirim OTP via WhatsApp
|
// Kirim OTP via WhatsApp menggunakan API terautentikasi
|
||||||
const waMessage = `Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.\n\n>> Kode OTP anda: ${codeOtp}.`;
|
const waMessage = `Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.\n\n>> Kode OTP anda: ${codeOtp}.`;
|
||||||
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
|
|
||||||
const waRes = await fetch(waUrl);
|
|
||||||
const waData = await waRes.json();
|
|
||||||
|
|
||||||
if (waData.status !== "success") {
|
const waResult = await sendWhatsAppOtp({
|
||||||
|
nomor,
|
||||||
|
message: waMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!waResult.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, message: "Gagal mengirim OTP via WhatsApp" },
|
{ success: false, message: waResult.message || "Gagal mengirim OTP via WhatsApp", debug: waResult.data },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { randomOTP } from "../_lib/randomOTP";
|
import { randomOTP } from "../_lib/randomOTP";
|
||||||
|
import { sendWhatsAppOtp } from "@/lib/wa-service";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
@@ -22,13 +23,18 @@ export async function POST(req: Request) {
|
|||||||
const codeOtp = randomOTP();
|
const codeOtp = randomOTP();
|
||||||
const otpNumber = Number(codeOtp);
|
const otpNumber = Number(codeOtp);
|
||||||
|
|
||||||
// Kirim WA
|
// Kirim WA menggunakan API terautentikasi
|
||||||
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
|
const waMessage = `Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
|
||||||
const res = await fetch(waUrl);
|
const waResult = await sendWhatsAppOtp({
|
||||||
const sendWa = await res.json();
|
nomor,
|
||||||
|
message: waMessage,
|
||||||
|
});
|
||||||
|
|
||||||
if (sendWa.status !== "success") {
|
if (!waResult.success) {
|
||||||
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP' }, { status: 400 });
|
return NextResponse.json(
|
||||||
|
{ success: false, message: waResult.message || 'Gagal mengirim OTP', debug: waResult.data },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simpan OTP
|
// Simpan OTP
|
||||||
|
|||||||
@@ -82,12 +82,6 @@ export function MusicProvider({ children }: { children: ReactNode }) {
|
|||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const isSeekingRef = useRef(false);
|
const isSeekingRef = useRef(false);
|
||||||
const animationFrameRef = useRef<number | null>(null);
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
const isRepeatRef = useRef(false); // Ref untuk avoid stale closure
|
|
||||||
|
|
||||||
// Sync ref dengan state
|
|
||||||
useEffect(() => {
|
|
||||||
isRepeatRef.current = isRepeat;
|
|
||||||
}, [isRepeat]);
|
|
||||||
|
|
||||||
// Load musik data
|
// Load musik data
|
||||||
const loadMusikData = useCallback(async () => {
|
const loadMusikData = useCallback(async () => {
|
||||||
@@ -117,8 +111,7 @@ export function MusicProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
audioRef.current.addEventListener('ended', () => {
|
audioRef.current.addEventListener('ended', () => {
|
||||||
// Gunakan ref untuk avoid stale closure
|
if (isRepeat) {
|
||||||
if (isRepeatRef.current) {
|
|
||||||
audioRef.current!.currentTime = 0;
|
audioRef.current!.currentTime = 0;
|
||||||
audioRef.current!.play();
|
audioRef.current!.play();
|
||||||
} else {
|
} else {
|
||||||
@@ -139,7 +132,7 @@ export function MusicProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- playNext is intentionally not in deps to avoid circular dependency
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- playNext is intentionally not in deps to avoid circular dependency
|
||||||
}, [loadMusikData]); // Remove isRepeat dari deps karena sudah pakai ref
|
}, [loadMusikData, isRepeat]);
|
||||||
|
|
||||||
// Update time with requestAnimationFrame for smooth progress
|
// Update time with requestAnimationFrame for smooth progress
|
||||||
const updateTime = useCallback(() => {
|
const updateTime = useCallback(() => {
|
||||||
|
|||||||
@@ -92,10 +92,10 @@ const MusicPlayer = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 'xs', sm: 'md', md: 100 }} py="xl">
|
<Box px={{ base: 'md', md: 100 }} py="xl">
|
||||||
<Paper
|
<Paper
|
||||||
mx="auto"
|
mx="auto"
|
||||||
p={{ base: 'md', sm: 'xl' }}
|
p="xl"
|
||||||
radius="lg"
|
radius="lg"
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
bg="white"
|
bg="white"
|
||||||
@@ -105,52 +105,42 @@ const MusicPlayer = () => {
|
|||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<BackButton />
|
<BackButton />
|
||||||
<Flex
|
<Group justify="space-between" mb="xl" mt={"md"}>
|
||||||
justify="space-between"
|
|
||||||
align={{ base: 'flex-start', sm: 'center' }}
|
|
||||||
direction={{ base: 'column', sm: 'row' }}
|
|
||||||
gap="md"
|
|
||||||
mb="xl"
|
|
||||||
mt="md"
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<Text fz={{ base: '24px', sm: '32px' }} fw={700} c="#0B4F78" lh={1.2}>Selamat Datang Kembali</Text>
|
<Text size="32px" fw={700} c="#0B4F78">Selamat Datang Kembali</Text>
|
||||||
<Text size="sm" c="#5A6C7D">Temukan musik favorit Anda hari ini</Text>
|
<Text size="md" c="#5A6C7D">Temukan musik favorit Anda hari ini</Text>
|
||||||
</div>
|
</div>
|
||||||
<TextInput
|
<Group gap="md">
|
||||||
placeholder="Cari lagu..."
|
<TextInput
|
||||||
leftSection={<IconSearch size={18} />}
|
placeholder="Cari lagu..."
|
||||||
radius="xl"
|
leftSection={<IconSearch size={18} />}
|
||||||
w={{ base: '100%', sm: 280 }}
|
radius="xl"
|
||||||
value={search}
|
w={280}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
value={search}
|
||||||
styles={{ input: { backgroundColor: '#fff' } }}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
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={{ base: 'md', sm: 'xl' }} shadow="md" withBorder>
|
<Card radius="md" p="xl" shadow="md">
|
||||||
<Flex
|
<Group align="center" gap="xl">
|
||||||
direction={{ base: 'column', sm: 'row' }}
|
|
||||||
align="center"
|
|
||||||
gap={{ base: 'md', sm: 'xl' }}
|
|
||||||
>
|
|
||||||
<Avatar
|
<Avatar
|
||||||
src={currentSong.coverImage?.link || '/mp3-logo.png'}
|
src={currentSong.coverImage?.link || '/mp3-logo.png'}
|
||||||
size={120}
|
size={180}
|
||||||
radius="md"
|
radius="md"
|
||||||
/>
|
/>
|
||||||
<Stack gap="md" style={{ flex: 1, width: '100%' }}>
|
<Stack gap="md" style={{ flex: 1 }}>
|
||||||
<Box ta={{ base: 'center', sm: 'left' }}>
|
<div>
|
||||||
<Text fz={{ base: '20px', sm: '28px' }} fw={700} c="#0B4F78" lineClamp={1}>{currentSong.judul}</Text>
|
<Text size="28px" fw={700} c="#0B4F78">{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>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</div>
|
||||||
<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
|
||||||
@@ -165,7 +155,7 @@ const MusicPlayer = () => {
|
|||||||
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration || 0)}</Text>
|
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration || 0)}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Flex>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card radius="md" p="xl" shadow="md">
|
<Card radius="md" p="xl" shadow="md">
|
||||||
@@ -185,29 +175,28 @@ const MusicPlayer = () => {
|
|||||||
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}>
|
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}>
|
||||||
<Card
|
<Card
|
||||||
radius="md"
|
radius="md"
|
||||||
p="sm"
|
p="md"
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
withBorder
|
|
||||||
style={{
|
style={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
borderColor: currentSong?.id === song.id ? '#0B4F78' : 'transparent',
|
border: currentSong?.id === song.id ? '2px solid #0B4F78' : '2px solid transparent',
|
||||||
backgroundColor: currentSong?.id === song.id ? '#F0F7FA' : 'white',
|
|
||||||
transition: 'all 0.2s'
|
transition: 'all 0.2s'
|
||||||
}}
|
}}
|
||||||
onClick={() => playSong(song)}
|
onClick={() => playSong(song)}
|
||||||
>
|
>
|
||||||
<Group gap="sm" align="center" wrap="nowrap">
|
<Group gap="md" align="center">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={song.coverImage?.link || '/mp3-logo.png'}
|
src={song.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
|
||||||
size={50}
|
size={64}
|
||||||
radius="md"
|
radius="md"
|
||||||
/>
|
/>
|
||||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
<Stack gap={4} 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" truncate>{song.artis}</Text>
|
<Text size="xs" c="#5A6C7D">{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" size="xs">Playing</Badge>
|
<Badge color="#0B4F78" variant="filled">Memutar</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -218,42 +207,34 @@ const MusicPlayer = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Control Player Section */}
|
|
||||||
<Paper
|
<Paper
|
||||||
mt="xl"
|
mt="xl"
|
||||||
mx="auto"
|
mx="auto"
|
||||||
p={{ base: 'md', sm: 'xl' }}
|
p="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
|
<Flex align="center" justify="space-between" gap="xl" h="100%">
|
||||||
direction={{ base: 'column', md: 'row' }}
|
<Group gap="md" style={{ flex: 1 }}>
|
||||||
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 || '/mp3-logo.png'}
|
src={currentSong?.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
|
||||||
size={48}
|
size={56}
|
||||||
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" truncate>{currentSong.artis}</Text>
|
<Text size="xs" c="#5A6C7D">{currentSong.artis}</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Text size="sm" c="dimmed">Tidak ada lagu</Text>
|
<Text size="sm" c="dimmed">Tidak ada lagu</Text>
|
||||||
@@ -261,31 +242,29 @@ const MusicPlayer = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Controls + Progress */}
|
<Stack gap="xs" style={{ flex: 1 }} align="center">
|
||||||
<Stack gap="xs" style={{ flex: 2, width: '100%' }} align="center">
|
<Group gap="md">
|
||||||
<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={48} radius="xl" onClick={skipBack}>
|
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl" onClick={skipBack}>
|
||||||
<IconPlayerSkipBackFilled size={20} />
|
<IconPlayerSkipBackFilled size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color="#0B4F78"
|
color="#0B4F78"
|
||||||
size={48}
|
size={56}
|
||||||
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={48} radius="xl" onClick={skipForward}>
|
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl" onClick={skipForward}>
|
||||||
<IconPlayerSkipForwardFilled size={20} />
|
<IconPlayerSkipForwardFilled size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@@ -293,7 +272,6 @@ const MusicPlayer = () => {
|
|||||||
color="#0B4F78"
|
color="#0B4F78"
|
||||||
onClick={toggleRepeatHandler}
|
onClick={toggleRepeatHandler}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
size="md"
|
|
||||||
>
|
>
|
||||||
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -312,8 +290,7 @@ const MusicPlayer = () => {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Volume Control - Hidden on mobile, shown on md and up */}
|
<Group gap="xs" style={{ flex: 1 }} justify="flex-end">
|
||||||
<Group gap="xs" style={{ flex: 1 }} justify="flex-end" visibleFrom="md">
|
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={toggleMuteHandler}>
|
<ActionIcon variant="subtle" color="gray" onClick={toggleMuteHandler}>
|
||||||
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
|
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
ActionIcon,
|
ActionIcon,
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Flex,
|
Flex,
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -13,7 +12,6 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconArrowsShuffle,
|
IconArrowsShuffle,
|
||||||
IconMusic,
|
|
||||||
IconPlayerPauseFilled,
|
IconPlayerPauseFilled,
|
||||||
IconPlayerPlayFilled,
|
IconPlayerPlayFilled,
|
||||||
IconPlayerSkipBackFilled,
|
IconPlayerSkipBackFilled,
|
||||||
@@ -47,7 +45,7 @@ export default function FixedPlayerBar() {
|
|||||||
} = useMusic();
|
} = useMusic();
|
||||||
|
|
||||||
const [showVolume, setShowVolume] = useState(false);
|
const [showVolume, setShowVolume] = useState(false);
|
||||||
const [isMinimized, setIsMinimized] = useState(false);
|
const [isPlayerVisible, setIsPlayerVisible] = useState(true);
|
||||||
|
|
||||||
// Format time
|
// Format time
|
||||||
const formatTime = (seconds: number) => {
|
const formatTime = (seconds: number) => {
|
||||||
@@ -71,46 +69,12 @@ export default function FixedPlayerBar() {
|
|||||||
toggleShuffle();
|
toggleShuffle();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle minimize player (show floating icon)
|
// Handle close player
|
||||||
const handleMinimizePlayer = () => {
|
const handleClosePlayer = () => {
|
||||||
setIsMinimized(true);
|
setIsPlayerVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle restore player from floating icon
|
if (!currentSong || !isPlayerVisible) {
|
||||||
const handleRestorePlayer = () => {
|
|
||||||
setIsMinimized(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// If minimized, show floating icon instead of player bar
|
|
||||||
if (isMinimized) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Floating Music Icon - Shows when player is minimized */}
|
|
||||||
<Button
|
|
||||||
color="#0B4F78"
|
|
||||||
variant="filled"
|
|
||||||
size="md"
|
|
||||||
mt="md"
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: '50%',
|
|
||||||
left: '0px',
|
|
||||||
transform: 'translateY(-50%)',
|
|
||||||
borderBottomRightRadius: '20px',
|
|
||||||
borderTopRightRadius: '20px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'transform 0.2s ease',
|
|
||||||
zIndex: 1000 // Higher z-index
|
|
||||||
}}
|
|
||||||
onClick={handleRestorePlayer}
|
|
||||||
>
|
|
||||||
<IconMusic size={24} color="white" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentSong) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,43 +86,41 @@ export default function FixedPlayerBar() {
|
|||||||
bottom={0}
|
bottom={0}
|
||||||
left={0}
|
left={0}
|
||||||
right={0}
|
right={0}
|
||||||
p={{ base: 'xs', sm: 'sm' }}
|
p="sm"
|
||||||
shadow="xl"
|
shadow="lg"
|
||||||
style={{
|
style={{
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
borderTop: '1px solid rgba(0,0,0,0.1)',
|
borderTop: '1px solid rgba(0,0,0,0.1)',
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
|
||||||
backdropFilter: 'blur(10px)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Flex align="center" gap={{ base: 'xs', sm: 'md' }} justify="space-between">
|
<Flex align="center" gap="md" justify="space-between">
|
||||||
{/* Song Info - Left */}
|
{/* Song Info */}
|
||||||
<Group gap="xs" flex={{ base: 2, sm: 1 }} style={{ minWidth: 0 }} wrap="nowrap">
|
<Group gap="sm" flex={1} style={{ minWidth: 0 }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={currentSong.coverImage?.link || ''}
|
src={currentSong.coverImage?.link || ''}
|
||||||
alt={currentSong.judul}
|
alt={currentSong.judul}
|
||||||
size={"36"}
|
size={40}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
|
imageProps={{ loading: 'lazy' }}
|
||||||
/>
|
/>
|
||||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
<Box style={{ minWidth: 0 }}>
|
||||||
<Text fz={{ base: 'xs', sm: 'sm' }} fw={600} truncate>
|
<Text fz="sm" fw={600} truncate>
|
||||||
{currentSong.judul}
|
{currentSong.judul}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz="10px" c="dimmed" truncate>
|
<Text fz="xs" c="dimmed" truncate>
|
||||||
{currentSong.artis}
|
{currentSong.artis}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Controls - Center */}
|
{/* Controls */}
|
||||||
<Group gap={"xs"} flex={{ base: 1, sm: 2 }} justify="center" wrap="nowrap">
|
<Group gap="xs">
|
||||||
{/* Shuffle - Desktop Only */}
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={isShuffle ? 'filled' : 'subtle'}
|
variant={isShuffle ? 'filled' : 'subtle'}
|
||||||
color={isShuffle ? '#0B4F78' : 'gray'}
|
color={isShuffle ? 'blue' : 'gray'}
|
||||||
size={"md"}
|
size="lg"
|
||||||
onClick={handleToggleShuffle}
|
onClick={handleToggleShuffle}
|
||||||
visibleFrom="sm"
|
title="Shuffle"
|
||||||
>
|
>
|
||||||
<IconArrowsShuffle size={18} />
|
<IconArrowsShuffle size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -166,18 +128,20 @@ export default function FixedPlayerBar() {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
size={"md"}
|
size="lg"
|
||||||
onClick={playPrev}
|
onClick={playPrev}
|
||||||
|
title="Previous"
|
||||||
>
|
>
|
||||||
<IconPlayerSkipBackFilled size={20} />
|
<IconPlayerSkipBackFilled size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color="#0B4F78"
|
color={isPlaying ? 'blue' : 'gray'}
|
||||||
size={"lg"}
|
size="xl"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
onClick={togglePlayPause}
|
onClick={togglePlayPause}
|
||||||
|
title={isPlaying ? 'Pause' : 'Play'}
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
<IconPlayerPauseFilled size={24} />
|
<IconPlayerPauseFilled size={24} />
|
||||||
@@ -189,58 +153,62 @@ export default function FixedPlayerBar() {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
size={"md"}
|
size="lg"
|
||||||
onClick={playNext}
|
onClick={playNext}
|
||||||
|
title="Next"
|
||||||
>
|
>
|
||||||
<IconPlayerSkipForwardFilled size={20} />
|
<IconPlayerSkipForwardFilled size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
{/* Repeat - Desktop Only */}
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={isRepeat ? 'filled' : 'subtle'}
|
variant="subtle"
|
||||||
color={isRepeat ? '#0B4F78' : 'gray'}
|
color={isRepeat ? 'blue' : 'gray'}
|
||||||
size={"md"}
|
size="lg"
|
||||||
onClick={toggleRepeat}
|
onClick={toggleRepeat}
|
||||||
visibleFrom="sm"
|
title={isRepeat ? 'Repeat On' : 'Repeat Off'}
|
||||||
>
|
>
|
||||||
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
{/* Progress Bar - Desktop Only */}
|
|
||||||
<Box w={150} ml="md" visibleFrom="md">
|
|
||||||
<Slider
|
|
||||||
value={currentTime}
|
|
||||||
max={duration || 100}
|
|
||||||
onChange={handleSeek}
|
|
||||||
size="xs"
|
|
||||||
color="#0B4F78"
|
|
||||||
label={(value) => formatTime(value)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Right Controls - Volume + Close */}
|
{/* Progress Bar - Desktop */}
|
||||||
<Group gap={4} flex={1} justify="flex-end" wrap="nowrap">
|
<Box w={200} display={{ base: 'none', md: 'block' }}>
|
||||||
{/* Volume Control - Tablet/Desktop */}
|
<Slider
|
||||||
|
value={currentTime}
|
||||||
|
max={duration || 100}
|
||||||
|
onChange={handleSeek}
|
||||||
|
size="sm"
|
||||||
|
color="blue"
|
||||||
|
label={(value) => formatTime(value)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Right Controls */}
|
||||||
|
<Group gap="xs">
|
||||||
<Box
|
<Box
|
||||||
onMouseEnter={() => setShowVolume(true)}
|
onMouseEnter={() => setShowVolume(true)}
|
||||||
onMouseLeave={() => setShowVolume(false)}
|
onMouseLeave={() => setShowVolume(false)}
|
||||||
pos="relative"
|
pos="relative"
|
||||||
visibleFrom="sm"
|
|
||||||
>
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color={isMuted ? 'red' : 'gray'}
|
color={isMuted ? 'red' : 'gray'}
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={toggleMute}
|
onClick={toggleMute}
|
||||||
|
title={isMuted ? 'Unmute' : 'Mute'}
|
||||||
>
|
>
|
||||||
{isMuted ? <IconVolumeOff size={18} /> : <IconVolume size={18} />}
|
{isMuted ? (
|
||||||
|
<IconVolumeOff size={18} />
|
||||||
|
) : (
|
||||||
|
<IconVolume size={18} />
|
||||||
|
)}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
mounted={showVolume}
|
mounted={showVolume}
|
||||||
transition="scale-y"
|
transition="scale-y"
|
||||||
duration={200}
|
duration={200}
|
||||||
|
timingFunction="ease"
|
||||||
>
|
>
|
||||||
{(style) => (
|
{(style) => (
|
||||||
<Paper
|
<Paper
|
||||||
@@ -249,8 +217,8 @@ export default function FixedPlayerBar() {
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: '100%',
|
bottom: '100%',
|
||||||
right: 0,
|
right: 0,
|
||||||
marginBottom: '10px',
|
mb: 'xs',
|
||||||
padding: '10px',
|
p: 'sm',
|
||||||
zIndex: 1001,
|
zIndex: 1001,
|
||||||
}}
|
}}
|
||||||
shadow="md"
|
shadow="md"
|
||||||
@@ -260,8 +228,8 @@ export default function FixedPlayerBar() {
|
|||||||
value={isMuted ? 0 : volume}
|
value={isMuted ? 0 : volume}
|
||||||
max={100}
|
max={100}
|
||||||
onChange={handleVolumeChange}
|
onChange={handleVolumeChange}
|
||||||
h={80}
|
h={100}
|
||||||
color="#0B4F78"
|
color="blue"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -272,29 +240,30 @@ export default function FixedPlayerBar() {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
size={"md"}
|
size="lg"
|
||||||
onClick={handleMinimizePlayer}
|
onClick={handleClosePlayer}
|
||||||
|
title="Close player"
|
||||||
>
|
>
|
||||||
<IconX size={18} />
|
<IconX size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* Progress Bar - Mobile (Base) */}
|
{/* Progress Bar - Mobile */}
|
||||||
<Box px="xs" mt={4} hiddenFrom="md">
|
<Box mt="xs" display={{ base: 'block', md: 'none' }}>
|
||||||
<Slider
|
<Slider
|
||||||
value={currentTime}
|
value={currentTime}
|
||||||
max={duration || 100}
|
max={duration || 100}
|
||||||
onChange={handleSeek}
|
onChange={handleSeek}
|
||||||
size="xs"
|
size="sm"
|
||||||
color="#0B4F78"
|
color="blue"
|
||||||
label={(value) => formatTime(value)}
|
label={(value) => formatTime(value)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Spacer to prevent content from being hidden behind player */}
|
{/* Spacer to prevent content from being hidden behind player */}
|
||||||
<Box h={{ base: 70, sm: 80 }} />
|
<Box h={80} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { Button } from '@mantine/core';
|
import { Button } from '@mantine/core';
|
||||||
import { IconDisabled, IconDisabledOff } from '@tabler/icons-react';
|
import { IconMusic, IconMusicOff } from '@tabler/icons-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
const NewsReaderLanding = () => {
|
const NewsReaderLanding = () => {
|
||||||
@@ -95,17 +95,15 @@ const NewsReaderLanding = () => {
|
|||||||
mt="md"
|
mt="md"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: '50%', // Menempatkan titik atas ikon di tengah layar
|
bottom: '350px',
|
||||||
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',
|
||||||
cursor: 'pointer',
|
transition: 'all 0.3s ease',
|
||||||
transition: 'transform 0.2s',
|
|
||||||
zIndex: 1
|
zIndex: 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPointerMode ? <IconDisabledOff /> : <IconDisabled />}
|
{isPointerMode ? <IconMusicOff /> : <IconMusic />}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,27 +1,12 @@
|
|||||||
/* 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';
|
||||||
|
|
||||||
interface APBDesItem {
|
function Summary({ title, data }: any) {
|
||||||
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: APBDesItem) => s + i.anggaran, 0);
|
const totalAnggaran = data.reduce((s: number, i: any) => s + i.anggaran, 0);
|
||||||
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
|
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
|
||||||
const totalRealisasi = data.reduce(
|
const totalRealisasi = data.reduce((s: number, i: any) => s + (i.realisasi || i.totalRealisasi || 0), 0);
|
||||||
(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;
|
||||||
@@ -93,21 +78,28 @@ function Summary({ title, data }: SummaryProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GrafikRealisasi({
|
export default function GrafikRealisasi({ apbdesData }: any) {
|
||||||
apbdesData,
|
const items = apbdesData.items || [];
|
||||||
}: {
|
const tahun = apbdesData.tahun || new Date().getFullYear();
|
||||||
apbdesData: {
|
|
||||||
tahun?: number | null;
|
|
||||||
items?: APBDesItem[] | null;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
}) {
|
|
||||||
const items = apbdesData?.items || [];
|
|
||||||
const tahun = apbdesData?.tahun || new Date().getFullYear();
|
|
||||||
|
|
||||||
const pendapatan = items.filter((i: APBDesItem) => i.tipe === 'pendapatan');
|
const pendapatan = items.filter((i: any) => i.tipe === 'pendapatan');
|
||||||
const belanja = items.filter((i: APBDesItem) => i.tipe === 'belanja');
|
const belanja = items.filter((i: any) => i.tipe === 'belanja');
|
||||||
const pembiayaan = items.filter((i: APBDesItem) => i.tipe === 'pembiayaan');
|
const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan');
|
||||||
|
|
||||||
|
// Hitung total keseluruhan
|
||||||
|
const totalAnggaranSemua = items.reduce((s: number, i: any) => s + i.anggaran, 0);
|
||||||
|
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
|
||||||
|
const totalRealisasiSemua = items.reduce((s: number, i: any) => s + (i.realisasi || i.totalRealisasi || 0), 0);
|
||||||
|
const persenSemua = totalAnggaranSemua > 0 ? (totalRealisasiSemua / totalAnggaranSemua) * 100 : 0;
|
||||||
|
|
||||||
|
const formatRupiah = (angka: number) => {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IDR',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(angka);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder p="md" radius="md">
|
<Paper withBorder p="md" radius="md">
|
||||||
@@ -120,6 +112,27 @@ export default function GrafikRealisasi({
|
|||||||
<Summary title="💸 Belanja" data={belanja} />
|
<Summary title="💸 Belanja" data={belanja} />
|
||||||
<Summary title="📊 Pembiayaan" data={pembiayaan} />
|
<Summary title="📊 Pembiayaan" data={pembiayaan} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
{/* Summary Total Keseluruhan */}
|
||||||
|
<Box p="md" bg="gray.0">
|
||||||
|
<>
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text fw={700} fz="lg">TOTAL KESELURUHAN</Text>
|
||||||
|
<Text fw={700} fz="xl" c={persenSemua >= 100 ? 'teal' : persenSemua >= 80 ? 'blue' : 'red'}>
|
||||||
|
{persenSemua.toFixed(2)}%
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fz="sm" c="dimmed" mb="xs">
|
||||||
|
{formatRupiah(totalRealisasiSemua)} / {formatRupiah(totalAnggaranSemua)}
|
||||||
|
</Text>
|
||||||
|
<Progress
|
||||||
|
value={persenSemua}
|
||||||
|
size="lg"
|
||||||
|
radius="xl"
|
||||||
|
color={persenSemua >= 100 ? 'teal' : persenSemua >= 80 ? 'blue' : 'red'}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,24 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { Paper, Table, Title, Text } from '@mantine/core';
|
import { Paper, Table, Title } 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 bg="gray.0">
|
<Table.Tr>
|
||||||
<Table.Td colSpan={2}>
|
<Table.Td colSpan={2}>
|
||||||
<Text fw={700} fz={{ base: 'xs', sm: 'sm' }}>{title}</Text>
|
<strong>{title}</strong>
|
||||||
</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>
|
||||||
<Text fz={{ base: 'xs', sm: 'sm' }} lineClamp={2}>
|
{item.kode} - {item.uraian}
|
||||||
{item.kode} - {item.uraian}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td ta="right">
|
<Table.Td ta="right">
|
||||||
<Text fz={{ base: 'xs', sm: 'sm' }} fw={500} style={{ whiteSpace: 'nowrap' }}>
|
Rp {item.anggaran.toLocaleString('id-ID')}
|
||||||
Rp {item.anggaran.toLocaleString('id-ID')}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
@@ -43,24 +39,22 @@ 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={{ base: 'sm', sm: 'md' }} radius="md">
|
<Paper withBorder p="md" radius="md">
|
||||||
<Title order={5} mb="md" fz={{ base: 'sm', sm: 'md' }}>{title}</Title>
|
<Title order={5} mb="md">{title}</Title>
|
||||||
|
|
||||||
<Table.ScrollContainer minWidth={280}>
|
<Table>
|
||||||
<Table verticalSpacing="xs">
|
<Table.Thead>
|
||||||
<Table.Thead>
|
<Table.Tr>
|
||||||
<Table.Tr>
|
<Table.Th>Uraian</Table.Th>
|
||||||
<Table.Th fz={{ base: 'xs', sm: 'sm' }}>Uraian</Table.Th>
|
<Table.Th ta="right">Anggaran (Rp)</Table.Th>
|
||||||
<Table.Th ta="right" fz={{ base: 'xs', sm: 'sm' }}>Anggaran (Rp)</Table.Th>
|
</Table.Tr>
|
||||||
</Table.Tr>
|
</Table.Thead>
|
||||||
</Table.Thead>
|
<Table.Tbody>
|
||||||
<Table.Tbody>
|
<Section title="1) PENDAPATAN" data={pendapatan} />
|
||||||
<Section title="1) PENDAPATAN" data={pendapatan} />
|
<Section title="2) BELANJA" data={belanja} />
|
||||||
<Section title="2) BELANJA" data={belanja} />
|
<Section title="3) PEMBIAYAAN" data={pembiayaan} />
|
||||||
<Section title="3) PEMBIAYAAN" data={pembiayaan} />
|
</Table.Tbody>
|
||||||
</Table.Tbody>
|
</Table>
|
||||||
</Table>
|
|
||||||
</Table.ScrollContainer>
|
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -30,62 +30,56 @@ export default function RealisasiTable({ apbdesData }: any) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder p={{ base: 'sm', sm: 'md' }} radius="md">
|
<Paper withBorder p="md" radius="md">
|
||||||
<Title order={5} mb="md" fz={{ base: 'sm', sm: 'md' }}>{title}</Title>
|
<Title order={5} mb="md">{title}</Title>
|
||||||
|
|
||||||
{allRealisasiRows.length === 0 ? (
|
{allRealisasiRows.length === 0 ? (
|
||||||
<Text fz="sm" c="dimmed" ta="center" py="md">
|
<Text fz="sm" c="dimmed" ta="center" py="md">
|
||||||
Belum ada data realisasi
|
Belum ada data realisasi
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Table.ScrollContainer minWidth={300}>
|
<Table>
|
||||||
<Table verticalSpacing="xs">
|
<Table.Thead>
|
||||||
<Table.Thead>
|
<Table.Tr>
|
||||||
<Table.Tr>
|
<Table.Th>Uraian</Table.Th>
|
||||||
<Table.Th fz={{ base: 'xs', sm: 'sm' }}>Uraian</Table.Th>
|
<Table.Th ta="right">Realisasi (Rp)</Table.Th>
|
||||||
<Table.Th ta="right" fz={{ base: 'xs', sm: 'sm' }}>Realisasi (Rp)</Table.Th>
|
<Table.Th ta="center">%</Table.Th>
|
||||||
<Table.Th ta="center" fz={{ base: 'xs', sm: 'sm' }}>%</Table.Th>
|
</Table.Tr>
|
||||||
</Table.Tr>
|
</Table.Thead>
|
||||||
</Table.Thead>
|
<Table.Tbody>
|
||||||
<Table.Tbody>
|
{allRealisasiRows.map(({ realisasi, parentItem }) => {
|
||||||
{allRealisasiRows.map(({ realisasi, parentItem }) => {
|
const persentase = parentItem.anggaran > 0
|
||||||
const persentase = parentItem.anggaran > 0
|
? (realisasi.jumlah / parentItem.anggaran) * 100
|
||||||
? (realisasi.jumlah / parentItem.anggaran) * 100
|
: 0;
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Tr key={realisasi.id}>
|
<Table.Tr key={realisasi.id}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text fz={{ base: 'xs', sm: 'sm' }} lineClamp={2}>
|
<Text>{realisasi.kode || '-'} - {realisasi.keterangan || '-'}</Text>
|
||||||
{realisasi.kode || '-'} - {realisasi.keterangan || '-'}
|
</Table.Td>
|
||||||
</Text>
|
<Table.Td ta="right">
|
||||||
</Table.Td>
|
<Text fw={600} c="blue">
|
||||||
<Table.Td ta="right">
|
{formatRupiah(realisasi.jumlah || 0)}
|
||||||
<Text fw={600} c="blue" fz={{ base: 'xs', sm: 'sm' }} style={{ whiteSpace: 'nowrap' }}>
|
</Text>
|
||||||
{formatRupiah(realisasi.jumlah || 0)}
|
</Table.Td>
|
||||||
</Text>
|
<Table.Td ta="center">
|
||||||
</Table.Td>
|
<Badge
|
||||||
<Table.Td ta="center">
|
color={
|
||||||
<Badge
|
persentase >= 100
|
||||||
size="sm"
|
? 'teal'
|
||||||
variant="light"
|
: persentase >= 60
|
||||||
color={
|
? 'yellow'
|
||||||
persentase >= 100
|
: 'red'
|
||||||
? 'teal'
|
}
|
||||||
: persentase >= 60
|
>
|
||||||
? 'yellow'
|
{persentase.toFixed(2)}%
|
||||||
: 'red'
|
</Badge>
|
||||||
}
|
</Table.Td>
|
||||||
>
|
</Table.Tr>
|
||||||
{persentase.toFixed(1)}%
|
);
|
||||||
</Badge>
|
})}
|
||||||
</Table.Td>
|
</Table.Tbody>
|
||||||
</Table.Tr>
|
</Table>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Table.ScrollContainer>
|
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import "@mantine/core/styles.css";
|
import "@mantine/core/styles.css";
|
||||||
import "./globals.css"; // Sisanya import di globals.css
|
import "./globals.css";
|
||||||
|
|
||||||
import LoadDataFirstClient from "@/app/darmasaba/_com/LoadDataFirstClient";
|
import LoadDataFirstClient from "@/app/darmasaba/_com/LoadDataFirstClient";
|
||||||
import { MusicProvider } from "@/app/context/MusicContext";
|
import { MusicProvider } from "@/app/context/MusicContext";
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
MantineProvider,
|
MantineProvider,
|
||||||
createTheme,
|
createTheme,
|
||||||
mantineHtmlProps,
|
mantineHtmlProps,
|
||||||
// mantineHtmlProps,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Metadata, Viewport } from "next";
|
import { Metadata, Viewport } from "next";
|
||||||
import { ViewTransitions } from "next-view-transitions";
|
import { ViewTransitions } from "next-view-transitions";
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ 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">
|
||||||
|
|||||||
122
src/lib/wa-service.ts
Normal file
122
src/lib/wa-service.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* WhatsApp Service - Send OTP via WhatsApp using otp.wibudev.com API
|
||||||
|
* Uses API Key authentication for secure access
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SendWaOtpParams {
|
||||||
|
nomor: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendWaOtpResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
data?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send WhatsApp message using wibudev.com API with authentication
|
||||||
|
* @param params - { nomor: string, message: string }
|
||||||
|
* @returns Promise<SendWaOtpResponse>
|
||||||
|
*/
|
||||||
|
export async function sendWhatsAppOtp({
|
||||||
|
nomor,
|
||||||
|
message,
|
||||||
|
}: SendWaOtpParams): Promise<SendWaOtpResponse> {
|
||||||
|
const apiKey = process.env.WIBU_WA_API_KEY;
|
||||||
|
const waApiUrl = process.env.WIBU_WA_API_URL || 'https://wa.wibudev.com';
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
console.error('❌ WIBU_WA_API_KEY is not configured');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'WhatsApp API key not configured',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Using the API endpoint with authentication
|
||||||
|
// Format: GET https://wa.wibudev.com/code?nom=...&text=...
|
||||||
|
// With Authorization header for API key
|
||||||
|
const url = `${waApiUrl}/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(message)}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'X-API-Key': apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('❌ WA API Error:', result);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: result.message || 'Failed to send WhatsApp message',
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if response has success status
|
||||||
|
if (result.status !== 'success') {
|
||||||
|
console.error('❌ WA API Status Error:', result);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: result.message || 'Failed to send WhatsApp message',
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ WA Response:', result);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'WhatsApp sent successfully',
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fetch WA API Error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy function for backward compatibility (deprecated)
|
||||||
|
* @deprecated Use sendWhatsAppOtp instead
|
||||||
|
*/
|
||||||
|
export async function sendWhatsAppOtpLegacy({
|
||||||
|
nomor,
|
||||||
|
message,
|
||||||
|
}: SendWaOtpParams): Promise<SendWaOtpResponse> {
|
||||||
|
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(message)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(waUrl);
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
if (result.status !== 'success') {
|
||||||
|
console.error('❌ WA Legacy Error:', result);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: result.message || 'Failed to send WhatsApp message',
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'WhatsApp sent successfully',
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fetch WA Legacy Error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user