Compare commits
8 Commits
test-model
...
fix-wa-otp
| Author | SHA1 | Date | |
|---|---|---|---|
| 9190840c48 | |||
| 781d125d4c | |||
| 144ac37e12 | |||
| f90477ed63 | |||
| f63aaf916d | |||
| 2d901912ea | |||
| a791efe76c | |||
| e9f7bc2043 |
@@ -238,19 +238,21 @@ model APBDesItem {
|
||||
// Model baru untuk multiple realisasi per item
|
||||
model RealisasiItem {
|
||||
id String @id @default(cuid())
|
||||
kode String? // Kode realisasi, mirip dengan APBDesItem
|
||||
apbdesItemId String
|
||||
apbdesItem APBDesItem @relation(fields: [apbdesItemId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
||||
jumlah Float // Jumlah realisasi dalam Rupiah
|
||||
tanggal DateTime @db.Date // Tanggal realisasi
|
||||
keterangan String? @db.Text // Keterangan tambahan (opsional)
|
||||
buktiFileId String? // FileStorage ID untuk bukti/foto (opsional)
|
||||
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
@@index([kode])
|
||||
@@index([apbdesItemId])
|
||||
@@index([tanggal])
|
||||
}
|
||||
|
||||
@@ -5,21 +5,17 @@ import { toast } from "react-toastify";
|
||||
import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
|
||||
// --- Zod Schema untuk APBDes Item (tanpa field kalkulasi) ---
|
||||
// --- Zod Schema untuk APBDes Item (dengan field kalkulasi) ---
|
||||
const ApbdesItemSchema = z.object({
|
||||
kode: z.string().min(1, "Kode wajib diisi"),
|
||||
uraian: z.string().min(1, "Uraian wajib diisi"),
|
||||
anggaran: z.number().min(0, "Anggaran tidak boleh negatif"),
|
||||
level: z.number().int().min(1).max(3),
|
||||
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
|
||||
});
|
||||
|
||||
// --- Zod Schema untuk Realisasi Item ---
|
||||
const RealisasiItemSchema = z.object({
|
||||
jumlah: z.number().min(0, "Jumlah tidak boleh negatif"),
|
||||
tanggal: z.string(),
|
||||
keterangan: z.string().optional(),
|
||||
buktiFileId: z.string().optional(),
|
||||
// Field kalkulasi dari realisasiItems (auto-calculated di backend)
|
||||
realisasi: z.number().min(0).default(0),
|
||||
selisih: z.number().default(0),
|
||||
persentase: z.number().default(0),
|
||||
});
|
||||
|
||||
const ApbdesFormSchema = z.object({
|
||||
@@ -43,7 +39,7 @@ const defaultApbdesForm = {
|
||||
items: [] as z.infer<typeof ApbdesItemSchema>[],
|
||||
};
|
||||
|
||||
// --- Helper: Normalize item (tanpa kalkulasi, backend yang hitung) ---
|
||||
// --- Helper: Normalize item (dengan field kalkulasi) ---
|
||||
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
|
||||
return {
|
||||
kode: item.kode || "",
|
||||
@@ -51,6 +47,9 @@ function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer
|
||||
anggaran: item.anggaran ?? 0,
|
||||
level: item.level || 1,
|
||||
tipe: item.tipe ?? null,
|
||||
realisasi: item.realisasi ?? 0,
|
||||
selisih: item.selisih ?? 0,
|
||||
persentase: item.persentase ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,7 +111,7 @@ const apbdes = proxy({
|
||||
findMany: {
|
||||
data: null as
|
||||
| Prisma.APBDesGetPayload<{
|
||||
include: { image: true; file: true; items: true };
|
||||
include: { image: true; file: true; items: { include: { realisasiItems: true } } };
|
||||
}>[]
|
||||
| null,
|
||||
page: 1,
|
||||
@@ -157,33 +156,37 @@ const apbdes = proxy({
|
||||
findUnique: {
|
||||
data: null as
|
||||
| Prisma.APBDesGetPayload<{
|
||||
include: { image: true; file: true; items: true };
|
||||
include: { image: true; file: true; items: { include: { realisasiItems: true } } };
|
||||
}>
|
||||
| null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
|
||||
|
||||
async load(id: string) {
|
||||
if (!id || id.trim() === '') {
|
||||
this.data = null;
|
||||
this.error = "ID tidak valid";
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Prevent multiple simultaneous loads
|
||||
if (this.loading) {
|
||||
console.log("⚠️ Already loading, skipping...");
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
|
||||
try {
|
||||
// Pastikan URL-nya benar
|
||||
const url = `/api/landingpage/apbdes/${id}`;
|
||||
console.log("🌐 Fetching:", url);
|
||||
|
||||
// Gunakan fetch biasa atau ApiFetch dengan cara yang benar
|
||||
|
||||
const response = await fetch(url);
|
||||
const res = await response.json();
|
||||
|
||||
|
||||
console.log("📦 Response:", res);
|
||||
|
||||
|
||||
if (res.success && res.data) {
|
||||
this.data = res.data;
|
||||
} else {
|
||||
@@ -252,6 +255,9 @@ const apbdes = proxy({
|
||||
kode: item.kode,
|
||||
uraian: item.uraian,
|
||||
anggaran: item.anggaran,
|
||||
realisasi: item.totalRealisasi || 0,
|
||||
selisih: item.selisih || 0,
|
||||
persentase: item.persentase || 0,
|
||||
level: item.level,
|
||||
tipe: item.tipe || 'pendapatan',
|
||||
})),
|
||||
@@ -279,11 +285,24 @@ const apbdes = proxy({
|
||||
try {
|
||||
this.loading = true;
|
||||
// Include the ID in the request body
|
||||
// Omit realisasi, selisih, persentase karena itu calculated fields di backend
|
||||
const requestData = {
|
||||
...parsed.data,
|
||||
id: this.id, // Add the ID to the request body
|
||||
tahun: parsed.data.tahun,
|
||||
name: parsed.data.name,
|
||||
deskripsi: parsed.data.deskripsi,
|
||||
jumlah: parsed.data.jumlah,
|
||||
imageId: parsed.data.imageId,
|
||||
fileId: parsed.data.fileId,
|
||||
id: this.id,
|
||||
items: parsed.data.items.map(item => ({
|
||||
kode: item.kode,
|
||||
uraian: item.uraian,
|
||||
anggaran: item.anggaran,
|
||||
level: item.level,
|
||||
tipe: item.tipe ?? null,
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
|
||||
|
||||
if (res.data?.success) {
|
||||
@@ -322,15 +341,16 @@ const apbdes = proxy({
|
||||
// =========================================
|
||||
realisasi: {
|
||||
// Create realisasi
|
||||
async create(itemId: string, data: { jumlah: number; tanggal: string; keterangan?: string; buktiFileId?: string }) {
|
||||
async create(itemId: string, data: { kode: string; jumlah: number; tanggal: string; keterangan?: string; buktiFileId?: string }) {
|
||||
try {
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any)[itemId].realisasi.post(data);
|
||||
|
||||
if (res.data?.success) {
|
||||
toast.success("Realisasi berhasil ditambahkan");
|
||||
// Reload findUnique untuk update data
|
||||
if (apbdes.findUnique.data) {
|
||||
await apbdes.findUnique.load(apbdes.findUnique.data.id);
|
||||
const currentId = apbdes.findUnique.data?.id;
|
||||
if (currentId) {
|
||||
await apbdes.findUnique.load(currentId);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
@@ -345,15 +365,16 @@ const apbdes = proxy({
|
||||
},
|
||||
|
||||
// Update realisasi
|
||||
async update(realisasiId: string, data: { jumlah?: number; tanggal?: string; keterangan?: string; buktiFileId?: string }) {
|
||||
async update(realisasiId: string, data: { kode?: string; jumlah?: number; tanggal?: string; keterangan?: string; buktiFileId?: string }) {
|
||||
try {
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].put(data);
|
||||
|
||||
if (res.data?.success) {
|
||||
toast.success("Realisasi berhasil diperbarui");
|
||||
// Reload findUnique untuk update data
|
||||
if (apbdes.findUnique.data) {
|
||||
await apbdes.findUnique.load(apbdes.findUnique.data.id);
|
||||
const currentId = apbdes.findUnique.data?.id;
|
||||
if (currentId) {
|
||||
await apbdes.findUnique.load(currentId);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -23,7 +25,6 @@ import {
|
||||
Badge,
|
||||
Modal,
|
||||
Divider,
|
||||
Loader,
|
||||
Center,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
@@ -33,9 +34,6 @@ import {
|
||||
IconCalendar,
|
||||
IconCoin,
|
||||
} from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import colors from '@/con/colors';
|
||||
|
||||
interface RealisasiManagerProps {
|
||||
itemId: string;
|
||||
@@ -63,6 +61,7 @@ export default function RealisasiManager({
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
kode: '',
|
||||
jumlah: 0,
|
||||
tanggal: new Date().toISOString().split('T')[0], // YYYY-MM-DD format for input
|
||||
keterangan: '',
|
||||
@@ -70,6 +69,7 @@ export default function RealisasiManager({
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
kode: '',
|
||||
jumlah: 0,
|
||||
tanggal: new Date().toISOString().split('T')[0],
|
||||
keterangan: '',
|
||||
@@ -85,8 +85,9 @@ export default function RealisasiManager({
|
||||
const handleOpenEdit = (realisasi: any) => {
|
||||
const tanggal = new Date(realisasi.tanggal);
|
||||
const tanggalStr = tanggal.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
|
||||
|
||||
setFormData({
|
||||
kode: realisasi.kode || '',
|
||||
jumlah: realisasi.jumlah,
|
||||
tanggal: tanggalStr,
|
||||
keterangan: realisasi.keterangan || '',
|
||||
@@ -100,12 +101,17 @@ export default function RealisasiManager({
|
||||
return toast.warn('Jumlah realisasi harus lebih dari 0');
|
||||
}
|
||||
|
||||
if (!formData.kode || formData.kode.trim() === '') {
|
||||
return toast.warn('Kode realisasi wajib diisi');
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
if (editingId) {
|
||||
// Update existing realisasi
|
||||
const success = await state.realisasi.update(editingId, {
|
||||
kode: formData.kode,
|
||||
jumlah: formData.jumlah,
|
||||
tanggal: new Date(formData.tanggal).toISOString(),
|
||||
keterangan: formData.keterangan,
|
||||
@@ -117,6 +123,7 @@ export default function RealisasiManager({
|
||||
} else {
|
||||
// Create new realisasi
|
||||
const success = await state.realisasi.create(itemId, {
|
||||
kode: formData.kode,
|
||||
jumlah: formData.jumlah,
|
||||
tanggal: new Date(formData.tanggal).toISOString(),
|
||||
keterangan: formData.keterangan,
|
||||
@@ -257,6 +264,7 @@ export default function RealisasiManager({
|
||||
<Table striped highlightOnHover fz="sm">
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>Kode</TableTh>
|
||||
<TableTh>Tanggal</TableTh>
|
||||
<TableTh>Uraian</TableTh>
|
||||
<TableTh ta="right">Jumlah</TableTh>
|
||||
@@ -266,6 +274,11 @@ export default function RealisasiManager({
|
||||
<TableTbody>
|
||||
{realisasiItems.map((realisasi) => (
|
||||
<TableTr key={realisasi.id}>
|
||||
<TableTd>
|
||||
<Badge variant="light" color="blue" size="sm">
|
||||
{realisasi.kode || '-'}
|
||||
</Badge>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Group gap="xs">
|
||||
<IconCalendar size={16} />
|
||||
@@ -314,7 +327,7 @@ export default function RealisasiManager({
|
||||
Belum ada realisasi untuk item ini
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Klik tombol "Tambah Realisasi" untuk menambahkan
|
||||
Klik tombol "Tambah Realisasi" untuk menambahkan
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
@@ -349,6 +362,15 @@ export default function RealisasiManager({
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
<TextInput
|
||||
label="Kode Realisasi"
|
||||
placeholder="Contoh: 4.1.1-R1"
|
||||
value={formData.kode}
|
||||
onChange={(e) => setFormData({ ...formData, kode: e.target.value })}
|
||||
description="Kode unik untuk realisasi ini"
|
||||
required
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Jumlah Realisasi (Rp)"
|
||||
value={formData.jumlah}
|
||||
|
||||
@@ -44,6 +44,9 @@ type ItemForm = {
|
||||
anggaran: number;
|
||||
level: number;
|
||||
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
|
||||
realisasi?: number;
|
||||
selisih?: number;
|
||||
persentase?: number;
|
||||
};
|
||||
|
||||
function EditAPBDes() {
|
||||
@@ -72,6 +75,9 @@ function EditAPBDes() {
|
||||
anggaran: 0,
|
||||
level: 1,
|
||||
tipe: 'pendapatan',
|
||||
realisasi: 0,
|
||||
selisih: 0,
|
||||
persentase: 0,
|
||||
});
|
||||
|
||||
// Simpan data original untuk reset form
|
||||
@@ -125,9 +131,9 @@ function EditAPBDes() {
|
||||
kode: item.kode,
|
||||
uraian: item.uraian,
|
||||
anggaran: item.anggaran,
|
||||
realisasi: item.realisasi,
|
||||
selisih: item.selisih,
|
||||
persentase: item.persentase,
|
||||
realisasi: item.totalRealisasi || 0,
|
||||
selisih: item.selisih || 0,
|
||||
persentase: item.persentase || 0,
|
||||
level: item.level,
|
||||
tipe: item.tipe || 'pendapatan',
|
||||
})),
|
||||
@@ -155,7 +161,7 @@ function EditAPBDes() {
|
||||
};
|
||||
|
||||
const handleAddItem = () => {
|
||||
const { kode, uraian, anggaran, level, tipe } = newItem;
|
||||
const { kode, uraian, anggaran, level, tipe, realisasi, selisih, persentase } = newItem;
|
||||
if (!kode || !uraian) {
|
||||
return toast.warn('Kode dan uraian wajib diisi');
|
||||
}
|
||||
@@ -166,6 +172,9 @@ function EditAPBDes() {
|
||||
kode,
|
||||
uraian,
|
||||
anggaran,
|
||||
realisasi: realisasi || 0,
|
||||
selisih: selisih || 0,
|
||||
persentase: persentase || 0,
|
||||
level,
|
||||
tipe: finalTipe,
|
||||
});
|
||||
@@ -176,6 +185,9 @@ function EditAPBDes() {
|
||||
anggaran: 0,
|
||||
level: 1,
|
||||
tipe: 'pendapatan',
|
||||
realisasi: 0,
|
||||
selisih: 0,
|
||||
persentase: 0,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -264,6 +276,9 @@ function EditAPBDes() {
|
||||
anggaran: 0,
|
||||
level: 1,
|
||||
tipe: 'pendapatan',
|
||||
realisasi: 0,
|
||||
selisih: 0,
|
||||
persentase: 0,
|
||||
});
|
||||
|
||||
toast.info('Form dikembalikan ke data awal');
|
||||
@@ -527,6 +542,9 @@ function EditAPBDes() {
|
||||
<th>Kode</th>
|
||||
<th>Uraian</th>
|
||||
<th>Anggaran</th>
|
||||
<th>Realisasi</th>
|
||||
<th>Selisih</th>
|
||||
<th>%</th>
|
||||
<th>Level</th>
|
||||
<th>Tipe</th>
|
||||
<th style={{ width: '50px' }}>Aksi</th>
|
||||
@@ -542,6 +560,11 @@ function EditAPBDes() {
|
||||
</td>
|
||||
<td>{item.uraian}</td>
|
||||
<td>{item.anggaran.toLocaleString('id-ID')}</td>
|
||||
<td>{item.realisasi?.toLocaleString('id-ID') || '0'}</td>
|
||||
<td style={{ color: item.selisih && item.selisih > 0 ? 'red' : 'green' }}>
|
||||
{item.selisih?.toLocaleString('id-ID') || '0'}
|
||||
</td>
|
||||
<td>{item.persentase?.toFixed(2) || '0'}%</td>
|
||||
<td>
|
||||
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
|
||||
L{item.level}
|
||||
|
||||
@@ -233,7 +233,7 @@ function DetailAPBDes() {
|
||||
</Table>
|
||||
|
||||
{/* Realisasi Manager untuk setiap item */}
|
||||
{data.items.map((item: any) => (
|
||||
{data.items.map((item) => (
|
||||
<RealisasiManager
|
||||
key={item.id}
|
||||
itemId={item.id}
|
||||
|
||||
@@ -69,6 +69,7 @@ const APBDes = new Elysia({
|
||||
.post("/:itemId/realisasi", realisasiCreate, {
|
||||
params: t.Object({ itemId: t.String() }),
|
||||
body: t.Object({
|
||||
kode: t.String(),
|
||||
jumlah: t.Number(),
|
||||
tanggal: t.String(),
|
||||
keterangan: t.Optional(t.String()),
|
||||
@@ -80,6 +81,7 @@ const APBDes = new Elysia({
|
||||
.put("/realisasi/:realisasiId", realisasiUpdate, {
|
||||
params: t.Object({ realisasiId: t.String() }),
|
||||
body: t.Object({
|
||||
kode: t.Optional(t.String()),
|
||||
jumlah: t.Optional(t.Number()),
|
||||
tanggal: t.Optional(t.String()),
|
||||
keterangan: t.Optional(t.String()),
|
||||
|
||||
@@ -3,6 +3,7 @@ import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
type RealisasiCreateBody = {
|
||||
kode: string;
|
||||
jumlah: number;
|
||||
tanggal: string; // ISO format
|
||||
keterangan?: string;
|
||||
@@ -33,6 +34,7 @@ export default async function realisasiCreate(context: Context) {
|
||||
const realisasi = await prisma.realisasiItem.create({
|
||||
data: {
|
||||
apbdesItemId: itemId,
|
||||
kode: body.kode,
|
||||
jumlah: body.jumlah,
|
||||
tanggal: new Date(body.tanggal),
|
||||
keterangan: body.keterangan,
|
||||
|
||||
@@ -3,6 +3,7 @@ import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
type RealisasiUpdateBody = {
|
||||
kode?: string;
|
||||
jumlah?: number;
|
||||
tanggal?: string;
|
||||
keterangan?: string;
|
||||
@@ -33,6 +34,7 @@ export default async function realisasiUpdate(context: Context) {
|
||||
const updated = await prisma.realisasiItem.update({
|
||||
where: { id: realisasiId },
|
||||
data: {
|
||||
...(body.kode !== undefined && { kode: body.kode }),
|
||||
...(body.jumlah !== undefined && { jumlah: body.jumlah }),
|
||||
...(body.tanggal !== undefined && { tanggal: new Date(body.tanggal) }),
|
||||
...(body.keterangan !== undefined && { keterangan: body.keterangan }),
|
||||
|
||||
@@ -28,6 +28,16 @@ export default async function apbdesUpdate(context: Context) {
|
||||
// 1. Pastikan APBDes ada
|
||||
const existing = await prisma.aPBDes.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
items: {
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
realisasiItems: {
|
||||
where: { isActive: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
@@ -38,17 +48,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: any[];
|
||||
}>();
|
||||
|
||||
existing.items.forEach(item => {
|
||||
existingItemsMap.set(item.kode, {
|
||||
id: item.id,
|
||||
realisasiItems: item.realisasiItems,
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Hapus semua item lama (cascade akan menghapus realisasiItems juga)
|
||||
// TAPI kita sudah save realisasiItems di map atas
|
||||
await prisma.aPBDesItem.deleteMany({
|
||||
where: { apbdesId: id },
|
||||
});
|
||||
|
||||
// 3. Buat item baru dengan auto-calculate fields
|
||||
// 4. Buat item baru dengan preserve realisasiItems
|
||||
await prisma.aPBDesItem.createMany({
|
||||
data: body.items.map((item) => {
|
||||
data: await Promise.all(body.items.map(async (item) => {
|
||||
const anggaran = item.anggaran;
|
||||
const totalRealisasi = 0; // Reset karena items baru
|
||||
const selisih = anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
|
||||
|
||||
// Check apakah item ini punya realisasiItems lama
|
||||
const existingItem = existingItemsMap.get(item.kode);
|
||||
const realisasiItemsData = existingItem?.realisasiItems || [];
|
||||
const totalRealisasi = realisasiItemsData.reduce((sum, r) => sum + r.jumlah, 0);
|
||||
const selisih = anggaran - totalRealisasi;
|
||||
const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0;
|
||||
|
||||
return {
|
||||
@@ -63,16 +91,68 @@ export default async function apbdesUpdate(context: Context) {
|
||||
persentase,
|
||||
isActive: true,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
});
|
||||
|
||||
// 4. Dapatkan semua item yang baru dibuat untuk mendapatkan ID-nya
|
||||
// 5. Dapatkan semua item yang baru dibuat untuk mendapatkan ID-nya
|
||||
const allItems = await prisma.aPBDesItem.findMany({
|
||||
where: { apbdesId: id },
|
||||
select: { id: true, kode: true },
|
||||
});
|
||||
|
||||
// 5. Update parentId untuk setiap item
|
||||
// 6. Build map baru untuk item IDs
|
||||
const newItemIdsMap = new Map<string, string>();
|
||||
allItems.forEach(item => {
|
||||
newItemIdsMap.set(item.kode, item.id);
|
||||
});
|
||||
|
||||
// 7. Re-create realisasiItems dengan link ke item IDs yang baru
|
||||
for (const [oldKode, oldItemData] of existingItemsMap.entries()) {
|
||||
if (oldItemData.realisasiItems.length > 0) {
|
||||
const newItemId = newItemIdsMap.get(oldKode);
|
||||
if (newItemId) {
|
||||
// Re-create realisasiItems untuk item ini
|
||||
await prisma.realisasiItem.createMany({
|
||||
data: oldItemData.realisasiItems.map(r => ({
|
||||
apbdesItemId: newItemId,
|
||||
kode: r.kode,
|
||||
jumlah: r.jumlah,
|
||||
tanggal: r.tanggal,
|
||||
keterangan: r.keterangan,
|
||||
buktiFileId: r.buktiFileId,
|
||||
isActive: true,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Recalculate totalRealisasi setelah re-create realisasiItems
|
||||
for (const [kode, _] of existingItemsMap.entries()) {
|
||||
const newItemId = newItemIdsMap.get(kode);
|
||||
if (newItemId) {
|
||||
const realisasiItems = await prisma.realisasiItem.findMany({
|
||||
where: { apbdesItemId: newItemId, isActive: true },
|
||||
});
|
||||
const totalRealisasi = realisasiItems.reduce((sum, r) => sum + r.jumlah, 0);
|
||||
|
||||
const item = await prisma.aPBDesItem.findUnique({
|
||||
where: { id: newItemId },
|
||||
});
|
||||
|
||||
if (item) {
|
||||
const selisih = item.anggaran - totalRealisasi;
|
||||
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
|
||||
|
||||
await prisma.aPBDesItem.update({
|
||||
where: { id: newItemId },
|
||||
data: { totalRealisasi, selisih, persentase },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Update parentId untuk setiap item
|
||||
const itemsForParentUpdate = allItems.map(item => ({
|
||||
id: item.id,
|
||||
kode: item.kode,
|
||||
@@ -80,7 +160,7 @@ export default async function apbdesUpdate(context: Context) {
|
||||
|
||||
await assignParentIdsToApbdesItems(itemsForParentUpdate);
|
||||
|
||||
// 6. Update data APBDes
|
||||
// 10. Update data APBDes
|
||||
await prisma.aPBDes.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -93,7 +173,7 @@ export default async function apbdesUpdate(context: Context) {
|
||||
},
|
||||
});
|
||||
|
||||
// 7. Ambil data lengkap untuk response (include realisasiItems)
|
||||
// 11. Ambil data lengkap untuk response (include realisasiItems)
|
||||
const result = await prisma.aPBDes.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import prisma from "@/lib/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomOTP } from "../_lib/randomOTP";
|
||||
import { cookies } from "next/headers";
|
||||
import { sendWhatsAppOtp } from "@/lib/wa-service";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (req.method !== "POST") {
|
||||
@@ -34,31 +35,22 @@ export async function POST(req: Request) {
|
||||
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 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") {
|
||||
console.error("❌ WA Service Error:", sendWa);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "Gagal mengirim OTP via WhatsApp",
|
||||
debug: sendWa
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch (waError) {
|
||||
console.error("❌ Fetch WA Error:", waError);
|
||||
// Send OTP via WhatsApp using authenticated API
|
||||
const waResult = await sendWhatsAppOtp({
|
||||
nomor,
|
||||
message: waMessage,
|
||||
});
|
||||
|
||||
if (!waResult.success) {
|
||||
console.error("❌ WA Service Error:", waResult);
|
||||
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 prisma from '@/lib/prisma';
|
||||
import { randomOTP } from '../_lib/randomOTP';
|
||||
import { sendWhatsAppOtp } from '@/lib/wa-service';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -24,12 +25,18 @@ export async function POST(req: Request) {
|
||||
const otpNumber = Number(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") {
|
||||
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP via WhatsApp' }, { status: 400 });
|
||||
// Send OTP via WhatsApp using authenticated API
|
||||
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
|
||||
|
||||
@@ -3,6 +3,7 @@ import prisma from "@/lib/prisma";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomOTP } from "../_lib/randomOTP";
|
||||
import { sendWhatsAppOtp } from "@/lib/wa-service";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -18,15 +19,17 @@ export async function POST(req: Request) {
|
||||
const codeOtp = randomOTP();
|
||||
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 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(
|
||||
{ success: false, message: "Gagal mengirim OTP via WhatsApp" },
|
||||
{ success: false, message: waResult.message || "Gagal mengirim OTP via WhatsApp", debug: waResult.data },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomOTP } from "../_lib/randomOTP";
|
||||
import { sendWhatsAppOtp } from "@/lib/wa-service";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -22,13 +23,18 @@ export async function POST(req: Request) {
|
||||
const codeOtp = randomOTP();
|
||||
const otpNumber = Number(codeOtp);
|
||||
|
||||
// Kirim WA
|
||||
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
|
||||
const res = await fetch(waUrl);
|
||||
const sendWa = await res.json();
|
||||
// Kirim WA menggunakan API terautentikasi
|
||||
const waMessage = `Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
|
||||
const waResult = await sendWhatsAppOtp({
|
||||
nomor,
|
||||
message: waMessage,
|
||||
});
|
||||
|
||||
if (sendWa.status !== "success") {
|
||||
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP' }, { status: 400 });
|
||||
if (!waResult.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: waResult.message || 'Gagal mengirim OTP', debug: waResult.data },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Simpan OTP
|
||||
|
||||
313
src/app/context/MusicContext.tsx
Normal file
313
src/app/context/MusicContext.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
'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);
|
||||
|
||||
// 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', () => {
|
||||
if (isRepeat) {
|
||||
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, isRepeat]);
|
||||
|
||||
// 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'
|
||||
import { useMusic } from '@/app/context/MusicContext';
|
||||
import { ActionIcon, Avatar, Badge, Box, Card, Flex, Grid, Group, Paper, ScrollArea, Slider, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { IconArrowsShuffle, IconPlayerPauseFilled, IconPlayerPlayFilled, IconPlayerSkipBackFilled, IconPlayerSkipForwardFilled, IconRepeat, IconRepeatOff, IconSearch, IconVolume, IconVolumeOff, IconX } from '@tabler/icons-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import BackButton from '../../desa/layanan/_com/BackButto';
|
||||
import { togglePlayPause } from '../lib/playPause';
|
||||
import { getNextIndex, getPrevIndex } from '../lib/nextPrev';
|
||||
import { handleRepeatOrNext } from '../lib/repeat';
|
||||
import { toggleShuffle } from '../lib/shuffle';
|
||||
import { setAudioVolume, toggleMute as toggleMuteUtil } from '../lib/volume';
|
||||
import { useAudioProgress } from '../lib/useAudioProgress';
|
||||
|
||||
interface MusicFile {
|
||||
id: string;
|
||||
name: string;
|
||||
realName: string;
|
||||
path: string;
|
||||
mimeType: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
interface Musik {
|
||||
id: string;
|
||||
judul: string;
|
||||
artis: string;
|
||||
deskripsi: string | null;
|
||||
durasi: string;
|
||||
genre: string | null;
|
||||
tahunRilis: number | null;
|
||||
audioFile: MusicFile | null;
|
||||
coverImage: MusicFile | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const MusicPlayer = () => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(70);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isRepeat, setIsRepeat] = useState(false);
|
||||
const [isShuffle, setIsShuffle] = useState(false);
|
||||
const {
|
||||
isPlaying,
|
||||
currentSong,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
isMuted,
|
||||
isRepeat,
|
||||
isShuffle,
|
||||
isLoading,
|
||||
musikData,
|
||||
playSong,
|
||||
togglePlayPause,
|
||||
playNext,
|
||||
playPrev,
|
||||
seek,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
toggleRepeat,
|
||||
toggleShuffle,
|
||||
} = useMusic();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [musikData, setMusikData] = useState<Musik[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentSongIndex, setCurrentSongIndex] = useState(-1);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const isSeekingRef = useRef(false);
|
||||
const lastPlayedSongIdRef = useRef<string | null>(null);
|
||||
const lastSeekTimeRef = useRef<number>(0); // Track last seek time
|
||||
|
||||
// Smooth progress update dengan requestAnimationFrame
|
||||
useAudioProgress(audioRef as React.RefObject<HTMLAudioElement>, isPlaying, setCurrentTime, isSeekingRef);
|
||||
// Fetch musik data from global state
|
||||
const { loadMusikData } = useMusic();
|
||||
|
||||
// Fetch musik data from API
|
||||
useEffect(() => {
|
||||
const fetchMusik = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/desa/musik/find-many?page=1&limit=50');
|
||||
const data = await res.json();
|
||||
if (data.success && data.data) {
|
||||
const activeMusik = data.data.filter((m: Musik) => m.isActive);
|
||||
setMusikData(activeMusik);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching musik:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMusik();
|
||||
}, []);
|
||||
loadMusikData();
|
||||
}, [loadMusikData]);
|
||||
|
||||
// Filter musik based on search - gunakan useMemo untuk mencegah re-calculate setiap render
|
||||
const filteredMusik = useMemo(() => {
|
||||
@@ -82,146 +46,42 @@ const MusicPlayer = () => {
|
||||
);
|
||||
}, [musikData, search]);
|
||||
|
||||
const currentSong = currentSongIndex >= 0 && currentSongIndex < filteredMusik.length
|
||||
? filteredMusik[currentSongIndex]
|
||||
: null;
|
||||
|
||||
// // Update progress bar
|
||||
// useEffect(() => {
|
||||
// if (isPlaying && audioRef.current) {
|
||||
// progressInterval.current = window.setInterval(() => {
|
||||
// if (audioRef.current) {
|
||||
// setCurrentTime(Math.floor(audioRef.current.currentTime));
|
||||
// }
|
||||
// }, 1000);
|
||||
// } else {
|
||||
// if (progressInterval.current) {
|
||||
// clearInterval(progressInterval.current);
|
||||
// }
|
||||
// }
|
||||
|
||||
// return () => {
|
||||
// if (progressInterval.current) {
|
||||
// clearInterval(progressInterval.current);
|
||||
// }
|
||||
// };
|
||||
// }, [isPlaying]);
|
||||
|
||||
// Update duration when song changes (HANYA saat ganti lagu, bukan saat isPlaying berubah)
|
||||
useEffect(() => {
|
||||
if (currentSong && audioRef.current) {
|
||||
// Cek apakah ini benar-benar lagu baru
|
||||
const isNewSong = lastPlayedSongIdRef.current !== currentSong.id;
|
||||
|
||||
if (isNewSong) {
|
||||
// Gunakan durasi dari database sebagai acuan utama
|
||||
const durationParts = currentSong.durasi.split(':');
|
||||
const durationInSeconds = parseInt(durationParts[0]) * 60 + parseInt(durationParts[1]);
|
||||
setDuration(durationInSeconds);
|
||||
|
||||
// Reset audio currentTime ke 0 untuk lagu baru
|
||||
audioRef.current.currentTime = 0;
|
||||
setCurrentTime(0);
|
||||
|
||||
// Update ref
|
||||
lastPlayedSongIdRef.current = currentSong.id;
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current.play().catch(err => {
|
||||
console.error('Error playing audio:', err);
|
||||
setIsPlaying(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Jika bukan lagu baru, jangan reset currentTime (biar seek tidak kembali ke 0)
|
||||
}
|
||||
}, [currentSong?.id]); // Intentional: hanya depend on song ID, bukan isPlaying
|
||||
|
||||
// Sync duration dari audio element jika berbeda signifikan (> 1 detik)
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !currentSong) return;
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
const audioDuration = Math.floor(audio.duration);
|
||||
const durationParts = currentSong.durasi.split(':');
|
||||
const dbDuration = parseInt(durationParts[0]) * 60 + parseInt(durationParts[1]);
|
||||
|
||||
// Jika perbedaan > 2 detik, gunakan audio duration (lebih akurat)
|
||||
if (Math.abs(audioDuration - dbDuration) > 2) {
|
||||
setDuration(audioDuration);
|
||||
}
|
||||
};
|
||||
|
||||
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
return () => audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
}, [currentSong?.id]); // Intentional: hanya depend on song ID
|
||||
|
||||
// Format time helper
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const playSong = (index: number) => {
|
||||
if (index < 0 || index >= filteredMusik.length) return;
|
||||
|
||||
setCurrentSongIndex(index);
|
||||
setIsPlaying(true);
|
||||
const handleVolumeChange = (value: number) => {
|
||||
setVolume(value);
|
||||
};
|
||||
|
||||
const handleSongEnd = () => {
|
||||
const playNext = () => {
|
||||
let nextIndex: number;
|
||||
if (isShuffle) {
|
||||
nextIndex = Math.floor(Math.random() * filteredMusik.length);
|
||||
} else {
|
||||
nextIndex = (currentSongIndex + 1) % filteredMusik.length;
|
||||
}
|
||||
|
||||
if (filteredMusik.length > 1) {
|
||||
playSong(nextIndex);
|
||||
} else {
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
}
|
||||
};
|
||||
|
||||
handleRepeatOrNext(audioRef, isRepeat, playNext);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
toggleMuteUtil(audioRef, isMuted, setIsMuted);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (val: number) => {
|
||||
setAudioVolume(audioRef, val, setVolume, setIsMuted);
|
||||
};
|
||||
|
||||
const skipBack = () => {
|
||||
const prevIndex = getPrevIndex(currentSongIndex, filteredMusik.length, isShuffle);
|
||||
if (prevIndex >= 0) {
|
||||
playSong(prevIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const skipForward = () => {
|
||||
const nextIndex = getNextIndex(currentSongIndex, filteredMusik.length, isShuffle);
|
||||
if (nextIndex >= 0) {
|
||||
playSong(nextIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleShuffleHandler = () => {
|
||||
toggleShuffle(isShuffle, setIsShuffle);
|
||||
const toggleMuteHandler = () => {
|
||||
toggleMute();
|
||||
};
|
||||
|
||||
const togglePlayPauseHandler = () => {
|
||||
if (!currentSong) return;
|
||||
togglePlayPause(audioRef, isPlaying, setIsPlaying);
|
||||
togglePlayPause();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
const skipBack = () => {
|
||||
playPrev();
|
||||
};
|
||||
|
||||
const skipForward = () => {
|
||||
playNext();
|
||||
};
|
||||
|
||||
const toggleShuffleHandler = () => {
|
||||
toggleShuffle();
|
||||
};
|
||||
|
||||
const toggleRepeatHandler = () => {
|
||||
toggleRepeat();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box px={{ base: 'md', md: 100 }} py="xl">
|
||||
<Paper mx="auto" p="xl" radius="lg" shadow="sm" bg="white">
|
||||
@@ -233,16 +93,6 @@ const MusicPlayer = () => {
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'md', md: 100 }} py="xl">
|
||||
{/* Hidden audio element - gunakan key yang stabil untuk mencegah remount */}
|
||||
{currentSong?.audioFile && (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={currentSong?.audioFile?.link}
|
||||
muted={isMuted}
|
||||
onEnded={handleSongEnd}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Paper
|
||||
mx="auto"
|
||||
p="xl"
|
||||
@@ -279,7 +129,7 @@ const MusicPlayer = () => {
|
||||
<Card radius="md" p="xl" shadow="md">
|
||||
<Group align="center" gap="xl">
|
||||
<Avatar
|
||||
src={currentSong.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
|
||||
src={currentSong.coverImage?.link || '/mp3-logo.png'}
|
||||
size={180}
|
||||
radius="md"
|
||||
/>
|
||||
@@ -295,38 +145,14 @@ const MusicPlayer = () => {
|
||||
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text>
|
||||
<Slider
|
||||
value={currentTime}
|
||||
max={duration}
|
||||
onChange={(v) => {
|
||||
isSeekingRef.current = true;
|
||||
setCurrentTime(v);
|
||||
}}
|
||||
onChangeEnd={(v) => {
|
||||
// Validasi: jangan seek melebihi durasi
|
||||
const seekTime = Math.min(Math.max(0, v), duration);
|
||||
|
||||
if (audioRef.current) {
|
||||
// Set audio currentTime
|
||||
audioRef.current.currentTime = seekTime;
|
||||
setCurrentTime(seekTime);
|
||||
lastSeekTimeRef.current = seekTime;
|
||||
|
||||
// Jika audio tidak sedang playing, mainkan
|
||||
if (!audioRef.current.paused && !isPlaying) {
|
||||
audioRef.current.play().catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Set seeking false SETELAH semua operasi selesai
|
||||
setTimeout(() => {
|
||||
isSeekingRef.current = false;
|
||||
}, 0);
|
||||
}}
|
||||
max={duration || 100}
|
||||
onChange={(v) => seek(v)}
|
||||
color="#0B4F78"
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
styles={{ thumb: { borderWidth: 2 } }}
|
||||
/>
|
||||
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration)}</Text>
|
||||
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration || 0)}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
@@ -345,7 +171,7 @@ const MusicPlayer = () => {
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={400}>
|
||||
<Grid gutter="md">
|
||||
{filteredMusik.map((song, index) => (
|
||||
{filteredMusik.map((song) => (
|
||||
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}>
|
||||
<Card
|
||||
radius="md"
|
||||
@@ -356,7 +182,7 @@ const MusicPlayer = () => {
|
||||
border: currentSong?.id === song.id ? '2px solid #0B4F78' : '2px solid transparent',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onClick={() => playSong(index)}
|
||||
onClick={() => playSong(song)}
|
||||
>
|
||||
<Group gap="md" align="center">
|
||||
<Avatar
|
||||
@@ -444,7 +270,7 @@ const MusicPlayer = () => {
|
||||
<ActionIcon
|
||||
variant={isRepeat ? 'filled' : 'subtle'}
|
||||
color="#0B4F78"
|
||||
onClick={() => setIsRepeat(!isRepeat)}
|
||||
onClick={toggleRepeatHandler}
|
||||
radius="xl"
|
||||
>
|
||||
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
||||
@@ -454,42 +280,18 @@ const MusicPlayer = () => {
|
||||
<Text size="xs" c="#5A6C7D" w={40} ta="right">{formatTime(currentTime)}</Text>
|
||||
<Slider
|
||||
value={currentTime}
|
||||
max={duration}
|
||||
onChange={(v) => {
|
||||
isSeekingRef.current = true;
|
||||
setCurrentTime(v); // preview - update UI saja
|
||||
}}
|
||||
onChangeEnd={(v) => {
|
||||
// Validasi: jangan seek melebihi durasi
|
||||
const seekTime = Math.min(Math.max(0, v), duration);
|
||||
|
||||
if (audioRef.current) {
|
||||
// Set audio currentTime
|
||||
audioRef.current.currentTime = seekTime;
|
||||
setCurrentTime(seekTime);
|
||||
lastSeekTimeRef.current = seekTime;
|
||||
|
||||
// Jika audio tidak sedang playing, mainkan
|
||||
if (!audioRef.current.paused && !isPlaying) {
|
||||
audioRef.current.play().catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Set seeking false SETELAH semua operasi selesai
|
||||
setTimeout(() => {
|
||||
isSeekingRef.current = false;
|
||||
}, 0);
|
||||
}}
|
||||
max={duration || 100}
|
||||
onChange={(v) => seek(v)}
|
||||
color="#0B4F78"
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Text size="xs" c="#5A6C7D" w={40}>{formatTime(duration)}</Text>
|
||||
<Text size="xs" c="#5A6C7D" w={40}>{formatTime(duration || 0)}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs" style={{ flex: 1 }} justify="flex-end">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={toggleMute}>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={toggleMuteHandler}>
|
||||
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
|
||||
</ActionIcon>
|
||||
<Slider
|
||||
@@ -507,86 +309,4 @@ const MusicPlayer = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default MusicPlayer;
|
||||
|
||||
// 'use client'
|
||||
// import {
|
||||
// Box, Paper, Group, Stack, Text, Slider, ActionIcon
|
||||
// } from '@mantine/core';
|
||||
// import {
|
||||
// IconPlayerPlayFilled,
|
||||
// IconPlayerPauseFilled
|
||||
// } from '@tabler/icons-react';
|
||||
// import { useEffect, useState } from 'react';
|
||||
// import { useAudioEngine } from '../lib/useAudioProgress';
|
||||
|
||||
// interface Musik {
|
||||
// id: string;
|
||||
// judul: string;
|
||||
// artis: string;
|
||||
// audioFile: { link: string };
|
||||
// }
|
||||
|
||||
// export default function MusicPlayer() {
|
||||
// const {
|
||||
// audioRef,
|
||||
// isPlaying,
|
||||
// currentTime,
|
||||
// duration,
|
||||
// load,
|
||||
// toggle,
|
||||
// seek,
|
||||
// } = useAudioEngine();
|
||||
|
||||
// const [songs, setSongs] = useState<Musik[]>([]);
|
||||
// const [index, setIndex] = useState(0);
|
||||
|
||||
// useEffect(() => {
|
||||
// fetch('/api/desa/musik/find-many?page=1&limit=50')
|
||||
// .then(r => r.json())
|
||||
// .then(r => setSongs(r.data ?? []));
|
||||
// }, []);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (!songs[index]) return;
|
||||
// load(songs[index].audioFile.link);
|
||||
// }, [songs, index, load]);
|
||||
|
||||
// const format = (n: number) => {
|
||||
// const m = Math.floor(n / 60);
|
||||
// const s = Math.floor(n % 60);
|
||||
// return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <Box p="xl">
|
||||
// <audio ref={audioRef} />
|
||||
|
||||
// <Paper p="lg">
|
||||
// <Stack>
|
||||
// <Text fw={700}>{songs[index]?.judul}</Text>
|
||||
// <Text size="sm">{songs[index]?.artis}</Text>
|
||||
|
||||
// <Group>
|
||||
// <Text size="xs">{format(currentTime)}</Text>
|
||||
|
||||
// <Slider
|
||||
// value={currentTime}
|
||||
// max={duration}
|
||||
// onChange={seek}
|
||||
// style={{ flex: 1 }}
|
||||
// />
|
||||
|
||||
// <Text size="xs">{format(duration)}</Text>
|
||||
// </Group>
|
||||
|
||||
// <ActionIcon size={56} radius="xl" onClick={toggle}>
|
||||
// {isPlaying
|
||||
// ? <IconPlayerPauseFilled />
|
||||
// : <IconPlayerPlayFilled />}
|
||||
// </ActionIcon>
|
||||
// </Stack>
|
||||
// </Paper>
|
||||
// </Box>
|
||||
// );
|
||||
// }
|
||||
export default MusicPlayer;
|
||||
@@ -78,7 +78,8 @@ function APBDesProgress({ apbdesData }: APBDesProgressProps) {
|
||||
// Hitung total per kategori
|
||||
const calcTotal = (items: { anggaran: number; realisasi: number }[]) => {
|
||||
const anggaran = items.reduce((sum, item) => sum + item.anggaran, 0);
|
||||
const realisasi = items.reduce((sum, item) => sum + item.realisasi, 0);
|
||||
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
|
||||
const realisasi = items.reduce((sum, item) => sum + (item.realisasi || 0), 0);
|
||||
const persen = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
|
||||
return { anggaran, realisasi, persen };
|
||||
};
|
||||
|
||||
@@ -68,6 +68,7 @@ function APBDesTable({ apbdesData }: APBDesTableProps) {
|
||||
|
||||
// Calculate totals
|
||||
const totalAnggaran = items.reduce((sum, item) => sum + (item.anggaran || 0), 0);
|
||||
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
|
||||
const totalRealisasi = items.reduce((sum, item) => sum + (item.realisasi || 0), 0);
|
||||
const totalSelisih = totalAnggaran - totalRealisasi;
|
||||
const totalPersentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
|
||||
|
||||
@@ -51,7 +51,8 @@ export function transformAPBDesData(data: any): APBDesData {
|
||||
kode: item.kode || '',
|
||||
uraian: item.uraian || '',
|
||||
anggaran: typeof item.anggaran === 'number' ? item.anggaran : 0,
|
||||
realisasi: typeof item.realisasi === 'number' ? item.realisasi : 0,
|
||||
// Map totalRealisasi from backend to realisasi field
|
||||
realisasi: typeof item.totalRealisasi === 'number' ? item.totalRealisasi : (typeof item.realisasi === 'number' ? item.realisasi : 0),
|
||||
selisih: typeof item.selisih === 'number' ? item.selisih : 0,
|
||||
persentase: typeof item.persentase === 'number' ? item.persentase : 0,
|
||||
level: typeof item.level === 'number' ? item.level : 1,
|
||||
|
||||
269
src/app/darmasaba/_com/FixedPlayerBar.tsx
Normal file
269
src/app/darmasaba/_com/FixedPlayerBar.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { useMusic } from '@/app/context/MusicContext';
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Box,
|
||||
Flex,
|
||||
Group,
|
||||
Paper,
|
||||
Slider,
|
||||
Text,
|
||||
Transition
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowsShuffle,
|
||||
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 [isPlayerVisible, setIsPlayerVisible] = useState(true);
|
||||
|
||||
// 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 close player
|
||||
const handleClosePlayer = () => {
|
||||
setIsPlayerVisible(false);
|
||||
};
|
||||
|
||||
if (!currentSong || !isPlayerVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mini Player Bar - Always visible when song is playing */}
|
||||
<Paper
|
||||
pos="fixed"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="sm"
|
||||
shadow="lg"
|
||||
style={{
|
||||
zIndex: 1000,
|
||||
borderTop: '1px solid rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<Flex align="center" gap="md" justify="space-between">
|
||||
{/* Song Info */}
|
||||
<Group gap="sm" flex={1} style={{ minWidth: 0 }}>
|
||||
<Avatar
|
||||
src={currentSong.coverImage?.link || ''}
|
||||
alt={currentSong.judul}
|
||||
size={40}
|
||||
radius="sm"
|
||||
imageProps={{ loading: 'lazy' }}
|
||||
/>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz="sm" fw={600} truncate>
|
||||
{currentSong.judul}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed" truncate>
|
||||
{currentSong.artis}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{/* Controls */}
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant={isShuffle ? 'filled' : 'subtle'}
|
||||
color={isShuffle ? 'blue' : 'gray'}
|
||||
size="lg"
|
||||
onClick={handleToggleShuffle}
|
||||
title="Shuffle"
|
||||
>
|
||||
<IconArrowsShuffle size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
onClick={playPrev}
|
||||
title="Previous"
|
||||
>
|
||||
<IconPlayerSkipBackFilled size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color={isPlaying ? 'blue' : 'gray'}
|
||||
size="xl"
|
||||
radius="xl"
|
||||
onClick={togglePlayPause}
|
||||
title={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<IconPlayerPauseFilled size={24} />
|
||||
) : (
|
||||
<IconPlayerPlayFilled size={24} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
onClick={playNext}
|
||||
title="Next"
|
||||
>
|
||||
<IconPlayerSkipForwardFilled size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={isRepeat ? 'blue' : 'gray'}
|
||||
size="lg"
|
||||
onClick={toggleRepeat}
|
||||
title={isRepeat ? 'Repeat On' : 'Repeat Off'}
|
||||
>
|
||||
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{/* Progress Bar - Desktop */}
|
||||
<Box w={200} display={{ base: 'none', md: 'block' }}>
|
||||
<Slider
|
||||
value={currentTime}
|
||||
max={duration || 100}
|
||||
onChange={handleSeek}
|
||||
size="sm"
|
||||
color="blue"
|
||||
label={(value) => formatTime(value)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Right Controls */}
|
||||
<Group gap="xs">
|
||||
<Box
|
||||
onMouseEnter={() => setShowVolume(true)}
|
||||
onMouseLeave={() => setShowVolume(false)}
|
||||
pos="relative"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={isMuted ? 'red' : 'gray'}
|
||||
size="lg"
|
||||
onClick={toggleMute}
|
||||
title={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{isMuted ? (
|
||||
<IconVolumeOff size={18} />
|
||||
) : (
|
||||
<IconVolume size={18} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
|
||||
<Transition
|
||||
mounted={showVolume}
|
||||
transition="scale-y"
|
||||
duration={200}
|
||||
timingFunction="ease"
|
||||
>
|
||||
{(style) => (
|
||||
<Paper
|
||||
style={{
|
||||
...style,
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
right: 0,
|
||||
mb: 'xs',
|
||||
p: 'sm',
|
||||
zIndex: 1001,
|
||||
}}
|
||||
shadow="md"
|
||||
withBorder
|
||||
>
|
||||
<Slider
|
||||
value={isMuted ? 0 : volume}
|
||||
max={100}
|
||||
onChange={handleVolumeChange}
|
||||
h={100}
|
||||
color="blue"
|
||||
size="sm"
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
</Transition>
|
||||
</Box>
|
||||
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
onClick={handleClosePlayer}
|
||||
title="Close player"
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Flex>
|
||||
|
||||
{/* Progress Bar - Mobile */}
|
||||
<Box mt="xs" display={{ base: 'block', md: 'none' }}>
|
||||
<Slider
|
||||
value={currentTime}
|
||||
max={duration || 100}
|
||||
onChange={handleSeek}
|
||||
size="sm"
|
||||
color="blue"
|
||||
label={(value) => formatTime(value)}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Spacer to prevent content from being hidden behind player */}
|
||||
<Box h={80} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,29 +4,25 @@
|
||||
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
|
||||
import colors from '@/con/colors'
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Title
|
||||
} from '@mantine/core'
|
||||
import { IconDownload } from '@tabler/icons-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useProxy } from 'valtio/utils'
|
||||
import GrafikRealisasi from './lib/grafikRealisasi'
|
||||
import PaguTable from './lib/paguTable'
|
||||
import RealisasiTable from './lib/realisasiTable'
|
||||
import GrafikRealisasi from './lib/grafikRealisasi'
|
||||
|
||||
function Apbdes() {
|
||||
const state = useProxy(apbdes)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedYear, setSelectedYear] = useState<string | null>(null)
|
||||
|
||||
const textHeading = {
|
||||
@@ -37,12 +33,9 @@ function Apbdes() {
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await state.findMany.load()
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
@@ -73,10 +66,12 @@ function Apbdes() {
|
||||
? dataAPBDes.find((item: any) => item?.tahun?.toString() === selectedYear) || dataAPBDes[0]
|
||||
: null
|
||||
|
||||
const data = (state.findMany.data || []).slice(0, 3)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const previewData = (state.findMany.data || []).slice(0, 3)
|
||||
|
||||
return (
|
||||
<Stack p="sm" gap="xl" bg={colors.Bg}>
|
||||
<Divider c="gray.3" size="sm" />
|
||||
{/* 📌 HEADING */}
|
||||
<Box mt="xl">
|
||||
<Stack gap="sm">
|
||||
@@ -116,7 +111,7 @@ function Apbdes() {
|
||||
</Group>
|
||||
|
||||
{/* COMBOBOX */}
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
<Box px={{ base: 'md', md: "sm" }}>
|
||||
<Select
|
||||
label={<Text fw={600} fz="sm">Pilih Tahun APBDes</Text>}
|
||||
placeholder="Pilih tahun"
|
||||
@@ -132,7 +127,7 @@ function Apbdes() {
|
||||
|
||||
{/* Tabel & Grafik - Hanya tampilkan jika ada data */}
|
||||
{currentApbdes && currentApbdes.items?.length > 0 ? (
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
<Box px={{ base: 'md', md: 'sm' }} mb="xl">
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<PaguTable apbdesData={currentApbdes} />
|
||||
<RealisasiTable apbdesData={currentApbdes} />
|
||||
@@ -140,19 +135,19 @@ function Apbdes() {
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
) : currentApbdes ? (
|
||||
<Box px={{ base: 'md', md: 100 }} py="md">
|
||||
<Box px={{ base: 'md', md: 100 }} py="md" mb="xl">
|
||||
<Text fz="sm" c="dimmed" ta="center" lh={1.5}>
|
||||
Tidak ada data item untuk tahun yang dipilih.
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* GRID - Card Preview */}
|
||||
{loading ? (
|
||||
{/* GRID - Card Preview
|
||||
{state.findMany.loading ? (
|
||||
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
|
||||
<Loader size="lg" color="blue" />
|
||||
</Center>
|
||||
) : data.length === 0 ? (
|
||||
) : previewData.length === 0 ? (
|
||||
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
<Text fz="lg" c="dimmed" lh={1.4}>
|
||||
@@ -170,7 +165,7 @@ function Apbdes() {
|
||||
spacing="lg"
|
||||
pb="xl"
|
||||
>
|
||||
{data.map((v: any, k: number) => (
|
||||
{previewData.map((v, k) => (
|
||||
<Box
|
||||
key={k}
|
||||
pos="relative"
|
||||
@@ -224,7 +219,7 @@ function Apbdes() {
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
)} */}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ function Summary({ title, data }: any) {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
const totalAnggaran = data.reduce((s: number, i: any) => s + i.anggaran, 0);
|
||||
const totalRealisasi = data.reduce((s: number, i: any) => s + i.realisasi, 0);
|
||||
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
|
||||
const totalRealisasi = data.reduce((s: number, i: any) => s + (i.realisasi || i.totalRealisasi || 0), 0);
|
||||
|
||||
const persen =
|
||||
totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
|
||||
@@ -36,38 +37,38 @@ function Summary({ title, data }: any) {
|
||||
{persen.toFixed(2)}%
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
|
||||
<Text fz="sm" c="dimmed" mb="xs">
|
||||
Realisasi: {formatRupiah(totalRealisasi)} / Anggaran: {formatRupiah(totalAnggaran)}
|
||||
</Text>
|
||||
|
||||
<Progress
|
||||
value={persen}
|
||||
size="xl"
|
||||
|
||||
<Progress
|
||||
value={persen}
|
||||
size="xl"
|
||||
radius="xl"
|
||||
color={getProgressColor(persen)}
|
||||
striped={persen < 100}
|
||||
animated={persen < 100}
|
||||
/>
|
||||
|
||||
|
||||
{persen >= 100 && (
|
||||
<Text fz="xs" c="teal" mt="xs" fw={500}>
|
||||
✓ Realisasi mencapai 100% dari anggaran
|
||||
</Text>
|
||||
)}
|
||||
|
||||
|
||||
{persen < 100 && persen >= 80 && (
|
||||
<Text fz="xs" c="blue" mt="xs" fw={500}>
|
||||
⚡ Realisasi baik, mendekati target
|
||||
</Text>
|
||||
)}
|
||||
|
||||
|
||||
{persen < 80 && persen >= 60 && (
|
||||
<Text fz="xs" c="yellow" mt="xs" fw={500}>
|
||||
⚠️ Realisasi cukup, perlu ditingkatkan
|
||||
</Text>
|
||||
)}
|
||||
|
||||
|
||||
{persen < 60 && (
|
||||
<Text fz="xs" c="red" mt="xs" fw={500}>
|
||||
⚠️ Realisasi rendah, perlu perhatian khusus
|
||||
@@ -87,7 +88,8 @@ export default function GrafikRealisasi({ apbdesData }: 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);
|
||||
// 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) => {
|
||||
@@ -105,29 +107,31 @@ export default function GrafikRealisasi({ apbdesData }: any) {
|
||||
GRAFIK REALISASI APBDes {tahun}
|
||||
</Title>
|
||||
|
||||
<Stack gap="lg">
|
||||
<Stack gap="lg" mb="lg">
|
||||
<Summary title="💰 Pendapatan" data={pendapatan} />
|
||||
<Summary title="💸 Belanja" data={belanja} />
|
||||
<Summary title="📊 Pembiayaan" data={pembiayaan} />
|
||||
</Stack>
|
||||
|
||||
{/* Summary Total Keseluruhan */}
|
||||
<Box mb="lg" p="md" bg="gray.0">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={700} fz="lg">TOTAL KESELURUHAN</Text>
|
||||
<Text fw={700} fz="xl" c={persenSemua >= 100 ? 'teal' : persenSemua >= 80 ? 'blue' : 'red'}>
|
||||
{persenSemua.toFixed(2)}%
|
||||
{/* 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>
|
||||
</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'}
|
||||
/>
|
||||
<Progress
|
||||
value={persenSemua}
|
||||
size="lg"
|
||||
radius="xl"
|
||||
color={persenSemua >= 100 ? 'teal' : persenSemua >= 80 ? 'blue' : 'red'}
|
||||
/>
|
||||
</>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -1,43 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Paper, Table, Title, Badge } from '@mantine/core';
|
||||
|
||||
function Section({ title, data }: any) {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={3}>
|
||||
<strong>{title}</strong>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
{data.map((item: any) => (
|
||||
<Table.Tr key={item.id}>
|
||||
<Table.Td>
|
||||
{item.kode} - {item.uraian}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
Rp {item.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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { Paper, Table, Title, Badge, Text } from '@mantine/core';
|
||||
|
||||
export default function RealisasiTable({ apbdesData }: any) {
|
||||
const items = apbdesData.items || [];
|
||||
@@ -47,28 +9,78 @@ export default function RealisasiTable({ apbdesData }: any) {
|
||||
? `REALISASI APBDes Tahun ${apbdesData.tahun}`
|
||||
: 'REALISASI APBDes';
|
||||
|
||||
const pendapatan = items.filter((i: any) => i.tipe === 'pendapatan');
|
||||
const belanja = items.filter((i: any) => i.tipe === 'belanja');
|
||||
const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan');
|
||||
// Flatten: kumpulkan semua realisasi items
|
||||
const allRealisasiRows: Array<{ realisasi: any; parentItem: any }> = [];
|
||||
|
||||
items.forEach((item: any) => {
|
||||
if (item.realisasiItems && item.realisasiItems.length > 0) {
|
||||
item.realisasiItems.forEach((realisasi: any) => {
|
||||
allRealisasiRows.push({ realisasi, parentItem: item });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const formatRupiah = (amount: number) => {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Title order={5} mb="md">{title}</Title>
|
||||
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Uraian</Table.Th>
|
||||
<Table.Th ta="right">Realisasi (Rp)</Table.Th>
|
||||
<Table.Th ta="center">%</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
<Section title="1) PENDAPATAN" data={pendapatan} />
|
||||
<Section title="2) BELANJA" data={belanja} />
|
||||
<Section title="3) PEMBIAYAAN" data={pembiayaan} />
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{allRealisasiRows.length === 0 ? (
|
||||
<Text fz="sm" c="dimmed" ta="center" py="md">
|
||||
Belum ada data realisasi
|
||||
</Text>
|
||||
) : (
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Uraian</Table.Th>
|
||||
<Table.Th ta="right">Realisasi (Rp)</Table.Th>
|
||||
<Table.Th ta="center">%</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{allRealisasiRows.map(({ realisasi, parentItem }) => {
|
||||
const persentase = parentItem.anggaran > 0
|
||||
? (realisasi.jumlah / parentItem.anggaran) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Table.Tr key={realisasi.id}>
|
||||
<Table.Td>
|
||||
<Text>{realisasi.kode || '-'} - {realisasi.keterangan || '-'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text fw={600} c="blue">
|
||||
{formatRupiah(realisasi.jumlah || 0)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="center">
|
||||
<Badge
|
||||
color={
|
||||
persentase >= 100
|
||||
? 'teal'
|
||||
: persentase >= 60
|
||||
? 'yellow'
|
||||
: 'red'
|
||||
}
|
||||
>
|
||||
{persentase.toFixed(2)}%
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Box, Space, Stack } from "@mantine/core";
|
||||
|
||||
import { Navbar } from "@/app/darmasaba/_com/Navbar";
|
||||
import Footer from "./_com/Footer";
|
||||
import FixedPlayerBar from "./_com/FixedPlayerBar";
|
||||
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
@@ -21,6 +22,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
{children}
|
||||
</Box>
|
||||
<Footer />
|
||||
<FixedPlayerBar />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import "@mantine/core/styles.css";
|
||||
import "./globals.css";
|
||||
|
||||
import LoadDataFirstClient from "@/app/darmasaba/_com/LoadDataFirstClient";
|
||||
import { MusicProvider } from "@/app/context/MusicContext";
|
||||
import {
|
||||
ColorSchemeScript,
|
||||
MantineProvider,
|
||||
@@ -104,15 +105,17 @@ export default function RootLayout({
|
||||
<ColorSchemeScript defaultColorScheme="light" />
|
||||
</head>
|
||||
<body>
|
||||
<MantineProvider theme={theme} defaultColorScheme="light">
|
||||
{children}
|
||||
<LoadDataFirstClient />
|
||||
<ToastContainer
|
||||
position="bottom-center"
|
||||
hideProgressBar
|
||||
style={{ zIndex: 9999 }}
|
||||
/>
|
||||
</MantineProvider>
|
||||
<MusicProvider>
|
||||
<MantineProvider theme={theme} defaultColorScheme="light">
|
||||
{children}
|
||||
<LoadDataFirstClient />
|
||||
<ToastContainer
|
||||
position="bottom-center"
|
||||
hideProgressBar
|
||||
style={{ zIndex: 9999 }}
|
||||
/>
|
||||
</MantineProvider>
|
||||
</MusicProvider>
|
||||
</body>
|
||||
</html>
|
||||
</ViewTransitions>
|
||||
|
||||
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