Compare commits
105 Commits
fix/admin/
...
fix-respon
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bc546e985 | |||
| 4fb522f88f | |||
| 85332a8225 | |||
| 3fe2a5ccab | |||
| 363bfa65fb | |||
| dccf590cbf | |||
| f076b81d14 | |||
| b5ea3216e0 | |||
| 64b116588b | |||
| 63161e1a39 | |||
| 8b8c65dd1e | |||
| 159fb3cec6 | |||
| 4821934224 | |||
| ee39b88b00 | |||
| ce46d3b5f7 | |||
| 144ac37e12 | |||
| f90477ed63 | |||
| 4a7811e06f | |||
| f63aaf916d | |||
| 3803c79c95 | |||
| 2d901912ea | |||
| a791efe76c | |||
| e9f7bc2043 | |||
| 6712da9ac2 | |||
| ac11a9367c | |||
| 67e5ceb254 | |||
| 65942ac9d2 | |||
| e0436cc384 | |||
| 63682e47b6 | |||
| f4705690a9 | |||
| 239771a714 | |||
| 03451195c8 | |||
| 597af7e716 | |||
| 0a8a026b94 | |||
| a5bd91b580 | |||
| ae3187804e | |||
| 91e32f3f1c | |||
| 4d03908f23 | |||
| 0563f9664f | |||
| 961cc32057 | |||
| fe7672e09f | |||
| 341ff5779f | |||
| 69f7b4c162 | |||
| 409ad4f1a2 | |||
| 55ea3c473a | |||
| 0160fa636d | |||
| a152eaf984 | |||
| 3684e83187 | |||
| 223b85a714 | |||
| 77c54b5c8a | |||
| f1729151b3 | |||
| 8e8c133eea | |||
| 1e7acac193 | |||
| bb80b0ecc1 | |||
| 42dcbcfb22 | |||
| 22de1aa1f3 | |||
| 1b59d6bf09 | |||
| eb1ad54db6 | |||
| 21ec3ad1c1 | |||
| 3a115908c4 | |||
| 5ff791642c | |||
| b803c7a90c | |||
| fb2fe67c23 | |||
| 51460558d4 | |||
| d105ceeb6b | |||
| c865aee766 | |||
| 273dfdfd09 | |||
| 1d1d8e50dc | |||
| 092afe67d2 | |||
| 2d9170705d | |||
| fdf9a951a4 | |||
| ca74029688 | |||
| 1a8fc1a670 | |||
| 19235f0791 | |||
| 61de7d8d33 | |||
| 8fb85ce56c | |||
| 1f98b6993d | |||
| f3a10d63d1 | |||
| 7a42bec63b | |||
| 44c421129e | |||
| ddff427926 | |||
| 00c8caade4 | |||
| 0209f49449 | |||
| 344c6ada6d | |||
| 11acd04419 | |||
| 8d49213b68 | |||
| 96911e3cf1 | |||
| 9950c28b9b | |||
| fa0f3538d1 | |||
| 2778f53aff | |||
| 37ac91d4f4 | |||
| 217f4a9a3b | |||
| 5d6a7437ed | |||
| 752a6cabee | |||
| 134ddc6154 | |||
| 28979c6b49 | |||
| b2066caa13 | |||
| 023c77d636 | |||
| 9bf3ec72cf | |||
| f359f5b1ce | |||
| 1c1e8fb190 | |||
| 54f83da3b8 | |||
| f8985c550f | |||
| e3d909e760 | |||
| 16a8df50c1 |
73
AUDIT_REPORT.md
Normal file
73
AUDIT_REPORT.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Engineering Audit Report: Desa Darmasaba
|
||||||
|
**Status:** Production Readiness Review (Critical)
|
||||||
|
**Auditor:** Staff Technical Architect
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Executive Summary & Scores
|
||||||
|
|
||||||
|
| Category | Score | Status |
|
||||||
|
| :--- | :---: | :--- |
|
||||||
|
| **Project Architecture** | 3/10 | 🔴 Critical Failure |
|
||||||
|
| **Code Quality** | 4/10 | 🟠 Poor |
|
||||||
|
| **Performance** | 5/10 | 🟡 Mediocre |
|
||||||
|
| **Security** | 5/10 | 🟠 Risk Detected |
|
||||||
|
| **Production Readiness** | 2/10 | 🔴 Not Ready |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 1. Project Architecture
|
||||||
|
The project suffers from a **"Frankenstein Architecture"**. It attempts to run a full Elysia.js instance inside a Next.js Catch-All route.
|
||||||
|
- **Fractured Backend:** Logic is split between standard Next.js routes (`/api/auth`) and embedded Elysia modules.
|
||||||
|
- **Stateful Dependency:** Reliance on local filesystem (`WIBU_UPLOAD_DIR`) makes the application impossible to deploy on modern serverless platforms like Vercel.
|
||||||
|
- **Polluted Namespace:** Routing tree contains "test/coba" folders (`src/app/coba`, `src/app/percobaan`) that would be accessible in production.
|
||||||
|
|
||||||
|
## ⚛️ 2. Frontend Engineering (React / Next.js)
|
||||||
|
- **State Management Chaos:** Simultaneous use of `Valtio`, `Jotai`, `React Context`, and `localStorage`.
|
||||||
|
- **Tight Coupling:** Public pages (`/darmasaba`) import state directly from Admin internal states (`/admin/(dashboard)/_state`).
|
||||||
|
- **Heavy Client-Side Logic:** Logic that belongs in Server Actions or Hooks is embedded in presentational components (e.g., `Footer.tsx`).
|
||||||
|
|
||||||
|
## 📡 3. Backend / API Design
|
||||||
|
- **Framework Overhead:** Running Elysia inside Next.js adds unnecessary cold-boot overhead and complexity.
|
||||||
|
- **Weak Validation:** Widespread use of `as Type` casting in API handlers instead of runtime validation (Zod/Schema).
|
||||||
|
- **Service Integration:** OTP codes are sent via external `GET` requests with sensitive data in the query string—a major logging risk.
|
||||||
|
|
||||||
|
## 🗄️ 4. Database & Data Modeling (Prisma)
|
||||||
|
- **Schema Over-Normalization:** ~2000 lines of schema. Every minor content type (e.g., `LambangDesa`) is a separate table instead of a unified CMS model.
|
||||||
|
- **Polymorphic Monolith:** `FileStorage` is a "god table" with optional relations to ~40 other tables, creating a massive bottleneck and data integrity risk.
|
||||||
|
- **Connection Mismanagement:** Manual `prisma.$disconnect()` in API routes kills connection pooling performance.
|
||||||
|
|
||||||
|
## 🚀 5. Performance Engineering
|
||||||
|
- **Bypassing Optimization:** Custom `/api/utils/img` endpoint bypasses `next/image` optimization, serving uncompressed assets.
|
||||||
|
- **Aggressive Polling:** Client-side 30s polling for notifications is battery-draining and inefficient compared to SSE or SWR.
|
||||||
|
|
||||||
|
## 🔒 6. Security Audit
|
||||||
|
- **Insecure OTP Delivery:** Credentials passed as URL parameters to the WhatsApp service.
|
||||||
|
- **File Upload Risks:** Potential for Arbitrary File Upload due to direct local filesystem writes without rigorous sanitization.
|
||||||
|
|
||||||
|
## 🧹 7. Code Quality
|
||||||
|
- **Inconsistency:** Mixed English/Indonesian naming (e.g., `nomor` vs `createdAt`).
|
||||||
|
- **Artifacts:** Root directory is littered with scratch files: `xcoba.ts`, `xx.ts`, `test.txt`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚩 Top 10 Critical Problems
|
||||||
|
1. **Architectural Fracture:** Embedding Elysia inside Next.js creates a "split-brain" system.
|
||||||
|
2. **Serverless Incompatibility:** Dependency on local disk storage for uploads.
|
||||||
|
3. **Database Bloat:** Over-complicated schema with a fragile `FileStorage` monolith.
|
||||||
|
4. **State Fragmentation:** Mixed usage of Jotai and Valtio without a clear standard.
|
||||||
|
5. **Credential Leakage:** OTP codes sent via GET query parameters.
|
||||||
|
6. **Poor Cleanup:** Trial/Test folders and files committed to the production source.
|
||||||
|
7. **Asset Performance:** Bypassing Next.js image optimization.
|
||||||
|
8. **Coupling:** High dependency between public UI and internal Admin state.
|
||||||
|
9. **Type Safety:** Manual casting in APIs instead of runtime validation.
|
||||||
|
10. **Connection Pooling:** Inefficient Prisma connection management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Tech Lead Refactoring Priorities
|
||||||
|
1. **Unify the API:** Decommission the Elysia wrapper. Port all logic to standard Next.js Route Handlers with Zod validation.
|
||||||
|
2. **Stateless Storage:** Implement an S3-compatible adapter for all file uploads. Remove `fs` usage.
|
||||||
|
3. **Schema Consolidation:** Refactor the schema to use generic content models where possible.
|
||||||
|
4. **Standardize State:** Choose one global state manager and migrate all components.
|
||||||
|
5. **Project Sanitization:** Delete all `coba`, `percobaan`, and scratch files (`xcoba.ts`, etc.).
|
||||||
@@ -19,7 +19,6 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -26,7 +26,24 @@ 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) {
|
||||||
@@ -44,26 +61,32 @@ export async function seedBerita() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.berita.upsert({
|
try {
|
||||||
where: { id: b.id },
|
await prisma.berita.upsert({
|
||||||
update: {
|
where: { id: b.id },
|
||||||
judul: b.judul,
|
update: {
|
||||||
deskripsi: b.deskripsi,
|
judul: b.judul,
|
||||||
content: b.content,
|
deskripsi: b.deskripsi,
|
||||||
kategoriBeritaId: b.kategoriBeritaId,
|
content: b.content,
|
||||||
imageId,
|
kategoriBeritaId: b.kategoriBeritaId,
|
||||||
},
|
imageId,
|
||||||
create: {
|
},
|
||||||
id: b.id,
|
create: {
|
||||||
judul: b.judul,
|
id: b.id,
|
||||||
deskripsi: b.deskripsi,
|
judul: b.judul,
|
||||||
content: b.content,
|
deskripsi: b.deskripsi,
|
||||||
kategoriBeritaId: b.kategoriBeritaId,
|
content: b.content,
|
||||||
imageId,
|
kategoriBeritaId: b.kategoriBeritaId,
|
||||||
},
|
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");
|
||||||
|
|||||||
170
prisma/migrations/20260225082505_deploy/migration.sql
Normal file
170
prisma/migrations/20260225082505_deploy/migration.sql
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/*
|
||||||
|
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;
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (e.g., Git)
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ model FileStorage {
|
|||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
link String
|
link String
|
||||||
category String // "image" / "document" / "other"
|
category String // "image" / "document" / "audio" / "other"
|
||||||
Berita Berita[]
|
Berita Berita[] @relation("BeritaFeaturedImage")
|
||||||
|
BeritaImages Berita[] @relation("BeritaImages")
|
||||||
PotensiDesa PotensiDesa[]
|
PotensiDesa PotensiDesa[]
|
||||||
Posyandu Posyandu[]
|
Posyandu Posyandu[]
|
||||||
StrukturPPID StrukturPPID[]
|
StrukturPPID StrukturPPID[]
|
||||||
@@ -102,6 +103,9 @@ model FileStorage {
|
|||||||
|
|
||||||
ArtikelKesehatan ArtikelKesehatan[]
|
ArtikelKesehatan ArtikelKesehatan[]
|
||||||
StrukturBumDes StrukturBumDes[]
|
StrukturBumDes StrukturBumDes[]
|
||||||
|
|
||||||
|
MusikDesaAudio MusikDesa[] @relation("MusikAudioFile")
|
||||||
|
MusikDesaCover MusikDesa[] @relation("MusikCoverImage")
|
||||||
}
|
}
|
||||||
|
|
||||||
//========================================= MENU LANDING PAGE ========================================= //
|
//========================================= MENU LANDING PAGE ========================================= //
|
||||||
@@ -205,16 +209,22 @@ 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)
|
||||||
realisasi Float
|
tipe String? // "pendapatan" | "belanja" | "pembiayaan" | null
|
||||||
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?
|
||||||
@@ -225,6 +235,28 @@ 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())
|
||||||
@@ -609,15 +641,19 @@ model Berita {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
judul String
|
judul String
|
||||||
deskripsi String
|
deskripsi String
|
||||||
image FileStorage? @relation(fields: [imageId], references: [id])
|
image FileStorage? @relation("BeritaFeaturedImage", 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 {
|
||||||
@@ -633,25 +669,25 @@ model KategoriBerita {
|
|||||||
// ========================================= POTENSI DESA ========================================= //
|
// ========================================= POTENSI DESA ========================================= //
|
||||||
model PotensiDesa {
|
model PotensiDesa {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String @unique @db.VarChar(255)
|
||||||
deskripsi String
|
deskripsi String @db.Text
|
||||||
kategori KategoriPotensi? @relation(fields: [kategoriId], references: [id])
|
kategori KategoriPotensi? @relation(fields: [kategoriId], references: [id])
|
||||||
kategoriId String?
|
kategoriId String @db.VarChar(36)
|
||||||
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 @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
model KategoriPotensi {
|
model KategoriPotensi {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
nama String
|
nama String @unique @db.VarChar(100)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
PotensiDesa PotensiDesa[]
|
PotensiDesa PotensiDesa[]
|
||||||
}
|
}
|
||||||
@@ -2263,3 +2299,25 @@ 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])
|
||||||
|
}
|
||||||
|
|||||||
BIN
public/mp3-logo.png
Normal file
BIN
public/mp3-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
@@ -12,6 +12,8 @@ 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)
|
||||||
@@ -21,6 +23,8 @@ const defaultForm = {
|
|||||||
imageId: "",
|
imageId: "",
|
||||||
content: "",
|
content: "",
|
||||||
kategoriBeritaId: "",
|
kategoriBeritaId: "",
|
||||||
|
imageIds: [] as string[],
|
||||||
|
linkVideo: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4. Berita proxy
|
// 4. Berita proxy
|
||||||
@@ -62,14 +66,7 @@ const berita = proxy({
|
|||||||
// State untuk berita utama (hanya 1)
|
// State untuk berita utama (hanya 1)
|
||||||
|
|
||||||
findMany: {
|
findMany: {
|
||||||
data: null as
|
data: null as any[] | null,
|
||||||
| Prisma.BeritaGetPayload<{
|
|
||||||
include: {
|
|
||||||
image: true;
|
|
||||||
kategoriBerita: true;
|
|
||||||
};
|
|
||||||
}>[]
|
|
||||||
| null,
|
|
||||||
page: 1,
|
page: 1,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -79,14 +76,14 @@ const berita = proxy({
|
|||||||
berita.findMany.loading = true;
|
berita.findMany.loading = true;
|
||||||
berita.findMany.page = page;
|
berita.findMany.page = page;
|
||||||
berita.findMany.search = search;
|
berita.findMany.search = search;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const query: any = { page, limit };
|
const query: any = { page, limit };
|
||||||
if (search) query.search = search;
|
if (search) query.search = search;
|
||||||
if (kategori) query.kategori = kategori;
|
if (kategori) query.kategori = kategori;
|
||||||
|
|
||||||
const res = await ApiFetch.api.desa.berita["find-many"].get({ query });
|
const res = await ApiFetch.api.desa.berita["find-many"].get({ query });
|
||||||
|
|
||||||
if (res.status === 200 && res.data?.success) {
|
if (res.status === 200 && res.data?.success) {
|
||||||
berita.findMany.data = res.data.data ?? [];
|
berita.findMany.data = res.data.data ?? [];
|
||||||
berita.findMany.totalPages = res.data.totalPages ?? 1;
|
berita.findMany.totalPages = res.data.totalPages ?? 1;
|
||||||
@@ -103,18 +100,19 @@ const berita = proxy({
|
|||||||
const elapsed = Date.now() - startTime;
|
const elapsed = Date.now() - startTime;
|
||||||
const minDelay = 300;
|
const minDelay = 300;
|
||||||
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
|
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
berita.findMany.loading = false;
|
berita.findMany.loading = false;
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
findUnique: {
|
findUnique: {
|
||||||
data: null as Prisma.BeritaGetPayload<{
|
data: null as Prisma.BeritaGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
image: true;
|
image: true;
|
||||||
|
images: true;
|
||||||
kategoriBerita: true;
|
kategoriBerita: true;
|
||||||
};
|
};
|
||||||
}> | null,
|
}> | null,
|
||||||
@@ -199,6 +197,8 @@ 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,6 +237,8 @@ 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,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
297
src/app/admin/(dashboard)/_state/desa/musik.ts
Normal file
297
src/app/admin/(dashboard)/_state/desa/musik.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
/* 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;
|
||||||
@@ -5,53 +5,52 @@ import { toast } from "react-toastify";
|
|||||||
import { proxy } from "valtio";
|
import { proxy } from "valtio";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
// --- Zod Schema ---
|
// --- Zod Schema untuk APBDes Item (dengan field kalkulasi) ---
|
||||||
const ApbdesItemSchema = z.object({
|
const ApbdesItemSchema = z.object({
|
||||||
kode: z.string().min(1, "Kode wajib diisi"),
|
kode: z.string().min(1, "Kode wajib diisi"),
|
||||||
uraian: z.string().min(1, "Uraian wajib diisi"),
|
uraian: z.string().min(1, "Uraian wajib diisi"),
|
||||||
anggaran: z.number().min(0),
|
anggaran: z.number().min(0, "Anggaran tidak boleh negatif"),
|
||||||
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"),
|
||||||
imageId: z.string().min(1, "Gambar wajib diunggah"),
|
name: z.string().optional(),
|
||||||
fileId: z.string().min(1, "File wajib diunggah"),
|
deskripsi: z.string().optional(),
|
||||||
|
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: hitung selisih & persentase otomatis (opsional di frontend) ---
|
// --- Helper: Normalize item (dengan field kalkulasi) ---
|
||||||
// --- 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,
|
anggaran: item.anggaran ?? 0,
|
||||||
realisasi,
|
|
||||||
selisih,
|
|
||||||
persentase,
|
|
||||||
level: item.level || 1,
|
level: item.level || 1,
|
||||||
tipe: item.tipe, // biarkan null jika memang null
|
tipe: item.tipe ?? null,
|
||||||
|
realisasi: item.realisasi ?? 0,
|
||||||
|
selisih: item.selisih ?? 0,
|
||||||
|
persentase: item.persentase ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +112,7 @@ const apbdes = proxy({
|
|||||||
findMany: {
|
findMany: {
|
||||||
data: null as
|
data: null as
|
||||||
| Prisma.APBDesGetPayload<{
|
| Prisma.APBDesGetPayload<{
|
||||||
include: { image: true; file: true; items: true };
|
include: { image: true; file: true; items: { include: { realisasiItems: true } } };
|
||||||
}>[]
|
}>[]
|
||||||
| null,
|
| null,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -158,33 +157,37 @@ const apbdes = proxy({
|
|||||||
findUnique: {
|
findUnique: {
|
||||||
data: null as
|
data: null as
|
||||||
| Prisma.APBDesGetPayload<{
|
| Prisma.APBDesGetPayload<{
|
||||||
include: { image: true; file: true; items: true };
|
include: { image: true; file: true; items: { include: { realisasiItems: true } } };
|
||||||
}>
|
}>
|
||||||
| null,
|
| null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null as string | null,
|
error: null as string | null,
|
||||||
|
|
||||||
async load(id: string) {
|
async load(id: string) {
|
||||||
if (!id || id.trim() === '') {
|
if (!id || id.trim() === '') {
|
||||||
this.data = null;
|
this.data = null;
|
||||||
this.error = "ID tidak valid";
|
this.error = "ID tidak valid";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent multiple simultaneous loads
|
||||||
|
if (this.loading) {
|
||||||
|
console.log("⚠️ Already loading, skipping...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Pastikan URL-nya benar
|
|
||||||
const url = `/api/landingpage/apbdes/${id}`;
|
const url = `/api/landingpage/apbdes/${id}`;
|
||||||
console.log("🌐 Fetching:", url);
|
console.log("🌐 Fetching:", url);
|
||||||
|
|
||||||
// Gunakan fetch biasa atau ApiFetch dengan cara yang benar
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const res = await response.json();
|
const res = await response.json();
|
||||||
|
|
||||||
console.log("📦 Response:", res);
|
console.log("📦 Response:", res);
|
||||||
|
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
this.data = res.data;
|
this.data = res.data;
|
||||||
} else {
|
} else {
|
||||||
@@ -244,15 +247,18 @@ 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.realisasi,
|
realisasi: item.totalRealisasi || 0,
|
||||||
selisih: item.selisih,
|
selisih: item.selisih || 0,
|
||||||
persentase: item.persentase,
|
persentase: item.persentase || 0,
|
||||||
level: item.level,
|
level: item.level,
|
||||||
tipe: item.tipe || 'pendapatan',
|
tipe: item.tipe || 'pendapatan',
|
||||||
})),
|
})),
|
||||||
@@ -280,11 +286,24 @@ const apbdes = proxy({
|
|||||||
try {
|
try {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
// Include the ID in the request body
|
// Include the ID in the request body
|
||||||
|
// Omit realisasi, selisih, persentase karena itu calculated fields di backend
|
||||||
const requestData = {
|
const requestData = {
|
||||||
...parsed.data,
|
tahun: parsed.data.tahun,
|
||||||
id: this.id, // Add the ID to the request body
|
name: parsed.data.name,
|
||||||
|
deskripsi: parsed.data.deskripsi,
|
||||||
|
jumlah: parsed.data.jumlah,
|
||||||
|
imageId: parsed.data.imageId,
|
||||||
|
fileId: parsed.data.fileId,
|
||||||
|
id: this.id,
|
||||||
|
items: parsed.data.items.map(item => ({
|
||||||
|
kode: item.kode,
|
||||||
|
uraian: item.uraian,
|
||||||
|
anggaran: item.anggaran,
|
||||||
|
level: item.level,
|
||||||
|
tipe: item.tipe ?? null,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
|
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
|
||||||
|
|
||||||
if (res.data?.success) {
|
if (res.data?.success) {
|
||||||
@@ -317,6 +336,82 @@ 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;
|
||||||
@@ -160,7 +160,7 @@ function ListKategoriBerita({ search }: { search: string }) {
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableTr>
|
<TableTr>
|
||||||
<TableTd colSpan={4}>
|
<TableTd colSpan={3}> {/* ✅ Match column count (3 columns) */}
|
||||||
<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
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
ActionIcon,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Card,
|
||||||
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Image,
|
Image,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -17,7 +19,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 {
|
||||||
@@ -25,19 +27,51 @@ 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: "",
|
||||||
@@ -48,9 +82,17 @@ 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 === '';
|
||||||
};
|
};
|
||||||
@@ -61,21 +103,12 @@ function EditBerita() {
|
|||||||
formData.judul?.trim() !== '' &&
|
formData.judul?.trim() !== '' &&
|
||||||
formData.kategoriBeritaId !== '' &&
|
formData.kategoriBeritaId !== '' &&
|
||||||
!isHtmlEmpty(formData.deskripsi) &&
|
!isHtmlEmpty(formData.deskripsi) &&
|
||||||
(file !== null || originalData.imageId !== '') && // Either a new file is selected or an existing image exists
|
(file !== null || originalData.imageId !== '') &&
|
||||||
!isHtmlEmpty(formData.content)
|
!isHtmlEmpty(formData.content)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [originalData, setOriginalData] = useState({
|
// Load data
|
||||||
judul: "",
|
|
||||||
deskripsi: "",
|
|
||||||
kategoriBeritaId: "",
|
|
||||||
content: "",
|
|
||||||
imageId: "",
|
|
||||||
imageUrl: ""
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load kategori + berita
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
beritaState.kategoriBerita.findMany.load();
|
beritaState.kategoriBerita.findMany.load();
|
||||||
|
|
||||||
@@ -84,7 +117,7 @@ function EditBerita() {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await stateDashboardBerita.berita.edit.load(id);
|
const data = await stateDashboardBerita.berita.edit.load(id) as BeritaData | null;
|
||||||
if (data) {
|
if (data) {
|
||||||
setFormData({
|
setFormData({
|
||||||
judul: data.judul || "",
|
judul: data.judul || "",
|
||||||
@@ -106,6 +139,17 @@ 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);
|
||||||
@@ -120,27 +164,59 @@ 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');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.kategoriBeritaId) {
|
if (!formData.kategoriBeritaId) {
|
||||||
toast.error('Kategori wajib dipilih');
|
toast.error('Kategori wajib dipilih');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHtmlEmpty(formData.deskripsi)) {
|
if (isHtmlEmpty(formData.deskripsi)) {
|
||||||
toast.error('Deskripsi singkat wajib diisi');
|
toast.error('Deskripsi singkat wajib diisi');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file && !originalData.imageId) {
|
if (!file && !originalData.imageId) {
|
||||||
toast.error('Gambar wajib dipilih');
|
toast.error('Gambar utama wajib dipilih');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHtmlEmpty(formData.content)) {
|
if (isHtmlEmpty(formData.content)) {
|
||||||
toast.error('Konten wajib diisi');
|
toast.error('Konten wajib diisi');
|
||||||
return;
|
return;
|
||||||
@@ -148,12 +224,14 @@ 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,
|
||||||
@@ -162,12 +240,33 @@ function EditBerita() {
|
|||||||
const uploaded = res.data?.data;
|
const uploaded = res.data?.data;
|
||||||
|
|
||||||
if (!uploaded?.id) {
|
if (!uploaded?.id) {
|
||||||
return toast.error("Gagal upload gambar");
|
return toast.error("Gagal upload gambar utama");
|
||||||
}
|
}
|
||||||
|
|
||||||
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");
|
||||||
@@ -189,9 +288,12 @@ 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 */}
|
||||||
@@ -219,6 +321,7 @@ 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"
|
||||||
@@ -227,6 +330,7 @@ function EditBerita() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Kategori */}
|
||||||
<Select
|
<Select
|
||||||
value={formData.kategoriBeritaId}
|
value={formData.kategoriBeritaId}
|
||||||
onChange={(val) => handleChange("kategoriBeritaId", val || "")}
|
onChange={(val) => handleChange("kategoriBeritaId", val || "")}
|
||||||
@@ -241,9 +345,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
|
||||||
@@ -256,11 +360,10 @@ 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 Berita
|
Gambar Utama (Featured)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={(files) => {
|
onDrop={(files) => {
|
||||||
@@ -274,17 +377,13 @@ 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/*": [] }}
|
accept={{ "image/*": ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||||
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
|
<IconUpload size={48} color={colors["blue-button"]} stroke={1.5} />
|
||||||
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} />
|
||||||
@@ -292,14 +391,6 @@ 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>
|
||||||
|
|
||||||
@@ -328,9 +419,7 @@ function EditBerita() {
|
|||||||
setPreviewImage(null);
|
setPreviewImage(null);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -338,6 +427,138 @@ 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">
|
||||||
@@ -351,9 +572,8 @@ function EditBerita() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Action */}
|
{/* Action Buttons */}
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
{/* Tombol Batal */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -363,8 +583,6 @@ function EditBerita() {
|
|||||||
>
|
>
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Tombol Simpan */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
|
import { Box, Button, Card, Grid, Group, Image, Paper, Skeleton, Stack, Text, Badge, AspectRatio } from '@mantine/core';
|
||||||
import { useShallowEffect } from '@mantine/hooks';
|
import { useShallowEffect } from '@mantine/hooks';
|
||||||
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
import { IconArrowBack, IconEdit, IconTrash, IconVideo } 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,6 +10,23 @@ 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);
|
||||||
@@ -38,7 +55,7 @@ function DetailBerita() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = beritaState.berita.findUnique.data;
|
const data = beritaState.berita.findUnique.data as unknown as BeritaDetail;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||||
@@ -68,71 +85,131 @@ 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 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: data.deskripsi || '-' }}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Gambar Utama (Featured) */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Gambar</Text>
|
<Text fz="lg" fw="bold">Gambar Utama</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={200}
|
w={{ base: '100%', md: 400 }}
|
||||||
h={200}
|
h={300}
|
||||||
radius="md"
|
radius="md"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
loading='lazy'
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Text fz="sm" c="dimmed">Tidak ada gambar</Text>
|
<Text fz="sm" c="dimmed">Tidak ada gambar utama</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>
|
||||||
<Text
|
<Paper bg="white" p="md" radius="md" mt="xs">
|
||||||
fz="md"
|
<Text
|
||||||
c="dimmed"
|
fz="md"
|
||||||
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
|
c="dimmed"
|
||||||
/>
|
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Action Button */}
|
{/* Action Buttons */}
|
||||||
<Group gap="sm">
|
<Group gap="sm" mt="md">
|
||||||
<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} />
|
>
|
||||||
</Button>
|
Hapus
|
||||||
|
</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} />
|
>
|
||||||
</Button>
|
Edit
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -15,26 +15,38 @@ 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 } from '@tabler/icons-react';
|
import { IconArrowBack, IconPhoto, IconUpload, IconX, IconVideo, IconTrash } 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 === '';
|
||||||
};
|
};
|
||||||
@@ -61,9 +73,35 @@ 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 () => {
|
||||||
@@ -71,22 +109,22 @@ export default function CreateBerita() {
|
|||||||
toast.error('Judul wajib diisi');
|
toast.error('Judul wajib diisi');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!beritaState.berita.create.form.kategoriBeritaId) {
|
if (!beritaState.berita.create.form.kategoriBeritaId) {
|
||||||
toast.error('Kategori wajib dipilih');
|
toast.error('Kategori wajib dipilih');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHtmlEmpty(beritaState.berita.create.form.deskripsi)) {
|
if (isHtmlEmpty(beritaState.berita.create.form.deskripsi)) {
|
||||||
toast.error('Deskripsi singkat wajib diisi');
|
toast.error('Deskripsi singkat wajib diisi');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
toast.error('Gambar wajib dipilih');
|
toast.error('Gambar utama wajib dipilih');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHtmlEmpty(beritaState.berita.create.form.content)) {
|
if (isHtmlEmpty(beritaState.berita.create.form.content)) {
|
||||||
toast.error('Konten wajib diisi');
|
toast.error('Konten wajib diisi');
|
||||||
return;
|
return;
|
||||||
@@ -94,21 +132,37 @@ export default function CreateBerita() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
if (!file) {
|
|
||||||
return toast.warn('Silakan pilih file gambar terlebih dahulu');
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await ApiFetch.api.fileStorage.create.post({
|
// Upload featured image
|
||||||
|
const featuredRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
file,
|
file,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
});
|
});
|
||||||
|
const featuredUploaded = featuredRes.data?.data;
|
||||||
const uploaded = res.data?.data;
|
if (!featuredUploaded?.id) {
|
||||||
if (!uploaded?.id) {
|
return toast.error('Gagal mengunggah gambar utama');
|
||||||
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
|
|
||||||
}
|
}
|
||||||
|
beritaState.berita.create.form.imageId = featuredUploaded.id;
|
||||||
|
|
||||||
beritaState.berita.create.form.imageId = uploaded.id;
|
// Upload gallery images
|
||||||
|
const galleryIds: 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) {
|
||||||
|
galleryIds.push(galleryUploaded.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
beritaState.berita.create.form.imageIds = galleryIds;
|
||||||
|
|
||||||
|
// Set YouTube link if provided
|
||||||
|
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||||
|
if (embedLink) {
|
||||||
|
beritaState.berita.create.form.linkVideo = embedLink;
|
||||||
|
}
|
||||||
|
|
||||||
await beritaState.berita.create.create();
|
await beritaState.berita.create.create();
|
||||||
|
|
||||||
@@ -122,16 +176,13 @@ 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 dengan tombol kembali */}
|
{/* Header */}
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<Button
|
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||||
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">
|
||||||
@@ -148,6 +199,7 @@ 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"
|
||||||
@@ -156,6 +208,7 @@ export default function CreateBerita() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Kategori */}
|
||||||
<Select
|
<Select
|
||||||
label="Kategori"
|
label="Kategori"
|
||||||
placeholder="Pilih kategori"
|
placeholder="Pilih kategori"
|
||||||
@@ -182,6 +235,7 @@ 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
|
||||||
@@ -194,9 +248,10 @@ 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 Berita
|
Gambar Utama (Featured)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={(files) => {
|
onDrop={(files) => {
|
||||||
@@ -232,17 +287,11 @@ 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"
|
alt="Preview Gambar Utama"
|
||||||
radius="md"
|
radius="md"
|
||||||
style={{
|
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
|
||||||
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"
|
||||||
@@ -255,9 +304,7 @@ export default function CreateBerita() {
|
|||||||
setPreviewImage(null);
|
setPreviewImage(null);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -265,6 +312,102 @@ 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
|
||||||
@@ -277,6 +420,7 @@ export default function CreateBerita() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -287,8 +431,6 @@ export default function CreateBerita() {
|
|||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Tombol Simpan */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ function ListBerita({ search }: { search: string }) {
|
|||||||
<Pagination
|
<Pagination
|
||||||
value={page}
|
value={page}
|
||||||
onChange={(newPage) => {
|
onChange={(newPage) => {
|
||||||
load(newPage, 10);
|
load(newPage, 10, debouncedSearch); // ✅ Include search parameter
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
}}
|
}}
|
||||||
total={totalPages}
|
total={totalPages}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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();
|
||||||
@@ -77,7 +78,17 @@ export default function DetailPotensi() {
|
|||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Deskripsi</Text>
|
<Text fz="lg" fw="bold">Deskripsi</Text>
|
||||||
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}></Text>
|
<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>
|
||||||
@@ -102,7 +113,12 @@ export default function DetailPotensi() {
|
|||||||
<Text
|
<Text
|
||||||
fz="md"
|
fz="md"
|
||||||
c="dimmed"
|
c="dimmed"
|
||||||
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__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>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ 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("");
|
||||||
@@ -137,7 +138,12 @@ function ListPotensi({ search }: { search: string }) {
|
|||||||
fz="sm"
|
fz="sm"
|
||||||
lh={1.5}
|
lh={1.5}
|
||||||
lineClamp={2}
|
lineClamp={2}
|
||||||
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__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>
|
||||||
@@ -199,7 +205,12 @@ function ListPotensi({ search }: { search: string }) {
|
|||||||
<Text
|
<Text
|
||||||
fz="sm"
|
fz="sm"
|
||||||
lh={1.5}
|
lh={1.5}
|
||||||
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__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>
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ function Page() {
|
|||||||
fz={{ base: 'md', md: 'lg' }}
|
fz={{ base: 'md', md: 'lg' }}
|
||||||
lh={{ base: 1.4, md: 1.4 }}
|
lh={{ base: 1.4, md: 1.4 }}
|
||||||
>
|
>
|
||||||
{perbekel.nama || "I.B. Surya Prabhawa Manuaba, S.H., M.H."}
|
I.B. Surya Prabhawa Manuaba, S.H., M.H.
|
||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
/* 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 "Tambah Realisasi" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,9 +42,11 @@ 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() {
|
||||||
@@ -71,14 +73,19 @@ 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: '',
|
||||||
@@ -103,6 +110,9 @@ 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 || '',
|
||||||
@@ -112,15 +122,18 @@ 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.realisasi,
|
realisasi: item.totalRealisasi || 0,
|
||||||
selisih: item.selisih,
|
selisih: item.selisih || 0,
|
||||||
persentase: item.persentase,
|
persentase: item.persentase || 0,
|
||||||
level: item.level,
|
level: item.level,
|
||||||
tipe: item.tipe || 'pendapatan',
|
tipe: item.tipe || 'pendapatan',
|
||||||
})),
|
})),
|
||||||
@@ -148,34 +161,33 @@ function EditAPBDes() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddItem = () => {
|
const handleAddItem = () => {
|
||||||
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
|
const { kode, uraian, anggaran, level, tipe, realisasi, selisih, persentase } = newItem;
|
||||||
if (!kode || !uraian) {
|
if (!kode || !uraian) {
|
||||||
return toast.warn('Kode dan uraian wajib diisi');
|
return toast.warn('Kode dan uraian wajib diisi');
|
||||||
}
|
}
|
||||||
|
|
||||||
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: realisasi || 0,
|
||||||
selisih,
|
selisih: selisih || 0,
|
||||||
persentase,
|
persentase: persentase || 0,
|
||||||
level,
|
level,
|
||||||
tipe: finalTipe, // ✅ Tidak akan undefined
|
tipe: finalTipe,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -193,7 +205,6 @@ function EditAPBDes() {
|
|||||||
|
|
||||||
// Upload file baru jika ada perubahan
|
// Upload file baru jika ada perubahan
|
||||||
if (imageFile) {
|
if (imageFile) {
|
||||||
// Hapus file lama dari form jika ada file baru
|
|
||||||
const res = await ApiFetch.api.fileStorage.create.post({
|
const res = await ApiFetch.api.fileStorage.create.post({
|
||||||
file: imageFile,
|
file: imageFile,
|
||||||
name: imageFile.name,
|
name: imageFile.name,
|
||||||
@@ -205,7 +216,6 @@ function EditAPBDes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (docFile) {
|
if (docFile) {
|
||||||
// Hapus file lama dari form jika ada file baru
|
|
||||||
const res = await ApiFetch.api.fileStorage.create.post({
|
const res = await ApiFetch.api.fileStorage.create.post({
|
||||||
file: docFile,
|
file: docFile,
|
||||||
name: docFile.name,
|
name: docFile.name,
|
||||||
@@ -216,15 +226,7 @@ function EditAPBDes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jika tidak ada file baru, gunakan ID lama (sudah ada di form)
|
// Image dan file sekarang opsional, tidak perlu validasi
|
||||||
// Pastikan imageId dan fileId tetap ada
|
|
||||||
if (!apbdesState.edit.form.imageId) {
|
|
||||||
return toast.warn('Gambar wajib diunggah');
|
|
||||||
}
|
|
||||||
if (!apbdesState.edit.form.fileId) {
|
|
||||||
return toast.warn('Dokumen wajib diunggah');
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await apbdesState.edit.update();
|
const success = await apbdesState.edit.update();
|
||||||
if (success) {
|
if (success) {
|
||||||
router.push('/admin/landing-page/apbdes');
|
router.push('/admin/landing-page/apbdes');
|
||||||
@@ -238,9 +240,12 @@ function EditAPBDes() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
// Reset ke data original (tahun, imageId, fileId)
|
// Reset ke data original (tahun, name, deskripsi, jumlah, 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
|
||||||
@@ -249,21 +254,23 @@ function EditAPBDes() {
|
|||||||
// Reset preview ke data original
|
// Reset preview ke data original
|
||||||
setPreviewImage(originalData.imageUrl || null);
|
setPreviewImage(originalData.imageUrl || null);
|
||||||
setPreviewDoc(originalData.fileUrl || null);
|
setPreviewDoc(originalData.fileUrl || null);
|
||||||
|
|
||||||
// Reset file uploads
|
// Reset file uploads
|
||||||
setImageFile(null);
|
setImageFile(null);
|
||||||
setDocFile(null);
|
setDocFile(null);
|
||||||
|
|
||||||
// Reset new item form
|
// Reset new item form
|
||||||
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,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.info('Form dikembalikan ke data awal');
|
toast.info('Form dikembalikan ke data awal');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -288,6 +295,33 @@ 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()}
|
||||||
@@ -299,11 +333,11 @@ function EditAPBDes() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Gambar & Dokumen */}
|
{/* Gambar & Dokumen (Opsional) */}
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Gambar APBDes
|
Gambar APBDes (Opsional)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={handleDrop('image')}
|
onDrop={handleDrop('image')}
|
||||||
@@ -343,6 +377,7 @@ function EditAPBDes() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPreviewImage(null);
|
setPreviewImage(null);
|
||||||
setImageFile(null);
|
setImageFile(null);
|
||||||
|
apbdesState.edit.form.imageId = ''; // Clear imageId from form
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
@@ -353,7 +388,7 @@ function EditAPBDes() {
|
|||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Dokumen APBDes
|
Dokumen APBDes (Opsional)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={handleDrop('doc')}
|
onDrop={handleDrop('doc')}
|
||||||
@@ -402,6 +437,7 @@ function EditAPBDes() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPreviewDoc(null);
|
setPreviewDoc(null);
|
||||||
setDocFile(null);
|
setDocFile(null);
|
||||||
|
apbdesState.edit.form.fileId = ''; // Clear fileId from form
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
@@ -475,13 +511,6 @@ 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} />}
|
||||||
@@ -506,6 +535,8 @@ 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>
|
||||||
@@ -521,7 +552,11 @@ function EditAPBDes() {
|
|||||||
</td>
|
</td>
|
||||||
<td>{item.uraian}</td>
|
<td>{item.uraian}</td>
|
||||||
<td>{item.anggaran.toLocaleString('id-ID')}</td>
|
<td>{item.anggaran.toLocaleString('id-ID')}</td>
|
||||||
<td>{item.realisasi.toLocaleString('id-ID')}</td>
|
<td>{item.realisasi?.toLocaleString('id-ID') || '0'}</td>
|
||||||
|
<td style={{ color: item.selisih && item.selisih > 0 ? 'red' : 'green' }}>
|
||||||
|
{item.selisih?.toLocaleString('id-ID') || '0'}
|
||||||
|
</td>
|
||||||
|
<td>{item.persentase?.toFixed(2) || '0'}%</td>
|
||||||
<td>
|
<td>
|
||||||
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
|
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
|
||||||
L{item.level}
|
L{item.level}
|
||||||
@@ -533,7 +568,7 @@ function EditAPBDes() {
|
|||||||
{item.tipe}
|
{item.tipe}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
'-'
|
<Text size="sm" c="dimmed">-</Text>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -94,7 +95,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 || '-'}
|
{data.name || `APBDes Tahun ${data.tahun}`}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -105,6 +106,24 @@ 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 ? (
|
||||||
@@ -173,48 +192,60 @@ function DetailAPBDes() {
|
|||||||
|
|
||||||
{/* Tabel Items */}
|
{/* Tabel Items */}
|
||||||
{data.items && data.items.length > 0 ? (
|
{data.items && data.items.length > 0 ? (
|
||||||
<Paper withBorder p="md" radius="md">
|
<Stack gap="md">
|
||||||
<Text fz="lg" fw="bold" mb="sm">
|
<Text fz="lg" fw="bold">
|
||||||
Rincian Pendapatan & Belanja ({data.items.length} item)
|
Rincian Pendapatan & Belanja ({data.items.length} item)
|
||||||
</Text>
|
</Text>
|
||||||
<Box style={{ overflowX: 'auto' }}>
|
<Table striped highlightOnHover>
|
||||||
<Table striped highlightOnHover>
|
<TableThead>
|
||||||
<TableThead>
|
<TableTr>
|
||||||
<TableTr>
|
<TableTh>Uraian</TableTh>
|
||||||
<TableTh>Uraian</TableTh>
|
<TableTh>Anggaran (Rp)</TableTh>
|
||||||
<TableTh>Anggaran (Rp)</TableTh>
|
<TableTh>Realisasi (Rp)</TableTh>
|
||||||
<TableTh>Realisasi (Rp)</TableTh>
|
<TableTh>Selisih (Rp)</TableTh>
|
||||||
<TableTh>Selisih (Rp)</TableTh>
|
<TableTh>Persentase (%)</TableTh>
|
||||||
<TableTh>Persentase (%)</TableTh>
|
</TableTr>
|
||||||
</TableTr>
|
</TableThead>
|
||||||
</TableThead>
|
<TableTbody>
|
||||||
<TableTbody>
|
{[...data.items]
|
||||||
{[...data.items] // Create a new array before sorting
|
.sort((a, b) => a.kode.localeCompare(b.kode))
|
||||||
.sort((a, b) => a.kode.localeCompare(b.kode))
|
.map((item) => (
|
||||||
.map((item) => (
|
<TableTr key={item.id}>
|
||||||
<TableTr key={item.id}>
|
<TableTd style={getIndent(item.level)}>
|
||||||
<TableTd style={getIndent(item.level)}>
|
<Group>
|
||||||
<Group>
|
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
|
||||||
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
|
<Text fz="sm" c="dimmed">{item.uraian}</Text>
|
||||||
<Text fz="sm" c="dimmed">{item.uraian}</Text>
|
</Group>
|
||||||
</Group>
|
</TableTd>
|
||||||
</TableTd>
|
<TableTd>{item.anggaran.toLocaleString('id-ID')}</TableTd>
|
||||||
<TableTd>{item.anggaran.toLocaleString('id-ID')}</TableTd>
|
<TableTd>{item.totalRealisasi.toLocaleString('id-ID')}</TableTd>
|
||||||
<TableTd>{item.realisasi.toLocaleString('id-ID')}</TableTd>
|
<TableTd>
|
||||||
<TableTd>
|
<Text c={item.selisih >= 0 ? 'green' : 'red'}>
|
||||||
<Text c={item.selisih >= 0 ? 'green' : 'red'}>
|
{item.selisih.toLocaleString('id-ID')}
|
||||||
{item.selisih.toLocaleString('id-ID')}
|
</Text>
|
||||||
</Text>
|
</TableTd>
|
||||||
</TableTd>
|
<TableTd>
|
||||||
<TableTd>
|
<Text fw={500}>{item.persentase.toFixed(2)}%</Text>
|
||||||
<Text fw={500}>{item.persentase.toFixed(2)}%</Text>
|
</TableTd>
|
||||||
</TableTd>
|
</TableTr>
|
||||||
</TableTr>
|
))}
|
||||||
))}
|
</TableTbody>
|
||||||
</TableTbody>
|
</Table>
|
||||||
</Table>
|
|
||||||
</Box>
|
{/* Realisasi Manager untuk setiap item */}
|
||||||
</Paper>
|
{data.items.map((item) => (
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ 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';
|
||||||
};
|
};
|
||||||
@@ -47,13 +46,9 @@ function CreateAPBDes() {
|
|||||||
const [docFile, setDocFile] = useState<File | null>(null);
|
const [docFile, setDocFile] = useState<File | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
// Check if form is valid
|
// Check if form is valid - hanya cek items, gambar dan file opsional
|
||||||
const isFormValid = () => {
|
const isFormValid = () => {
|
||||||
return (
|
return stateAPBDes.create.form.items.length > 0;
|
||||||
imageFile !== null &&
|
|
||||||
docFile !== null &&
|
|
||||||
stateAPBDes.create.form.items.length > 0
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Form sementara untuk input item baru
|
// Form sementara untuk input item baru
|
||||||
@@ -61,7 +56,6 @@ function CreateAPBDes() {
|
|||||||
kode: '',
|
kode: '',
|
||||||
uraian: '',
|
uraian: '',
|
||||||
anggaran: 0,
|
anggaran: 0,
|
||||||
realisasi: 0,
|
|
||||||
level: 1,
|
level: 1,
|
||||||
tipe: 'pendapatan',
|
tipe: 'pendapatan',
|
||||||
});
|
});
|
||||||
@@ -80,35 +74,40 @@ 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 }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const imageId = uploadImageRes?.data?.data?.id;
|
// Upload files hanya jika ada file yang dipilih
|
||||||
const fileId = uploadDocRes?.data?.data?.id;
|
let imageId = '';
|
||||||
|
let fileId = '';
|
||||||
|
|
||||||
if (!imageId || !fileId) {
|
if (imageFile) {
|
||||||
return toast.error("Gagal mengupload file");
|
const uploadImageRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
|
file: imageFile,
|
||||||
|
name: imageFile.name,
|
||||||
|
});
|
||||||
|
imageId = uploadImageRes?.data?.data?.id || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update form dengan ID file
|
if (docFile) {
|
||||||
|
const uploadDocRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
|
file: docFile,
|
||||||
|
name: docFile.name,
|
||||||
|
});
|
||||||
|
fileId = uploadDocRes?.data?.data?.id || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update form dengan ID file (bisa kosong)
|
||||||
stateAPBDes.create.form.imageId = imageId;
|
stateAPBDes.create.form.imageId = imageId;
|
||||||
stateAPBDes.create.form.fileId = fileId;
|
stateAPBDes.create.form.fileId = fileId;
|
||||||
|
|
||||||
@@ -117,9 +116,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) {
|
} catch (error: any) {
|
||||||
console.error("Gagal submit:", error);
|
console.error("Gagal submit:", error);
|
||||||
toast.error("Gagal menyimpan data");
|
toast.error(error?.message || "Gagal menyimpan data");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -127,22 +126,17 @@ function CreateAPBDes() {
|
|||||||
|
|
||||||
// Tambahkan item ke state
|
// Tambahkan item ke state
|
||||||
const handleAddItem = () => {
|
const handleAddItem = () => {
|
||||||
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
|
const { kode, uraian, anggaran, 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,
|
||||||
});
|
});
|
||||||
@@ -152,7 +146,6 @@ function CreateAPBDes() {
|
|||||||
kode: '',
|
kode: '',
|
||||||
uraian: '',
|
uraian: '',
|
||||||
anggaran: 0,
|
anggaran: 0,
|
||||||
realisasi: 0,
|
|
||||||
level: 1,
|
level: 1,
|
||||||
tipe: 'pendapatan',
|
tipe: 'pendapatan',
|
||||||
});
|
});
|
||||||
@@ -183,12 +176,16 @@ function CreateAPBDes() {
|
|||||||
style={{ border: '1px solid #e0e0e0' }}
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{/* Gambar & Dokumen (dipendekkan untuk fokus pada items) */}
|
{/* Info: File opsional */}
|
||||||
|
<Text fz="sm" c="dimmed" mb="xs">
|
||||||
|
* Upload gambar dan dokumen bersifat opsional. Bisa dikosongkan jika belum ada.
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Stack gap={"xs"}>
|
<Stack gap={"xs"}>
|
||||||
{/* Gambar APBDes */}
|
{/* Gambar APBDes */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Gambar APBDes
|
Gambar APBDes (Opsional)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={(files) => {
|
onDrop={(files) => {
|
||||||
@@ -258,10 +255,10 @@ function CreateAPBDes() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Dokumen APBDes */}
|
{/* Dokumen APBDes (Opsional) */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Dokumen APBDes
|
Dokumen APBDes (Opsional)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={(files) => {
|
onDrop={(files) => {
|
||||||
@@ -334,6 +331,27 @@ 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()}
|
||||||
@@ -406,13 +424,6 @@ 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} />}
|
||||||
@@ -434,28 +445,30 @@ 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, idx) => (
|
{stateAPBDes.create.form.items.map((item: any, 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>
|
||||||
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
|
{item.tipe ? (
|
||||||
{item.tipe}
|
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
|
||||||
</Badge>
|
{item.tipe}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">-</Text>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>
|
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
428
src/app/admin/(dashboard)/musik/[id]/edit/page.tsx
Normal file
428
src/app/admin/(dashboard)/musik/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
src/app/admin/(dashboard)/musik/[id]/page.tsx
Normal file
271
src/app/admin/(dashboard)/musik/[id]/page.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
'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 "{data.judul}"?
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
426
src/app/admin/(dashboard)/musik/create/page.tsx
Normal file
426
src/app/admin/(dashboard)/musik/create/page.tsx
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
src/app/admin/(dashboard)/musik/page.tsx
Normal file
231
src/app/admin/(dashboard)/musik/page.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
'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;
|
||||||
25
src/app/admin/(dashboard)/user&role/_com/getMenuIdByRole.ts
Normal file
25
src/app/admin/(dashboard)/user&role/_com/getMenuIdByRole.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// src/app/admin/_com/getMenuIdsByRoleId.ts
|
||||||
|
import { navBar, role1, role2, role3 } from '@/app/admin/_com/list_PageAdmin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mengembalikan daftar ID menu (string[]) berdasarkan roleId
|
||||||
|
*/
|
||||||
|
export function getMenuIdsByRoleId(roleId: string | number): string[] {
|
||||||
|
const id = typeof roleId === 'string' ? parseInt(roleId, 10) : roleId;
|
||||||
|
|
||||||
|
switch (id) {
|
||||||
|
case 0:
|
||||||
|
// Asumsikan devBar ada dan punya struktur sama
|
||||||
|
return []; // atau sesuaikan jika ada devBar
|
||||||
|
case 1:
|
||||||
|
return navBar.map(section => section.id);
|
||||||
|
case 2:
|
||||||
|
return role1.map(section => section.id);
|
||||||
|
case 3:
|
||||||
|
return role2.map(section => section.id);
|
||||||
|
case 4:
|
||||||
|
return role3.map(section => section.id);
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -330,7 +330,7 @@ export const devBar = [
|
|||||||
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
|
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "Pendidikan",
|
id: "Pendidikan",
|
||||||
name: "Pendidikan",
|
name: "Pendidikan",
|
||||||
@@ -373,6 +373,11 @@ export const devBar = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "Musik",
|
||||||
|
name: "Musik",
|
||||||
|
path: "/admin/musik"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "User & Role",
|
id: "User & Role",
|
||||||
name: "User & Role",
|
name: "User & Role",
|
||||||
@@ -729,7 +734,7 @@ export const navBar = [
|
|||||||
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
|
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "Pendidikan",
|
id: "Pendidikan",
|
||||||
name: "Pendidikan",
|
name: "Pendidikan",
|
||||||
@@ -772,6 +777,11 @@ export const navBar = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "Musik",
|
||||||
|
name: "Musik",
|
||||||
|
path: "/admin/musik"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "User & Role",
|
id: "User & Role",
|
||||||
name: "User & Role",
|
name: "User & Role",
|
||||||
@@ -1051,7 +1061,7 @@ export const role1 = [
|
|||||||
}
|
}
|
||||||
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "Lingkungan",
|
id: "Lingkungan",
|
||||||
name: "Lingkungan",
|
name: "Lingkungan",
|
||||||
@@ -1088,6 +1098,11 @@ 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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1133,6 +1148,11 @@ export const role2 = [
|
|||||||
path: "/admin/kesehatan/info-wabah-penyakit"
|
path: "/admin/kesehatan/info-wabah-penyakit"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Musik",
|
||||||
|
name: "Musik",
|
||||||
|
path: "/admin/musik"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1178,5 +1198,10 @@ export const role3 = [
|
|||||||
path: "/admin/pendidikan/data-pendidikan"
|
path: "/admin/pendidikan/data-pendidikan"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Musik",
|
||||||
|
name: "Musik",
|
||||||
|
path: "/admin/musik"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { authStore } from "@/store/authStore";
|
|
||||||
import { useDarkMode } from "@/state/darkModeStore";
|
|
||||||
import { themeTokens, getActiveStateStyles } from "@/utils/themeTokens";
|
|
||||||
import { DarkModeToggle } from "@/components/admin/DarkModeToggle";
|
import { DarkModeToggle } from "@/components/admin/DarkModeToggle";
|
||||||
|
import { useDarkMode } from "@/state/darkModeStore";
|
||||||
|
import { authStore } from "@/store/authStore";
|
||||||
|
import { themeTokens } from "@/utils/themeTokens";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
AppShell,
|
AppShell,
|
||||||
@@ -316,8 +316,13 @@ 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
|
||||||
|
|||||||
@@ -1,26 +1,33 @@
|
|||||||
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 = Prisma.BeritaGetPayload<{
|
type FormCreate = {
|
||||||
select: {
|
judul: string;
|
||||||
judul: true;
|
deskripsi: string;
|
||||||
deskripsi: true;
|
content: string;
|
||||||
content: true;
|
kategoriBeritaId: string;
|
||||||
kategoriBeritaId: true;
|
imageId: string; // Featured image
|
||||||
imageId: true;
|
imageIds?: string[]; // Multiple images for gallery
|
||||||
};
|
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default async function handler(
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
image: true,
|
image: true,
|
||||||
|
images: true,
|
||||||
kategoriBerita: true,
|
kategoriBerita: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ 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)
|
||||||
@@ -39,6 +41,8 @@ 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()),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,15 +2,49 @@ 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) {
|
||||||
const id = context.params.id as string;
|
try {
|
||||||
|
const id = context.params?.id as string;
|
||||||
|
|
||||||
await prisma.kategoriBerita.delete({
|
if (!id) {
|
||||||
where: { id },
|
return Response.json({
|
||||||
});
|
success: false,
|
||||||
|
message: "ID tidak boleh kosong",
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
// ✅ Cek apakah kategori masih digunakan oleh berita
|
||||||
status: 200,
|
const beritaCount = await prisma.berita.count({
|
||||||
success: true,
|
where: {
|
||||||
message: "Sukses Menghapus kategori berita",
|
kategoriBeritaId: id,
|
||||||
};
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,52 +4,48 @@ 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 = Prisma.BeritaGetPayload<{
|
type FormUpdate = {
|
||||||
select: {
|
id: string;
|
||||||
id: true;
|
judul: string;
|
||||||
judul: true;
|
deskripsi: string;
|
||||||
deskripsi: true;
|
content: string;
|
||||||
content: true;
|
kategoriBeritaId: string;
|
||||||
kategoriBeritaId: true;
|
imageId: string; // Featured image
|
||||||
imageId: true;
|
imageIds?: string[]; // Multiple images for gallery
|
||||||
};
|
linkVideo?: string; // YouTube link
|
||||||
}>;
|
};
|
||||||
|
|
||||||
async function beritaUpdate(context: Context) {
|
async function beritaUpdate(context: Context) {
|
||||||
try {
|
try {
|
||||||
const id = context.params?.id as string; // ambil dari URL
|
const id = context.params?.id as string;
|
||||||
const body = (await context.body) as Omit<FormUpdate, "id">;
|
const body = (await context.body) as Omit<FormUpdate, "id">;
|
||||||
|
|
||||||
const {
|
const { judul, deskripsi, content, kategoriBeritaId, imageId, imageIds, linkVideo } = body;
|
||||||
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" } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await prisma.berita.findUnique({
|
const existing = await prisma.berita.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
image: true,
|
image: true,
|
||||||
|
images: true, // Include gallery images
|
||||||
kategoriBerita: true,
|
kategoriBerita: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
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) {
|
||||||
@@ -64,35 +60,60 @@ 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: {
|
data: updateData,
|
||||||
judul,
|
include: {
|
||||||
deskripsi,
|
image: true,
|
||||||
content,
|
images: true,
|
||||||
kategoriBeritaId: kategoriBeritaId || null,
|
kategoriBerita: true,
|
||||||
imageId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
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);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
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;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Elysia from "elysia";
|
|||||||
import Berita from "./berita";
|
import Berita from "./berita";
|
||||||
import Pengumuman from "./pengumuman";
|
import Pengumuman from "./pengumuman";
|
||||||
import ProfileDesa from "./profile/profile_desa";
|
import ProfileDesa from "./profile/profile_desa";
|
||||||
import PotensiDesa from "./potensi";
|
import PotensiDesa from "./potensi";
|
||||||
import GalleryFoto from "./gallery/foto";
|
import GalleryFoto from "./gallery/foto";
|
||||||
import GalleryVideo from "./gallery/video";
|
import GalleryVideo from "./gallery/video";
|
||||||
import LayananDesa from "./layanan";
|
import LayananDesa from "./layanan";
|
||||||
@@ -12,6 +12,7 @@ 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"] })
|
||||||
@@ -28,6 +29,7 @@ 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;
|
||||||
|
|||||||
37
src/app/api/[[...slugs]]/_lib/desa/musik/create.ts
Normal file
37
src/app/api/[[...slugs]]/_lib/desa/musik/create.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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;
|
||||||
54
src/app/api/[[...slugs]]/_lib/desa/musik/del.ts
Normal file
54
src/app/api/[[...slugs]]/_lib/desa/musik/del.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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;
|
||||||
66
src/app/api/[[...slugs]]/_lib/desa/musik/find-by-id.ts
Normal file
66
src/app/api/[[...slugs]]/_lib/desa/musik/find-by-id.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/app/api/[[...slugs]]/_lib/desa/musik/find-many.ts
Normal file
69
src/app/api/[[...slugs]]/_lib/desa/musik/find-many.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/* 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;
|
||||||
47
src/app/api/[[...slugs]]/_lib/desa/musik/index.ts
Normal file
47
src/app/api/[[...slugs]]/_lib/desa/musik/index.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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;
|
||||||
65
src/app/api/[[...slugs]]/_lib/desa/musik/updt.ts
Normal file
65
src/app/api/[[...slugs]]/_lib/desa/musik/updt.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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;
|
||||||
@@ -21,8 +21,12 @@ export default async function findUnique(
|
|||||||
}, { status: 400 });
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await prisma.potensiDesa.findUnique({
|
// ✅ Filter by isActive and deletedAt
|
||||||
where: { id },
|
const data = await prisma.potensiDesa.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
image: true,
|
image: true,
|
||||||
kategori: true
|
kategori: true
|
||||||
@@ -48,5 +52,5 @@ export default async function findUnique(
|
|||||||
message: "Gagal mengambil potensi desa: " + (error instanceof Error ? error.message : 'Unknown error'),
|
message: "Gagal mengambil potensi desa: " + (error instanceof Error ? error.message : 'Unknown error'),
|
||||||
}, { status: 500 });
|
}, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -2,15 +2,49 @@ 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) {
|
||||||
const id = context.params.id as string;
|
try {
|
||||||
|
const id = context.params?.id as string;
|
||||||
|
|
||||||
await prisma.kategoriPotensi.delete({
|
if (!id) {
|
||||||
where: { id },
|
return Response.json({
|
||||||
});
|
success: false,
|
||||||
|
message: "ID tidak boleh kosong",
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
// ✅ Cek apakah kategori masih digunakan oleh potensi desa
|
||||||
status: 200,
|
const existingPotensi = await prisma.potensiDesa.findFirst({
|
||||||
success: true,
|
where: {
|
||||||
message: "Sukses Menghapus kategori potensi",
|
kategoriId: id,
|
||||||
};
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { requireAuth } from "@/lib/api-auth";
|
import { requireAuth } from "@/lib/api-auth";
|
||||||
|
|
||||||
export default async function sejarahDesaFindFirst(request: Request) {
|
export default async function sejarahDesaFindFirst() {
|
||||||
// ✅ Authentication check
|
// ✅ Authentication check
|
||||||
const headers = new Headers(request.url);
|
const authResult = await requireAuth();
|
||||||
const authResult = await requireAuth({ headers });
|
|
||||||
if (!authResult.authenticated) {
|
if (!authResult.authenticated) {
|
||||||
return authResult.response;
|
return authResult.response;
|
||||||
}
|
}
|
||||||
@@ -12,9 +11,8 @@ export default async function sejarahDesaFindFirst(request: Request) {
|
|||||||
try {
|
try {
|
||||||
// Get the first active record
|
// Get the first active record
|
||||||
const data = await prisma.sejarahDesa.findFirst({
|
const data = await prisma.sejarahDesa.findFirst({
|
||||||
where: {
|
where: {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
deletedAt: null
|
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'asc' } // Get the oldest one first
|
orderBy: { createdAt: 'asc' } // Get the oldest one first
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ const SejarahDesa = new Elysia({
|
|||||||
prefix: "/sejarah",
|
prefix: "/sejarah",
|
||||||
tags: ["Desa/Profile"],
|
tags: ["Desa/Profile"],
|
||||||
})
|
})
|
||||||
.get("/first", async (context) => {
|
.get("/first", async () => {
|
||||||
const response = await sejarahDesaFindFirst(new Request(context.request));
|
const response = await sejarahDesaFindFirst();
|
||||||
return response;
|
return response;
|
||||||
})
|
})
|
||||||
.get("/:id", async (context) => {
|
.get("/:id", async (context) => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Context } from "elysia";
|
|||||||
|
|
||||||
export default async function sejarahDesaUpdate(context: Context) {
|
export default async function sejarahDesaUpdate(context: Context) {
|
||||||
// ✅ Authentication check
|
// ✅ Authentication check
|
||||||
const authResult = await requireAuth(context);
|
const authResult = await requireAuth();
|
||||||
if (!authResult.authenticated) {
|
if (!authResult.authenticated) {
|
||||||
return authResult.response;
|
return authResult.response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ 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 category = isImage ? "image" : "document";
|
const isAudio = file.type.startsWith("audio/");
|
||||||
|
const category = isImage ? "image" : isAudio ? "audio" : "document";
|
||||||
|
|
||||||
const pathName = category === "image" ? "images" : "documents";
|
const pathName = category === "image" ? "images" : category === "audio" ? "audio" : "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 });
|
||||||
|
|
||||||
@@ -54,6 +55,11 @@ 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") {
|
||||||
|
|||||||
@@ -8,24 +8,23 @@ 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;
|
||||||
imageId: string;
|
name?: string;
|
||||||
fileId: string;
|
deskripsi?: 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 {
|
||||||
@@ -33,43 +32,44 @@ export default async function apbdesCreate(context: Context) {
|
|||||||
if (!body.tahun) {
|
if (!body.tahun) {
|
||||||
throw new Error('Tahun is required');
|
throw new Error('Tahun is required');
|
||||||
}
|
}
|
||||||
if (!body.imageId) {
|
// Image dan file sekarang opsional
|
||||||
throw new Error('Image ID is required');
|
|
||||||
}
|
|
||||||
if (!body.fileId) {
|
|
||||||
throw new Error('File ID is required');
|
|
||||||
}
|
|
||||||
if (!body.items || body.items.length === 0) {
|
if (!body.items || body.items.length === 0) {
|
||||||
throw new Error('At least one item is required');
|
throw new Error('At least one item is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Buat APBDes + items (tanpa parentId dulu)
|
// 1. Buat APBDes + items dengan auto-calculate fields
|
||||||
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: `APBDes 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, // null jika tidak ada
|
||||||
|
fileId: body.fileId || null, // null jika tidak ada
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create items in a batch
|
// Create items dengan auto-calculate totalRealisasi=0, selisih, persentase
|
||||||
const items = await Promise.all(
|
const items = await Promise.all(
|
||||||
body.items.map(item => {
|
body.items.map(async item => {
|
||||||
// Create a new object with only the fields that exist in the APBDesItem model
|
const anggaran = item.anggaran;
|
||||||
|
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: item.anggaran,
|
anggaran: anggaran,
|
||||||
realisasi: item.realisasi,
|
|
||||||
selisih: item.selisih,
|
|
||||||
persentase: item.persentase,
|
|
||||||
level: item.level,
|
level: item.level,
|
||||||
tipe: item.tipe, // ✅ sertakan, biar null
|
tipe: item.tipe || null,
|
||||||
|
totalRealisasi,
|
||||||
|
selisih,
|
||||||
|
persentase,
|
||||||
apbdesId: apbdes.id,
|
apbdesId: apbdes.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
return prisma.aPBDesItem.create({
|
return prisma.aPBDesItem.create({
|
||||||
data: itemData,
|
data: itemData,
|
||||||
select: { id: true, kode: true },
|
select: { id: true, kode: true },
|
||||||
@@ -84,20 +84,27 @@ 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
|
// 3. Ambil ulang data lengkap untuk response (include realisasiItems)
|
||||||
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' },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('APBDes created successfully:', JSON.stringify(result, null, 2));
|
console.log('APBDes created successfully:', JSON.stringify(result, null, 2));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Berhasil membuat APBDes",
|
message: "Berhasil membuat APBDes",
|
||||||
@@ -105,7 +112,6 @@ 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",
|
||||||
@@ -115,13 +121,12 @@ 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);
|
||||||
|
|
||||||
const errorMessage = error.message || 'Unknown error';
|
const errorMessage = error.message || 'Unknown error';
|
||||||
|
|
||||||
context.set.status = 500;
|
context.set.status = 500;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default async function apbdesFindMany(context: Context) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const where: any = { isActive: true };
|
const where: any = { isActive: true };
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ name: { contains: search, mode: "insensitive" } },
|
{ name: { contains: search, mode: "insensitive" } },
|
||||||
@@ -47,7 +47,16 @@ export default async function apbdesFindMany(context: Context) {
|
|||||||
include: {
|
include: {
|
||||||
image: true,
|
image: true,
|
||||||
file: true,
|
file: true,
|
||||||
items: true,
|
items: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { kode: "asc" },
|
||||||
|
include: {
|
||||||
|
realisasiItems: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { tanggal: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.aPBDes.count({ where }),
|
prisma.aPBDes.count({ where }),
|
||||||
|
|||||||
@@ -2,15 +2,9 @@ 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;
|
||||||
@@ -20,9 +14,9 @@ export default async function apbdesFindUnique(context: Context) {
|
|||||||
debug: { pathSegments }
|
debug: { pathSegments }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathSegments[0] !== 'api' ||
|
if (pathSegments[0] !== 'api' ||
|
||||||
pathSegments[1] !== 'landingpage' ||
|
pathSegments[1] !== 'landingpage' ||
|
||||||
pathSegments[2] !== 'apbdes') {
|
pathSegments[2] !== 'apbdes') {
|
||||||
context.set.status = 400;
|
context.set.status = 400;
|
||||||
return {
|
return {
|
||||||
@@ -31,9 +25,9 @@ export default async function apbdesFindUnique(context: Context) {
|
|||||||
debug: { pathSegments }
|
debug: { pathSegments }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = pathSegments[3]; // ✅ ID ada di index ke-3
|
const id = pathSegments[3];
|
||||||
|
|
||||||
if (!id || id.trim() === '') {
|
if (!id || id.trim() === '') {
|
||||||
context.set.status = 400;
|
context.set.status = 400;
|
||||||
return {
|
return {
|
||||||
@@ -48,11 +42,17 @@ 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) {
|
||||||
|
|||||||
@@ -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
|
// Definisikan skema untuk item APBDes (tanpa realisasi field)
|
||||||
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()])) // misal: "pendapatan" atau "belanja"
|
tipe: t.Optional(t.Union([t.String(), t.Null()])), // "pendapatan" | "belanja" | "pembiayaan" | null
|
||||||
});
|
});
|
||||||
|
|
||||||
const APBDes = new Elysia({
|
const APBDes = new Elysia({
|
||||||
@@ -26,33 +26,72 @@ 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
|
// ✅ Find by ID (include realisasiItems)
|
||||||
.get("/:id", apbdesFindUnique)
|
.get("/:id", apbdesFindUnique)
|
||||||
|
|
||||||
// ✅ Create
|
// ✅ Create APBDes dengan items (tanpa realisasi)
|
||||||
.post("/create", apbdesCreate, {
|
.post("/create", apbdesCreate, {
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
tahun: t.Number(),
|
tahun: t.Number(),
|
||||||
imageId: t.String(),
|
name: t.Optional(t.String()),
|
||||||
fileId: t.String(),
|
deskripsi: t.Optional(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
|
// ✅ Update APBDes dengan items (tanpa realisasi)
|
||||||
.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(),
|
||||||
imageId: t.String(),
|
name: t.Optional(t.String()),
|
||||||
fileId: t.String(),
|
deskripsi: t.Optional(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
|
// ✅ Delete APBDes
|
||||||
.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;
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
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;
|
||||||
imageId: string;
|
name?: string;
|
||||||
fileId: string;
|
deskripsi?: string;
|
||||||
|
jumlah?: string;
|
||||||
|
imageId?: string | null;
|
||||||
|
fileId?: string | null;
|
||||||
items: APBDesItemInput[];
|
items: APBDesItemInput[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,6 +29,16 @@ export default async function apbdesUpdate(context: Context) {
|
|||||||
// 1. Pastikan APBDes ada
|
// 1. Pastikan APBDes ada
|
||||||
const existing = await prisma.aPBDes.findUnique({
|
const existing = await prisma.aPBDes.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
where: { isActive: true },
|
||||||
|
include: {
|
||||||
|
realisasiItems: {
|
||||||
|
where: { isActive: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -38,59 +49,144 @@ export default async function apbdesUpdate(context: Context) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Hapus semua item lama
|
// 2. Build map untuk preserve realisasiItems berdasarkan kode
|
||||||
|
const existingItemsMap = new Map<string, {
|
||||||
|
id: string;
|
||||||
|
realisasiItems: RealisasiItem[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
existing.items.forEach(item => {
|
||||||
|
existingItemsMap.set(item.kode, {
|
||||||
|
id: item.id,
|
||||||
|
realisasiItems: item.realisasiItems,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Hapus semua item lama (cascade akan menghapus realisasiItems juga)
|
||||||
|
// TAPI kita sudah save realisasiItems di map atas
|
||||||
await prisma.aPBDesItem.deleteMany({
|
await prisma.aPBDesItem.deleteMany({
|
||||||
where: { apbdesId: id },
|
where: { apbdesId: id },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Buat item baru tanpa parentId terlebih dahulu
|
// 4. Buat item baru dengan preserve realisasiItems
|
||||||
await prisma.aPBDesItem.createMany({
|
await prisma.aPBDesItem.createMany({
|
||||||
data: body.items.map((item) => ({
|
data: await Promise.all(body.items.map(async (item) => {
|
||||||
apbdesId: id,
|
const anggaran = item.anggaran;
|
||||||
kode: item.kode,
|
|
||||||
uraian: item.uraian,
|
// Check apakah item ini punya realisasiItems lama
|
||||||
anggaran: item.anggaran,
|
const existingItem = existingItemsMap.get(item.kode);
|
||||||
realisasi: item.realisasi,
|
const realisasiItemsData = existingItem?.realisasiItems || [];
|
||||||
selisih: item.anggaran - item.realisasi,
|
const totalRealisasi = realisasiItemsData.reduce((sum, r) => sum + r.jumlah, 0);
|
||||||
persentase: item.anggaran > 0 ? (item.realisasi / item.anggaran) * 100 : 0,
|
const selisih = anggaran - totalRealisasi;
|
||||||
level: item.level,
|
const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0;
|
||||||
tipe: item.tipe || null,
|
|
||||||
isActive: true,
|
return {
|
||||||
|
apbdesId: id,
|
||||||
|
kode: item.kode,
|
||||||
|
uraian: item.uraian,
|
||||||
|
anggaran: anggaran,
|
||||||
|
level: item.level,
|
||||||
|
tipe: item.tipe || null,
|
||||||
|
totalRealisasi,
|
||||||
|
selisih,
|
||||||
|
persentase,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Dapatkan semua item yang baru dibuat untuk mendapatkan ID-nya
|
// 5. Dapatkan semua item yang baru dibuat untuk mendapatkan ID-nya
|
||||||
const allItems = await prisma.aPBDesItem.findMany({
|
const allItems = await prisma.aPBDesItem.findMany({
|
||||||
where: { apbdesId: id },
|
where: { apbdesId: id },
|
||||||
select: { id: true, kode: true },
|
select: { id: true, kode: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Update parentId untuk setiap item
|
// 6. Build map baru untuk item IDs
|
||||||
// Pastikan allItems memiliki tipe yang benar
|
const newItemIdsMap = new Map<string, string>();
|
||||||
|
allItems.forEach(item => {
|
||||||
|
newItemIdsMap.set(item.kode, item.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Re-create realisasiItems dengan link ke item IDs yang baru
|
||||||
|
for (const [oldKode, oldItemData] of existingItemsMap.entries()) {
|
||||||
|
if (oldItemData.realisasiItems.length > 0) {
|
||||||
|
const newItemId = newItemIdsMap.get(oldKode);
|
||||||
|
if (newItemId) {
|
||||||
|
// Re-create realisasiItems untuk item ini
|
||||||
|
await prisma.realisasiItem.createMany({
|
||||||
|
data: oldItemData.realisasiItems.map(r => ({
|
||||||
|
apbdesItemId: newItemId,
|
||||||
|
kode: r.kode,
|
||||||
|
jumlah: r.jumlah,
|
||||||
|
tanggal: r.tanggal,
|
||||||
|
keterangan: r.keterangan,
|
||||||
|
buktiFileId: r.buktiFileId,
|
||||||
|
isActive: true,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Recalculate totalRealisasi setelah re-create realisasiItems
|
||||||
|
for (const kode of existingItemsMap.keys()) {
|
||||||
|
const newItemId = newItemIdsMap.get(kode);
|
||||||
|
if (newItemId) {
|
||||||
|
const realisasiItems = await prisma.realisasiItem.findMany({
|
||||||
|
where: { apbdesItemId: newItemId, isActive: true },
|
||||||
|
});
|
||||||
|
const totalRealisasi = realisasiItems.reduce((sum, r) => sum + r.jumlah, 0);
|
||||||
|
|
||||||
|
const item = await prisma.aPBDesItem.findUnique({
|
||||||
|
where: { id: newItemId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
const selisih = item.anggaran - totalRealisasi;
|
||||||
|
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
|
||||||
|
|
||||||
|
await prisma.aPBDesItem.update({
|
||||||
|
where: { id: newItemId },
|
||||||
|
data: { totalRealisasi, selisih, persentase },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Update parentId untuk setiap item
|
||||||
const itemsForParentUpdate = allItems.map(item => ({
|
const itemsForParentUpdate = allItems.map(item => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
kode: item.kode,
|
kode: item.kode,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await assignParentIdsToApbdesItems(itemsForParentUpdate);
|
await assignParentIdsToApbdesItems(itemsForParentUpdate);
|
||||||
|
|
||||||
// 6. Update data APBDes
|
// 10. Update data APBDes
|
||||||
await prisma.aPBDes.update({
|
await prisma.aPBDes.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
tahun: body.tahun,
|
tahun: body.tahun,
|
||||||
imageId: body.imageId,
|
name: body.name || `APBDes Tahun ${body.tahun}`,
|
||||||
fileId: body.fileId,
|
deskripsi: body.deskripsi,
|
||||||
|
jumlah: body.jumlah,
|
||||||
|
imageId: body.imageId === '' ? null : body.imageId,
|
||||||
|
fileId: body.fileId === '' ? null : body.fileId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Ambil data lengkap untuk response
|
// 11. Ambil data lengkap untuk response (include realisasiItems)
|
||||||
const result = await prisma.aPBDes.findUnique({
|
const result = await prisma.aPBDes.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
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,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { getMenuIdsByRoleId } from "@/app/admin/(dashboard)/user&role/_com/getMenuIdByRole";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { Context } from "elysia";
|
import { Context } from "elysia";
|
||||||
|
|
||||||
@@ -34,11 +35,25 @@ export default async function userUpdate(context: Context) {
|
|||||||
const isActiveChanged =
|
const isActiveChanged =
|
||||||
isActive !== undefined && currentUser.isActive !== isActive;
|
isActive !== undefined && currentUser.isActive !== isActive;
|
||||||
|
|
||||||
// ✅ Jika role berubah, hapus semua akses menu yang ada
|
// ✅ Jika role berubah, reset dan set ulang akses menu
|
||||||
if (isRoleChanged) {
|
if (isRoleChanged && roleId) {
|
||||||
|
// Hapus akses lama
|
||||||
await prisma.userMenuAccess.deleteMany({
|
await prisma.userMenuAccess.deleteMany({
|
||||||
where: { userId: id }
|
where: { userId: id }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ambil menu default untuk role baru
|
||||||
|
const menuIds = getMenuIdsByRoleId(roleId);
|
||||||
|
|
||||||
|
if (menuIds.length > 0) {
|
||||||
|
// Buat akses baru
|
||||||
|
await prisma.userMenuAccess.createMany({
|
||||||
|
data: menuIds.map(menuId => ({
|
||||||
|
userId: id,
|
||||||
|
menuId
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user
|
// Update user
|
||||||
|
|||||||
@@ -46,11 +46,17 @@ fs.mkdir(UPLOAD_DIR_IMAGE, {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
const corsConfig = {
|
const corsConfig = {
|
||||||
origin: "*",
|
origin: [
|
||||||
methods: ["GET", "POST", "PATCH", "DELETE", "PUT"] as HTTPMethod[],
|
"http://localhost:3000",
|
||||||
allowedHeaders: "*",
|
"http://localhost:3001",
|
||||||
|
"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: 5,
|
maxAge: 86400, // 24 hours
|
||||||
credentials: true,
|
credentials: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -33,37 +33,34 @@ export async function POST(req: Request) {
|
|||||||
const codeOtp = randomOTP();
|
const codeOtp = randomOTP();
|
||||||
const otpNumber = Number(codeOtp);
|
const otpNumber = Number(codeOtp);
|
||||||
|
|
||||||
// ✅ PERBAIKAN: Gunakan format pesan yang lebih sederhana
|
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}.`;
|
||||||
// Hapus karakter khusus yang bisa bikin masalah
|
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
|
||||||
// const waMessage = `Website Desa Darmasaba\nKode verifikasi Anda ${codeOtp}`;
|
|
||||||
|
|
||||||
// // ✅ OPSI 1: Tanpa encoding (coba dulu ini)
|
console.log("🔍 Debug WA URL:", waUrl);
|
||||||
// const waUrl = `https://wa.wibudev.com/code?nom=${nomor}&text=${waMessage}`;
|
|
||||||
|
|
||||||
// ✅ OPSI 2: Dengan encoding (kalau opsi 1 gagal)
|
try {
|
||||||
// const waUrl = `https://wa.wibudev.com/code?nom=${nomor}&text=${encodeURIComponent(waMessage)}`;
|
const res = await fetch(waUrl);
|
||||||
|
const sendWa = await res.json();
|
||||||
// ✅ OPSI 3: Encoding manual untuk URL-safe (alternatif terakhir)
|
console.log("📱 WA Response:", sendWa);
|
||||||
// const encodedMessage = waMessage.replace(/\n/g, '%0A').replace(/ /g, '%20');
|
|
||||||
// const waUrl = `https://wa.wibudev.com/code?nom=${nomor}&text=${encodedMessage}`;
|
|
||||||
|
|
||||||
// console.log("🔍 Debug WA URL:", waUrl); // Untuk debugging
|
if (sendWa.status !== "success") {
|
||||||
|
console.error("❌ WA Service Error:", sendWa);
|
||||||
// const res = await fetch(waUrl);
|
return NextResponse.json(
|
||||||
// const sendWa = await res.json();
|
{
|
||||||
|
success: false,
|
||||||
// console.log("📱 WA Response:", sendWa); // Debug response
|
message: "Gagal mengirim OTP via WhatsApp",
|
||||||
|
debug: sendWa
|
||||||
// if (sendWa.status !== "success") {
|
},
|
||||||
// return NextResponse.json(
|
{ status: 400 }
|
||||||
// {
|
);
|
||||||
// success: false,
|
}
|
||||||
// message: "Gagal mengirim OTP via WhatsApp",
|
} catch (waError) {
|
||||||
// debug: sendWa // Tampilkan error detail
|
console.error("❌ Fetch WA Error:", waError);
|
||||||
// },
|
return NextResponse.json(
|
||||||
// { status: 400 }
|
{ success: false, message: "Terjadi kesalahan saat mengirim WA" },
|
||||||
// );
|
{ status: 500 }
|
||||||
// }
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const createOtpId = await prisma.kodeOtp.create({
|
const createOtpId = await prisma.kodeOtp.create({
|
||||||
data: { nomor, otp: otpNumber, isActive: true },
|
data: { nomor, otp: otpNumber, isActive: true },
|
||||||
|
|||||||
@@ -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 = `Kode verifikasi Anda: ${codeOtp}`;
|
const waMessage = `Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.\n\n>> Kode OTP anda: ${codeOtp}.`;
|
||||||
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
|
const 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();
|
||||||
|
|||||||
320
src/app/context/MusicContext.tsx
Normal file
320
src/app/context/MusicContext.tsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
interface MusicFile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
realName: string;
|
||||||
|
path: string;
|
||||||
|
mimeType: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Musik {
|
||||||
|
id: string;
|
||||||
|
judul: string;
|
||||||
|
artis: string;
|
||||||
|
deskripsi: string | null;
|
||||||
|
durasi: string;
|
||||||
|
genre: string | null;
|
||||||
|
tahunRilis: number | null;
|
||||||
|
audioFile: MusicFile | null;
|
||||||
|
coverImage: MusicFile | null;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MusicContextType {
|
||||||
|
// State
|
||||||
|
isPlaying: boolean;
|
||||||
|
currentSong: Musik | null;
|
||||||
|
currentSongIndex: number;
|
||||||
|
musikData: Musik[];
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
volume: number;
|
||||||
|
isMuted: boolean;
|
||||||
|
isRepeat: boolean;
|
||||||
|
isShuffle: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
isPlayerOpen: boolean;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
playSong: (song: Musik) => void;
|
||||||
|
togglePlayPause: () => void;
|
||||||
|
playNext: () => void;
|
||||||
|
playPrev: () => void;
|
||||||
|
seek: (time: number) => void;
|
||||||
|
setVolume: (volume: number) => void;
|
||||||
|
toggleMute: () => void;
|
||||||
|
toggleRepeat: () => void;
|
||||||
|
toggleShuffle: () => void;
|
||||||
|
togglePlayer: () => void;
|
||||||
|
loadMusikData: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MusicContext = createContext<MusicContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function MusicProvider({ children }: { children: ReactNode }) {
|
||||||
|
// State
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentSong, setCurrentSong] = useState<Musik | null>(null);
|
||||||
|
const [currentSongIndex, setCurrentSongIndex] = useState(-1);
|
||||||
|
const [musikData, setMusikData] = useState<Musik[]>([]);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [volume, setVolumeState] = useState(70);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [isRepeat, setIsRepeat] = useState(false);
|
||||||
|
const [isShuffle, setIsShuffle] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const isSeekingRef = useRef(false);
|
||||||
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
|
const isRepeatRef = useRef(false); // Ref untuk avoid stale closure
|
||||||
|
|
||||||
|
// Sync ref dengan state
|
||||||
|
useEffect(() => {
|
||||||
|
isRepeatRef.current = isRepeat;
|
||||||
|
}, [isRepeat]);
|
||||||
|
|
||||||
|
// Load musik data
|
||||||
|
const loadMusikData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const res = await fetch('/api/desa/musik/find-many?page=1&limit=50');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.data) {
|
||||||
|
const activeMusik = data.data.filter((m: Musik) => m.isActive);
|
||||||
|
setMusikData(activeMusik);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching musik:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize audio element
|
||||||
|
useEffect(() => {
|
||||||
|
audioRef.current = new Audio();
|
||||||
|
audioRef.current.preload = 'metadata';
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
audioRef.current.addEventListener('loadedmetadata', () => {
|
||||||
|
setDuration(Math.floor(audioRef.current!.duration));
|
||||||
|
});
|
||||||
|
|
||||||
|
audioRef.current.addEventListener('ended', () => {
|
||||||
|
// Gunakan ref untuk avoid stale closure
|
||||||
|
if (isRepeatRef.current) {
|
||||||
|
audioRef.current!.currentTime = 0;
|
||||||
|
audioRef.current!.play();
|
||||||
|
} else {
|
||||||
|
playNext();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load initial data
|
||||||
|
loadMusikData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current = null;
|
||||||
|
}
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- playNext is intentionally not in deps to avoid circular dependency
|
||||||
|
}, [loadMusikData]); // Remove isRepeat dari deps karena sudah pakai ref
|
||||||
|
|
||||||
|
// Update time with requestAnimationFrame for smooth progress
|
||||||
|
const updateTime = useCallback(() => {
|
||||||
|
if (audioRef.current && !audioRef.current.paused && !isSeekingRef.current) {
|
||||||
|
setCurrentTime(Math.floor(audioRef.current.currentTime));
|
||||||
|
animationFrameRef.current = requestAnimationFrame(updateTime);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPlaying) {
|
||||||
|
animationFrameRef.current = requestAnimationFrame(updateTime);
|
||||||
|
} else {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isPlaying, updateTime]);
|
||||||
|
|
||||||
|
// Play song
|
||||||
|
const playSong = useCallback(
|
||||||
|
(song: Musik) => {
|
||||||
|
if (!song?.audioFile?.link || !audioRef.current) return;
|
||||||
|
|
||||||
|
const songIndex = musikData.findIndex(m => m.id === song.id);
|
||||||
|
setCurrentSongIndex(songIndex);
|
||||||
|
setCurrentSong(song);
|
||||||
|
setIsPlaying(true);
|
||||||
|
|
||||||
|
audioRef.current.src = song.audioFile.link;
|
||||||
|
audioRef.current.load();
|
||||||
|
audioRef.current
|
||||||
|
.play()
|
||||||
|
.catch((err) => console.error('Error playing audio:', err));
|
||||||
|
},
|
||||||
|
[musikData]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle play/pause
|
||||||
|
const togglePlayPause = useCallback(() => {
|
||||||
|
if (!audioRef.current || !currentSong) return;
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
setIsPlaying(false);
|
||||||
|
} else {
|
||||||
|
audioRef.current
|
||||||
|
.play()
|
||||||
|
.then(() => setIsPlaying(true))
|
||||||
|
.catch((err) => console.error('Error playing audio:', err));
|
||||||
|
}
|
||||||
|
}, [isPlaying, currentSong]);
|
||||||
|
|
||||||
|
// Play next
|
||||||
|
const playNext = useCallback(() => {
|
||||||
|
if (musikData.length === 0) return;
|
||||||
|
|
||||||
|
let nextIndex: number;
|
||||||
|
if (isShuffle) {
|
||||||
|
nextIndex = Math.floor(Math.random() * musikData.length);
|
||||||
|
} else {
|
||||||
|
nextIndex = (currentSongIndex + 1) % musikData.length;
|
||||||
|
}
|
||||||
|
const nextSong = musikData[nextIndex];
|
||||||
|
if (nextSong) {
|
||||||
|
playSong(nextSong);
|
||||||
|
}
|
||||||
|
}, [musikData, isShuffle, currentSongIndex, playSong]);
|
||||||
|
|
||||||
|
// Play previous
|
||||||
|
const playPrev = useCallback(() => {
|
||||||
|
if (musikData.length === 0) return;
|
||||||
|
|
||||||
|
// If more than 3 seconds into song, restart it
|
||||||
|
if (currentTime > 3) {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevIndex =
|
||||||
|
currentSongIndex <= 0 ? musikData.length - 1 : currentSongIndex - 1;
|
||||||
|
const prevSong = musikData[prevIndex];
|
||||||
|
if (prevSong) {
|
||||||
|
playSong(prevSong);
|
||||||
|
}
|
||||||
|
}, [musikData, currentSongIndex, currentTime, playSong]);
|
||||||
|
|
||||||
|
// Seek
|
||||||
|
const seek = useCallback((time: number) => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
audioRef.current.currentTime = time;
|
||||||
|
setCurrentTime(time);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set volume
|
||||||
|
const setVolume = useCallback((vol: number) => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
const normalizedVol = Math.max(0, Math.min(100, vol)) / 100;
|
||||||
|
audioRef.current.volume = normalizedVol;
|
||||||
|
setVolumeState(Math.max(0, Math.min(100, vol)));
|
||||||
|
setIsMuted(normalizedVol === 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Toggle mute
|
||||||
|
const toggleMute = useCallback(() => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
|
const newMuted = !isMuted;
|
||||||
|
audioRef.current.muted = newMuted;
|
||||||
|
setIsMuted(newMuted);
|
||||||
|
|
||||||
|
if (newMuted && volume > 0) {
|
||||||
|
audioRef.current.volume = 0;
|
||||||
|
} else if (!newMuted && volume > 0) {
|
||||||
|
audioRef.current.volume = volume / 100;
|
||||||
|
}
|
||||||
|
}, [isMuted, volume]);
|
||||||
|
|
||||||
|
// Toggle repeat
|
||||||
|
const toggleRepeat = useCallback(() => {
|
||||||
|
setIsRepeat((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Toggle shuffle
|
||||||
|
const toggleShuffle = useCallback(() => {
|
||||||
|
setIsShuffle((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Toggle player
|
||||||
|
const togglePlayer = useCallback(() => {
|
||||||
|
setIsPlayerOpen((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value: MusicContextType = {
|
||||||
|
isPlaying,
|
||||||
|
currentSong,
|
||||||
|
currentSongIndex,
|
||||||
|
musikData,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
volume,
|
||||||
|
isMuted,
|
||||||
|
isRepeat,
|
||||||
|
isShuffle,
|
||||||
|
isLoading,
|
||||||
|
isPlayerOpen,
|
||||||
|
playSong,
|
||||||
|
togglePlayPause,
|
||||||
|
playNext,
|
||||||
|
playPrev,
|
||||||
|
seek,
|
||||||
|
setVolume,
|
||||||
|
toggleMute,
|
||||||
|
toggleRepeat,
|
||||||
|
toggleShuffle,
|
||||||
|
togglePlayer,
|
||||||
|
loadMusikData,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MusicContext.Provider value={value}>{children}</MusicContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMusic() {
|
||||||
|
const context = useContext(MusicContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useMusic must be used within a MusicProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -3,10 +3,43 @@
|
|||||||
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 { Box, Center, Container, Group, Image, Skeleton, Stack, Text, Title } from '@mantine/core';
|
import {
|
||||||
|
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 }>();
|
||||||
@@ -45,13 +78,30 @@ 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}
|
||||||
@@ -59,41 +109,108 @@ 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"
|
||||||
>
|
>
|
||||||
{state.findUnique.data.judul}
|
{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" />
|
|
||||||
</Container>
|
{/* Featured Image */}
|
||||||
<Box px={{ base: 'md', md: 100 }}>
|
{data.image?.link && (
|
||||||
<Stack gap="xs">
|
<Image
|
||||||
|
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.6, md: 1.8 }}
|
lh={{ base: 1.8, md: 2 }}
|
||||||
ta="justify"
|
ta="justify"
|
||||||
|
c="dimmed"
|
||||||
style={{
|
style={{
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
whiteSpace: 'normal',
|
whiteSpace: 'normal',
|
||||||
}}
|
}}
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: state.findUnique.data.content || '',
|
__html: data.content || '',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Box>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|||||||
32
src/app/darmasaba/(pages)/musik/lib/nextPrev.ts
Normal file
32
src/app/darmasaba/(pages)/musik/lib/nextPrev.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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);
|
||||||
24
src/app/darmasaba/(pages)/musik/lib/playPause.ts
Normal file
24
src/app/darmasaba/(pages)/musik/lib/playPause.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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)
|
||||||
|
// }
|
||||||
22
src/app/darmasaba/(pages)/musik/lib/repeat.ts
Normal file
22
src/app/darmasaba/(pages)/musik/lib/repeat.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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)
|
||||||
|
// }
|
||||||
19
src/app/darmasaba/(pages)/musik/lib/seek.ts
Normal file
19
src/app/darmasaba/(pages)/musik/lib/seek.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/app/darmasaba/(pages)/musik/lib/shuffle.ts
Normal file
6
src/app/darmasaba/(pages)/musik/lib/shuffle.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export function toggleShuffle(
|
||||||
|
isShuffle: boolean,
|
||||||
|
setIsShuffle: (v: boolean) => void
|
||||||
|
) {
|
||||||
|
setIsShuffle(!isShuffle);
|
||||||
|
}
|
||||||
145
src/app/darmasaba/(pages)/musik/lib/useAudioProgress.ts
Normal file
145
src/app/darmasaba/(pages)/musik/lib/useAudioProgress.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
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,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
29
src/app/darmasaba/(pages)/musik/lib/volume.ts
Normal file
29
src/app/darmasaba/(pages)/musik/lib/volume.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,69 +1,101 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
'use client'
|
'use client'
|
||||||
import { ActionIcon, Avatar, Badge, Box, Card, Flex, Grid, Group, Paper, Slider, Stack, Text, TextInput } from '@mantine/core';
|
import { useMusic } from '@/app/context/MusicContext';
|
||||||
|
import { ActionIcon, Avatar, Badge, Box, Card, Flex, Grid, Group, Paper, ScrollArea, Slider, Stack, Text, TextInput } from '@mantine/core';
|
||||||
import { IconArrowsShuffle, IconPlayerPauseFilled, IconPlayerPlayFilled, IconPlayerSkipBackFilled, IconPlayerSkipForwardFilled, IconRepeat, IconRepeatOff, IconSearch, IconVolume, IconVolumeOff, IconX } from '@tabler/icons-react';
|
import { IconArrowsShuffle, IconPlayerPauseFilled, IconPlayerPlayFilled, IconPlayerSkipBackFilled, IconPlayerSkipForwardFilled, IconRepeat, IconRepeatOff, IconSearch, IconVolume, IconVolumeOff, IconX } from '@tabler/icons-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import BackButton from '../../desa/layanan/_com/BackButto';
|
import BackButton from '../../desa/layanan/_com/BackButto';
|
||||||
|
|
||||||
const MusicPlayer = () => {
|
const MusicPlayer = () => {
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const {
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
isPlaying,
|
||||||
const [duration, setDuration] = useState(245);
|
currentSong,
|
||||||
const [volume, setVolume] = useState(70);
|
currentTime,
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
duration,
|
||||||
const [isRepeat, setIsRepeat] = useState(false);
|
volume,
|
||||||
const [isShuffle, setIsShuffle] = useState(false);
|
isMuted,
|
||||||
|
isRepeat,
|
||||||
|
isShuffle,
|
||||||
|
isLoading,
|
||||||
|
musikData,
|
||||||
|
playSong,
|
||||||
|
togglePlayPause,
|
||||||
|
playNext,
|
||||||
|
playPrev,
|
||||||
|
seek,
|
||||||
|
setVolume,
|
||||||
|
toggleMute,
|
||||||
|
toggleRepeat,
|
||||||
|
toggleShuffle,
|
||||||
|
} = useMusic();
|
||||||
|
|
||||||
const songs = [
|
const [search, setSearch] = useState('');
|
||||||
{ 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' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const [currentSong, setCurrentSong] = useState(songs[0]);
|
// Fetch musik data from global state
|
||||||
|
const { loadMusikData } = useMusic();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let interval: any;
|
loadMusikData();
|
||||||
if (isPlaying) {
|
}, [loadMusikData]);
|
||||||
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 playSong = (song: any) => {
|
const handleVolumeChange = (value: number) => {
|
||||||
setCurrentSong(song);
|
setVolume(value);
|
||||||
setCurrentTime(0);
|
|
||||||
setIsPlaying(true);
|
|
||||||
const durationInSeconds = parseInt(song.duration.split(':')[0]) * 60 + parseInt(song.duration.split(':')[1]);
|
|
||||||
setDuration(durationInSeconds);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleMute = () => {
|
const toggleMuteHandler = () => {
|
||||||
setIsMuted(!isMuted);
|
toggleMute();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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: 'md', md: 100 }} py="xl">
|
<Box px={{ base: 'xs', sm: 'md', md: 100 }} py="xl">
|
||||||
<Paper
|
<Paper
|
||||||
mx="auto"
|
mx="auto"
|
||||||
p="xl"
|
p={{ base: 'md', sm: 'xl' }}
|
||||||
radius="lg"
|
radius="lg"
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
bg="white"
|
bg="white"
|
||||||
@@ -73,138 +105,195 @@ const MusicPlayer = () => {
|
|||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<BackButton />
|
<BackButton />
|
||||||
<Group justify="space-between" mb="xl" mt={"md"}>
|
<Flex
|
||||||
|
justify="space-between"
|
||||||
|
align={{ base: 'flex-start', sm: 'center' }}
|
||||||
|
direction={{ base: 'column', sm: 'row' }}
|
||||||
|
gap="md"
|
||||||
|
mb="xl"
|
||||||
|
mt="md"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<Text size="32px" fw={700} c="#0B4F78">Selamat Datang Kembali</Text>
|
<Text fz={{ base: '24px', sm: '32px' }} fw={700} c="#0B4F78" lh={1.2}>Selamat Datang Kembali</Text>
|
||||||
<Text size="md" c="#5A6C7D">Temukan musik favorit Anda hari ini</Text>
|
<Text size="sm" c="#5A6C7D">Temukan musik favorit Anda hari ini</Text>
|
||||||
</div>
|
</div>
|
||||||
<Group gap="md">
|
<TextInput
|
||||||
<TextInput
|
placeholder="Cari lagu..."
|
||||||
placeholder="Cari lagu..."
|
leftSection={<IconSearch size={18} />}
|
||||||
leftSection={<IconSearch size={18} />}
|
radius="xl"
|
||||||
radius="xl"
|
w={{ base: '100%', sm: 280 }}
|
||||||
w={280}
|
value={search}
|
||||||
styles={{ input: { backgroundColor: '#fff' } }}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
styles={{ input: { backgroundColor: '#fff' } }}
|
||||||
</Group>
|
/>
|
||||||
</Group>
|
</Flex>
|
||||||
|
|
||||||
<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>
|
||||||
<Card radius="md" p="xl" shadow="md">
|
{currentSong ? (
|
||||||
<Group align="center" gap="xl">
|
<Card radius="md" p={{ base: 'md', sm: 'xl' }} shadow="md" withBorder>
|
||||||
<Avatar src={currentSong.cover} size={180} radius="md" />
|
<Flex
|
||||||
<Stack gap="md" style={{ flex: 1 }}>
|
direction={{ base: 'column', sm: 'row' }}
|
||||||
<div>
|
align="center"
|
||||||
<Text size="28px" fw={700} c="#0B4F78">{currentSong.title}</Text>
|
gap={{ base: 'md', sm: 'xl' }}
|
||||||
<Text size="lg" c="#5A6C7D">{currentSong.artist}</Text>
|
>
|
||||||
</div>
|
<Avatar
|
||||||
<Group gap="xs" align="center">
|
src={currentSong.coverImage?.link || '/mp3-logo.png'}
|
||||||
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text>
|
size={120}
|
||||||
<Slider
|
radius="md"
|
||||||
value={currentTime}
|
/>
|
||||||
max={duration}
|
<Stack gap="md" style={{ flex: 1, width: '100%' }}>
|
||||||
onChange={setCurrentTime}
|
<Box ta={{ base: 'center', sm: 'left' }}>
|
||||||
color="#0B4F78"
|
<Text fz={{ base: '20px', sm: '28px' }} fw={700} c="#0B4F78" lineClamp={1}>{currentSong.judul}</Text>
|
||||||
size="sm"
|
<Text size="lg" c="#5A6C7D">{currentSong.artis}</Text>
|
||||||
style={{ flex: 1 }}
|
{currentSong.genre && (
|
||||||
styles={{ thumb: { borderWidth: 2 } }}
|
<Badge mt="xs" color="#0B4F78" variant="light">{currentSong.genre}</Badge>
|
||||||
/>
|
)}
|
||||||
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration)}</Text>
|
</Box>
|
||||||
</Group>
|
<Group gap="xs" align="center">
|
||||||
</Stack>
|
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text>
|
||||||
</Group>
|
<Slider
|
||||||
</Card>
|
value={currentTime}
|
||||||
|
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>
|
||||||
<Grid gutter="md">
|
{filteredMusik.length === 0 ? (
|
||||||
{songs.map(song => (
|
<Text ta="center" c="dimmed">Tidak ada musik yang ditemukan</Text>
|
||||||
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}>
|
) : (
|
||||||
<Card
|
<ScrollArea.Autosize mah={400}>
|
||||||
radius="md"
|
<Grid gutter="md">
|
||||||
p="md"
|
{filteredMusik.map((song) => (
|
||||||
shadow="sm"
|
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}>
|
||||||
style={{
|
<Card
|
||||||
cursor: 'pointer',
|
radius="md"
|
||||||
border: currentSong.id === song.id ? '2px solid #0B4F78' : '2px solid transparent',
|
p="sm"
|
||||||
transition: 'all 0.2s'
|
shadow="sm"
|
||||||
}}
|
withBorder
|
||||||
onClick={() => playSong(song)}
|
style={{
|
||||||
>
|
cursor: 'pointer',
|
||||||
<Group gap="md" align="center">
|
borderColor: currentSong?.id === song.id ? '#0B4F78' : 'transparent',
|
||||||
<Avatar src={song.cover} size={64} radius="md" />
|
backgroundColor: currentSong?.id === song.id ? '#F0F7FA' : 'white',
|
||||||
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
|
transition: 'all 0.2s'
|
||||||
<Text size="sm" fw={600} c="#0B4F78" truncate>{song.title}</Text>
|
}}
|
||||||
<Text size="xs" c="#5A6C7D">{song.artist}</Text>
|
onClick={() => playSong(song)}
|
||||||
<Text size="xs" c="#8A9BA8">{song.duration}</Text>
|
>
|
||||||
</Stack>
|
<Group gap="sm" align="center" wrap="nowrap">
|
||||||
{currentSong.id === song.id && isPlaying && (
|
<Avatar
|
||||||
<Badge color="#0B4F78" variant="filled">Memutar</Badge>
|
src={song.coverImage?.link || '/mp3-logo.png'}
|
||||||
)}
|
size={50}
|
||||||
</Group>
|
radius="md"
|
||||||
</Card>
|
/>
|
||||||
</Grid.Col>
|
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||||
))}
|
<Text size="sm" fw={600} c="#0B4F78" truncate>{song.judul}</Text>
|
||||||
</Grid>
|
<Text size="xs" c="#5A6C7D" truncate>{song.artis}</Text>
|
||||||
|
</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="xl"
|
p={{ base: 'md', sm: 'xl' }}
|
||||||
radius="lg"
|
radius="lg"
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
bg="white"
|
bg="white"
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid #eaeaea',
|
border: '1px solid #eaeaea',
|
||||||
|
position: 'sticky',
|
||||||
|
bottom: 20,
|
||||||
|
zIndex: 10
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Flex align="center" justify="space-between" gap="xl" h="100%">
|
<Flex
|
||||||
<Group gap="md" style={{ flex: 1 }}>
|
direction={{ base: 'column', md: 'row' }}
|
||||||
<Avatar src={currentSong.cover} size={56} radius="md" />
|
align="center"
|
||||||
|
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 }}>
|
||||||
<Text size="sm" fw={600} c="#0B4F78" truncate>{currentSong.title}</Text>
|
{currentSong ? (
|
||||||
<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>
|
||||||
|
|
||||||
<Stack gap="xs" style={{ flex: 1 }} align="center">
|
{/* Controls + Progress */}
|
||||||
<Group gap="md">
|
<Stack gap="xs" style={{ flex: 2, width: '100%' }} align="center">
|
||||||
|
<Group gap="sm">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={isShuffle ? 'filled' : 'subtle'}
|
variant={isShuffle ? 'filled' : 'subtle'}
|
||||||
color="#0B4F78"
|
color="#0B4F78"
|
||||||
onClick={() => setIsShuffle(!isShuffle)}
|
onClick={toggleShuffleHandler}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
|
size={48}
|
||||||
>
|
>
|
||||||
{isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />}
|
{isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl">
|
<ActionIcon variant="light" color="#0B4F78" size={48} radius="xl" onClick={skipBack}>
|
||||||
<IconPlayerSkipBackFilled size={20} />
|
<IconPlayerSkipBackFilled size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color="#0B4F78"
|
color="#0B4F78"
|
||||||
size={56}
|
size={48}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
onClick={() => setIsPlaying(!isPlaying)}
|
onClick={togglePlayPauseHandler}
|
||||||
>
|
>
|
||||||
{isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />}
|
{isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl">
|
<ActionIcon variant="light" color="#0B4F78" size={48} radius="xl" onClick={skipForward}>
|
||||||
<IconPlayerSkipForwardFilled size={20} />
|
<IconPlayerSkipForwardFilled size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={isRepeat ? 'filled' : 'subtle'}
|
variant={isRepeat ? 'filled' : 'subtle'}
|
||||||
color="#0B4F78"
|
color="#0B4F78"
|
||||||
onClick={() => setIsRepeat(!isRepeat)}
|
onClick={toggleRepeatHandler}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
|
size="md"
|
||||||
>
|
>
|
||||||
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -213,26 +302,24 @@ const MusicPlayer = () => {
|
|||||||
<Text size="xs" c="#5A6C7D" w={40} ta="right">{formatTime(currentTime)}</Text>
|
<Text size="xs" c="#5A6C7D" w={40} ta="right">{formatTime(currentTime)}</Text>
|
||||||
<Slider
|
<Slider
|
||||||
value={currentTime}
|
value={currentTime}
|
||||||
max={duration}
|
max={duration || 100}
|
||||||
onChange={setCurrentTime}
|
onChange={(v) => seek(v)}
|
||||||
color="#0B4F78"
|
color="#0B4F78"
|
||||||
size="xs"
|
size="xs"
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<Text size="xs" c="#5A6C7D" w={40}>{formatTime(duration)}</Text>
|
<Text size="xs" c="#5A6C7D" w={40}>{formatTime(duration || 0)}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Group gap="xs" style={{ flex: 1 }} justify="flex-end">
|
{/* Volume Control - Hidden on mobile, shown on md and up */}
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={toggleMute}>
|
<Group gap="xs" style={{ flex: 1 }} justify="flex-end" visibleFrom="md">
|
||||||
|
<ActionIcon variant="subtle" color="gray" onClick={toggleMuteHandler}>
|
||||||
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
|
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<Slider
|
<Slider
|
||||||
value={isMuted ? 0 : volume}
|
value={isMuted ? 0 : volume}
|
||||||
onChange={(val) => {
|
onChange={handleVolumeChange}
|
||||||
setVolume(val);
|
|
||||||
if (val > 0) setIsMuted(false);
|
|
||||||
}}
|
|
||||||
color="#0B4F78"
|
color="#0B4F78"
|
||||||
size="xs"
|
size="xs"
|
||||||
w={100}
|
w={100}
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ function APBDesProgress({ apbdesData }: APBDesProgressProps) {
|
|||||||
// Hitung total per kategori
|
// Hitung total per kategori
|
||||||
const calcTotal = (items: { anggaran: number; realisasi: number }[]) => {
|
const calcTotal = (items: { anggaran: number; realisasi: number }[]) => {
|
||||||
const anggaran = items.reduce((sum, item) => sum + item.anggaran, 0);
|
const anggaran = items.reduce((sum, item) => sum + item.anggaran, 0);
|
||||||
const realisasi = items.reduce((sum, item) => sum + item.realisasi, 0);
|
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
|
||||||
|
const realisasi = items.reduce((sum, item) => sum + (item.realisasi || 0), 0);
|
||||||
const persen = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
|
const persen = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
|
||||||
return { anggaran, realisasi, persen };
|
return { anggaran, realisasi, persen };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ function APBDesTable({ apbdesData }: APBDesTableProps) {
|
|||||||
|
|
||||||
// Calculate totals
|
// Calculate totals
|
||||||
const totalAnggaran = items.reduce((sum, item) => sum + (item.anggaran || 0), 0);
|
const totalAnggaran = items.reduce((sum, item) => sum + (item.anggaran || 0), 0);
|
||||||
|
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
|
||||||
const totalRealisasi = items.reduce((sum, item) => sum + (item.realisasi || 0), 0);
|
const totalRealisasi = items.reduce((sum, item) => sum + (item.realisasi || 0), 0);
|
||||||
const totalSelisih = totalAnggaran - totalRealisasi;
|
const totalSelisih = totalAnggaran - totalRealisasi;
|
||||||
const totalPersentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
|
const totalPersentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ export function transformAPBDesData(data: any): APBDesData {
|
|||||||
kode: item.kode || '',
|
kode: item.kode || '',
|
||||||
uraian: item.uraian || '',
|
uraian: item.uraian || '',
|
||||||
anggaran: typeof item.anggaran === 'number' ? item.anggaran : 0,
|
anggaran: typeof item.anggaran === 'number' ? item.anggaran : 0,
|
||||||
realisasi: typeof item.realisasi === 'number' ? item.realisasi : 0,
|
// Map totalRealisasi from backend to realisasi field
|
||||||
|
realisasi: typeof item.totalRealisasi === 'number' ? item.totalRealisasi : (typeof item.realisasi === 'number' ? item.realisasi : 0),
|
||||||
selisih: typeof item.selisih === 'number' ? item.selisih : 0,
|
selisih: typeof item.selisih === 'number' ? item.selisih : 0,
|
||||||
persentase: typeof item.persentase === 'number' ? item.persentase : 0,
|
persentase: typeof item.persentase === 'number' ? item.persentase : 0,
|
||||||
level: typeof item.level === 'number' ? item.level : 1,
|
level: typeof item.level === 'number' ? item.level : 1,
|
||||||
|
|||||||
300
src/app/darmasaba/_com/FixedPlayerBar.tsx
Normal file
300
src/app/darmasaba/_com/FixedPlayerBar.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { useMusic } from '@/app/context/MusicContext';
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Flex,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Slider,
|
||||||
|
Text,
|
||||||
|
Transition
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconArrowsShuffle,
|
||||||
|
IconMusic,
|
||||||
|
IconPlayerPauseFilled,
|
||||||
|
IconPlayerPlayFilled,
|
||||||
|
IconPlayerSkipBackFilled,
|
||||||
|
IconPlayerSkipForwardFilled,
|
||||||
|
IconRepeat,
|
||||||
|
IconRepeatOff,
|
||||||
|
IconVolume,
|
||||||
|
IconVolumeOff,
|
||||||
|
IconX,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export default function FixedPlayerBar() {
|
||||||
|
const {
|
||||||
|
isPlaying,
|
||||||
|
currentSong,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
volume,
|
||||||
|
isMuted,
|
||||||
|
isRepeat,
|
||||||
|
isShuffle,
|
||||||
|
togglePlayPause,
|
||||||
|
playNext,
|
||||||
|
playPrev,
|
||||||
|
seek,
|
||||||
|
setVolume,
|
||||||
|
toggleMute,
|
||||||
|
toggleRepeat,
|
||||||
|
toggleShuffle,
|
||||||
|
} = useMusic();
|
||||||
|
|
||||||
|
const [showVolume, setShowVolume] = useState(false);
|
||||||
|
const [isMinimized, setIsMinimized] = useState(false);
|
||||||
|
|
||||||
|
// Format time
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle seek
|
||||||
|
const handleSeek = (value: number) => {
|
||||||
|
seek(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle volume change
|
||||||
|
const handleVolumeChange = (value: number) => {
|
||||||
|
setVolume(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle shuffle toggle
|
||||||
|
const handleToggleShuffle = () => {
|
||||||
|
toggleShuffle();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle minimize player (show floating icon)
|
||||||
|
const handleMinimizePlayer = () => {
|
||||||
|
setIsMinimized(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle restore player from floating icon
|
||||||
|
const handleRestorePlayer = () => {
|
||||||
|
setIsMinimized(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// If minimized, show floating icon instead of player bar
|
||||||
|
if (isMinimized) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Floating Music Icon - Shows when player is minimized */}
|
||||||
|
<Button
|
||||||
|
color="#0B4F78"
|
||||||
|
variant="filled"
|
||||||
|
size="md"
|
||||||
|
mt="md"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '50%',
|
||||||
|
left: '0px',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
borderBottomRightRadius: '20px',
|
||||||
|
borderTopRightRadius: '20px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'transform 0.2s ease',
|
||||||
|
zIndex: 1000 // Higher z-index
|
||||||
|
}}
|
||||||
|
onClick={handleRestorePlayer}
|
||||||
|
>
|
||||||
|
<IconMusic size={24} color="white" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentSong) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mini Player Bar - Always visible when song is playing */}
|
||||||
|
<Paper
|
||||||
|
pos="fixed"
|
||||||
|
bottom={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
p={{ base: 'xs', sm: 'sm' }}
|
||||||
|
shadow="xl"
|
||||||
|
style={{
|
||||||
|
zIndex: 1000,
|
||||||
|
borderTop: '1px solid rgba(0,0,0,0.1)',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex align="center" gap={{ base: 'xs', sm: 'md' }} justify="space-between">
|
||||||
|
{/* Song Info - Left */}
|
||||||
|
<Group gap="xs" flex={{ base: 2, sm: 1 }} style={{ minWidth: 0 }} wrap="nowrap">
|
||||||
|
<Avatar
|
||||||
|
src={currentSong.coverImage?.link || ''}
|
||||||
|
alt={currentSong.judul}
|
||||||
|
size={"36"}
|
||||||
|
radius="sm"
|
||||||
|
/>
|
||||||
|
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Text fz={{ base: 'xs', sm: 'sm' }} fw={600} truncate>
|
||||||
|
{currentSong.judul}
|
||||||
|
</Text>
|
||||||
|
<Text fz="10px" c="dimmed" truncate>
|
||||||
|
{currentSong.artis}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Controls - Center */}
|
||||||
|
<Group gap={"xs"} flex={{ base: 1, sm: 2 }} justify="center" wrap="nowrap">
|
||||||
|
{/* Shuffle - Desktop Only */}
|
||||||
|
<ActionIcon
|
||||||
|
variant={isShuffle ? 'filled' : 'subtle'}
|
||||||
|
color={isShuffle ? '#0B4F78' : 'gray'}
|
||||||
|
size={"md"}
|
||||||
|
onClick={handleToggleShuffle}
|
||||||
|
visibleFrom="sm"
|
||||||
|
>
|
||||||
|
<IconArrowsShuffle size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size={"md"}
|
||||||
|
onClick={playPrev}
|
||||||
|
>
|
||||||
|
<IconPlayerSkipBackFilled size={20} />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="#0B4F78"
|
||||||
|
size={"lg"}
|
||||||
|
radius="xl"
|
||||||
|
onClick={togglePlayPause}
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<IconPlayerPauseFilled size={24} />
|
||||||
|
) : (
|
||||||
|
<IconPlayerPlayFilled size={24} />
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size={"md"}
|
||||||
|
onClick={playNext}
|
||||||
|
>
|
||||||
|
<IconPlayerSkipForwardFilled size={20} />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
{/* Repeat - Desktop Only */}
|
||||||
|
<ActionIcon
|
||||||
|
variant={isRepeat ? 'filled' : 'subtle'}
|
||||||
|
color={isRepeat ? '#0B4F78' : 'gray'}
|
||||||
|
size={"md"}
|
||||||
|
onClick={toggleRepeat}
|
||||||
|
visibleFrom="sm"
|
||||||
|
>
|
||||||
|
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
{/* Progress Bar - Desktop Only */}
|
||||||
|
<Box w={150} ml="md" visibleFrom="md">
|
||||||
|
<Slider
|
||||||
|
value={currentTime}
|
||||||
|
max={duration || 100}
|
||||||
|
onChange={handleSeek}
|
||||||
|
size="xs"
|
||||||
|
color="#0B4F78"
|
||||||
|
label={(value) => formatTime(value)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Right Controls - Volume + Close */}
|
||||||
|
<Group gap={4} flex={1} justify="flex-end" wrap="nowrap">
|
||||||
|
{/* Volume Control - Tablet/Desktop */}
|
||||||
|
<Box
|
||||||
|
onMouseEnter={() => setShowVolume(true)}
|
||||||
|
onMouseLeave={() => setShowVolume(false)}
|
||||||
|
pos="relative"
|
||||||
|
visibleFrom="sm"
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color={isMuted ? 'red' : 'gray'}
|
||||||
|
size="lg"
|
||||||
|
onClick={toggleMute}
|
||||||
|
>
|
||||||
|
{isMuted ? <IconVolumeOff size={18} /> : <IconVolume size={18} />}
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
mounted={showVolume}
|
||||||
|
transition="scale-y"
|
||||||
|
duration={200}
|
||||||
|
>
|
||||||
|
{(style) => (
|
||||||
|
<Paper
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '100%',
|
||||||
|
right: 0,
|
||||||
|
marginBottom: '10px',
|
||||||
|
padding: '10px',
|
||||||
|
zIndex: 1001,
|
||||||
|
}}
|
||||||
|
shadow="md"
|
||||||
|
withBorder
|
||||||
|
>
|
||||||
|
<Slider
|
||||||
|
value={isMuted ? 0 : volume}
|
||||||
|
max={100}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
h={80}
|
||||||
|
color="#0B4F78"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size={"md"}
|
||||||
|
onClick={handleMinimizePlayer}
|
||||||
|
>
|
||||||
|
<IconX size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* Progress Bar - Mobile (Base) */}
|
||||||
|
<Box px="xs" mt={4} hiddenFrom="md">
|
||||||
|
<Slider
|
||||||
|
value={currentTime}
|
||||||
|
max={duration || 100}
|
||||||
|
onChange={handleSeek}
|
||||||
|
size="xs"
|
||||||
|
color="#0B4F78"
|
||||||
|
label={(value) => formatTime(value)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Spacer to prevent content from being hidden behind player */}
|
||||||
|
<Box h={{ base: 70, sm: 80 }} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { Button } from '@mantine/core';
|
import { Button } from '@mantine/core';
|
||||||
import { IconMusic, IconMusicOff } from '@tabler/icons-react';
|
import { IconDisabled, IconDisabledOff } from '@tabler/icons-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
const NewsReaderLanding = () => {
|
const NewsReaderLanding = () => {
|
||||||
@@ -95,15 +95,17 @@ const NewsReaderLanding = () => {
|
|||||||
mt="md"
|
mt="md"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: '350px',
|
top: '50%', // Menempatkan titik atas ikon di tengah layar
|
||||||
left: '0px',
|
left: '0px',
|
||||||
|
transform: 'translateY(80%)', // Menggeser ikon ke atas sebesar setengah tingginya sendiri agar benar-benar di tengah
|
||||||
borderBottomRightRadius: '20px',
|
borderBottomRightRadius: '20px',
|
||||||
borderTopRightRadius: '20px',
|
borderTopRightRadius: '20px',
|
||||||
transition: 'all 0.3s ease',
|
cursor: 'pointer',
|
||||||
|
transition: 'transform 0.2s',
|
||||||
zIndex: 1
|
zIndex: 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPointerMode ? <IconMusicOff /> : <IconMusic />}
|
{isPointerMode ? <IconDisabledOff /> : <IconDisabled />}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,31 +2,27 @@
|
|||||||
/* 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,
|
||||||
Center,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
|
||||||
Select,
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { IconDownload } from '@tabler/icons-react'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useProxy } from 'valtio/utils'
|
import { useProxy } from 'valtio/utils'
|
||||||
|
import GrafikRealisasi from './lib/grafikRealisasi'
|
||||||
|
import PaguTable from './lib/paguTable'
|
||||||
|
import 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 = {
|
||||||
@@ -37,12 +33,9 @@ function Apbdes() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
|
||||||
await state.findMany.load()
|
await state.findMany.load()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading data:', error)
|
console.error('Error loading data:', error)
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadData()
|
loadData()
|
||||||
@@ -51,18 +44,17 @@ 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)
|
||||||
.sort((a, b) => b - a)
|
.map(year => ({
|
||||||
.map(year => ({
|
value: year.toString(),
|
||||||
value: year.toString(),
|
label: `Tahun ${year}`,
|
||||||
label: `Tahun ${year}`,
|
}))
|
||||||
}))
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (years.length > 0 && !selectedYear) {
|
if (years.length > 0 && !selectedYear) {
|
||||||
@@ -71,13 +63,15 @@ function Apbdes() {
|
|||||||
}, [years, selectedYear])
|
}, [years, selectedYear])
|
||||||
|
|
||||||
const currentApbdes = dataAPBDes.length > 0
|
const currentApbdes = dataAPBDes.length > 0
|
||||||
? transformAPBDesData(dataAPBDes.find(item => item?.tahun?.toString() === selectedYear) || dataAPBDes[0])
|
? dataAPBDes.find((item: any) => item?.tahun?.toString() === selectedYear) || dataAPBDes[0]
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const data = (state.findMany.data || []).slice(0, 3)
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const previewData = (state.findMany.data || []).slice(0, 3)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack p="sm" gap="xl" bg={colors.Bg}>
|
<Stack p="sm" gap="xl" bg={colors.Bg}>
|
||||||
|
<Divider c="gray.3" size="sm" />
|
||||||
{/* 📌 HEADING */}
|
{/* 📌 HEADING */}
|
||||||
<Box mt="xl">
|
<Box mt="xl">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
@@ -117,7 +111,7 @@ function Apbdes() {
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* COMBOBOX */}
|
{/* COMBOBOX */}
|
||||||
<Box px={{ base: 'md', md: 100 }}>
|
<Box px={{ base: 'md', md: "sm" }}>
|
||||||
<Select
|
<Select
|
||||||
label={<Text fw={600} fz="sm">Pilih Tahun APBDes</Text>}
|
label={<Text fw={600} fz="sm">Pilih Tahun APBDes</Text>}
|
||||||
placeholder="Pilih tahun"
|
placeholder="Pilih tahun"
|
||||||
@@ -131,23 +125,29 @@ function Apbdes() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Progress */}
|
{/* Tabel & Grafik - Hanya tampilkan jika ada data */}
|
||||||
{currentApbdes ? (
|
{currentApbdes && currentApbdes.items?.length > 0 ? (
|
||||||
<APBDesProgress apbdesData={currentApbdes} />
|
<Box px={{ base: 'md', md: 'sm' }} mb="xl">
|
||||||
) : (
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
<Box px={{ base: 'md', md: 100 }} py="md">
|
<PaguTable apbdesData={currentApbdes} />
|
||||||
|
<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 APBDes untuk tahun yang dipilih.
|
Tidak ada data item untuk tahun yang dipilih.
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{/* GRID */}
|
{/* GRID - Card Preview
|
||||||
{loading ? (
|
{state.findMany.loading ? (
|
||||||
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
|
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
|
||||||
<Loader size="lg" color="blue" />
|
<Loader size="lg" color="blue" />
|
||||||
</Center>
|
</Center>
|
||||||
) : data.length === 0 ? (
|
) : previewData.length === 0 ? (
|
||||||
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
|
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
|
||||||
<Stack align="center" gap="xs">
|
<Stack align="center" gap="xs">
|
||||||
<Text fz="lg" c="dimmed" lh={1.4}>
|
<Text fz="lg" c="dimmed" lh={1.4}>
|
||||||
@@ -165,14 +165,18 @@ function Apbdes() {
|
|||||||
spacing="lg"
|
spacing="lg"
|
||||||
pb="xl"
|
pb="xl"
|
||||||
>
|
>
|
||||||
{data.map((v, k) => (
|
{previewData.map((v, k) => (
|
||||||
<BackgroundImage
|
<Box
|
||||||
key={k}
|
key={k}
|
||||||
src={v.image?.link || ''}
|
|
||||||
h={360}
|
|
||||||
radius="xl"
|
|
||||||
pos="relative"
|
pos="relative"
|
||||||
style={{ overflow: 'hidden' }}
|
style={{
|
||||||
|
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 }} />
|
||||||
|
|
||||||
@@ -185,7 +189,7 @@ function Apbdes() {
|
|||||||
lh={1.35}
|
lh={1.35}
|
||||||
lineClamp={2}
|
lineClamp={2}
|
||||||
>
|
>
|
||||||
{v.name}
|
{v.name || `APBDes Tahun ${v.tahun}`}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
@@ -196,7 +200,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>
|
||||||
@@ -212,12 +216,12 @@ function Apbdes() {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Center>
|
</Center>
|
||||||
</Stack>
|
</Stack>
|
||||||
</BackgroundImage>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
)}
|
)} */}
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Apbdes
|
export default Apbdes
|
||||||
125
src/app/darmasaba/_com/main-page/apbdes/lib/grafikRealisasi.tsx
Normal file
125
src/app/darmasaba/_com/main-page/apbdes/lib/grafikRealisasi.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/app/darmasaba/_com/main-page/apbdes/lib/paguTable.tsx
Normal file
66
src/app/darmasaba/_com/main-page/apbdes/lib/paguTable.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Box, Space, Stack } from "@mantine/core";
|
|||||||
|
|
||||||
import { Navbar } from "@/app/darmasaba/_com/Navbar";
|
import { Navbar } from "@/app/darmasaba/_com/Navbar";
|
||||||
import Footer from "./_com/Footer";
|
import Footer from "./_com/Footer";
|
||||||
|
import FixedPlayerBar from "./_com/FixedPlayerBar";
|
||||||
|
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
@@ -21,6 +22,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<FixedPlayerBar />
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import "@mantine/core/styles.css";
|
import "@mantine/core/styles.css";
|
||||||
import "./globals.css";
|
import "./globals.css"; // Sisanya import di globals.css
|
||||||
|
|
||||||
import LoadDataFirstClient from "@/app/darmasaba/_com/LoadDataFirstClient";
|
import LoadDataFirstClient from "@/app/darmasaba/_com/LoadDataFirstClient";
|
||||||
|
import { MusicProvider } from "@/app/context/MusicContext";
|
||||||
import {
|
import {
|
||||||
ColorSchemeScript,
|
ColorSchemeScript,
|
||||||
MantineProvider,
|
MantineProvider,
|
||||||
createTheme,
|
createTheme,
|
||||||
mantineHtmlProps,
|
mantineHtmlProps,
|
||||||
|
// mantineHtmlProps,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Metadata, Viewport } from "next";
|
import { Metadata, Viewport } from "next";
|
||||||
import { ViewTransitions } from "next-view-transitions";
|
import { ViewTransitions } from "next-view-transitions";
|
||||||
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",
|
||||||
@@ -101,15 +106,17 @@ export default function RootLayout({
|
|||||||
<ColorSchemeScript defaultColorScheme="light" />
|
<ColorSchemeScript defaultColorScheme="light" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<MantineProvider theme={theme} defaultColorScheme="light">
|
<MusicProvider>
|
||||||
{children}
|
<MantineProvider theme={theme} defaultColorScheme="light">
|
||||||
<LoadDataFirstClient />
|
{children}
|
||||||
<ToastContainer
|
<LoadDataFirstClient />
|
||||||
position="bottom-center"
|
<ToastContainer
|
||||||
hideProgressBar
|
position="bottom-center"
|
||||||
style={{ zIndex: 9999 }}
|
hideProgressBar
|
||||||
/>
|
style={{ zIndex: 9999 }}
|
||||||
</MantineProvider>
|
/>
|
||||||
|
</MantineProvider>
|
||||||
|
</MusicProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
</ViewTransitions>
|
</ViewTransitions>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function Page() {
|
|||||||
dengan ketentuan ini, harap jangan gunakan Website.
|
dengan ketentuan ini, harap jangan gunakan Website.
|
||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
|
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
|
||||||
|
|||||||
@@ -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 { Paper, Box, BoxProps, Divider, DividerProps } from '@mantine/core';
|
import { Box, BoxProps, Divider, DividerProps, Paper } from '@mantine/core';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,7 +22,6 @@ import React from 'react';
|
|||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Unified Card Component
|
// Unified Card Component
|
||||||
* ============================================================================
|
|
||||||
|
|
||||||
interface UnifiedCardProps extends BoxProps {
|
interface UnifiedCardProps extends BoxProps {
|
||||||
withBorder?: boolean;
|
withBorder?: boolean;
|
||||||
@@ -63,12 +62,18 @@ 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
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ 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
|
||||||
*
|
*
|
||||||
@@ -73,7 +75,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.brand;
|
if (color === 'brand') return tokens.colors.text.brand;
|
||||||
return color;
|
return color;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,8 +111,14 @@ 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?: 'start' | 'end' | 'middle' | boolean;
|
truncate?: TextTruncate;
|
||||||
span?: boolean;
|
span?: boolean;
|
||||||
|
mt?: string;
|
||||||
|
mb?: string;
|
||||||
|
ml?: string;
|
||||||
|
mr?: string;
|
||||||
|
mx?: string;
|
||||||
|
my?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +131,12 @@ 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();
|
||||||
@@ -163,7 +177,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.brand;
|
return tokens.colors.text.brand;
|
||||||
case 'link':
|
case 'link':
|
||||||
return tokens.colors.text.link;
|
return tokens.colors.text.link;
|
||||||
default:
|
default:
|
||||||
@@ -177,7 +191,7 @@ export function UnifiedText({
|
|||||||
|
|
||||||
if (span) {
|
if (span) {
|
||||||
return (
|
return (
|
||||||
<Text.Span
|
<Text
|
||||||
ta={align}
|
ta={align}
|
||||||
fz={typo.fz}
|
fz={typo.fz}
|
||||||
fw={fw}
|
fw={fw}
|
||||||
@@ -185,10 +199,16 @@ 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.Span>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +221,12 @@ 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}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Authentication helper untuk API endpoints
|
* Authentication helper untuk API endpoints
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* import { requireAuth } from "@/lib/api-auth";
|
* import { requireAuth } from "@/lib/api-auth";
|
||||||
*
|
*
|
||||||
* export default async function myEndpoint(context: Context) {
|
* export default async function myEndpoint() {
|
||||||
* const authResult = await requireAuth(context);
|
* const authResult = await requireAuth();
|
||||||
* if (!authResult.authenticated) {
|
* if (!authResult.authenticated) {
|
||||||
* return authResult.response;
|
* return authResult.response;
|
||||||
* }
|
* }
|
||||||
@@ -13,24 +13,24 @@
|
|||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getSession } from "@/lib/session";
|
import { getSession, SessionData } from "@/lib/session";
|
||||||
|
|
||||||
export type AuthResult =
|
export type AuthResult =
|
||||||
| { authenticated: true; user: any }
|
| { authenticated: true; user: NonNullable<SessionData["user"]> }
|
||||||
| { authenticated: false; response: Response };
|
| { authenticated: false; response: Response };
|
||||||
|
|
||||||
export async function requireAuth(context: any): Promise<AuthResult> {
|
export async function requireAuth(): Promise<AuthResult> {
|
||||||
try {
|
try {
|
||||||
// Cek session dari cookies
|
// Cek session dari cookies
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
|
|
||||||
if (!session || !session.user) {
|
if (!session || !session.user) {
|
||||||
return {
|
return {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
response: new Response(JSON.stringify({
|
response: new Response(JSON.stringify({
|
||||||
success: false,
|
success: false,
|
||||||
message: "Unauthorized - Silakan login terlebih dahulu"
|
message: "Unauthorized - Silakan login terlebih dahulu"
|
||||||
}), {
|
}), {
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
})
|
})
|
||||||
@@ -44,7 +44,7 @@ export async function requireAuth(context: any): Promise<AuthResult> {
|
|||||||
response: new Response(JSON.stringify({
|
response: new Response(JSON.stringify({
|
||||||
success: false,
|
success: false,
|
||||||
message: "Akun Anda tidak aktif. Hubungi administrator."
|
message: "Akun Anda tidak aktif. Hubungi administrator."
|
||||||
}), {
|
}), {
|
||||||
status: 403,
|
status: 403,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
})
|
})
|
||||||
@@ -55,14 +55,13 @@ export async function requireAuth(context: any): Promise<AuthResult> {
|
|||||||
authenticated: true,
|
authenticated: true,
|
||||||
user: session.user
|
user: session.user
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("Auth error:", error);
|
|
||||||
return {
|
return {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
response: new Response(JSON.stringify({
|
response: new Response(JSON.stringify({
|
||||||
success: false,
|
success: false,
|
||||||
message: "Authentication error"
|
message: "Authentication error"
|
||||||
}), {
|
}), {
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
})
|
})
|
||||||
@@ -74,11 +73,11 @@ export async function requireAuth(context: any): Promise<AuthResult> {
|
|||||||
* Optional auth - tidak error jika tidak authenticated
|
* Optional auth - tidak error jika tidak authenticated
|
||||||
* Berguna untuk endpoint yang bisa diakses public atau private
|
* Berguna untuk endpoint yang bisa diakses public atau private
|
||||||
*/
|
*/
|
||||||
export async function optionalAuth(context: any): Promise<any> {
|
export async function optionalAuth(): Promise<NonNullable<SessionData["user"]> | null> {
|
||||||
try {
|
try {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
return session?.user || null;
|
return session?.user || null;
|
||||||
} catch (error) {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,6 +223,10 @@ 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;
|
||||||
@@ -381,3 +385,7 @@ export const getActiveStateStyles = (isActive: boolean, isDark: boolean = false)
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user