Compare commits

...

29 Commits

Author SHA1 Message Date
a791efe76c fix(grafik): sync grafikRealisasi with totalRealisasi field
Fix:
- Change i.realisasi to i.totalRealisasi in Summary component
- Change i.realisasi to i.totalRealisasi in total calculation
- Add fragment wrapper <> to fix Box children type error
- Reorder Total Keseluruhan section to top (before category breakdown)

Now grafik shows correct data from multiple realisasi items.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 16:47:26 +08:00
e9f7bc2043 feat(ui landing): add expandable realisasi detail in RealisasiTable
Features:
- Add expandable rows for each APBDes item
- Show detailed realisasi breakdown per item
- Each realisasi shows:
  * Keterangan/Uraian
  * Jumlah (formatted in Rupiah)
  * Tanggal (formatted date)
- Chevron icon indicator (right/down)
- Click row to expand/collapse
- Hover effect on clickable rows
- Info text: "Klik pada item untuk melihat detail realisasi"

UI Components:
- RealisasiDetail: Component to display list of realisasi
- ItemRow: Expandable row with click handler
- Updated Section: Manage expanded state per item

Styling:
- Gray background for detail section
- Blue color for amount
- Dimmed color for date
- Responsive layout with wrap="nowrap"
- Proper spacing between items

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 16:38:15 +08:00
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
70 changed files with 5220 additions and 629 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

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

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

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

@@ -17,7 +17,6 @@ export default async function kategoriBeritaDelete(context: Context) {
where: {
kategoriBeritaId: id,
isActive: true,
deletedAt: null,
},
});

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

@@ -23,10 +23,9 @@ export default async function findUnique(
// ✅ Filter by isActive and deletedAt
const data = await prisma.potensiDesa.findFirst({
where: {
where: {
id,
isActive: true,
deletedAt: null,
},
include: {
image: true,

View File

@@ -17,7 +17,6 @@ export default async function kategoriPotensiDelete(context: Context) {
where: {
kategoriId: id,
isActive: true,
deletedAt: null,
},
});

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,136 @@
/* 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.totalRealisasi, 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.totalRealisasi, 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>
{/* Summary Total Keseluruhan */}
<Box mb="lg" p="md" bg="gray.0" radius="md">
<>
<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>
<Stack gap="lg">
<Summary title="💰 Pendapatan" data={pendapatan} />
<Summary title="💸 Belanja" data={belanja} />
<Summary title="📊 Pembiayaan" data={pembiayaan} />
</Stack>
</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,181 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Table, Title, Badge, Group, Text, Box, Collapse } from '@mantine/core';
import { useState } from 'react';
import { IconChevronDown, IconChevronRight } from '@tabler/icons-react';
function RealisasiDetail({ realisasiItems }: { realisasiItems: any[] }) {
if (!realisasiItems || realisasiItems.length === 0) {
return (
<Text fz="xs" c="dimmed" py="xs">
Belum ada realisasi
</Text>
);
}
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', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
};
return (
<Box mt="xs" p="xs" bg="gray.0" radius="md">
<Text fz="xs" fw={600} mb="xs">
Rincian Realisasi ({realisasiItems.length}):
</Text>
<>
{realisasiItems.map((realisasi: any, idx: number) => (
<Box key={realisasi.id} mb={idx < realisasiItems.length - 1 ? 'xs' : 0}>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text fz="xs" style={{ flex: 1 }}>
{realisasi.keterangan || `Realisasi ${idx + 1}`}
</Text>
<Text fz="xs" fw={600} c="blue">
{formatRupiah(realisasi.jumlah)}
</Text>
<Text fz="xs" c="dimmed">
{formatDate(realisasi.tanggal)}
</Text>
</Group>
</Box>
))}
</>
</Box>
);
}
function ItemRow({ item, expanded, onToggle }: any) {
const formatRupiah = (amount: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
const hasRealisasi = item.realisasiItems && item.realisasiItems.length > 0;
return (
<>
<Table.Tr
onClick={onToggle}
style={{ cursor: 'pointer', '&:hover': { backgroundColor: 'var(--mantine-color-gray-0)' } }}
>
<Table.Td>
<Group gap="xs">
{hasRealisasi ? (
expanded ? (
<IconChevronDown size={16} />
) : (
<IconChevronRight size={16} />
)
) : (
<Box w={16} />
)}
<Text fw={500}>{item.kode} - {item.uraian}</Text>
</Group>
</Table.Td>
<Table.Td ta="right">
<Text fw={600} c="blue">
{formatRupiah(item.totalRealisasi)}
</Text>
</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>
<Table.Tr>
<Table.Td colSpan={3} p={0}>
<Collapse in={expanded}>
{hasRealisasi && <RealisasiDetail realisasiItems={item.realisasiItems} />}
</Collapse>
</Table.Td>
</Table.Tr>
</>
);
}
function Section({ title, data }: any) {
if (!data || data.length === 0) return null;
const [expandedId, setExpandedId] = useState<string | null>(null);
return (
<>
<Table.Tr>
<Table.Td colSpan={3}>
<Text fw={700} fz="sm">{title}</Text>
</Table.Td>
</Table.Tr>
{data.map((item: any) => (
<ItemRow
key={item.id}
item={item}
expanded={expandedId === item.id}
onToggle={() => setExpandedId(expandedId === item.id ? null : item.id)}
/>
))}
</>
);
}
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>
<Text fz="xs" c="dimmed" mb="sm">
💡 Klik pada item untuk melihat detail realisasi
</Text>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Uraian</Table.Th>
<Table.Th ta="right">Total 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)
},
};
};