Compare commits

..

1 Commits

91 changed files with 824 additions and 6431 deletions

View File

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

View File

@@ -19,6 +19,7 @@ const nextConfig: NextConfig = {
}, },
]; ];
}, },
}; };
export default nextConfig; export default nextConfig;

View File

@@ -33,7 +33,7 @@
"@mantine/modals": "^8.3.6", "@mantine/modals": "^8.3.6",
"@mantine/tiptap": "^7.17.4", "@mantine/tiptap": "^7.17.4",
"@paljs/types": "^8.1.0", "@paljs/types": "^8.1.0",
"@prisma/client": "6.3.1", "@prisma/client": "^6.3.1",
"@tabler/icons-react": "^3.30.0", "@tabler/icons-react": "^3.30.0",
"@tiptap/extension-highlight": "^2.11.7", "@tiptap/extension-highlight": "^2.11.7",
"@tiptap/extension-link": "^2.11.7", "@tiptap/extension-link": "^2.11.7",
@@ -89,7 +89,7 @@
"p-limit": "^6.2.0", "p-limit": "^6.2.0",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primereact": "^10.9.6", "primereact": "^10.9.6",
"prisma": "6.3.1", "prisma": "^6.3.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-exif-orientation-img": "^0.1.5", "react-exif-orientation-img": "^0.1.5",

View File

@@ -26,24 +26,7 @@ export async function seedBerita() {
console.log("🔄 Seeding Berita..."); console.log("🔄 Seeding Berita...");
// Build a map of valid kategori IDs
const validKategoriIds = new Set<string>();
const kategoriList = await prisma.kategoriBerita.findMany({
select: { id: true, name: true },
});
kategoriList.forEach((k) => validKategoriIds.add(k.id));
console.log(`📋 Found ${validKategoriIds.size} valid kategori IDs in database`);
for (const b of beritaJson) { for (const b of beritaJson) {
// Validate kategoriBeritaId exists
if (!b.kategoriBeritaId || !validKategoriIds.has(b.kategoriBeritaId)) {
console.warn(
`⚠️ Skipping berita "${b.judul}": Invalid kategoriBeritaId "${b.kategoriBeritaId}"`,
);
continue;
}
let imageId: string | null = null; let imageId: string | null = null;
if (b.imageName) { if (b.imageName) {
@@ -61,32 +44,26 @@ export async function seedBerita() {
} }
} }
try { await prisma.berita.upsert({
await prisma.berita.upsert({ where: { id: b.id },
where: { id: b.id }, update: {
update: { judul: b.judul,
judul: b.judul, deskripsi: b.deskripsi,
deskripsi: b.deskripsi, content: b.content,
content: b.content, kategoriBeritaId: b.kategoriBeritaId,
kategoriBeritaId: b.kategoriBeritaId, imageId,
imageId, },
}, create: {
create: { id: b.id,
id: b.id, judul: b.judul,
judul: b.judul, deskripsi: b.deskripsi,
deskripsi: b.deskripsi, content: b.content,
content: b.content, kategoriBeritaId: b.kategoriBeritaId,
kategoriBeritaId: b.kategoriBeritaId, imageId,
imageId, },
}, });
});
console.log(`✅ Berita seeded: ${b.judul}`); console.log(`✅ Berita seeded: ${b.judul}`);
} catch (error: any) {
console.error(
`❌ Failed to seed berita "${b.judul}": ${error.message}`,
);
}
} }
console.log("🎉 Berita seed selesai"); console.log("🎉 Berita seed selesai");

View File

@@ -1,170 +0,0 @@
/*
Warnings:
- You are about to alter the column `nama` on the `KategoriPotensi` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(100)`.
- You are about to alter the column `name` on the `PotensiDesa` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(255)`.
- You are about to alter the column `kategoriId` on the `PotensiDesa` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(36)`.
- A unique constraint covering the columns `[nama]` on the table `KategoriPotensi` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[name]` on the table `PotensiDesa` will be added. If there are existing duplicate values, this will fail.
- Made the column `kategoriId` on table `PotensiDesa` required. This step will fail if there are existing NULL values in that column.
*/
-- DropForeignKey
ALTER TABLE "DataPerpustakaan" DROP CONSTRAINT "DataPerpustakaan_imageId_fkey";
-- DropForeignKey
ALTER TABLE "DesaDigital" DROP CONSTRAINT "DesaDigital_imageId_fkey";
-- DropForeignKey
ALTER TABLE "InfoTekno" DROP CONSTRAINT "InfoTekno_imageId_fkey";
-- DropForeignKey
ALTER TABLE "KegiatanDesa" DROP CONSTRAINT "KegiatanDesa_imageId_fkey";
-- DropForeignKey
ALTER TABLE "PengaduanMasyarakat" DROP CONSTRAINT "PengaduanMasyarakat_imageId_fkey";
-- DropForeignKey
ALTER TABLE "PotensiDesa" DROP CONSTRAINT "PotensiDesa_kategoriId_fkey";
-- DropForeignKey
ALTER TABLE "ProfileDesaImage" DROP CONSTRAINT "ProfileDesaImage_imageId_fkey";
-- AlterTable
ALTER TABLE "CaraMemperolehInformasi" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "CaraMemperolehSalinanInformasi" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "DaftarInformasiPublik" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "DasarHukumPPID" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "DataPerpustakaan" ALTER COLUMN "imageId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "DesaDigital" ALTER COLUMN "imageId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "FormulirPermohonanKeberatan" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "InfoTekno" ALTER COLUMN "imageId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "JenisInformasiDiminta" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "JenisKelaminResponden" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "KategoriPotensi" ALTER COLUMN "nama" SET DATA TYPE VARCHAR(100),
ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "KategoriPrestasiDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "KegiatanDesa" ALTER COLUMN "imageId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "LambangDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "MaskotDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "PegawaiPPID" ADD COLUMN "deletedAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "PengaduanMasyarakat" ALTER COLUMN "imageId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "PermohonanInformasiPublik" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "PilihanRatingResponden" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "PosisiOrganisasiPPID" ADD COLUMN "deletedAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "PotensiDesa" ALTER COLUMN "name" SET DATA TYPE VARCHAR(255),
ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT,
ALTER COLUMN "kategoriId" SET NOT NULL,
ALTER COLUMN "kategoriId" SET DATA TYPE VARCHAR(36);
-- AlterTable
ALTER TABLE "PrestasiDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "ProfileDesaImage" ALTER COLUMN "imageId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "ProfilePPID" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "Responden" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "SejarahDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "UmurResponden" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "VisiMisiDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "VisiMisiPPID" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- CreateIndex
CREATE UNIQUE INDEX "KategoriPotensi_nama_key" ON "KategoriPotensi"("nama");
-- CreateIndex
CREATE UNIQUE INDEX "PotensiDesa_name_key" ON "PotensiDesa"("name");
-- AddForeignKey
ALTER TABLE "ProfileDesaImage" ADD CONSTRAINT "ProfileDesaImage_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PotensiDesa" ADD CONSTRAINT "PotensiDesa_kategoriId_fkey" FOREIGN KEY ("kategoriId") REFERENCES "KategoriPotensi"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DesaDigital" ADD CONSTRAINT "DesaDigital_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InfoTekno" ADD CONSTRAINT "InfoTekno_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PengaduanMasyarakat" ADD CONSTRAINT "PengaduanMasyarakat_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KegiatanDesa" ADD CONSTRAINT "KegiatanDesa_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DataPerpustakaan" ADD CONSTRAINT "DataPerpustakaan_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -60,9 +60,8 @@ model FileStorage {
deletedAt DateTime? deletedAt DateTime?
isActive Boolean @default(true) isActive Boolean @default(true)
link String link String
category String // "image" / "document" / "audio" / "other" category String // "image" / "document" / "other"
Berita Berita[] @relation("BeritaFeaturedImage") Berita Berita[]
BeritaImages Berita[] @relation("BeritaImages")
PotensiDesa PotensiDesa[] PotensiDesa PotensiDesa[]
Posyandu Posyandu[] Posyandu Posyandu[]
StrukturPPID StrukturPPID[] StrukturPPID StrukturPPID[]
@@ -103,9 +102,6 @@ model FileStorage {
ArtikelKesehatan ArtikelKesehatan[] ArtikelKesehatan ArtikelKesehatan[]
StrukturBumDes StrukturBumDes[] StrukturBumDes StrukturBumDes[]
MusikDesaAudio MusikDesa[] @relation("MusikAudioFile")
MusikDesaCover MusikDesa[] @relation("MusikCoverImage")
} }
//========================================= MENU LANDING PAGE ========================================= // //========================================= MENU LANDING PAGE ========================================= //
@@ -209,22 +205,16 @@ model APBDesItem {
kode String // contoh: "4", "4.1", "4.1.2" kode String // contoh: "4", "4.1", "4.1.2"
uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha" uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha"
anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS) anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS)
tipe String? // "pendapatan" | "belanja" | "pembiayaan" | null realisasi Float
selisih Float // realisasi - anggaran
persentase Float
tipe String? // (realisasi / anggaran) * 100
level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail
parentId String? // untuk relasi hierarki parentId String? // untuk relasi hierarki
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id]) parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
children APBDesItem[] @relation("APBDesItemParent") children APBDesItem[] @relation("APBDesItemParent")
apbdesId String apbdesId String
apbdes APBDes @relation(fields: [apbdesId], references: [id]) apbdes APBDes @relation(fields: [apbdesId], references: [id])
// Field kalkulasi (auto-calculated dari realisasi items)
totalRealisasi Float @default(0) // Sum dari semua realisasi
selisih Float @default(0) // totalRealisasi - anggaran
persentase Float @default(0) // (totalRealisasi / anggaran) * 100
// Relasi ke realisasi items
realisasiItems RealisasiItem[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
@@ -235,28 +225,6 @@ model APBDesItem {
@@index([apbdesId]) @@index([apbdesId])
} }
// Model baru untuk multiple realisasi per item
model RealisasiItem {
id String @id @default(cuid())
kode String? // Kode realisasi, mirip dengan APBDesItem
apbdesItemId String
apbdesItem APBDesItem @relation(fields: [apbdesItemId], references: [id], onDelete: Cascade)
jumlah Float // Jumlah realisasi dalam Rupiah
tanggal DateTime @db.Date // Tanggal realisasi
keterangan String? @db.Text // Keterangan tambahan (opsional)
buktiFileId String? // FileStorage ID untuk bukti/foto (opsional)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
isActive Boolean @default(true)
@@index([kode])
@@index([apbdesItemId])
@@index([tanggal])
}
//========================================= PRESTASI DESA ========================================= // //========================================= PRESTASI DESA ========================================= //
model PrestasiDesa { model PrestasiDesa {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -268,7 +236,7 @@ model PrestasiDesa {
imageId String? imageId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
} }
@@ -277,7 +245,7 @@ model KategoriPrestasiDesa {
name String @unique name String @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
PrestasiDesa PrestasiDesa[] PrestasiDesa PrestasiDesa[]
} }
@@ -295,7 +263,7 @@ model Responden {
kelompokUmurId String kelompokUmurId String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
} }
@@ -304,7 +272,7 @@ model JenisKelaminResponden {
name String @unique name String @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
Responden Responden[] Responden Responden[]
} }
@@ -314,7 +282,7 @@ model PilihanRatingResponden {
name String @unique name String @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
Responden Responden[] Responden Responden[]
} }
@@ -324,7 +292,7 @@ model UmurResponden {
name String @unique name String @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
Responden Responden[] Responden Responden[]
} }
@@ -358,7 +326,6 @@ model PosisiOrganisasiPPID {
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime?
parent PosisiOrganisasiPPID? @relation("Parent", fields: [parentId], references: [id]) parent PosisiOrganisasiPPID? @relation("Parent", fields: [parentId], references: [id])
children PosisiOrganisasiPPID[] @relation("Parent") children PosisiOrganisasiPPID[] @relation("Parent")
StrukturOrganisasiPPID StrukturOrganisasiPPID[] StrukturOrganisasiPPID StrukturOrganisasiPPID[]
@@ -378,7 +345,6 @@ model PegawaiPPID {
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime?
posisi PosisiOrganisasiPPID @relation(fields: [posisiId], references: [id]) posisi PosisiOrganisasiPPID @relation(fields: [posisiId], references: [id])
strukturOrganisasi StrukturPPID[] // Relasi balik strukturOrganisasi StrukturPPID[] // Relasi balik
StrukturOrganisasiPPID StrukturOrganisasiPPID[] StrukturOrganisasiPPID StrukturOrganisasiPPID[]
@@ -404,7 +370,7 @@ model VisiMisiPPID {
misi String @db.Text misi String @db.Text
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
} }
@@ -415,7 +381,7 @@ model DasarHukumPPID {
content String @db.Text content String @db.Text
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
} }
@@ -432,7 +398,7 @@ model ProfilePPID {
imageId String? imageId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
} }
@@ -444,7 +410,7 @@ model DaftarInformasiPublik {
tanggal DateTime @db.Date tanggal DateTime @db.Date
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
} }
@@ -465,7 +431,7 @@ model PermohonanInformasiPublik {
caraMemperolehSalinanInformasiId String? caraMemperolehSalinanInformasiId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
} }
@@ -474,7 +440,7 @@ model JenisInformasiDiminta {
name String @unique name String @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
PermohonanInformasiPublik PermohonanInformasiPublik[] PermohonanInformasiPublik PermohonanInformasiPublik[]
} }
@@ -484,7 +450,7 @@ model CaraMemperolehInformasi {
name String @unique name String @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
PermohonanInformasiPublik PermohonanInformasiPublik[] PermohonanInformasiPublik PermohonanInformasiPublik[]
} }
@@ -494,7 +460,7 @@ model CaraMemperolehSalinanInformasi {
name String @unique name String @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
PermohonanInformasiPublik PermohonanInformasiPublik[] PermohonanInformasiPublik PermohonanInformasiPublik[]
} }
@@ -508,7 +474,7 @@ model FormulirPermohonanKeberatan {
alasan String alasan String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
} }
@@ -565,7 +531,7 @@ model SejarahDesa {
deskripsi String @db.Text deskripsi String @db.Text
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
} }
@@ -575,7 +541,7 @@ model VisiMisiDesa {
misi String @db.Text misi String @db.Text
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
} }
@@ -585,7 +551,7 @@ model LambangDesa {
deskripsi String @db.Text deskripsi String @db.Text
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
} }
@@ -596,7 +562,7 @@ model MaskotDesa {
images ProfileDesaImage[] images ProfileDesaImage[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
} }
@@ -641,19 +607,15 @@ model Berita {
id String @id @default(cuid()) id String @id @default(cuid())
judul String judul String
deskripsi String deskripsi String
image FileStorage? @relation("BeritaFeaturedImage", fields: [imageId], references: [id]) image FileStorage? @relation(fields: [imageId], references: [id])
imageId String? imageId String?
images FileStorage[] @relation("BeritaImages")
content String @db.Text content String @db.Text
linkVideo String? @db.VarChar(500)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
kategoriBerita KategoriBerita? @relation(fields: [kategoriBeritaId], references: [id]) kategoriBerita KategoriBerita? @relation(fields: [kategoriBeritaId], references: [id])
kategoriBeritaId String? kategoriBeritaId String?
@@index([kategoriBeritaId])
} }
model KategoriBerita { model KategoriBerita {
@@ -669,25 +631,25 @@ model KategoriBerita {
// ========================================= POTENSI DESA ========================================= // // ========================================= POTENSI DESA ========================================= //
model PotensiDesa { model PotensiDesa {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique @db.VarChar(255) name String
deskripsi String @db.Text deskripsi String
kategori KategoriPotensi? @relation(fields: [kategoriId], references: [id]) kategori KategoriPotensi? @relation(fields: [kategoriId], references: [id])
kategoriId String @db.VarChar(36) kategoriId String?
image FileStorage? @relation(fields: [imageId], references: [id]) image FileStorage? @relation(fields: [imageId], references: [id])
imageId String? imageId String?
content String @db.Text content String @db.Text
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
} }
model KategoriPotensi { model KategoriPotensi {
id String @id @default(cuid()) id String @id @default(cuid())
nama String @unique @db.VarChar(100) nama String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
PotensiDesa PotensiDesa[] PotensiDesa PotensiDesa[]
} }
@@ -2299,25 +2261,3 @@ model UserMenuAccess {
@@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali @@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali
} }
// ========================================= MUSIK DESA ========================================= //
model MusikDesa {
id String @id @default(cuid())
judul String @db.VarChar(255)
artis String @db.VarChar(255)
deskripsi String? @db.Text
durasi String @db.VarChar(20) // format: "MM:SS"
audioFile FileStorage? @relation("MusikAudioFile", fields: [audioFileId], references: [id])
audioFileId String?
coverImage FileStorage? @relation("MusikCoverImage", fields: [coverImageId], references: [id])
coverImageId String?
genre String? @db.VarChar(100)
tahunRilis Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
isActive Boolean @default(true)
@@index([judul])
@@index([artis])
}

View File

@@ -69,8 +69,8 @@ import { seedProfilPpd } from "./_seeder_list/ppid/profil-ppid/seed_profil_ppd";
(async () => { (async () => {
// Always run seedAssets to handle new images without duplication // Always run seedAssets to handle new images without duplication
console.log("📂 Checking for new assets to seed..."); // console.log("📂 Checking for new assets to seed...");
await seedAssets(); // await seedAssets();
// // =========== FILE STORAGE =========== // // =========== FILE STORAGE ===========

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -12,8 +12,6 @@ const templateForm = z.object({
content: z.string().min(3, "Content minimal 3 karakter"), content: z.string().min(3, "Content minimal 3 karakter"),
kategoriBeritaId: z.string().nonempty(), kategoriBeritaId: z.string().nonempty(),
imageId: z.string().nonempty(), imageId: z.string().nonempty(),
imageIds: z.array(z.string()),
linkVideo: z.string().optional(),
}); });
// 2. Default value form berita (hindari uncontrolled input) // 2. Default value form berita (hindari uncontrolled input)
@@ -23,8 +21,6 @@ const defaultForm = {
imageId: "", imageId: "",
content: "", content: "",
kategoriBeritaId: "", kategoriBeritaId: "",
imageIds: [] as string[],
linkVideo: "",
}; };
// 4. Berita proxy // 4. Berita proxy
@@ -66,7 +62,14 @@ const berita = proxy({
// State untuk berita utama (hanya 1) // State untuk berita utama (hanya 1)
findMany: { findMany: {
data: null as any[] | null, data: null as
| Prisma.BeritaGetPayload<{
include: {
image: true;
kategoriBerita: true;
};
}>[]
| null,
page: 1, page: 1,
totalPages: 1, totalPages: 1,
loading: false, loading: false,
@@ -112,7 +115,6 @@ const berita = proxy({
data: null as Prisma.BeritaGetPayload<{ data: null as Prisma.BeritaGetPayload<{
include: { include: {
image: true; image: true;
images: true;
kategoriBerita: true; kategoriBerita: true;
}; };
}> | null, }> | null,
@@ -197,8 +199,6 @@ const berita = proxy({
content: data.content, content: data.content,
kategoriBeritaId: data.kategoriBeritaId || "", kategoriBeritaId: data.kategoriBeritaId || "",
imageId: data.imageId || "", imageId: data.imageId || "",
imageIds: data.images?.map((img: any) => img.id) || [],
linkVideo: data.linkVideo || "",
}; };
return data; // Return the loaded data return data; // Return the loaded data
} else { } else {
@@ -237,8 +237,6 @@ const berita = proxy({
content: this.form.content, content: this.form.content,
kategoriBeritaId: this.form.kategoriBeritaId || null, kategoriBeritaId: this.form.kategoriBeritaId || null,
imageId: this.form.imageId, imageId: this.form.imageId,
imageIds: this.form.imageIds,
linkVideo: this.form.linkVideo,
}), }),
}); });

View File

@@ -1,297 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ApiFetch from "@/lib/api-fetch";
import { Prisma } from "@prisma/client";
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
// 1. Schema validasi dengan Zod
const templateForm = z.object({
judul: z.string().min(3, "Judul minimal 3 karakter"),
artis: z.string().min(3, "Artis minimal 3 karakter"),
deskripsi: z.string().optional(),
durasi: z.string().min(3, "Durasi minimal 3 karakter"),
audioFileId: z.string().nonempty(),
coverImageId: z.string().nonempty(),
genre: z.string().optional(),
tahunRilis: z.number().optional().or(z.literal(undefined)),
});
// 2. Default value form musik
const defaultForm = {
judul: "",
artis: "",
deskripsi: "",
durasi: "",
audioFileId: "",
coverImageId: "",
genre: "",
tahunRilis: undefined as number | undefined,
};
// 3. Musik proxy
const musik = proxy({
create: {
form: { ...defaultForm },
loading: false,
async create() {
const cek = templateForm.safeParse(musik.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
musik.create.loading = true;
const res = await ApiFetch.api.desa.musik["create"].post(
musik.create.form
);
if (res.status === 200) {
musik.findMany.load();
return toast.success("Musik berhasil disimpan!");
}
return toast.error("Gagal menyimpan musik");
} catch (error) {
console.log((error as Error).message);
} finally {
musik.create.loading = false;
}
},
resetForm() {
musik.create.form = { ...defaultForm };
},
},
findMany: {
data: null as
| Prisma.MusikDesaGetPayload<{
include: {
audioFile: true;
coverImage: true;
};
}>[]
| null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "", genre = "") => {
const startTime = Date.now();
musik.findMany.loading = true;
musik.findMany.page = page;
musik.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
if (genre) query.genre = genre;
const res = await ApiFetch.api.desa.musik["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
musik.findMany.data = res.data.data ?? [];
musik.findMany.totalPages = res.data.totalPages ?? 1;
} else {
musik.findMany.data = [];
musik.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch musik paginated:", err);
musik.findMany.data = [];
musik.findMany.totalPages = 1;
} finally {
const elapsed = Date.now() - startTime;
const minDelay = 300;
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
setTimeout(() => {
musik.findMany.loading = false;
}, delay);
}
},
},
findUnique: {
data: null as Prisma.MusikDesaGetPayload<{
include: {
audioFile: true;
coverImage: true;
};
}> | null,
loading: false,
async load(id: string) {
try {
musik.findUnique.loading = true;
const res = await fetch(`/api/desa/musik/${id}`);
if (res.ok) {
const data = await res.json();
musik.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch musik:", res.statusText);
musik.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching musik:", error);
musik.findUnique.data = null;
} finally {
musik.findUnique.loading = false;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
musik.delete.loading = true;
const response = await fetch(`/api/desa/musik/delete/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Musik berhasil dihapus");
await musik.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus musik");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus musik");
} finally {
musik.delete.loading = false;
}
},
},
edit: {
id: "",
form: { ...defaultForm },
loading: false,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
try {
const response = await fetch(`/api/desa/musik/${id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result?.success) {
const data = result.data;
this.id = data.id;
this.form = {
judul: data.judul,
artis: data.artis,
deskripsi: data.deskripsi || "",
durasi: data.durasi,
audioFileId: data.audioFileId || "",
coverImageId: data.coverImageId || "",
genre: data.genre || "",
tahunRilis: data.tahunRilis || undefined,
};
return data;
} else {
throw new Error(result?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error loading musik:", error);
toast.error(
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
}
},
async update() {
const cek = templateForm.safeParse(musik.edit.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
toast.error(err);
return false;
}
try {
musik.edit.loading = true;
const response = await fetch(`/api/desa/musik/${this.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
judul: this.form.judul,
artis: this.form.artis,
deskripsi: this.form.deskripsi,
durasi: this.form.durasi,
audioFileId: this.form.audioFileId,
coverImageId: this.form.coverImageId,
genre: this.form.genre,
tahunRilis: this.form.tahunRilis,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
}
const result = await response.json();
if (result.success) {
toast.success("Musik berhasil diupdate");
await musik.findMany.load();
return true;
} else {
throw new Error(result.message || "Gagal update musik");
}
} catch (error) {
console.error("Error updating musik:", error);
toast.error(
error instanceof Error
? error.message
: "Terjadi kesalahan saat update musik"
);
return false;
} finally {
musik.edit.loading = false;
}
},
reset() {
musik.edit.id = "";
musik.edit.form = { ...defaultForm };
},
},
});
// 4. State global
const stateDashboardMusik = proxy({
musik,
});
export default stateDashboardMusik;

View File

@@ -5,52 +5,53 @@ import { toast } from "react-toastify";
import { proxy } from "valtio"; import { proxy } from "valtio";
import { z } from "zod"; import { z } from "zod";
// --- Zod Schema untuk APBDes Item (dengan field kalkulasi) --- // --- Zod Schema ---
const ApbdesItemSchema = z.object({ const ApbdesItemSchema = z.object({
kode: z.string().min(1, "Kode wajib diisi"), kode: z.string().min(1, "Kode wajib diisi"),
uraian: z.string().min(1, "Uraian wajib diisi"), uraian: z.string().min(1, "Uraian wajib diisi"),
anggaran: z.number().min(0, "Anggaran tidak boleh negatif"), anggaran: z.number().min(0),
realisasi: z.number().min(0),
selisih: z.number(),
persentase: z.number(),
level: z.number().int().min(1).max(3), level: z.number().int().min(1).max(3),
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(), tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
// Field kalkulasi dari realisasiItems (auto-calculated di backend)
realisasi: z.number().min(0).default(0),
selisih: z.number().default(0),
persentase: z.number().default(0),
}); });
const ApbdesFormSchema = z.object({ const ApbdesFormSchema = z.object({
tahun: z.number().int().min(2000, "Tahun tidak valid"), tahun: z.number().int().min(2000, "Tahun tidak valid"),
name: z.string().optional(), imageId: z.string().min(1, "Gambar wajib diunggah"),
deskripsi: z.string().optional(), fileId: z.string().min(1, "File wajib diunggah"),
jumlah: z.string().optional(),
// Image dan file opsional (bisa kosong)
imageId: z.string().optional(),
fileId: z.string().optional(),
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"), items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
}); });
// --- Default Form --- // --- Default Form ---
const defaultApbdesForm = { const defaultApbdesForm = {
tahun: new Date().getFullYear(), tahun: new Date().getFullYear(),
name: "",
deskripsi: "",
jumlah: "",
imageId: "", imageId: "",
fileId: "", fileId: "",
items: [] as z.infer<typeof ApbdesItemSchema>[], items: [] as z.infer<typeof ApbdesItemSchema>[],
}; };
// --- Helper: Normalize item (dengan field kalkulasi) --- // --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> { function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
const anggaran = item.anggaran ?? 0;
const realisasi = item.realisasi ?? 0;
// ✅ Formula yang benar
const selisih = realisasi - anggaran; // positif = sisa anggaran, negatif = over budget
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran
return { return {
kode: item.kode || "", kode: item.kode || "",
uraian: item.uraian || "", uraian: item.uraian || "",
anggaran: item.anggaran ?? 0, anggaran,
realisasi,
selisih,
persentase,
level: item.level || 1, level: item.level || 1,
tipe: item.tipe ?? null, tipe: item.tipe, // biarkan null jika memang null
realisasi: item.realisasi ?? 0,
selisih: item.selisih ?? 0,
persentase: item.persentase ?? 0,
}; };
} }
@@ -112,7 +113,7 @@ const apbdes = proxy({
findMany: { findMany: {
data: null as data: null as
| Prisma.APBDesGetPayload<{ | Prisma.APBDesGetPayload<{
include: { image: true; file: true; items: { include: { realisasiItems: true } } }; include: { image: true; file: true; items: true };
}>[] }>[]
| null, | null,
page: 1, page: 1,
@@ -157,7 +158,7 @@ const apbdes = proxy({
findUnique: { findUnique: {
data: null as data: null as
| Prisma.APBDesGetPayload<{ | Prisma.APBDesGetPayload<{
include: { image: true; file: true; items: { include: { realisasiItems: true } } }; include: { image: true; file: true; items: true };
}> }>
| null, | null,
loading: false, loading: false,
@@ -170,19 +171,15 @@ const apbdes = proxy({
return; return;
} }
// Prevent multiple simultaneous loads
if (this.loading) {
console.log("⚠️ Already loading, skipping...");
return;
}
this.loading = true; this.loading = true;
this.error = null; this.error = null;
try { try {
// Pastikan URL-nya benar
const url = `/api/landingpage/apbdes/${id}`; const url = `/api/landingpage/apbdes/${id}`;
console.log("🌐 Fetching:", url); console.log("🌐 Fetching:", url);
// Gunakan fetch biasa atau ApiFetch dengan cara yang benar
const response = await fetch(url); const response = await fetch(url);
const res = await response.json(); const res = await response.json();
@@ -247,18 +244,15 @@ const apbdes = proxy({
this.id = data.id; this.id = data.id;
this.form = { this.form = {
tahun: data.tahun || new Date().getFullYear(), tahun: data.tahun || new Date().getFullYear(),
name: data.name || "",
deskripsi: data.deskripsi || "",
jumlah: data.jumlah || "",
imageId: data.imageId || "", imageId: data.imageId || "",
fileId: data.fileId || "", fileId: data.fileId || "",
items: (data.items || []).map((item: any) => ({ items: (data.items || []).map((item: any) => ({
kode: item.kode, kode: item.kode,
uraian: item.uraian, uraian: item.uraian,
anggaran: item.anggaran, anggaran: item.anggaran,
realisasi: item.totalRealisasi || 0, realisasi: item.realisasi,
selisih: item.selisih || 0, selisih: item.selisih,
persentase: item.persentase || 0, persentase: item.persentase,
level: item.level, level: item.level,
tipe: item.tipe || 'pendapatan', tipe: item.tipe || 'pendapatan',
})), })),
@@ -286,22 +280,9 @@ const apbdes = proxy({
try { try {
this.loading = true; this.loading = true;
// Include the ID in the request body // Include the ID in the request body
// Omit realisasi, selisih, persentase karena itu calculated fields di backend
const requestData = { const requestData = {
tahun: parsed.data.tahun, ...parsed.data,
name: parsed.data.name, id: this.id, // Add the ID to the request body
deskripsi: parsed.data.deskripsi,
jumlah: parsed.data.jumlah,
imageId: parsed.data.imageId,
fileId: parsed.data.fileId,
id: this.id,
items: parsed.data.items.map(item => ({
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
level: item.level,
tipe: item.tipe ?? null,
})),
}; };
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData); const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
@@ -336,82 +317,6 @@ const apbdes = proxy({
this.form = { ...defaultApbdesForm }; this.form = { ...defaultApbdesForm };
}, },
}, },
// =========================================
// REALISASI STATE MANAGEMENT
// =========================================
realisasi: {
// Create realisasi
async create(itemId: string, data: { kode: string; jumlah: number; tanggal: string; keterangan?: string; buktiFileId?: string }) {
try {
const res = await (ApiFetch.api.landingpage.apbdes as any)[itemId].realisasi.post(data);
if (res.data?.success) {
toast.success("Realisasi berhasil ditambahkan");
// Reload findUnique untuk update data
const currentId = apbdes.findUnique.data?.id;
if (currentId) {
await apbdes.findUnique.load(currentId);
}
return true;
} else {
toast.error(res.data?.message || "Gagal menambahkan realisasi");
return false;
}
} catch (error: any) {
console.error("Create realisasi error:", error);
toast.error(error?.message || "Terjadi kesalahan saat menambahkan realisasi");
return false;
}
},
// Update realisasi
async update(realisasiId: string, data: { kode?: string; jumlah?: number; tanggal?: string; keterangan?: string; buktiFileId?: string }) {
try {
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].put(data);
if (res.data?.success) {
toast.success("Realisasi berhasil diperbarui");
// Reload findUnique untuk update data
const currentId = apbdes.findUnique.data?.id;
if (currentId) {
await apbdes.findUnique.load(currentId);
}
return true;
} else {
toast.error(res.data?.message || "Gagal memperbarui realisasi");
return false;
}
} catch (error: any) {
console.error("Update realisasi error:", error);
toast.error(error?.message || "Terjadi kesalahan saat memperbarui realisasi");
return false;
}
},
// Delete realisasi
async delete(realisasiId: string) {
try {
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].delete();
if (res.data?.success) {
toast.success("Realisasi berhasil dihapus");
// Reload findUnique untuk update data
if (apbdes.findUnique.data) {
await apbdes.findUnique.load(apbdes.findUnique.data.id);
}
return true;
} else {
toast.error(res.data?.message || "Gagal menghapus realisasi");
return false;
}
} catch (error: any) {
console.error("Delete realisasi error:", error);
toast.error(error?.message || "Terjadi kesalahan saat menghapus realisasi");
return false;
}
},
},
}); });
export default apbdes; export default apbdes;

View File

@@ -160,7 +160,7 @@ function ListKategoriBerita({ search }: { search: string }) {
)) ))
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={3}> {/* ✅ Match column count (3 columns) */} <TableTd colSpan={4}>
<Center py={24}> <Center py={24}>
<Text c="dimmed" fz="sm" lh={1.4}> <Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori berita yang cocok Tidak ada data kategori berita yang cocok

View File

@@ -9,8 +9,6 @@ import {
ActionIcon, ActionIcon,
Box, Box,
Button, Button,
Card,
Grid,
Group, Group,
Image, Image,
Paper, Paper,
@@ -19,7 +17,7 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
Loader, Loader
} from "@mantine/core"; } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone"; import { Dropzone } from "@mantine/dropzone";
import { import {
@@ -27,51 +25,19 @@ import {
IconPhoto, IconPhoto,
IconUpload, IconUpload,
IconX, IconX,
IconVideo,
IconTrash,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useProxy } from "valtio/utils"; import { useProxy } from "valtio/utils";
import { convertYoutubeUrlToEmbed } from '@/app/admin/(dashboard)/desa/gallery/lib/youtube-utils';
interface ExistingImage {
id: string;
link: string;
name: string;
}
interface BeritaData {
id: string;
judul: string;
deskripsi: string;
content: string;
kategoriBeritaId: string | null;
imageId: string | null;
image?: { link: string } | null;
images?: ExistingImage[];
linkVideo?: string | null;
}
function EditBerita() { function EditBerita() {
const beritaState = useProxy(stateDashboardBerita); const beritaState = useProxy(stateDashboardBerita);
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
// Featured image state
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
// Gallery images state
const [existingGalleryImages, setExistingGalleryImages] = useState<ExistingImage[]>([]);
const [galleryFiles, setGalleryFiles] = useState<File[]>([]);
const [galleryPreviews, setGalleryPreviews] = useState<string[]>([]);
// YouTube link state
const [youtubeLink, setYoutubeLink] = useState('');
const [originalYoutubeLink, setOriginalYoutubeLink] = useState('');
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
judul: "", judul: "",
deskripsi: "", deskripsi: "",
@@ -82,17 +48,9 @@ function EditBerita() {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
judul: "",
deskripsi: "",
kategoriBeritaId: "",
content: "",
imageId: "",
imageUrl: ""
});
// Helper function to check if HTML content is empty // Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => { const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim(); const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === ''; return textContent === '';
}; };
@@ -103,12 +61,21 @@ function EditBerita() {
formData.judul?.trim() !== '' && formData.judul?.trim() !== '' &&
formData.kategoriBeritaId !== '' && formData.kategoriBeritaId !== '' &&
!isHtmlEmpty(formData.deskripsi) && !isHtmlEmpty(formData.deskripsi) &&
(file !== null || originalData.imageId !== '') && (file !== null || originalData.imageId !== '') && // Either a new file is selected or an existing image exists
!isHtmlEmpty(formData.content) !isHtmlEmpty(formData.content)
); );
}; };
// Load data const [originalData, setOriginalData] = useState({
judul: "",
deskripsi: "",
kategoriBeritaId: "",
content: "",
imageId: "",
imageUrl: ""
});
// Load kategori + berita
useEffect(() => { useEffect(() => {
beritaState.kategoriBerita.findMany.load(); beritaState.kategoriBerita.findMany.load();
@@ -117,7 +84,7 @@ function EditBerita() {
if (!id) return; if (!id) return;
try { try {
const data = await stateDashboardBerita.berita.edit.load(id) as BeritaData | null; const data = await stateDashboardBerita.berita.edit.load(id);
if (data) { if (data) {
setFormData({ setFormData({
judul: data.judul || "", judul: data.judul || "",
@@ -139,17 +106,6 @@ function EditBerita() {
if (data?.image?.link) { if (data?.image?.link) {
setPreviewImage(data.image.link); setPreviewImage(data.image.link);
} }
// Load gallery images
if (data?.images && data.images.length > 0) {
setExistingGalleryImages(data.images);
}
// Load YouTube link
if (data?.linkVideo) {
setYoutubeLink(data.linkVideo);
setOriginalYoutubeLink(data.linkVideo);
}
} }
} catch (error) { } catch (error) {
console.error("Error loading berita:", error); console.error("Error loading berita:", error);
@@ -164,38 +120,6 @@ function EditBerita() {
setFormData((prev) => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
}; };
const handleGalleryDrop = (files: File[]) => {
const maxImages = 10;
const currentCount = existingGalleryImages.length + galleryFiles.length;
const availableSlots = maxImages - currentCount;
if (availableSlots <= 0) {
toast.warn('Maksimal 10 gambar untuk galeri');
return;
}
const newFiles = files.slice(0, availableSlots);
if (newFiles.length === 0) {
toast.warn('Tidak ada slot tersisa untuk gambar galeri');
return;
}
setGalleryFiles([...galleryFiles, ...newFiles]);
const newPreviews = newFiles.map((f) => URL.createObjectURL(f));
setGalleryPreviews([...galleryPreviews, ...newPreviews]);
};
const removeGalleryImage = (index: number, isExisting: boolean = false) => {
if (isExisting) {
setExistingGalleryImages(existingGalleryImages.filter((_, i) => i !== index));
} else {
setGalleryFiles(galleryFiles.filter((_, i) => i !== index));
setGalleryPreviews(galleryPreviews.filter((_, i) => i !== index));
}
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (!formData.judul?.trim()) { if (!formData.judul?.trim()) {
toast.error('Judul wajib diisi'); toast.error('Judul wajib diisi');
@@ -213,7 +137,7 @@ function EditBerita() {
} }
if (!file && !originalData.imageId) { if (!file && !originalData.imageId) {
toast.error('Gambar utama wajib dipilih'); toast.error('Gambar wajib dipilih');
return; return;
} }
@@ -224,14 +148,12 @@ function EditBerita() {
try { try {
setIsSubmitting(true); setIsSubmitting(true);
// Update global state hanya sekali di sini
// Update global state
beritaState.berita.edit.form = { beritaState.berita.edit.form = {
...beritaState.berita.edit.form, ...beritaState.berita.edit.form,
...formData, ...formData,
}; };
// Upload new featured image if changed
if (file) { if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
file, file,
@@ -240,33 +162,12 @@ function EditBerita() {
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) { if (!uploaded?.id) {
return toast.error("Gagal upload gambar utama"); return toast.error("Gagal upload gambar");
} }
beritaState.berita.edit.form.imageId = uploaded.id; beritaState.berita.edit.form.imageId = uploaded.id;
} }
// Upload new gallery images
const newGalleryIds: string[] = [];
for (const galleryFile of galleryFiles) {
const galleryRes = await ApiFetch.api.fileStorage.create.post({
file: galleryFile,
name: galleryFile.name,
});
const galleryUploaded = galleryRes.data?.data;
if (galleryUploaded?.id) {
newGalleryIds.push(galleryUploaded.id);
}
}
// Combine existing (not removed) and new gallery images
const remainingExistingIds = existingGalleryImages.map(img => img.id);
beritaState.berita.edit.form.imageIds = [...remainingExistingIds, ...newGalleryIds];
// Set YouTube link
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
beritaState.berita.edit.form.linkVideo = embedLink || '';
await beritaState.berita.edit.update(); await beritaState.berita.edit.update();
toast.success("Berita berhasil diperbarui!"); toast.success("Berita berhasil diperbarui!");
router.push("/admin/desa/berita/list-berita"); router.push("/admin/desa/berita/list-berita");
@@ -288,12 +189,9 @@ function EditBerita() {
}); });
setPreviewImage(originalData.imageUrl || null); setPreviewImage(originalData.imageUrl || null);
setFile(null); setFile(null);
setYoutubeLink(originalYoutubeLink);
toast.info("Form dikembalikan ke data awal"); toast.info("Form dikembalikan ke data awal");
}; };
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header */} {/* Header */}
@@ -321,7 +219,6 @@ function EditBerita() {
style={{ border: "1px solid #e0e0e0" }} style={{ border: "1px solid #e0e0e0" }}
> >
<Stack gap="md"> <Stack gap="md">
{/* Judul */}
<TextInput <TextInput
label="Judul" label="Judul"
placeholder="Masukkan judul" placeholder="Masukkan judul"
@@ -330,7 +227,6 @@ function EditBerita() {
required required
/> />
{/* Kategori */}
<Select <Select
value={formData.kategoriBeritaId} value={formData.kategoriBeritaId}
onChange={(val) => handleChange("kategoriBeritaId", val || "")} onChange={(val) => handleChange("kategoriBeritaId", val || "")}
@@ -345,9 +241,9 @@ function EditBerita() {
clearable clearable
searchable searchable
required required
error={!formData.kategoriBeritaId ? "Pilih kategori" : undefined}
/> />
{/* Deskripsi */}
<Box> <Box>
<Text fz="sm" fw="bold"> <Text fz="sm" fw="bold">
Deskripsi Singkat Deskripsi Singkat
@@ -360,10 +256,11 @@ function EditBerita() {
/> />
</Box> </Box>
{/* Featured Image */}
{/* Upload Gambar */}
<Box> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
Gambar Utama (Featured) Gambar Berita
</Text> </Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
@@ -377,13 +274,17 @@ function EditBerita() {
toast.error("File tidak valid, gunakan format gambar") toast.error("File tidak valid, gunakan format gambar")
} }
maxSize={5 * 1024 ** 2} maxSize={5 * 1024 ** 2}
accept={{ "image/*": ['.jpeg', '.jpg', '.png', '.webp'] }} accept={{ "image/*": [] }}
radius="md" radius="md"
p="xl" p="xl"
> >
<Group justify="center" gap="xl" mih={180}> <Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept> <Dropzone.Accept>
<IconUpload size={48} color={colors["blue-button"]} stroke={1.5} /> <IconUpload
size={48}
color={colors["blue-button"]}
stroke={1.5}
/>
</Dropzone.Accept> </Dropzone.Accept>
<Dropzone.Reject> <Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} /> <IconX size={48} color="red" stroke={1.5} />
@@ -391,6 +292,14 @@ function EditBerita() {
<Dropzone.Idle> <Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} /> <IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle> </Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group> </Group>
</Dropzone> </Dropzone>
@@ -419,7 +328,9 @@ function EditBerita() {
setPreviewImage(null); setPreviewImage(null);
setFile(null); setFile(null);
}} }}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }} style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
> >
<IconX size={14} /> <IconX size={14} />
</ActionIcon> </ActionIcon>
@@ -427,138 +338,6 @@ function EditBerita() {
)} )}
</Box> </Box>
{/* Gallery Images */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Galeri Gambar (Opsional - Maksimal 10)
</Text>
<Dropzone
onDrop={handleGalleryDrop}
onReject={() => toast.error("File tidak valid, gunakan format gambar")}
maxSize={5 * 1024 ** 2}
accept={{ "image/*": ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="md"
multiple
>
<Group justify="center" gap="xl" mih={120}>
<Dropzone.Accept>
<IconUpload size={40} color={colors["blue-button"]} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={40} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={40} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="xs" color="dimmed">
Seret gambar untuk menambahkan ke galeri
</Text>
</Dropzone>
{/* Existing Gallery Images */}
{existingGalleryImages.length > 0 && (
<Box mt="sm">
<Text fz="xs" fw="bold" mb={6} c="dimmed">
Gambar Existing ({existingGalleryImages.length})
</Text>
<Grid gutter="sm">
{existingGalleryImages.map((img, index) => (
<Grid.Col span={4} key={img.id}>
<Card p="xs" radius="md" withBorder>
<Image src={img.link} alt={img.name} radius="sm" height={100} fit="cover" />
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => removeGalleryImage(index, true)}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconTrash size={14} />
</ActionIcon>
</Card>
</Grid.Col>
))}
</Grid>
</Box>
)}
{/* New Gallery Images */}
{galleryPreviews.length > 0 && (
<Box mt="sm">
<Text fz="xs" fw="bold" mb={6} c="dimmed">
Gambar Baru ({galleryPreviews.length})
</Text>
<Grid gutter="sm">
{galleryPreviews.map((preview, index) => (
<Grid.Col span={4} key={index}>
<Card p="xs" radius="md" withBorder>
<Image src={preview} alt={`New ${index}`} radius="sm" height={100} fit="cover" />
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => removeGalleryImage(index, false)}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconTrash size={14} />
</ActionIcon>
</Card>
</Grid.Col>
))}
</Grid>
</Box>
)}
</Box>
{/* YouTube Video */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Link Video YouTube (Opsional)
</Text>
<TextInput
placeholder="https://www.youtube.com/watch?v=..."
value={youtubeLink}
onChange={(e) => setYoutubeLink(e.currentTarget.value)}
leftSection={<IconVideo size={18} />}
rightSection={
youtubeLink && (
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setYoutubeLink('')}
>
<IconX size={18} />
</ActionIcon>
)
}
/>
{embedLink && (
<Box mt="sm" pos="relative">
<iframe
style={{
borderRadius: 10,
width: '100%',
height: 250,
border: '1px solid #ddd',
}}
src={embedLink}
title="Preview Video"
allowFullScreen
/>
</Box>
)}
</Box>
{/* Konten */} {/* Konten */}
<Box> <Box>
<Text fz="sm" fw="bold"> <Text fz="sm" fw="bold">
@@ -572,8 +351,9 @@ function EditBerita() {
/> />
</Box> </Box>
{/* Action Buttons */} {/* Action */}
<Group justify="right"> <Group justify="right">
{/* Tombol Batal */}
<Button <Button
variant="outline" variant="outline"
color="gray" color="gray"
@@ -583,6 +363,8 @@ function EditBerita() {
> >
Batal Batal
</Button> </Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { Box, Button, Card, Grid, Group, Image, Paper, Skeleton, Stack, Text, Badge, AspectRatio } from '@mantine/core'; import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash, IconVideo } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
@@ -10,23 +10,6 @@ import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirma
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita'; import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
import colors from '@/con/colors'; import colors from '@/con/colors';
interface ExistingImage {
id: string;
link: string;
name: string;
}
interface BeritaDetail {
id: string;
judul: string;
deskripsi: string;
content: string;
image?: { link: string } | null;
images?: ExistingImage[];
linkVideo?: string | null;
kategoriBerita?: { name: string } | null;
}
function DetailBerita() { function DetailBerita() {
const beritaState = useProxy(stateDashboardBerita); const beritaState = useProxy(stateDashboardBerita);
const [modalHapus, setModalHapus] = useState(false); const [modalHapus, setModalHapus] = useState(false);
@@ -55,7 +38,7 @@ function DetailBerita() {
); );
} }
const data = beritaState.berita.findUnique.data as unknown as BeritaDetail; const data = beritaState.berita.findUnique.data;
return ( return (
<Box px={{ base: 0, md: 'xs' }} py="xs"> <Box px={{ base: 0, md: 'xs' }} py="xs">
@@ -85,131 +68,71 @@ function DetailBerita() {
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs"> <Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm"> <Stack gap="sm">
{/* Kategori */}
<Box> <Box>
<Text fz="lg" fw="bold">Kategori</Text> <Text fz="lg" fw="bold">Kategori</Text>
<Text fz="md" c="dimmed">{data.kategoriBerita?.name || '-'}</Text> <Text fz="md" c="dimmed">{data.kategoriBerita?.name || '-'}</Text>
</Box> </Box>
{/* Judul */}
<Box> <Box>
<Text fz="lg" fw="bold">Judul</Text> <Text fz="lg" fw="bold">Judul</Text>
<Text fz="md" c="dimmed">{data.judul || '-'}</Text> <Text fz="md" c="dimmed">{data.judul || '-'}</Text>
</Box> </Box>
{/* Deskripsi */}
<Box> <Box>
<Text fz="lg" fw="bold">Deskripsi</Text> <Text fz="lg" fw="bold">Deskripsi</Text>
<Text <Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }} />
fz="md"
c="dimmed"
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
/>
</Box> </Box>
{/* Gambar Utama (Featured) */}
<Box> <Box>
<Text fz="lg" fw="bold">Gambar Utama</Text> <Text fz="lg" fw="bold">Gambar</Text>
{data.image?.link ? ( {data.image?.link ? (
<Image <Image
src={data.image.link} src={data.image.link}
alt={data.judul || 'Gambar Berita'} alt={data.judul || 'Gambar Berita'}
w={{ base: '100%', md: 400 }} w={200}
h={300} h={200}
radius="md" radius="md"
fit="cover" fit="cover"
loading="lazy" loading='lazy'
/> />
) : ( ) : (
<Text fz="sm" c="dimmed">Tidak ada gambar utama</Text> <Text fz="sm" c="dimmed">Tidak ada gambar</Text>
)} )}
</Box> </Box>
{/* Gallery Images */}
{data.images && data.images.length > 0 && (
<Box>
<Group gap="xs" mb="sm">
<Text fz="lg" fw="bold">Galeri Gambar</Text>
<Badge color="blue" variant="light">
{data.images.length}
</Badge>
</Group>
<Grid gutter="md">
{data.images.map((img, index) => (
<Grid.Col span={{ base: 6, md: 4 }} key={img.id}>
<Card p="xs" radius="md" withBorder>
<Image
src={img.link}
alt={img.name || `Gallery ${index + 1}`}
h={150}
radius="sm"
fit="cover"
loading="lazy"
/>
</Card>
</Grid.Col>
))}
</Grid>
</Box>
)}
{/* YouTube Video */}
{data.linkVideo && (
<Box>
<Group gap="xs" mb="sm">
<Text fz="lg" fw="bold">Video YouTube</Text>
<IconVideo size={20} color={colors['blue-button']} />
</Group>
<AspectRatio ratio={16 / 9} mah={400}>
<iframe
src={data.linkVideo}
title="YouTube Video"
allowFullScreen
style={{ borderRadius: 10, border: '1px solid #ddd' }}
/>
</AspectRatio>
</Box>
)}
{/* Konten */}
<Box> <Box>
<Text fz="lg" fw="bold">Konten</Text> <Text fz="lg" fw="bold">Konten</Text>
<Paper bg="white" p="md" radius="md" mt="xs"> <Text
<Text fz="md"
fz="md" c="dimmed"
c="dimmed" dangerouslySetInnerHTML={{ __html: data.content || '-' }}
dangerouslySetInnerHTML={{ __html: data.content || '-' }} />
/>
</Paper>
</Box> </Box>
{/* Action Buttons */} {/* Action Button */}
<Group gap="sm" mt="md"> <Group gap="sm">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
setSelectedId(data.id); setSelectedId(data.id);
setModalHapus(true); setModalHapus(true);
}} }}
variant="light" variant="light"
radius="md" radius="md"
size="md" size="md"
leftSection={<IconTrash size={20} />} >
> <IconTrash size={20} />
Hapus </Button>
</Button>
<Button <Button
color="green" color="green"
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)} onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
variant="light" variant="light"
radius="md" radius="md"
size="md" size="md"
leftSection={<IconEdit size={20} />} >
> <IconEdit size={20} />
Edit </Button>
</Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -15,38 +15,26 @@ import {
TextInput, TextInput,
Title, Title,
Loader, Loader,
ActionIcon, ActionIcon
Grid,
Card,
} from '@mantine/core'; } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconPhoto, IconUpload, IconX, IconVideo, IconTrash } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { convertYoutubeUrlToEmbed } from '@/app/admin/(dashboard)/desa/gallery/lib/youtube-utils';
export default function CreateBerita() { export default function CreateBerita() {
const beritaState = useProxy(stateDashboardBerita); const beritaState = useProxy(stateDashboardBerita);
const router = useRouter();
// Featured image state
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const router = useRouter();
// Gallery images state
const [galleryFiles, setGalleryFiles] = useState<File[]>([]);
const [galleryPreviews, setGalleryPreviews] = useState<string[]>([]);
// YouTube link state
const [youtubeLink, setYoutubeLink] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// Helper function to check if HTML content is empty // Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => { const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim(); const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === ''; return textContent === '';
}; };
@@ -73,35 +61,9 @@ export default function CreateBerita() {
kategoriBeritaId: '', kategoriBeritaId: '',
imageId: '', imageId: '',
content: '', content: '',
imageIds: [],
linkVideo: '',
}; };
setPreviewImage(null); setPreviewImage(null);
setFile(null); setFile(null);
setGalleryFiles([]);
setGalleryPreviews([]);
setYoutubeLink('');
};
const handleGalleryDrop = (files: File[]) => {
const newFiles = files.filter(
(_, index) => galleryFiles.length + index < 10 // Max 10 images
);
if (newFiles.length === 0) {
toast.warn('Maksimal 10 gambar untuk galeri');
return;
}
setGalleryFiles([...galleryFiles, ...newFiles]);
const newPreviews = newFiles.map((f) => URL.createObjectURL(f));
setGalleryPreviews([...galleryPreviews, ...newPreviews]);
};
const removeGalleryImage = (index: number) => {
setGalleryFiles(galleryFiles.filter((_, i) => i !== index));
setGalleryPreviews(galleryPreviews.filter((_, i) => i !== index));
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -121,7 +83,7 @@ export default function CreateBerita() {
} }
if (!file) { if (!file) {
toast.error('Gambar utama wajib dipilih'); toast.error('Gambar wajib dipilih');
return; return;
} }
@@ -132,37 +94,21 @@ export default function CreateBerita() {
try { try {
setIsSubmitting(true); setIsSubmitting(true);
if (!file) {
return toast.warn('Silakan pilih file gambar terlebih dahulu');
}
// Upload featured image const res = await ApiFetch.api.fileStorage.create.post({
const featuredRes = await ApiFetch.api.fileStorage.create.post({
file, file,
name: file.name, name: file.name,
}); });
const featuredUploaded = featuredRes.data?.data;
if (!featuredUploaded?.id) {
return toast.error('Gagal mengunggah gambar utama');
}
beritaState.berita.create.form.imageId = featuredUploaded.id;
// Upload gallery images const uploaded = res.data?.data;
const galleryIds: string[] = []; if (!uploaded?.id) {
for (const galleryFile of galleryFiles) { return toast.error('Gagal mengunggah gambar, silakan coba lagi');
const galleryRes = await ApiFetch.api.fileStorage.create.post({
file: galleryFile,
name: galleryFile.name,
});
const galleryUploaded = galleryRes.data?.data;
if (galleryUploaded?.id) {
galleryIds.push(galleryUploaded.id);
}
} }
beritaState.berita.create.form.imageIds = galleryIds;
// Set YouTube link if provided beritaState.berita.create.form.imageId = uploaded.id;
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
if (embedLink) {
beritaState.berita.create.form.linkVideo = embedLink;
}
await beritaState.berita.create.create(); await beritaState.berita.create.create();
@@ -176,13 +122,16 @@ export default function CreateBerita() {
} }
}; };
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header */} {/* Header dengan tombol kembali */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
@@ -199,7 +148,6 @@ export default function CreateBerita() {
style={{ border: '1px solid #e0e0e0' }} style={{ border: '1px solid #e0e0e0' }}
> >
<Stack gap="md"> <Stack gap="md">
{/* Judul */}
<TextInput <TextInput
label="Judul" label="Judul"
placeholder="Masukkan judul berita" placeholder="Masukkan judul berita"
@@ -208,7 +156,6 @@ export default function CreateBerita() {
required required
/> />
{/* Kategori */}
<Select <Select
label="Kategori" label="Kategori"
placeholder="Pilih kategori" placeholder="Pilih kategori"
@@ -235,7 +182,6 @@ export default function CreateBerita() {
required required
/> />
{/* Deskripsi */}
<Box> <Box>
<Text fz="sm" fw="bold" mb={6}> <Text fz="sm" fw="bold" mb={6}>
Deskripsi Singkat Deskripsi Singkat
@@ -248,10 +194,9 @@ export default function CreateBerita() {
/> />
</Box> </Box>
{/* Featured Image */}
<Box> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
Gambar Utama (Featured) Gambar Berita
</Text> </Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
@@ -287,11 +232,17 @@ export default function CreateBerita() {
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}> <Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image <Image
src={previewImage} src={previewImage}
alt="Preview Gambar Utama" alt="Preview Gambar"
radius="md" radius="md"
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }} style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy" loading="lazy"
/> />
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon <ActionIcon
variant="filled" variant="filled"
color="red" color="red"
@@ -304,7 +255,9 @@ export default function CreateBerita() {
setPreviewImage(null); setPreviewImage(null);
setFile(null); setFile(null);
}} }}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }} style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
> >
<IconX size={14} /> <IconX size={14} />
</ActionIcon> </ActionIcon>
@@ -312,102 +265,6 @@ export default function CreateBerita() {
)} )}
</Box> </Box>
{/* Gallery Images */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Galeri Gambar (Opsional - Maksimal 10)
</Text>
<Dropzone
onDrop={handleGalleryDrop}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="md"
multiple
>
<Group justify="center" gap="xl" mih={120}>
<Dropzone.Accept>
<IconUpload size={40} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={40} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={40} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="xs" color="dimmed">
Seret gambar atau klik untuk menambahkan ke galeri
</Text>
</Dropzone>
{galleryPreviews.length > 0 && (
<Grid mt="sm" gutter="sm">
{galleryPreviews.map((preview, index) => (
<Grid.Col span={4} key={index}>
<Card p="xs" radius="md" withBorder>
<Image src={preview} alt={`Gallery ${index}`} radius="sm" height={100} fit="cover" />
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => removeGalleryImage(index)}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconTrash size={14} />
</ActionIcon>
</Card>
</Grid.Col>
))}
</Grid>
)}
</Box>
{/* YouTube Video */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Link Video YouTube (Opsional)
</Text>
<TextInput
placeholder="https://www.youtube.com/watch?v=..."
value={youtubeLink}
onChange={(e) => setYoutubeLink(e.currentTarget.value)}
leftSection={<IconVideo size={18} />}
rightSection={
youtubeLink && (
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setYoutubeLink('')}
>
<IconX size={18} />
</ActionIcon>
)
}
/>
{embedLink && (
<Box mt="sm" pos="relative">
<iframe
style={{
borderRadius: 10,
width: '100%',
height: 250,
border: '1px solid #ddd',
}}
src={embedLink}
title="Preview Video"
allowFullScreen
/>
</Box>
)}
</Box>
{/* Konten */}
<Box> <Box>
<Text fz="sm" fw="bold" mb={6}> <Text fz="sm" fw="bold" mb={6}>
Konten Konten
@@ -420,7 +277,6 @@ export default function CreateBerita() {
/> />
</Box> </Box>
{/* Buttons */}
<Group justify="right"> <Group justify="right">
<Button <Button
variant="outline" variant="outline"
@@ -431,6 +287,8 @@ export default function CreateBerita() {
> >
Reset Reset
</Button> </Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"

View File

@@ -187,7 +187,7 @@ function ListBerita({ search }: { search: string }) {
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => { onChange={(newPage) => {
load(newPage, 10, debouncedSearch); // ✅ Include search parameter load(newPage, 10);
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}} }}
total={totalPages} total={totalPages}

View File

@@ -8,7 +8,6 @@ import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import DOMPurify from 'dompurify';
export default function DetailPotensi() { export default function DetailPotensi() {
const router = useRouter(); const router = useRouter();
@@ -78,17 +77,7 @@ export default function DetailPotensi() {
<Box> <Box>
<Text fz="lg" fw="bold">Deskripsi</Text> <Text fz="lg" fw="bold">Deskripsi</Text>
<Text <Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}></Text>
fz="md"
c="dimmed"
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(data.deskripsi || '-', {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
ALLOWED_ATTR: []
})
}}
></Text>
</Box> </Box>
<Box> <Box>
@@ -113,12 +102,7 @@ export default function DetailPotensi() {
<Text <Text
fz="md" fz="md"
c="dimmed" c="dimmed"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{ __html: data.content || '-' }}
__html: DOMPurify.sanitize(data.content || '-', {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
ALLOWED_ATTR: []
})
}}
style={{ wordBreak: "break-word", whiteSpace: "normal" }} style={{ wordBreak: "break-word", whiteSpace: "normal" }}
/> />
</Box> </Box>

View File

@@ -27,7 +27,6 @@ import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import potensiDesaState from '../../../_state/desa/potensi'; import potensiDesaState from '../../../_state/desa/potensi';
import { useDebouncedValue } from '@mantine/hooks'; import { useDebouncedValue } from '@mantine/hooks';
import DOMPurify from 'dompurify';
function Potensi() { function Potensi() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -138,12 +137,7 @@ function ListPotensi({ search }: { search: string }) {
fz="sm" fz="sm"
lh={1.5} lh={1.5}
lineClamp={2} lineClamp={2}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{ __html: item.deskripsi }}
__html: DOMPurify.sanitize(item.deskripsi, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
ALLOWED_ATTR: []
})
}}
style={{ wordBreak: 'break-word' }} style={{ wordBreak: 'break-word' }}
/> />
</TableTd> </TableTd>
@@ -205,12 +199,7 @@ function ListPotensi({ search }: { search: string }) {
<Text <Text
fz="sm" fz="sm"
lh={1.5} lh={1.5}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{ __html: item.deskripsi }}
__html: DOMPurify.sanitize(item.deskripsi, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
ALLOWED_ATTR: []
})
}}
style={{ wordBreak: 'break-word' }} style={{ wordBreak: 'break-word' }}
/> />
</Box> </Box>

View File

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

View File

@@ -42,11 +42,9 @@ type ItemForm = {
kode: string; kode: string;
uraian: string; uraian: string;
anggaran: number; anggaran: number;
realisasi: number;
level: number; level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan'; tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
realisasi?: number;
selisih?: number;
persentase?: number;
}; };
function EditAPBDes() { function EditAPBDes() {
@@ -73,19 +71,14 @@ function EditAPBDes() {
kode: '', kode: '',
uraian: '', uraian: '',
anggaran: 0, anggaran: 0,
realisasi: 0,
level: 1, level: 1,
tipe: 'pendapatan', tipe: 'pendapatan',
realisasi: 0,
selisih: 0,
persentase: 0,
}); });
// Simpan data original untuk reset form // Simpan data original untuk reset form
const [originalData, setOriginalData] = useState({ const [originalData, setOriginalData] = useState({
tahun: 0, tahun: 0,
name: '',
deskripsi: '',
jumlah: '',
imageId: '', imageId: '',
fileId: '', fileId: '',
imageUrl: '', imageUrl: '',
@@ -110,9 +103,6 @@ function EditAPBDes() {
// Simpan data original untuk reset // Simpan data original untuk reset
setOriginalData({ setOriginalData({
tahun: data.tahun || new Date().getFullYear(), tahun: data.tahun || new Date().getFullYear(),
name: data.name || '',
deskripsi: data.deskripsi || '',
jumlah: data.jumlah || '',
imageId: data.imageId || '', imageId: data.imageId || '',
fileId: data.fileId || '', fileId: data.fileId || '',
imageUrl: data.image?.link || '', imageUrl: data.image?.link || '',
@@ -122,18 +112,15 @@ function EditAPBDes() {
// Set form dengan data lama (termasuk imageId dan fileId) // Set form dengan data lama (termasuk imageId dan fileId)
apbdesState.edit.form = { apbdesState.edit.form = {
tahun: data.tahun || new Date().getFullYear(), tahun: data.tahun || new Date().getFullYear(),
name: data.name || '',
deskripsi: data.deskripsi || '',
jumlah: data.jumlah || '',
imageId: data.imageId || '', imageId: data.imageId || '',
fileId: data.fileId || '', fileId: data.fileId || '',
items: (data.items || []).map((item: any) => ({ items: (data.items || []).map((item: any) => ({
kode: item.kode, kode: item.kode,
uraian: item.uraian, uraian: item.uraian,
anggaran: item.anggaran, anggaran: item.anggaran,
realisasi: item.totalRealisasi || 0, realisasi: item.realisasi,
selisih: item.selisih || 0, selisih: item.selisih,
persentase: item.persentase || 0, persentase: item.persentase,
level: item.level, level: item.level,
tipe: item.tipe || 'pendapatan', tipe: item.tipe || 'pendapatan',
})), })),
@@ -161,33 +148,34 @@ function EditAPBDes() {
}; };
const handleAddItem = () => { const handleAddItem = () => {
const { kode, uraian, anggaran, level, tipe, realisasi, selisih, persentase } = newItem; const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
if (!kode || !uraian) { if (!kode || !uraian) {
return toast.warn('Kode dan uraian wajib diisi'); return toast.warn('Kode dan uraian wajib diisi');
} }
const finalTipe = level === 1 ? null : tipe; const finalTipe = level === 1 ? null : tipe;
const selisih = realisasi - anggaran;
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
apbdesState.edit.addItem({ apbdesState.edit.addItem({
kode, kode,
uraian, uraian,
anggaran, anggaran,
realisasi: realisasi || 0, realisasi,
selisih: selisih || 0, selisih,
persentase: persentase || 0, persentase,
level, level,
tipe: finalTipe, tipe: finalTipe, // ✅ Tidak akan undefined
}); });
setNewItem({ setNewItem({
kode: '', kode: '',
uraian: '', uraian: '',
anggaran: 0, anggaran: 0,
realisasi: 0,
level: 1, level: 1,
tipe: 'pendapatan', tipe: 'pendapatan',
realisasi: 0,
selisih: 0,
persentase: 0,
}); });
}; };
@@ -205,6 +193,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 +205,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 +216,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');
@@ -240,12 +238,9 @@ function EditAPBDes() {
}; };
const handleReset = () => { const handleReset = () => {
// Reset ke data original (tahun, name, deskripsi, jumlah, imageId, fileId) // Reset ke data original (tahun, imageId, fileId)
apbdesState.edit.form = { apbdesState.edit.form = {
tahun: originalData.tahun, tahun: originalData.tahun,
name: originalData.name,
deskripsi: originalData.deskripsi,
jumlah: originalData.jumlah,
imageId: originalData.imageId, imageId: originalData.imageId,
fileId: originalData.fileId, fileId: originalData.fileId,
items: [...apbdesState.edit.form.items], // keep existing items items: [...apbdesState.edit.form.items], // keep existing items
@@ -264,11 +259,9 @@ function EditAPBDes() {
kode: '', kode: '',
uraian: '', uraian: '',
anggaran: 0, anggaran: 0,
realisasi: 0,
level: 1, level: 1,
tipe: 'pendapatan', tipe: 'pendapatan',
realisasi: 0,
selisih: 0,
persentase: 0,
}); });
toast.info('Form dikembalikan ke data awal'); toast.info('Form dikembalikan ke data awal');
@@ -295,33 +288,6 @@ function EditAPBDes() {
> >
<Stack gap="md"> <Stack gap="md">
{/* Header Form */} {/* Header Form */}
<TextInput
label="Nama APBDes"
placeholder="Contoh: APBDes Tahun 2025"
value={apbdesState.edit.form.name}
onChange={(e) =>
(apbdesState.edit.form.name = e.target.value)
}
description="Opsional - akan diisi otomatis jika kosong"
/>
<TextInput
label="Deskripsi"
placeholder="Deskripsi APBDes (opsional)"
value={apbdesState.edit.form.deskripsi}
onChange={(e) =>
(apbdesState.edit.form.deskripsi = e.target.value)
}
description="Opsional"
/>
<TextInput
label="Jumlah Total"
placeholder="Contoh: Rp 1.000.000.000"
value={apbdesState.edit.form.jumlah}
onChange={(e) =>
(apbdesState.edit.form.jumlah = e.target.value)
}
description="Opsional - total keseluruhan anggaran"
/>
<NumberInput <NumberInput
label="Tahun" label="Tahun"
value={apbdesState.edit.form.tahun || new Date().getFullYear()} value={apbdesState.edit.form.tahun || new Date().getFullYear()}
@@ -333,11 +299,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 +343,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 +353,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 +402,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} />
@@ -511,6 +475,13 @@ function EditAPBDes() {
thousandSeparator thousandSeparator
min={0} min={0}
/> />
<NumberInput
label="Realisasi (Rp)"
value={newItem.realisasi}
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
thousandSeparator
min={0}
/>
</Group> </Group>
<Button <Button
leftSection={<IconPlus size={16} />} leftSection={<IconPlus size={16} />}
@@ -535,8 +506,6 @@ function EditAPBDes() {
<th>Uraian</th> <th>Uraian</th>
<th>Anggaran</th> <th>Anggaran</th>
<th>Realisasi</th> <th>Realisasi</th>
<th>Selisih</th>
<th>%</th>
<th>Level</th> <th>Level</th>
<th>Tipe</th> <th>Tipe</th>
<th style={{ width: '50px' }}>Aksi</th> <th style={{ width: '50px' }}>Aksi</th>
@@ -552,11 +521,7 @@ function EditAPBDes() {
</td> </td>
<td>{item.uraian}</td> <td>{item.uraian}</td>
<td>{item.anggaran.toLocaleString('id-ID')}</td> <td>{item.anggaran.toLocaleString('id-ID')}</td>
<td>{item.realisasi?.toLocaleString('id-ID') || '0'}</td> <td>{item.realisasi.toLocaleString('id-ID')}</td>
<td style={{ color: item.selisih && item.selisih > 0 ? 'red' : 'green' }}>
{item.selisih?.toLocaleString('id-ID') || '0'}
</td>
<td>{item.persentase?.toFixed(2) || '0'}%</td>
<td> <td>
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}> <Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
L{item.level} L{item.level}
@@ -568,7 +533,7 @@ function EditAPBDes() {
{item.tipe} {item.tipe}
</Badge> </Badge>
) : ( ) : (
<Text size="sm" c="dimmed">-</Text> '-'
)} )}
</td> </td>
<td> <td>

View File

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

View File

@@ -33,6 +33,7 @@ type ItemForm = {
kode: string; kode: string;
uraian: string; uraian: string;
anggaran: number; anggaran: number;
realisasi: number;
level: number; level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan'; tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
}; };
@@ -46,9 +47,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
@@ -56,6 +61,7 @@ function CreateAPBDes() {
kode: '', kode: '',
uraian: '', uraian: '',
anggaran: 0, anggaran: 0,
realisasi: 0,
level: 1, level: 1,
tipe: 'pendapatan', tipe: 'pendapatan',
}); });
@@ -74,40 +80,35 @@ function CreateAPBDes() {
kode: '', kode: '',
uraian: '', uraian: '',
anggaran: 0, anggaran: 0,
realisasi: 0,
level: 1, level: 1,
tipe: 'pendapatan', tipe: 'pendapatan',
}); });
}; };
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;
@@ -116,9 +117,9 @@ function CreateAPBDes() {
toast.success("Berhasil menambahkan APBDes"); toast.success("Berhasil menambahkan APBDes");
resetForm(); resetForm();
router.push("/admin/landing-page/apbdes"); router.push("/admin/landing-page/apbdes");
} catch (error: any) { } catch (error) {
console.error("Gagal submit:", error); console.error("Gagal submit:", error);
toast.error(error?.message || "Gagal menyimpan data"); toast.error("Gagal menyimpan data");
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -126,17 +127,22 @@ function CreateAPBDes() {
// Tambahkan item ke state // Tambahkan item ke state
const handleAddItem = () => { const handleAddItem = () => {
const { kode, uraian, anggaran, level, tipe } = newItem; const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
if (!kode || !uraian) { if (!kode || !uraian) {
return toast.warn("Kode dan uraian wajib diisi"); return toast.warn("Kode dan uraian wajib diisi");
} }
const finalTipe = level === 1 ? null : tipe; const finalTipe = level === 1 ? null : tipe;
const selisih = realisasi - anggaran;
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
stateAPBDes.create.addItem({ stateAPBDes.create.addItem({
kode, kode,
uraian, uraian,
anggaran, anggaran,
realisasi,
selisih,
persentase,
level, level,
tipe: finalTipe, tipe: finalTipe,
}); });
@@ -146,6 +152,7 @@ function CreateAPBDes() {
kode: '', kode: '',
uraian: '', uraian: '',
anggaran: 0, anggaran: 0,
realisasi: 0,
level: 1, level: 1,
tipe: 'pendapatan', tipe: 'pendapatan',
}); });
@@ -176,16 +183,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 +258,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) => {
@@ -331,27 +334,6 @@ function CreateAPBDes() {
</Stack> </Stack>
{/* Form Header */} {/* Form Header */}
<TextInput
label="Nama APBDes"
placeholder="Contoh: APBDes Tahun 2025"
value={stateAPBDes.create.form.name}
onChange={(e) => (stateAPBDes.create.form.name = e.target.value)}
description="Opsional - akan diisi otomatis jika kosong"
/>
<TextInput
label="Deskripsi"
placeholder="Deskripsi APBDes (opsional)"
value={stateAPBDes.create.form.deskripsi}
onChange={(e) => (stateAPBDes.create.form.deskripsi = e.target.value)}
description="Opsional"
/>
<TextInput
label="Jumlah Total"
placeholder="Contoh: Rp 1.000.000.000"
value={stateAPBDes.create.form.jumlah}
onChange={(e) => (stateAPBDes.create.form.jumlah = e.target.value)}
description="Opsional - total keseluruhan anggaran"
/>
<NumberInput <NumberInput
label="Tahun" label="Tahun"
value={stateAPBDes.create.form.tahun || new Date().getFullYear()} value={stateAPBDes.create.form.tahun || new Date().getFullYear()}
@@ -424,6 +406,13 @@ function CreateAPBDes() {
thousandSeparator thousandSeparator
min={0} min={0}
/> />
<NumberInput
label="Realisasi (Rp)"
value={newItem.realisasi}
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
thousandSeparator
min={0}
/>
</Group> </Group>
<Button <Button
leftSection={<IconPlus size={16} />} leftSection={<IconPlus size={16} />}
@@ -445,30 +434,28 @@ function CreateAPBDes() {
<th>Kode</th> <th>Kode</th>
<th>Uraian</th> <th>Uraian</th>
<th>Anggaran</th> <th>Anggaran</th>
<th>Realisasi</th>
<th>Level</th> <th>Level</th>
<th>Tipe</th> <th>Tipe</th>
<th style={{ width: 50 }}>Aksi</th> <th style={{ width: 50 }}>Aksi</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{stateAPBDes.create.form.items.map((item: any, idx) => ( {stateAPBDes.create.form.items.map((item, idx) => (
<tr key={idx}> <tr key={idx}>
<td><Text size="sm" fw={500}>{item.kode}</Text></td> <td><Text size="sm" fw={500}>{item.kode}</Text></td>
<td>{item.uraian}</td> <td>{item.uraian}</td>
<td>{item.anggaran.toLocaleString('id-ID')}</td> <td>{item.anggaran.toLocaleString('id-ID')}</td>
<td>{item.realisasi.toLocaleString('id-ID')}</td>
<td> <td>
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}> <Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
L{item.level} L{item.level}
</Badge> </Badge>
</td> </td>
<td> <td>
{item.tipe ? ( <Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}> {item.tipe}
{item.tipe} </Badge>
</Badge>
) : (
<Text size="sm" c="dimmed">-</Text>
)}
</td> </td>
<td> <td>
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}> <ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>

View File

@@ -45,7 +45,7 @@ function APBDes() {
function ListAPBDes({ search }: { search: string }) { function ListAPBDes({ search }: { search: string }) {
const listState = useProxy(apbdes); const listState = useProxy(apbdes);
const router = useRouter(); const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000); const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = listState.findMany; const { data, page, totalPages, loading, load } = listState.findMany;

View File

@@ -1,428 +0,0 @@
'use client'
import CreateEditor from '../../../_com/createEditor';
import stateDashboardMusik from '../../../_state/desa/musik';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
Box,
Button,
Card,
Center,
Group,
Image,
Paper,
Stack,
Text,
TextInput,
Title,
Loader,
ActionIcon,
NumberInput
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconPhoto, IconUpload, IconX, IconMusic } from '@tabler/icons-react';
import { useRouter, useParams } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
export default function EditMusik() {
const musikState = useProxy(stateDashboardMusik);
const router = useRouter();
const params = useParams();
const id = params.id as string;
const [previewCover, setPreviewCover] = useState<string | null>(null);
const [coverFile, setCoverFile] = useState<File | null>(null);
const [previewAudio, setPreviewAudio] = useState<string | null>(null);
const [audioFile, setAudioFile] = useState<File | null>(null);
const [isExtractingDuration, setIsExtractingDuration] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// Fungsi untuk mendapatkan durasi dari file audio
const getAudioDuration = (file: File): Promise<string> => {
return new Promise((resolve) => {
const audio = new Audio();
const url = URL.createObjectURL(file);
audio.addEventListener('loadedmetadata', () => {
const duration = audio.duration;
const minutes = Math.floor(duration / 60);
const seconds = Math.floor(duration % 60);
const formatted = `${minutes}:${seconds.toString().padStart(2, '0')}`;
URL.revokeObjectURL(url);
resolve(formatted);
});
audio.addEventListener('error', () => {
URL.revokeObjectURL(url);
resolve('0:00');
});
audio.src = url;
});
};
useShallowEffect(() => {
if (id) {
musikState.musik.edit.load(id).then(() => setIsLoading(false));
}
}, [id]);
const isFormValid = () => {
return (
musikState.musik.edit.form.judul?.trim() !== '' &&
musikState.musik.edit.form.artis?.trim() !== '' &&
musikState.musik.edit.form.durasi?.trim() !== '' &&
(coverFile !== null || musikState.musik.edit.form.coverImageId !== '') &&
(audioFile !== null || musikState.musik.edit.form.audioFileId !== '')
);
};
const resetForm = () => {
musikState.musik.edit.reset();
setPreviewCover(null);
setCoverFile(null);
setPreviewAudio(null);
setAudioFile(null);
};
const handleSubmit = async () => {
if (!musikState.musik.edit.form.judul?.trim()) {
toast.error('Judul wajib diisi');
return;
}
if (!musikState.musik.edit.form.artis?.trim()) {
toast.error('Artis wajib diisi');
return;
}
if (!musikState.musik.edit.form.durasi?.trim()) {
toast.error('Durasi wajib diisi');
return;
}
try {
setIsSubmitting(true);
// Upload cover image if new file selected
if (coverFile) {
const res = await ApiFetch.api.fileStorage.create.post({
file: coverFile,
name: coverFile.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah cover, silakan coba lagi');
}
musikState.musik.edit.form.coverImageId = uploaded.id;
}
// Upload audio file if new file selected
if (audioFile) {
const res = await ApiFetch.api.fileStorage.create.post({
file: audioFile,
name: audioFile.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah audio, silakan coba lagi');
}
musikState.musik.edit.form.audioFileId = uploaded.id;
}
await musikState.musik.edit.update();
resetForm();
router.push('/admin/musik');
} catch (error) {
console.error('Error updating musik:', error);
toast.error('Terjadi kesalahan saat mengupdate musik');
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<Box px={{ base: 0, md: 'lg' }} py="xl">
<Center>
<Loader />
</Center>
</Box>
);
}
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header dengan tombol kembali */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Musik
</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Judul"
placeholder="Masukkan judul lagu"
value={musikState.musik.edit.form.judul}
onChange={(e) => (musikState.musik.edit.form.judul = e.target.value)}
required
/>
<TextInput
label="Artis"
placeholder="Masukkan nama artis"
value={musikState.musik.edit.form.artis}
onChange={(e) => (musikState.musik.edit.form.artis = e.target.value)}
required
/>
<Box>
<Text fz="sm" fw="bold" mb={6}>
Deskripsi
</Text>
<CreateEditor
value={musikState.musik.edit.form.deskripsi}
onChange={(htmlContent) => {
musikState.musik.edit.form.deskripsi = htmlContent;
}}
/>
</Box>
<Group gap="md">
<TextInput
label="Durasi"
placeholder="Contoh: 3:45"
value={musikState.musik.edit.form.durasi}
onChange={(e) => (musikState.musik.edit.form.durasi = e.target.value)}
required
style={{ flex: 1 }}
/>
<TextInput
label="Genre"
placeholder="Contoh: Pop, Rock, Jazz"
value={musikState.musik.edit.form.genre}
onChange={(e) => (musikState.musik.edit.form.genre = e.target.value)}
style={{ flex: 1 }}
/>
</Group>
<NumberInput
label="Tahun Rilis"
placeholder="Contoh: 2024"
value={musikState.musik.edit.form.tahunRilis}
onChange={(val) => (musikState.musik.edit.form.tahunRilis = val as number | undefined)}
min={1900}
max={new Date().getFullYear() + 1}
/>
{/* Cover Image */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Cover Image
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setCoverFile(selectedFile);
setPreviewCover(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB)
</Text>
</Dropzone>
{(previewCover || musikState.musik.edit.form.coverImageId) && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewCover || '/api/placeholder/200/200'}
alt="Preview Cover"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewCover(null);
setCoverFile(null);
musikState.musik.edit.form.coverImageId = '';
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Audio File */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
File Audio
</Text>
<Dropzone
onDrop={async (files) => {
const selectedFile = files[0];
if (selectedFile) {
setAudioFile(selectedFile);
setPreviewAudio(selectedFile.name);
// Extract durasi otomatis dari audio
setIsExtractingDuration(true);
try {
const duration = await getAudioDuration(selectedFile);
musikState.musik.edit.form.durasi = duration;
toast.success(`Durasi audio terdeteksi: ${duration}`);
} catch (error) {
console.error('Error extracting audio duration:', error);
toast.error('Gagal mendeteksi durasi audio, silakan isi manual');
} finally {
setIsExtractingDuration(false);
}
}
}}
onReject={() => toast.error('File tidak valid, gunakan format audio (MP3, WAV, OGG)')}
maxSize={50 * 1024 ** 2}
accept={{ 'audio/*': ['.mp3', '.wav', '.ogg', '.m4a'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconMusic size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret file audio atau klik untuk memilih file (maks 50MB)
</Text>
</Dropzone>
{(previewAudio || musikState.musik.edit.form.audioFileId) && (
<Box mt="sm">
<Card p="sm" withBorder>
<Group gap="sm">
<IconMusic size={20} color={colors['blue-button']} />
<Text fz="sm" truncate style={{ flex: 1 }}>
{previewAudio || 'File audio tersimpan'}
</Text>
{isExtractingDuration && (
<Text fz="xs" c="blue">
Mendeteksi durasi...
</Text>
)}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
onClick={() => {
setPreviewAudio(null);
setAudioFile(null);
musikState.musik.edit.form.audioFileId = '';
}}
>
<IconX size={14} />
</ActionIcon>
</Group>
</Card>
</Box>
)}
</Box>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Update'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -1,271 +0,0 @@
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Card,
Center,
Group,
Image,
Modal,
Paper,
Skeleton,
Stack,
Text,
Title
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import stateDashboardMusik from '../../_state/desa/musik';
export default function DetailMusik() {
const musikState = useProxy(stateDashboardMusik);
const router = useRouter();
const params = useParams();
const id = params.id as string;
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const { data, loading, load } = musikState.musik.findUnique;
useShallowEffect(() => {
if (id) {
load(id);
}
}, [id]);
if (loading || !data) {
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Stack>
<Skeleton height={50} radius="md" />
<Skeleton height={400} radius="md" />
</Stack>
</Box>
);
}
if (!data) {
return (
<Box px={{ base: 0, md: 'lg' }} py="xl">
<Center>
<Text c="dimmed">Musik tidak ditemukan</Text>
</Center>
</Box>
);
}
const handleDelete = async () => {
try {
setIsDeleting(true);
await musikState.musik.delete.byId(id);
setShowDeleteModal(false);
router.push('/admin/musik');
} catch (error) {
console.error('Error deleting musik:', error);
} finally {
setIsDeleting(false);
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header dengan tombol kembali */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.push('/admin/musik')}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Detail Musik
</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
{/* Cover Image */}
{data.coverImage && (
<Box
style={{
width: '100%',
maxWidth: 400,
margin: '0 auto',
}}
>
<Image
src={data.coverImage.link}
alt={data.judul}
radius="md"
style={{
width: '100%',
aspectRatio: '1/1',
objectFit: 'cover',
display: 'block',
}}
/>
</Box>
)}
{/* Info Section */}
<Stack gap="sm">
<Box>
<Text fz="sm" fw={600} c="dimmed">
Judul
</Text>
<Text fz="md" fw={600}>
{data.judul}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} c="dimmed">
Artis
</Text>
<Text fz="md" fw={500}>
{data.artis}
</Text>
</Box>
{data.deskripsi && (
<Box>
<Text fz="sm" fw={600} c="dimmed">
Deskripsi
</Text>
<Text fz="sm" fw={500} dangerouslySetInnerHTML={{ __html: data.deskripsi }} />
</Box>
)}
<Group gap="xl">
<Box>
<Text fz="sm" fw={600} c="dimmed">
Durasi
</Text>
<Text fz="md" fw={500}>
{data.durasi}
</Text>
</Box>
{data.genre && (
<Box>
<Text fz="sm" fw={600} c="dimmed">
Genre
</Text>
<Text fz="md" fw={500}>
{data.genre}
</Text>
</Box>
)}
{data.tahunRilis && (
<Box>
<Text fz="sm" fw={600} c="dimmed">
Tahun Rilis
</Text>
<Text fz="md" fw={500}>
{data.tahunRilis}
</Text>
</Box>
)}
</Group>
{/* Audio File */}
{data.audioFile && (
<Box>
<Text fz="sm" fw={600} c="dimmed">
File Audio
</Text>
<Card mt="xs" p="sm" withBorder>
<Group gap="sm">
<Text fz="sm" truncate style={{ flex: 1 }}>
{data.audioFile.realName}
</Text>
<Button
component="a"
href={data.audioFile.link}
target="_blank"
variant="light"
size="sm"
>
Putar
</Button>
</Group>
</Card>
</Box>
)}
</Stack>
{/* Action Buttons */}
<Group justify="right" mt="md">
<Button
variant="outline"
color="red"
radius="md"
size="md"
leftSection={<IconTrash size={18} />}
onClick={() => setShowDeleteModal(true)}
>
Hapus
</Button>
<Button
variant="filled"
color="blue"
radius="md"
size="md"
leftSection={<IconEdit size={18} />}
onClick={() => router.push(`/admin/musik/${id}/edit`)}
>
Edit
</Button>
</Group>
</Stack>
</Paper>
{/* Delete Confirmation Modal */}
<Modal
opened={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
title="Konfirmasi Hapus"
centered
>
<Stack gap="md">
<Text>
Apakah Anda yakin ingin menghapus musik &quot;{data.judul}&quot;?
</Text>
<Text c="red" fz="sm">
Tindakan ini tidak dapat dibatalkan.
</Text>
<Group justify="right" mt="md">
<Button
variant="outline"
color="gray"
onClick={() => setShowDeleteModal(false)}
>
Batal
</Button>
<Button
color="red"
onClick={handleDelete}
loading={isDeleting}
>
Hapus
</Button>
</Group>
</Stack>
</Modal>
</Box>
);
}

View File

@@ -1,426 +0,0 @@
'use client'
import CreateEditor from '../../_com/createEditor';
import stateDashboardMusik from '../../_state/desa/musik';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
Box,
Button,
Card,
Group,
Image,
Paper,
Stack,
Text,
TextInput,
Title,
Loader,
ActionIcon,
NumberInput
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconPhoto, IconUpload, IconX, IconMusic } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
export default function CreateMusik() {
const musikState = useProxy(stateDashboardMusik);
const [previewCover, setPreviewCover] = useState<string | null>(null);
const [coverFile, setCoverFile] = useState<File | null>(null);
const [previewAudio, setPreviewAudio] = useState<string | null>(null);
const [audioFile, setAudioFile] = useState<File | null>(null);
const [isExtractingDuration, setIsExtractingDuration] = useState(false);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
// Fungsi untuk mendapatkan durasi dari file audio
const getAudioDuration = (file: File): Promise<string> => {
return new Promise((resolve) => {
const audio = new Audio();
const url = URL.createObjectURL(file);
audio.addEventListener('loadedmetadata', () => {
const duration = audio.duration;
const minutes = Math.floor(duration / 60);
const seconds = Math.floor(duration % 60);
const formatted = `${minutes}:${seconds.toString().padStart(2, '0')}`;
URL.revokeObjectURL(url);
resolve(formatted);
});
audio.addEventListener('error', () => {
URL.revokeObjectURL(url);
resolve('0:00');
});
audio.src = url;
});
};
const isFormValid = () => {
return (
musikState.musik.create.form.judul?.trim() !== '' &&
musikState.musik.create.form.artis?.trim() !== '' &&
musikState.musik.create.form.durasi?.trim() !== '' &&
audioFile !== null &&
coverFile !== null
);
};
useShallowEffect(() => {
return () => {
musikState.musik.create.resetForm();
};
}, []);
const resetForm = () => {
musikState.musik.create.form = {
judul: '',
artis: '',
deskripsi: '',
durasi: '',
audioFileId: '',
coverImageId: '',
genre: '',
tahunRilis: undefined,
};
setPreviewCover(null);
setCoverFile(null);
setPreviewAudio(null);
setAudioFile(null);
};
const handleSubmit = async () => {
if (!musikState.musik.create.form.judul?.trim()) {
toast.error('Judul wajib diisi');
return;
}
if (!musikState.musik.create.form.artis?.trim()) {
toast.error('Artis wajib diisi');
return;
}
if (!musikState.musik.create.form.durasi?.trim()) {
toast.error('Durasi wajib diisi');
return;
}
if (!coverFile) {
toast.error('Cover image wajib dipilih');
return;
}
if (!audioFile) {
toast.error('File audio wajib dipilih');
return;
}
try {
setIsSubmitting(true);
// Upload cover image
const coverRes = await ApiFetch.api.fileStorage.create.post({
file: coverFile,
name: coverFile.name,
});
const coverUploaded = coverRes.data?.data;
if (!coverUploaded?.id) {
return toast.error('Gagal mengunggah cover, silakan coba lagi');
}
musikState.musik.create.form.coverImageId = coverUploaded.id;
// Upload audio file
const audioRes = await ApiFetch.api.fileStorage.create.post({
file: audioFile,
name: audioFile.name,
});
const audioUploaded = audioRes.data?.data;
if (!audioUploaded?.id) {
return toast.error('Gagal mengunggah audio, silakan coba lagi');
}
musikState.musik.create.form.audioFileId = audioUploaded.id;
await musikState.musik.create.create();
resetForm();
router.push('/admin/musik');
} catch (error) {
console.error('Error creating musik:', error);
toast.error('Terjadi kesalahan saat membuat musik');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header dengan tombol kembali */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Musik
</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Judul"
placeholder="Masukkan judul lagu"
value={musikState.musik.create.form.judul}
onChange={(e) => (musikState.musik.create.form.judul = e.target.value)}
required
/>
<TextInput
label="Artis"
placeholder="Masukkan nama artis"
value={musikState.musik.create.form.artis}
onChange={(e) => (musikState.musik.create.form.artis = e.target.value)}
required
/>
<Box>
<Text fz="sm" fw="bold" mb={6}>
Deskripsi
</Text>
<CreateEditor
value={musikState.musik.create.form.deskripsi}
onChange={(htmlContent) => {
musikState.musik.create.form.deskripsi = htmlContent;
}}
/>
</Box>
<Group gap="md">
<TextInput
label="Durasi"
placeholder="Contoh: 3:45"
value={musikState.musik.create.form.durasi}
onChange={(e) => (musikState.musik.create.form.durasi = e.target.value)}
required
style={{ flex: 1 }}
/>
<TextInput
label="Genre"
placeholder="Contoh: Pop, Rock, Jazz"
value={musikState.musik.create.form.genre}
onChange={(e) => (musikState.musik.create.form.genre = e.target.value)}
style={{ flex: 1 }}
/>
</Group>
<NumberInput
label="Tahun Rilis"
placeholder="Contoh: 2024"
value={musikState.musik.create.form.tahunRilis}
onChange={(val) => (musikState.musik.create.form.tahunRilis = val as number | undefined)}
min={1900}
max={new Date().getFullYear() + 1}
/>
{/* Cover Image */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Cover Image
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setCoverFile(selectedFile);
setPreviewCover(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB)
</Text>
</Dropzone>
{previewCover && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewCover}
alt="Preview Cover"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewCover(null);
setCoverFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Audio File */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
File Audio
</Text>
<Dropzone
onDrop={async (files) => {
const selectedFile = files[0];
if (selectedFile) {
setAudioFile(selectedFile);
setPreviewAudio(selectedFile.name);
// Extract durasi otomatis dari audio
setIsExtractingDuration(true);
try {
const duration = await getAudioDuration(selectedFile);
musikState.musik.create.form.durasi = duration;
toast.success(`Durasi audio terdeteksi: ${duration}`);
} catch (error) {
console.error('Error extracting audio duration:', error);
toast.error('Gagal mendeteksi durasi audio, silakan isi manual');
} finally {
setIsExtractingDuration(false);
}
}
}}
onReject={() => toast.error('File tidak valid, gunakan format audio (MP3, WAV, OGG)')}
maxSize={50 * 1024 ** 2}
accept={{ 'audio/*': ['.mp3', '.wav', '.ogg', '.m4a'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconMusic size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret file audio atau klik untuk memilih file (maks 50MB)
</Text>
</Dropzone>
{previewAudio && (
<Box mt="sm">
<Card p="sm" withBorder>
<Group gap="sm">
<IconMusic size={20} color={colors['blue-button']} />
<Text fz="sm" truncate style={{ flex: 1 }}>
{previewAudio}
</Text>
{isExtractingDuration && (
<Text fz="xs" c="blue">
Mendeteksi durasi...
</Text>
)}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
onClick={() => {
setPreviewAudio(null);
setAudioFile(null);
}}
>
<IconX size={14} />
</ActionIcon>
</Group>
</Card>
</Box>
)}
</Box>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -1,231 +0,0 @@
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../_com/header';
import stateDashboardMusik from '../_state/desa/musik';
function Musik() {
const [search, setSearch] = useState("");
return (
<Box>
<HeaderSearch
title="Musik Desa"
placeholder="Cari judul, artis, atau genre..."
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListMusik search={search} />
</Box>
);
}
function ListMusik({ search }: { search: string }) {
const musikState = useProxy(stateDashboardMusik);
const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = musikState.musik.findMany;
useShallowEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
if (loading || !data) {
return (
<Stack py="md">
<Skeleton height={600} radius="md" />
</Stack>
);
}
const filteredData = data || [];
return (
<Box py="md">
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Musik</Title>
<Button
leftSection={<IconCircleDashedPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/musik/create')}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table highlightOnHover
layout="fixed"
withColumnBorders={false} miw={0}>
<TableThead>
<TableTr>
<TableTh w="30%">Judul</TableTh>
<TableTh w="20%">Artis</TableTh>
<TableTh w="15%">Durasi</TableTh>
<TableTh w="15%">Genre</TableTh>
<TableTh w="20%">Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fz="md" fw={600} lh={1.45} truncate="end">
{item.judul}
</Text>
</TableTd>
<TableTd>
<Text fz="sm" c="dimmed" lh={1.45}>
{item.artis}
</Text>
</TableTd>
<TableTd>
<Text fz="sm" c="dimmed" lh={1.45}>
{item.durasi}
</Text>
</TableTd>
<TableTd>
<Text fz="sm" c="dimmed" lh={1.45}>
{item.genre || '-'}
</Text>
</TableTd>
<TableTd>
<Button
variant="light"
color="blue"
onClick={() =>
router.push(`/admin/musik/${item.id}`)
}
fz="sm"
px="sm"
h={36}
>
<IconDeviceImacCog size={18} />
<Text ml="xs">Detail</Text>
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={5}>
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data musik yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Cards */}
<Stack hiddenFrom="md" gap="sm" mt="sm">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="md" radius="md">
<Stack gap={"xs"}>
<Text fz="sm" fw={600} lh={1.4} c="dimmed">
Judul
</Text>
<Text fz="sm" fw={500} lh={1.45}>
{item.judul}
</Text>
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">
Artis
</Text>
<Text fz="sm" lh={1.45} fw={500}>
{item.artis}
</Text>
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">
Durasi
</Text>
<Text fz="sm" lh={1.45} fw={500}>
{item.durasi}
</Text>
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">
Genre
</Text>
<Text fz="sm" lh={1.45} fw={500}>
{item.genre || '-'}
</Text>
<Button
variant="light"
color="blue"
fullWidth
mt="sm"
onClick={() =>
router.push(`/admin/musik/${item.id}`)
}
fz="sm"
h={36}
>
<IconDeviceImacCog size={18} />
<Text ml="xs">Detail</Text>
</Button>
</Stack>
</Paper>
))
) : (
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data musik yang cocok
</Text>
</Center>
)}
</Stack>
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, debouncedSearch);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
</Box>
);
}
export default Musik;

View File

@@ -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 [];
}
}

View File

@@ -373,11 +373,6 @@ export const devBar = [
} }
] ]
}, },
{
id: "Musik",
name: "Musik",
path: "/admin/musik"
},
{ {
id: "User & Role", id: "User & Role",
name: "User & Role", name: "User & Role",
@@ -777,11 +772,6 @@ export const navBar = [
} }
] ]
}, },
{
id: "Musik",
name: "Musik",
path: "/admin/musik"
},
{ {
id: "User & Role", id: "User & Role",
name: "User & Role", name: "User & Role",
@@ -1098,11 +1088,6 @@ export const role1 = [
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana" path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
} }
] ]
},
{
id: "Musik",
name: "Musik",
path: "/admin/musik"
} }
] ]
@@ -1148,11 +1133,6 @@ export const role2 = [
path: "/admin/kesehatan/info-wabah-penyakit" path: "/admin/kesehatan/info-wabah-penyakit"
} }
] ]
},
{
id: "Musik",
name: "Musik",
path: "/admin/musik"
} }
] ]
@@ -1198,10 +1178,5 @@ export const role3 = [
path: "/admin/pendidikan/data-pendidikan" path: "/admin/pendidikan/data-pendidikan"
} }
] ]
},
{
id: "Musik",
name: "Musik",
path: "/admin/musik"
} }
] ]

View File

@@ -1,9 +1,9 @@
'use client' 'use client'
import { DarkModeToggle } from "@/components/admin/DarkModeToggle";
import { useDarkMode } from "@/state/darkModeStore";
import { authStore } from "@/store/authStore"; import { authStore } from "@/store/authStore";
import { themeTokens } from "@/utils/themeTokens"; import { useDarkMode } from "@/state/darkModeStore";
import { themeTokens, getActiveStateStyles } from "@/utils/themeTokens";
import { DarkModeToggle } from "@/components/admin/DarkModeToggle";
import { import {
ActionIcon, ActionIcon,
AppShell, AppShell,
@@ -316,13 +316,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
}} }}
variant="light" variant="light"
active={isParentActive} active={isParentActive}
onClick={(e) => {
e.preventDefault();
if (v.path) handleNavClick(v.path);
}}
href={v.path || undefined}
> >
{v.children?.map((child, key) => { {v.children.map((child, key) => {
const isChildActive = segments.includes(_.lowerCase(child.name)); const isChildActive = segments.includes(_.lowerCase(child.name));
return ( return (
<NavLink <NavLink
@@ -359,8 +354,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
borderLeft: `2px solid ${tokens.colors.primary}`, borderLeft: `2px solid ${tokens.colors.primary}`,
}), }),
...(mounted && isChildActive && !isDark && { ...(mounted && isChildActive && !isDark && {
backgroundColor: 'rgba(25, 113, 194, 0.1)', backgroundColor: tokens.colors.bg.hover,
borderLeft: `2px solid ${tokens.colors.primary}`,
}), }),
} }
}} }}

View File

@@ -1,33 +1,26 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Prisma } from "@prisma/client";
import { Context } from "elysia"; import { Context } from "elysia";
type FormCreate = { type FormCreate = Prisma.BeritaGetPayload<{
judul: string; select: {
deskripsi: string; judul: true;
content: string; deskripsi: true;
kategoriBeritaId: string; content: true;
imageId: string; // Featured image kategoriBeritaId: true;
imageIds?: string[]; // Multiple images for gallery imageId: true;
linkVideo?: string; // YouTube link };
}; }>;
async function beritaCreate(context: Context) { async function beritaCreate(context: Context) {
const body = context.body as FormCreate; const body = context.body as FormCreate;
await prisma.berita.create({ await prisma.berita.create({
data: { data: {
content: body.content, content: body.content,
deskripsi: body.deskripsi, deskripsi: body.deskripsi,
imageId: body.imageId, imageId: body.imageId,
judul: body.judul, judul: body.judul,
kategoriBeritaId: body.kategoriBeritaId, kategoriBeritaId: body.kategoriBeritaId,
// Connect multiple images if provided
linkVideo: body.linkVideo,
images: body.imageIds && body.imageIds.length > 0
? {
connect: body.imageIds.map((id) => ({ id })),
}
: undefined,
}, },
}); });

View File

@@ -28,7 +28,6 @@ export default async function handler(
where: { id }, where: { id },
include: { include: {
image: true, image: true,
images: true,
kategoriBerita: true, kategoriBerita: true,
}, },
}); });

View File

@@ -21,8 +21,6 @@ const Berita = new Elysia({ prefix: "/berita", tags: ["Desa/Berita"] })
imageId: t.String(), imageId: t.String(),
content: t.String(), content: t.String(),
kategoriBeritaId: t.Union([t.String(), t.Null()]), kategoriBeritaId: t.Union([t.String(), t.Null()]),
imageIds: t.Array(t.String()),
linkVideo: t.Optional(t.String()),
}), }),
}) })
.get("/find-first", beritaFindFirst) .get("/find-first", beritaFindFirst)
@@ -41,8 +39,6 @@ const Berita = new Elysia({ prefix: "/berita", tags: ["Desa/Berita"] })
imageId: t.String(), imageId: t.String(),
content: t.String(), content: t.String(),
kategoriBeritaId: t.Union([t.String(), t.Null()]), kategoriBeritaId: t.Union([t.String(), t.Null()]),
imageIds: t.Array(t.String()),
linkVideo: t.Optional(t.String()),
}), }),
} }
); );

View File

@@ -2,49 +2,15 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
export default async function kategoriBeritaDelete(context: Context) { export default async function kategoriBeritaDelete(context: Context) {
try { const id = context.params.id as string;
const id = context.params?.id as string;
if (!id) { await prisma.kategoriBerita.delete({
return Response.json({ where: { id },
success: false, });
message: "ID tidak boleh kosong",
}, { status: 400 });
}
// ✅ Cek apakah kategori masih digunakan oleh berita return {
const beritaCount = await prisma.berita.count({ status: 200,
where: { success: true,
kategoriBeritaId: id, message: "Sukses Menghapus kategori berita",
isActive: true, };
},
});
if (beritaCount > 0) {
return Response.json({
success: false,
message: `Kategori tidak dapat dihapus karena masih digunakan oleh ${beritaCount} berita`,
}, { status: 400 });
}
// ✅ Soft delete (bukan hard delete)
await prisma.kategoriBerita.update({
where: { id },
data: {
deletedAt: new Date(),
isActive: false,
},
});
return {
success: true,
message: "Kategori berita berhasil dihapus",
};
} catch (error) {
console.error("Delete kategori error:", error);
return Response.json({
success: false,
message: "Gagal menghapus kategori: " + (error instanceof Error ? error.message : 'Unknown error'),
}, { status: 500 });
}
} }

View File

@@ -4,28 +4,34 @@ import { Prisma } from "@prisma/client";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
type FormUpdate = { type FormUpdate = Prisma.BeritaGetPayload<{
id: string; select: {
judul: string; id: true;
deskripsi: string; judul: true;
content: string; deskripsi: true;
kategoriBeritaId: string; content: true;
imageId: string; // Featured image kategoriBeritaId: true;
imageIds?: string[]; // Multiple images for gallery imageId: true;
linkVideo?: string; // YouTube link };
}; }>;
async function beritaUpdate(context: Context) { async function beritaUpdate(context: Context) {
try { try {
const id = context.params?.id as string; const id = context.params?.id as string; // ambil dari URL
const body = (await context.body) as Omit<FormUpdate, "id">; const body = (await context.body) as Omit<FormUpdate, "id">;
const { judul, deskripsi, content, kategoriBeritaId, imageId, imageIds, linkVideo } = body; const {
judul,
deskripsi,
content,
kategoriBeritaId,
imageId,
} = body;
if (!id) { if (!id) {
return new Response( return new Response(
JSON.stringify({ success: false, message: "ID tidak boleh kosong" }), JSON.stringify({ success: false, message: "ID tidak boleh kosong" }),
{ status: 400, headers: { "Content-Type": "application/json" } }, { status: 400, headers: { 'Content-Type': 'application/json' } }
); );
} }
@@ -33,7 +39,6 @@ async function beritaUpdate(context: Context) {
where: { id }, where: { id },
include: { include: {
image: true, image: true,
images: true, // Include gallery images
kategoriBerita: true, kategoriBerita: true,
}, },
}); });
@@ -41,11 +46,10 @@ async function beritaUpdate(context: Context) {
if (!existing) { if (!existing) {
return new Response( return new Response(
JSON.stringify({ success: false, message: "Berita tidak ditemukan" }), JSON.stringify({ success: false, message: "Berita tidak ditemukan" }),
{ status: 404, headers: { "Content-Type": "application/json" } }, { status: 404, headers: { 'Content-Type': 'application/json' } }
); );
} }
// Delete old featured image if changed
if (existing.imageId && existing.imageId !== imageId) { if (existing.imageId && existing.imageId !== imageId) {
const oldImage = existing.image; const oldImage = existing.image;
if (oldImage) { if (oldImage) {
@@ -61,39 +65,14 @@ async function beritaUpdate(context: Context) {
} }
} }
// Build update data
const updateData: Prisma.BeritaUpdateInput = {
judul,
deskripsi,
content,
kategoriBerita: kategoriBeritaId ? { connect: { id: kategoriBeritaId } } : { disconnect: true },
image: imageId ? { connect: { id: imageId } } : { disconnect: true },
linkVideo,
};
// Handle multiple images update
if (imageIds !== undefined) {
// Disconnect all existing images first
updateData.images = {
set: [],
};
// Connect new images if provided
if (imageIds.length > 0) {
updateData.images = {
...updateData.images,
connect: imageIds.map((id) => ({ id })),
};
}
}
const updated = await prisma.berita.update({ const updated = await prisma.berita.update({
where: { id }, where: { id },
data: updateData, data: {
include: { judul,
image: true, deskripsi,
images: true, content,
kategoriBerita: true, kategoriBeritaId: kategoriBeritaId || null,
imageId,
}, },
}); });
@@ -103,7 +82,7 @@ async function beritaUpdate(context: Context) {
message: "Berita berhasil diupdate", message: "Berita berhasil diupdate",
data: updated, data: updated,
}), }),
{ status: 200, headers: { "Content-Type": "application/json" } }, { status: 200, headers: { 'Content-Type': 'application/json' } }
); );
} catch (error) { } catch (error) {
console.error("Error updating berita:", error); console.error("Error updating berita:", error);
@@ -112,8 +91,8 @@ async function beritaUpdate(context: Context) {
success: false, success: false,
message: "Terjadi kesalahan saat mengupdate berita", message: "Terjadi kesalahan saat mengupdate berita",
}), }),
{ status: 500, headers: { "Content-Type": "application/json" } }, { status: 500, headers: { 'Content-Type': 'application/json' } }
); );
} }
} }
export default beritaUpdate; export default beritaUpdate;

View File

@@ -12,7 +12,6 @@ import KategoriBerita from "./berita/kategori-berita";
import KategoriPengumuman from "./pengumuman/kategori-pengumuman"; import KategoriPengumuman from "./pengumuman/kategori-pengumuman";
import MantanPerbekel from "./profile/profile-mantan-perbekel"; import MantanPerbekel from "./profile/profile-mantan-perbekel";
import AjukanPermohonan from "./layanan/ajukan_permohonan"; import AjukanPermohonan from "./layanan/ajukan_permohonan";
import Musik from "./musik";
const Desa = new Elysia({ prefix: "/api/desa", tags: ["Desa"] }) const Desa = new Elysia({ prefix: "/api/desa", tags: ["Desa"] })
@@ -29,7 +28,6 @@ const Desa = new Elysia({ prefix: "/api/desa", tags: ["Desa"] })
.use(KategoriBerita) .use(KategoriBerita)
.use(KategoriPengumuman) .use(KategoriPengumuman)
.use(AjukanPermohonan) .use(AjukanPermohonan)
.use(Musik)
export default Desa; export default Desa;

View File

@@ -1,37 +0,0 @@
import { Context } from "elysia";
import prisma from "@/lib/prisma";
type FormCreate = {
judul: string;
artis: string;
deskripsi?: string;
durasi: string;
audioFileId: string;
coverImageId: string;
genre?: string;
tahunRilis?: number | null;
};
async function musikCreate(context: Context) {
const body = context.body as FormCreate;
await prisma.musikDesa.create({
data: {
judul: body.judul,
artis: body.artis,
deskripsi: body.deskripsi,
durasi: body.durasi,
audioFileId: body.audioFileId,
coverImageId: body.coverImageId,
genre: body.genre,
tahunRilis: body.tahunRilis,
},
});
return {
success: true,
message: "Sukses menambahkan musik",
};
}
export default musikCreate;

View File

@@ -1,54 +0,0 @@
import { Context } from "elysia";
import prisma from "@/lib/prisma";
import path from "path";
const musikDelete = async (context: Context) => {
const { id } = context.params as { id: string };
const musik = await prisma.musikDesa.findUnique({
where: { id },
include: { audioFile: true, coverImage: true },
});
if (!musik) return { status: 404, body: "Musik tidak ditemukan" };
// 1. HAPUS MUSIK DULU
await prisma.musikDesa.delete({ where: { id } });
// 2. HAPUS FILE AUDIO (jika ada)
if (musik.audioFile) {
try {
const fs = await import("fs/promises");
const filePath = path.join(musik.audioFile.path, musik.audioFile.name);
await fs.unlink(filePath);
await prisma.fileStorage.delete({
where: { id: musik.audioFile.id },
});
} catch (error) {
console.error("Error deleting audio file:", error);
}
}
// 3. HAPUS FILE COVER (jika ada)
if (musik.coverImage) {
try {
const fs = await import("fs/promises");
const filePath = path.join(musik.coverImage.path, musik.coverImage.name);
await fs.unlink(filePath);
await prisma.fileStorage.delete({
where: { id: musik.coverImage.id },
});
} catch (error) {
console.error("Error deleting cover image:", error);
}
}
return {
success: true,
message: "Musik dan file terkait berhasil dihapus",
};
};
export default musikDelete;

View File

@@ -1,66 +0,0 @@
import prisma from "@/lib/prisma";
export default async function findMusikById(request: Request) {
try {
const url = new URL(request.url);
const id = url.pathname.split("/").pop();
if (!id) {
return new Response(
JSON.stringify({
success: false,
message: "ID tidak valid",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const data = await prisma.musikDesa.findUnique({
where: { id },
include: {
audioFile: true,
coverImage: true,
},
});
if (!data) {
return new Response(
JSON.stringify({
success: false,
message: "Musik tidak ditemukan",
}),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
);
}
return new Response(
JSON.stringify({
success: true,
message: "Success fetch musik by ID",
data,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
} catch (e) {
console.error("Error fetching musik by ID:", e);
return new Response(
JSON.stringify({
success: false,
message: "Gagal mengambil musik: " + (e instanceof Error ? e.message : 'Unknown error'),
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
}
}

View File

@@ -1,69 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// /api/desa/musik/find-many.ts
import prisma from "@/lib/prisma";
import { Context } from "elysia";
async function musikFindMany(context: Context) {
// Ambil parameter dari query
const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 10;
const search = (context.query.search as string) || '';
const genre = (context.query.genre as string) || '';
const skip = (page - 1) * limit;
// Buat where clause
const where: any = { isActive: true };
// Filter berdasarkan genre (jika ada)
if (genre) {
where.genre = {
equals: genre,
mode: 'insensitive'
};
}
// Tambahkan pencarian (jika ada)
if (search) {
where.OR = [
{ judul: { contains: search, mode: 'insensitive' } },
{ artis: { contains: search, mode: 'insensitive' } },
{ deskripsi: { contains: search, mode: 'insensitive' } },
{ genre: { contains: search, mode: 'insensitive' } }
];
}
try {
// Ambil data dan total count secara paralel
const [data, total] = await Promise.all([
prisma.musikDesa.findMany({
where,
include: {
audioFile: true,
coverImage: true,
},
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.musikDesa.count({ where }),
]);
return {
success: true,
message: "Berhasil ambil data musik dengan pagination",
data,
page,
limit,
total,
totalPages: Math.ceil(total / limit),
};
} catch (e) {
console.error("Error di findMany paginated:", e);
return {
success: false,
message: "Gagal mengambil data musik",
};
}
}
export default musikFindMany;

View File

@@ -1,47 +0,0 @@
import Elysia, { t } from "elysia";
import musikFindMany from "./find-many";
import musikCreate from "./create";
import musikDelete from "./del";
import musikUpdate from "./updt";
import findMusikById from "./find-by-id";
const Musik = new Elysia({ prefix: "/musik", tags: ["Desa/Musik"] })
.get("/find-many", musikFindMany)
.get("/:id", async (context) => {
const response = await findMusikById(new Request(context.request));
return response;
})
.post("/create", musikCreate, {
body: t.Object({
judul: t.String(),
artis: t.String(),
deskripsi: t.Optional(t.String()),
durasi: t.String(),
audioFileId: t.String(),
coverImageId: t.String(),
genre: t.Optional(t.String()),
tahunRilis: t.Optional(t.Number()),
}),
})
.delete("/delete/:id", musikDelete)
.put(
"/:id",
async (context) => {
const response = await musikUpdate(context);
return response;
},
{
body: t.Object({
judul: t.String(),
artis: t.String(),
deskripsi: t.Optional(t.String()),
durasi: t.String(),
audioFileId: t.String(),
coverImageId: t.String(),
genre: t.Optional(t.String()),
tahunRilis: t.Optional(t.Number()),
}),
}
);
export default Musik;

View File

@@ -1,65 +0,0 @@
import { Context } from "elysia";
import prisma from "@/lib/prisma";
type FormUpdate = {
judul: string;
artis: string;
deskripsi?: string;
durasi: string;
audioFileId: string;
coverImageId: string;
genre?: string;
tahunRilis?: number | null;
};
async function musikUpdate(context: Context) {
const { id } = context.params as { id: string };
const body = context.body as FormUpdate;
try {
const existing = await prisma.musikDesa.findUnique({
where: { id },
});
if (!existing) {
return {
status: 404,
body: {
success: false,
message: "Musik tidak ditemukan",
},
};
}
const updated = await prisma.musikDesa.update({
where: { id },
data: {
judul: body.judul,
artis: body.artis,
deskripsi: body.deskripsi,
durasi: body.durasi,
audioFileId: body.audioFileId,
coverImageId: body.coverImageId,
genre: body.genre,
tahunRilis: body.tahunRilis,
},
});
return {
success: true,
message: "Musik berhasil diupdate",
data: updated,
};
} catch (error) {
console.error("Error updating musik:", error);
return {
status: 500,
body: {
success: false,
message: "Terjadi kesalahan saat mengupdate musik",
},
};
}
}
export default musikUpdate;

View File

@@ -21,12 +21,8 @@ export default async function findUnique(
}, { status: 400 }); }, { status: 400 });
} }
// ✅ Filter by isActive and deletedAt const data = await prisma.potensiDesa.findUnique({
const data = await prisma.potensiDesa.findFirst({ where: { id },
where: {
id,
isActive: true,
},
include: { include: {
image: true, image: true,
kategori: true kategori: true

View File

@@ -2,49 +2,15 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
export default async function kategoriPotensiDelete(context: Context) { export default async function kategoriPotensiDelete(context: Context) {
try { const id = context.params.id as string;
const id = context.params?.id as string;
if (!id) { await prisma.kategoriPotensi.delete({
return Response.json({ where: { id },
success: false, });
message: "ID tidak boleh kosong",
}, { status: 400 });
}
// ✅ Cek apakah kategori masih digunakan oleh potensi desa return {
const existingPotensi = await prisma.potensiDesa.findFirst({ status: 200,
where: { success: true,
kategoriId: id, message: "Sukses Menghapus kategori potensi",
isActive: true, };
},
});
if (existingPotensi) {
return Response.json({
success: false,
message: "Kategori masih digunakan oleh potensi desa. Tidak dapat dihapus.",
}, { status: 400 });
}
// Soft delete
await prisma.kategoriPotensi.update({
where: { id },
data: {
deletedAt: new Date(),
isActive: false,
},
});
return {
success: true,
message: "Kategori potensi berhasil dihapus",
};
} catch (error) {
console.error("Delete kategori error:", error);
return Response.json({
success: false,
message: "Gagal menghapus kategori: " + (error instanceof Error ? error.message : 'Unknown error'),
}, { status: 500 });
}
} }

View File

@@ -1,38 +0,0 @@
import prisma from "@/lib/prisma";
import { requireAuth } from "@/lib/api-auth";
export default async function sejarahDesaFindFirst() {
// ✅ Authentication check
const authResult = await requireAuth();
if (!authResult.authenticated) {
return authResult.response;
}
try {
// Get the first active record
const data = await prisma.sejarahDesa.findFirst({
where: {
isActive: true,
},
orderBy: { createdAt: 'asc' } // Get the oldest one first
});
if (!data) {
return Response.json({
success: false,
message: "Data tidak ditemukan",
}, {status: 404})
}
return Response.json({
success: true,
data,
}, {status: 200})
} catch (error) {
console.error("Gagal mengambil data sejarah desa:", error)
return Response.json({
success: false,
message: "Terjadi kesalahan saat mengambil data",
}, {status: 500})
}
}

View File

@@ -1,16 +1,11 @@
import Elysia, { t } from "elysia"; import Elysia, { t } from "elysia";
import sejarahDesaFindById from "./find-by-id"; import sejarahDesaFindById from "./find-by-id";
import sejarahDesaUpdate from "./update"; import sejarahDesaUpdate from "./update";
import sejarahDesaFindFirst from "./find-first";
const SejarahDesa = new Elysia({ const SejarahDesa = new Elysia({
prefix: "/sejarah", prefix: "/sejarah",
tags: ["Desa/Profile"], tags: ["Desa/Profile"],
}) })
.get("/first", async () => {
const response = await sejarahDesaFindFirst();
return response;
})
.get("/:id", async (context) => { .get("/:id", async (context) => {
const response = await sejarahDesaFindById(new Request(context.request)); const response = await sejarahDesaFindById(new Request(context.request));
return response; return response;

View File

@@ -1,14 +1,7 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { requireAuth } from "@/lib/api-auth";
import { Context } from "elysia"; import { Context } from "elysia";
export default async function sejarahDesaUpdate(context: Context) { export default async function sejarahDesaUpdate(context: Context) {
// ✅ Authentication check
const authResult = await requireAuth();
if (!authResult.authenticated) {
return authResult.response;
}
try { try {
const id = context.params?.id as string; const id = context.params?.id as string;
const body = await context.body as { const body = await context.body as {

View File

@@ -22,10 +22,9 @@ const fileStorageCreate = async (context: Context) => {
if (!UPLOAD_DIR) return { status: 500, body: "UPLOAD_DIR is not defined" }; if (!UPLOAD_DIR) return { status: 500, body: "UPLOAD_DIR is not defined" };
const isImage = file.type.startsWith("image/"); const isImage = file.type.startsWith("image/");
const isAudio = file.type.startsWith("audio/"); const category = isImage ? "image" : "document";
const category = isImage ? "image" : isAudio ? "audio" : "document";
const pathName = category === "image" ? "images" : category === "audio" ? "audio" : "documents"; const pathName = category === "image" ? "images" : "documents";
const rootPath = path.join(UPLOAD_DIR, pathName); const rootPath = path.join(UPLOAD_DIR, pathName);
await fs.mkdir(rootPath, { recursive: true }); await fs.mkdir(rootPath, { recursive: true });
@@ -55,11 +54,6 @@ const fileStorageCreate = async (context: Context) => {
// Simpan metadata untuk versi desktop sebagai default // Simpan metadata untuk versi desktop sebagai default
finalName = desktopName; finalName = desktopName;
finalMimeType = "image/webp"; finalMimeType = "image/webp";
} else if (isAudio) {
// Simpan file audio tanpa kompresi
const ext = file.name.split(".").pop() || "mp3";
finalName = `${finalName}.${ext}`;
await fs.writeFile(path.join(rootPath, finalName), buffer);
} else { } else {
// Jika file adalah PDF, simpan tanpa kompresi // Jika file adalah PDF, simpan tanpa kompresi
if (file.type === "application/pdf") { if (file.type === "application/pdf") {

View File

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

View File

@@ -47,16 +47,7 @@ export default async function apbdesFindMany(context: Context) {
include: { include: {
image: true, image: true,
file: true, file: true,
items: { items: true,
where: { isActive: true },
orderBy: { kode: "asc" },
include: {
realisasiItems: {
where: { isActive: true },
orderBy: { tanggal: 'asc' },
},
},
},
}, },
}), }),
prisma.aPBDes.count({ where }), prisma.aPBDes.count({ where }),

View File

@@ -2,9 +2,15 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
export default async function apbdesFindUnique(context: Context) { export default async function apbdesFindUnique(context: Context) {
// ✅ Parse URL secara manual
const url = new URL(context.request.url); const url = new URL(context.request.url);
const pathSegments = url.pathname.split('/').filter(Boolean); const pathSegments = url.pathname.split('/').filter(Boolean);
console.log("🔍 DEBUG INFO:");
console.log("- Full URL:", context.request.url);
console.log("- Pathname:", url.pathname);
console.log("- Path segments:", pathSegments);
// Expected: ['api', 'landingpage', 'apbdes', 'ID'] // Expected: ['api', 'landingpage', 'apbdes', 'ID']
if (pathSegments.length < 4) { if (pathSegments.length < 4) {
context.set.status = 400; context.set.status = 400;
@@ -26,7 +32,7 @@ export default async function apbdesFindUnique(context: Context) {
}; };
} }
const id = pathSegments[3]; const id = pathSegments[3]; // ✅ ID ada di index ke-3
if (!id || id.trim() === '') { if (!id || id.trim() === '') {
context.set.status = 400; context.set.status = 400;
@@ -42,17 +48,11 @@ export default async function apbdesFindUnique(context: Context) {
include: { include: {
items: { items: {
where: { isActive: true }, where: { isActive: true },
orderBy: { kode: 'asc' }, orderBy: { kode: 'asc' }
include: {
realisasiItems: {
where: { isActive: true },
orderBy: { tanggal: 'asc' },
},
},
}, },
image: true, image: true,
file: true, file: true
}, }
}); });
if (!result || !result.isActive) { if (!result || !result.isActive) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,22 @@
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;
uraian: string; uraian: string;
anggaran: number; anggaran: number;
realisasi: number;
selisih: number;
persentase: number;
level: number; level: number;
tipe?: string | null; tipe?: string | null;
}; };
type FormUpdateBody = { type FormUpdateBody = {
tahun: number; tahun: number;
name?: string; imageId: string;
deskripsi?: string; fileId: string;
jumlah?: string;
imageId?: string | null;
fileId?: string | null;
items: APBDesItemInput[]; items: APBDesItemInput[];
}; };
@@ -29,16 +28,6 @@ export default async function apbdesUpdate(context: Context) {
// 1. Pastikan APBDes ada // 1. Pastikan APBDes ada
const existing = await prisma.aPBDes.findUnique({ const existing = await prisma.aPBDes.findUnique({
where: { id }, where: { id },
include: {
items: {
where: { isActive: true },
include: {
realisasiItems: {
where: { isActive: true },
},
},
},
},
}); });
if (!existing) { if (!existing) {
@@ -49,111 +38,35 @@ export default async function apbdesUpdate(context: Context) {
}; };
} }
// 2. Build map untuk preserve realisasiItems berdasarkan kode // 2. Hapus semua item lama
const existingItemsMap = new Map<string, {
id: string;
realisasiItems: RealisasiItem[];
}>();
existing.items.forEach(item => {
existingItemsMap.set(item.kode, {
id: item.id,
realisasiItems: item.realisasiItems,
});
});
// 3. Hapus semua item lama (cascade akan menghapus realisasiItems juga)
// TAPI kita sudah save realisasiItems di map atas
await prisma.aPBDesItem.deleteMany({ await prisma.aPBDesItem.deleteMany({
where: { apbdesId: id }, where: { apbdesId: id },
}); });
// 4. Buat item baru dengan preserve realisasiItems // 3. Buat item baru tanpa parentId terlebih dahulu
await prisma.aPBDesItem.createMany({ await prisma.aPBDesItem.createMany({
data: await Promise.all(body.items.map(async (item) => { data: body.items.map((item) => ({
const anggaran = item.anggaran; apbdesId: id,
kode: item.kode,
// Check apakah item ini punya realisasiItems lama uraian: item.uraian,
const existingItem = existingItemsMap.get(item.kode); anggaran: item.anggaran,
const realisasiItemsData = existingItem?.realisasiItems || []; realisasi: item.realisasi,
const totalRealisasi = realisasiItemsData.reduce((sum, r) => sum + r.jumlah, 0); selisih: item.anggaran - item.realisasi,
const selisih = anggaran - totalRealisasi; persentase: item.anggaran > 0 ? (item.realisasi / item.anggaran) * 100 : 0,
const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0; level: item.level,
tipe: item.tipe || null,
return { isActive: true,
apbdesId: id,
kode: item.kode,
uraian: item.uraian,
anggaran: anggaran,
level: item.level,
tipe: item.tipe || null,
totalRealisasi,
selisih,
persentase,
isActive: true,
};
})), })),
}); });
// 5. Dapatkan semua item yang baru dibuat untuk mendapatkan ID-nya // 4. Dapatkan semua item yang baru dibuat untuk mendapatkan ID-nya
const allItems = await prisma.aPBDesItem.findMany({ const allItems = await prisma.aPBDesItem.findMany({
where: { apbdesId: id }, where: { apbdesId: id },
select: { id: true, kode: true }, select: { id: true, kode: true },
}); });
// 6. Build map baru untuk item IDs // 5. Update parentId untuk setiap item
const newItemIdsMap = new Map<string, string>(); // Pastikan allItems memiliki tipe yang benar
allItems.forEach(item => {
newItemIdsMap.set(item.kode, item.id);
});
// 7. Re-create realisasiItems dengan link ke item IDs yang baru
for (const [oldKode, oldItemData] of existingItemsMap.entries()) {
if (oldItemData.realisasiItems.length > 0) {
const newItemId = newItemIdsMap.get(oldKode);
if (newItemId) {
// Re-create realisasiItems untuk item ini
await prisma.realisasiItem.createMany({
data: oldItemData.realisasiItems.map(r => ({
apbdesItemId: newItemId,
kode: r.kode,
jumlah: r.jumlah,
tanggal: r.tanggal,
keterangan: r.keterangan,
buktiFileId: r.buktiFileId,
isActive: true,
})),
});
}
}
}
// 8. Recalculate totalRealisasi setelah re-create realisasiItems
for (const kode of existingItemsMap.keys()) {
const newItemId = newItemIdsMap.get(kode);
if (newItemId) {
const realisasiItems = await prisma.realisasiItem.findMany({
where: { apbdesItemId: newItemId, isActive: true },
});
const totalRealisasi = realisasiItems.reduce((sum, r) => sum + r.jumlah, 0);
const item = await prisma.aPBDesItem.findUnique({
where: { id: newItemId },
});
if (item) {
const selisih = item.anggaran - totalRealisasi;
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
await prisma.aPBDesItem.update({
where: { id: newItemId },
data: { totalRealisasi, selisih, persentase },
});
}
}
}
// 9. Update parentId untuk setiap item
const itemsForParentUpdate = allItems.map(item => ({ const itemsForParentUpdate = allItems.map(item => ({
id: item.id, id: item.id,
kode: item.kode, kode: item.kode,
@@ -161,32 +74,23 @@ export default async function apbdesUpdate(context: Context) {
await assignParentIdsToApbdesItems(itemsForParentUpdate); await assignParentIdsToApbdesItems(itemsForParentUpdate);
// 10. Update data APBDes // 6. Update data APBDes
await prisma.aPBDes.update({ await prisma.aPBDes.update({
where: { id }, where: { id },
data: { data: {
tahun: body.tahun, tahun: body.tahun,
name: body.name || `APBDes Tahun ${body.tahun}`, imageId: body.imageId,
deskripsi: body.deskripsi, fileId: body.fileId,
jumlah: body.jumlah,
imageId: body.imageId === '' ? null : body.imageId,
fileId: body.fileId === '' ? null : body.fileId,
}, },
}); });
// 11. Ambil data lengkap untuk response (include realisasiItems) // 5. Ambil data lengkap untuk response
const result = await prisma.aPBDes.findUnique({ const result = await prisma.aPBDes.findUnique({
where: { id }, where: { id },
include: { include: {
items: { items: {
where: { isActive: true }, where: { isActive: true },
orderBy: { kode: 'asc' }, orderBy: { kode: 'asc' }
include: {
realisasiItems: {
where: { isActive: true },
orderBy: { tanggal: 'asc' },
},
},
}, },
image: true, image: true,
file: true, file: true,

View File

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

View File

@@ -46,17 +46,11 @@ fs.mkdir(UPLOAD_DIR_IMAGE, {
}).catch(() => {}); }).catch(() => {});
const corsConfig = { const corsConfig = {
origin: [ origin: "*",
"http://localhost:3000", methods: ["GET", "POST", "PATCH", "DELETE", "PUT"] as HTTPMethod[],
"http://localhost:3001", allowedHeaders: "*",
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
"*", // Allow all origins in development
],
methods: ["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] as HTTPMethod[],
allowedHeaders: ["Content-Type", "Authorization", "*"],
exposedHeaders: "*", exposedHeaders: "*",
maxAge: 86400, // 24 hours maxAge: 5,
credentials: true, credentials: true,
}; };

View File

@@ -33,34 +33,37 @@ export async function POST(req: Request) {
const codeOtp = randomOTP(); const codeOtp = randomOTP();
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}.`; // ✅ PERBAIKAN: Gunakan format pesan yang lebih sederhana
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`; // Hapus karakter khusus yang bisa bikin masalah
// const waMessage = `Website Desa Darmasaba\nKode verifikasi Anda ${codeOtp}`;
console.log("🔍 Debug WA URL:", waUrl); // // ✅ OPSI 1: Tanpa encoding (coba dulu ini)
// const waUrl = `https://wa.wibudev.com/code?nom=${nomor}&text=${waMessage}`;
try { // ✅ OPSI 2: Dengan encoding (kalau opsi 1 gagal)
const res = await fetch(waUrl); // const waUrl = `https://wa.wibudev.com/code?nom=${nomor}&text=${encodeURIComponent(waMessage)}`;
const sendWa = await res.json();
console.log("📱 WA Response:", sendWa);
if (sendWa.status !== "success") { // ✅ OPSI 3: Encoding manual untuk URL-safe (alternatif terakhir)
console.error("❌ WA Service Error:", sendWa); // const encodedMessage = waMessage.replace(/\n/g, '%0A').replace(/ /g, '%20');
return NextResponse.json( // const waUrl = `https://wa.wibudev.com/code?nom=${nomor}&text=${encodedMessage}`;
{
success: false, // console.log("🔍 Debug WA URL:", waUrl); // Untuk debugging
message: "Gagal mengirim OTP via WhatsApp",
debug: sendWa // const res = await fetch(waUrl);
}, // const sendWa = await res.json();
{ status: 400 }
); // console.log("📱 WA Response:", sendWa); // Debug response
}
} catch (waError) { // if (sendWa.status !== "success") {
console.error("❌ Fetch WA Error:", waError); // return NextResponse.json(
return NextResponse.json( // {
{ success: false, message: "Terjadi kesalahan saat mengirim WA" }, // success: false,
{ status: 500 } // message: "Gagal mengirim OTP via WhatsApp",
); // debug: sendWa // Tampilkan error detail
} // },
// { status: 400 }
// );
// }
const createOtpId = await prisma.kodeOtp.create({ const createOtpId = await prisma.kodeOtp.create({
data: { nomor, otp: otpNumber, isActive: true }, data: { nomor, otp: otpNumber, isActive: true },

View File

@@ -19,7 +19,7 @@ export async function POST(req: Request) {
const otpNumber = Number(codeOtp); const otpNumber = Number(codeOtp);
// Kirim OTP via WhatsApp // Kirim OTP via WhatsApp
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 = `Kode verifikasi Anda: ${codeOtp}`;
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`; const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
const waRes = await fetch(waUrl); const waRes = await fetch(waUrl);
const waData = await waRes.json(); const waData = await waRes.json();

View File

@@ -1,320 +0,0 @@
'use client';
import {
createContext,
useContext,
useState,
useRef,
useEffect,
useCallback,
ReactNode,
} from 'react';
interface MusicFile {
id: string;
name: string;
realName: string;
path: string;
mimeType: string;
link: string;
}
export interface Musik {
id: string;
judul: string;
artis: string;
deskripsi: string | null;
durasi: string;
genre: string | null;
tahunRilis: number | null;
audioFile: MusicFile | null;
coverImage: MusicFile | null;
isActive: boolean;
}
interface MusicContextType {
// State
isPlaying: boolean;
currentSong: Musik | null;
currentSongIndex: number;
musikData: Musik[];
currentTime: number;
duration: number;
volume: number;
isMuted: boolean;
isRepeat: boolean;
isShuffle: boolean;
isLoading: boolean;
isPlayerOpen: boolean;
// Actions
playSong: (song: Musik) => void;
togglePlayPause: () => void;
playNext: () => void;
playPrev: () => void;
seek: (time: number) => void;
setVolume: (volume: number) => void;
toggleMute: () => void;
toggleRepeat: () => void;
toggleShuffle: () => void;
togglePlayer: () => void;
loadMusikData: () => Promise<void>;
}
const MusicContext = createContext<MusicContextType | undefined>(undefined);
export function MusicProvider({ children }: { children: ReactNode }) {
// State
const [isPlaying, setIsPlaying] = useState(false);
const [currentSong, setCurrentSong] = useState<Musik | null>(null);
const [currentSongIndex, setCurrentSongIndex] = useState(-1);
const [musikData, setMusikData] = useState<Musik[]>([]);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolumeState] = useState(70);
const [isMuted, setIsMuted] = useState(false);
const [isRepeat, setIsRepeat] = useState(false);
const [isShuffle, setIsShuffle] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
// Refs
const audioRef = useRef<HTMLAudioElement | null>(null);
const isSeekingRef = useRef(false);
const animationFrameRef = useRef<number | null>(null);
const isRepeatRef = useRef(false); // Ref untuk avoid stale closure
// Sync ref dengan state
useEffect(() => {
isRepeatRef.current = isRepeat;
}, [isRepeat]);
// Load musik data
const loadMusikData = useCallback(async () => {
try {
setIsLoading(true);
const res = await fetch('/api/desa/musik/find-many?page=1&limit=50');
const data = await res.json();
if (data.success && data.data) {
const activeMusik = data.data.filter((m: Musik) => m.isActive);
setMusikData(activeMusik);
}
} catch (error) {
console.error('Error fetching musik:', error);
} finally {
setIsLoading(false);
}
}, []);
// Initialize audio element
useEffect(() => {
audioRef.current = new Audio();
audioRef.current.preload = 'metadata';
// Event listeners
audioRef.current.addEventListener('loadedmetadata', () => {
setDuration(Math.floor(audioRef.current!.duration));
});
audioRef.current.addEventListener('ended', () => {
// Gunakan ref untuk avoid stale closure
if (isRepeatRef.current) {
audioRef.current!.currentTime = 0;
audioRef.current!.play();
} else {
playNext();
}
});
// Load initial data
loadMusikData();
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- playNext is intentionally not in deps to avoid circular dependency
}, [loadMusikData]); // Remove isRepeat dari deps karena sudah pakai ref
// Update time with requestAnimationFrame for smooth progress
const updateTime = useCallback(() => {
if (audioRef.current && !audioRef.current.paused && !isSeekingRef.current) {
setCurrentTime(Math.floor(audioRef.current.currentTime));
animationFrameRef.current = requestAnimationFrame(updateTime);
}
}, []);
useEffect(() => {
if (isPlaying) {
animationFrameRef.current = requestAnimationFrame(updateTime);
} else {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
}
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [isPlaying, updateTime]);
// Play song
const playSong = useCallback(
(song: Musik) => {
if (!song?.audioFile?.link || !audioRef.current) return;
const songIndex = musikData.findIndex(m => m.id === song.id);
setCurrentSongIndex(songIndex);
setCurrentSong(song);
setIsPlaying(true);
audioRef.current.src = song.audioFile.link;
audioRef.current.load();
audioRef.current
.play()
.catch((err) => console.error('Error playing audio:', err));
},
[musikData]
);
// Toggle play/pause
const togglePlayPause = useCallback(() => {
if (!audioRef.current || !currentSong) return;
if (isPlaying) {
audioRef.current.pause();
setIsPlaying(false);
} else {
audioRef.current
.play()
.then(() => setIsPlaying(true))
.catch((err) => console.error('Error playing audio:', err));
}
}, [isPlaying, currentSong]);
// Play next
const playNext = useCallback(() => {
if (musikData.length === 0) return;
let nextIndex: number;
if (isShuffle) {
nextIndex = Math.floor(Math.random() * musikData.length);
} else {
nextIndex = (currentSongIndex + 1) % musikData.length;
}
const nextSong = musikData[nextIndex];
if (nextSong) {
playSong(nextSong);
}
}, [musikData, isShuffle, currentSongIndex, playSong]);
// Play previous
const playPrev = useCallback(() => {
if (musikData.length === 0) return;
// If more than 3 seconds into song, restart it
if (currentTime > 3) {
if (audioRef.current) {
audioRef.current.currentTime = 0;
}
return;
}
const prevIndex =
currentSongIndex <= 0 ? musikData.length - 1 : currentSongIndex - 1;
const prevSong = musikData[prevIndex];
if (prevSong) {
playSong(prevSong);
}
}, [musikData, currentSongIndex, currentTime, playSong]);
// Seek
const seek = useCallback((time: number) => {
if (!audioRef.current) return;
audioRef.current.currentTime = time;
setCurrentTime(time);
}, []);
// Set volume
const setVolume = useCallback((vol: number) => {
if (!audioRef.current) return;
const normalizedVol = Math.max(0, Math.min(100, vol)) / 100;
audioRef.current.volume = normalizedVol;
setVolumeState(Math.max(0, Math.min(100, vol)));
setIsMuted(normalizedVol === 0);
}, []);
// Toggle mute
const toggleMute = useCallback(() => {
if (!audioRef.current) return;
const newMuted = !isMuted;
audioRef.current.muted = newMuted;
setIsMuted(newMuted);
if (newMuted && volume > 0) {
audioRef.current.volume = 0;
} else if (!newMuted && volume > 0) {
audioRef.current.volume = volume / 100;
}
}, [isMuted, volume]);
// Toggle repeat
const toggleRepeat = useCallback(() => {
setIsRepeat((prev) => !prev);
}, []);
// Toggle shuffle
const toggleShuffle = useCallback(() => {
setIsShuffle((prev) => !prev);
}, []);
// Toggle player
const togglePlayer = useCallback(() => {
setIsPlayerOpen((prev) => !prev);
}, []);
const value: MusicContextType = {
isPlaying,
currentSong,
currentSongIndex,
musikData,
currentTime,
duration,
volume,
isMuted,
isRepeat,
isShuffle,
isLoading,
isPlayerOpen,
playSong,
togglePlayPause,
playNext,
playPrev,
seek,
setVolume,
toggleMute,
toggleRepeat,
toggleShuffle,
togglePlayer,
loadMusikData,
};
return (
<MusicContext.Provider value={value}>{children}</MusicContext.Provider>
);
}
export function useMusic() {
const context = useContext(MusicContext);
if (context === undefined) {
throw new Error('useMusic must be used within a MusicProvider');
}
return context;
}

View File

@@ -3,43 +3,10 @@
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita'; import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
import NewsReader from '@/app/darmasaba/_com/NewsReader'; import NewsReader from '@/app/darmasaba/_com/NewsReader';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import { Box, Center, Container, Group, Image, Skeleton, Stack, Text, Title } from '@mantine/core';
Box,
Center,
Container,
Group,
Image,
Skeleton,
Stack,
Text,
Title,
Grid,
Card,
AspectRatio,
Badge,
Divider,
} from '@mantine/core';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { IconVideo } from '@tabler/icons-react';
interface ExistingImage {
id: string;
link: string;
name: string;
}
interface BeritaDetail {
id: string;
judul: string;
deskripsi: string;
content: string;
image?: { link: string } | null;
images?: ExistingImage[];
linkVideo?: string | null;
kategoriBerita?: { name: string } | null;
}
function Page() { function Page() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
@@ -78,30 +45,13 @@ function Page() {
); );
} }
const data = state.findUnique.data as unknown as BeritaDetail;
return ( return (
<Stack pos="relative" bg={colors.Bg} pb="xl" gap="xs" px={{ base: 'md', md: 0 }}> <Stack pos="relative" bg={colors.Bg} pb="xl" gap="xs" px={{ base: 'md', md: 0 }}>
<Group px={{ base: 'md', md: 100 }}> <Group px={{ base: 'md', md: 100 }}>
<NewsReader /> <NewsReader />
</Group> </Group>
<Container w={{ base: '100%', md: '50%' }}>
<Container w={{ base: '100%', md: '60%' }}>
<Box pb={20}> <Box pb={20}>
{/* Kategori Badge */}
{data.kategoriBerita?.name && (
<Badge
color={colors['blue-button']}
variant="light"
size="lg"
mb="md"
style={{ textTransform: 'uppercase' }}
>
{data.kategoriBerita.name}
</Badge>
)}
{/* Judul */}
<Title <Title
id="news-title" id="news-title"
order={1} order={1}
@@ -109,106 +59,39 @@ function Page() {
c={colors['blue-button']} c={colors['blue-button']}
fw="bold" fw="bold"
lh={{ base: 1.2, md: 1.25 }} lh={{ base: 1.2, md: 1.25 }}
mb="md"
> >
{data.judul} {state.findUnique.data.judul}
</Title>
<Title
order={2}
ta="center"
fw="bold"
fz={{ base: 'md', md: 'lg' }}
lh={{ base: 1.3, md: 1.35 }}
>
Informasi dan Pelayanan Administrasi Digital
</Title> </Title>
<Divider my="xs" />
</Box> </Box>
<Image src={state.findUnique.data.image?.link || ''} alt="" w="100%" loading="lazy" />
{/* Featured Image */} </Container>
{data.image?.link && ( <Box px={{ base: 'md', md: 100 }}>
<Image <Stack gap="xs">
src={data.image.link}
alt={data.judul}
w="100%"
h={{ base: 300, md: 400 }}
radius="md"
loading="lazy"
fit="cover"
/>
)}
{/* Content */}
<Box mt="xl">
<Title order={3} c={colors['blue-button']} mb="md">
Deskripsi Berita
</Title>
<Text <Text
id="news-content" id="news-content"
py={20} py={20}
px={{ base: 0, md: 'sm' }}
fz={{ base: 'sm', md: 'md' }} fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.8, md: 2 }} lh={{ base: 1.6, md: 1.8 }}
ta="justify" ta="justify"
c="dimmed"
style={{ style={{
wordBreak: 'break-word', wordBreak: 'break-word',
whiteSpace: 'normal', whiteSpace: 'normal',
}} }}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: data.content || '', __html: state.findUnique.data.content || '',
}} }}
/> />
</Box> </Stack>
</Box>
{/* Gallery Images */}
{data.images && data.images.length > 0 && (
<Box mt="xl">
<Group gap="xs" mb="md">
<Title order={3} c={colors['blue-button']}>
Galeri Foto
</Title>
<Badge color={colors['blue-button']} variant="light">
{data.images.length}
</Badge>
</Group>
<Grid gutter="md">
{data.images.map((img, index) => (
<Grid.Col span={{ base: 6, md: 4 }} key={img.id}>
<Card p="xs" radius="md" withBorder>
<Image
src={img.link}
alt={img.name || `Foto ${index + 1}`}
h={180}
radius="sm"
fit="cover"
loading="lazy"
/>
</Card>
</Grid.Col>
))}
</Grid>
</Box>
)}
{/* YouTube Video */}
{data.linkVideo && (
<Box mt="xl">
<Group gap="xs" mb="md">
<Title order={3} c={colors['blue-button']}>
Video
</Title>
<IconVideo size={24} color={colors['blue-button']} />
</Group>
<AspectRatio ratio={16 / 9} mah={500}>
<iframe
src={data.linkVideo}
title="YouTube Video"
allowFullScreen
style={{
borderRadius: 12,
border: '1px solid #e0e0e0',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
}}
/>
</AspectRatio>
</Box>
)}
</Container>
</Stack> </Stack>
); );
} }

View File

@@ -1,32 +0,0 @@
export function getNextIndex(
currentIndex: number,
total: number,
isShuffle: boolean
) {
if (total === 0) return -1;
if (isShuffle) {
return Math.floor(Math.random() * total);
}
return (currentIndex + 1) % total;
}
export function getPrevIndex(
currentIndex: number,
total: number,
isShuffle: boolean
) {
if (total === 0) return -1;
if (isShuffle) {
return Math.floor(Math.random() * total);
}
return currentIndex - 1 < 0 ? total - 1 : currentIndex - 1;
}
//pakai di ui
// const next = getNextIndex(currentSongIndex, filteredMusik.length, isShuffle);
// playSong(next);

View File

@@ -1,24 +0,0 @@
import { RefObject } from "react";
export function togglePlayPause(
audioRef: RefObject<HTMLAudioElement | null>,
isPlaying: boolean,
setIsPlaying: (v: boolean) => void
) {
if (!audioRef.current) return;
if (isPlaying) {
audioRef.current.pause();
setIsPlaying(false);
} else {
audioRef.current
.play()
.then(() => setIsPlaying(true))
.catch(console.error);
}
}
// pakai di ui
// onClick={() =>
// togglePlayPause(audioRef, isPlaying, setIsPlaying)
// }

View File

@@ -1,22 +0,0 @@
import { RefObject } from "react";
export function handleRepeatOrNext(
audioRef: RefObject<HTMLAudioElement | null>,
isRepeat: boolean,
playNext: () => void
) {
if (!audioRef.current) return;
if (isRepeat) {
audioRef.current.currentTime = 0;
audioRef.current.play();
} else {
playNext();
}
}
//dipakai di ui
// onEnded={() =>
// handleRepeatOrNext(audioRef, isRepeat, playNext)
// }

View File

@@ -1,19 +0,0 @@
export function seekTo(
audioRef: React.RefObject<HTMLAudioElement>,
time: number,
setCurrentTime?: (v: number) => void
) {
if (!audioRef.current) return;
// Validasi: jangan seek melebihi durasi atau negatif
const duration = audioRef.current.duration || 0;
const safeTime = Math.min(Math.max(0, time), duration);
// Set waktu audio
audioRef.current.currentTime = safeTime;
// Update state jika provided
if (setCurrentTime) {
setCurrentTime(Math.floor(safeTime));
}
}

View File

@@ -1,6 +0,0 @@
export function toggleShuffle(
isShuffle: boolean,
setIsShuffle: (v: boolean) => void
) {
setIsShuffle(!isShuffle);
}

View File

@@ -1,145 +0,0 @@
import { useRef, useEffect, useCallback } from 'react';
/**
* Custom hook untuk smooth audio progress update menggunakan requestAnimationFrame
* Lebih smooth dan reliable dibanding onTimeUpdate event
*/
export function useAudioProgress(
audioRef: React.RefObject<HTMLAudioElement>,
isPlaying: boolean,
setCurrentTime: (time: number) => void,
isSeekingRef: React.RefObject<boolean>
) {
const rafRef = useRef<number | null>(null);
const lastTimeRef = useRef<number>(0);
const updateProgress = useCallback(() => {
if (!audioRef.current || audioRef.current.paused || isSeekingRef.current) {
rafRef.current = requestAnimationFrame(updateProgress);
return;
}
const audio = audioRef.current;
const currentTime = Math.floor(audio.currentTime);
// Hanya update state jika waktu berubah
if (currentTime !== lastTimeRef.current) {
lastTimeRef.current = currentTime;
setCurrentTime(currentTime);
}
rafRef.current = requestAnimationFrame(updateProgress);
}, [audioRef, setCurrentTime, isSeekingRef]);
useEffect(() => {
if (isPlaying) {
rafRef.current = requestAnimationFrame(updateProgress);
} else if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
};
}, [isPlaying, updateProgress]);
return rafRef;
}
// 'use client'
// import { useEffect, useRef, useState, useCallback } from 'react';
// export function useAudioEngine() {
// const audioRef = useRef<HTMLAudioElement | null>(null);
// const rafRef = useRef<number | null>(null);
// const isSeekingRef = useRef(false);
// const [isPlaying, setIsPlaying] = useState(false);
// const [currentTime, setCurrentTime] = useState(0);
// const [duration, setDuration] = useState(0);
// const load = useCallback((src: string) => {
// if (!audioRef.current) return;
// audioRef.current.src = src;
// audioRef.current.load();
// setCurrentTime(0);
// }, []);
// const play = async () => {
// if (!audioRef.current) return;
// await audioRef.current.play();
// setIsPlaying(true);
// };
// const pause = () => {
// if (!audioRef.current) return;
// audioRef.current.pause();
// setIsPlaying(false);
// };
// const toggle = () => {
// if (!audioRef.current) return;
// audioRef.current.paused ? play() : pause();
// };
// const seek = (time: number) => {
// if (!audioRef.current) return;
// isSeekingRef.current = true;
// audioRef.current.currentTime = time;
// setCurrentTime(time);
// requestAnimationFrame(() => {
// isSeekingRef.current = false;
// });
// };
// useEffect(() => {
// if (!audioRef.current) return;
// const audio = audioRef.current;
// const onLoaded = () => {
// setDuration(Math.floor(audio.duration));
// };
// const onEnded = () => {
// setIsPlaying(false);
// setCurrentTime(0);
// };
// audio.addEventListener('loadedmetadata', onLoaded);
// audio.addEventListener('ended', onEnded);
// return () => {
// audio.removeEventListener('loadedmetadata', onLoaded);
// audio.removeEventListener('ended', onEnded);
// };
// }, []);
// useEffect(() => {
// const loop = () => {
// if (
// audioRef.current &&
// !audioRef.current.paused &&
// !isSeekingRef.current
// ) {
// setCurrentTime(Math.floor(audioRef.current.currentTime));
// }
// rafRef.current = requestAnimationFrame(loop);
// };
// rafRef.current = requestAnimationFrame(loop);
// return () => {
// if (rafRef.current) cancelAnimationFrame(rafRef.current);
// };
// }, []);
// return {
// audioRef,
// isPlaying,
// currentTime,
// duration,
// load,
// toggle,
// seek,
// };
// }

View File

@@ -1,29 +0,0 @@
import { RefObject } from "react";
export function setAudioVolume(
audioRef: RefObject<HTMLAudioElement | null>,
volume: number,
setVolume: (v: number) => void,
setIsMuted: (v: boolean) => void
) {
if (!audioRef.current) return;
audioRef.current.volume = volume / 100;
setVolume(volume);
if (volume > 0) {
setIsMuted(false);
}
}
export function toggleMute(
audioRef: RefObject<HTMLAudioElement | null>,
isMuted: boolean,
setIsMuted: (v: boolean) => void
) {
if (!audioRef.current) return;
const muted = !isMuted;
audioRef.current.muted = muted;
setIsMuted(muted);
}

View File

@@ -1,101 +1,69 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client' 'use client'
import { useMusic } from '@/app/context/MusicContext'; import { ActionIcon, Avatar, Badge, Box, Card, Flex, Grid, Group, Paper, Slider, Stack, Text, TextInput } from '@mantine/core';
import { ActionIcon, Avatar, Badge, Box, Card, Flex, Grid, Group, Paper, ScrollArea, Slider, Stack, Text, TextInput } from '@mantine/core';
import { IconArrowsShuffle, IconPlayerPauseFilled, IconPlayerPlayFilled, IconPlayerSkipBackFilled, IconPlayerSkipForwardFilled, IconRepeat, IconRepeatOff, IconSearch, IconVolume, IconVolumeOff, IconX } from '@tabler/icons-react'; import { IconArrowsShuffle, IconPlayerPauseFilled, IconPlayerPlayFilled, IconPlayerSkipBackFilled, IconPlayerSkipForwardFilled, IconRepeat, IconRepeatOff, IconSearch, IconVolume, IconVolumeOff, IconX } from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import BackButton from '../../desa/layanan/_com/BackButto'; import BackButton from '../../desa/layanan/_com/BackButto';
const MusicPlayer = () => { const MusicPlayer = () => {
const { const [isPlaying, setIsPlaying] = useState(false);
isPlaying, const [currentTime, setCurrentTime] = useState(0);
currentSong, const [duration, setDuration] = useState(245);
currentTime, const [volume, setVolume] = useState(70);
duration, const [isMuted, setIsMuted] = useState(false);
volume, const [isRepeat, setIsRepeat] = useState(false);
isMuted, const [isShuffle, setIsShuffle] = useState(false);
isRepeat,
isShuffle,
isLoading,
musikData,
playSong,
togglePlayPause,
playNext,
playPrev,
seek,
setVolume,
toggleMute,
toggleRepeat,
toggleShuffle,
} = useMusic();
const [search, setSearch] = useState(''); const songs = [
{ id: 1, title: 'Midnight Dreams', artist: 'The Wanderers', duration: '4:05', cover: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop' },
{ id: 2, title: 'Summer Breeze', artist: 'Coastal Vibes', duration: '3:42', cover: 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop' },
{ id: 3, title: 'City Lights', artist: 'Urban Echo', duration: '4:18', cover: 'https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=400&h=400&fit=crop' },
{ id: 4, title: 'Ocean Waves', artist: 'Serenity Sound', duration: '5:20', cover: 'https://images.unsplash.com/photo-1459749411175-04bf5292ceea?w=400&h=400&fit=crop' },
{ id: 5, title: 'Neon Nights', artist: 'Electric Dreams', duration: '3:55', cover: 'https://images.unsplash.com/photo-1487180144351-b8472da7d491?w=400&h=400&fit=crop' },
{ id: 6, title: 'Mountain High', artist: 'Peak Performers', duration: '4:32', cover: 'https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?w=400&h=400&fit=crop' }
];
// Fetch musik data from global state const [currentSong, setCurrentSong] = useState(songs[0]);
const { loadMusikData } = useMusic();
useEffect(() => { useEffect(() => {
loadMusikData(); let interval: any;
}, [loadMusikData]); if (isPlaying) {
interval = setInterval(() => {
setCurrentTime(prev => {
if (prev >= duration) {
setIsPlaying(false);
return 0;
}
return prev + 1;
});
}, 1000);
}
return () => clearInterval(interval);
}, [isPlaying, duration]);
// Filter musik based on search - gunakan useMemo untuk mencegah re-calculate setiap render
const filteredMusik = useMemo(() => {
return musikData.filter(musik =>
musik.judul.toLowerCase().includes(search.toLowerCase()) ||
musik.artis.toLowerCase().includes(search.toLowerCase()) ||
(musik.genre && musik.genre.toLowerCase().includes(search.toLowerCase()))
);
}, [musikData, search]);
// Format time helper
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60); const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`; return `${mins}:${secs.toString().padStart(2, '0')}`;
}; };
const handleVolumeChange = (value: number) => { const playSong = (song: any) => {
setVolume(value); setCurrentSong(song);
setCurrentTime(0);
setIsPlaying(true);
const durationInSeconds = parseInt(song.duration.split(':')[0]) * 60 + parseInt(song.duration.split(':')[1]);
setDuration(durationInSeconds);
}; };
const toggleMuteHandler = () => { const toggleMute = () => {
toggleMute(); setIsMuted(!isMuted);
}; };
const togglePlayPauseHandler = () => {
togglePlayPause();
};
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">
<Text ta="center">Memuat data musik...</Text>
</Paper>
</Box>
);
}
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,195 +73,138 @@ 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)} styles={{ input: { backgroundColor: '#fff' } }}
styles={{ input: { backgroundColor: '#fff' } }} />
/> </Group>
</Flex> </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 ? ( <Card radius="md" p="xl" shadow="md">
<Card radius="md" p={{ base: 'md', sm: 'xl' }} shadow="md" withBorder> <Group align="center" gap="xl">
<Flex <Avatar src={currentSong.cover} size={180} radius="md" />
direction={{ base: 'column', sm: 'row' }} <Stack gap="md" style={{ flex: 1 }}>
align="center" <div>
gap={{ base: 'md', sm: 'xl' }} <Text size="28px" fw={700} c="#0B4F78">{currentSong.title}</Text>
> <Text size="lg" c="#5A6C7D">{currentSong.artist}</Text>
<Avatar </div>
src={currentSong.coverImage?.link || '/mp3-logo.png'} <Group gap="xs" align="center">
size={120} <Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text>
radius="md" <Slider
/> value={currentTime}
<Stack gap="md" style={{ flex: 1, width: '100%' }}> max={duration}
<Box ta={{ base: 'center', sm: 'left' }}> onChange={setCurrentTime}
<Text fz={{ base: '20px', sm: '28px' }} fw={700} c="#0B4F78" lineClamp={1}>{currentSong.judul}</Text> color="#0B4F78"
<Text size="lg" c="#5A6C7D">{currentSong.artis}</Text> size="sm"
{currentSong.genre && ( style={{ flex: 1 }}
<Badge mt="xs" color="#0B4F78" variant="light">{currentSong.genre}</Badge> styles={{ thumb: { borderWidth: 2 } }}
)} />
</Box> <Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration)}</Text>
<Group gap="xs" align="center"> </Group>
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text> </Stack>
<Slider </Group>
value={currentTime} </Card>
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 || 0)}</Text>
</Group>
</Stack>
</Flex>
</Card>
) : (
<Card radius="md" p="xl" shadow="md">
<Text ta="center" c="dimmed">Pilih lagu untuk diputar</Text>
</Card>
)}
</div> </div>
<div> <div>
<Text size="xl" fw={700} c="#0B4F78" mb="md">Daftar Putar</Text> <Text size="xl" fw={700} c="#0B4F78" mb="md">Daftar Putar</Text>
{filteredMusik.length === 0 ? ( <Grid gutter="md">
<Text ta="center" c="dimmed">Tidak ada musik yang ditemukan</Text> {songs.map(song => (
) : ( <Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}>
<ScrollArea.Autosize mah={400}> <Card
<Grid gutter="md"> radius="md"
{filteredMusik.map((song) => ( p="md"
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}> shadow="sm"
<Card style={{
radius="md" cursor: 'pointer',
p="sm" border: currentSong.id === song.id ? '2px solid #0B4F78' : '2px solid transparent',
shadow="sm" transition: 'all 0.2s'
withBorder }}
style={{ onClick={() => playSong(song)}
cursor: 'pointer', >
borderColor: currentSong?.id === song.id ? '#0B4F78' : 'transparent', <Group gap="md" align="center">
backgroundColor: currentSong?.id === song.id ? '#F0F7FA' : 'white', <Avatar src={song.cover} size={64} radius="md" />
transition: 'all 0.2s' <Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
}} <Text size="sm" fw={600} c="#0B4F78" truncate>{song.title}</Text>
onClick={() => playSong(song)} <Text size="xs" c="#5A6C7D">{song.artist}</Text>
> <Text size="xs" c="#8A9BA8">{song.duration}</Text>
<Group gap="sm" align="center" wrap="nowrap"> </Stack>
<Avatar {currentSong.id === song.id && isPlaying && (
src={song.coverImage?.link || '/mp3-logo.png'} <Badge color="#0B4F78" variant="filled">Memutar</Badge>
size={50} )}
radius="md" </Group>
/> </Card>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}> </Grid.Col>
<Text size="sm" fw={600} c="#0B4F78" truncate>{song.judul}</Text> ))}
<Text size="xs" c="#5A6C7D" truncate>{song.artis}</Text> </Grid>
</Stack>
{currentSong?.id === song.id && isPlaying && (
<Badge color="#0B4F78" variant="filled" size="xs">Playing</Badge>
)}
</Group>
</Card>
</Grid.Col>
))}
</Grid>
</ScrollArea.Autosize>
)}
</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" <Avatar src={currentSong.cover} size={56} radius="md" />
justify="space-between"
gap={{ base: 'md', md: 'xl' }}
>
{/* Song Info */}
<Group gap="md" style={{ flex: 1, width: '100%' }} wrap="nowrap">
<Avatar
src={currentSong?.coverImage?.link || '/mp3-logo.png'}
size={48}
radius="md"
/>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{currentSong ? ( <Text size="sm" fw={600} c="#0B4F78" truncate>{currentSong.title}</Text>
<> <Text size="xs" c="#5A6C7D">{currentSong.artist}</Text>
<Text size="sm" fw={600} c="#0B4F78" truncate>{currentSong.judul}</Text>
<Text size="xs" c="#5A6C7D" truncate>{currentSong.artis}</Text>
</>
) : (
<Text size="sm" c="dimmed">Tidak ada lagu</Text>
)}
</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={() => setIsShuffle(!isShuffle)}
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">
<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={() => setIsPlaying(!isPlaying)}
> >
{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">
<IconPlayerSkipForwardFilled size={20} /> <IconPlayerSkipForwardFilled size={20} />
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon
variant={isRepeat ? 'filled' : 'subtle'} variant={isRepeat ? 'filled' : 'subtle'}
color="#0B4F78" color="#0B4F78"
onClick={toggleRepeatHandler} onClick={() => setIsRepeat(!isRepeat)}
radius="xl" radius="xl"
size="md"
> >
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />} {isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
</ActionIcon> </ActionIcon>
@@ -302,24 +213,26 @@ const MusicPlayer = () => {
<Text size="xs" c="#5A6C7D" w={40} ta="right">{formatTime(currentTime)}</Text> <Text size="xs" c="#5A6C7D" w={40} ta="right">{formatTime(currentTime)}</Text>
<Slider <Slider
value={currentTime} value={currentTime}
max={duration || 100} max={duration}
onChange={(v) => seek(v)} onChange={setCurrentTime}
color="#0B4F78" color="#0B4F78"
size="xs" size="xs"
style={{ flex: 1 }} style={{ flex: 1 }}
/> />
<Text size="xs" c="#5A6C7D" w={40}>{formatTime(duration || 0)}</Text> <Text size="xs" c="#5A6C7D" w={40}>{formatTime(duration)}</Text>
</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={toggleMute}>
<ActionIcon variant="subtle" color="gray" onClick={toggleMuteHandler}>
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />} {isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
</ActionIcon> </ActionIcon>
<Slider <Slider
value={isMuted ? 0 : volume} value={isMuted ? 0 : volume}
onChange={handleVolumeChange} onChange={(val) => {
setVolume(val);
if (val > 0) setIsMuted(false);
}}
color="#0B4F78" color="#0B4F78"
size="xs" size="xs"
w={100} w={100}

View File

@@ -78,8 +78,7 @@ function APBDesProgress({ apbdesData }: APBDesProgressProps) {
// Hitung total per kategori // Hitung total per kategori
const calcTotal = (items: { anggaran: number; realisasi: number }[]) => { const calcTotal = (items: { anggaran: number; realisasi: number }[]) => {
const anggaran = items.reduce((sum, item) => sum + item.anggaran, 0); const anggaran = items.reduce((sum, item) => sum + item.anggaran, 0);
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData) const realisasi = items.reduce((sum, item) => sum + item.realisasi, 0);
const realisasi = items.reduce((sum, item) => sum + (item.realisasi || 0), 0);
const persen = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; const persen = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
return { anggaran, realisasi, persen }; return { anggaran, realisasi, persen };
}; };

View File

@@ -68,7 +68,6 @@ function APBDesTable({ apbdesData }: APBDesTableProps) {
// Calculate totals // Calculate totals
const totalAnggaran = items.reduce((sum, item) => sum + (item.anggaran || 0), 0); const totalAnggaran = items.reduce((sum, item) => sum + (item.anggaran || 0), 0);
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
const totalRealisasi = items.reduce((sum, item) => sum + (item.realisasi || 0), 0); const totalRealisasi = items.reduce((sum, item) => sum + (item.realisasi || 0), 0);
const totalSelisih = totalAnggaran - totalRealisasi; const totalSelisih = totalAnggaran - totalRealisasi;
const totalPersentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0; const totalPersentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;

View File

@@ -51,8 +51,7 @@ export function transformAPBDesData(data: any): APBDesData {
kode: item.kode || '', kode: item.kode || '',
uraian: item.uraian || '', uraian: item.uraian || '',
anggaran: typeof item.anggaran === 'number' ? item.anggaran : 0, anggaran: typeof item.anggaran === 'number' ? item.anggaran : 0,
// Map totalRealisasi from backend to realisasi field realisasi: typeof item.realisasi === 'number' ? item.realisasi : 0,
realisasi: typeof item.totalRealisasi === 'number' ? item.totalRealisasi : (typeof item.realisasi === 'number' ? item.realisasi : 0),
selisih: typeof item.selisih === 'number' ? item.selisih : 0, selisih: typeof item.selisih === 'number' ? item.selisih : 0,
persentase: typeof item.persentase === 'number' ? item.persentase : 0, persentase: typeof item.persentase === 'number' ? item.persentase : 0,
level: typeof item.level === 'number' ? item.level : 1, level: typeof item.level === 'number' ? item.level : 1,

View File

@@ -1,300 +0,0 @@
import { useMusic } from '@/app/context/MusicContext';
import {
ActionIcon,
Avatar,
Box,
Button,
Flex,
Group,
Paper,
Slider,
Text,
Transition
} from '@mantine/core';
import {
IconArrowsShuffle,
IconMusic,
IconPlayerPauseFilled,
IconPlayerPlayFilled,
IconPlayerSkipBackFilled,
IconPlayerSkipForwardFilled,
IconRepeat,
IconRepeatOff,
IconVolume,
IconVolumeOff,
IconX,
} from '@tabler/icons-react';
import { useState } from 'react';
export default function FixedPlayerBar() {
const {
isPlaying,
currentSong,
currentTime,
duration,
volume,
isMuted,
isRepeat,
isShuffle,
togglePlayPause,
playNext,
playPrev,
seek,
setVolume,
toggleMute,
toggleRepeat,
toggleShuffle,
} = useMusic();
const [showVolume, setShowVolume] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
// Format time
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// Handle seek
const handleSeek = (value: number) => {
seek(value);
};
// Handle volume change
const handleVolumeChange = (value: number) => {
setVolume(value);
};
// Handle shuffle toggle
const handleToggleShuffle = () => {
toggleShuffle();
};
// Handle minimize player (show floating icon)
const handleMinimizePlayer = () => {
setIsMinimized(true);
};
// Handle restore player from floating icon
const handleRestorePlayer = () => {
setIsMinimized(false);
};
// If minimized, show floating icon instead of player bar
if (isMinimized) {
return (
<>
{/* Floating Music Icon - Shows when player is minimized */}
<Button
color="#0B4F78"
variant="filled"
size="md"
mt="md"
style={{
position: 'fixed',
top: '50%',
left: '0px',
transform: 'translateY(-50%)',
borderBottomRightRadius: '20px',
borderTopRightRadius: '20px',
cursor: 'pointer',
transition: 'transform 0.2s ease',
zIndex: 1000 // Higher z-index
}}
onClick={handleRestorePlayer}
>
<IconMusic size={24} color="white" />
</Button>
</>
);
}
if (!currentSong) {
return null;
}
return (
<>
{/* Mini Player Bar - Always visible when song is playing */}
<Paper
pos="fixed"
bottom={0}
left={0}
right={0}
p={{ base: 'xs', sm: 'sm' }}
shadow="xl"
style={{
zIndex: 1000,
borderTop: '1px solid rgba(0,0,0,0.1)',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
}}
>
<Flex align="center" gap={{ base: 'xs', sm: 'md' }} justify="space-between">
{/* Song Info - Left */}
<Group gap="xs" flex={{ base: 2, sm: 1 }} style={{ minWidth: 0 }} wrap="nowrap">
<Avatar
src={currentSong.coverImage?.link || ''}
alt={currentSong.judul}
size={"36"}
radius="sm"
/>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fz={{ base: 'xs', sm: 'sm' }} fw={600} truncate>
{currentSong.judul}
</Text>
<Text fz="10px" c="dimmed" truncate>
{currentSong.artis}
</Text>
</Box>
</Group>
{/* Controls - Center */}
<Group gap={"xs"} flex={{ base: 1, sm: 2 }} justify="center" wrap="nowrap">
{/* Shuffle - Desktop Only */}
<ActionIcon
variant={isShuffle ? 'filled' : 'subtle'}
color={isShuffle ? '#0B4F78' : 'gray'}
size={"md"}
onClick={handleToggleShuffle}
visibleFrom="sm"
>
<IconArrowsShuffle size={18} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size={"md"}
onClick={playPrev}
>
<IconPlayerSkipBackFilled size={20} />
</ActionIcon>
<ActionIcon
variant="filled"
color="#0B4F78"
size={"lg"}
radius="xl"
onClick={togglePlayPause}
>
{isPlaying ? (
<IconPlayerPauseFilled size={24} />
) : (
<IconPlayerPlayFilled size={24} />
)}
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size={"md"}
onClick={playNext}
>
<IconPlayerSkipForwardFilled size={20} />
</ActionIcon>
{/* Repeat - Desktop Only */}
<ActionIcon
variant={isRepeat ? 'filled' : 'subtle'}
color={isRepeat ? '#0B4F78' : 'gray'}
size={"md"}
onClick={toggleRepeat}
visibleFrom="sm"
>
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
</ActionIcon>
{/* Progress Bar - Desktop Only */}
<Box w={150} ml="md" visibleFrom="md">
<Slider
value={currentTime}
max={duration || 100}
onChange={handleSeek}
size="xs"
color="#0B4F78"
label={(value) => formatTime(value)}
/>
</Box>
</Group>
{/* Right Controls - Volume + Close */}
<Group gap={4} flex={1} justify="flex-end" wrap="nowrap">
{/* Volume Control - Tablet/Desktop */}
<Box
onMouseEnter={() => setShowVolume(true)}
onMouseLeave={() => setShowVolume(false)}
pos="relative"
visibleFrom="sm"
>
<ActionIcon
variant="subtle"
color={isMuted ? 'red' : 'gray'}
size="lg"
onClick={toggleMute}
>
{isMuted ? <IconVolumeOff size={18} /> : <IconVolume size={18} />}
</ActionIcon>
<Transition
mounted={showVolume}
transition="scale-y"
duration={200}
>
{(style) => (
<Paper
style={{
...style,
position: 'absolute',
bottom: '100%',
right: 0,
marginBottom: '10px',
padding: '10px',
zIndex: 1001,
}}
shadow="md"
withBorder
>
<Slider
value={isMuted ? 0 : volume}
max={100}
onChange={handleVolumeChange}
h={80}
color="#0B4F78"
size="sm"
/>
</Paper>
)}
</Transition>
</Box>
<ActionIcon
variant="subtle"
color="gray"
size={"md"}
onClick={handleMinimizePlayer}
>
<IconX size={18} />
</ActionIcon>
</Group>
</Flex>
{/* Progress Bar - Mobile (Base) */}
<Box px="xs" mt={4} hiddenFrom="md">
<Slider
value={currentTime}
max={duration || 100}
onChange={handleSeek}
size="xs"
color="#0B4F78"
label={(value) => formatTime(value)}
/>
</Box>
</Paper>
{/* Spacer to prevent content from being hidden behind player */}
<Box h={{ base: 70, sm: 80 }} />
</>
);
}

View File

@@ -1,6 +1,6 @@
'use client'; '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>
); );
}; };

View File

@@ -2,27 +2,31 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes' import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
import APBDesProgress from '@/app/darmasaba/(tambahan)/apbdes/lib/apbDesaProgress'
import { transformAPBDesData } from '@/app/darmasaba/(tambahan)/apbdes/lib/types'
import colors from '@/con/colors' import colors from '@/con/colors'
import { import {
ActionIcon,
BackgroundImage,
Box, Box,
Button, Button,
Divider, Center,
Group, Group,
Loader,
Select, Select,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text, Text,
Title Title,
} from '@mantine/core' } from '@mantine/core'
import { IconDownload } from '@tabler/icons-react'
import Link from 'next/link' import Link from 'next/link'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useProxy } from 'valtio/utils' import { useProxy } from 'valtio/utils'
import GrafikRealisasi from './lib/grafikRealisasi'
import PaguTable from './lib/paguTable'
import RealisasiTable from './lib/realisasiTable'
function Apbdes() { function Apbdes() {
const state = useProxy(apbdes) const state = useProxy(apbdes)
const [loading, setLoading] = useState(false)
const [selectedYear, setSelectedYear] = useState<string | null>(null) const [selectedYear, setSelectedYear] = useState<string | null>(null)
const textHeading = { const textHeading = {
@@ -33,9 +37,12 @@ function Apbdes() {
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
try { try {
setLoading(true)
await state.findMany.load() await state.findMany.load()
} catch (error) { } catch (error) {
console.error('Error loading data:', error) console.error('Error loading data:', error)
} finally {
setLoading(false)
} }
} }
loadData() loadData()
@@ -44,17 +51,18 @@ function Apbdes() {
const dataAPBDes = state.findMany.data || [] const dataAPBDes = state.findMany.data || []
const years = Array.from( const years = Array.from(
new Set( new Set(
dataAPBDes dataAPBDes
.map((item: any) => item?.tahun) .map((item: any) => item?.tahun)
.filter((tahun): tahun is number => typeof tahun === 'number') .filter((tahun): tahun is number => typeof tahun === 'number')
)
) )
.sort((a, b) => b - a) )
.map(year => ({ .sort((a, b) => b - a)
value: year.toString(), .map(year => ({
label: `Tahun ${year}`, value: year.toString(),
})) label: `Tahun ${year}`,
}))
useEffect(() => { useEffect(() => {
if (years.length > 0 && !selectedYear) { if (years.length > 0 && !selectedYear) {
@@ -63,15 +71,13 @@ function Apbdes() {
}, [years, selectedYear]) }, [years, selectedYear])
const currentApbdes = dataAPBDes.length > 0 const currentApbdes = dataAPBDes.length > 0
? dataAPBDes.find((item: any) => item?.tahun?.toString() === selectedYear) || dataAPBDes[0] ? transformAPBDesData(dataAPBDes.find(item => item?.tahun?.toString() === selectedYear) || dataAPBDes[0])
: null : null
// eslint-disable-next-line @typescript-eslint/no-unused-vars const data = (state.findMany.data || []).slice(0, 3)
const previewData = (state.findMany.data || []).slice(0, 3)
return ( return (
<Stack p="sm" gap="xl" bg={colors.Bg}> <Stack p="sm" gap="xl" bg={colors.Bg}>
<Divider c="gray.3" size="sm" />
{/* 📌 HEADING */} {/* 📌 HEADING */}
<Box mt="xl"> <Box mt="xl">
<Stack gap="sm"> <Stack gap="sm">
@@ -111,7 +117,7 @@ function Apbdes() {
</Group> </Group>
{/* COMBOBOX */} {/* COMBOBOX */}
<Box px={{ base: 'md', md: "sm" }}> <Box px={{ base: 'md', md: 100 }}>
<Select <Select
label={<Text fw={600} fz="sm">Pilih Tahun APBDes</Text>} label={<Text fw={600} fz="sm">Pilih Tahun APBDes</Text>}
placeholder="Pilih tahun" placeholder="Pilih tahun"
@@ -125,29 +131,23 @@ function Apbdes() {
/> />
</Box> </Box>
{/* Tabel & Grafik - Hanya tampilkan jika ada data */} {/* Progress */}
{currentApbdes && currentApbdes.items?.length > 0 ? ( {currentApbdes ? (
<Box px={{ base: 'md', md: 'sm' }} mb="xl"> <APBDesProgress apbdesData={currentApbdes} />
<SimpleGrid cols={{ base: 1, sm: 3 }}> ) : (
<PaguTable apbdesData={currentApbdes} /> <Box px={{ base: 'md', md: 100 }} py="md">
<RealisasiTable apbdesData={currentApbdes} />
<GrafikRealisasi apbdesData={currentApbdes} />
</SimpleGrid>
</Box>
) : currentApbdes ? (
<Box px={{ base: 'md', md: 100 }} py="md" mb="xl">
<Text fz="sm" c="dimmed" ta="center" lh={1.5}> <Text fz="sm" c="dimmed" ta="center" lh={1.5}>
Tidak ada data item untuk tahun yang dipilih. Tidak ada data APBDes untuk tahun yang dipilih.
</Text> </Text>
</Box> </Box>
) : null} )}
{/* GRID - Card Preview {/* GRID */}
{state.findMany.loading ? ( {loading ? (
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl"> <Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
<Loader size="lg" color="blue" /> <Loader size="lg" color="blue" />
</Center> </Center>
) : previewData.length === 0 ? ( ) : data.length === 0 ? (
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl"> <Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
<Stack align="center" gap="xs"> <Stack align="center" gap="xs">
<Text fz="lg" c="dimmed" lh={1.4}> <Text fz="lg" c="dimmed" lh={1.4}>
@@ -165,18 +165,14 @@ function Apbdes() {
spacing="lg" spacing="lg"
pb="xl" pb="xl"
> >
{previewData.map((v, k) => ( {data.map((v, k) => (
<Box <BackgroundImage
key={k} key={k}
src={v.image?.link || ''}
h={360}
radius="xl"
pos="relative" pos="relative"
style={{ style={{ overflow: 'hidden' }}
backgroundImage: `url(${v.image?.link || ''})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
borderRadius: 16,
height: 360,
overflow: 'hidden',
}}
> >
<Box pos="absolute" inset={0} bg="rgba(0,0,0,0.45)" style={{ borderRadius: 16 }} /> <Box pos="absolute" inset={0} bg="rgba(0,0,0,0.45)" style={{ borderRadius: 16 }} />
@@ -189,7 +185,7 @@ function Apbdes() {
lh={1.35} lh={1.35}
lineClamp={2} lineClamp={2}
> >
{v.name || `APBDes Tahun ${v.tahun}`} {v.name}
</Text> </Text>
<Text <Text
@@ -200,7 +196,7 @@ function Apbdes() {
lh={1} lh={1}
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }} style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
> >
{v.jumlah || '-'} {v.jumlah}
</Text> </Text>
<Center> <Center>
@@ -216,10 +212,10 @@ function Apbdes() {
</ActionIcon> </ActionIcon>
</Center> </Center>
</Stack> </Stack>
</Box> </BackgroundImage>
))} ))}
</SimpleGrid> </SimpleGrid>
)} */} )}
</Stack> </Stack>
) )
} }

View File

@@ -1,125 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Title, Progress, Stack, Text, Group, Box } from '@mantine/core';
interface APBDesItem {
tipe: string | null;
anggaran: number;
realisasi?: number;
totalRealisasi?: number;
}
interface SummaryProps {
title: string;
data: APBDesItem[];
}
function Summary({ title, data }: SummaryProps) {
if (!data || data.length === 0) return null;
const totalAnggaran = data.reduce((s: number, i: APBDesItem) => s + i.anggaran, 0);
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
const totalRealisasi = data.reduce(
(s: number, i: APBDesItem) => s + (i.realisasi || i.totalRealisasi || 0),
0
);
const persen =
totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
// Format angka ke dalam format Rupiah
const formatRupiah = (angka: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(angka);
};
// Tentukan warna berdasarkan persentase
const getProgressColor = (persen: number) => {
if (persen >= 100) return 'teal';
if (persen >= 80) return 'blue';
if (persen >= 60) return 'yellow';
return 'red';
};
return (
<Box>
<Group justify="space-between" mb="xs">
<Text fw={600} fz="md">{title}</Text>
<Text fw={700} fz="lg" c={getProgressColor(persen)}>
{persen.toFixed(2)}%
</Text>
</Group>
<Text fz="sm" c="dimmed" mb="xs">
Realisasi: {formatRupiah(totalRealisasi)} / Anggaran: {formatRupiah(totalAnggaran)}
</Text>
<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
</Text>
)}
</Box>
);
}
export default function GrafikRealisasi({
apbdesData,
}: {
apbdesData: {
tahun?: number | null;
items?: APBDesItem[] | null;
[key: string]: any;
};
}) {
const items = apbdesData?.items || [];
const tahun = apbdesData?.tahun || new Date().getFullYear();
const pendapatan = items.filter((i: APBDesItem) => i.tipe === 'pendapatan');
const belanja = items.filter((i: APBDesItem) => i.tipe === 'belanja');
const pembiayaan = items.filter((i: APBDesItem) => i.tipe === 'pembiayaan');
return (
<Paper withBorder p="md" radius="md">
<Title order={5} mb="md">
GRAFIK REALISASI APBDes {tahun}
</Title>
<Stack gap="lg" mb="lg">
<Summary title="💰 Pendapatan" data={pendapatan} />
<Summary title="💸 Belanja" data={belanja} />
<Summary title="📊 Pembiayaan" data={pembiayaan} />
</Stack>
</Paper>
);
}

View File

@@ -1,66 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Table, Title, Text } from '@mantine/core';
function Section({ title, data }: any) {
if (!data || data.length === 0) return null;
return (
<>
<Table.Tr bg="gray.0">
<Table.Td colSpan={2}>
<Text fw={700} fz={{ base: 'xs', sm: 'sm' }}>{title}</Text>
</Table.Td>
</Table.Tr>
{data.map((item: any) => (
<Table.Tr key={item.id}>
<Table.Td>
<Text fz={{ base: 'xs', sm: 'sm' }} lineClamp={2}>
{item.kode} - {item.uraian}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text fz={{ base: 'xs', sm: 'sm' }} fw={500} style={{ whiteSpace: 'nowrap' }}>
Rp {item.anggaran.toLocaleString('id-ID')}
</Text>
</Table.Td>
</Table.Tr>
))}
</>
);
}
export default function PaguTable({ apbdesData }: any) {
const items = apbdesData.items || [];
const title =
apbdesData.tahun
? `PAGU APBDes Tahun ${apbdesData.tahun}`
: 'PAGU 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');
return (
<Paper withBorder p={{ base: 'sm', sm: 'md' }} radius="md">
<Title order={5} mb="md" fz={{ base: 'sm', sm: 'md' }}>{title}</Title>
<Table.ScrollContainer minWidth={280}>
<Table verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th fz={{ base: 'xs', sm: 'sm' }}>Uraian</Table.Th>
<Table.Th ta="right" fz={{ base: 'xs', sm: 'sm' }}>Anggaran (Rp)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Section title="1) PENDAPATAN" data={pendapatan} />
<Section title="2) BELANJA" data={belanja} />
<Section title="3) PEMBIAYAAN" data={pembiayaan} />
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Paper>
);
}

View File

@@ -1,92 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Table, Title, Badge, Text } from '@mantine/core';
export default function RealisasiTable({ apbdesData }: any) {
const items = apbdesData.items || [];
const title =
apbdesData.tahun
? `REALISASI APBDes Tahun ${apbdesData.tahun}`
: 'REALISASI APBDes';
// 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={{ base: 'sm', sm: 'md' }} radius="md">
<Title order={5} mb="md" fz={{ base: 'sm', sm: 'md' }}>{title}</Title>
{allRealisasiRows.length === 0 ? (
<Text fz="sm" c="dimmed" ta="center" py="md">
Belum ada data realisasi
</Text>
) : (
<Table.ScrollContainer minWidth={300}>
<Table verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th fz={{ base: 'xs', sm: 'sm' }}>Uraian</Table.Th>
<Table.Th ta="right" fz={{ base: 'xs', sm: 'sm' }}>Realisasi (Rp)</Table.Th>
<Table.Th ta="center" fz={{ base: 'xs', sm: 'sm' }}>%</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{allRealisasiRows.map(({ realisasi, parentItem }) => {
const persentase = parentItem.anggaran > 0
? (realisasi.jumlah / parentItem.anggaran) * 100
: 0;
return (
<Table.Tr key={realisasi.id}>
<Table.Td>
<Text fz={{ base: 'xs', sm: 'sm' }} lineClamp={2}>
{realisasi.kode || '-'} - {realisasi.keterangan || '-'}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text fw={600} c="blue" fz={{ base: 'xs', sm: 'sm' }} style={{ whiteSpace: 'nowrap' }}>
{formatRupiah(realisasi.jumlah || 0)}
</Text>
</Table.Td>
<Table.Td ta="center">
<Badge
size="sm"
variant="light"
color={
persentase >= 100
? 'teal'
: persentase >= 60
? 'yellow'
: 'red'
}
>
{persentase.toFixed(1)}%
</Badge>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Paper>
);
}

View File

@@ -10,7 +10,8 @@ import {
SimpleGrid, SimpleGrid,
Skeleton, Skeleton,
Stack, Stack,
Text Text,
useMantineColorScheme
} from "@mantine/core"; } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks"; import { useShallowEffect } from "@mantine/hooks";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
@@ -23,6 +24,8 @@ type ProgramInovasiItem = Prisma.ProgramInovasiGetPayload<{ include: { image: tr
function ModuleItem({ data }: { data: ProgramInovasiItem }) { function ModuleItem({ data }: { data: ProgramInovasiItem }) {
const router = useTransitionRouter(); const router = useTransitionRouter();
const { colorScheme } = useMantineColorScheme();
const isDark = colorScheme === "dark";
return ( return (
<motion.div whileHover={{ scale: 1.03 }}> <motion.div whileHover={{ scale: 1.03 }}>
@@ -34,7 +37,7 @@ function ModuleItem({ data }: { data: ProgramInovasiItem }) {
role="button" role="button"
tabIndex={0} tabIndex={0}
className="cursor-pointer transition-all" className="cursor-pointer transition-all"
bg="white" bg={isDark ? "dark.6" : "white"}
> >
<Center h={160}> <Center h={160}>
{data.image?.link ? ( {data.image?.link ? (

View File

@@ -1,28 +1,25 @@
"use client";
import colors from "@/con/colors"; import colors from "@/con/colors";
import { Box, Space, Stack } from "@mantine/core"; import { Box, Space, Stack } from "@mantine/core";
import { Navbar } from "@/app/darmasaba/_com/Navbar"; import { Navbar } from "@/app/darmasaba/_com/Navbar";
import Footer from "./_com/Footer"; import Footer from "./_com/Footer";
import FixedPlayerBar from "./_com/FixedPlayerBar";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<Stack gap={0} bg={colors.grey[1]}> <Stack gap={0} bg={colors.grey[1]}>
<Navbar /> <Navbar />
<Space h={{ <Space h={{
base: "3.9rem", base: "3.9rem",
md: "2.5rem" md: "2.5rem"
}} /> }} />
<Box style={{ <Box style={{
overflow: "scroll" overflow: "scroll"
}}> }}>
{children} {children}
</Box> </Box>
<Footer /> <Footer />
<FixedPlayerBar /> </Stack>
</Stack>
) )
} }

View File

@@ -150,13 +150,13 @@ export default function Page() {
<Box id="page-root"> <Box id="page-root">
<Stack bg={colors.grey[1]} gap={0}> <Stack bg={colors.grey[1]} gap={0}>
<LandingPage /> <LandingPage />
<Apbdes />
<Penghargaan /> <Penghargaan />
<Layanan /> <Layanan />
<Potensi /> <Potensi />
<DesaAntiKorupsi /> <DesaAntiKorupsi />
<Kepuasan /> <Kepuasan />
<SDGS /> <SDGS />
<Apbdes />
<Prestasi /> <Prestasi />
<ScrollToTopButton /> <ScrollToTopButton />
<NewsReaderLanding /> <NewsReaderLanding />

View File

@@ -1,22 +1,17 @@
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 { import {
ColorSchemeScript, ColorSchemeScript,
MantineProvider, MantineProvider,
createTheme, createTheme,
mantineHtmlProps, mantineHtmlProps,
// mantineHtmlProps,
} from "@mantine/core"; } from "@mantine/core";
import { Metadata, Viewport } from "next"; import { Metadata, Viewport } from "next";
import { ViewTransitions } from "next-view-transitions"; import { ViewTransitions } from "next-view-transitions";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
// Force dynamic rendering untuk menghindari error prerendering
export const dynamic = 'force-dynamic';
// ✅ Pisahkan viewport ke export tersendiri // ✅ Pisahkan viewport ke export tersendiri
export const viewport: Viewport = { export const viewport: Viewport = {
width: "device-width", width: "device-width",
@@ -103,20 +98,18 @@ export default function RootLayout({
<html lang="id" {...mantineHtmlProps}> <html lang="id" {...mantineHtmlProps}>
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<ColorSchemeScript defaultColorScheme="light" /> <ColorSchemeScript />
</head> </head>
<body> <body>
<MusicProvider> <MantineProvider theme={theme}>
<MantineProvider theme={theme} defaultColorScheme="light"> {children}
{children} <LoadDataFirstClient />
<LoadDataFirstClient /> <ToastContainer
<ToastContainer position="bottom-center"
position="bottom-center" hideProgressBar
hideProgressBar style={{ zIndex: 9999 }}
style={{ zIndex: 9999 }} />
/> </MantineProvider>
</MantineProvider>
</MusicProvider>
</body> </body>
</html> </html>
</ViewTransitions> </ViewTransitions>

View File

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

View File

@@ -2,7 +2,7 @@
import { useDarkMode } from '@/state/darkModeStore'; import { useDarkMode } from '@/state/darkModeStore';
import { themeTokens } from '@/utils/themeTokens'; import { themeTokens } from '@/utils/themeTokens';
import { Box, BoxProps, Divider, DividerProps, Paper } from '@mantine/core'; import { Paper, Box, BoxProps, Divider, DividerProps } from '@mantine/core';
import React from 'react'; import React from 'react';
/** /**
@@ -22,6 +22,7 @@ import React from 'react';
// ============================================================================ // ============================================================================
// Unified Card Component // Unified Card Component
* ============================================================================
interface UnifiedCardProps extends BoxProps { interface UnifiedCardProps extends BoxProps {
withBorder?: boolean; withBorder?: boolean;
@@ -62,18 +63,12 @@ export function UnifiedCard({
} }
}; };
const getShadow = () => {
if (shadow === 'none') return 'none';
return tokens.shadows[shadow];
};
return ( return (
<Paper <Paper
withBorder={withBorder} withBorder={withBorder}
bg={tokens.colors.bg.card} bg={tokens.colors.bg.card}
p={getPadding()} p={getPadding()}
radius={tokens.radius.lg} // 12-16px sesuai spec radius={tokens.radius.lg} // 12-16px sesuai spec
shadow={getShadow()}
style={{ style={{
borderColor: tokens.colors.border.default, borderColor: tokens.colors.border.default,
transition: hoverable transition: hoverable

View File

@@ -5,8 +5,6 @@ import { themeTokens, getResponsiveFz } from '@/utils/themeTokens';
import { Text, Title, Box, BoxProps } from '@mantine/core'; import { Text, Title, Box, BoxProps } from '@mantine/core';
import React from 'react'; import React from 'react';
type TextTruncate = 'end' | 'start' | boolean;
/** /**
* Unified Typography Components * Unified Typography Components
* *
@@ -75,7 +73,7 @@ export function UnifiedTitle({
const getColor = () => { const getColor = () => {
if (color === 'primary') return tokens.colors.text.primary; if (color === 'primary') return tokens.colors.text.primary;
if (color === 'secondary') return tokens.colors.text.secondary; if (color === 'secondary') return tokens.colors.text.secondary;
if (color === 'brand') return tokens.colors.text.brand; if (color === 'brand') return tokens.colors.brand;
return color; return color;
}; };
@@ -111,14 +109,8 @@ interface UnifiedTextProps {
align?: 'left' | 'center' | 'right'; align?: 'left' | 'center' | 'right';
color?: 'primary' | 'secondary' | 'tertiary' | 'muted' | 'brand' | 'link' | string; color?: 'primary' | 'secondary' | 'tertiary' | 'muted' | 'brand' | 'link' | string;
lineClamp?: number; lineClamp?: number;
truncate?: TextTruncate; truncate?: 'start' | 'end' | 'middle' | boolean;
span?: boolean; span?: boolean;
mt?: string;
mb?: string;
ml?: string;
mr?: string;
mx?: string;
my?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
} }
@@ -131,12 +123,6 @@ export function UnifiedText({
lineClamp, lineClamp,
truncate, truncate,
span = false, span = false,
mt,
mb,
ml,
mr,
mx,
my,
style, style,
}: UnifiedTextProps) { }: UnifiedTextProps) {
const { isDark } = useDarkMode(); const { isDark } = useDarkMode();
@@ -177,7 +163,7 @@ export function UnifiedText({
case 'muted': case 'muted':
return tokens.colors.text.muted; return tokens.colors.text.muted;
case 'brand': case 'brand':
return tokens.colors.text.brand; return tokens.colors.brand;
case 'link': case 'link':
return tokens.colors.text.link; return tokens.colors.text.link;
default: default:
@@ -191,7 +177,7 @@ export function UnifiedText({
if (span) { if (span) {
return ( return (
<Text <Text.Span
ta={align} ta={align}
fz={typo.fz} fz={typo.fz}
fw={fw} fw={fw}
@@ -199,16 +185,10 @@ export function UnifiedText({
c={textColor} c={textColor}
lineClamp={lineClamp} lineClamp={lineClamp}
truncate={truncate} truncate={truncate}
mt={mt}
mb={mb}
ml={ml}
mr={mr}
mx={mx}
my={my}
style={style} style={style}
> >
{children} {children}
</Text> </Text.Span>
); );
} }
@@ -221,12 +201,6 @@ export function UnifiedText({
c={textColor} c={textColor}
lineClamp={lineClamp} lineClamp={lineClamp}
truncate={truncate} truncate={truncate}
mt={mt}
mb={mb}
ml={ml}
mr={mr}
mx={mx}
my={my}
style={style} style={style}
> >
{children} {children}

View File

@@ -1,83 +0,0 @@
/**
* Authentication helper untuk API endpoints
*
* Usage:
* import { requireAuth } from "@/lib/api-auth";
*
* export default async function myEndpoint() {
* const authResult = await requireAuth();
* if (!authResult.authenticated) {
* return authResult.response;
* }
* // Lanjut proses dengan authResult.user
* }
*/
import { getSession, SessionData } from "@/lib/session";
export type AuthResult =
| { authenticated: true; user: NonNullable<SessionData["user"]> }
| { authenticated: false; response: Response };
export async function requireAuth(): Promise<AuthResult> {
try {
// Cek session dari cookies
const session = await getSession();
if (!session || !session.user) {
return {
authenticated: false,
response: new Response(JSON.stringify({
success: false,
message: "Unauthorized - Silakan login terlebih dahulu"
}), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
};
}
// Check jika user masih aktif
if (!session.user.isActive) {
return {
authenticated: false,
response: new Response(JSON.stringify({
success: false,
message: "Akun Anda tidak aktif. Hubungi administrator."
}), {
status: 403,
headers: { 'Content-Type': 'application/json' }
})
};
}
return {
authenticated: true,
user: session.user
};
} catch {
return {
authenticated: false,
response: new Response(JSON.stringify({
success: false,
message: "Authentication error"
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
};
}
}
/**
* Optional auth - tidak error jika tidak authenticated
* Berguna untuk endpoint yang bisa diakses public atau private
*/
export async function optionalAuth(): Promise<NonNullable<SessionData["user"]> | null> {
try {
const session = await getSession();
return session?.user || null;
} catch {
return null;
}
}

View File

@@ -1,68 +0,0 @@
/**
* Session helper menggunakan iron-session
*
* Usage:
* import { getSession } from "@/lib/session";
*
* const session = await getSession();
* if (session?.user) {
* // User authenticated
* }
*/
import { getIronSession } from 'iron-session';
import { cookies } from 'next/headers';
export type SessionData = {
user?: {
id: string;
name: string;
roleId: number;
menuIds?: string[] | null;
isActive?: boolean;
};
};
export type Session = SessionData & {
save: () => Promise<void>;
destroy: () => Promise<void>;
};
const SESSION_OPTIONS = {
cookieName: 'desa-session',
password: process.env.SESSION_PASSWORD || 'default-password-change-in-production',
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: 'lax' as const,
maxAge: 60 * 60 * 24 * 7, // 7 days
},
};
export async function getSession(): Promise<SessionData | null> {
try {
const cookieStore = await cookies();
const session = await getIronSession<SessionData>(
cookieStore,
SESSION_OPTIONS
);
return session;
} catch (error) {
console.error('Session error:', error);
return null;
}
}
export async function destroySession(): Promise<void> {
try {
const cookieStore = await cookies();
const session = await getIronSession<SessionData>(
cookieStore,
SESSION_OPTIONS
);
await session.destroy();
} catch (error) {
console.error('Destroy session error:', error);
}
}

View File

@@ -21,7 +21,7 @@ import { proxy, useSnapshot } from 'valtio';
const STORAGE_KEY = 'darmasaba-admin-dark-mode'; const STORAGE_KEY = 'darmasaba-admin-dark-mode';
// Initialize from localStorage or default to light mode // Initialize from localStorage or system preference
const getInitialDarkMode = (): boolean => { const getInitialDarkMode = (): boolean => {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
@@ -30,9 +30,8 @@ const getInitialDarkMode = (): boolean => {
return stored === 'true'; return stored === 'true';
} }
// Default to light mode for first-time users // Fallback to system preference
// System preference is NOT used as default to ensure consistent UX return window.matchMedia('(prefers-color-scheme: dark)').matches;
return false;
}; };
class DarkModeStore { class DarkModeStore {

View File

@@ -223,10 +223,6 @@ export const themeTokens = (isDark: boolean = false): ThemeTokens => {
hoverSoft: 'rgba(25, 113, 194, 0.03)', hoverSoft: 'rgba(25, 113, 194, 0.03)',
hoverMedium: 'rgba(25, 113, 194, 0.05)', hoverMedium: 'rgba(25, 113, 194, 0.05)',
activeAccent: 'rgba(25, 113, 194, 0.1)', activeAccent: 'rgba(25, 113, 194, 0.1)',
success: '#22c55e',
warning: '#facc15',
error: '#ef4444',
info: '#38bdf8',
}; };
const current = isDark ? darkColors : lightColors; const current = isDark ? darkColors : lightColors;
@@ -385,7 +381,3 @@ export const getActiveStateStyles = (isActive: boolean, isDark: boolean = false)
}, },
}; };
}; };