Compare commits

...

29 Commits

Author SHA1 Message Date
6712da9ac2 fix(ui): replace Title with Text in Modal title to avoid h5 in h2 nesting
Fix:
- Change Modal title from <Title order={5}> to <Text fz='lg' fw={600}>
- Avoids invalid HTML nesting (<h5> cannot be child of <h2>)
- Maintains same visual appearance

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 16:21:27 +08:00
ac11a9367c fix(api): correct selisih calculation formula
Bug Fix:
- Change selisih formula from: totalRealisasi - anggaran
- To: anggaran - totalRealisasi

Reason:
- Selisih positif = Sisa anggaran (belum digunakan)
- Selisih negatif = Over budget (melebihi anggaran)

Example:
- Anggaran: Rp 30.000.000
- Realisasi: Rp 5.000.000
- Selisih (OLD): 5jt - 30jt = -25jt  Wrong
- Selisih (NEW): 30jt - 5jt = 25jt  Correct (sisa anggaran)

Files Updated:
- create.ts: Fix initial item creation
- updt.ts: Fix item update
- realisasi/create.ts: Fix after adding realisasi
- realisasi/update.ts: Fix after updating realisasi
- realisasi/delete.ts: Fix after deleting realisasi

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 16:16:38 +08:00
67e5ceb254 feat(ui): add Realisasi Manager component for multiple realisasi CRUD
New Component - RealisasiManager:
- Add modal form for create/edit realisasi
- Input fields: Jumlah (Rp), Tanggal, Keterangan/Uraian
- Display list of existing realisasi with edit/delete actions
- Summary cards showing: Anggaran, Total Realisasi, Sisa Anggaran, Persentase
- Color-coded percentage badges (teal ≥100%, blue ≥80%, yellow ≥60%, red <60%)
- Auto-reload data after create/update/delete operations

Features:
- Multiple realisasi per APBDes item
- Each realisasi has its own description (uraian)
- Date picker for realisasi tanggal
- Format currency in IDR (Rupiah)
- Responsive table layout
- Empty state when no realisasi exists

Integration:
- Integrated with existing state.realisasi CRUD functions
- Auto-calculate totalRealisasi and persentase (handled by backend)
- Display realisasi items from API response
- Works with existing APBDes detail page

UI/UX:
- Clean modal design with form validation
- Summary cards with color-coded backgrounds
- Icon indicators for date and currency
- Confirmation dialog before delete
- Loading states during async operations

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 15:41:43 +08:00
65942ac9d2 refactor(create): remove realisasiAwal, simplify to anggaran-only input
Refactoring:
- Remove realisasiAwal field from ItemForm type
- Remove NumberInput for realisasi awal from UI
- Remove realisasiAwal column from preview table
- Simplify state management (no realisasiAwal mapping)
- API create: Create items with totalRealisasi=0 (no auto-create realisasi)

Rationale:
- Cleaner separation: Anggaran dan Realisasi adalah entitas terpisah
- User create item untuk ANGGARAN dulu
- Setelah item dibuat, user bisa add MULTIPLE REALISASI dengan:
  * Uraian yang jelas untuk setiap realisasi
  * Tanggal yang spesifik
  * Keterangan detail
  * Bukti file attachment
- Follows the original schema design more closely

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 15:30:33 +08:00
e0436cc384 feat(create): add realisasi awal input di create page
Features:
- Add realisasiAwal field to ItemForm type
- Add NumberInput for realisasi awal (optional)
- Update table preview to show realisasi awal
- Update state to send realisasiAwal to API
- Update API create to handle realisasiAwal:
  * Create APBDesItem with totalRealisasi = realisasiAwal
  * Auto-create first RealisasiItem if realisasiAwal > 0
  * Auto-calculate selisih and persentase

UX Improvements:
- User can input initial realization during create
- Optional field with clear label and description
- Auto-calculation of percentages on backend
- Single transaction for item + first realisasi

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 15:13:58 +08:00
63682e47b6 feat(state): update APBDes state management for multiple realisasi
State Changes:
- Update ApbdesItemSchema: Remove realisasi, selisih, persentase fields
- Add RealisasiItemSchema for realisasi CRUD operations
- Update normalizeItem: Remove manual calculations (backend handles it)
- Update edit.load: Map items without realisasi fields
- Add realisasi state: create, update, delete functions

UI Changes:
- Update create/page.tsx: Remove realisasi input field and column
- Update edit/page.tsx: Remove realisasi input field and column
- Update ItemForm type: Remove realisasi property
- Simplify forms to only input anggaran, realisasi added separately

Features:
- Support for multiple realisasi per item
- Realisasi CRUD via dedicated state functions
- Auto-reload findUnique after realisasi operations
- Backend auto-calculates totalRealisasi, selisih, persentase

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 14:55:06 +08:00
f4705690a9 feat(api): implement multiple realisasi for APBDes
Schema Changes:
- Add RealisasiItem model for multiple realizations per APBDesItem
- Replace realisasi field with totalRealisasi (auto-calculated sum)
- Add selisih and persentase as auto-calculated fields
- Cascade delete realisasi when item is deleted

API Changes:
- Update index.ts: Add realisasi CRUD endpoints
  - POST /:itemId/realisasi - Create realisasi
  - PUT /realisasi/:realisasiId - Update realisasi
  - DELETE /realisasi/:realisasiId - Delete realisasi
- Update create.ts: Auto-calculate totalRealisasi=0, selisih, persentase
- Update updt.ts: Reset calculations when items updated
- Update findUnique.ts: Include realisasiItems in response
- Update findMany.ts: Include realisasiItems in response
- Remove realisasi field from ApbdesItemSchema

New Files:
- realisasi/create.ts - Create realisasi with auto-calculation
- realisasi/update.ts - Update realisasi with recalculation
- realisasi/delete.ts - Soft delete with recalculation

Features:
- Auto-calculate totalRealisasi from sum of all realisasiItems
- Auto-calculate selisih = totalRealisasi - anggaran
- Auto-calculate persentase = (totalRealisasi / anggaran) * 100
- Support for bukti file attachment
- Support for keterangan (notes) per realisasi
- Soft delete support for audit trail

UI Updates:
- Update admin detail page to use totalRealisasi instead of realisasi
- Update landing page realisasiTable to use totalRealisasi

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 14:45:21 +08:00
239771a714 fix(apbdes): improve UI components and styling
- Update Apbdes component with better conditional rendering
- Enhance grafikRealisasi with improved percentage display
- Refine color coding and feedback messages
- Optimize layout and spacing for better UX

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 14:15:27 +08:00
03451195c8 feat(apbdes) grafik: add detailed percentage comparison
- Display percentage value prominently next to each category title
- Add formatted currency (Rupiah) for better readability
- Color-coded progress bars based on achievement level:
  * Teal: ≥100% (target tercapai)
  * Blue: ≥80% (baik)
  * Yellow: ≥60% (cukup)
  * Red: <60% (perlu perhatian)
- Add contextual feedback messages based on percentage:
  * ✓ Achievement message for 100%
  *  Positive message for 80-99%
  * ⚠️ Warning messages for <80%
- Add TOTAL KESELURUHAN summary section at the top
- Add emoji icons for better visual distinction (💰 💸 📊)
- Animated progress bars for <100% achievement

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 13:58:52 +08:00
597af7e716 fix(apbdes) landing page: fix APBDes component not displaying on darmasaba page
- Restore Apbdes component with full functionality (fetch data, year selector, tables, charts)
- Fix realisasiTable.tsx: add missing items variable
- Fix grafikRealisasi.tsx: dynamic year title instead of hardcoded 2026
- Add eslint-disable comments for TypeScript any types
- Remove unused imports in paguTable.tsx
- Integrate PaguTable, RealisasiTable, GrafikRealisasi into main Apbdes component
- Component now fetches data from Valtio state and displays 3 tables + charts

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 12:51:53 +08:00
0a8a026b94 fix(apbdes): integrate new APBDes API with admin UI
- Update API schema to support name, deskripsi, and jumlah fields
- Enhance state management with additional form fields
- Add input fields for name, description, and total amount in create/edit pages
- Display description and total amount in detail page
- Fix APBDes component order in landing page
- Update TypeScript types and Prisma schema integration

API Changes:
- POST /api/landingpage/apbdes/create: Added optional fields (name, deskripsi, jumlah)
- PUT /api/landingpage/apbdes/🆔 Added optional fields (name, deskripsi, jumlah)

Admin UI Changes:
- create/page.tsx: Add TextInput for name, deskripsi, and jumlah
- edit/page.tsx: Add TextInput for name, deskripsi, and jumlah; improve reset functionality
- [id]/page.tsx: Display deskripsi and jumlah if available
- page.tsx: Minor formatting fix
- _state/apbdes.ts: Update Zod schema and default form with new fields

Landing Page:
- Move Apbdes component to top of stack for better visibility

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 10:56:30 +08:00
a5bd91b580 feat(berita): add multiple images gallery and YouTube video support
- Update schema: add images relation list and linkVideo field
- API: support multiple image upload and YouTube link in create/update
- Admin create page: add gallery upload (max 10) and YouTube embed preview
- Admin edit page: manage existing/new gallery images and YouTube link
- Admin detail page: display gallery grid and YouTube video embed
- Public detail page: show gallery images and YouTube video with responsive layout
- State: add imageIds[] and linkVideo fields with proper type handling
- Music player: fix seek functionality and ESLint warnings

Breaking changes:
- Prisma schema updated - requires migration
- API create/update endpoints now expect imageIds array and linkVideo string

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-02 16:06:53 +08:00
ae3187804e Notes slider musik belum berfungsi 2026-03-02 14:28:20 +08:00
91e32f3f1c fix(musik): fix seek slider reset ke 0 - root cause: useEffect dependency
ROOT CAUSE:
- filteredMusik di-calculate ulang setiap render (.filter() tanpa memoization)
- currentSong = filteredMusik[currentSongIndex] → object reference baru setiap render
- useEffect dependency [currentSong, currentSongIndex] trigger setiap render
- useEffect reset setCurrentTime(0) → slider kembali ke awal

FIX:
1. useMemo untuk filteredMusik - mencegah re-calculate setiap render
2. useEffect dependency [currentSong?.id, currentSongIndex] - hanya trigger saat lagu benar-benar berubah
3. Hapus semua debug console.log yang tidak diperlukan
4. Simplifikasi seekTo function

File Changed:
- src/app/darmasaba/(pages)/musik/musik-desa/page.tsx
- src/app/darmasaba/(pages)/musik/lib/seek.ts

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-02 12:03:26 +08:00
4d03908f23 feat(musik): tambahkan extensive debug logging untuk tracking seek issue
- Tambah key stabil pada audio element untuk mencegah remount
- Log di seekTo: before/after currentTime
- Log di onTimeUpdate: currentTime, rounded, isSeeking
- Log di onChangeEnd slider: value, seekTime
- Log di useEffect song change: currentSong, currentSongIndex
- Log di skipBack/skipForward: index perubahan lagu

Purpose: Track urutan eksekusi dan identifikasi race condition atau re-render yang tidak diinginkan

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-02 11:58:40 +08:00
0563f9664f fix(musik): perbaiki timing dan rounding pada seek slider
- Gunakan durasi dari database sebagai acuan utama (bukan dari audio metadata)
- Ganti Math.floor dengan Math.round untuk smoothing currentTime
- Tambahkan validasi seek time: Math.min(Math.max(0, v), duration)
- Tambahkan debug logging untuk tracking seek behavior
- Hapus override duration di onLoadedMetadata untuk menghindari konflik

Root cause:
- Duration dari database (string 'MM:SS' → seconds) berbeda dengan audio.duration (float)
- Math.floor menyebabkan lompatan kasar dan kehilangan presisi
- onLoadedMetadata override duration dengan audio.duration yang tidak exact

Fix:
- Database duration = source of truth
- Math.round untuk smoothing tanpa kehilangan presisi
- Validasi bounds untuk mencegah seek negatif atau melebihi durasi

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-02 11:53:36 +08:00
961cc32057 fix(musik): slider seek sekarang berfungsi dengan benar
- Fix slider seek reset ke detik awal saat digeser
- Tambahkan isSeeking state untuk mencegah onTimeUpdate mereset posisi slider
- Implementasi pattern preview/commit untuk seek:
  - onChange: update UI state saja (preview)
  - onChangeEnd: commit ke audio player (commit)
- Update seekTo function untuk support optional setCurrentTime callback
- Terapkan fix ke kedua slider (Sedang Diputar dan bottom player)

Bug: Slider seek langsung kembali ke posisi awal saat digeser karena:
1. onTimeUpdate terus menerus update currentTime state
2. seekTo tidak update React state setelah set audio.currentTime
3. Tidak ada isSeeking flag untuk block onTimeUpdate saat user sedang seek

Fix:
1. Set isSeeking=true saat onChange, false saat onChangeEnd
2. onTimeUpdate check isSeeking sebelum update state
3. seekTo sekarang juga update state via callback optional

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-02 11:47:05 +08:00
fe7672e09f refactor(musik): integrate music player library functions and fix build errors
- Integrate togglePlayPause, getNextIndex, getPrevIndex, handleRepeatOrNext, seekTo, toggleShuffle, setAudioVolume, toggleMute library functions
- Fix ESLint warnings: remove unused eslint-disable, add missing useEffect dependencies
- Fix ESLint error in useMusicPlayer.ts togglePlayPause function
- Add force-dynamic export to root layout to prevent prerendering errors
- Improve seek slider with preview/commit functionality
- Add isSeeking state to prevent UI flickering during seek

Fixes: Build PageNotFoundError for admin/darmasaba pages

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-02 11:41:14 +08:00
341ff5779f Fix Durasi Musik Di Tampilan User 2026-02-27 11:52:18 +08:00
69f7b4c162 feat: integrate musik desa page with API and improve audio player
- Fetch musik data from /api/desa/musik/find-many endpoint
- Filter only active musik (isActive: true)
- Add search functionality by title, artist, and genre
- Implement real audio playback with HTML5 audio element
- Add play/pause, next/previous, shuffle, repeat controls
- Add progress bar with seek functionality
- Add volume control with mute toggle
- Auto-play next song when current song ends
- Add loading and empty states
- Use cover image and audio file from database
- Fix skip back/forward button handlers

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-26 22:24:25 +08:00
409ad4f1a2 Fix Login KodeOtp WA 2026-02-26 22:10:28 +08:00
55ea3c473a add menu musik 2026-02-26 21:32:33 +08:00
a152eaf984 Fix Login KodeOtp WA 2026-02-26 14:12:54 +08:00
223b85a714 Fix CORS config for staging environment
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-25 22:55:36 +08:00
f1729151b3 Fix themeTokens light mode status colors
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-25 21:24:39 +08:00
8e8c133eea Fix eror build 2026-02-25 21:19:56 +08:00
1e7acac193 Fix eror build 2026-02-25 21:18:26 +08:00
42dcbcfb22 fix-admin-menu-desa-berita 2026-02-25 16:25:59 +08:00
22de1aa1f3 fix-admin-menu-desa-potensi 2026-02-25 15:41:01 +08:00
76 changed files with 5413 additions and 659 deletions

View File

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

View File

@@ -26,7 +26,24 @@ export async function seedBerita() {
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) {
// 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;
if (b.imageName) {
@@ -44,26 +61,32 @@ export async function seedBerita() {
}
}
await prisma.berita.upsert({
where: { id: b.id },
update: {
judul: b.judul,
deskripsi: b.deskripsi,
content: b.content,
kategoriBeritaId: b.kategoriBeritaId,
imageId,
},
create: {
id: b.id,
judul: b.judul,
deskripsi: b.deskripsi,
content: b.content,
kategoriBeritaId: b.kategoriBeritaId,
imageId,
},
});
try {
await prisma.berita.upsert({
where: { id: b.id },
update: {
judul: b.judul,
deskripsi: b.deskripsi,
content: b.content,
kategoriBeritaId: b.kategoriBeritaId,
imageId,
},
create: {
id: b.id,
judul: b.judul,
deskripsi: b.deskripsi,
content: b.content,
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");

View 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;

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
provider = "postgresql"

View File

@@ -60,8 +60,9 @@ model FileStorage {
deletedAt DateTime?
isActive Boolean @default(true)
link String
category String // "image" / "document" / "other"
Berita Berita[]
category String // "image" / "document" / "audio" / "other"
Berita Berita[] @relation("BeritaFeaturedImage")
BeritaImages Berita[] @relation("BeritaImages")
PotensiDesa PotensiDesa[]
Posyandu Posyandu[]
StrukturPPID StrukturPPID[]
@@ -102,6 +103,9 @@ model FileStorage {
ArtikelKesehatan ArtikelKesehatan[]
StrukturBumDes StrukturBumDes[]
MusikDesaAudio MusikDesa[] @relation("MusikAudioFile")
MusikDesaCover MusikDesa[] @relation("MusikCoverImage")
}
//========================================= MENU LANDING PAGE ========================================= //
@@ -205,16 +209,22 @@ model APBDesItem {
kode String // contoh: "4", "4.1", "4.1.2"
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)
realisasi Float
selisih Float // realisasi - anggaran
persentase Float
tipe String? // (realisasi / anggaran) * 100
tipe String? // "pendapatan" | "belanja" | "pembiayaan" | null
level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail
parentId String? // untuk relasi hierarki
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
children APBDesItem[] @relation("APBDesItemParent")
apbdesId String
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())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@ -225,6 +235,26 @@ model APBDesItem {
@@index([apbdesId])
}
// Model baru untuk multiple realisasi per item
model RealisasiItem {
id String @id @default(cuid())
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([apbdesItemId])
@@index([tanggal])
}
//========================================= PRESTASI DESA ========================================= //
model PrestasiDesa {
id String @id @default(cuid())
@@ -609,15 +639,19 @@ model Berita {
id String @id @default(cuid())
judul String
deskripsi String
image FileStorage? @relation(fields: [imageId], references: [id])
image FileStorage? @relation("BeritaFeaturedImage", fields: [imageId], references: [id])
imageId String?
images FileStorage[] @relation("BeritaImages")
content String @db.Text
linkVideo String? @db.VarChar(500)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
isActive Boolean @default(true)
kategoriBerita KategoriBerita? @relation(fields: [kategoriBeritaId], references: [id])
kategoriBeritaId String?
@@index([kategoriBeritaId])
}
model KategoriBerita {
@@ -633,25 +667,25 @@ model KategoriBerita {
// ========================================= POTENSI DESA ========================================= //
model PotensiDesa {
id String @id @default(cuid())
name String
deskripsi String
name String @unique @db.VarChar(255)
deskripsi String @db.Text
kategori KategoriPotensi? @relation(fields: [kategoriId], references: [id])
kategoriId String?
kategoriId String @db.VarChar(36)
image FileStorage? @relation(fields: [imageId], references: [id])
imageId String?
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
model KategoriPotensi {
id String @id @default(cuid())
nama String
nama String @unique @db.VarChar(100)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
PotensiDesa PotensiDesa[]
}
@@ -2263,3 +2297,25 @@ model UserMenuAccess {
@@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

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -12,6 +12,8 @@ const templateForm = z.object({
content: z.string().min(3, "Content minimal 3 karakter"),
kategoriBeritaId: z.string().nonempty(),
imageId: z.string().nonempty(),
imageIds: z.array(z.string()),
linkVideo: z.string().optional(),
});
// 2. Default value form berita (hindari uncontrolled input)
@@ -21,6 +23,8 @@ const defaultForm = {
imageId: "",
content: "",
kategoriBeritaId: "",
imageIds: [] as string[],
linkVideo: "",
};
// 4. Berita proxy
@@ -62,14 +66,7 @@ const berita = proxy({
// State untuk berita utama (hanya 1)
findMany: {
data: null as
| Prisma.BeritaGetPayload<{
include: {
image: true;
kategoriBerita: true;
};
}>[]
| null,
data: null as any[] | null,
page: 1,
totalPages: 1,
loading: false,
@@ -79,14 +76,14 @@ const berita = proxy({
berita.findMany.loading = true;
berita.findMany.page = page;
berita.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
if (kategori) query.kategori = kategori;
const res = await ApiFetch.api.desa.berita["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
berita.findMany.data = res.data.data ?? [];
berita.findMany.totalPages = res.data.totalPages ?? 1;
@@ -103,18 +100,19 @@ const berita = proxy({
const elapsed = Date.now() - startTime;
const minDelay = 300;
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
setTimeout(() => {
berita.findMany.loading = false;
}, delay);
}
},
},
},
findUnique: {
data: null as Prisma.BeritaGetPayload<{
include: {
image: true;
images: true;
kategoriBerita: true;
};
}> | null,
@@ -199,6 +197,8 @@ const berita = proxy({
content: data.content,
kategoriBeritaId: data.kategoriBeritaId || "",
imageId: data.imageId || "",
imageIds: data.images?.map((img: any) => img.id) || [],
linkVideo: data.linkVideo || "",
};
return data; // Return the loaded data
} else {
@@ -237,6 +237,8 @@ const berita = proxy({
content: this.form.content,
kategoriBeritaId: this.form.kategoriBeritaId || null,
imageId: this.form.imageId,
imageIds: this.form.imageIds,
linkVideo: this.form.linkVideo,
}),
});

View 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;

View File

@@ -5,20 +5,28 @@ import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
// --- Zod Schema ---
// --- Zod Schema untuk APBDes Item (tanpa field kalkulasi) ---
const ApbdesItemSchema = z.object({
kode: z.string().min(1, "Kode wajib diisi"),
uraian: z.string().min(1, "Uraian wajib diisi"),
anggaran: z.number().min(0),
realisasi: z.number().min(0),
selisih: z.number(),
persentase: z.number(),
anggaran: z.number().min(0, "Anggaran tidak boleh negatif"),
level: z.number().int().min(1).max(3),
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
});
// --- Zod Schema untuk Realisasi Item ---
const RealisasiItemSchema = z.object({
jumlah: z.number().min(0, "Jumlah tidak boleh negatif"),
tanggal: z.string(),
keterangan: z.string().optional(),
buktiFileId: z.string().optional(),
});
const ApbdesFormSchema = z.object({
tahun: z.number().int().min(2000, "Tahun tidak valid"),
name: z.string().optional(),
deskripsi: z.string().optional(),
jumlah: z.string().optional(),
imageId: z.string().min(1, "Gambar wajib diunggah"),
fileId: z.string().min(1, "File wajib diunggah"),
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
@@ -27,31 +35,22 @@ const ApbdesFormSchema = z.object({
// --- Default Form ---
const defaultApbdesForm = {
tahun: new Date().getFullYear(),
name: "",
deskripsi: "",
jumlah: "",
imageId: "",
fileId: "",
items: [] as z.infer<typeof ApbdesItemSchema>[],
};
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
// --- Helper: Normalize item (tanpa kalkulasi, backend yang hitung) ---
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 {
kode: item.kode || "",
uraian: item.uraian || "",
anggaran,
realisasi,
selisih,
persentase,
anggaran: item.anggaran ?? 0,
level: item.level || 1,
tipe: item.tipe, // biarkan null jika memang null
tipe: item.tipe ?? null,
};
}
@@ -244,15 +243,15 @@ const apbdes = proxy({
this.id = data.id;
this.form = {
tahun: data.tahun || new Date().getFullYear(),
name: data.name || "",
deskripsi: data.deskripsi || "",
jumlah: data.jumlah || "",
imageId: data.imageId || "",
fileId: data.fileId || "",
items: (data.items || []).map((item: any) => ({
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
realisasi: item.realisasi,
selisih: item.selisih,
persentase: item.persentase,
level: item.level,
tipe: item.tipe || 'pendapatan',
})),
@@ -317,6 +316,80 @@ const apbdes = proxy({
this.form = { ...defaultApbdesForm };
},
},
// =========================================
// REALISASI STATE MANAGEMENT
// =========================================
realisasi: {
// Create realisasi
async create(itemId: string, data: { jumlah: number; tanggal: string; keterangan?: string; buktiFileId?: string }) {
try {
const res = await (ApiFetch.api.landingpage.apbdes as any)[itemId].realisasi.post(data);
if (res.data?.success) {
toast.success("Realisasi berhasil ditambahkan");
// Reload findUnique untuk update data
if (apbdes.findUnique.data) {
await apbdes.findUnique.load(apbdes.findUnique.data.id);
}
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: { jumlah?: number; tanggal?: string; keterangan?: string; buktiFileId?: string }) {
try {
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].put(data);
if (res.data?.success) {
toast.success("Realisasi berhasil diperbarui");
// Reload findUnique untuk update data
if (apbdes.findUnique.data) {
await apbdes.findUnique.load(apbdes.findUnique.data.id);
}
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;

View File

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

View File

@@ -9,6 +9,8 @@ import {
ActionIcon,
Box,
Button,
Card,
Grid,
Group,
Image,
Paper,
@@ -17,7 +19,7 @@ import {
Text,
TextInput,
Title,
Loader
Loader,
} from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import {
@@ -25,19 +27,51 @@ import {
IconPhoto,
IconUpload,
IconX,
IconVideo,
IconTrash,
} from "@tabler/icons-react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
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() {
const beritaState = useProxy(stateDashboardBerita);
const router = useRouter();
const params = useParams();
// Featured image state
const [previewImage, setPreviewImage] = useState<string | 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({
judul: "",
deskripsi: "",
@@ -48,9 +82,17 @@ function EditBerita() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
judul: "",
deskripsi: "",
kategoriBeritaId: "",
content: "",
imageId: "",
imageUrl: ""
});
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
@@ -61,21 +103,12 @@ function EditBerita() {
formData.judul?.trim() !== '' &&
formData.kategoriBeritaId !== '' &&
!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)
);
};
const [originalData, setOriginalData] = useState({
judul: "",
deskripsi: "",
kategoriBeritaId: "",
content: "",
imageId: "",
imageUrl: ""
});
// Load kategori + berita
// Load data
useEffect(() => {
beritaState.kategoriBerita.findMany.load();
@@ -84,7 +117,7 @@ function EditBerita() {
if (!id) return;
try {
const data = await stateDashboardBerita.berita.edit.load(id);
const data = await stateDashboardBerita.berita.edit.load(id) as BeritaData | null;
if (data) {
setFormData({
judul: data.judul || "",
@@ -106,6 +139,17 @@ function EditBerita() {
if (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) {
console.error("Error loading berita:", error);
@@ -120,27 +164,59 @@ function EditBerita() {
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 () => {
if (!formData.judul?.trim()) {
toast.error('Judul wajib diisi');
return;
}
if (!formData.kategoriBeritaId) {
toast.error('Kategori wajib dipilih');
return;
}
if (isHtmlEmpty(formData.deskripsi)) {
toast.error('Deskripsi singkat wajib diisi');
return;
}
if (!file && !originalData.imageId) {
toast.error('Gambar wajib dipilih');
toast.error('Gambar utama wajib dipilih');
return;
}
if (isHtmlEmpty(formData.content)) {
toast.error('Konten wajib diisi');
return;
@@ -148,12 +224,14 @@ function EditBerita() {
try {
setIsSubmitting(true);
// Update global state hanya sekali di sini
// Update global state
beritaState.berita.edit.form = {
...beritaState.berita.edit.form,
...formData,
};
// Upload new featured image if changed
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({
file,
@@ -162,12 +240,33 @@ function EditBerita() {
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error("Gagal upload gambar");
return toast.error("Gagal upload gambar utama");
}
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();
toast.success("Berita berhasil diperbarui!");
router.push("/admin/desa/berita/list-berita");
@@ -189,9 +288,12 @@ function EditBerita() {
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
setYoutubeLink(originalYoutubeLink);
toast.info("Form dikembalikan ke data awal");
};
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header */}
@@ -219,6 +321,7 @@ function EditBerita() {
style={{ border: "1px solid #e0e0e0" }}
>
<Stack gap="md">
{/* Judul */}
<TextInput
label="Judul"
placeholder="Masukkan judul"
@@ -227,6 +330,7 @@ function EditBerita() {
required
/>
{/* Kategori */}
<Select
value={formData.kategoriBeritaId}
onChange={(val) => handleChange("kategoriBeritaId", val || "")}
@@ -241,9 +345,9 @@ function EditBerita() {
clearable
searchable
required
error={!formData.kategoriBeritaId ? "Pilih kategori" : undefined}
/>
{/* Deskripsi */}
<Box>
<Text fz="sm" fw="bold">
Deskripsi Singkat
@@ -256,11 +360,10 @@ function EditBerita() {
/>
</Box>
{/* Upload Gambar */}
{/* Featured Image */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Berita
Gambar Utama (Featured)
</Text>
<Dropzone
onDrop={(files) => {
@@ -274,17 +377,13 @@ function EditBerita() {
toast.error("File tidak valid, gunakan format gambar")
}
maxSize={5 * 1024 ** 2}
accept={{ "image/*": [] }}
accept={{ "image/*": ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload
size={48}
color={colors["blue-button"]}
stroke={1.5}
/>
<IconUpload size={48} color={colors["blue-button"]} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
@@ -292,14 +391,6 @@ function EditBerita() {
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</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>
</Dropzone>
@@ -328,9 +419,7 @@ function EditBerita() {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconX size={14} />
</ActionIcon>
@@ -338,6 +427,138 @@ function EditBerita() {
)}
</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 */}
<Box>
<Text fz="sm" fw="bold">
@@ -351,9 +572,8 @@ function EditBerita() {
/>
</Box>
{/* Action */}
{/* Action Buttons */}
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
@@ -363,8 +583,6 @@ function EditBerita() {
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"

View File

@@ -1,7 +1,7 @@
'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 { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { IconArrowBack, IconEdit, IconTrash, IconVideo } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
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 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() {
const beritaState = useProxy(stateDashboardBerita);
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 (
<Box px={{ base: 0, md: 'xs' }} py="xs">
@@ -68,71 +85,131 @@ function DetailBerita() {
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
{/* Kategori */}
<Box>
<Text fz="lg" fw="bold">Kategori</Text>
<Text fz="md" c="dimmed">{data.kategoriBerita?.name || '-'}</Text>
</Box>
{/* Judul */}
<Box>
<Text fz="lg" fw="bold">Judul</Text>
<Text fz="md" c="dimmed">{data.judul || '-'}</Text>
</Box>
{/* Deskripsi */}
<Box>
<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>
{/* Gambar Utama (Featured) */}
<Box>
<Text fz="lg" fw="bold">Gambar</Text>
<Text fz="lg" fw="bold">Gambar Utama</Text>
{data.image?.link ? (
<Image
src={data.image.link}
alt={data.judul || 'Gambar Berita'}
w={200}
h={200}
w={{ base: '100%', md: 400 }}
h={300}
radius="md"
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>
{/* 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>
<Text fz="lg" fw="bold">Konten</Text>
<Text
fz="md"
c="dimmed"
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
/>
<Paper bg="white" p="md" radius="md" mt="xs">
<Text
fz="md"
c="dimmed"
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
/>
</Paper>
</Box>
{/* Action Button */}
<Group gap="sm">
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
variant="light"
radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
{/* Action Buttons */}
<Group gap="sm" mt="md">
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
variant="light"
radius="md"
size="md"
leftSection={<IconTrash size={20} />}
>
Hapus
</Button>
<Button
color="green"
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
<Button
color="green"
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
variant="light"
radius="md"
size="md"
leftSection={<IconEdit size={20} />}
>
Edit
</Button>
</Group>
</Stack>
</Paper>

View File

@@ -15,26 +15,38 @@ import {
TextInput,
Title,
Loader,
ActionIcon
ActionIcon,
Grid,
Card,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
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 { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import { convertYoutubeUrlToEmbed } from '@/app/admin/(dashboard)/desa/gallery/lib/youtube-utils';
export default function CreateBerita() {
const beritaState = useProxy(stateDashboardBerita);
const router = useRouter();
// Featured image state
const [previewImage, setPreviewImage] = useState<string | 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);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
@@ -61,9 +73,35 @@ export default function CreateBerita() {
kategoriBeritaId: '',
imageId: '',
content: '',
imageIds: [],
linkVideo: '',
};
setPreviewImage(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 () => {
@@ -71,22 +109,22 @@ export default function CreateBerita() {
toast.error('Judul wajib diisi');
return;
}
if (!beritaState.berita.create.form.kategoriBeritaId) {
toast.error('Kategori wajib dipilih');
return;
}
if (isHtmlEmpty(beritaState.berita.create.form.deskripsi)) {
toast.error('Deskripsi singkat wajib diisi');
return;
}
if (!file) {
toast.error('Gambar wajib dipilih');
toast.error('Gambar utama wajib dipilih');
return;
}
if (isHtmlEmpty(beritaState.berita.create.form.content)) {
toast.error('Konten wajib diisi');
return;
@@ -94,21 +132,37 @@ export default function CreateBerita() {
try {
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,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
const featuredUploaded = featuredRes.data?.data;
if (!featuredUploaded?.id) {
return toast.error('Gagal mengunggah gambar utama');
}
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();
@@ -122,16 +176,13 @@ export default function CreateBerita() {
}
};
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header dengan tombol kembali */}
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
@@ -148,6 +199,7 @@ export default function CreateBerita() {
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
{/* Judul */}
<TextInput
label="Judul"
placeholder="Masukkan judul berita"
@@ -156,6 +208,7 @@ export default function CreateBerita() {
required
/>
{/* Kategori */}
<Select
label="Kategori"
placeholder="Pilih kategori"
@@ -182,6 +235,7 @@ export default function CreateBerita() {
required
/>
{/* Deskripsi */}
<Box>
<Text fz="sm" fw="bold" mb={6}>
Deskripsi Singkat
@@ -194,9 +248,10 @@ export default function CreateBerita() {
/>
</Box>
{/* Featured Image */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Berita
Gambar Utama (Featured)
</Text>
<Dropzone
onDrop={(files) => {
@@ -232,17 +287,11 @@ export default function CreateBerita() {
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
alt="Preview Gambar Utama"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
@@ -255,9 +304,7 @@ export default function CreateBerita() {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconX size={14} />
</ActionIcon>
@@ -265,6 +312,102 @@ export default function CreateBerita() {
)}
</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>
<Text fz="sm" fw="bold" mb={6}>
Konten
@@ -277,6 +420,7 @@ export default function CreateBerita() {
/>
</Box>
{/* Buttons */}
<Group justify="right">
<Button
variant="outline"
@@ -287,8 +431,6 @@ export default function CreateBerita() {
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"

View File

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

View File

@@ -8,6 +8,7 @@ import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import DOMPurify from 'dompurify';
export default function DetailPotensi() {
const router = useRouter();
@@ -77,7 +78,17 @@ export default function DetailPotensi() {
<Box>
<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>
@@ -102,7 +113,12 @@ export default function DetailPotensi() {
<Text
fz="md"
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" }}
/>
</Box>

View File

@@ -27,6 +27,7 @@ import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import potensiDesaState from '../../../_state/desa/potensi';
import { useDebouncedValue } from '@mantine/hooks';
import DOMPurify from 'dompurify';
function Potensi() {
const [search, setSearch] = useState("");
@@ -137,7 +138,12 @@ function ListPotensi({ search }: { search: string }) {
fz="sm"
lh={1.5}
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' }}
/>
</TableTd>
@@ -199,7 +205,12 @@ function ListPotensi({ search }: { search: string }) {
<Text
fz="sm"
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' }}
/>
</Box>

View File

@@ -95,7 +95,7 @@ function Page() {
fz={{ base: 'md', md: 'lg' }}
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>
</Paper>
</Stack>

View File

@@ -0,0 +1,407 @@
/* 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 {
Box,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
NumberInput,
Title,
Table,
TableThead,
TableTbody,
TableTr,
TableTh,
TableTd,
ActionIcon,
Badge,
Modal,
Divider,
Loader,
Center,
} from '@mantine/core';
import {
IconPlus,
IconEdit,
IconTrash,
IconCalendar,
IconCoin,
} from '@tabler/icons-react';
import { useState } from 'react';
import { toast } from 'react-toastify';
import colors from '@/con/colors';
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({
jumlah: 0,
tanggal: new Date().toISOString().split('T')[0], // YYYY-MM-DD format for input
keterangan: '',
});
const resetForm = () => {
setFormData({
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({
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');
}
try {
setLoading(true);
if (editingId) {
// Update existing realisasi
const success = await state.realisasi.update(editingId, {
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, {
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>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>
<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>
<NumberInput
label="Jumlah Realisasi (Rp)"
value={formData.jumlah}
onChange={(val) => setFormData({ ...formData, jumlah: Number(val) || 0 })}
leftSection={<IconCoin size={16} />}
thousandSeparator
min={0}
step={100000}
required
/>
<TextInput
label="Tanggal Realisasi"
type="date"
value={formData.tanggal}
onChange={(e) => setFormData({ ...formData, tanggal: e.target.value })}
leftSection={<IconCalendar size={18} />}
required
/>
<TextInput
label="Keterangan / Uraian"
placeholder="Contoh: Penyaluran BLT Tahap 1"
value={formData.keterangan}
onChange={(e) => setFormData({ ...formData, keterangan: e.target.value })}
description="Deskripsi singkat tentang realisasi ini"
/>
<Divider my="xs" />
<Group justify="right">
<Button
variant="outline"
color="gray"
onClick={() => {
setModalOpened(false);
resetForm();
}}
disabled={loading}
>
Batal
</Button>
<Button
onClick={handleSubmit}
loading={loading}
color="blue"
leftSection={editingId ? <IconEdit size={16} /> : <IconPlus size={16} />}
>
{editingId ? 'Perbarui' : 'Tambah'} Realisasi
</Button>
</Group>
</Stack>
</Modal>
</Paper>
);
}

View File

@@ -42,7 +42,6 @@ type ItemForm = {
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
};
@@ -71,7 +70,6 @@ function EditAPBDes() {
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
@@ -79,6 +77,9 @@ function EditAPBDes() {
// Simpan data original untuk reset form
const [originalData, setOriginalData] = useState({
tahun: 0,
name: '',
deskripsi: '',
jumlah: '',
imageId: '',
fileId: '',
imageUrl: '',
@@ -103,6 +104,9 @@ function EditAPBDes() {
// Simpan data original untuk reset
setOriginalData({
tahun: data.tahun || new Date().getFullYear(),
name: data.name || '',
deskripsi: data.deskripsi || '',
jumlah: data.jumlah || '',
imageId: data.imageId || '',
fileId: data.fileId || '',
imageUrl: data.image?.link || '',
@@ -112,6 +116,9 @@ function EditAPBDes() {
// Set form dengan data lama (termasuk imageId dan fileId)
apbdesState.edit.form = {
tahun: data.tahun || new Date().getFullYear(),
name: data.name || '',
deskripsi: data.deskripsi || '',
jumlah: data.jumlah || '',
imageId: data.imageId || '',
fileId: data.fileId || '',
items: (data.items || []).map((item: any) => ({
@@ -148,32 +155,25 @@ function EditAPBDes() {
};
const handleAddItem = () => {
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
const { kode, uraian, anggaran, level, tipe } = newItem;
if (!kode || !uraian) {
return toast.warn('Kode dan uraian wajib diisi');
}
const finalTipe = level === 1 ? null : tipe;
const selisih = realisasi - anggaran;
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
apbdesState.edit.addItem({
kode,
uraian,
anggaran,
realisasi,
selisih,
persentase,
level,
tipe: finalTipe, // ✅ Tidak akan undefined
tipe: finalTipe,
});
setNewItem({
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
@@ -238,9 +238,12 @@ function EditAPBDes() {
};
const handleReset = () => {
// Reset ke data original (tahun, imageId, fileId)
// Reset ke data original (tahun, name, deskripsi, jumlah, imageId, fileId)
apbdesState.edit.form = {
tahun: originalData.tahun,
name: originalData.name,
deskripsi: originalData.deskripsi,
jumlah: originalData.jumlah,
imageId: originalData.imageId,
fileId: originalData.fileId,
items: [...apbdesState.edit.form.items], // keep existing items
@@ -249,21 +252,20 @@ function EditAPBDes() {
// Reset preview ke data original
setPreviewImage(originalData.imageUrl || null);
setPreviewDoc(originalData.fileUrl || null);
// Reset file uploads
setImageFile(null);
setDocFile(null);
// Reset new item form
setNewItem({
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
toast.info('Form dikembalikan ke data awal');
};
@@ -288,6 +290,33 @@ function EditAPBDes() {
>
<Stack gap="md">
{/* 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
label="Tahun"
value={apbdesState.edit.form.tahun || new Date().getFullYear()}
@@ -475,13 +504,6 @@ function EditAPBDes() {
thousandSeparator
min={0}
/>
<NumberInput
label="Realisasi (Rp)"
value={newItem.realisasi}
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
thousandSeparator
min={0}
/>
</Group>
<Button
leftSection={<IconPlus size={16} />}
@@ -505,7 +527,6 @@ function EditAPBDes() {
<th>Kode</th>
<th>Uraian</th>
<th>Anggaran</th>
<th>Realisasi</th>
<th>Level</th>
<th>Tipe</th>
<th style={{ width: '50px' }}>Aksi</th>
@@ -521,7 +542,6 @@ function EditAPBDes() {
</td>
<td>{item.uraian}</td>
<td>{item.anggaran.toLocaleString('id-ID')}</td>
<td>{item.realisasi.toLocaleString('id-ID')}</td>
<td>
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
L{item.level}
@@ -533,7 +553,7 @@ function EditAPBDes() {
{item.tipe}
</Badge>
) : (
'-'
<Text size="sm" c="dimmed">-</Text>
)}
</td>
<td>

View File

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

View File

@@ -33,7 +33,6 @@ type ItemForm = {
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
};
@@ -61,7 +60,6 @@ function CreateAPBDes() {
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
@@ -80,7 +78,6 @@ function CreateAPBDes() {
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
@@ -117,9 +114,9 @@ function CreateAPBDes() {
toast.success("Berhasil menambahkan APBDes");
resetForm();
router.push("/admin/landing-page/apbdes");
} catch (error) {
} catch (error: any) {
console.error("Gagal submit:", error);
toast.error("Gagal menyimpan data");
toast.error(error?.message || "Gagal menyimpan data");
} finally {
setIsSubmitting(false);
}
@@ -127,22 +124,17 @@ function CreateAPBDes() {
// Tambahkan item ke state
const handleAddItem = () => {
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
const { kode, uraian, anggaran, level, tipe } = newItem;
if (!kode || !uraian) {
return toast.warn("Kode dan uraian wajib diisi");
}
const finalTipe = level === 1 ? null : tipe;
const selisih = realisasi - anggaran;
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
stateAPBDes.create.addItem({
kode,
uraian,
anggaran,
realisasi,
selisih,
persentase,
level,
tipe: finalTipe,
});
@@ -152,7 +144,6 @@ function CreateAPBDes() {
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
@@ -334,6 +325,27 @@ function CreateAPBDes() {
</Stack>
{/* 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
label="Tahun"
value={stateAPBDes.create.form.tahun || new Date().getFullYear()}
@@ -406,13 +418,6 @@ function CreateAPBDes() {
thousandSeparator
min={0}
/>
<NumberInput
label="Realisasi (Rp)"
value={newItem.realisasi}
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
thousandSeparator
min={0}
/>
</Group>
<Button
leftSection={<IconPlus size={16} />}
@@ -434,28 +439,30 @@ function CreateAPBDes() {
<th>Kode</th>
<th>Uraian</th>
<th>Anggaran</th>
<th>Realisasi</th>
<th>Level</th>
<th>Tipe</th>
<th style={{ width: 50 }}>Aksi</th>
</tr>
</thead>
<tbody>
{stateAPBDes.create.form.items.map((item, idx) => (
{stateAPBDes.create.form.items.map((item: any, idx) => (
<tr key={idx}>
<td><Text size="sm" fw={500}>{item.kode}</Text></td>
<td>{item.uraian}</td>
<td>{item.anggaran.toLocaleString('id-ID')}</td>
<td>{item.realisasi.toLocaleString('id-ID')}</td>
<td>
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
L{item.level}
</Badge>
</td>
<td>
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
{item.tipe}
</Badge>
{item.tipe ? (
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
{item.tipe}
</Badge>
) : (
<Text size="sm" c="dimmed">-</Text>
)}
</td>
<td>
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>

View File

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

View 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>
);
}

View 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 &quot;{data.judul}&quot;?
</Text>
<Text c="red" fz="sm">
Tindakan ini tidak dapat dibatalkan.
</Text>
<Group justify="right" mt="md">
<Button
variant="outline"
color="gray"
onClick={() => setShowDeleteModal(false)}
>
Batal
</Button>
<Button
color="red"
onClick={handleDelete}
loading={isDeleting}
>
Hapus
</Button>
</Group>
</Stack>
</Modal>
</Box>
);
}

View File

@@ -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>
);
}

View 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;

View File

@@ -330,7 +330,7 @@ export const devBar = [
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
}
]
},
},
{
id: "Pendidikan",
name: "Pendidikan",
@@ -373,6 +373,11 @@ export const devBar = [
}
]
},
{
id: "Musik",
name: "Musik",
path: "/admin/musik"
},
{
id: "User & Role",
name: "User & Role",
@@ -729,7 +734,7 @@ export const navBar = [
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
}
]
},
},
{
id: "Pendidikan",
name: "Pendidikan",
@@ -772,6 +777,11 @@ export const navBar = [
}
]
},
{
id: "Musik",
name: "Musik",
path: "/admin/musik"
},
{
id: "User & Role",
name: "User & Role",
@@ -1051,7 +1061,7 @@ export const role1 = [
}
]
},
},
{
id: "Lingkungan",
name: "Lingkungan",
@@ -1088,6 +1098,11 @@ export const role1 = [
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"
}
]
},
{
id: "Musik",
name: "Musik",
path: "/admin/musik"
}
]
@@ -1178,5 +1198,10 @@ export const role3 = [
path: "/admin/pendidikan/data-pendidikan"
}
]
},
{
id: "Musik",
name: "Musik",
path: "/admin/musik"
}
]

View File

@@ -1,9 +1,9 @@
'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 { useDarkMode } from "@/state/darkModeStore";
import { authStore } from "@/store/authStore";
import { themeTokens } from "@/utils/themeTokens";
import {
ActionIcon,
AppShell,
@@ -316,8 +316,13 @@ export default function Layout({ children }: { children: React.ReactNode }) {
}}
variant="light"
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));
return (
<NavLink

View File

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

View File

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

View File

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

View File

@@ -2,15 +2,49 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia";
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({
where: { id },
});
if (!id) {
return Response.json({
success: false,
message: "ID tidak boleh kosong",
}, { status: 400 });
}
return {
status: 200,
success: true,
message: "Sukses Menghapus kategori berita",
};
// ✅ Cek apakah kategori masih digunakan oleh berita
const beritaCount = await prisma.berita.count({
where: {
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 });
}
}

View File

@@ -4,52 +4,48 @@ import { Prisma } from "@prisma/client";
import fs from "fs/promises";
import path from "path";
type FormUpdate = Prisma.BeritaGetPayload<{
select: {
id: true;
judul: true;
deskripsi: true;
content: true;
kategoriBeritaId: true;
imageId: true;
};
}>;
type FormUpdate = {
id: string;
judul: string;
deskripsi: string;
content: string;
kategoriBeritaId: string;
imageId: string; // Featured image
imageIds?: string[]; // Multiple images for gallery
linkVideo?: string; // YouTube link
};
async function beritaUpdate(context: Context) {
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 {
judul,
deskripsi,
content,
kategoriBeritaId,
imageId,
} = body;
const { judul, deskripsi, content, kategoriBeritaId, imageId, imageIds, linkVideo } = body;
if (!id) {
return new Response(
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({
where: { id },
include: {
image: true,
images: true, // Include gallery images
kategoriBerita: true,
},
});
if (!existing) {
return new Response(
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) {
const oldImage = existing.image;
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({
where: { id },
data: {
judul,
deskripsi,
content,
kategoriBeritaId: kategoriBeritaId || null,
imageId,
data: updateData,
include: {
image: true,
images: true,
kategoriBerita: true,
},
});
return new Response(
JSON.stringify({
success: true,
message: "Berita berhasil diupdate",
data: updated,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Error updating berita:", error);
console.error("Error updating berita:", error);
return new Response(
JSON.stringify({
success: false,
message: "Terjadi kesalahan saat mengupdate berita",
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
}
export default beritaUpdate;
export default beritaUpdate;

View File

@@ -2,7 +2,7 @@ import Elysia from "elysia";
import Berita from "./berita";
import Pengumuman from "./pengumuman";
import ProfileDesa from "./profile/profile_desa";
import PotensiDesa from "./potensi";
import PotensiDesa from "./potensi";
import GalleryFoto from "./gallery/foto";
import GalleryVideo from "./gallery/video";
import LayananDesa from "./layanan";
@@ -12,6 +12,7 @@ import KategoriBerita from "./berita/kategori-berita";
import KategoriPengumuman from "./pengumuman/kategori-pengumuman";
import MantanPerbekel from "./profile/profile-mantan-perbekel";
import AjukanPermohonan from "./layanan/ajukan_permohonan";
import Musik from "./musik";
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(KategoriPengumuman)
.use(AjukanPermohonan)
.use(Musik)
export default Desa;

View 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;

View 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;

View 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" },
}
);
}
}

View 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;

View 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;

View 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;

View File

@@ -21,8 +21,12 @@ export default async function findUnique(
}, { status: 400 });
}
const data = await prisma.potensiDesa.findUnique({
where: { id },
// ✅ Filter by isActive and deletedAt
const data = await prisma.potensiDesa.findFirst({
where: {
id,
isActive: true,
},
include: {
image: true,
kategori: true
@@ -48,5 +52,5 @@ export default async function findUnique(
message: "Gagal mengambil potensi desa: " + (error instanceof Error ? error.message : 'Unknown error'),
}, { status: 500 });
}
}

View File

@@ -2,15 +2,49 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia";
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({
where: { id },
});
if (!id) {
return Response.json({
success: false,
message: "ID tidak boleh kosong",
}, { status: 400 });
}
return {
status: 200,
success: true,
message: "Sukses Menghapus kategori potensi",
};
// ✅ Cek apakah kategori masih digunakan oleh potensi desa
const existingPotensi = await prisma.potensiDesa.findFirst({
where: {
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 });
}
}

View File

@@ -1,10 +1,9 @@
import prisma from "@/lib/prisma";
import { requireAuth } from "@/lib/api-auth";
export default async function sejarahDesaFindFirst(request: Request) {
export default async function sejarahDesaFindFirst() {
// ✅ Authentication check
const headers = new Headers(request.url);
const authResult = await requireAuth({ headers });
const authResult = await requireAuth();
if (!authResult.authenticated) {
return authResult.response;
}
@@ -12,9 +11,8 @@ export default async function sejarahDesaFindFirst(request: Request) {
try {
// Get the first active record
const data = await prisma.sejarahDesa.findFirst({
where: {
where: {
isActive: true,
deletedAt: null
},
orderBy: { createdAt: 'asc' } // Get the oldest one first
});

View File

@@ -7,8 +7,8 @@ const SejarahDesa = new Elysia({
prefix: "/sejarah",
tags: ["Desa/Profile"],
})
.get("/first", async (context) => {
const response = await sejarahDesaFindFirst(new Request(context.request));
.get("/first", async () => {
const response = await sejarahDesaFindFirst();
return response;
})
.get("/:id", async (context) => {

View File

@@ -4,7 +4,7 @@ import { Context } from "elysia";
export default async function sejarahDesaUpdate(context: Context) {
// ✅ Authentication check
const authResult = await requireAuth(context);
const authResult = await requireAuth();
if (!authResult.authenticated) {
return authResult.response;
}

View File

@@ -22,9 +22,10 @@ const fileStorageCreate = async (context: Context) => {
if (!UPLOAD_DIR) return { status: 500, body: "UPLOAD_DIR is not defined" };
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);
await fs.mkdir(rootPath, { recursive: true });
@@ -54,6 +55,11 @@ const fileStorageCreate = async (context: Context) => {
// Simpan metadata untuk versi desktop sebagai default
finalName = desktopName;
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 {
// Jika file adalah PDF, simpan tanpa kompresi
if (file.type === "application/pdf") {

View File

@@ -8,15 +8,15 @@ type APBDesItemInput = {
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
selisih: number;
persentase: number;
level: number;
tipe?: string | null;
};
type FormCreate = {
tahun: number;
name?: string;
deskripsi?: string;
jumlah?: string;
imageId: string;
fileId: string;
items: APBDesItemInput[];
@@ -24,8 +24,7 @@ type FormCreate = {
export default async function apbdesCreate(context: Context) {
const body = context.body as FormCreate;
// Log the incoming request for debugging
console.log('Incoming request body:', JSON.stringify(body, null, 2));
try {
@@ -43,33 +42,39 @@ export default async function apbdesCreate(context: Context) {
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 apbdes = await prisma.aPBDes.create({
data: {
tahun: body.tahun,
name: `APBDes Tahun ${body.tahun}`,
name: body.name || `APBDes Tahun ${body.tahun}`,
deskripsi: body.deskripsi,
jumlah: body.jumlah,
imageId: body.imageId,
fileId: body.fileId,
},
});
// Create items in a batch
// Create items dengan auto-calculate totalRealisasi=0, selisih, persentase
const items = await Promise.all(
body.items.map(item => {
// Create a new object with only the fields that exist in the APBDesItem model
body.items.map(async item => {
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 = {
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
realisasi: item.realisasi,
selisih: item.selisih,
persentase: item.persentase,
anggaran: anggaran,
level: item.level,
tipe: item.tipe, // ✅ sertakan, biar null
tipe: item.tipe || null,
totalRealisasi,
selisih,
persentase,
apbdesId: apbdes.id,
};
return prisma.aPBDesItem.create({
data: itemData,
select: { id: true, kode: true },
@@ -84,20 +89,27 @@ export default async function apbdesCreate(context: Context) {
// 2. Isi parentId berdasarkan kode
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({
where: { id: created.id },
include: {
image: true,
file: true,
items: {
where: { isActive: true },
orderBy: { kode: 'asc' },
include: {
realisasiItems: {
where: { isActive: true },
orderBy: { tanggal: 'asc' },
},
},
},
},
});
console.log('APBDes created successfully:', JSON.stringify(result, null, 2));
return {
success: true,
message: "Berhasil membuat APBDes",
@@ -105,7 +117,6 @@ export default async function apbdesCreate(context: Context) {
};
} catch (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 {
success: true,
message: "APBDes berhasil dibuat, tetapi ada masalah dengan pemrosesan tambahan",
@@ -115,13 +126,12 @@ export default async function apbdesCreate(context: Context) {
}
} catch (error: any) {
console.error("Error creating APBDes:", error);
// Log the full error for debugging
if (error.code) console.error('Prisma error code:', error.code);
if (error.meta) console.error('Prisma error meta:', error.meta);
const errorMessage = error.message || 'Unknown error';
context.set.status = 500;
return {
success: false,

View File

@@ -21,7 +21,7 @@ export default async function apbdesFindMany(context: Context) {
try {
const where: any = { isActive: true };
if (search) {
where.OR = [
{ name: { contains: search, mode: "insensitive" } },
@@ -47,7 +47,16 @@ export default async function apbdesFindMany(context: Context) {
include: {
image: true,
file: true,
items: true,
items: {
where: { isActive: true },
orderBy: { kode: "asc" },
include: {
realisasiItems: {
where: { isActive: true },
orderBy: { tanggal: 'asc' },
},
},
},
},
}),
prisma.aPBDes.count({ where }),

View File

@@ -2,15 +2,9 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function apbdesFindUnique(context: Context) {
// ✅ Parse URL secara manual
const url = new URL(context.request.url);
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']
if (pathSegments.length < 4) {
context.set.status = 400;
@@ -20,9 +14,9 @@ export default async function apbdesFindUnique(context: Context) {
debug: { pathSegments }
};
}
if (pathSegments[0] !== 'api' ||
pathSegments[1] !== 'landingpage' ||
if (pathSegments[0] !== 'api' ||
pathSegments[1] !== 'landingpage' ||
pathSegments[2] !== 'apbdes') {
context.set.status = 400;
return {
@@ -31,9 +25,9 @@ export default async function apbdesFindUnique(context: Context) {
debug: { pathSegments }
};
}
const id = pathSegments[3]; // ✅ ID ada di index ke-3
const id = pathSegments[3];
if (!id || id.trim() === '') {
context.set.status = 400;
return {
@@ -48,11 +42,17 @@ export default async function apbdesFindUnique(context: Context) {
include: {
items: {
where: { isActive: true },
orderBy: { kode: 'asc' }
orderBy: { kode: 'asc' },
include: {
realisasiItems: {
where: { isActive: true },
orderBy: { tanggal: 'asc' },
},
},
},
image: true,
file: true
}
file: true,
},
});
if (!result || !result.isActive) {

View File

@@ -5,17 +5,17 @@ import apbdesDelete from "./del";
import apbdesFindMany from "./findMany";
import apbdesFindUnique from "./findUnique";
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({
kode: t.String(),
uraian: t.String(),
anggaran: t.Number(),
realisasi: t.Number(),
selisih: t.Number(),
persentase: 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({
@@ -26,33 +26,70 @@ const APBDes = new Elysia({
// ✅ Find all (dengan query opsional: page, limit, tahun)
.get("/findMany", apbdesFindMany)
// ✅ Find by ID
// ✅ Find by ID (include realisasiItems)
.get("/:id", apbdesFindUnique)
// ✅ Create
// ✅ Create APBDes dengan items (tanpa realisasi)
.post("/create", apbdesCreate, {
body: t.Object({
tahun: t.Number(),
name: t.Optional(t.String()),
deskripsi: t.Optional(t.String()),
jumlah: t.Optional(t.String()),
imageId: t.String(),
fileId: t.String(),
items: t.Array(ApbdesItemSchema),
}),
})
// ✅ Update
// ✅ Update APBDes dengan items (tanpa realisasi)
.put("/:id", apbdesUpdate, {
params: t.Object({ id: t.String() }),
body: t.Object({
tahun: t.Number(),
name: t.Optional(t.String()),
deskripsi: t.Optional(t.String()),
jumlah: t.Optional(t.String()),
imageId: t.String(),
fileId: t.String(),
items: t.Array(ApbdesItemSchema),
}),
})
// ✅ Delete
// ✅ Delete APBDes
.delete("/del/:id", apbdesDelete, {
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({
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({
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;

View File

@@ -0,0 +1,82 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
type RealisasiCreateBody = {
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,
jumlah: body.jumlah,
tanggal: new Date(body.tanggal),
keterangan: body.keterangan,
buktiFileId: body.buktiFileId,
},
});
// 3. Update totalRealisasi, selisih, persentase di APBDesItem
const allRealisasi = await prisma.realisasiItem.findMany({
where: { apbdesItemId: itemId, isActive: true },
select: { jumlah: true },
});
const totalRealisasi = allRealisasi.reduce((sum, r) => sum + r.jumlah, 0);
const selisih = item.anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
await prisma.aPBDesItem.update({
where: { id: itemId },
data: {
totalRealisasi,
selisih,
persentase,
},
});
// 4. Return response
return {
success: true,
message: "Realisasi berhasil ditambahkan",
data: realisasi,
meta: {
totalRealisasi,
selisih,
persentase,
},
};
} catch (error: any) {
console.error("Error creating realisasi:", error);
context.set.status = 500;
return {
success: false,
message: `Gagal menambahkan realisasi: ${error.message}`,
error: process.env.NODE_ENV === 'development' ? error : undefined,
};
}
}

View File

@@ -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,
};
}
}

View File

@@ -0,0 +1,85 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
type RealisasiUpdateBody = {
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.jumlah !== undefined && { jumlah: body.jumlah }),
...(body.tanggal !== undefined && { tanggal: new Date(body.tanggal) }),
...(body.keterangan !== undefined && { keterangan: body.keterangan }),
...(body.buktiFileId !== undefined && { buktiFileId: body.buktiFileId }),
},
});
// 3. Recalculate totalRealisasi, selisih, persentase di APBDesItem
const allRealisasi = await prisma.realisasiItem.findMany({
where: { apbdesItemId: existing.apbdesItemId, isActive: true },
select: { jumlah: true },
});
const item = await prisma.aPBDesItem.findUnique({
where: { id: existing.apbdesItemId },
});
if (item) {
const totalRealisasi = allRealisasi.reduce((sum, r) => sum + r.jumlah, 0);
const selisih = item.anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
await prisma.aPBDesItem.update({
where: { id: existing.apbdesItemId },
data: {
totalRealisasi,
selisih,
persentase,
},
});
}
return {
success: true,
message: "Realisasi berhasil diperbarui",
data: updated,
meta: {
totalRealisasi: allRealisasi.reduce((sum, r) => sum + r.jumlah, 0),
},
};
} catch (error: any) {
console.error("Error updating realisasi:", error);
context.set.status = 500;
return {
success: false,
message: `Gagal memperbarui realisasi: ${error.message}`,
error: process.env.NODE_ENV === 'development' ? error : undefined,
};
}
}

View File

@@ -6,15 +6,15 @@ type APBDesItemInput = {
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
selisih: number;
persentase: number;
level: number;
tipe?: string | null;
};
type FormUpdateBody = {
tahun: number;
name?: string;
deskripsi?: string;
jumlah?: string;
imageId: string;
fileId: string;
items: APBDesItemInput[];
@@ -38,25 +38,32 @@ export default async function apbdesUpdate(context: Context) {
};
}
// 2. Hapus semua item lama
// 2. Hapus semua item lama (cascade akan menghapus realisasiItems juga)
await prisma.aPBDesItem.deleteMany({
where: { apbdesId: id },
});
// 3. Buat item baru tanpa parentId terlebih dahulu
// 3. Buat item baru dengan auto-calculate fields
await prisma.aPBDesItem.createMany({
data: body.items.map((item) => ({
apbdesId: id,
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
realisasi: item.realisasi,
selisih: item.anggaran - item.realisasi,
persentase: item.anggaran > 0 ? (item.realisasi / item.anggaran) * 100 : 0,
level: item.level,
tipe: item.tipe || null,
isActive: true,
})),
data: body.items.map((item) => {
const anggaran = item.anggaran;
const totalRealisasi = 0; // Reset karena items baru
const selisih = anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0;
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
@@ -66,12 +73,11 @@ export default async function apbdesUpdate(context: Context) {
});
// 5. Update parentId untuk setiap item
// Pastikan allItems memiliki tipe yang benar
const itemsForParentUpdate = allItems.map(item => ({
id: item.id,
kode: item.kode,
}));
await assignParentIdsToApbdesItems(itemsForParentUpdate);
// 6. Update data APBDes
@@ -79,18 +85,27 @@ export default async function apbdesUpdate(context: Context) {
where: { id },
data: {
tahun: body.tahun,
name: body.name || `APBDes Tahun ${body.tahun}`,
deskripsi: body.deskripsi,
jumlah: body.jumlah,
imageId: body.imageId,
fileId: body.fileId,
},
});
// 5. Ambil data lengkap untuk response
// 7. Ambil data lengkap untuk response (include realisasiItems)
const result = await prisma.aPBDes.findUnique({
where: { id },
include: {
items: {
where: { isActive: true },
orderBy: { kode: 'asc' }
orderBy: { kode: 'asc' },
include: {
realisasiItems: {
where: { isActive: true },
orderBy: { tanggal: 'asc' },
},
},
},
image: true,
file: true,

View File

@@ -46,11 +46,17 @@ fs.mkdir(UPLOAD_DIR_IMAGE, {
}).catch(() => {});
const corsConfig = {
origin: "*",
methods: ["GET", "POST", "PATCH", "DELETE", "PUT"] as HTTPMethod[],
allowedHeaders: "*",
origin: [
"http://localhost:3000",
"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: "*",
maxAge: 5,
maxAge: 86400, // 24 hours
credentials: true,
};

View File

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

View File

@@ -19,7 +19,7 @@ export async function POST(req: Request) {
const otpNumber = Number(codeOtp);
// 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 waRes = await fetch(waUrl);
const waData = await waRes.json();

View File

@@ -3,10 +3,43 @@
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
import NewsReader from '@/app/darmasaba/_com/NewsReader';
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 { useEffect, useState } from 'react';
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() {
const params = useParams<{ id: string }>();
@@ -45,13 +78,30 @@ function Page() {
);
}
const data = state.findUnique.data as unknown as BeritaDetail;
return (
<Stack pos="relative" bg={colors.Bg} pb="xl" gap="xs" px={{ base: 'md', md: 0 }}>
<Group px={{ base: 'md', md: 100 }}>
<NewsReader />
</Group>
<Container w={{ base: '100%', md: '50%' }}>
<Container w={{ base: '100%', md: '60%' }}>
<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
id="news-title"
order={1}
@@ -59,41 +109,108 @@ function Page() {
c={colors['blue-button']}
fw="bold"
lh={{ base: 1.2, md: 1.25 }}
mb="md"
>
{state.findUnique.data.judul}
</Title>
<Title
order={2}
ta="center"
fw="bold"
fz={{ base: 'md', md: 'lg' }}
lh={{ base: 1.3, md: 1.35 }}
>
Informasi dan Pelayanan Administrasi Digital
{data.judul}
</Title>
<Divider my="xs" />
</Box>
<Image src={state.findUnique.data.image?.link || ''} alt="" w="100%" loading="lazy" />
</Container>
<Box px={{ base: 'md', md: 100 }}>
<Stack gap="xs">
{/* Featured Image */}
{data.image?.link && (
<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
id="news-content"
py={20}
px={{ base: 0, md: 'sm' }}
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.6, md: 1.8 }}
lh={{ base: 1.8, md: 2 }}
ta="justify"
c="dimmed"
style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
}}
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>
);
}
export default Page;
export default Page;

View 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);

View 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)
// }

View 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)
// }

View 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));
}
}

View File

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

View 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,
// };
// }

View 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);
}

View File

@@ -1,45 +1,161 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import { ActionIcon, Avatar, Badge, Box, Card, Flex, Grid, Group, Paper, Slider, Stack, Text, TextInput } from '@mantine/core';
import { ActionIcon, Avatar, Badge, Box, Card, Flex, Grid, Group, Paper, ScrollArea, Slider, Stack, Text, TextInput } from '@mantine/core';
import { IconArrowsShuffle, IconPlayerPauseFilled, IconPlayerPlayFilled, IconPlayerSkipBackFilled, IconPlayerSkipForwardFilled, IconRepeat, IconRepeatOff, IconSearch, IconVolume, IconVolumeOff, IconX } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import BackButton from '../../desa/layanan/_com/BackButto';
import { togglePlayPause } from '../lib/playPause';
import { getNextIndex, getPrevIndex } from '../lib/nextPrev';
import { handleRepeatOrNext } from '../lib/repeat';
import { toggleShuffle } from '../lib/shuffle';
import { setAudioVolume, toggleMute as toggleMuteUtil } from '../lib/volume';
import { useAudioProgress } from '../lib/useAudioProgress';
interface MusicFile {
id: string;
name: string;
realName: string;
path: string;
mimeType: string;
link: string;
}
interface Musik {
id: string;
judul: string;
artis: string;
deskripsi: string | null;
durasi: string;
genre: string | null;
tahunRilis: number | null;
audioFile: MusicFile | null;
coverImage: MusicFile | null;
isActive: boolean;
}
const MusicPlayer = () => {
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(245);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(70);
const [isMuted, setIsMuted] = useState(false);
const [isRepeat, setIsRepeat] = useState(false);
const [isShuffle, setIsShuffle] = useState(false);
const [search, setSearch] = useState('');
const [musikData, setMusikData] = useState<Musik[]>([]);
const [loading, setLoading] = useState(true);
const [currentSongIndex, setCurrentSongIndex] = useState(-1);
const audioRef = useRef<HTMLAudioElement | null>(null);
const isSeekingRef = useRef(false);
const lastPlayedSongIdRef = useRef<string | null>(null);
const lastSeekTimeRef = useRef<number>(0); // Track last seek time
const songs = [
{ id: 1, title: 'Midnight Dreams', artist: 'The Wanderers', duration: '4:05', cover: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop' },
{ id: 2, title: 'Summer Breeze', artist: 'Coastal Vibes', duration: '3:42', cover: 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop' },
{ id: 3, title: 'City Lights', artist: 'Urban Echo', duration: '4:18', cover: 'https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=400&h=400&fit=crop' },
{ id: 4, title: 'Ocean Waves', artist: 'Serenity Sound', duration: '5:20', cover: 'https://images.unsplash.com/photo-1459749411175-04bf5292ceea?w=400&h=400&fit=crop' },
{ id: 5, title: 'Neon Nights', artist: 'Electric Dreams', duration: '3:55', cover: 'https://images.unsplash.com/photo-1487180144351-b8472da7d491?w=400&h=400&fit=crop' },
{ id: 6, title: 'Mountain High', artist: 'Peak Performers', duration: '4:32', cover: 'https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?w=400&h=400&fit=crop' }
];
const [currentSong, setCurrentSong] = useState(songs[0]);
// Smooth progress update dengan requestAnimationFrame
useAudioProgress(audioRef as React.RefObject<HTMLAudioElement>, isPlaying, setCurrentTime, isSeekingRef);
// Fetch musik data from API
useEffect(() => {
let interval: any;
if (isPlaying) {
interval = setInterval(() => {
setCurrentTime(prev => {
if (prev >= duration) {
const fetchMusik = async () => {
try {
setLoading(true);
const res = await fetch('/api/desa/musik/find-many?page=1&limit=50');
const data = await res.json();
if (data.success && data.data) {
const activeMusik = data.data.filter((m: Musik) => m.isActive);
setMusikData(activeMusik);
}
} catch (error) {
console.error('Error fetching musik:', error);
} finally {
setLoading(false);
}
};
fetchMusik();
}, []);
// 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]);
const currentSong = currentSongIndex >= 0 && currentSongIndex < filteredMusik.length
? filteredMusik[currentSongIndex]
: null;
// // Update progress bar
// useEffect(() => {
// if (isPlaying && audioRef.current) {
// progressInterval.current = window.setInterval(() => {
// if (audioRef.current) {
// setCurrentTime(Math.floor(audioRef.current.currentTime));
// }
// }, 1000);
// } else {
// if (progressInterval.current) {
// clearInterval(progressInterval.current);
// }
// }
// return () => {
// if (progressInterval.current) {
// clearInterval(progressInterval.current);
// }
// };
// }, [isPlaying]);
// Update duration when song changes (HANYA saat ganti lagu, bukan saat isPlaying berubah)
useEffect(() => {
if (currentSong && audioRef.current) {
// Cek apakah ini benar-benar lagu baru
const isNewSong = lastPlayedSongIdRef.current !== currentSong.id;
if (isNewSong) {
// Gunakan durasi dari database sebagai acuan utama
const durationParts = currentSong.durasi.split(':');
const durationInSeconds = parseInt(durationParts[0]) * 60 + parseInt(durationParts[1]);
setDuration(durationInSeconds);
// Reset audio currentTime ke 0 untuk lagu baru
audioRef.current.currentTime = 0;
setCurrentTime(0);
// Update ref
lastPlayedSongIdRef.current = currentSong.id;
if (isPlaying) {
audioRef.current.play().catch(err => {
console.error('Error playing audio:', err);
setIsPlaying(false);
return 0;
}
return prev + 1;
});
}, 1000);
});
}
}
// Jika bukan lagu baru, jangan reset currentTime (biar seek tidak kembali ke 0)
}
return () => clearInterval(interval);
}, [isPlaying, duration]);
}, [currentSong?.id]); // Intentional: hanya depend on song ID, bukan isPlaying
// Sync duration dari audio element jika berbeda signifikan (> 1 detik)
useEffect(() => {
const audio = audioRef.current;
if (!audio || !currentSong) return;
const handleLoadedMetadata = () => {
const audioDuration = Math.floor(audio.duration);
const durationParts = currentSong.durasi.split(':');
const dbDuration = parseInt(durationParts[0]) * 60 + parseInt(durationParts[1]);
// Jika perbedaan > 2 detik, gunakan audio duration (lebih akurat)
if (Math.abs(audioDuration - dbDuration) > 2) {
setDuration(audioDuration);
}
};
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
return () => audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
}, [currentSong?.id]); // Intentional: hanya depend on song ID
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
@@ -47,20 +163,86 @@ const MusicPlayer = () => {
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const playSong = (song: any) => {
setCurrentSong(song);
setCurrentTime(0);
const playSong = (index: number) => {
if (index < 0 || index >= filteredMusik.length) return;
setCurrentSongIndex(index);
setIsPlaying(true);
const durationInSeconds = parseInt(song.duration.split(':')[0]) * 60 + parseInt(song.duration.split(':')[1]);
setDuration(durationInSeconds);
};
const handleSongEnd = () => {
const playNext = () => {
let nextIndex: number;
if (isShuffle) {
nextIndex = Math.floor(Math.random() * filteredMusik.length);
} else {
nextIndex = (currentSongIndex + 1) % filteredMusik.length;
}
if (filteredMusik.length > 1) {
playSong(nextIndex);
} else {
setIsPlaying(false);
setCurrentTime(0);
}
};
handleRepeatOrNext(audioRef, isRepeat, playNext);
};
const toggleMute = () => {
setIsMuted(!isMuted);
toggleMuteUtil(audioRef, isMuted, setIsMuted);
};
const handleVolumeChange = (val: number) => {
setAudioVolume(audioRef, val, setVolume, setIsMuted);
};
const skipBack = () => {
const prevIndex = getPrevIndex(currentSongIndex, filteredMusik.length, isShuffle);
if (prevIndex >= 0) {
playSong(prevIndex);
}
};
const skipForward = () => {
const nextIndex = getNextIndex(currentSongIndex, filteredMusik.length, isShuffle);
if (nextIndex >= 0) {
playSong(nextIndex);
}
};
const toggleShuffleHandler = () => {
toggleShuffle(isShuffle, setIsShuffle);
};
const togglePlayPauseHandler = () => {
if (!currentSong) return;
togglePlayPause(audioRef, isPlaying, setIsPlaying);
};
if (loading) {
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 (
<Box px={{ base: 'md', md: 100 }} py="xl">
{/* Hidden audio element - gunakan key yang stabil untuk mencegah remount */}
{currentSong?.audioFile && (
<audio
ref={audioRef}
src={currentSong?.audioFile?.link}
muted={isMuted}
onEnded={handleSongEnd}
/>
)}
<Paper
mx="auto"
p="xl"
@@ -84,6 +266,8 @@ const MusicPlayer = () => {
leftSection={<IconSearch size={18} />}
radius="xl"
w={280}
value={search}
onChange={(e) => setSearch(e.target.value)}
styles={{ input: { backgroundColor: '#fff' } }}
/>
</Group>
@@ -91,63 +275,110 @@ const MusicPlayer = () => {
<Stack gap="xl">
<div>
<Text size="xl" fw={700} c="#0B4F78" mb="md">Sedang Diputar</Text>
<Card radius="md" p="xl" shadow="md">
<Group align="center" gap="xl">
<Avatar src={currentSong.cover} size={180} radius="md" />
<Stack gap="md" style={{ flex: 1 }}>
<div>
<Text size="28px" fw={700} c="#0B4F78">{currentSong.title}</Text>
<Text size="lg" c="#5A6C7D">{currentSong.artist}</Text>
</div>
<Group gap="xs" align="center">
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text>
<Slider
value={currentTime}
max={duration}
onChange={setCurrentTime}
color="#0B4F78"
size="sm"
style={{ flex: 1 }}
styles={{ thumb: { borderWidth: 2 } }}
/>
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration)}</Text>
</Group>
</Stack>
</Group>
</Card>
{currentSong ? (
<Card radius="md" p="xl" shadow="md">
<Group align="center" gap="xl">
<Avatar
src={currentSong.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
size={180}
radius="md"
/>
<Stack gap="md" style={{ flex: 1 }}>
<div>
<Text size="28px" fw={700} c="#0B4F78">{currentSong.judul}</Text>
<Text size="lg" c="#5A6C7D">{currentSong.artis}</Text>
{currentSong.genre && (
<Badge mt="xs" color="#0B4F78" variant="light">{currentSong.genre}</Badge>
)}
</div>
<Group gap="xs" align="center">
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text>
<Slider
value={currentTime}
max={duration}
onChange={(v) => {
isSeekingRef.current = true;
setCurrentTime(v);
}}
onChangeEnd={(v) => {
// Validasi: jangan seek melebihi durasi
const seekTime = Math.min(Math.max(0, v), duration);
if (audioRef.current) {
// Set audio currentTime
audioRef.current.currentTime = seekTime;
setCurrentTime(seekTime);
lastSeekTimeRef.current = seekTime;
// Jika audio tidak sedang playing, mainkan
if (!audioRef.current.paused && !isPlaying) {
audioRef.current.play().catch(console.error);
}
}
// Set seeking false SETELAH semua operasi selesai
setTimeout(() => {
isSeekingRef.current = false;
}, 0);
}}
color="#0B4F78"
size="sm"
style={{ flex: 1 }}
styles={{ thumb: { borderWidth: 2 } }}
/>
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration)}</Text>
</Group>
</Stack>
</Group>
</Card>
) : (
<Card radius="md" p="xl" shadow="md">
<Text ta="center" c="dimmed">Pilih lagu untuk diputar</Text>
</Card>
)}
</div>
<div>
<Text size="xl" fw={700} c="#0B4F78" mb="md">Daftar Putar</Text>
<Grid gutter="md">
{songs.map(song => (
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}>
<Card
radius="md"
p="md"
shadow="sm"
style={{
cursor: 'pointer',
border: currentSong.id === song.id ? '2px solid #0B4F78' : '2px solid transparent',
transition: 'all 0.2s'
}}
onClick={() => playSong(song)}
>
<Group gap="md" align="center">
<Avatar src={song.cover} size={64} radius="md" />
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} c="#0B4F78" truncate>{song.title}</Text>
<Text size="xs" c="#5A6C7D">{song.artist}</Text>
<Text size="xs" c="#8A9BA8">{song.duration}</Text>
</Stack>
{currentSong.id === song.id && isPlaying && (
<Badge color="#0B4F78" variant="filled">Memutar</Badge>
)}
</Group>
</Card>
</Grid.Col>
))}
</Grid>
{filteredMusik.length === 0 ? (
<Text ta="center" c="dimmed">Tidak ada musik yang ditemukan</Text>
) : (
<ScrollArea.Autosize mah={400}>
<Grid gutter="md">
{filteredMusik.map((song, index) => (
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}>
<Card
radius="md"
p="md"
shadow="sm"
style={{
cursor: 'pointer',
border: currentSong?.id === song.id ? '2px solid #0B4F78' : '2px solid transparent',
transition: 'all 0.2s'
}}
onClick={() => playSong(index)}
>
<Group gap="md" align="center">
<Avatar
src={song.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
size={64}
radius="md"
/>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} c="#0B4F78" truncate>{song.judul}</Text>
<Text size="xs" c="#5A6C7D">{song.artis}</Text>
<Text size="xs" c="#8A9BA8">{song.durasi}</Text>
</Stack>
{currentSong?.id === song.id && isPlaying && (
<Badge color="#0B4F78" variant="filled">Memutar</Badge>
)}
</Group>
</Card>
</Grid.Col>
))}
</Grid>
</ScrollArea.Autosize>
)}
</div>
</Stack>
@@ -168,10 +399,20 @@ const MusicPlayer = () => {
>
<Flex align="center" justify="space-between" gap="xl" h="100%">
<Group gap="md" style={{ flex: 1 }}>
<Avatar src={currentSong.cover} size={56} radius="md" />
<Avatar
src={currentSong?.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
size={56}
radius="md"
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} c="#0B4F78" truncate>{currentSong.title}</Text>
<Text size="xs" c="#5A6C7D">{currentSong.artist}</Text>
{currentSong ? (
<>
<Text size="sm" fw={600} c="#0B4F78" truncate>{currentSong.judul}</Text>
<Text size="xs" c="#5A6C7D">{currentSong.artis}</Text>
</>
) : (
<Text size="sm" c="dimmed">Tidak ada lagu</Text>
)}
</div>
</Group>
@@ -180,12 +421,12 @@ const MusicPlayer = () => {
<ActionIcon
variant={isShuffle ? 'filled' : 'subtle'}
color="#0B4F78"
onClick={() => setIsShuffle(!isShuffle)}
onClick={toggleShuffleHandler}
radius="xl"
>
{isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />}
</ActionIcon>
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl">
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl" onClick={skipBack}>
<IconPlayerSkipBackFilled size={20} />
</ActionIcon>
<ActionIcon
@@ -193,11 +434,11 @@ const MusicPlayer = () => {
color="#0B4F78"
size={56}
radius="xl"
onClick={() => setIsPlaying(!isPlaying)}
onClick={togglePlayPauseHandler}
>
{isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />}
</ActionIcon>
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl">
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl" onClick={skipForward}>
<IconPlayerSkipForwardFilled size={20} />
</ActionIcon>
<ActionIcon
@@ -214,7 +455,31 @@ const MusicPlayer = () => {
<Slider
value={currentTime}
max={duration}
onChange={setCurrentTime}
onChange={(v) => {
isSeekingRef.current = true;
setCurrentTime(v); // preview - update UI saja
}}
onChangeEnd={(v) => {
// Validasi: jangan seek melebihi durasi
const seekTime = Math.min(Math.max(0, v), duration);
if (audioRef.current) {
// Set audio currentTime
audioRef.current.currentTime = seekTime;
setCurrentTime(seekTime);
lastSeekTimeRef.current = seekTime;
// Jika audio tidak sedang playing, mainkan
if (!audioRef.current.paused && !isPlaying) {
audioRef.current.play().catch(console.error);
}
}
// Set seeking false SETELAH semua operasi selesai
setTimeout(() => {
isSeekingRef.current = false;
}, 0);
}}
color="#0B4F78"
size="xs"
style={{ flex: 1 }}
@@ -229,10 +494,7 @@ const MusicPlayer = () => {
</ActionIcon>
<Slider
value={isMuted ? 0 : volume}
onChange={(val) => {
setVolume(val);
if (val > 0) setIsMuted(false);
}}
onChange={handleVolumeChange}
color="#0B4F78"
size="xs"
w={100}
@@ -245,4 +507,86 @@ const MusicPlayer = () => {
);
};
export default MusicPlayer;
export default MusicPlayer;
// 'use client'
// import {
// Box, Paper, Group, Stack, Text, Slider, ActionIcon
// } from '@mantine/core';
// import {
// IconPlayerPlayFilled,
// IconPlayerPauseFilled
// } from '@tabler/icons-react';
// import { useEffect, useState } from 'react';
// import { useAudioEngine } from '../lib/useAudioProgress';
// interface Musik {
// id: string;
// judul: string;
// artis: string;
// audioFile: { link: string };
// }
// export default function MusicPlayer() {
// const {
// audioRef,
// isPlaying,
// currentTime,
// duration,
// load,
// toggle,
// seek,
// } = useAudioEngine();
// const [songs, setSongs] = useState<Musik[]>([]);
// const [index, setIndex] = useState(0);
// useEffect(() => {
// fetch('/api/desa/musik/find-many?page=1&limit=50')
// .then(r => r.json())
// .then(r => setSongs(r.data ?? []));
// }, []);
// useEffect(() => {
// if (!songs[index]) return;
// load(songs[index].audioFile.link);
// }, [songs, index, load]);
// const format = (n: number) => {
// const m = Math.floor(n / 60);
// const s = Math.floor(n % 60);
// return `${m}:${s.toString().padStart(2, '0')}`;
// };
// return (
// <Box p="xl">
// <audio ref={audioRef} />
// <Paper p="lg">
// <Stack>
// <Text fw={700}>{songs[index]?.judul}</Text>
// <Text size="sm">{songs[index]?.artis}</Text>
// <Group>
// <Text size="xs">{format(currentTime)}</Text>
// <Slider
// value={currentTime}
// max={duration}
// onChange={seek}
// style={{ flex: 1 }}
// />
// <Text size="xs">{format(duration)}</Text>
// </Group>
// <ActionIcon size={56} radius="xl" onClick={toggle}>
// {isPlaying
// ? <IconPlayerPauseFilled />
// : <IconPlayerPlayFilled />}
// </ActionIcon>
// </Stack>
// </Paper>
// </Box>
// );
// }

View File

@@ -2,12 +2,9 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
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 {
ActionIcon,
BackgroundImage,
Box,
Button,
Center,
@@ -23,6 +20,9 @@ import { IconDownload } from '@tabler/icons-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useProxy } from 'valtio/utils'
import PaguTable from './lib/paguTable'
import RealisasiTable from './lib/realisasiTable'
import GrafikRealisasi from './lib/grafikRealisasi'
function Apbdes() {
const state = useProxy(apbdes)
@@ -51,18 +51,17 @@ function Apbdes() {
const dataAPBDes = state.findMany.data || []
const years = Array.from(
new Set(
dataAPBDes
.map((item: any) => item?.tahun)
.filter((tahun): tahun is number => typeof tahun === 'number')
new Set(
dataAPBDes
.map((item: any) => item?.tahun)
.filter((tahun): tahun is number => typeof tahun === 'number')
)
)
)
.sort((a, b) => b - a)
.map(year => ({
value: year.toString(),
label: `Tahun ${year}`,
}))
.sort((a, b) => b - a)
.map(year => ({
value: year.toString(),
label: `Tahun ${year}`,
}))
useEffect(() => {
if (years.length > 0 && !selectedYear) {
@@ -71,7 +70,7 @@ function Apbdes() {
}, [years, selectedYear])
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
const data = (state.findMany.data || []).slice(0, 3)
@@ -131,18 +130,24 @@ function Apbdes() {
/>
</Box>
{/* Progress */}
{currentApbdes ? (
<APBDesProgress apbdesData={currentApbdes} />
) : (
{/* Tabel & Grafik - Hanya tampilkan jika ada data */}
{currentApbdes && currentApbdes.items?.length > 0 ? (
<Box px={{ base: 'md', md: 100 }}>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<PaguTable apbdesData={currentApbdes} />
<RealisasiTable apbdesData={currentApbdes} />
<GrafikRealisasi apbdesData={currentApbdes} />
</SimpleGrid>
</Box>
) : currentApbdes ? (
<Box px={{ base: 'md', md: 100 }} py="md">
<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>
</Box>
)}
) : null}
{/* GRID */}
{/* GRID - Card Preview */}
{loading ? (
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
<Loader size="lg" color="blue" />
@@ -165,14 +170,18 @@ function Apbdes() {
spacing="lg"
pb="xl"
>
{data.map((v, k) => (
<BackgroundImage
{data.map((v: any, k: number) => (
<Box
key={k}
src={v.image?.link || ''}
h={360}
radius="xl"
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 }} />
@@ -185,7 +194,7 @@ function Apbdes() {
lh={1.35}
lineClamp={2}
>
{v.name}
{v.name || `APBDes Tahun ${v.tahun}`}
</Text>
<Text
@@ -196,7 +205,7 @@ function Apbdes() {
lh={1}
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
>
{v.jumlah}
{v.jumlah || '-'}
</Text>
<Center>
@@ -212,7 +221,7 @@ function Apbdes() {
</ActionIcon>
</Center>
</Stack>
</BackgroundImage>
</Box>
))}
</SimpleGrid>
)}
@@ -220,4 +229,4 @@ function Apbdes() {
)
}
export default Apbdes
export default Apbdes

View File

@@ -0,0 +1,134 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Title, Progress, Stack, Text, Group, Box } from '@mantine/core';
function Summary({ title, data }: any) {
if (!data || data.length === 0) return null;
const totalAnggaran = data.reduce((s: number, i: any) => s + i.anggaran, 0);
const totalRealisasi = data.reduce((s: number, i: any) => s + i.realisasi, 0);
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 }: any) {
const items = apbdesData.items || [];
const tahun = apbdesData.tahun || new Date().getFullYear();
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');
// Hitung total keseluruhan
const totalAnggaranSemua = items.reduce((s: number, i: any) => s + i.anggaran, 0);
const totalRealisasiSemua = items.reduce((s: number, i: any) => s + i.realisasi, 0);
const persenSemua = totalAnggaranSemua > 0 ? (totalRealisasiSemua / totalAnggaranSemua) * 100 : 0;
const formatRupiah = (angka: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(angka);
};
return (
<Paper withBorder p="md" radius="md">
<Title order={5} mb="md">
GRAFIK REALISASI APBDes {tahun}
</Title>
<Stack gap="lg">
<Summary title="💰 Pendapatan" data={pendapatan} />
<Summary title="💸 Belanja" data={belanja} />
<Summary title="📊 Pembiayaan" data={pembiayaan} />
</Stack>
{/* Summary Total Keseluruhan */}
<Box mb="lg" p="md" bg="gray.0">
<Group justify="space-between" mb="xs">
<Text fw={700} fz="lg">TOTAL KESELURUHAN</Text>
<Text fw={700} fz="xl" c={persenSemua >= 100 ? 'teal' : persenSemua >= 80 ? 'blue' : 'red'}>
{persenSemua.toFixed(2)}%
</Text>
</Group>
<Text fz="sm" c="dimmed" mb="xs">
{formatRupiah(totalRealisasiSemua)} / {formatRupiah(totalAnggaranSemua)}
</Text>
<Progress
value={persenSemua}
size="lg"
radius="xl"
color={persenSemua >= 100 ? 'teal' : persenSemua >= 80 ? 'blue' : 'red'}
/>
</Box>
</Paper>
);
}

View File

@@ -0,0 +1,60 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Table, Title } from '@mantine/core';
function Section({ title, data }: any) {
if (!data || data.length === 0) return null;
return (
<>
<Table.Tr>
<Table.Td colSpan={2}>
<strong>{title}</strong>
</Table.Td>
</Table.Tr>
{data.map((item: any) => (
<Table.Tr key={item.id}>
<Table.Td>
{item.kode} - {item.uraian}
</Table.Td>
<Table.Td ta="right">
Rp {item.anggaran.toLocaleString('id-ID')}
</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="md" radius="md">
<Title order={5} mb="md">{title}</Title>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Uraian</Table.Th>
<Table.Th ta="right">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>
</Paper>
);
}

View File

@@ -0,0 +1,74 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Table, Title, Badge } from '@mantine/core';
function Section({ title, data }: any) {
if (!data || data.length === 0) return null;
return (
<>
<Table.Tr>
<Table.Td colSpan={3}>
<strong>{title}</strong>
</Table.Td>
</Table.Tr>
{data.map((item: any) => (
<Table.Tr key={item.id}>
<Table.Td>
{item.kode} - {item.uraian}
</Table.Td>
<Table.Td ta="right">
Rp {item.totalRealisasi.toLocaleString('id-ID')}
</Table.Td>
<Table.Td ta="center">
<Badge
color={
item.persentase >= 100
? 'teal'
: item.persentase >= 60
? 'yellow'
: 'red'
}
>
{item.persentase.toFixed(2)}%
</Badge>
</Table.Td>
</Table.Tr>
))}
</>
);
}
export default function RealisasiTable({ apbdesData }: any) {
const items = apbdesData.items || [];
const title =
apbdesData.tahun
? `REALISASI APBDes Tahun ${apbdesData.tahun}`
: 'REALISASI APBDes';
const pendapatan = items.filter((i: any) => i.tipe === 'pendapatan');
const belanja = items.filter((i: any) => i.tipe === 'belanja');
const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan');
return (
<Paper withBorder p="md" radius="md">
<Title order={5} mb="md">{title}</Title>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Uraian</Table.Th>
<Table.Th ta="right">Realisasi (Rp)</Table.Th>
<Table.Th ta="center">%</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Section title="1) PENDAPATAN" data={pendapatan} />
<Section title="2) BELANJA" data={belanja} />
<Section title="3) PEMBIAYAAN" data={pembiayaan} />
</Table.Tbody>
</Table>
</Paper>
);
}

View File

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

View File

@@ -12,6 +12,9 @@ import { Metadata, Viewport } from "next";
import { ViewTransitions } from "next-view-transitions";
import { ToastContainer } from "react-toastify";
// Force dynamic rendering untuk menghindari error prerendering
export const dynamic = 'force-dynamic';
// ✅ Pisahkan viewport ke export tersendiri
export const viewport: Viewport = {
width: "device-width",

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
/**
* Authentication helper untuk API endpoints
*
*
* Usage:
* import { requireAuth } from "@/lib/api-auth";
*
* export default async function myEndpoint(context: Context) {
* const authResult = await requireAuth(context);
*
* export default async function myEndpoint() {
* const authResult = await requireAuth();
* if (!authResult.authenticated) {
* return authResult.response;
* }
@@ -13,24 +13,24 @@
* }
*/
import { getSession } from "@/lib/session";
import { getSession, SessionData } from "@/lib/session";
export type AuthResult =
| { authenticated: true; user: any }
export type AuthResult =
| { authenticated: true; user: NonNullable<SessionData["user"]> }
| { authenticated: false; response: Response };
export async function requireAuth(context: any): Promise<AuthResult> {
export async function requireAuth(): Promise<AuthResult> {
try {
// Cek session dari cookies
const session = await getSession();
if (!session || !session.user) {
return {
authenticated: false,
response: new Response(JSON.stringify({
success: false,
message: "Unauthorized - Silakan login terlebih dahulu"
}), {
}), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
@@ -44,7 +44,7 @@ export async function requireAuth(context: any): Promise<AuthResult> {
response: new Response(JSON.stringify({
success: false,
message: "Akun Anda tidak aktif. Hubungi administrator."
}), {
}), {
status: 403,
headers: { 'Content-Type': 'application/json' }
})
@@ -55,14 +55,13 @@ export async function requireAuth(context: any): Promise<AuthResult> {
authenticated: true,
user: session.user
};
} catch (error) {
console.error("Auth error:", error);
} catch {
return {
authenticated: false,
response: new Response(JSON.stringify({
success: false,
message: "Authentication error"
}), {
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
@@ -74,11 +73,11 @@ export async function requireAuth(context: any): Promise<AuthResult> {
* Optional auth - tidak error jika tidak authenticated
* 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 {
const session = await getSession();
return session?.user || null;
} catch (error) {
} catch {
return null;
}
}

View File

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