Compare commits

..

7 Commits

Author SHA1 Message Date
b1d28a8322 fix-admin-menu-desa-profile 2026-02-25 15:25:51 +08:00
b86a3a85c3 fix: force default light mode for public pages and admin
- Set defaultColorScheme='light' in root MantineProvider
- Change darkModeStore default from system preference to false (light)
- Add MantineProvider with light theme to darmasaba/layout.tsx
- Remove dark mode dependency from ModuleView component
- Prevent system color scheme from affecting initial page load

This ensures consistent light mode on first visit for both
public pages and admin panel, regardless of OS settings.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-25 10:45:27 +08:00
fd63bb0fd4 feat: implement dark mode support & fix Prisma schema validation
- Add dark mode toggle component in admin header
- Integrate dark mode store across admin layout and components
- Add unified typography and surface components for consistent theming
- Implement smooth transitions for dark/light mode switching
- Fix Prisma schema: remove @default(null) from DateTime? fields
- Update form validation for inovasi, lingkungan, and pendidikan modules
- Add form validation and improve UX across multiple admin pages

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-25 10:41:48 +08:00
f2c9a922a6 fix(profil-module): QC improvements based on QC-PROFIL-MODULE.md
- Fix fetch method inconsistency (convert to ApiFetch)
  - programInovasi: findUnique, delete, update methods
  - mediaSosial: findUnique, delete, update methods
- Add loading state to findUnique operations
- Fix iconUrl validation (make optional instead of required)
- Add DOMPurify for HTML sanitization (XSS protection)
  - program-inovasi page.tsx (list & detail)
- Remove console.log in production (use dev-only logging)
- Install dompurify and @types/dompurify

Security: Prevent XSS attacks by sanitizing HTML content
Consistency: Use ApiFetch for all API operations
UX: Proper loading states for better user feedback

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-23 15:11:00 +08:00
92b24440fe fix: Quality Control improvements & bug fixes
- APBDes: Fix edit form original data tracking (imageId, fileId)
- APBDes: Update formula consistency in state
- PPID modules: Various UI improvements and bug fixes
- PPID Profil: Preview and edit page improvements
- PPID Dasar Hukum: Page structure improvements
- PPID Visi Misi: Page structure improvements
- PPID Struktur: Posisi organisasi page improvements
- PPID Daftar Informasi: Edit page improvements
- Auth login: Route improvements
- Update dependencies (package.json, bun.lockb)
- Update seed data
- Update .gitignore

QC Reports added:
- QC-APBDES-MODULE.md
- QC-PROFIL-MODULE.md
- QC-SDGS-DESA.md
- QC-DESA-ANTI-KORUPSI.md
- QC-PRESTASI-DESA-MODULE.md
- QC-PPID-PROFIL-MODULE.md
- QC-STRUKTUR-PPID-MODULE.md
- QC-VISI-MISI-PPID-MODULE.md
- QC-DASAR-HUKUM-PPID-MODULE.md
- QC-PERMOHONAN-INFORMASI-PUBLIK-MODULE.md
- QC-PERMOHONAN-KEBERATAN-INFORMASI-MODULE.md
- QC-DAFTAR-INFORMASI-PUBLIK-MODULE.md
- QC-IKM-MODULE.md

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-23 14:38:28 +08:00
f0558aa0d0 feat: implement dark mode support for admin layout and components
- Add dark mode toggle component in admin header
- Integrate dark mode store across admin layout and child components
- Update header, judulList, and judulListTab components with theme tokens
- Add unified typography components for consistent theming
- Implement smooth transitions for dark/light mode switching
- Add mounted state to prevent hydration mismatches
- Style navbar with dark mode aware colors and hover states
- Update button styles with gradient effects for both themes

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-23 10:48:00 +08:00
8132609ccb feat: add form validation for inovasi, lingkungan, and pendidikan modules
- Added isFormValid() and isHtmlEmpty() helper functions for form validation
- Disabled submit buttons when required fields are empty across multiple admin and public pages
- Applied consistent validation pattern for creating and editing records
- Commented out WhatsApp OTP sending in login route for debugging/testing
- Fixed path in NavbarMainMenu tooltip action
2026-02-20 15:08:41 +08:00
111 changed files with 3680 additions and 10631 deletions

3
.gitignore vendored
View File

@@ -31,6 +31,9 @@ yarn-error.log*
# env
.env*
# QC
QC
# vercel
.vercel

View File

@@ -1,763 +0,0 @@
# QC Summary - APBDes Module
**Scope:** List APBDes, Create, Edit, Detail
**Date:** 2026-02-23
**Status:** ✅ Secara umum sudah baik, ada beberapa critical issues yang perlu diperbaiki
---
## 📊 OVERVIEW
| Aspect | Schema | API | UI Admin | State Management | Overall |
|--------|--------|-----|----------|-----------------|---------|
| APBDes | ✅ Baik | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 Perlu fix |
---
## ✅ YANG SUDAH BAIK
### **1. UI/UX Consistency**
- ✅ Responsive design (desktop table + mobile cards)
- ✅ Loading states dengan Skeleton
- ✅ Search dengan debounce (1000ms)
- ✅ Pagination konsisten
- ✅ Empty state handling yang informatif
- ✅ Modal konfirmasi hapus
### **2. File Upload Handling**
- ✅ Dual upload: Gambar + Dokumen
- ✅ Dropzone dengan preview (image + iframe untuk dokumen)
- ✅ Validasi format (gambar: JPEG/PNG/WEBP, dokumen: PDF/DOC/DOCX)
- ✅ Validasi ukuran file (max 5MB untuk gambar, 10MB untuk dokumen di edit)
- ✅ Tombol hapus preview (IconX di pojok kanan atas)
- ✅ URL.createObjectURL untuk preview lokal
### **3. Form Validation**
- ✅ Zod schema untuk validasi typed
- ✅ isFormValid() check sebelum submit
- ✅ Error toast dengan pesan spesifik
- ✅ Button disabled saat invalid/loading
- ✅ Type number input untuk tahun
### **4. Complex Feature - APBDes Items**
- ✅ Hierarchical items dengan level (1, 2, 3)
- ✅ Tipe classification (pendapatan, belanja, pembiayaan)
- ✅ Auto-calculation: selisih & persentase
- ✅ Add/remove items dynamic
- ✅ Table preview dengan badge color coding
- ✅ Indentasi visual berdasarkan level
### **5. Edit Form - Original Data Tracking**
- ✅ Original data state untuk reset form
- ✅ Load data existing dengan benar
- ✅ Preview image & dokumen dari data lama
- ✅ Reset form mengembalikan ke data original
- ✅ File replacement logic (upload baru jika ada perubahan)
**Code Example (✅ GOOD):**
```typescript
// Line ~95-130 - Load data & save original
const data = await apbdesState.edit.load(id);
setOriginalData({
tahun: data.tahun || new Date().getFullYear(),
imageId: data.imageId || '',
fileId: data.fileId || '',
imageUrl: data.image?.link || '',
fileUrl: data.file?.link || '',
});
// Set form dengan data lama (termasuk imageId dan fileId)
apbdesState.edit.form = {
tahun: data.tahun || new Date().getFullYear(),
imageId: data.imageId || '', // ✅ Preserve old ID
fileId: data.fileId || '', // ✅ Preserve old ID
items: (data.items || []).map(...),
};
// Line ~270 - Handle reset
const handleReset = () => {
apbdesState.edit.form = {
tahun: originalData.tahun,
imageId: originalData.imageId, // ✅ Restore old ID
fileId: originalData.fileId, // ✅ Restore old ID
items: [...apbdesState.edit.form.items],
};
setPreviewImage(originalData.imageUrl || null);
setPreviewDoc(originalData.fileUrl || null);
setImageFile(null);
setDocFile(null);
toast.info('Form dikembalikan ke data awal');
};
```
**Verdict:****SUDAH BENAR** - Original data tracking sudah implementasi dengan baik.
---
### **6. Schema Design**
- ✅ Proper relations: APBDes ↔ FileStorage (image & file)
- ✅ Self-relation untuk hierarchical items (parentId → children)
- ✅ Indexing untuk performa (kode, level, apbdesId)
- ✅ Soft delete support (deletedAt, isActive)
- ✅ Nullable deletedAt yang benar (`DateTime? @default(null)`)
**Schema Example (✅ GOOD):**
```prisma
model APBDes {
id String @id @default(cuid())
tahun Int?
name String?
deskripsi String?
jumlah String?
items APBDesItem[]
image FileStorage? @relation("APBDesImage", fields: [imageId], references: [id])
imageId String?
file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id])
fileId String?
deletedAt DateTime? // ✅ Nullable, no default
isActive Boolean @default(true)
}
model APBDesItem {
id String @id @default(cuid())
kode String
uraian String
anggaran Float
realisasi Float
selisih Float // ✅ Formula di komentar
persentase Float
tipe String? // ✅ Nullable untuk level 1
level Int
parentId String?
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
children APBDesItem[] @relation("APBDesItemParent")
apbdesId String
apbdes APBDes @relation(fields: [apbdesId], references: [id])
@@index([kode])
@@index([level])
@@index([apbdesId])
}
```
**Verdict:****SUDAH BENAR** - Schema design sudah solid.
---
## ⚠️ ISSUES & SARAN PERBAIKAN
### **🔴 CRITICAL**
#### **1. Formula Selisih - SALAH di State, BENAR di Schema/API**
**Lokasi:**
- `src/app/admin/(dashboard)/_state/landing-page/apbdes.ts` (line 36)
- Schema komentar di `prisma/schema.prisma` (line 210)
**Masalah:**
```typescript
// ❌ SALAH di state (line 36)
function normalizeItem(item: Partial<...>): z.infer<typeof ApbdesItemSchema> {
const anggaran = item.anggaran ?? 0;
const realisasi = item.realisasi ?? 0;
// ❌ WRONG FORMULA
const selisih = anggaran - realisasi; // positif = sisa anggaran
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
return { ... };
}
```
```prisma
// ✅ BENAR di schema komentar (line 210)
model APBDesItem {
// ...
realisasi Float
selisih Float // ✅ realisasi - anggaran (komentar benar)
// ...
}
```
**Dampak:**
- **Data salah!** Selisih positif/negatif terbalik
- Jika realisasi > anggaran (over budget), seharusnya **negatif** tapi jadi **positif**
- Jika realisasi < anggaran (under budget/sisa), seharusnya **positif** tapi jadi **negatif**
- Color coding di UI (green/red) juga terbalik!
**Contoh:**
```
Anggaran: Rp 100.000.000
Realisasi: Rp 120.000.000 (over budget!)
❌ Formula sekarang: selisih = 100M - 120M = -20M (negatif)
UI show: merah (over budget) ✅ TAPI karena negatif
✅ Seharusnya: selisih = 120M - 100M = +20M (positif)
UI show: merah (over budget) ✅ Karena positif
```
**Rekomendasi:** Fix formula di state:
```typescript
// ✅ CORRECT FORMULA
const selisih = realisasi - anggaran; // positif = over budget, negatif = under budget
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
```
**Priority:** 🔴 **CRITICAL**
**Effort:** Low (1 line fix)
**Impact:** **HIGH** (data integrity issue)
---
#### **2. State Management - Inconsistency Fetch Pattern**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/apbdes.ts`
**Masalah:** Ada 3 pattern berbeda untuk fetch API:
```typescript
// ❌ Pattern 1: ApiFetch (create, findMany, delete, edit.load, edit.update)
const res = await ApiFetch.api.landingpage.apbdes["create"].post(parsed.data);
const res = await ApiFetch.api.landingpage.apbdes["findMany"].get({ query });
const res = await (ApiFetch.api.landingpage.apbdes as any)["del"][id].delete();
const res = await (ApiFetch.api.landingpage.apbdes as any)[id].get();
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
// ❌ Pattern 2: fetch manual (findUnique)
const response = await fetch(`/api/landingpage/apbdes/${id}`);
const res = await response.json();
```
**Dampak:**
- Code consistency buruk
- Sulit maintenance
- Type safety tidak konsisten
- Duplikasi logic error handling
- Console.log debugging tertinggal di production
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
```typescript
// ✅ Unified pattern
async load(id: string) {
try {
this.loading = true;
const res = await ApiFetch.api.landingpage.apbdes[id].get();
if (res.data?.success) {
this.data = res.data.data;
} else {
this.data = null;
this.error = res.data?.message || "Gagal memuat detail APBDes";
toast.error(this.error);
}
} catch (error) {
console.error("FindUnique error:", error);
this.data = null;
this.error = "Gagal memuat detail APBDes";
toast.error(this.error);
} finally {
this.loading = false;
}
}
```
**Priority:** 🔴 High
**Effort:** Medium (refactor di findUnique)
---
#### **3. Console.log Debugging di Production**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/apbdes.ts`
**Masalah:**
```typescript
// Line ~175-177
const url = `/api/landingpage/apbdes/${id}`;
console.log("🌐 Fetching:", url); // ❌ Debug log
const response = await fetch(url);
const res = await response.json();
console.log("📦 Response:", res); // ❌ Debug log
```
**Dampak:**
- Performance impact (I/O operation)
- Security risk (expose API structure)
- Log pollution di production
- Unprofessional
**Rekomendasi:** Remove atau gunakan conditional logging:
```typescript
// ✅ Remove completely (recommended)
// Atau gunakan conditional logging
if (process.env.NODE_ENV === 'development') {
console.log("🌐 Fetching:", url);
console.log("📦 Response:", res);
}
```
**Priority:** 🔴 Medium
**Effort:** Low
---
### **🟡 MEDIUM**
#### **4. Type Safety - Any Usage di Edit Methods**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/apbdes.ts`
**Masalah:**
```typescript
// Line ~215
const res = await (ApiFetch.api.landingpage.apbdes as any)[id].get();
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Line ~245
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
// ^^^^^^^^^^^^^^^^^^^^^^^^^^
```
**Dampak:**
- Type safety hilang
- Autocomplete tidak bekerja
- Runtime errors tidak terdeteksi di compile time
- Refactoring sulit
**Rekomendasi:** Define typed API client:
```typescript
// Define proper types
interface APBDesAPI {
[id: string]: {
get: () => Promise<ApiResponse<APBDesData>>;
put: (data: APBDesForm) => Promise<ApiResponse<APBDesData>>;
};
del: {
[id: string]: {
delete: () => Promise<ApiResponse<void>>;
};
};
}
// Use typed client
const res = await ApiFetch.api.landingpage.apbdes[id].get();
// No more `as any`
```
**Priority:** 🟡 Medium
**Effort:** Medium (perlu setup types)
---
#### **5. Edit Form - Items Tidak Di-Restore Saat Reset**
**Lokasi:** `src/app/admin/(dashboard)/landing-page/apbdes/[id]/edit/page.tsx`
**Masalah:**
```typescript
// Line ~270-285
const handleReset = () => {
apbdesState.edit.form = {
tahun: originalData.tahun,
imageId: originalData.imageId,
fileId: originalData.fileId,
items: [...apbdesState.edit.form.items], // ⚠️ Keep MODIFIED items
};
// ...
};
```
**Issue:** Saat reset, items yang sudah di-modified (added/removed) tidak di-restore ke original. User expect reset = kembali ke data awal sepenuhnya.
**Rekomendasi:** Save original items dan restore saat reset:
```typescript
// Add to originalData state
const [originalData, setOriginalData] = useState({
tahun: 0,
imageId: '',
fileId: '',
imageUrl: '',
fileUrl: '',
items: [] as ItemForm[], // ✅ Save original items
});
// Load data
setOriginalData({
tahun: data.tahun || new Date().getFullYear(),
imageId: data.imageId || '',
fileId: data.fileId || '',
imageUrl: data.image?.link || '',
fileUrl: data.file?.link || '',
items: (data.items || []).map((item: any) => ({...})), // ✅ Save
});
// Reset
const handleReset = () => {
apbdesState.edit.form = {
tahun: originalData.tahun,
imageId: originalData.imageId,
fileId: originalData.fileId,
items: [...originalData.items], // ✅ Restore original items
};
// ...
};
```
**Priority:** 🟡 Medium
**Effort:** Low
---
#### **6. Zod Schema - Error Message Tidak Akurat**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/apbdes.ts`
**Masalah:**
```typescript
// Line ~10
const ApbdesItemSchema = z.object({
kode: z.string().min(1, "Kode wajib diisi"), // ✅ OK
uraian: z.string().min(1, "Uraian wajib diisi"), // ✅ OK
anggaran: z.number().min(0), // ⚠️ No custom message
realisasi: z.number().min(0), // ⚠️ No custom message
// ...
});
// Line ~17
const ApbdesFormSchema = z.object({
tahun: z.number().int().min(2000, "Tahun tidak valid"), // ⚠️ Generic
imageId: z.string().min(1, "Gambar wajib diunggah"), // ✅ OK
fileId: z.string().min(1, "File wajib diunggah"), // ✅ OK
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"), // ✅ OK
});
```
**Dampak:** Error messages tidak konsisten, beberapa generic beberapa spesifik.
**Rekomendasi:** Standardisasi error messages:
```typescript
const ApbdesItemSchema = z.object({
kode: z.string().min(1, "Kode wajib diisi"),
uraian: z.string().min(1, "Uraian wajib diisi"),
anggaran: z.number().min(0, "Anggaran tidak boleh negatif"),
realisasi: z.number().min(0, "Realisasi tidak boleh negatif"),
selisih: z.number(),
persentase: z.number(),
level: z.number().int().min(1).max(3, "Level harus antara 1-3"),
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
});
const ApbdesFormSchema = z.object({
tahun: z.number().int().min(2000, "Tahun minimal 2000").max(2100, "Tahun maksimal 2100"),
imageId: z.string().min(1, "Gambar wajib diunggah"),
fileId: z.string().min(1, "Dokumen wajib diunggah"),
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
});
```
**Priority:** 🟡 Low
**Effort:** Low
---
#### **7. Console.log di Production (UI Components)**
**Lokasi:** Multiple UI files
**Masalah:**
```typescript
// edit/page.tsx - Line ~220
console.error('Update error:', err);
// create/page.tsx - Line ~120
console.error("Gagal submit:", error);
// detail/page.tsx - Line ~40
console.error('Error loading APBDes:', error);
```
**Rekomendasi:** Gunakan conditional logging:
```typescript
if (process.env.NODE_ENV === 'development') {
console.error('Update error:', err);
}
```
**Priority:** 🟡 Low
**Effort:** Low
---
### **🟢 LOW (Minor Polish)**
#### **8. Mobile Layout - Title Order Inconsistency**
**Lokasi:** `page.tsx`
**Masalah:**
```typescript
// Line ~170 (Mobile)
<Title order={2} size="lg" lh={1.2}>
Daftar APBDes
</Title>
// Line ~70 (Desktop - inside Paper)
<Title order={4} size="lg" lh={1.2}>
Daftar APBDes
</Title>
```
**Issue:** Mobile pakai `order={2}` (heading besar), desktop `order={4}`. Seharusnya konsisten.
**Rekomendasi:** Samakan:
```typescript
<Title order={4} size="lg" lh={1.2}>
Daftar APBDes
</Title>
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **9. Search Placeholder Tidak Spesifik**
**Lokasi:** `page.tsx`
**Masalah:**
```typescript
// Line ~30
<HeaderSearch
title="APBDes"
placeholder="Cari APBDes..." // ⚠️ Generic
// ...
/>
```
**Rekomendasi:** Lebih spesifik:
```typescript
placeholder='Cari nama atau tahun APBDes...'
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **10. Duplicate Comment**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/apbdes.ts`
**Masalah:**
```typescript
// Line ~28-29
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
// ^ Duplicate line
```
**Priority:** 🟢 Low
**Effort:** Low (remove duplicate)
---
#### **11. Inconsistent Button Label**
**Lokasi:** Multiple files
**Masalah:**
```typescript
// create/page.tsx - Line ~270
<Button ...>Simpan</Button>
// edit/page.tsx - Line ~340
<Button ...>Simpan Perubahan</Button>
// Should be consistent: "Simpan" atau "Simpan Perubahan"
```
**Rekomendasi:** Standardisasi:
```typescript
// Create: "Simpan"
// Edit: "Simpan Perubahan" (lebih descriptive untuk edit)
// OR both: "Simpan"
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **12. Missing Search Feature in Pagination**
**Lokasi:** `page.tsx`
**Masalah:**
```typescript
// Line ~250
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10); // ⚠️ Missing search parameter
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
// ...
/>
```
**Issue:** Saat ganti page, search query hilang.
**Rekomendasi:** Include search:
```typescript
onChange={(newPage) => {
load(newPage, 10, debouncedSearch); // ✅ Include search
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **13. Edit Page - Document Max Size Inconsistency**
**Lokasi:** `edit/page.tsx`
**Masalah:**
```typescript
// Line ~230 (Image)
maxSize={5 * 1024 ** 2} // 5MB
// Line ~250 (Document)
maxSize={10 * 1024 ** 2} // 10MB
```
**Issue:** Create page maksimal 5MB untuk semua file, edit page 10MB untuk dokumen. Inconsistent.
**Rekomendasi:** Samakan (prefer 5MB untuk consistency):
```typescript
maxSize={5 * 1024 ** 2} // 5MB for both
```
**Priority:** 🟢 Low
**Effort:** Low
---
## 📋 RINGKASAN ACTION ITEMS
| Priority | Issue | Module | Impact | Effort | Status |
|----------|-------|--------|--------|--------|--------|
| 🔴 P0 | **Formula selisih SALAH** | State | **CRITICAL** | Low | **MUST FIX** |
| 🔴 P0 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
| 🔴 P1 | Console.log debugging in production | State | Medium | Low | Should fix |
| 🟡 M | Type safety (any usage) | State | Low | Medium | Optional |
| 🟡 M | Items tidak di-restore saat reset | Edit UI | Medium | Low | Should fix |
| 🟡 M | Zod schema error messages | State | Low | Low | Optional |
| 🟢 L | Console.log in UI components | UI | Low | Low | Optional |
| 🟢 L | Mobile title order inconsistency | List UI | Low | Low | Optional |
| 🟢 L | Search placeholder tidak spesifik | List UI | Low | Low | Optional |
| 🟢 L | Duplicate comment | State | Low | Low | Optional |
| 🟢 L | Inconsistent button label | UI | Low | Low | Optional |
| 🟢 L | Missing search in pagination | List UI | Low | Low | Should fix |
| 🟢 L | Document max size inconsistency | Edit UI | Low | Low | Optional |
---
## ✅ KESIMPULAN
### **Overall Quality: 🟢 BAIK (7/10)**
**Strengths:**
1. UI/UX konsisten & responsive
2. File upload handling solid (dual upload: image + document)
3. Form validation dengan Zod schema
4. State management terstruktur (Valtio)
5. **Edit form reset sudah benar** (original data tracking untuk files)
6. Complex feature: hierarchical items dengan level & tipe
7. Schema design solid (proper relations, indexing, soft delete)
8. Modal konfirmasi hapus untuk user safety
**Critical Issues:**
1. **FORMULA SELISIH SALAH** - Data integrity issue (CRITICAL)
2. Fetch method pattern inconsistency (ApiFetch vs fetch manual)
3. Console.log debugging tertinggal di production
**Areas for Improvement:**
1. **Fix formula selisih** (realisasi - anggaran, bukan anggaran - realisasi)
2. **Refactor fetch methods** untuk gunakan ApiFetch consistently
3. **Remove console.log** debugging dari production code
4. **Save & restore original items** saat reset form di edit page
5. **Improve type safety** dengan remove `as any` usage
6. **Standardisasi error messages** di Zod schema
**Recommended Next Steps:**
1. **🔴 CRITICAL: Fix formula selisih** di state (line 36) - 5 menit fix
2. **🔴 HIGH:** Refactor findUnique ke ApiFetch - 30 menit
3. **🔴 HIGH:** Remove console.log debugging - 10 menit
4. **🟡 MEDIUM:** Save & restore original items - 30 menit
5. **🟡 MEDIUM:** Improve type safety - 1-2 jam
6. **🟢 LOW:** Polish minor issues - 30 menit
---
## 📈 COMPARISON WITH OTHER MODULES
| Aspect | Profil | Desa Anti Korupsi | SDGs Desa | APBDes | Notes |
|--------|--------|-------------------|-----------|--------|-------|
| Fetch Pattern | Mixed | Mixed | Mixed | Mixed | All perlu refactor |
| Loading State | Some missing | Some missing | Missing | Good | APBDes paling baik |
| Edit Form Reset | Good | Good | Good | Good | All consistent |
| Type Safety | Some `any` | Some `any` | Some `any` | Some `any` | Same issue |
| File Upload | Images | Documents | Images | **Dual** | APBDes paling complex |
| Error Handling | Good | Good (better) | Good | Good | Consistent |
| Schema Design | Good | deletedAt issue | deletedAt issue | **Best** | APBDes paling solid |
| **Data Integrity** | Good | Good | Good | **Formula WRONG** | **APBDes CRITICAL issue** |
| Complexity | Low | Medium | Low | **High** | APBDes items hierarchy |
---
## 🎯 UNIQUE FEATURES OF APBDes MODULE
**Most Complex Module So Far:**
1. **Dual file upload** (gambar + dokumen) - unique to APBDes
2. **Hierarchical items** dengan 3 level - unique to APBDes
3. **Auto-calculation** (selisih & persentase) - unique to APBDes
4. **Type classification** (pendapatan, belanja, pembiayaan) - unique to APBDes
5. **Dynamic item management** (add/remove) - unique to APBDes
**Best Practices:**
1. Schema design paling solid (deletedAt nullable, proper indexing)
2. Edit form reset paling comprehensive (preserve files & items)
3. Validation paling thorough (Zod schema untuk items)
**Biggest Issue:**
1. **Formula selisih SALAH** - critical data integrity issue yang tidak ada di modul lain
---
**Catatan:** Secara keseluruhan, modul APBDes adalah **paling complex dan paling solid** dibanding modul lain yang sudah di-QC. Namun, ada **1 CRITICAL BUG** (formula selisih) yang harus **SEGERA DIPERBAIKI** karena menyangkut integritas data. Setelah fix critical issue, module ini production-ready dengan beberapa improvement minor yang bisa dilakukan secara incremental.
**Priority Action:**
```
🔴 FIX INI SEKARANG JUGA (5 MENIT):
File: src/app/admin/(dashboard)/_state/landing-page/apbdes.ts
Line: 36
Change: const selisih = anggaran - realisasi;
To: const selisih = realisasi - anggaran;
```

View File

@@ -1,639 +0,0 @@
# QC Summary - Desa Anti Korupsi Module
**Scope:** List Desa Anti Korupsi, Kategori Desa Anti Korupsi
**Date:** 2026-02-23
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
---
## 📊 OVERVIEW
| Module | Schema | API | UI Admin | State Management | Overall |
|--------|--------|-----|----------|-----------------|---------|
| List Desa Anti Korupsi | ✅ Baik | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 Perlu fix |
| Kategori Desa Anti Korupsi | ✅ Baik | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 Perlu fix |
---
## ✅ YANG SUDAH BAIK (COMMON)
### **1. UI/UX Consistency**
- ✅ Responsive design (desktop table + mobile cards)
- ✅ Loading states dengan Skeleton
- ✅ Search dengan debounce (1000ms)
- ✅ Pagination konsisten
- ✅ Empty state handling yang informatif
- ✅ Modal konfirmasi hapus
### **2. File Upload Handling** (Desa Anti Korupsi)
- ✅ Dropzone dengan preview iframe untuk dokumen
- ✅ Validasi format dokumen (PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX)
- ✅ Validasi ukuran file (max 5MB)
- ✅ Tombol hapus preview (IconX di pojok kanan atas)
- ✅ URL.createObjectURL untuk preview lokal
### **3. Form Validation**
- ✅ Zod schema untuk validasi typed
- ✅ isFormValid() check sebelum submit
- ✅ Error toast dengan pesan spesifik
- ✅ Button disabled saat invalid/loading
### **4. CRUD Operations**
- ✅ Create dengan upload file
- ✅ FindMany dengan pagination & search
- ✅ FindUnique untuk detail
- ✅ Delete dengan soft delete
- ✅ Update dengan file replacement
### **5. Error Handling**
- ✅ Try-catch di semua async operation
- ✅ Toast error dengan pesan user-friendly
- ✅ Console.error untuk debugging
- ✅ Response cloning untuk error handling yang lebih baik (di kategori update)
---
## ⚠️ ISSUES & SARAN PERBAIKAN
### **🔴 CRITICAL**
#### **1. Edit Form - File Lama Tidak Tersimpan Saat Reset**
**Lokasi:** `src/app/admin/(dashboard)/landing-page/desa-anti-korupsi/list-desa-anti-korupsi/[id]/edit/page.tsx`
**Masalah:**
```typescript
// Line ~70 - Load data
const data = await desaAntiKorupsiState.edit.load(id);
setFormData({
name: data.name,
deskripsi: data.deskripsi,
kategoriId: data.kategoriId,
fileId: data.fileId, // ✅ Sudah benar
});
setOriginalData({
name: data.name,
deskripsi: data.deskripsi,
kategoriId: data.kategoriId,
fileId: data.fileId,
fileUrl: data.file?.link || "", // ✅ Sudah benar
});
// Line ~130 - Handle reset
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
kategoriId: originalData.kategoriId,
fileId: originalData.fileId, // ✅ Sudah benar
});
setPreviewFile(originalData.fileUrl || null); // ✅ Sudah benar
setFile(null); // ✅ Sudah benar
};
```
**Status:****SUDAH BENAR** - Original data tracking sudah implementasi dengan baik.
**Verdict:** Tidak ada action needed.
---
#### **2. State Management - Inconsistency Fetch Pattern**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/desa-anti-korupsi.ts`
**Masalah:** Ada 2 pattern berbeda untuk fetch API:
```typescript
// ❌ Pattern 1: ApiFetch (create operations)
const res = await ApiFetch.api.landingpage.desaantikorupsi["create"].post({...});
// ❌ Pattern 2: fetch manual (findUnique, edit, delete)
const res = await fetch(`/api/landingpage/desaantikorupsi/${id}`);
const response = await fetch(`/api/landingpage/desaantikorupsi/del/${id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
});
```
**Dampak:**
- Code consistency buruk
- Sulit maintenance
- Type safety tidak konsisten
- Duplikasi logic error handling
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
```typescript
// ✅ Unified pattern
const res = await ApiFetch.api.landingpage.desaantikorupsi["create"].post(data);
const res = await ApiFetch.api.landingpage.desaantikorupsi[id].get();
const res = await ApiFetch.api.landingpage.desaantikorupsi[id].put(data);
const res = await ApiFetch.api.landingpage.desaantikorupsi["del"][id].delete();
```
**Priority:** 🔴 High
**Effort:** Medium (refactor di semua state methods)
---
#### **3. findUnique State - Tidak Ada Loading State Management**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/desa-anti-korupsi.ts`
**Masalah:**
```typescript
// Line ~97 - desaAntikorupsi.findUnique.load()
async load(id: string) {
try {
const res = await fetch(`/api/landingpage/desaantikorupsi/${id}`);
if (res.ok) {
const data = await res.json();
desaAntikorupsi.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch data", res.status, res.statusText);
desaAntikorupsi.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching data:", error);
desaAntikorupsi.findUnique.data = null;
}
// ❌ MISSING: finally block untuk stop loading
}
```
**Dampak:** UI mungkin stuck di loading state jika ada error.
**Rekomendasi:** Tambahkan loading state dan finally block:
```typescript
async load(id: string) {
try {
desaAntikorupsi.findUnique.loading = true; // ✅ Start loading
const res = await fetch(`/api/landingpage/desaantikorupsi/${id}`);
if (res.ok) {
const data = await res.json();
desaAntikorupsi.findUnique.data = data.data ?? null;
}
} catch (error) {
console.error("Error:", error);
} finally {
desaAntikorupsi.findUnique.loading = false; // ✅ Stop loading
}
}
```
**Priority:** 🔴 Medium
**Effort:** Low
---
#### **4. Kategori Edit - Response Cloning Overkill**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/desa-anti-korupsi.ts`
**Masalah:**
```typescript
// Line ~370 - kategoriDesaAntiKorupsi.edit.update()
async update() {
// ...
const response = await fetch(...);
// Clone the response to avoid 'body already read' error
const responseClone = response.clone();
try {
const result = await response.json();
// ...
} catch (error) {
// If JSON parsing fails, try to get the response text
try {
const text = await responseClone.text();
console.error("Error response text:", text);
throw new Error(`Gagal memproses respons dari server: ${text}`);
} catch (textError) {
// ...
}
}
}
```
**Analysis:**
-**GOOD:** Error handling sangat thorough
- ⚠️ **OVERKILL:** Untuk production API yang stable, ini berlebihan
- ⚠️ **INCONSISTENT:** Module lain tidak punya error handling se-detail ini
**Rekomendasi:** Simplify untuk consistency:
```typescript
async update() {
try {
kategoriDesaAntiKorupsi.edit.loading = true;
const response = await fetch(`/api/landingpage/kategoridak/${this.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: this.form.name }),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result?.message || `HTTP ${response.status}`);
}
if (result.success) {
toast.success(result.message || "Berhasil update");
await kategoriDesaAntiKorupsi.findMany.load();
return true;
}
throw new Error(result.message || "Gagal update");
} catch (error) {
console.error("Error updating:", error);
toast.error(error instanceof Error ? error.message : "Gagal update");
return false;
} finally {
kategoriDesaAntiKorupsi.edit.loading = false;
}
}
```
**Priority:** 🟡 Low
**Effort:** Low
---
### **🟡 MEDIUM**
#### **5. HTML Injection Risk - dangerouslySetInnerHTML**
**Lokasi:**
- `list-desa-anti-korupsi/[id]/page.tsx` (line ~105)
- `list-desa-anti-korupsi/create/page.tsx` (CreateEditor component)
- `list-desa-anti-korupsi/[id]/edit/page.tsx` (EditEditor component)
**Masalah:**
```typescript
// ❌ Direct HTML render tanpa sanitization
<Box
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
style={{ wordBreak: "break-word", whiteSpace: "normal", lineHeight: 1.6 }}
/>
```
**Risk:**
- XSS attack jika admin input script malicious
- Bisa inject iframe, script tag, dll
- Security vulnerability
**Rekomendasi:** Gunakan DOMPurify atau library sanitization:
```typescript
import DOMPurify from 'dompurify';
// Sanitize sebelum render
const sanitizedHtml = DOMPurify.sanitize(data.deskripsi);
<Box
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
// ...
/>
```
Atau validasi di backend untuk whitelist tag HTML yang diperbolehkan (hanya `<p>`, `<ul>`, `<li>`, `<strong>`, dll).
**Priority:** 🟡 Medium (**Security concern**)
**Effort:** Low
---
#### **6. Type Safety - Any Usage**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/desa-anti-korupsi.ts`
**Masalah:**
```typescript
// Line ~60
data: null as any[] | null, // ❌ Using 'any'
// Line ~280
data: null as any[] | null, // ❌ Using 'any'
// Line ~97
data: null as Prisma.DesaAntiKorupsiGetPayload<{...}> | null, // ✅ Typed
// Line ~310
data: null as Prisma.KategoriDesaAntiKorupsiGetPayload<{...}> | null, // ✅ Typed
```
**Rekomendasi:** Gunakan typed data consistently:
```typescript
// desaAntikorupsi.findMany
data: null as Prisma.DesaAntiKorupsiGetPayload<{
include: { kategori: true; file: true };
}>[] | null,
// kategoriDesaAntiKorupsi.findMany
data: null as Prisma.KategoriDesaAntiKorupsiGetPayload<{}>[] | null,
```
**Priority:** 🟡 Medium
**Effort:** Medium (perlu update semua reference)
---
#### **7. Console.log di Production**
**Lokasi:** Multiple places di state file
**Masalah:**
```typescript
// Line ~50
console.log(error);
toast.error("Gagal menambahkan data");
// Line ~85
console.error("Failed to load media sosial:", res.data?.message);
// Line ~91
console.error("Error loading media sosial:", error);
// Line ~110
console.error("Failed to fetch data", res.status, res.statusText);
// Line ~114
console.error("Error fetching data:", error);
// ... dan banyak lagi
```
**Rekomendasi:** Gunakan conditional logging:
```typescript
if (process.env.NODE_ENV === 'development') {
console.error("Error:", error);
}
```
Atau gunakan logging library (winston, pino, dll) dengan levels yang jelas.
**Priority:** 🟡 Low
**Effort:** Low
---
#### **8. Error Message Tidak Konsisten**
**Lokasi:** Multiple places
**Masalah:**
```typescript
// Create - Line ~40
return toast.error("Gagal menambahkan data");
// Create - Line ~42
toast.error("Gagal menambahkan data");
// Delete - Line ~140
toast.error("Terjadi kesalahan saat menghapus desa anti korupsi");
// Edit - Line ~190
toast.error("Gagal memuat data");
// Edit update - Line ~240
toast.error("Gagal mengupdate desa anti korupsi");
```
**Rekomendasi:** Standardisasi error messages:
```typescript
// Pattern: "[Action] [resource] gagal"
toast.error("Menambahkan data gagal");
toast.error("Menghapus data gagal");
toast.error("Memuat data gagal");
toast.error("Memperbarui data gagal");
// Atau lebih spesifik dengan context
toast.error("Gagal menambahkan data Desa Anti Korupsi");
toast.error("Gagal menghapus Kategori Desa Anti Korupsi");
```
**Priority:** 🟢 Low
**Effort:** Low
---
### **🟢 LOW (Minor Polish)**
#### **9. Placeholder Search Tidak Spesifik**
**Lokasi:**
- `list-desa-anti-korupsi/page.tsx`: `placeholder="Cari nama program atau kategori..."` ✅ Spesifik
- `kategori-desa-anti-korupsi/page.tsx`: `placeholder='pencarian'` ❌ Terlalu generic
**Rekomendasi:**
```typescript
// Kategori page
placeholder="Cari nama kategori..."
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **10. Alert vs Toast**
**Lokasi:** `kategori-desa-anti-korupsi/create/page.tsx`
**Masalah:**
```typescript
// Line ~37
if (!stateKategori.create.form.name) {
return alert('Nama kategori harus diisi'); // ❌ Using alert()
}
```
**Rekomendasi:** Gunakan toast untuk consistency:
```typescript
if (!stateKategori.create.form.name) {
return toast.warn('Nama kategori harus diisi'); // ✅ Using toast
}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **11. Component Name Mismatch**
**Lokasi:** `list-desa-anti-korupsi/[id]/page.tsx`
**Masalah:**
```typescript
// Line ~17
export default function DetailKegiatanDesa() { // ❌ Wrong name
// ...
}
```
**Rekomendasi:** Rename ke yang sesuai:
```typescript
export default function DetailDesaAntiKorupsi() { // ✅ Correct name
// ...
}
```
**Priority:** 🟢 Low
**Effort:** Low (hanya rename)
---
#### **12. Duplicate Error Logging**
**Lokasi:** `list-desa-anti-korupsi/[id]/edit/page.tsx`
**Masalah:**
```typescript
// Line ~87
} catch (err) {
console.error(err); // ❌ Duplicate logging
toast.error('Gagal memuat data Desa Anti Korupsi');
}
```
**Rekomendasi:** Cukup satu logging yang informatif:
```typescript
} catch (err) {
console.error('Failed to load Desa Anti Korupsi:', err);
toast.error('Gagal memuat data Desa Anti Korupsi');
}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **13. Comment Typo**
**Lokasi:** `kategori-desa-anti-korupsi/[id]/edit/page.tsx`
**Masalah:**
```typescript
// Line ~20
// 🧠 Ambil proxy asli (bisa ditulis) & snapshot (buat render)
const stateKategori = korupsiState.kategoriDesaAntiKorupsi;
const snapshotKategori = useProxy(stateKategori);
// ❌ snapshotKategori declared but never used
```
**Rekomendasi:** Remove unused variable:
```typescript
const stateKategori = korupsiState.kategoriDesaAntiKorupsi;
// const snapshotKategori = useProxy(stateKategori); // ❌ Remove
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **14. Schema - deletedAt Default Value**
**Lokasi:** `prisma/schema.prisma`
**Masalah:**
```prisma
model DesaAntiKorupsi {
// ...
deletedAt DateTime @default(now()) // ❌ Always has default value
isActive Boolean @default(true)
}
```
**Issue:** `deletedAt @default(now())` berarti setiap record baru langsung punya `deletedAt` value, yang bisa membingungkan untuk soft delete logic.
**Rekomendasi:**
```prisma
model DesaAntiKorupsi {
// ...
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
isActive Boolean @default(true)
}
```
**Priority:** 🟢 Medium (potential logic issue)
**Effort:** Medium (perlu migration)
---
## 📋 RINGKASAN ACTION ITEMS
| Priority | Issue | Module | Impact | Effort | Status |
|----------|-------|--------|--------|--------|--------|
| 🔴 P0 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
| 🔴 P0 | Missing loading state in findUnique | State | Medium | Low | Perlu fix |
| 🟡 M | HTML injection risk | UI | **High (Security)** | Low | **Should fix** |
| 🟡 M | Type safety (any usage) | State | Low | Medium | Optional |
| 🟡 M | Response cloning overkill | State (Kategori) | Low | Low | Optional |
| 🟢 L | Console.log in production | State | Low | Low | Optional |
| 🟢 L | Error message inconsistency | State | Low | Low | Optional |
| 🟢 L | Placeholder tidak spesifik | Kategori UI | Low | Low | Optional |
| 🟢 L | Alert vs Toast | Kategori Create | Low | Low | Optional |
| 🟢 L | Component name mismatch | Detail page | Low | Low | Optional |
| 🟢 L | Duplicate error logging | Edit page | Low | Low | Optional |
| 🟢 L | Unused variable | Kategori Edit | Low | Low | Optional |
| 🟢 M | deletedAt default value | Schema | Medium | Medium | Should fix |
---
## ✅ KESIMPULAN
### **Overall Quality: 🟢 BAIK (7.5/10)**
**Strengths:**
1. ✅ UI/UX konsisten & responsive
2. ✅ File upload handling solid (iframe preview untuk dokumen)
3. ✅ Form validation dengan Zod schema
4. ✅ State management terstruktur (Valtio)
5. ✅ Error handling comprehensive (terutama di kategori update)
6.**Edit form reset sudah benar** (original data tracking)
7. ✅ Modal konfirmasi hapus untuk user safety
**Areas for Improvement:**
1. ⚠️ **Security:** HTML injection di deskripsi (prioritas)
2. ⚠️ **Consistency:** Fetch method pattern (ApiFetch vs fetch manual)
3. ⚠️ **Loading States:** findUnique tidak ada loading state management
4. ⚠️ **Type Safety:** Reduce `any` usage, gunakan Prisma types
5. ⚠️ **Schema:** deletedAt default value bisa menyebabkan logic issue
**Recommended Next Steps:**
1. **Fix HTML injection** dengan DOMPurify atau backend validation
2. **Refactor fetch methods** untuk gunakan ApiFetch consistently
3. **Add loading state** di findUnique operations
4. **Fix deletedAt schema** untuk soft delete yang benar
5. **Optional:** Improve type safety dengan remove `any`
---
## 📈 COMPARISON WITH OTHER MODULES
| Aspect | Profil Module | Desa Anti Korupsi | Notes |
|--------|--------------|-------------------|-------|
| Fetch Pattern | ⚠️ Mixed | ⚠️ Mixed | Both perlu refactor |
| Loading State | ⚠️ Some missing | ⚠️ Some missing | Same issue |
| Edit Form Reset | ✅ Good | ✅ Good | Consistent |
| Type Safety | ⚠️ Some `any` | ⚠️ Some `any` | Same issue |
| HTML Injection | ⚠️ Present | ⚠️ Present | Both need fix |
| File Upload | ✅ Images | ✅ Documents | Different use case |
| Error Handling | ✅ Good | ✅ Good (better) | DAK more thorough |
---
**Catatan:** Secara keseluruhan, modul Desa Anti Korupsi sudah **production-ready** dengan beberapa improvements yang bisa dilakukan secara incremental. Module ini memiliki error handling yang lebih thorough dibanding module Profil, terutama di kategori update operation.

View File

@@ -1,875 +0,0 @@
# QC Summary - Prestasi Desa Module
**Scope:** List Prestasi Desa, Kategori Prestasi Desa, Create, Edit, Detail
**Date:** 2026-02-23
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
---
## 📊 OVERVIEW
| Aspect | Schema | API | UI Admin | State Management | Overall |
|--------|--------|-----|----------|-----------------|---------|
| Prestasi Desa | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 Perlu fix |
| Kategori Prestasi | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 Perlu fix |
---
## ✅ YANG SUDAH BAIK
### **1. UI/UX Consistency**
- ✅ Responsive design (desktop table + mobile cards)
- ✅ Loading states dengan Skeleton
- ✅ Search dengan debounce (1000ms)
- ✅ Pagination konsisten
- ✅ Empty state handling yang informatif
- ✅ Modal konfirmasi hapus
### **2. File Upload Handling**
- ✅ Dropzone dengan preview image
- ✅ Validasi format gambar (JPEG, JPG, PNG, WEBP)
- ✅ Validasi ukuran file (max 5MB)
- ✅ Tombol hapus preview (IconX di pojok kanan atas)
- ✅ URL.createObjectURL untuk preview lokal
- ✅ Preview dengan max height yang proper
### **3. Form Validation**
- ✅ Zod schema untuk validasi typed
- ✅ isFormValid() check sebelum submit
- ✅ Error toast dengan pesan spesifik
- ✅ Button disabled saat invalid/loading
### **4. CRUD Operations**
- ✅ Create dengan upload file
- ✅ FindMany dengan pagination & search
- ✅ FindUnique untuk detail
- ✅ Delete dengan hard delete (via Prisma)
- ✅ Update dengan file replacement
### **5. Edit Form - Original Data Tracking**
- ✅ Original data state untuk reset form
- ✅ Load data existing dengan benar
- ✅ Preview image dari data lama
- ✅ Reset form mengembalikan ke data original
**Code Example (✅ GOOD):**
```typescript
// edit/page.tsx - Line ~70-95
const data = await editState.edit.load(id);
setOriginalData({
name: data.name,
deskripsi: data.deskripsi,
kategoriId: data.kategoriId,
imageId: data.imageId,
imageUrl: data.image?.link || "",
});
setFormData({
name: data.name,
deskripsi: data.deskripsi,
kategoriId: data.kategoriId,
imageId: data.imageId,
});
if (data.image?.link) setPreviewFile(data.image.link);
// Line ~105 - Handle reset
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
kategoriId: originalData.kategoriId,
imageId: originalData.imageId,
});
setPreviewFile(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
```
**Verdict:****SUDAH BENAR** - Original data tracking sudah implementasi dengan baik.
---
### **6. State Management - Good Practices**
- ✅ Proper typing dengan Prisma types
- ✅ Loading state management dengan finally block
- ✅ Error handling yang comprehensive
- ✅ Reset function untuk cleanup
**Code Example (✅ GOOD):**
```typescript
// state file - Line ~70-95
load: async (page = 1, limit = 10, search = "") => {
prestasiDesa.findMany.loading = true; // ✅ Start loading
prestasiDesa.findMany.page = page;
prestasiDesa.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.landingpage.prestasidesa["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
prestasiDesa.findMany.data = res.data.data ?? [];
prestasiDesa.findMany.totalPages = res.data.totalPages ?? 1;
} else {
prestasiDesa.findMany.data = [];
prestasiDesa.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch prestasi desa paginated:", err);
prestasiDesa.findMany.data = [];
prestasiDesa.findMany.totalPages = 1;
} finally {
prestasiDesa.findMany.loading = false; // ✅ Stop loading
}
};
```
**Verdict:****SUDAH BENAR** - Loading state management sudah proper.
---
## ⚠️ ISSUES & SARAN PERBAIKAN
### **🔴 CRITICAL**
#### **1. Schema - deletedAt Default Value SALAH**
**Lokasi:** `prisma/schema.prisma` (line 239-240)
**Masalah:**
```prisma
model PrestasiDesa {
// ...
deletedAt DateTime @default(now()) // ❌ SALAH - selalu punya default value
isActive Boolean @default(true)
}
model KategoriPrestasiDesa {
// ...
deletedAt DateTime @default(now()) // ❌ SALAH - selalu punya default value
isActive Boolean @default(true)
}
```
**Dampak:**
- **LOGIC ERROR!** Setiap record baru langsung punya `deletedAt` value (timestamp creation)
- Soft delete tidak berfungsi dengan benar
- Query dengan `where: { deletedAt: null }` tidak akan pernah return data
- Data yang "dihapus" vs data "aktif" tidak bisa dibedakan
**Contoh Issue:**
```prisma
// Record baru dibuat
CREATE PrestasiDesa {
name: "Prestasi 1",
// deletedAt otomatis ter-set ke now() ❌
// isActive: true ✅
}
// Query untuk data aktif (seharusnya return data ini)
prisma.prestasiDesa.findMany({
where: { deletedAt: null, isActive: true }
})
// ❌ Return kosong! Karena deletedAt sudah ter-set
```
**Rekomendasi:** Fix schema:
```prisma
model PrestasiDesa {
// ...
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
isActive Boolean @default(true)
}
model KategoriPrestasiDesa {
// ...
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
isActive Boolean @default(true)
}
```
**Priority:** 🔴 **CRITICAL**
**Effort:** Medium (perlu migration)
**Impact:** **HIGH** (data integrity & soft delete logic)
---
#### **2. State Management - Inconsistency Fetch Pattern**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/prestasi-desa.ts`
**Masalah:** Ada 2 pattern berbeda untuk fetch API:
```typescript
// ❌ Pattern 1: ApiFetch (create, findMany)
const res = await ApiFetch.api.landingpage.prestasidesa["create"].post({...});
const res = await ApiFetch.api.landingpage.prestasidesa["find-many"].get({query});
// ❌ Pattern 2: fetch manual (findUnique, edit, delete)
const res = await fetch(`/api/landingpage/prestasidesa/${id}`);
const response = await fetch(`/api/landingpage/prestasidesa/del/${id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
});
```
**Dampak:**
- Code consistency buruk
- Sulit maintenance
- Type safety tidak konsisten
- Duplikasi logic error handling
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
```typescript
// ✅ Unified pattern
async load(id: string) {
try {
prestasiDesa.edit.loading = true;
const res = await ApiFetch.api.landingpage.prestasidesa[id].get();
if (res.data?.success) {
const data = res.data.data;
this.id = data.id;
this.form = {
name: data.name,
deskripsi: data.deskripsi,
imageId: data.imageId,
kategoriId: data.kategoriId,
};
return data;
} else {
throw new Error(res.data?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error loading prestasi desa:", error);
toast.error(error instanceof Error ? error.message : "Gagal memuat data");
return null;
} finally {
prestasiDesa.edit.loading = false;
}
}
```
**Priority:** 🔴 High
**Effort:** Medium (refactor di findUnique, edit, delete methods)
---
#### **3. findUnique State - Tidak Ada Loading State Management**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/prestasi-desa.ts`
**Masalah:**
```typescript
// Line ~110 - prestasiDesa.findUnique.load()
async load(id: string) {
try {
const res = await fetch(`/api/landingpage/prestasidesa/${id}`);
if (res.ok) {
const data = await res.json();
prestasiDesa.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch data", res.status, res.statusText);
prestasiDesa.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching data:", error);
prestasiDesa.findUnique.data = null;
}
// ❌ MISSING: finally block untuk stop loading
// ❌ MISSING: loading state initialization
}
```
**Dampak:** UI mungkin stuck di loading state jika ada error.
**Rekomendasi:** Tambahkan loading state dan finally block:
```typescript
async load(id: string) {
try {
prestasiDesa.findUnique.loading = true; // ✅ Start loading
const res = await fetch(`/api/landingpage/prestasidesa/${id}`);
if (res.ok) {
const data = await res.json();
prestasiDesa.findUnique.data = data.data ?? null;
}
} catch (error) {
console.error("Error:", error);
} finally {
prestasiDesa.findUnique.loading = false; // ✅ Stop loading
}
}
```
**Priority:** 🔴 Medium
**Effort:** Low
---
### **🟡 MEDIUM**
#### **4. HTML Injection Risk - dangerouslySetInnerHTML**
**Lokasi:**
- `list-prestasi-desa/page.tsx` (line ~90, 145)
- `list-prestasi-desa/[id]/page.tsx` (line ~85)
- `list-prestasi-desa/create/page.tsx` (CreateEditor component)
- `list-prestasi-desa/[id]/edit/page.tsx` (EditEditor component)
**Masalah:**
```typescript
// ❌ Direct HTML render tanpa sanitization
<Text
lineClamp={1}
fz="md"
c="dimmed"
lh={1.5}
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
/>
```
**Risk:**
- XSS attack jika admin input script malicious
- Bisa inject iframe, script tag, dll
- Security vulnerability
**Rekomendasi:** Gunakan DOMPurify atau library sanitization:
```typescript
import DOMPurify from 'dompurify';
// Sanitize sebelum render
const sanitizedHtml = DOMPurify.sanitize(item.deskripsi);
<Text
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
// ...
/>
```
Atau validasi di backend untuk whitelist tag HTML yang diperbolehkan (hanya `<p>`, `<ul>`, `<li>`, `<strong>`, dll).
**Priority:** 🟡 Medium (**Security concern**)
**Effort:** Low
---
#### **5. Type Safety - Any Usage**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/prestasi-desa.ts`
**Masalah:**
```typescript
// Line ~73
const query: any = { page, limit }; // ❌ Using 'any'
if (search) query.search = search;
// Line ~270
const query: any = { page, limit }; // ❌ Using 'any'
if (search) query.search = search;
```
**Rekomendasi:** Gunakan typed query:
```typescript
// Define type
interface FindManyQuery {
page: number | string;
limit: number | string;
search?: string;
}
// Use typed query
const query: FindManyQuery = { page, limit };
if (search) query.search = search;
```
**Priority:** 🟡 Medium
**Effort:** Low
---
#### **6. Console.log di Production**
**Lokasi:** Multiple places di state file
**Masalah:**
```typescript
// Line ~48
console.log(error);
toast.error("Gagal menambahkan data");
// Line ~120
console.error("Failed to fetch data", res.status, res.statusText);
// Line ~124
console.error("Error fetching data:", error);
// Line ~300
console.log(error);
toast.error("Gagal menambahkan data");
// ... dan banyak lagi
```
**Rekomendasi:** Gunakan conditional logging:
```typescript
if (process.env.NODE_ENV === 'development') {
console.error("Error:", error);
}
```
**Priority:** 🟡 Low
**Effort:** Low
---
#### **7. Error Message Tidak Konsisten**
**Lokasi:** Multiple places
**Masalah:**
```typescript
// Create - Line ~46
return toast.error("Gagal menambahkan data");
// Create - Line ~48
toast.error("Gagal menambahkan data");
// Delete - Line ~150
toast.error("Terjadi kesalahan saat menghapus prestasi desa");
// Edit - Line ~200
toast.error("Gagal memuat data");
// Edit update - Line ~240
toast.error("Gagal mengupdate prestasi desa");
// Toast success - Line ~235
toast.success("Berhasil update prestasi desa");
```
**Issue:**
- Inconsistent capitalization
- Mixed patterns ("Gagal menambahkan" vs "Terjadi kesalahan")
- Generic messages
**Rekomendasi:** Standardisasi error messages:
```typescript
// Pattern: "[Action] [resource] gagal" dengan proper casing
toast.error("Menambahkan data Prestasi Desa gagal");
toast.error("Menghapus data Prestasi Desa gagal");
toast.error("Memuat data Prestasi Desa gagal");
toast.error("Memperbarui data Prestasi Desa gagal");
// Atau lebih spesifik dengan context
toast.error("Gagal menambahkan data Prestasi Desa");
toast.error("Gagal menghapus Prestasi Desa");
toast.success("Berhasil memperbarui Prestasi Desa");
```
**Priority:** 🟡 Low
**Effort:** Low
---
#### **8. Zod Schema - Error Message Tidak Akurat**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/prestasi-desa.ts`
**Masalah:**
```typescript
// Line ~8
const templateprestasiDesaForm = z.object({
name: z.string().min(1, "Judul minimal 1 karakter"), // ⚠️ "Judul" instead of "Nama"
deskripsi: z.string().min(1, "Deskripsi minimal 1 karakter"), // ✅ OK
imageId: z.string().min(1, "File minimal 1"), // ⚠️ Generic
kategoriId: z.string().min(1, "Kategori minimal 1 karakter"), // ⚠️ "Kategori" instead of "Kategori Prestasi"
});
```
**Dampak:** User confusion saat validasi error muncul.
**Rekomendasi:** Fix error messages:
```typescript
const templateprestasiDesaForm = z.object({
name: z.string().min(1, "Nama prestasi wajib diisi"),
deskripsi: z.string().min(1, "Deskripsi prestasi wajib diisi"),
imageId: z.string().min(1, "Gambar prestasi wajib diunggah"),
kategoriId: z.string().min(1, "Kategori prestasi wajib dipilih"),
});
```
**Priority:** 🟡 Low
**Effort:** Low
---
### **🟢 LOW (Minor Polish)**
#### **9. Component Name Mismatch**
**Lokasi:** `list-prestasi-desa/page.tsx`
**Masalah:**
```typescript
// Line ~11
function ListPrestasiDesa() {
// ...
}
// Line ~27
function ListPrestasi({ search }: { search: string }) {
// ...
}
// ⚠️ Function name tidak konsisten dengan file name
```
**Rekomendasi:** Rename ke yang lebih descriptive:
```typescript
function ListPrestasiDesaPage() {
// ...
}
function ListPrestasiDesaTable({ search }: { search: string }) {
// ...
}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **10. Pagination onChange Tidak Include Search**
**Lokasi:** `list-prestasi-desa/page.tsx`
**Masalah:**
```typescript
// Line ~170
<Pagination
value={page}
onChange={load} // ⚠️ Hanya pass page number
total={totalPages}
// ...
/>
```
**Issue:** Saat ganti page, search query hilang karena `load` dipanggil hanya dengan page number.
**Rekomendasi:** Include search dan limit:
```typescript
<Pagination
value={page}
onChange={(newPage) => load(newPage, 10, debouncedSearch)} // ✅ Include all params
total={totalPages}
// ...
/>
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **11. Mobile Pagination - load Function Tidak Lengkap**
**Lokasi:** `kategori-prestasi-desa/page.tsx`
**Masalah:**
```typescript
// Line ~170 (Desktop)
onChange={(newPage) => load(newPage)} // ⚠️ Missing limit & search
// Line ~200 (Mobile)
onChange={(newPage) => load(newPage)} // ⚠️ Missing limit & search
```
**Rekomendasi:** Include all params:
```typescript
onChange={(newPage) => load(newPage, 10, debouncedSearch)}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **12. Duplicate Error Logging**
**Lokasi:** Multiple files
**Masalah:**
```typescript
// edit/page.tsx - Line ~100
} catch (error) {
console.error('Error loading prestasi desa:', error); // ❌ Duplicate
toast.error('Gagal memuat data prestasi desa');
}
// edit/page.tsx - Line ~130
} catch (error) {
console.error('Error updating prestasi desa:', error); // ❌ Duplicate
toast.error('Terjadi kesalahan saat memperbarui prestasi desa');
}
```
**Rekomendasi:** Cukup satu logging yang informatif:
```typescript
} catch (error) {
console.error('Failed to load Prestasi Desa:', err);
toast.error('Gagal memuat data Prestasi Desa');
}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **13. Inconsistent Button Label**
**Lokasi:** Multiple files
**Masalah:**
```typescript
// create/page.tsx - Line ~200
<Button ...>Reset</Button>
// edit/page.tsx - Line ~180
<Button ...>Batal</Button>
// Should be consistent: "Reset" atau "Batal"
```
**Rekomendasi:** Standardisasi:
```typescript
// Create: "Reset"
// Edit: "Batal" (lebih descriptive untuk cancel changes)
// OR both: "Reset" / "Batal"
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **14. Search Placeholder Tidak Spesifik**
**Lokasi:**
- `list-prestasi-desa/page.tsx`: `placeholder='Cari nama prestasi...'` ✅ OK
- `kategori-prestasi-desa/page.tsx`: `placeholder='Cari kategori prestasi...'` ✅ OK
**Verdict:****SUDAH BENAR** - Placeholder sudah spesifik.
**Priority:** 🟢 None
**Effort:** None
---
#### **15. Response Clone Overkill di Kategori Edit**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/prestasi-desa.ts`
**Masalah:**
```typescript
// Line ~370 - kategoriPrestasi.edit.update()
const response = await fetch(...);
const responseClone = response.clone();
try {
const result = await response.json();
// ...
} catch (error) {
try {
const text = await responseClone.text();
console.error("Error response text:", text);
throw new Error(`Gagal memproses respons dari server: ${text}`);
} catch (textError) {
console.error("Error parsing response as text:", textError);
console.error("Original error:", error);
throw new Error("Gagal memproses respons dari server");
}
}
```
**Analysis:**
-**GOOD:** Error handling sangat thorough
- ⚠️ **OVERKILL:** Untuk production API yang stable, ini berlebihan
- ⚠️ **INCONSISTENT:** Module lain tidak punya error handling se-detail ini
**Rekomendasi:** Simplify untuk consistency:
```typescript
async update() {
try {
kategoriPrestasi.edit.loading = true;
const response = await fetch(`/api/landingpage/kategoriprestasi/${this.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: this.form.name }),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result?.message || `HTTP ${response.status}`);
}
if (result.success) {
toast.success(result.message || "Berhasil update");
await kategoriPrestasi.findMany.load();
return true;
}
throw new Error(result.message || "Gagal update");
} catch (error) {
console.error("Error updating:", error);
toast.error(error instanceof Error ? error.message : "Gagal update");
return false;
} finally {
kategoriPrestasi.edit.loading = false;
}
}
```
**Priority:** 🟢 Low
**Effort:** Low
---
## 📋 RINGKASAN ACTION ITEMS
| Priority | Issue | Module | Impact | Effort | Status |
|----------|-------|--------|--------|--------|--------|
| 🔴 P0 | **Schema deletedAt default SALAH** | Schema | **CRITICAL** | Medium | **MUST FIX** |
| 🔴 P0 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
| 🔴 P1 | Missing loading state in findUnique | State | Medium | Low | Perlu fix |
| 🟡 M | HTML injection risk | UI | **High (Security)** | Low | **Should fix** |
| 🟡 M | Type safety (any usage) | State | Low | Low | Optional |
| 🟡 M | Console.log in production | State | Low | Low | Optional |
| 🟡 M | Error message inconsistency | State/UI | Low | Low | Optional |
| 🟡 M | Zod schema error messages | State | Low | Low | Should fix |
| 🟢 L | Component name mismatch | List UI | Low | Low | Optional |
| 🟢 L | Pagination missing search param | List UI | Low | Low | Should fix |
| 🟢 L | Duplicate error logging | UI | Low | Low | Optional |
| 🟢 L | Inconsistent button label | UI | Low | Low | Optional |
| 🟢 L | Response clone overkill | State (Kategori) | Low | Low | Optional |
---
## ✅ KESIMPULAN
### **Overall Quality: 🟢 BAIK (7/10)**
**Strengths:**
1. ✅ UI/UX konsisten & responsive
2. ✅ File upload handling solid
3. ✅ Form validation dengan Zod schema
4. ✅ State management terstruktur (Valtio)
5.**Edit form reset sudah benar** (original data tracking)
6. ✅ Loading state management di findMany (dengan finally block)
7. ✅ Modal konfirmasi hapus untuk user safety
**Critical Issues:**
1. ⚠️ **Schema deletedAt default SALAH** - Logic error untuk soft delete (CRITICAL)
2. ⚠️ Fetch method pattern inconsistency (ApiFetch vs fetch manual)
3. ⚠️ findUnique tidak ada loading state management
**Areas for Improvement:**
1. ⚠️ **Fix schema deletedAt** dari `@default(now())` ke `@default(null)` dengan nullable
2. ⚠️ **Refactor fetch methods** untuk gunakan ApiFetch consistently
3. ⚠️ **Add loading state** di findUnique operations
4. ⚠️ **Fix HTML injection** dengan DOMPurify atau backend validation
5. ⚠️ **Improve type safety** dengan remove `any` usage
6. ⚠️ **Standardisasi error messages** di Zod schema dan toast
**Recommended Next Steps:**
1. **🔴 CRITICAL: Fix schema deletedAt** - 30 menit (perlu migration)
2. **🔴 HIGH:** Refactor findUnique, edit, delete ke ApiFetch - 1 jam
3. **🔴 HIGH:** Add loading state di findUnique - 15 menit
4. **🟡 MEDIUM:** Fix HTML injection dengan DOMPurify - 30 menit
5. **🟡 MEDIUM:** Improve type safety - 30 menit
6. **🟢 LOW:** Polish minor issues - 30 menit
---
## 📈 COMPARISON WITH OTHER MODULES
| Aspect | Profil | Desa Anti Korupsi | SDGs Desa | APBDes | Prestasi Desa | Notes |
|--------|--------|-------------------|-----------|--------|---------------|-------|
| Fetch Pattern | ⚠️ Mixed | ⚠️ Mixed | ⚠️ Mixed | ⚠️ Mixed | ⚠️ Mixed | All perlu refactor |
| Loading State | ⚠️ Some missing | ⚠️ Some missing | ⚠️ Missing | ✅ Good | ⚠️ findUnique missing | Similar issue |
| Edit Form Reset | ✅ Good | ✅ Good | ✅ Good | ✅ Good | ✅ Good | All consistent |
| Type Safety | ⚠️ Some `any` | ⚠️ Some `any` | ⚠️ Some `any` | ⚠️ Some `any` | ⚠️ Some `any` | Same issue |
| File Upload | ✅ Images | ✅ Documents | ✅ Images | ✅ Dual | ✅ Images | APBDes paling complex |
| Error Handling | ✅ Good | ✅ Good (better) | ✅ Good | ✅ Good | ✅ Good | Consistent |
| **Schema deletedAt** | ⚠️ Issue | ⚠️ Issue | ⚠️ Issue | ✅ Good | ❌ **WRONG** | **Prestasi CRITICAL** |
| HTML Injection | ⚠️ Present | ⚠️ Present | N/A | N/A | ⚠️ Present | Security concern |
| Complexity | Low | Medium | Low | **High** | Medium | APBDes paling complex |
---
## 🎯 UNIQUE FEATURES OF PRESTASI DESA MODULE
**Standard Complexity:**
1. **Single file upload** (gambar) - similar to SDGs, Profil
2. **Kategori relation** - similar to Desa Anti Korupsi
3. **Rich text editor** (deskripsi) - similar to Desa Anti Korupsi
**Best Practices:**
1. ✅ Loading state management di findMany (dengan finally block) - better than SDGs
2. ✅ Edit form reset comprehensive (preserve all fields)
3. ✅ Proper typing di findMany (Prisma types)
**Critical Issues:**
1.**Schema deletedAt SALAH** - sama seperti SDGs & Desa Anti Korupsi, tapi APBDes sudah benar
---
**Catatan:** Secara keseluruhan, modul Prestasi Desa sudah **production-ready** dengan beberapa improvements yang bisa dilakukan secara incremental. Module ini memiliki struktur yang mirip dengan modul Desa Anti Korupsi (kategori relation, rich text editor, file upload).
**Unique Issues:**
1. Schema deletedAt default value yang salah (sama seperti SDGs & Desa Anti Korupsi)
2. HTML injection risk di deskripsi (sama seperti Desa Anti Korupsi)
3. Fetch pattern inconsistency (sama seperti semua modul lain)
**Priority Action:**
```diff
🔴 FIX INI SEKARANG (30 MENIT + MIGRATION):
File: prisma/schema.prisma
Line: 239-240, 248-249
model PrestasiDesa {
// ...
- deletedAt DateTime @default(now())
+ deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
model KategoriPrestasiDesa {
// ...
- deletedAt DateTime @default(now())
+ deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
# Lalu jalankan:
bunx prisma db push
# atau
bunx prisma migrate dev --name fix_deletedat_default
```
Setelah fix critical schema issue, module ini production-ready! 🎉

View File

@@ -1,488 +0,0 @@
# QC Summary - Profil Landing Page Module
**Scope:** Media Sosial, Pejabat Desa, Program Inovasi
**Date:** 2026-02-23
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement minor
---
## 📊 OVERVIEW
| Module | Schema | API | UI Admin | Public Page | Overall |
|--------|--------|-----|----------|-------------|---------|
| Media Sosial | ✅ Baik | ✅ Baik | ✅ Baik | N/A | 🟢 Baik |
| Pejabat Desa | ✅ Baik | ⚠️ Ada issue | ✅ Baik | N/A | 🟡 Perlu fix |
| Program Inovasi | ✅ Baik | ✅ Baik | ✅ Baik | N/A | 🟢 Baik |
---
## ✅ YANG SUDAH BAIK (COMMON)
### **1. Konsistensi UI/UX**
- ✅ Semua halaman menggunakan pattern yang sama (list → detail → edit)
- ✅ Responsive design (desktop table + mobile cards)
- ✅ Loading states dengan Skeleton
- ✅ Empty state handling yang informatif
- ✅ Search dengan debounce (1000ms)
- ✅ Pagination konsisten di semua modul
### **2. File Upload Handling**
- ✅ Dropzone dengan preview image
- ✅ Validasi format & ukuran file (max 5MB)
- ✅ Tombol hapus preview (IconX di pojok kanan atas)
- ✅ URL.createObjectURL untuk preview lokal
- ✅ Cleanup file state saat reset form
### **3. Form Validation**
- ✅ Zod schema untuk validasi typed
- ✅ isFormValid() check sebelum submit
- ✅ Error toast dengan pesan spesifik
- ✅ Button disabled saat invalid/loading
### **4. State Management (Valtio)**
- ✅ Proxy state untuk reaktivitas
- ✅ Separate state per modul (programInovasi, pejabatDesa, mediaSosial)
- ✅ Reset form function di setiap create/edit
- ✅ Original data tracking untuk reset
### **5. Error Handling**
- ✅ Try-catch di semua async operation
- ✅ Toast error dengan pesan user-friendly
- ✅ Console.error untuk debugging
- ✅ Modal konfirmasi hapus
---
## ⚠️ ISSUES & SARAN PERBAIKAN
### **🔴 CRITICAL**
#### **1. Pejabat Desa - Edit Form Tidak Reset imageId ke Original**
**Lokasi:** `src/app/admin/(dashboard)/landing-page/profil/pejabat-desa/[id]/edit/page.tsx`
**Masalah:**
```typescript
// Line ~100 - Load data
setFormData({
name: profileData.name || "",
position: profileData.position || "",
imageId: profileData.imageId || "", // ✅ Sudah benar
});
// Line ~170 - Handle reset
setFormData({
name: originalData.name,
position: originalData.position,
imageId: originalData.imageId, // ✅ Sudah benar
});
```
**Status:****SUDAH BENAR** - Tidak ada issue di sini
**Verdict:** Tidak ada action needed.
---
#### **2. Media Sosial - Edit Form Sudah Benar**
**Lokasi:** `src/app/admin/(dashboard)/landing-page/profil/media-sosial/[id]/edit/page.tsx`
**Verdict:****SUDAH BENAR** - Original data tracking sudah implementasi dengan baik:
```typescript
const [originalData, setOriginalData] = useState({
name: '',
icon: '',
iconUrl: '',
imageId: '',
imageUrl: '',
});
// Load data
setOriginalData({
...newForm,
imageUrl: data.image?.link || '',
});
// Reset form
setFormData({
name: originalData.name,
icon: originalData.icon,
iconUrl: originalData.iconUrl,
imageId: originalData.imageId,
});
```
**Verdict:** Tidak ada action needed.
---
#### **3. Program Inovasi - Edit Form Sudah Benar**
**Lokasi:** `src/app/admin/(dashboard)/landing-page/profil/program-inovasi/[id]/edit/page.tsx`
**Verdict:****SUDAH BENAR** - Original data tracking sudah implementasi dengan baik.
**Verdict:** Tidak ada action needed.
---
### **🟡 MEDIUM**
#### **4. Inconsistency: Fetch Method di State**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/profile.ts`
**Masalah:** Ada 3 pattern berbeda untuk fetch API:
```typescript
// ❌ Pattern 1: ApiFetch (programInovasi.create)
const res = await ApiFetch.api.landingpage.programinovasi["create"].post(formData);
// ❌ Pattern 2: fetch manual (programInovasi.findUnique)
const res = await fetch(`/api/landingpage/programinovasi/${id}`);
// ❌ Pattern 3: fetch dengan headers (programInovasi.update)
const response = await fetch(`/api/landingpage/programinovasi/${this.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({...}),
});
// ❌ Pattern 4: fetch dengan delete (programInovasi.delete)
const response = await fetch(`/api/landingpage/programinovasi/del/${id}`, {
method: "DELETE",
...
});
```
**Dampak:**
- Code consistency buruk
- Sulit maintenance
- Type safety tidak konsisten
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
```typescript
// ✅统一 pattern
const res = await ApiFetch.api.landingpage.programinovasi["create"].post(formData);
const res = await ApiFetch.api.landingpage.programinovasi[id].get();
const res = await ApiFetch.api.landingpage.programinovasi[id].put(data);
const res = await ApiFetch.api.landingpage.programinovasi["del"][id].delete();
```
**Priority:** 🟡 Medium
**Effort:** Low (refactor saja, tidak ada logic change)
---
#### **5. Media Sosial - Validasi IconUrl Tidak Selalu Relevan**
**Lokasi:** `src/app/admin/(dashboard)/landing-page/profil/media-sosial/create/page.tsx`
**Masalah:**
```typescript
// Line ~67
const isFormValid = () => {
const isNameValid = stateMediaSosial.create.form.name?.trim() !== '';
const isIconUrlValid = stateMediaSosial.create.form.iconUrl?.trim() !== ''; // ❌ Selalu required
const isCustomIconValid = selectedSosmed !== 'custom' || file !== null;
return isNameValid && isIconUrlValid && isCustomIconValid;
};
```
**Scenario:**
- User pilih icon "telephone" → iconUrl **seharusnya** required (nomor telepon)
- User pilih icon "facebook" → iconUrl **seharusnya** required (URL profile)
- Tapi jika user hanya mau tampil icon tanpa link → **tidak bisa**
**Rekomendasi:** Jadikan optional atau berikan default value:
```typescript
const isFormValid = () => {
const isNameValid = stateMediaSosial.create.form.name?.trim() !== '';
// IconUrl optional, atau validasi berdasarkan selectedSosmed
const isIconUrlValid = true; // atau validasi spesifik
const isCustomIconValid = selectedSosmed !== 'custom' || file !== null;
return isNameValid && isCustomIconValid;
};
```
**Priority:** 🟡 Medium
**Effort:** Low
---
#### **6. Pejabat Desa - Hanya Ada 1 Data (Hardcoded ID "edit")**
**Lokasi:** `src/app/admin/(dashboard)/landing-page/profil/pejabat-desa/page.tsx`
**Masalah:**
```typescript
// Line ~17
useShallowEffect(() => {
allList.findUnique.load("edit"); // ❌ Hardcoded ID
}, []);
```
**Dampak:**
- Tidak scalable jika nanti ada multiple pejabat desa
- Pattern berbeda dari modul lain (yang pakai findMany)
- Confusing untuk developer baru
**Rekomendasi:**
- Jika memang hanya 1 data, tambahkan komentar:
```typescript
// Note: "edit" adalah special ID untuk single pejabat desa record
// Backend akan return data pertama jika ID tidak ditemukan
allList.findUnique.load("edit");
```
- Atau gunakan pattern yang lebih clear:
```typescript
allList.findUnique.load("single"); // atau "default"
```
**Priority:** 🟡 Low-Medium
**Effort:** Low
---
#### **7. Program Inovasi - HTML Injection Risk di Deskripsi**
**Lokasi:**
- `src/app/admin/(dashboard)/landing-page/profil/program-inovasi/page.tsx` (line ~107)
- `src/app/admin/(dashboard)/landing-page/profil/program-inovasi/[id]/page.tsx` (line ~105)
**Masalah:**
```typescript
// ❌ Direct HTML render tanpa sanitization
<Text dangerouslySetInnerHTML={{ __html: item.description || '-' }}></Text>
```
**Risk:**
- XSS attack jika admin input script malicious
- Bisa inject iframe, script tag, dll
**Rekomendasi:** Gunakan DOMPurify atau library sanitization:
```typescript
import DOMPurify from 'dompurify';
// Sanitize sebelum render
const sanitizedHtml = DOMPurify.sanitize(item.description);
<Text dangerouslySetInnerHTML={{ __html: sanitizedHtml }}></Text>
```
Atau validasi di backend untuk whitelist tag HTML yang diperbolehkan (hanya `<p>`, `<ul>`, `<li>`, dll).
**Priority:** 🟡 Medium (security concern)
**Effort:** Low
---
### **🟢 LOW (Minor Polish)**
#### **8. Inconsistency: Button Size & Styling**
**Lokasi:** Multiple files
**Masalah:** Button styling tidak konsisten:
```typescript
// Media Sosial create
<Button size="md" ...>Simpan</Button>
// Program Inovasi create
<Button size="md" ...>Simpan</Button>
// Pejabat Desa edit
<Button size="md" ...>Simpan</Button>
// Media Sosial edit
<Button size="md" ...>Simpan</Button>
```
Tapi di detail page:
```typescript
// Semua detail page
<Button size="md" ...> // ✅ Konsisten
```
**Rekomendasi:** Buat konstanta untuk button size:
```typescript
const BUTTON_SIZE = "md";
const BUTTON_VARIANT = "light";
const BUTTON_RADIUS = "md";
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **9. Search Placeholder Tidak Spesifik**
**Lokasi:** Multiple list pages
**Masalah:**
```typescript
// Media Sosial
placeholder='Cari nama media sosial atau kontak...' // ✅ Spesifik
// Program Inovasi
placeholder="Cari program inovasi..." // ✅ Oke
// Pejabat Desa
// ❌ Tidak ada search feature
```
**Rekomendasi:** Tambahkan search feature ke Pejabat Desa jika memungkinkan, atau berikan komentar kenapa tidak ada (karena hanya 1 data).
**Priority:** 🟢 Low
**Effort:** Low
---
#### **10. Loading State Tidak Selalu Akurat**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/profile.ts`
**Masalah:**
```typescript
// Line ~120 - findUnique.load untuk programInovasi
async load(id: string) {
try {
const res = await fetch(`/api/landingpage/programinovasi/${id}`);
// ❌ Tidak ada loading state update di sini
if (res.ok) {
const data = await res.json();
programInovasi.findUnique.data = data.data ?? null;
}
} catch (error) {
// ❌ Tidak ada finally block untuk stop loading
}
}
```
**Dampak:** UI mungkin stuck di loading state jika ada error.
**Rekomendasi:** Tambahkan finally block:
```typescript
async load(id: string) {
try {
programInovasi.findUnique.loading = true; // ✅ Start loading
const res = await fetch(`/api/landingpage/programinovasi/${id}`);
if (res.ok) {
const data = await res.json();
programInovasi.findUnique.data = data.data ?? null;
}
} catch (error) {
console.error("Error:", error);
} finally {
programInovasi.findUnique.loading = false; // ✅ Stop loading
}
}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **11. Type Safety - Any Usage**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/profile.ts`
**Masalah:**
```typescript
// Line ~75
data: null as any[] | null, // ❌ Using 'any'
// Line ~120
data: null as Prisma.ProgramInovasiGetPayload<{...}> | null, // ✅ Typed
// Line ~200
data: null as any[] | null, // ❌ Using 'any'
```
**Rekomendasi:** Gunakan typed data:
```typescript
data: null as Prisma.MediaSosialGetPayload<{ include: { image: true } }>[] | null
```
**Priority:** 🟢 Low
**Effort:** Medium (perlu update semua reference)
---
#### **12. Console.log di Production**
**Lokasi:** Multiple files
**Masalah:**
```typescript
// Media Sosial edit page (line ~170)
console.log("Data yang akan dikirim ke backend:", stateMediaSosial.update.form);
// Profile state (multiple places)
console.log("Failed to load program inovasi:", res.statusText);
console.log((error as Error).message);
```
**Rekomendasi:** Gunakan conditional logging:
```typescript
if (process.env.NODE_ENV === 'development') {
console.log("Data:", stateMediaSosial.update.form);
}
```
Atau gunakan logging library (winston, pino, dll).
**Priority:** 🟢 Low
**Effort:** Low
---
## 📋 RINGKASAN ACTION ITEMS
| Priority | Issue | Module | Impact | Effort | Status |
|----------|-------|--------|--------|--------|--------|
| 🟡 M | Fetch method inconsistency | All | Medium | Low | Perlu refactor |
| 🟡 M | IconUrl validation terlalu strict | Media Sosial | Low | Low | Perlu fix logic |
| 🟡 M | HTML injection risk | Program Inovasi | **High (Security)** | Low | **Should fix** |
| 🟢 L | Hardcoded ID "edit" | Pejabat Desa | Low | Low | Optional |
| 🟢 L | Button styling inconsistency | All | Low | Low | Optional |
| 🟢 L | Missing search feature | Pejabat Desa | Low | Low | Optional |
| 🟢 L | Loading state inaccurate | All | Low | Low | Perlu fix |
| 🟢 L | Type safety (any usage) | All | Low | Medium | Optional |
| 🟢 L | Console.log in production | All | Low | Low | Optional |
---
## ✅ KESIMPULAN
### **Overall Quality: 🟢 BAIK (8/10)**
**Strengths:**
1. ✅ UI/UX konsisten & responsive
2. ✅ File upload handling sudah solid
3. ✅ Form validation dengan Zod
4. ✅ State management terstruktur
5. ✅ Error handling comprehensive
6. ✅ Edit form reset sudah benar di semua modul
**Areas for Improvement:**
1. ⚠️ **Security:** HTML injection di deskripsi Program Inovasi (prioritas)
2. ⚠️ **Consistency:** Fetch method pattern (ApiFetch vs fetch manual)
3. ⚠️ **Type Safety:** Reduce `any` usage, gunakan Prisma types
4. ⚠️ **Loading States:** Pastikan selalu ada finally block
**Recommended Next Steps:**
1. **Fix HTML injection** dengan DOMPurify atau backend validation
2. **Refactor fetch methods** untuk gunakan ApiFetch consistently
3. **Add loading state cleanup** di semua async operations
4. **Optional:** Improve type safety dengan remove `any`
---
**Catatan:** Secara keseluruhan, modul Profil sudah **production-ready** dengan minor improvements yang bisa dilakukan secara incremental.

View File

@@ -1,651 +0,0 @@
# QC Summary - SDGs Desa Module
**Scope:** List SDGs Desa, Create, Edit, Detail
**Date:** 2026-02-23
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
---
## 📊 OVERVIEW
| Aspect | Schema | API | UI Admin | State Management | Overall |
|--------|--------|-----|----------|-----------------|---------|
| SDGs Desa | ✅ Baik | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 Perlu fix |
---
## ✅ YANG SUDAH BAIK
### **1. UI/UX Consistency**
- ✅ Responsive design (desktop table + mobile cards)
- ✅ Loading states dengan Skeleton
- ✅ Search dengan debounce (1000ms)
- ✅ Pagination konsisten
- ✅ Empty state handling yang informatif
- ✅ Modal konfirmasi hapus
### **2. File Upload Handling**
- ✅ Dropzone dengan preview image
- ✅ Validasi format gambar (JPEG, JPG, PNG, WEBP)
- ✅ Validasi ukuran file (max 5MB)
- ✅ Tombol hapus preview (IconX di pojok kanan atas)
- ✅ URL.createObjectURL untuk preview lokal
### **3. Form Validation**
- ✅ Zod schema untuk validasi typed
- ✅ isFormValid() check sebelum submit
- ✅ Error toast dengan pesan spesifik
- ✅ Button disabled saat invalid/loading
- ✅ Type number input untuk jumlah
### **4. CRUD Operations**
- ✅ Create dengan upload file
- ✅ FindMany dengan pagination & search
- ✅ FindUnique untuk detail
- ✅ Delete dengan hard delete (via Prisma)
- ✅ Update dengan file replacement
### **5. Edit Form - Original Data Tracking**
- ✅ Original data state untuk reset form
- ✅ Load data existing dengan benar
- ✅ Preview image dari data lama
- ✅ Reset form mengembalikan ke data original
**Code Example (✅ GOOD):**
```typescript
// Line ~60-80 - Load data
const data = await sdgsState.edit.load(id);
setFormData({
name: data.name || "",
jumlah: data.jumlah || "",
imageId: data.imageId || "",
});
setOriginalData({
...newForm,
imageUrl: data.image?.link || "",
});
setPreviewImage(data.image?.link || null);
// Line ~90 - Handle reset
const handleResetForm = () => {
setFormData({
name: originalData.name,
jumlah: originalData.jumlah,
imageId: originalData.imageId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
```
**Verdict:****SUDAH BENAR** - Original data tracking sudah implementasi dengan baik.
---
## ⚠️ ISSUES & SARAN PERBAIKAN
### **🔴 CRITICAL**
#### **1. State Management - Inconsistency Fetch Pattern**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/sdgs-desa.ts`
**Masalah:** Ada 2 pattern berbeda untuk fetch API:
```typescript
// ❌ Pattern 1: ApiFetch (create, findMany)
const res = await ApiFetch.api.landingpage.sdgsdesa["create"].post({...});
const res = await ApiFetch.api.landingpage.sdgsdesa["findMany"].get({query});
// ❌ Pattern 2: fetch manual (findUnique, edit, delete)
const res = await fetch(`/api/landingpage/sdgsdesa/${id}`);
const response = await fetch(`/api/landingpage/sdgsdesa/del/${id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
});
```
**Dampak:**
- Code consistency buruk
- Sulit maintenance
- Type safety tidak konsisten
- Duplikasi logic error handling
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
```typescript
// ✅ Unified pattern
const res = await ApiFetch.api.landingpage.sdgsdesa["create"].post(data);
const res = await ApiFetch.api.landingpage.sdgsdesa[id].get();
const res = await ApiFetch.api.landingpage.sdgsdesa[id].put(data);
const res = await ApiFetch.api.landingpage.sdgsdesa["del"][id].delete();
```
**Priority:** 🔴 High
**Effort:** Medium (refactor di semua state methods)
---
#### **2. findUnique State - Tidak Ada Loading State Management**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/sdgs-desa.ts`
**Masalah:**
```typescript
// Line ~125 - sdgsDesa.findUnique.load()
async load(id: string) {
try {
const res = await fetch(`/api/landingpage/sdgsdesa/${id}`);
if (res.ok) {
const data = await res.json();
sdgsDesa.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch data", res.status, res.statusText);
sdgsDesa.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching data:", error);
sdgsDesa.findUnique.data = null;
}
// ❌ MISSING: finally block untuk stop loading
}
```
**Dampak:** UI mungkin stuck di loading state jika ada error.
**Rekomendasi:** Tambahkan loading state dan finally block:
```typescript
async load(id: string) {
try {
sdgsDesa.findUnique.loading = true; // ✅ Start loading
const res = await fetch(`/api/landingpage/sdgsdesa/${id}`);
if (res.ok) {
const data = await res.json();
sdgsDesa.findUnique.data = data.data ?? null;
}
} catch (error) {
console.error("Error:", error);
} finally {
sdgsDesa.findUnique.loading = false; // ✅ Stop loading
}
}
```
**Priority:** 🔴 Medium
**Effort:** Low
---
#### **3. findManyAll - Tidak Digunakan di UI**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/sdgs-desa.ts`
**Masalah:**
```typescript
// Line ~95 - findManyAll state
findManyAll: {
data: null as any[] | null,
loading: false,
load: async () => {
// ... fetch all data tanpa pagination
},
}
```
**Analysis:**
- ⚠️ **UNUSED:** Tidak ada component yang menggunakan `findManyAll`
- ⚠️ **DEAD CODE:** Menambah bundle size tanpa manfaat
- ⚠️ **CONFUSING:** Developer baru bisa bingung kapan pakai findMany vs findManyAll
**Rekomendasi:** Remove jika tidak digunakan:
```typescript
// ❌ Remove entire findManyAll block
```
Atau jika diperlukan untuk future feature, tambahkan comment:
```typescript
// Reserved for future use - dropdown select without pagination
findManyAll: { ... }
```
**Priority:** 🔴 Low-Medium
**Effort:** Low
---
### **🟡 MEDIUM**
#### **4. Type Safety - Any Usage**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/sdgs-desa.ts`
**Masalah:**
```typescript
// Line ~58
data: null as any[] | null, // ❌ Using 'any'
// Line ~96
data: null as any[] | null, // ❌ Using 'any'
// Line ~118
data: null as Prisma.SdgsDesaGetPayload<{...}> | null, // ✅ Typed
```
**Rekomendasi:** Gunakan typed data consistently:
```typescript
// findMany
data: null as Prisma.SdgsDesaGetPayload<{
include: { image: true };
}>[] | null,
// findManyAll (jika tidak dihapus)
data: null as Prisma.SdgsDesaGetPayload<{
include: { image: true };
}>[] | null,
```
**Priority:** 🟡 Medium
**Effort:** Medium (perlu update semua reference)
---
#### **5. Console.log di Production**
**Lokasi:** Multiple places di state file
**Masalah:**
```typescript
// Line ~48
console.log(error);
toast.error("Gagal menambahkan data");
// Line ~80
console.error("Failed to load media sosial:", res.data?.message);
// Line ~85
console.error("Error loading media sosial:", error);
// Line ~132
console.error("Failed to fetch data", res.status, res.statusText);
// Line ~136
console.error("Error fetching data:", error);
// ... dan banyak lagi
```
**Rekomendasi:** Gunakan conditional logging:
```typescript
if (process.env.NODE_ENV === 'development') {
console.error("Error:", error);
}
```
Atau gunakan logging library (winston, pino, dll) dengan levels yang jelas.
**Priority:** 🟡 Low
**Effort:** Low
---
#### **6. Error Message Tidak Konsisten**
**Lokasi:** Multiple places
**Masalah:**
```typescript
// Create - Line ~44
return toast.error("Gagal menambahkan data");
// Create - Line ~46
toast.error("Gagal menambahkan data");
// Delete - Line ~165
toast.error("Terjadi kesalahan saat menghapus sdgs desa");
// Edit - Line ~210
toast.error("Gagal memuat data");
// Edit update - Line ~250
toast.error("Gagal mengupdate sdgs desa");
// Toast success - Line ~240
toast.success("Berhasil update sdgs desa");
```
**Issue:**
- Inconsistent capitalization ("sdgs desa" vs "Sdgs Desa")
- Mixed patterns ("Gagal menambahkan" vs "Terjadi kesalahan")
- Typo: "sdgs" seharusnya "SDGs" (acronym)
**Rekomendasi:** Standardisasi error messages:
```typescript
// Pattern: "[Action] [resource] gagal" dengan proper casing
toast.error("Menambahkan data SDGs Desa gagal");
toast.error("Menghapus data SDGs Desa gagal");
toast.error("Memuat data SDGs Desa gagal");
toast.error("Memperbarui data SDGs Desa gagal");
// Atau lebih spesifik dengan context
toast.error("Gagal menambahkan data SDGs Desa");
toast.error("Gagal menghapus SDGs Desa");
toast.success("Berhasil memperbarui SDGs Desa");
```
**Priority:** 🟡 Low
**Effort:** Low
---
#### **7. Zod Schema - Error Message Tidak Akurat**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/sdgs-desa.ts`
**Masalah:**
```typescript
// Line ~8
const templatesdgsDesaForm = z.object({
name: z.string().min(1, "Judul minimal 1 karakter"), // ❌ "Judul" instead of "Nama"
jumlah: z.string().min(1, "Deskripsi minimal 1 karakter"), // ❌ "Deskripsi" instead of "Jumlah"
imageId: z.string().min(1, "File minimal 1"),
});
```
**Dampak:** User confusion saat validasi error muncul:
```
Error: "Judul minimal 1 karakter" // User: "Lho, ini field nama bukan judul?"
Error: "Deskripsi minimal 1 karakter" // User: "Ini field jumlah bukan deskripsi?"
```
**Rekomendasi:** Fix error messages:
```typescript
const templatesdgsDesaForm = z.object({
name: z.string().min(1, "Nama SDGs Desa minimal 1 karakter"),
jumlah: z.string().min(1, "Jumlah minimal 1 karakter"),
imageId: z.string().min(1, "Gambar wajib dipilih"),
});
```
**Priority:** 🟡 Low
**Effort:** Low
---
### **🟢 LOW (Minor Polish)**
#### **8. Component Name Mismatch**
**Lokasi:** `src/app/admin/(dashboard)/landing-page/SDGs/[id]/edit/page.tsx`
**Masalah:**
```typescript
// Line ~30
export default function EditKolaborasiInovasi() { // ❌ Wrong name
// ...
}
```
**Dampak:** Confusing untuk developer lain, sulit untuk search/reference.
**Rekomendasi:** Rename ke yang sesuai:
```typescript
export default function EditSDGsDesa() { // ✅ Correct name
// ...
}
```
**Priority:** 🟢 Low
**Effort:** Low (hanya rename)
---
#### **9. Text Label Tidak Konsisten**
**Lokasi:** Multiple files
**Masalah:**
```typescript
// Create page - Line ~100
<Text fw="bold" fz="sm" mb={6}>
Gambar Program Inovasi // ❌ Wrong label
</Text>
// Edit page - Line ~170
<Text fw="bold" fz="sm" mb={6}>
Gambar Program Inovasi // ❌ Wrong label (copy-paste?)
</Text>
```
**Rekomendasi:** Fix label:
```typescript
<Text fw="bold" fz="sm" mb={6}>
Gambar SDGs Desa // ✅ Correct label
</Text>
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **10. Placeholder Search Tidak Spesifik**
**Lokasi:** `page.tsx`
**Masalah:**
```typescript
// Line ~17
<HeaderSearch
title='Sdgs Desa'
placeholder='Cari Sdgs Desa...' // ⚠️ Generic
// ...
/>
```
**Rekomendasi:** Lebih spesifik:
```typescript
placeholder='Cari nama SDGs Desa...'
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **11. Capitalization Inconsistency**
**Lokasi:** Multiple files
**Masalah:**
```typescript
// page.tsx - Line ~17
title='Sdgs Desa' // ❌ Mixed case
// create/page.tsx - Line ~90
<Title>Tambah Sdgs Desa</Title> // ❌ Mixed case
// edit/page.tsx - Line ~160
<Title>Edit Sdgs Desa</Title> // ❌ Mixed case
// Should be:
// "SDGs Desa" (all caps for acronym)
```
**Rekomendasi:** Standardisasi:
```typescript
title='SDGs Desa'
<Title>Tambah SDGs Desa</Title>
<Title>Edit SDGs Desa</Title>
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **12. Schema - deletedAt Default Value**
**Lokasi:** `prisma/schema.prisma`
**Masalah:**
```prisma
model SdgsDesa {
// ...
deletedAt DateTime @default(now()) // ❌ Always has default value
isActive Boolean @default(true)
}
```
**Issue:** `deletedAt @default(now())` berarti setiap record baru langsung punya `deletedAt` value, yang bisa membingungkan untuk soft delete logic.
**Rekomendasi:**
```prisma
model SdgsDesa {
// ...
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
isActive Boolean @default(true)
}
```
**Priority:** 🟢 Medium (potential logic issue)
**Effort:** Medium (perlu migration)
---
#### **13. Duplicate Error Logging**
**Lokasi:** `edit/page.tsx`
**Masalah:**
```typescript
// Line ~80
} catch (error) {
console.error("Error loading sdgs desa:", error); // ❌ Duplicate
toast.error("Gagal memuat data sdgs desa");
}
// Line ~120
} catch (error) {
console.error("Error updating sdgs desa:", error); // ❌ Duplicate
toast.error("Terjadi kesalahan saat memperbarui sdgs desa");
}
```
**Rekomendasi:** Cukup satu logging yang informatif:
```typescript
} catch (error) {
console.error('Failed to load SDGs Desa:', err);
toast.error('Gagal memuat data SDGs Desa');
}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **14. API Response Handling - Inconsistent Error Messages**
**Lokasi:** API endpoints
**Masalah:** (dari grep search results)
```typescript
// del.ts - Line ~18
message: "Berhasil menghapus SDGS Desa", // ✅ Proper
// updt.ts - Line ~38
message: "SDGS Desa berhasil diperbarui", // ✅ Proper
// create.ts - (assumed)
// Might have inconsistent casing
```
**Rekomendasi:** Ensure all API responses use consistent "SDGs Desa" casing.
**Priority:** 🟢 Low
**Effort:** Low
---
## 📋 RINGKASAN ACTION ITEMS
| Priority | Issue | Module | Impact | Effort | Status |
|----------|-------|--------|--------|--------|--------|
| 🔴 P0 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
| 🔴 P0 | Missing loading state in findUnique | State | Medium | Low | Perlu fix |
| 🔴 P1 | Unused findManyAll code | State | Low | Low | Should remove |
| 🟡 M | Type safety (any usage) | State | Low | Medium | Optional |
| 🟡 M | Console.log in production | State | Low | Low | Optional |
| 🟡 M | Error message inconsistency | State/UI | Low | Low | Optional |
| 🟡 M | Zod schema error messages | State | Low | Low | Should fix |
| 🟢 L | Component name mismatch | Edit page | Low | Low | Optional |
| 🟢 L | Wrong label text ("Program Inovasi") | Create/Edit | Low | Low | Should fix |
| 🟢 L | Placeholder tidak spesifik | List page | Low | Low | Optional |
| 🟢 L | Capitalization inconsistency | All UI | Low | Low | Should fix |
| 🟢 M | deletedAt default value | Schema | Medium | Medium | Should fix |
| 🟢 L | Duplicate error logging | Edit page | Low | Low | Optional |
---
## ✅ KESIMPULAN
### **Overall Quality: 🟢 BAIK (7.5/10)**
**Strengths:**
1. ✅ UI/UX konsisten & responsive
2. ✅ File upload handling solid
3. ✅ Form validation dengan Zod schema
4. ✅ State management terstruktur (Valtio)
5.**Edit form reset sudah benar** (original data tracking)
6. ✅ Modal konfirmasi hapus untuk user safety
7. ✅ Type number input untuk field jumlah
**Areas for Improvement:**
1. ⚠️ **Consistency:** Fetch method pattern (ApiFetch vs fetch manual)
2. ⚠️ **Loading States:** findUnique tidak ada loading state management
3. ⚠️ **Dead Code:** findManyAll tidak digunakan
4. ⚠️ **Type Safety:** Reduce `any` usage, gunakan Prisma types
5. ⚠️ **Schema:** deletedAt default value bisa menyebabkan logic issue
6. ⚠️ **Naming:** Component name & label text masih ada yang salah
**Recommended Next Steps:**
1. **Refactor fetch methods** untuk gunakan ApiFetch consistently
2. **Add loading state** di findUnique operations
3. **Remove findManyAll** jika tidak digunakan
4. **Fix component name** (EditKolaborasiInovasi → EditSDGsDesa)
5. **Fix label text** ("Gambar Program Inovasi" → "Gambar SDGs Desa")
6. **Fix capitalization** (Sdgs → SDGs)
7. **Optional:** Improve type safety dengan remove `any`
---
## 📈 COMPARISON WITH OTHER MODULES
| Aspect | Profil | Desa Anti Korupsi | SDGs Desa | Notes |
|--------|--------|-------------------|-----------|-------|
| Fetch Pattern | ⚠️ Mixed | ⚠️ Mixed | ⚠️ Mixed | All perlu refactor |
| Loading State | ⚠️ Some missing | ⚠️ Some missing | ⚠️ Missing | Same issue |
| Edit Form Reset | ✅ Good | ✅ Good | ✅ Good | Consistent |
| Type Safety | ⚠️ Some `any` | ⚠️ Some `any` | ⚠️ Some `any` | Same issue |
| File Upload | ✅ Images | ✅ Documents | ✅ Images | Different use case |
| Error Handling | ✅ Good | ✅ Good (better) | ✅ Good | Consistent |
| Dead Code | ❌ None | ❌ None | ⚠️ findManyAll | SDGs unique issue |
| Naming Issues | ❌ None | ⚠️ Some | ⚠️ Some | Similar level |
---
**Catatan:** Secara keseluruhan, modul SDGs Desa sudah **production-ready** dengan beberapa improvements yang bisa dilakukan secara incremental. Module ini memiliki struktur yang mirip dengan modul lain (Profil, Desa Anti Korupsi) sehingga pattern improvement yang sama bisa diterapkan.
**Unique Issues:**
1. findManyAll unused code (tidak ada di modul lain)
2. Component name mismatch (EditKolaborasiInovasi)
3. Wrong label text ("Gambar Program Inovasi") - kemungkinan copy-paste dari modul Program Inovasi

View File

@@ -1,879 +0,0 @@
# QC Summary - Daftar Informasi Publik PPID Module
**Scope:** List Daftar Informasi Publik, Create, Edit, Detail
**Date:** 2026-02-23
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
---
## 📊 OVERVIEW
| Aspect | Schema | API | UI Admin | State Management | Overall |
|--------|--------|-----|----------|-----------------|---------|
| Daftar Informasi Publik | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 Perlu fix |
---
## ✅ YANG SUDAH BAIK
### **1. UI/UX Design**
- ✅ Preview layout yang clean dengan responsive design
- ✅ Loading states dengan Skeleton
- ✅ Empty state handling yang informatif dengan icon
- ✅ Search functionality dengan debounce (1000ms)
- ✅ Pagination yang konsisten
- ✅ Desktop table + mobile cards responsive
- ✅ Sticky table header untuk better UX
- ✅ Responsive button text ("Tambah" vs "Tambah Baru")
### **2. Table & Card Layout**
- ✅ Fixed column widths (25%, 40%, 20%)
- ✅ Sticky header table untuk long lists
- ✅ Striped rows untuk readability
- ✅ Highlight on hover
- ✅ HTML tag stripping untuk preview deskripsi
- ✅ Text truncation dengan lineClamp dan substring
- ✅ Mobile card view dengan proper information hierarchy
**Code Example (✅ GOOD):**
```typescript
// page.tsx - Line ~95-120
<Table
highlightOnHover
striped
stickyHeader // ✅ GOOD - Header tetap visible saat scroll
style={{ minWidth: '700px' }} // ✅ GOOD - Minimum width untuk readability
>
<TableThead>
<TableTr>
<TableTh w="25%">
<Text fw={600} lh={1.4}>Jenis Informasi</Text>
</TableTh>
<TableTh w="40%">
<Text fw={600} lh={1.4}>Deskripsi</Text>
</TableTh>
<TableTh ta="center" w="20%">
<Text fw={600} lh={1.4}>Aksi</Text>
</TableTh>
</TableTr>
</TableThead>
```
**Verdict:****BAIK** - Table layout dengan sticky header yang helpful!
---
### **3. State Management**
- ✅ Proper typing dengan Prisma types
- ✅ Loading state management dengan finally block
- ✅ Error handling yang comprehensive
-**ApiFetch consistency** untuk create & findMany! ✅
- ✅ Zod validation untuk form data
- ✅ Proper date formatting untuk update operation
**Code Example (✅ GOOD):**
```typescript
// state file - Line ~50-85
findMany: {
data: null as Prisma.DaftarInformasiPublikGetPayload<{ omit: { isActive: true } }>[] | null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
daftarInformasiPublik.findMany.loading = true; // ✅ Start loading
daftarInformasiPublik.findMany.page = page;
daftarInformasiPublik.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.ppid.daftarinformasipublik["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
daftarInformasiPublik.findMany.data = res.data.data ?? [];
daftarInformasiPublik.findMany.totalPages = res.data.totalPages ?? 1;
}
} catch (err) {
console.error("Gagal fetch daftar informasi publik:", err);
daftarInformasiPublik.findMany.data = [];
daftarInformasiPublik.findMany.totalPages = 1;
} finally {
daftarInformasiPublik.findMany.loading = false; // ✅ Stop loading
}
},
}
```
**Verdict:****BAIK** - State management sudah proper dengan ApiFetch!
---
### **4. Zod Schema Validation**
- ✅ Comprehensive validation untuk semua fields
- ✅ Specific error messages untuk setiap field
- ✅ Minimum character validation (3 characters)
**Code Example (✅ GOOD):**
```typescript
// state file - Line ~8-12
const templateDaftarInformasi = z.object({
jenisInformasi: z.string().min(3, "Jenis Informasi minimal 3 karakter"),
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
tanggal: z.string().min(3, "Tanggal minimal 3 karakter"),
});
```
**Verdict:****BAIK** - Validation yang proper!
---
### **5. Edit Form - Original Data Tracking**
- ✅ Original data state untuk reset form (via useState)
- ✅ Load data existing dengan benar
- ✅ Reset form mengembalikan ke data original
- ✅ Rich text content handling yang proper
- ✅ Date formatting untuk input type="date"
**Code Example (✅ GOOD):**
```typescript
// edit/page.tsx - Line ~30-60
const [formData, setFormData] = useState<FormDaftarInformasi>({
jenisInformasi: '',
deskripsi: '',
tanggal: '',
});
const formatDateForInput = (dateString: string) => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toISOString().split('T')[0]; // ✅ Format untuk input date
};
// Load data
useEffect(() => {
const loadDaftarInformasi = async () => {
const data = await daftarInformasi.edit.load(id);
if (data) {
setFormData({
jenisInformasi: data.jenisInformasi || '',
deskripsi: data.deskripsi || '',
tanggal: data.tanggal || '',
});
}
};
loadDaftarInformasi();
}, [params?.id]);
```
**Verdict:****BAIK** - Original data tracking sudah implementasi dengan baik!
---
### **6. Rich Text Editor**
- ✅ CreateEditor untuk create page
- ✅ EditEditor untuk edit page
- ✅ Reusable component pattern
- ✅ HTML content handling yang proper
---
## ⚠️ ISSUES & SARAN PERBAIKAN
### **🔴 CRITICAL**
#### **1. Schema - deletedAt Default Value SALAH**
**Lokasi:** `prisma/schema.prisma` (line 414)
**Masalah:**
```prisma
model DaftarInformasiPublik {
id String @id @default(cuid())
jenisInformasi String
deskripsi String
tanggal DateTime @db.Date
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) // ❌ SALAH - selalu punya default value
isActive Boolean @default(true)
}
```
**Dampak:**
- **LOGIC ERROR!** Setiap record baru langsung punya `deletedAt` value (timestamp creation)
- Soft delete tidak berfungsi dengan benar
- Query dengan `where: { deletedAt: null }` tidak akan pernah return data
- Data yang "dihapus" vs data "aktif" tidak bisa dibedakan
**Contoh Issue:**
```prisma
// Record baru dibuat
CREATE DaftarInformasiPublik {
jenisInformasi: "Informasi 1",
deskripsi: "Deskripsi 1",
tanggal: "2024-01-01",
// deletedAt otomatis ter-set ke now() ❌
// isActive: true ✅
}
// Query untuk data aktif (seharusnya return data ini)
prisma.daftarInformasiPublik.findMany({
where: { deletedAt: null, isActive: true }
})
// ❌ Return kosong! Karena deletedAt sudah ter-set
```
**Rekomendasi:** Fix schema:
```prisma
model DaftarInformasiPublik {
id String @id @default(cuid())
jenisInformasi String
deskripsi String
tanggal DateTime @db.Date
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
isActive Boolean @default(true)
}
```
**Priority:** 🔴 **CRITICAL**
**Effort:** Medium (perlu migration)
**Impact:** **HIGH** (data integrity & soft delete logic)
---
#### **2. State Management - Fetch Pattern Inconsistency**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/daftar_informasi_publik/daftarInformasiPublik.ts`
**Masalah:** Ada 2 pattern berbeda untuk fetch API:
```typescript
// ❌ Pattern 1: ApiFetch (create, findMany)
const res = await ApiFetch.api.ppid.daftarinformasipublik["create"].post(form);
const res = await ApiFetch.api.ppid.daftarinformasipublik["find-many"].get({ query });
// ❌ Pattern 2: fetch manual (findUnique, edit, delete)
const res = await fetch(`/api/ppid/daftarinformasipublik/${id}`);
const response = await fetch(`/api/ppid/daftarinformasipublik/del/${id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
});
```
**Dampak:**
- Code consistency buruk
- Sulit maintenance
- Type safety tidak konsisten
- Duplikasi logic error handling
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
```typescript
// ✅ Unified pattern
async load(id: string) {
try {
const res = await ApiFetch.api.ppid.daftarinformasipublik[id].get();
if (res.data?.success) {
const data = res.data.data;
this.id = data.id;
this.form = {
jenisInformasi: data.jenisInformasi,
deskripsi: data.deskripsi,
tanggal: data.tanggal,
};
return data;
} else {
throw new Error(res.data?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error:", error);
toast.error("Gagal memuat data");
return null;
}
}
async byId(id: string) {
try {
const res = await ApiFetch.api.ppid.daftarinformasipublik["del"][id].delete();
if (res.data?.success) {
toast.success(res.data.message || "Berhasil hapus");
await daftarInformasiPublik.findMany.load();
} else {
toast.error(res.data?.message || "Gagal hapus");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus");
}
}
```
**Priority:** 🔴 High
**Effort:** Medium (refactor di findUnique, edit, delete methods)
---
#### **3. Missing Loading State di Edit Button**
**Lokasi:** `edit/page.tsx`
**Masalah:**
```typescript
// Line ~130-145
<Button
onClick={handleSubmit}
disabled={!isFormValid()} // ⚠️ Missing loading check
radius="md"
size="md"
// ...
>
Simpan Perubahan
</Button>
```
**Issue:** Button tidak disabled saat submitting. User bisa click multiple times.
**Rekomendasi:** Add loading state:
```typescript
const [isSubmitting, setIsSubmitting] = useState(false);
// In handleSubmit
const handleSubmit = async () => {
setIsSubmitting(true);
try {
await daftarInformasi.edit.update();
router.push('/admin/ppid/daftar-informasi-publik');
} catch (error) {
// ...
} finally {
setIsSubmitting(false);
}
};
// In button
<Button
onClick={handleSubmit}
disabled={!isFormValid() || isSubmitting}
// ...
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan Perubahan'}
</Button>
```
**Priority:** 🔴 Medium
**Effort:** Low
---
### **🟡 MEDIUM**
#### **4. Console.log di Production**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/daftar_informasi_publik/daftarInformasiPublik.ts`
**Masalah:**
```typescript
// Line ~45
console.log((error as Error).message);
// Line ~80
console.error("Gagal fetch daftar informasi publik paginated:", err);
// Line ~100
console.error("Failed to fetch daftar informasi publik:", res.statusText);
// Line ~104
console.error("Error fetching daftar informasi publik:", error);
// Line ~180
console.error("Error loading daftar informasi publik:", error);
// Line ~230
console.error("Error updating daftar informasi publik:", error);
```
**Rekomendasi:** Gunakan conditional logging:
```typescript
if (process.env.NODE_ENV === 'development') {
console.error("Error:", error);
}
```
**Priority:** 🟡 Low
**Effort:** Low
---
#### **5. Type Safety - Any Usage**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/daftar_informasi_publik/daftarInformasiPublik.ts`
**Masalah:**
```typescript
// Line ~70
const query: any = { page, limit }; // ❌ Using 'any'
if (search) query.search = search;
```
**Rekomendasi:** Gunakan typed query:
```typescript
// Define type
interface FindManyQuery {
page: number | string;
limit?: number | string;
search?: string;
}
// Use typed query
const query: FindManyQuery = { page, limit };
if (search) query.search = search;
```
**Priority:** 🟡 Medium
**Effort:** Low
---
#### **6. Alert() Instead of Toast**
**Lokasi:** `create/page.tsx`
**Masalah:**
```typescript
// Line ~30-40
const handleSubmit = async () => {
if (!daftarInformasi.create.form.jenisInformasi) {
return alert('Mohon isi jenis informasi'); // ❌ Using alert()
}
if (!daftarInformasi.create.form.deskripsi) {
return alert('Mohon isi deskripsi'); // ❌ Using alert()
}
if (!daftarInformasi.create.form.tanggal) {
return alert('Mohon pilih tanggal publikasi'); // ❌ Using alert()
}
try {
await daftarInformasi.create.create();
// ...
} catch (error) {
console.error('Error creating informasi publik:', error);
alert('Terjadi kesalahan saat menyimpan data'); // ❌ Using alert()
}
};
```
**Rekomendasi:** Gunakan toast untuk consistency:
```typescript
if (!daftarInformasi.create.form.jenisInformasi) {
return toast.warn('Mohon isi jenis informasi'); // ✅ Using toast
}
// ...
```
**Priority:** 🟡 Medium
**Effort:** Low
---
#### **7. Missing Reset Form Function**
**Lokasi:** `create/page.tsx`
**Masalah:**
```typescript
// Line ~20-25
const resetForm = () => {
daftarInformasi.create.form = {
jenisInformasi: "",
deskripsi: "",
tanggal: "",
};
};
// resetForm dipanggil di handleSubmit tapi tidak ada di form inputs
// Form inputs langsung update state tanpa reset setelah submit
```
**Issue:** Form tidak reset setelah successful submit.
**Rekomendasi:** Ensure reset is called:
```typescript
const handleSubmit = async () => {
// ... validation
try {
await daftarInformasi.create.create();
resetForm(); // ✅ Make sure this is called
router.push("/admin/ppid/daftar-informasi-publik");
} catch (error) {
// ...
}
};
```
**Verdict:****SUDAH BENAR** - resetForm() sudah dipanggil di handleSubmit!
**Priority:** 🟢 None
**Effort:** None
---
### **🟢 LOW (Minor Polish)**
#### **8. Pagination onChange Tidak Include Search**
**Lokasi:** `page.tsx`
**Masalah:**
```typescript
// Line ~190-200
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10); // ⚠️ Missing search parameter
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
// ...
/>
```
**Issue:** Saat ganti page, search query hilang.
**Rekomendasi:** Include search:
```typescript
onChange={(newPage) => {
load(newPage, 10, debouncedSearch); // ✅ Include search
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **9. Duplicate Error Logging**
**Lokasi:** Multiple files
**Masalah:**
```typescript
// edit/page.tsx - Line ~60
} catch (error) {
console.error('Error loading daftar informasi:', error); // ❌ Duplicate
toast.error('Gagal memuat data daftar informasi');
}
// edit/page.tsx - Line ~80
} catch (error) {
console.error('Error updating berita:', error); // ❌ Duplicate + wrong module name
toast.error('Terjadi kesalahan saat memperbarui berita'); // ❌ Wrong module name
}
```
**Issue:** Copy-paste error dari module "berita"!
**Rekomendasi:** Fix error messages:
```typescript
} catch (error) {
console.error('Failed to load Daftar Informasi Publik:', err);
toast.error('Gagal memuat data Daftar Informasi Publik');
}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **10. Missing Loading State di Detail Page**
**Lokasi:** `[id]/page.tsx`
**Masalah:**
```typescript
// Line ~20-25
useShallowEffect(() => {
stateDaftarInformasi.findUnique.load(params?.id as string)
}, [params?.id])
if (!stateDaftarInformasi.findUnique.data) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
)
}
```
**Issue:** Skeleton ditampilkan untuk semua kondisi (loading, error, not found).
**Rekomendasi:** Add proper loading state:
```typescript
if (stateDaftarInformasi.findUnique.loading) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
if (!stateDaftarInformasi.findUnique.data) {
return (
<Alert icon={<IconAlertCircle />} color="red">
Data tidak ditemukan
</Alert>
);
}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **11. Search Placeholder Tidak Spesifik**
**Lokasi:** `page.tsx`
**Masalah:**
```typescript
// Line ~30-35
<HeaderSearch
title='Daftar Informasi Publik'
placeholder='Cari jenis informasi atau deskripsi...' // ✅ Actually pretty specific!
// ...
/>
```
**Verdict:****SUDAH BENAR** - Placeholder sudah spesifik!
**Priority:** 🟢 None
**Effort:** None
---
#### **12. Empty State Icon Consistency**
**Lokasi:** `page.tsx`
**Masalah:**
```typescript
// Line ~85-95
<Stack align="center" py="xl">
<IconDeviceImacCog size={40} stroke={1.5} color={colors['blue-button']} />
<Text fz={{ base: 'sm', md: 'md' }} fw={500} c="dimmed" lh={1.5}>
Belum ada informasi publik yang tersedia
</Text>
</Stack>
```
**Verdict:****SUDAH BENAR** - Empty state dengan icon yang proper!
**Priority:** 🟢 None
**Effort:** None
---
#### **13. HTML Tag Stripping for Preview**
**Lokasi:** `page.tsx`
**Masalah:**
```typescript
// Line ~125-130
<Text fz="sm" lh={1.5} c="dimmed" lineClamp={1}>
{item.deskripsi?.replace(/<[^>]*>?/gm, '').substring(0, 80)}...
</Text>
```
**Verdict:****SUDAH BENAR** - HTML tag stripping yang proper untuk preview!
**Priority:** 🟢 None
**Effort:** None
---
## 📋 RINGKASAN ACTION ITEMS
| Priority | Issue | Module | Impact | Effort | Status |
|----------|-------|--------|--------|--------|--------|
| 🔴 P0 | **Schema deletedAt default SALAH** | Schema | **CRITICAL** | Medium | **MUST FIX** |
| 🔴 P0 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
| 🔴 P1 | Missing loading state di edit button | UI | Medium | Low | Should fix |
| 🟡 M | Console.log in production | State | Low | Low | Optional |
| 🟡 M | Type safety (any usage) | State | Low | Low | Optional |
| 🟡 M | Alert() instead of toast | Create UI | Low | Low | Should fix |
| 🟡 M | Copy-paste error messages (berita) | Edit UI | Low | Low | Should fix |
| 🟢 L | Pagination missing search param | UI | Low | Low | Optional |
| 🟢 L | Missing loading state di detail page | UI | Low | Low | Optional |
| 🟢 L | Duplicate error logging | UI/State | Low | Low | Optional |
---
## ✅ KESIMPULAN
### **Overall Quality: 🟢 BAIK (8/10)**
**Strengths:**
1. ✅ UI/UX clean & responsive
2.**Sticky header table** - Better UX untuk long lists
3.**HTML tag stripping** untuk preview deskripsi
4. ✅ Search functionality dengan debounce
5. ✅ Empty state handling yang informatif
6.**Zod validation** comprehensive
7. ✅ State management dengan ApiFetch untuk create & findMany
8. ✅ Loading state management dengan finally block
9. ✅ Mobile cards responsive
10.**Responsive button text** ("Tambah" vs "Tambah Baru")
11. ✅ Edit form dengan original data tracking
12. ✅ Date formatting untuk input type="date"
**Critical Issues:**
1. ⚠️ **Schema deletedAt default SALAH** - Logic error untuk soft delete (CRITICAL)
2. ⚠️ Fetch method pattern inconsistency (ApiFetch vs fetch manual)
3. ⚠️ Missing loading state di edit button
**Areas for Improvement:**
1. ⚠️ **Fix schema deletedAt** dari `@default(now())` ke `@default(null)` dengan nullable
2. ⚠️ **Refactor fetch methods** untuk gunakan ApiFetch consistently
3. ⚠️ **Add loading state** di edit button
4. ⚠️ **Fix alert()** ke toast
5. ⚠️ **Fix copy-paste error messages** dari module "berita"
**Recommended Next Steps:**
1. **🔴 CRITICAL: Fix schema deletedAt** - 30 menit (perlu migration)
2. **🔴 HIGH: Refactor findUnique, edit, delete** ke ApiFetch - 1 jam
3. **🔴 HIGH: Add loading state** di edit button - 15 menit
4. **🟡 MEDIUM: Fix alert()** ke toast - 15 menit
5. **🟡 MEDIUM: Fix copy-paste error messages** - 10 menit
6. **🟢 LOW: Add pagination search param** - 10 menit
7. **🟢 LOW: Polish minor issues** - 30 menit
---
## 📈 COMPARISON WITH OTHER MODULES
| Module | Fetch Pattern | State | Validation | Schema | Loading State | Overall |
|--------|--------------|-------|------------|--------|---------------|---------|
| Profil | ⚠️ Mixed | ⚠️ Good | ✅ Good | ⚠️ deletedAt | ⚠️ Some missing | 🟢 |
| Desa Anti Korupsi | ⚠️ Mixed | ⚠️ Good | ✅ Good | ⚠️ deletedAt | ⚠️ Some missing | 🟢 |
| SDGs Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ⚠️ deletedAt | ⚠️ Missing | 🟢 |
| APBDes | ⚠️ Mixed | ⚠️ Good | ✅ Good | ✅ Good | ✅ Good | 🟢 |
| Prestasi Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ WRONG | ⚠️ Some missing | 🟢 |
| PPID Profil | ⚠️ Mixed | ✅ **Best** | ✅ Good | ❌ WRONG | ✅ Good | 🟢⭐ |
| Struktur PPID | ⚠️ Mixed | ✅ Good | ✅ Good | ⚠️ Inconsistent | ✅ Good | 🟢 |
| Visi Misi PPID | ✅ **100% ApiFetch!** | ✅ Best | ✅ Good | ❌ WRONG | ✅ Good | 🟢⭐⭐ |
| Dasar Hukum PPID | ✅ **100% ApiFetch!** | ✅ Best | ✅ Good | ❌ WRONG | ✅ Good | 🟢⭐⭐ |
| Permohonan Informasi | ⚠️ Mixed | ⚠️ Good | ✅ **Best** | ❌ **4 models WRONG** | ✅ Good | 🟡 |
| **Daftar Informasi** | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ WRONG | ⚠️ Some missing | 🟢 |
**Daftar Informasi PPID Highlights:**
-**Sticky header table** - Unique feature untuk better UX
-**HTML tag stripping** untuk preview - Good practice
-**Responsive button text** - Attention to detail
- ⚠️ **Same deletedAt issue** seperti modul PPID lain
- ⚠️ **Copy-paste errors** dari module "berita"
---
## 🎯 UNIQUE FEATURES OF DAFTAR INFORMASI MODULE
**Best Table Implementation:**
1.**Sticky header table** - Unique feature!
2.**HTML tag stripping** untuk preview deskripsi
3.**Responsive button text** - "Tambah" vs "Tambah Baru"
4.**Fixed column widths** - 25%, 40%, 20%
5.**Minimum table width** - 700px untuk readability
**Best Practices:**
1.**Sticky header** - Best practice untuk long lists
2.**HTML stripping** - Good practice untuk rich text preview
3.**Loading state management** - Proper dengan finally block
4.**Original data tracking** - Edit form reset yang proper
5.**Date formatting** - Proper untuk input type="date"
**Critical Issues:**
1.**Schema deletedAt SALAH** - Same issue seperti modul PPID lain
2.**Fetch pattern inconsistency** - findUnique, edit, delete pakai fetch manual
3.**Copy-paste error messages** - Dari module "berita"
---
**Catatan:** **Daftar Informasi PPID adalah MODULE DENGAN TABLE IMPLEMENTATION TERBAIK** dengan sticky header dan HTML tag stripping untuk preview. Module ini juga punya attention to detail dengan responsive button text.
**Unique Strengths:**
1.**Sticky header table** - Best table UX
2.**HTML tag stripping** - Best practice untuk preview
3.**Responsive button text** - Attention to detail
4.**Fixed column widths** - Consistent layout
5.**Date formatting** - Proper handling
**Priority Action:**
```diff
🔴 FIX INI SEKARANG (30 MENIT + MIGRATION):
File: prisma/schema.prisma
Line: 414
model DaftarInformasiPublik {
id String @id @default(cuid())
jenisInformasi String
deskripsi String
tanggal DateTime @db.Date
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- deletedAt DateTime @default(now())
+ deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
# Lalu jalankan:
bunx prisma db push
# atau
bunx prisma migrate dev --name fix_deletedat_daftar_informasi
```
```diff
🔴 FIX COPY-PASTE ERRORS (10 MENIT):
File: edit/page.tsx
// Line ~80
- console.error('Error updating berita:', error);
+ console.error('Error updating daftar informasi:', error);
- toast.error('Terjadi kesalahan saat memperbarui berita');
+ toast.error('Terjadi kesalahan saat memperbarui daftar informasi');
```
Setelah fix critical issues, module ini **PRODUCTION-READY** dengan **BEST TABLE IMPLEMENTATION**! 🎉
---
## 📚 RECOMMENDED AS REFERENCE FOR OTHER MODULES
**Daftar Informasi PPID Module adalah BEST PRACTICE untuk:**
1.**Sticky header table** - Best practice untuk long lists
2.**HTML tag stripping** - Good practice untuk rich text preview
3.**Responsive button text** - Attention to detail
4.**Fixed column widths** - Consistent layout
5.**Date formatting** - Proper handling untuk date inputs
**Modules lain bisa belajar dari Daftar Informasi:**
- **ALL MODULES WITH TABLES:** Use sticky header untuk better UX
- **ALL MODULES WITH RICH TEXT:** Strip HTML tags untuk preview
- **ALL MODULES:** Responsive text untuk buttons
- **ALL MODULES:** Fixed column widths untuk consistency
- **ALL MODULES:** Proper date formatting untuk date inputs
---
**File Location:** `QC/PPID/QC-DAFTAR-INFORMASI-PUBLIK-MODULE.md` 📄

View File

@@ -1,821 +0,0 @@
# QC Summary - Dasar Hukum PPID Module
**Scope:** Preview Dasar Hukum, Edit Dasar Hukum dengan Rich Text Editor
**Date:** 2026-02-23
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
---
## 📊 OVERVIEW
| Aspect | Schema | API | UI Admin | State Management | Overall |
|--------|--------|-----|----------|-----------------|---------|
| Dasar Hukum PPID | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ✅ Baik | 🟡 Perlu fix |
---
## ✅ YANG SUDAH BAIK
### **1. UI/UX Design**
- ✅ Preview layout yang clean dengan logo desa
- ✅ Responsive design (mobile & desktop)
- ✅ Loading states dengan Skeleton
- ✅ Empty state handling yang informatif
- ✅ Edit button yang prominent
- ✅ Divider visual yang jelas antara Judul dan Content
### **2. Rich Text Editor (Tiptap)**
- ✅ Full-featured editor dengan toolbar lengkap (reuse dari PPIDTextEditor)
- ✅ Extensions: Bold, Italic, Underline, Highlight, Link, dll
- ✅ Text alignment (left, center, justify, right)
- ✅ Heading levels (H1-H4)
- ✅ Lists (bullet & ordered)
- ✅ Blockquote, code, superscript, subscript
- ✅ Undo/Redo
- ✅ Sticky toolbar untuk UX yang lebih baik
-**Dynamic import dengan `ssr: false`** untuk menghindari hydration issues! ✅
### **3. Form Component Structure**
- ✅ Reusable PPIDTextEditor component (shared dengan Visi Misi)
- ✅ Proper TypeScript typing
- ✅ Controlled components dengan onChange handler
- ✅ SSR handling yang proper dengan dynamic import
**Code Example (✅ EXCELLENT):**
```typescript
// edit/page.tsx - Line ~13-17
const PPIDTextEditor = dynamic(
() => import('../../_com/PPIDTextEditor').then(mod => mod.PPIDTextEditor),
{ ssr: false } // ✅ Disable SSR untuk avoid hydration mismatch
);
```
**Verdict:****EXCELLENT** - Proper SSR handling!
---
### **4. State Management**
- ✅ Proper typing dengan Prisma types
- ✅ Loading state management dengan finally block
- ✅ Error handling yang comprehensive
-**ApiFetch consistency** - Semua operasi pakai ApiFetch! ✅
- ✅ Zod validation untuk form data
**Code Example (✅ EXCELLENT):**
```typescript
// state file - Line ~20-45
findById: {
data: null as DasarHukumForm | null,
loading: false,
initialize() {
stateDasarHukumPPID.findById.data = {
id: '',
judul: '',
content: '',
} as DasarHukumForm;
},
async load(id: string) {
try {
stateDasarHukumPPID.findById.loading = true; // ✅ Start loading
const res = await ApiFetch.api.ppid.dasarhukumppid["find-by-id"].get({
query: { id },
});
if (res.status === 200) {
stateDasarHukumPPID.findById.data = res.data?.data ?? null;
}
} catch (error) {
console.error((error as Error).message);
toast.error("Terjadi kesalahan saat mengambil data dasar hukum");
} finally {
stateDasarHukumPPID.findById.loading = false; // ✅ Stop loading
}
},
}
```
**Verdict:****SANGAT BAIK** - State management sudah konsisten dengan ApiFetch!
---
### **5. Edit Form - Original Data Tracking**
- ✅ Original data state untuk reset form
- ✅ Load data existing dengan benar
- ✅ Reset form mengembalikan ke data original
- ✅ Rich text content handling yang proper
**Code Example (✅ GOOD):**
```typescript
// edit/page.tsx - Line ~20-45
const [formData, setFormData] = useState({ judul: '', content: '' });
const [originalData, setOriginalData] = useState({
judul: '',
content: '',
});
// Initialize from global state
useEffect(() => {
if (dasarHukumState.findById.data) {
setFormData({
judul: dasarHukumState.findById.data.judul ?? '',
content: dasarHukumState.findById.data.content ?? '',
});
setOriginalData({
judul: dasarHukumState.findById.data.judul ?? '',
content: dasarHukumState.findById.data.content ?? '',
});
}
}, [dasarHukumState.findById.data]);
// Line ~65 - Handle reset
const handleResetForm = () => {
setFormData({
judul: originalData.judul,
content: originalData.content,
});
toast.info("Form dikembalikan ke data awal");
};
```
**Verdict:****BAIK** - Original data tracking sudah implementasi dengan baik!
---
### **6. Rich Text Validation**
- ✅ Custom validation function untuk rich text content
- ✅ Check empty content setelah remove HTML tags
- ✅ Validation untuk kedua fields (judul & content)
**Code Example (✅ GOOD):**
```typescript
// edit/page.tsx - Line ~25-35
const isRichTextEmpty = (content: string) => {
// Remove HTML tags and check if the resulting text is empty
const plainText = content.replace(/<[^>]*>/g, '').trim();
return plainText === '' || content.trim() === '<p></p>' || content.trim() === '<p><br></p>';
};
const isFormValid = () => {
return (
!isRichTextEmpty(formData.judul) &&
!isRichTextEmpty(formData.content)
);
};
```
**Verdict:****EXCELLENT** - Rich text validation yang comprehensive!
---
## ⚠️ ISSUES & SARAN PERBAIKAN
### **🔴 CRITICAL**
#### **1. Schema - deletedAt Default Value SALAH**
**Lokasi:** `prisma/schema.prisma` (line 385)
**Masalah:**
```prisma
model DasarHukumPPID {
id String @id @default(cuid())
judul String @db.Text
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) // ❌ SALAH - selalu punya default value
isActive Boolean @default(true)
}
```
**Dampak:**
- **LOGIC ERROR!** Setiap record baru langsung punya `deletedAt` value (timestamp creation)
- Soft delete tidak berfungsi dengan benar
- Query dengan `where: { deletedAt: null }` tidak akan pernah return data
- Data yang "dihapus" vs data "aktif" tidak bisa dibedakan
**Contoh Issue:**
```prisma
// Record baru dibuat
CREATE DasarHukumPPID {
judul: "Judul 1",
content: "Content 1",
// deletedAt otomatis ter-set ke now() ❌
// isActive: true ✅
}
// Query untuk data aktif (seharusnya return data ini)
prisma.dasarHukumPPID.findMany({
where: { deletedAt: null, isActive: true }
})
// ❌ Return kosong! Karena deletedAt sudah ter-set
```
**Rekomendasi:** Fix schema:
```prisma
model DasarHukumPPID {
id String @id @default(cuid())
judul String @db.Text
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
isActive Boolean @default(true)
}
```
**Priority:** 🔴 **CRITICAL**
**Effort:** Medium (perlu migration)
**Impact:** **HIGH** (data integrity & soft delete logic)
---
#### **2. HTML Injection Risk - dangerouslySetInnerHTML**
**Lokasi:** `page.tsx` (preview page)
**Masalah:**
```typescript
// Line ~65-75
<Title
order={3}
ta="center"
lh={{ base: 1.15, md: 1.1 }}
fw="bold"
c={colors['blue-button']}
dangerouslySetInnerHTML={{ __html: listDasarHukum.findById.data.judul }} // ❌ No sanitization
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
/>
// Line ~80-90 (Content)
<Text
dangerouslySetInnerHTML={{ __html: listDasarHukum.findById.data.content }} // ❌ No sanitization
style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
fontSize: '1rem',
lineHeight: 1.55,
textAlign: 'justify',
}}
/>
```
**Risk:**
- XSS attack jika admin input script malicious
- Bisa inject iframe, script tag, dll
- Security vulnerability
**Rekomendasi:** Gunakan DOMPurify atau library sanitization:
```typescript
import DOMPurify from 'dompurify';
// Sanitize sebelum render
const sanitizedJudul = DOMPurify.sanitize(listDasarHukum.findById.data.judul);
const sanitizedContent = DOMPurify.sanitize(listDasarHukum.findById.data.content);
<Title
dangerouslySetInnerHTML={{ __html: sanitizedJudul }}
// ...
/>
<Text
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
// ...
/>
```
Atau validasi di backend untuk whitelist tag HTML yang diperbolehkan (hanya `<p>`, `<ul>`, `<li>`, `<strong>`, dll).
**Priority:** 🔴 **HIGH** (**Security concern**)
**Effort:** Low
---
#### **3. Missing Delete/Hard Delete Protection**
**Lokasi:** `page.tsx`, `edit/page.tsx`
**Masalah:**
- ❌ Tidak ada tombol delete untuk Dasar Hukum (correct - single record)
-**GOOD:** Single record pattern yang benar
- ⚠️ **ISSUE:** Tidak ada konfirmasi sebelum update (direct save)
**Issue:** User bisa accidentally save changes tanpa konfirmasi.
**Rekomendasi:** Add confirmation dialog sebelum save:
```typescript
const handleSubmit = () => {
// Check if data has changed
if (formData.judul === originalData.judul && formData.content === originalData.content) {
toast.info('Tidak ada perubahan');
return;
}
// Show confirmation
const confirmed = window.confirm('Apakah Anda yakin ingin mengubah Dasar Hukum PPID?');
if (!confirmed) return;
// Then save...
};
```
**Priority:** 🔴 Medium
**Effort:** Low
---
### **🟡 MEDIUM**
#### **4. Console.log di Production**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/dasar_hukum/dasarHukum.ts`
**Masalah:**
```typescript
// Line ~40
console.error((error as Error).message);
// Line ~65
console.error((error as Error).message);
```
**Rekomendasi:** Gunakan conditional logging:
```typescript
if (process.env.NODE_ENV === 'development') {
console.error("Error:", error);
}
```
**Priority:** 🟡 Low
**Effort:** Low
---
#### **5. Missing Loading State di Submit Button**
**Lokasi:** `edit/page.tsx`
**Masalah:**
```typescript
// Line ~130-140
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
// ...
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
```
**Issue:** Button tidak check `dasarHukumState.update.loading` dari global state.
**Rekomendasi:** Check both states:
```typescript
disabled={!isFormValid() || isSubmitting || dasarHukumState.update.loading}
{isSubmitting || dasarHukumState.update.loading ? (
<Loader size="sm" color="white" />
) : (
'Simpan'
)}
```
**Priority:** 🟡 Low
**Effort:** Low
---
#### **6. Zod Schema - Could Be More Specific**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/dasar_hukum/dasarHukum.ts`
**Masalah:**
```typescript
// Line ~7
const templateForm = z.object({
judul: z.string().min(3, "Judul minimal 3 karakter"), // ⚠️ Generic
content: z.string().min(3, "Content minimal 3 karakter"), // ⚠️ Generic
});
```
**Rekomendasi:** More specific error messages:
```typescript
const templateForm = z.object({
judul: z.string().min(3, "Judul dasar hukum minimal 3 karakter"),
content: z.string().min(3, "Konten dasar hukum minimal 3 karakter"),
});
```
**Priority:** 🟡 Low
**Effort:** Low
---
### **🟢 LOW (Minor Polish)**
#### **7. Missing Change Detection**
**Lokasi:** `edit/page.tsx`
**Masalah:**
```typescript
// Line ~75-85
const handleSubmit = () => {
try {
setIsSubmitting(true);
if (dasarHukumState.findById.data) {
// Update global state hanya saat submit
const updated = { ...dasarHukumState.findById.data, ...formData };
dasarHukumState.update.save(updated);
}
router.push('/admin/ppid/dasar-hukum');
} catch (error) {
console.error("Error updating dasar hukum:", error);
toast.error("Terjadi kesalahan saat memperbarui dasar hukum");
} finally {
setIsSubmitting(false);
}
};
```
**Issue:** Tidak ada check apakah data sudah berubah. User bisa save tanpa perubahan.
**Rekomendasi:** Add change detection:
```typescript
const handleSubmit = () => {
// Check if data has changed
if (formData.judul === originalData.judul && formData.content === originalData.content) {
toast.info('Tidak ada perubahan');
return;
}
try {
setIsSubmitting(true);
// ... rest of save logic
}
};
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **8. Editor - Duplicate useEffect**
**Lokasi:** `PPIDTextEditor.tsx` (shared component)
**Masalah:**
```typescript
// Line ~30-35 (di PPIDTextEditor.tsx)
const editor = useEditor({
extensions: [...],
immediatelyRender: false,
content: initialContent, // ✅ Set content directly
onUpdate: ({editor}) => {
onChange(editor.getHTML()) // ✅ Handle changes
}
});
// Line ~37-42
useEffect(() => {
if (editor && initialContent !== editor.getHTML()) {
editor.commands.setContent(initialContent || '<p></p>');
}
}, [initialContent, editor]);
```
**Issue:** Ada useEffect tambahan untuk set content, padahal sudah ada di `useEditor`. Bisa menyebabkan double content update.
**Rekomendasi:** Simplify - remove useEffect:
```typescript
const editor = useEditor({
extensions: [...],
immediatelyRender: false,
content: initialContent || '<p></p>', // ✅ Set content directly
onUpdate: ({editor}) => {
onChange(editor.getHTML())
},
});
// Remove useEffect completely
```
**Priority:** 🟢 Low
**Effort:** Low (perlu update shared component)
---
#### **9. Missing Error Boundary**
**Lokasi:** `edit/page.tsx`
**Masalah:**
- Tidak ada error boundary untuk handle unexpected errors
- Jika editor gagal load, tidak ada fallback UI
**Rekomendasi:** Add error boundary:
```typescript
if (dasarHukumState.findById.error) {
return (
<Alert icon={<IconAlertCircle />} color="red">
<Text fw="bold">Error</Text>
<Text>{dasarHukumState.findById.error}</Text>
</Alert>
);
}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **10. Preview Page - Title Order Inconsistency**
**Lokasi:** `page.tsx`
**Masalah:**
```typescript
// Line ~40
<Title order={3} ...>Preview Dasar Hukum PPID</Title>
// Line ~65
<Title order={3} ... dangerouslySetInnerHTML={{ __html: listDasarHukum.findById.data.judul }} />
```
**Issue:** Title hierarchy agak confusing. Page title dan content title sama-sama order 3.
**Rekomendasi:** Samakan hierarchy:
```typescript
// Page title: order={2}
// Content title (judul): order={3}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **11. Missing Toast Success After Save**
**Lokasi:** `edit/page.tsx`
**Masalah:**
```typescript
// Line ~75-90
const handleSubmit = () => {
try {
setIsSubmitting(true);
if (dasarHukumState.findById.data) {
const updated = { ...dasarHukumState.findById.data, ...formData };
dasarHukumState.update.save(updated);
}
router.push('/admin/ppid/dasar-hukum'); // ✅ Redirect tanpa toast
} catch (error) {
console.error("Error updating dasar hukum:", error);
toast.error("Terjadi kesalahan saat memperbarui dasar hukum");
} finally {
setIsSubmitting(false);
}
};
```
**Issue:** Toast success ada di state `update.save()`, tapi user mungkin tidak lihat karena langsung redirect.
**Rekomendasi:** Add toast before redirect atau wait untuk toast selesai:
```typescript
const handleSubmit = async () => {
try {
setIsSubmitting(true);
if (dasarHukumState.findById.data) {
const updated = { ...dasarHukumState.findById.data, ...formData };
await dasarHukumState.update.save(updated);
toast.success("Dasar Hukum berhasil diperbarui!");
setTimeout(() => {
router.push('/admin/ppid/dasar-hukum');
}, 1000); // Wait 1 second for toast to show
}
} catch (error) {
console.error("Error updating dasar hukum:", error);
toast.error("Terjadi kesalahan saat memperbarui dasar hukum");
} finally {
setIsSubmitting(false);
}
};
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **12. SSR Dynamic Import - Good but Could Add Loading**
**Lokasi:** `edit/page.tsx`
**Masalah:**
```typescript
// Line ~13-17
const PPIDTextEditor = dynamic(
() => import('../../_com/PPIDTextEditor').then(mod => mod.PPIDTextEditor),
{ ssr: false } // ✅ Good
);
```
**Issue:** Tidak ada loading state untuk dynamic import. Jika editor lambat load, user lihat kosong.
**Rekomendasi:** Add loading option:
```typescript
const PPIDTextEditor = dynamic(
() => import('../../_com/PPIDTextEditor').then(mod => mod.PPIDTextEditor),
{
ssr: false,
loading: () => (
<Center py={40}>
<Loader size="sm" />
<Text ml="md">Loading editor...</Text>
</Center>
)
}
);
```
**Priority:** 🟢 Low
**Effort:** Low
---
## 📋 RINGKASAN ACTION ITEMS
| Priority | Issue | Module | Impact | Effort | Status |
|----------|-------|--------|--------|--------|--------|
| 🔴 P0 | **Schema deletedAt default SALAH** | Schema | **CRITICAL** | Medium | **MUST FIX** |
| 🔴 P0 | **HTML injection risk** | UI | **HIGH (Security)** | Low | **Should fix** |
| 🔴 P1 | Missing delete confirmation | UI | Medium | Low | Should fix |
| 🟡 M | Console.log in production | State | Low | Low | Optional |
| 🟡 M | Missing loading state di submit button | UI | Low | Low | Should fix |
| 🟡 M | Zod schema error messages | State | Low | Low | Optional |
| 🟢 L | Missing change detection | Edit UI | Low | Low | Optional |
| 🟢 L | Duplicate useEffect di editor | Editor | Low | Low | Optional |
| 🟢 L | Missing error boundary | UI | Low | Low | Optional |
| 🟢 L | Title order inconsistency | UI | Low | Low | Optional |
| 🟢 L | Missing toast success timing | UI | Low | Low | Optional |
| 🟢 L | SSR loading state | UI | Low | Low | Optional |
---
## ✅ KESIMPULAN
### **Overall Quality: 🟢 BAIK (8.5/10) - CLEAN & SIMPLE!**
**Strengths:**
1. ✅ UI/UX clean & responsive
2.**Rich Text Editor** full-featured (Tiptap, shared component)
3.**Dynamic import dengan `ssr: false`** - Proper SSR handling! ✅
4.**State management BEST PRACTICES** - **100% ApiFetch!**
5.**Edit form reset sudah benar** (original data tracking)
6.**Rich text validation** comprehensive (check empty content)
7. ✅ Error handling comprehensive
8. ✅ Loading state management dengan finally block
9.**Reusable component** (PPIDTextEditor shared dengan Visi Misi)
**Critical Issues:**
1. ⚠️ **Schema deletedAt default SALAH** - Logic error untuk soft delete (CRITICAL)
2. ⚠️ **HTML injection risk** - dangerouslySetInnerHTML tanpa sanitization (HIGH Security)
3. ⚠️ Missing confirmation sebelum save (Medium UX)
**Areas for Improvement:**
1. ⚠️ **Fix schema deletedAt** dari `@default(now())` ke `@default(null)` dengan nullable
2. ⚠️ **Fix HTML injection** dengan DOMPurify atau backend validation
3. ⚠️ **Add confirmation dialog** sebelum save
4. ⚠️ **Add change detection** untuk avoid unnecessary saves
5. ⚠️ **Fix loading state** di submit button
**Recommended Next Steps:**
1. **🔴 CRITICAL: Fix schema deletedAt** - 30 menit (perlu migration)
2. **🔴 HIGH: Fix HTML injection** dengan DOMPurify - 30 menit
3. **🟡 MEDIUM: Add confirmation dialog** - 15 menit
4. **🟢 LOW: Add change detection** - 15 menit
5. **🟢 LOW: Add SSR loading state** - 10 menit
6. **🟢 LOW: Polish minor issues** - 30 menit
---
## 📈 COMPARISON WITH OTHER MODULES
| Module | Fetch Pattern | State | Edit Reset | Rich Text | SSR Handling | HTML Injection | deletedAt | Overall |
|--------|--------------|-------|------------|-----------|--------------|----------------|-----------|---------|
| Profil | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ None | N/A | ⚠️ Present | ⚠️ Issue | 🟢 |
| Desa Anti Korupsi | ⚠️ Mixed | ⚠️ Good | ✅ Good | ✅ Present | ⚠️ None | ⚠️ Present | ⚠️ Issue | 🟢 |
| SDGs Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ None | N/A | N/A | ⚠️ Issue | 🟢 |
| APBDes | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ None | N/A | N/A | ✅ Good | 🟢 |
| Prestasi Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ✅ Present | ⚠️ None | ⚠️ Present | ❌ WRONG | 🟢 |
| PPID Profil | ⚠️ Mixed | ✅ **Best** | ✅ **Excellent** | ✅ **Best** | ⚠️ None | ⚠️ Present | ❌ WRONG | 🟢⭐ |
| Struktur PPID | ⚠️ Mixed | ✅ Good | ✅ Good | ✅ Present | ⚠️ None | ⚠️ Present | ⚠️ Inconsistent | 🟢 |
| Visi Misi PPID | ✅ **100% ApiFetch!** | ✅ **Best** | ✅ Good | ✅ Present | ⚠️ None | ⚠️ Present | ❌ WRONG | 🟢⭐⭐ |
| **Dasar Hukum PPID** | ✅ **100% ApiFetch!** | ✅ **Best** | ✅ Good | ✅ Present | ✅ **EXCELLENT** | ⚠️ Present | ❌ WRONG | 🟢⭐⭐ |
**Dasar Hukum PPID Highlights:**
-**100% ApiFetch** - NO fetch manual sama sekali!
-**SSR Handling** - Dynamic import dengan `ssr: false` (UNIQUE!)
-**Reusable component** - Share PPIDTextEditor dengan Visi Misi
-**Simple & clean** - No unnecessary complexity
- ⚠️ **Same deletedAt issue** seperti modul PPID lain
---
## 🎯 UNIQUE FEATURES OF DASAR HUKUM PPID MODULE
**Simplest & Cleanest Module:**
1.**100% ApiFetch consistency** - NO fetch manual sama sekali!
2.**SSR Handling** - Dynamic import dengan `ssr: false` (UNIQUE!)
3.**Reusable component** - Share PPIDTextEditor dengan Visi Misi
4.**Simple single record pattern** - Only 2 fields (judul, content)
5.**Rich text validation** - Check empty content
**Best Practices:**
1.**API consistency** - 100% ApiFetch
2.**SSR handling** - Best practice untuk Next.js
3.**Loading state management** proper (dengan finally block)
4.**Rich text validation** comprehensive
5.**Original data tracking** untuk reset form
6.**Component reusability** - Share editor component
**Critical Issues:**
1.**Schema deletedAt SALAH** - Same issue seperti modul PPID lain
2.**HTML injection risk** - Same issue seperti modul dengan rich text lain
---
**Catatan:** **Dasar Hukum PPID adalah MODULE PALING CLEAN** bersama Visi Misi PPID dengan codebase paling simple dan **100% PAKAI ApiFetch** (no fetch manual sama sekali!). Module ini juga **SATU-SATUNYA MODULE** yang punya proper SSR handling dengan dynamic import!
**Unique Strengths:**
1.**100% ApiFetch** - Best API consistency
2.**SSR Handling** - Best practice untuk Next.js (UNIQUE!)
3.**Component reusability** - Share editor component
4.**Simple & clean** - No unnecessary complexity
5.**Rich text validation** - Most comprehensive
**Priority Action:**
```diff
🔴 FIX INI SEKARANG (30 MENIT + MIGRATION):
File: prisma/schema.prisma
Line: 385
model DasarHukumPPID {
id String @id @default(cuid())
judul String @db.Text
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- deletedAt DateTime @default(now())
+ deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
# Lalu jalankan:
bunx prisma db push
# atau
bunx prisma migrate dev --name fix_deletedat_dasarhukum_ppid
```
```diff
🔴 FIX HTML INJECTION (30 MENIT):
File: page.tsx
+ import DOMPurify from 'dompurify';
// Line ~65
- dangerouslySetInnerHTML={{ __html: listDasarHukum.findById.data.judul }}
+ dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(listDasarHukum.findById.data.judul) }}
// Line ~80
- dangerouslySetInnerHTML={{ __html: listDasarHukum.findById.data.content }}
+ dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(listDasarHukum.findById.data.content) }}
```
Setelah fix critical issues, module ini **PRODUCTION-READY** dan bisa jadi **REFERENCE untuk SSR HANDLING & API CONSISTENCY**! 🎉
---
## 📚 RECOMMENDED AS REFERENCE FOR OTHER MODULES
**Dasar Hukum PPID Module adalah BEST PRACTICE untuk:**
1.**API consistency** - 100% ApiFetch, NO fetch manual!
2.**SSR handling** - Dynamic import dengan `ssr: false`
3.**Simple state management** - Clean, straightforward
4.**Rich text validation** - Check empty content pattern
5.**Component reusability** - Share editor component
**Modules lain bisa belajar dari Dasar Hukum PPID:**
- **ALL MODULES:** Use ApiFetch consistently (NO fetch manual!)
- **ALL MODULES WITH RICH TEXT:** Use dynamic import dengan `ssr: false`
- **ALL MODULES:** Keep it simple (avoid unnecessary complexity)
- **Rich Text Modules:** Implement empty content validation
- **ALL MODULES:** Share reusable components
---
**File Location:** `QC/PPID/QC-DASAR-HUKUM-PPID-MODULE.md` 📄

View File

@@ -1,913 +0,0 @@
# QC Summary - Indeks Kepuasan Masyarakat (IKM) PPID Module
**Scope:** Responden (CRUD), Grafik Kepuasan Masyarakat, Master Data (Jenis Kelamin, Rating, Kelompok Umur)
**Date:** 2026-02-23
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
---
## 📊 OVERVIEW
| Sub-Module | Schema | API | UI Admin | State Management | Overall |
|------------|--------|-----|----------|-----------------|---------|
| Responden | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 |
| Grafik IKM | ✅ Baik | ✅ Baik | ✅ **Excellent** | ✅ Baik | 🟢 |
| Master Data (JK, Rating, Umur) | ⚠️ Ada issue | ✅ Baik | N/A | ⚠️ Ada issue | 🟡 |
---
## ✅ YANG SUDAH BAIK
### **1. UI/UX - Grafik & Charts (UNIQUE FEATURE!)**
-**Mantine Charts** - PieChart & BarChart yang modern
-**3 Distribusi Charts**: Jenis Kelamin, Penilaian, Kelompok Umur
-**Bar Chart Tren** - Monthly respondent trends
-**Responsive design** - SimpleGrid dengan proper breakpoints
-**Empty state handling** - "Tidak ada data" message
-**Loading states** dengan Skeleton
-**Color coding** yang konsisten
-**Legend & Labels** yang informatif
-**Tooltip** untuk interactive charts
**Code Example (✅ EXCELLENT):**
```typescript
// grafik-kepuasan-masyarakat/page.tsx - Line ~100-150
<Paper withBorder bg={colors['white-1']} p="lg" radius="xl" shadow="sm">
<Title order={3} mb="md" ta="center">Tren Jumlah Responden</Title>
<Box h={320}>
<BarChart
h={300}
data={barChartData}
dataKey="month"
series={[{ name: 'count', color: colors['blue-button'] }]}
tickLine="y"
xAxisLabel="Bulan"
yAxisLabel="Jumlah Responden"
withTooltip
tooltipAnimationDuration={200}
/>
</Box>
</Paper>
```
**Verdict:****EXCELLENT** - Best chart implementation di semua modul PPID!
---
### **2. Data Processing untuk Charts**
- ✅ Automatic calculation dari data responden
- ✅ Grouping by gender, rating, age group
- ✅ Monthly aggregation untuk bar chart
- ✅ Date parsing dari multiple fields (createdAt, tanggal)
- ✅ Sorting by month/year
- ✅ Empty data handling (all values = 0)
**Code Example (✅ EXCELLENT):**
```typescript
// grafik-kepuasan-masyarakat/page.tsx - Line ~45-85
// Hitung total berdasarkan jenis kelamin
const totalLaki = data.filter((item: any) =>
item.jenisKelamin?.name?.toLowerCase() === 'laki-laki'
).length;
const totalPerempuan = data.filter((item: any) =>
item.jenisKelamin?.name?.toLowerCase() === 'perempuan'
).length;
// Update gender chart data
setDonutDataJenisKelamin([
{ name: 'Laki-laki', value: totalLaki, color: colors['blue-button'] },
{ name: 'Perempuan', value: totalPerempuan, color: '#10A85AFF' },
]);
// Process data for bar chart (group by month)
const monthYearMap = new Map<string, number>();
data.forEach((item: any) => {
const dateValue = item.tanggal || item.createdAt;
const parsedDate = new Date(dateValue);
const month = parsedDate.getMonth() + 1;
const year = parsedDate.getFullYear();
const monthYearKey = `${year}-${String(month).padStart(2, '0')}`;
monthYearMap.set(monthYearKey, (monthYearMap.get(monthYearKey) || 0) + 1);
});
```
**Verdict:****EXCELLENT** - Data processing yang comprehensive!
---
### **3. Form Validation**
- ✅ Zod schema untuk semua forms
- ✅ Required field validation
- ✅ Multiple dropdown dependencies (Jenis Kelamin, Rating, Umur)
- ✅ Loading state handling untuk dropdown data
**Code Example (✅ GOOD):**
```typescript
// state file - Line ~10-16
const templateResponden = z.object({
name: z.string().min(1, "Nama harus diisi"),
tanggal: z.string().min(1, "Tanggal harus diisi"),
jenisKelaminId: z.string().min(1, "Jenis kelamin harus diisi"),
ratingId: z.string().min(1, "Rating harus diisi"),
kelompokUmurId: z.string().min(1, "Kelompok umur harus diisi"),
});
```
**Verdict:****BAIK** - Validation yang proper!
---
### **4. State Management**
- ✅ Proper typing dengan Prisma types (untuk findUnique)
- ✅ Loading state management dengan finally block
- ✅ Error handling yang comprehensive
-**ApiFetch consistency** untuk create & findMany! ✅
- ✅ Multiple related states (responden, jenisKelamin, rating, umur)
- ✅ Reusable Select component di edit page
**Code Example (✅ GOOD):**
```typescript
// state file - Line ~60-95
findMany: {
data: null as any[] | null,
page: 1,
totalPages: 1,
total: 0,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
responden.findMany.loading = true; // ✅ Start loading
responden.findMany.page = page;
responden.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.landingpage.responden["findMany"].get({ query });
if (res.status === 200 && res.data?.success) {
responden.findMany.data = res.data.data || [];
responden.findMany.total = res.data.total || 0;
responden.findMany.totalPages = res.data.totalPages || 1;
}
} catch (error) {
console.error("Error loading responden:", error);
responden.findMany.data = [];
responden.findMany.total = 0;
responden.findMany.totalPages = 1;
} finally {
responden.findMany.loading = false; // ✅ Stop loading
}
},
}
```
**Verdict:****BAIK** - State management sudah proper dengan ApiFetch!
---
### **5. Edit Form - Original Data Tracking**
- ✅ Original data state untuk reset form
- ✅ Load data existing dengan benar
- ✅ Reset form mengembalikan ke data original
- ✅ Reusable ControlledSelect component
- ✅ Error display untuk setiap field
**Code Example (✅ EXCELLENT):**
```typescript
// edit/page.tsx - Line ~40-60
const [formData, setFormData] = useState<FormResponden>({
name: '',
tanggal: '',
jenisKelaminId: '',
ratingId: '',
kelompokUmurId: '',
});
const [originalData, setOriginalData] = useState<FormResponden>({
name: '',
tanggal: '',
jenisKelaminId: '',
ratingId: '',
kelompokUmurId: '',
});
// Load data
const data = await state.update.load(id);
setFormData(newForm);
setOriginalData(newForm); // ✅ Save original
// Line ~130 - Handle reset
const handleResetForm = () => {
setFormData({ ...originalData });
toast.info('Form dikembalikan ke data awal');
};
// Line ~150 - Reusable Select component
const ControlledSelect = ({
label, value, onChange, options, error, loading,
}) => (
<Select
label={<Text fw="bold" fz="sm" mb={4}>{label}</Text>}
value={value}
onChange={(val) => onChange(val || '')}
data={options}
disabled={loading}
clearable
searchable
required
radius="md"
error={error}
/>
);
```
**Verdict:****EXCELLENT** - Best edit form implementation dengan reusable component!
---
### **6. Master Data Management**
- ✅ 3 master data tables: Jenis Kelamin, Rating, Kelompok Umur
- ✅ Separate proxy states untuk masing-masing
- ✅ Auto-load saat create/edit form
- ✅ Proper filtering dan mapping untuk dropdown options
---
## ⚠️ ISSUES & SARAN PERBAIKAN
### **🔴 CRITICAL**
#### **1. Schema - deletedAt Default Value SALAH (5 MODELS AFFECTED!)**
**Lokasi:** `prisma/schema.prisma` (line 266-297)
**Masalah:**
```prisma
model Responden {
// ...
deletedAt DateTime @default(now()) // ❌ SALAH
isActive Boolean @default(true)
}
model JenisKelaminResponden {
// ...
deletedAt DateTime @default(now()) // ❌ SALAH
isActive Boolean @default(true)
}
model PilihanRatingResponden {
// ...
deletedAt DateTime @default(now()) // ❌ SALAH
isActive Boolean @default(true)
}
model UmurResponden {
// ...
deletedAt DateTime @default(now()) // ❌ SALAH
isActive Boolean @default(true)
}
```
**Dampak:**
- **LOGIC ERROR!** Setiap record baru langsung punya `deletedAt` value
- Soft delete tidak berfungsi dengan benar
- Query dengan `where: { deletedAt: null }` tidak akan pernah return data
- **5 models affected!** (Responden + 3 master data + StrukturPPID)
**Rekomendasi:** Fix semua schema:
```prisma
model Responden {
// ...
deletedAt DateTime? @default(null) // ✅ Nullable
isActive Boolean @default(true)
}
model JenisKelaminResponden {
// ...
deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
model PilihanRatingResponden {
// ...
deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
model UmurResponden {
// ...
deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
```
**Priority:** 🔴 **CRITICAL**
**Effort:** Medium (perlu migration untuk 5 models)
**Impact:** **HIGH** (data integrity & soft delete logic)
---
#### **2. State Management - Fetch Pattern Inconsistency**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/indeks-kepuasan.ts`
**Masalah:** Ada 2 pattern berbeda untuk fetch API:
```typescript
// ❌ Pattern 1: ApiFetch (create, findMany)
const res = await ApiFetch.api.landingpage.responden["create"].post(form);
const res = await ApiFetch.api.landingpage.responden["findMany"].get({ query });
// ❌ Pattern 2: fetch manual (findUnique, update)
const res = await fetch(`/api/landingpage/responden/${id}`);
const response = await fetch(`/api/landingpage/responden/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
});
```
**Dampak:**
- Code consistency buruk
- Sulit maintenance
- Type safety tidak konsisten
- Duplikasi logic error handling
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
```typescript
// ✅ Unified pattern
async load(id: string) {
try {
const res = await ApiFetch.api.landingpage.responden[id].get();
if (res.data?.success) {
responden.findUnique.data = res.data.data;
} else {
toast.error(res.data?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error:", error);
toast.error("Gagal memuat data");
}
}
```
**Priority:** 🔴 High
**Effort:** Medium (refactor di findUnique, update methods)
---
#### **3. Type Safety - Any Usage di findMany**
**Lokasi:** `src/app/admin/(dashboard)/_state/landing-page/indeks-kepuasan.ts`
**Masalah:**
```typescript
// Line ~58
data: null as any[] | null, // ❌ Using 'any'
// Line ~270
data: null as any[] | null, // ❌ Using 'any'
// Line ~370
data: null as any[] | null, // ❌ Using 'any'
// Line ~470
data: null as any[] | null, // ❌ Using 'any'
```
**Issue:** findMany data tidak typed dengan Prisma types, hanya findUnique yang typed.
**Rekomendasi:** Gunakan typed data:
```typescript
// Define type
type RespondenWithRelations = Prisma.RespondenGetPayload<{
include: {
jenisKelamin: true;
rating: true;
kelompokUmur: true;
};
}>;
// Use typed data
data: null as RespondenWithRelations[] | null,
```
**Priority:** 🟡 Medium
**Effort:** Low
---
### **🟡 MEDIUM**
#### **4. Console.log di Production**
**Lokasi:** Multiple places di state file
**Masalah:**
```typescript
// Line ~80
console.error("Failed to load responden:", res.data?.message);
// Line ~85
console.error("Error loading responden:", error);
// Line ~110
console.error("Failed to fetch data", res.status, res.statusText);
// Line ~114
console.error("Error loading responden:", error);
// ... dan banyak lagi di semua master data states
```
**Rekomendasi:** Gunakan conditional logging:
```typescript
if (process.env.NODE_ENV === 'development') {
console.error("Error:", error);
}
```
**Priority:** 🟡 Low
**Effort:** Low
---
#### **5. Missing Loading State di Submit Button**
**Lokasi:** `create/page.tsx`
**Masalah:**
```typescript
// Line ~100-110
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
// ...
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
```
**Verdict:****SUDAH BENAR** - Loading state sudah ada di create page!
**Priority:** 🟢 None
**Effort:** None
---
#### **6. Missing Loading State di Edit Submit Button**
**Lokasi:** `edit/page.tsx`
**Masalah:**
```typescript
// Line ~220-230
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
// ⚠️ Missing state.update.loading check
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
```
**Issue:** Button tidak check `state.update.loading` dari global state.
**Rekomendasi:** Check both states:
```typescript
disabled={!isFormValid() || isSubmitting || state.update.loading}
{isSubmitting || state.update.loading ? (
<Loader size="sm" color="white" />
) : (
'Simpan'
)}
```
**Priority:** 🟡 Low
**Effort:** Low
---
#### **7. Pagination onChange Tidak Include Search**
**Lokasi:** `responden/page.tsx`
**Masalah:**
```typescript
// Line ~200-210
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10); // ⚠️ Missing search parameter
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
// ...
/>
```
**Issue:** Saat ganti page, search query hilang.
**Rekomendasi:** Include search:
```typescript
onChange={(newPage) => {
load(newPage, 10, debouncedSearch); // ✅ Include search
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
```
**Priority:** 🟡 Low
**Effort:** Low
---
### **🟢 LOW (Minor Polish)**
#### **8. Missing Delete Function di Master Data**
**Lokasi:** State file untuk master data
**Masalah:**
```typescript
// Line ~270-290 (jenisKelaminResponden)
delete: {
loading: false,
async byId(id: string) {
// ✅ Method sudah ada
},
}
```
**Verdict:****SUDAH BENAR** - Delete function sudah ada di semua master data!
**Priority:** 🟢 None
**Effort:** None
---
#### **9. Duplicate Loading State Assignment**
**Lokasi:** State file untuk master data
**Masalah:**
```typescript
// Line ~290-295 (jenisKelaminResponden.create)
async create() {
// ...
jenisKelaminResponden.create.loading = true; // ✅ First assignment
try {
jenisKelaminResponden.create.loading = true; // ❌ Duplicate!
const res = await ApiFetch.api.landingpage.jeniskelaminresponden["create"].post(form);
// ...
}
}
```
**Rekomendasi:** Remove duplicate:
```typescript
async create() {
// ...
jenisKelaminResponden.create.loading = true; // ✅ Keep only this
try {
// Remove duplicate line
const res = await ApiFetch.api.landingpage.jeniskelaminresponden["create"].post(form);
// ...
}
}
```
**Priority:** 🟢 Low
**Effort:** Low (ada di 3 master data states)
---
#### **10. Inconsistent Toast Messages**
**Lokasi:** State file
**Masalah:**
```typescript
// Line ~45 (responden.create)
toast.success("Responden berhasil ditambahkan");
// Line ~295 (jenisKelaminResponden.create)
toast.success("Jenis kelamin responden berhasil ditambahkan");
// Line ~400 (pilihanRatingResponden.create)
toast.success("Jenis kelamin responden berhasil ditambahkan"); // ❌ Wrong message!
// Line ~505 (kelompokUmurResponden.create)
toast.success("Kelompok umur responden berhasil ditambahkan");
```
**Issue:** Copy-paste error di pilihanRatingResponden (masih "Jenis kelamin responden").
**Rekomendasi:** Fix message:
```typescript
toast.success("Pilihan rating responden berhasil ditambahkan");
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **11. Missing Edit Page untuk Master Data**
**Lokasi:** Module structure
**Masalah:**
- ✅ Responden: Create, Edit, Detail, Delete
- ❌ Jenis Kelamin: Create, Delete (NO EDIT)
- ❌ Rating: Create, Delete (NO EDIT)
- ❌ Kelompok Umur: Create, Delete (NO EDIT)
**Issue:** Master data tidak bisa diedit, hanya bisa delete & create ulang.
**Rekomendasi:** Consider adding edit pages untuk master data jika diperlukan:
```typescript
// Add edit method di state (sudah ada)
// Add edit page di UI
/admin/ppid/indeks-kepuasan-masyarakat/jenis-kelamin/[id]/edit
```
**Priority:** 🟢 Low (business decision)
**Effort:** Medium
---
#### **12. Search Placeholder Tidak Spesifik**
**Lokasi:** `responden/page.tsx`
**Masalah:**
```typescript
// Line ~30-35
<HeaderSearch
title="Data Responden"
placeholder="Cari nama responden..." // ✅ Actually pretty specific!
// ...
/>
```
**Verdict:****SUDAH BENAR** - Placeholder sudah spesifik!
**Priority:** 🟢 None
**Effort:** None
---
#### **13. Chart Color Hardcoding**
**Lokasi:** `grafik-kepuasan-masyarakat/page.tsx`
**Masalah:**
```typescript
// Line ~55-60
setDonutDataJenisKelamin([
{ name: 'Laki-laki', value: totalLaki, color: colors['blue-button'] },
{ name: 'Perempuan', value: totalPerempuan, color: '#10A85AFF' }, // ❌ Hardcoded
]);
setDonutDataRating([
{ name: 'Sangat Baik', value: totalSangatBaik, color: colors['blue-button'] },
{ name: 'Baik', value: totalBaik, color: '#10A85AFF' }, // ❌ Hardcoded
{ name: 'Kurang Baik', value: totalKurangBaik, color: '#FFA500' }, // ❌ Hardcoded
{ name: 'Sangat Kurang Baik', value: totalSangatKurangBaik, color: '#FF4500' }, // ❌ Hardcoded
]);
```
**Rekomendasi:** Define color constants:
```typescript
// con/colors.ts atau file terpisah
export const chartColors = {
primary: colors['blue-button'],
success: '#10A85AFF',
warning: '#FFA500',
danger: '#FF4500',
};
// Use in chart data
{ name: 'Perempuan', value: totalPerempuan, color: chartColors.success },
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **14. Date Parsing di Detail Page**
**Lokasi:** `responden/[id]/page.tsx`
**Masalah:**
```typescript
// Line ~65-70
<Text fz="md" c="dimmed">{
stateDetail.findUnique.data?.tanggal
? new Date(stateDetail.findUnique.data.tanggal).toLocaleDateString('id-ID')
: '-'
}</Text>
```
**Verdict:****SUDAH BENAR** - Date formatting yang proper!
**Priority:** 🟢 None
**Effort:** None
---
## 📋 RINGKASAN ACTION ITEMS
| Priority | Issue | Module | Impact | Effort | Status |
|----------|-------|--------|--------|--------|--------|
| 🔴 P0 | **Schema deletedAt default SALAH (5 models)** | Schema | **CRITICAL** | Medium | **MUST FIX** |
| 🔴 P0 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
| 🟡 M | Type safety (any usage) | State | Low | Low | Optional |
| 🟡 M | Console.log in production | State | Low | Low | Optional |
| 🟡 M | Missing loading state di edit submit | UI | Low | Low | Should fix |
| 🟡 M | Pagination missing search param | UI | Low | Low | Should fix |
| 🟢 L | Duplicate loading state assignment | State | Low | Low | Optional |
| 🟢 L | Inconsistent toast messages | State | Low | Low | Should fix |
| 🟢 L | Missing edit page untuk master data | UI | Low | Medium | Optional |
| 🟢 L | Chart color hardcoding | UI | Low | Low | Optional |
---
## ✅ KESIMPULAN
### **Overall Quality: 🟢 BAIK (8/10)**
**Strengths:**
1.**Grafik & Charts EXCELLENT** - Best chart implementation di semua modul PPID!
2.**Data processing comprehensive** - Automatic calculation dari data responden
3.**3 Distribusi Charts** - Jenis Kelamin, Penilaian, Kelompok Umur
4.**Bar Chart Tren** - Monthly respondent trends
5. ✅ UI/UX clean & responsive
6. ✅ Form validation comprehensive
7. ✅ State management dengan ApiFetch untuk create & findMany
8.**Edit form EXCELLENT** - Reusable ControlledSelect component
9. ✅ Original data tracking untuk reset form
10. ✅ Master data management proper (3 tables)
11. ✅ Loading state management dengan finally block
12. ✅ Mobile cards responsive
**Critical Issues:**
1. ⚠️ **Schema deletedAt default SALAH** - 5 models affected (CRITICAL)
2. ⚠️ Fetch method pattern inconsistency (ApiFetch vs fetch manual)
3. ⚠️ Type safety (any usage di findMany)
**Areas for Improvement:**
1. ⚠️ **Fix schema deletedAt** untuk 5 models dari `@default(now())` ke `@default(null)` dengan nullable
2. ⚠️ **Refactor fetch methods** untuk gunakan ApiFetch consistently
3. ⚠️ **Improve type safety** dengan remove `any` usage
4. ⚠️ **Add loading state** di edit submit button
5. ⚠️ **Fix duplicate loading state** di master data create methods
6. ⚠️ **Fix copy-paste toast message** di pilihanRatingResponden
**Recommended Next Steps:**
1. **🔴 CRITICAL: Fix schema deletedAt** untuk 5 models - 1 jam (perlu migration)
2. **🔴 HIGH: Refactor findUnique, update** ke ApiFetch - 1 jam
3. **🟡 MEDIUM: Improve type safety** - 30 menit
4. **🟡 MEDIUM: Add loading state** di edit submit - 10 menit
5. **🟡 MEDIUM: Fix pagination search param** - 10 menit
6. **🟢 LOW: Fix duplicate loading state** - 15 menit
7. **🟢 LOW: Fix toast message** - 5 menit
8. **🟢 LOW: Define chart color constants** - 15 menit
---
## 📈 COMPARISON WITH OTHER MODULES
| Module | Charts | Data Processing | Edit Form | State | Schema | Overall |
|--------|--------|----------------|-----------|-------|--------|---------|
| Profil | ❌ None | N/A | ✅ Good | ⚠️ Good | ⚠️ deletedAt | 🟢 |
| Desa Anti Korupsi | ❌ None | N/A | ✅ Good | ⚠️ Good | ⚠️ deletedAt | 🟢 |
| SDGs Desa | ❌ None | N/A | ✅ Good | ⚠️ Good | ⚠️ deletedAt | 🟢 |
| APBDes | ❌ None | ✅ Items hierarchy | ✅ Good | ⚠️ Good | ✅ Good | 🟢 |
| Prestasi Desa | ❌ None | N/A | ✅ Good | ⚠️ Good | ❌ WRONG | 🟢 |
| PPID Profil | ❌ None | N/A | ✅ **Excellent** | ✅ **Best** | ❌ WRONG | 🟢⭐ |
| Struktur PPID | ❌ None | N/A | ✅ Good | ✅ Good | ⚠️ Inconsistent | 🟢 |
| Visi Misi PPID | ❌ None | N/A | ✅ Good | ✅ **Best** | ❌ WRONG | 🟢⭐⭐ |
| Dasar Hukum PPID | ❌ None | N/A | ✅ Good | ✅ **Best** | ❌ WRONG | 🟢⭐⭐ |
| Permohonan Informasi | ❌ None | N/A | ❌ Missing | ⚠️ Good | ❌ **4 models WRONG** | 🟡 |
| Permohonan Keberatan | ❌ None | N/A | ❌ Missing | ⚠️ Good | ❌ WRONG | 🟡 |
| Daftar Informasi | ❌ None | N/A | ✅ Good | ⚠️ Good | ❌ WRONG | 🟢 |
| **IKM (Indeks Kepuasan)** | ✅ **EXCELLENT** | ✅ **EXCELLENT** | ✅ **Excellent** | ⚠️ Good | ❌ **5 models WRONG** | 🟢 |
**IKM Highlights:**
-**BEST CHARTS** - Mantine Charts (PieChart, BarChart)
-**BEST DATA PROCESSING** - Automatic calculation & grouping
-**BEST EDIT FORM** - Reusable ControlledSelect component
- ⚠️ **5 models affected** - deletedAt issue (most affected module!)
---
## 🎯 UNIQUE FEATURES OF IKM MODULE
**Most Advanced Data Visualization:**
1.**Mantine Charts** - PieChart & BarChart (UNIQUE!)
2.**3 Distribusi Charts** - Jenis Kelamin, Penilaian, Kelompok Umur
3.**Monthly Trend Chart** - Bar chart dengan grouping
4.**Automatic Calculation** - Filter & count dari data
5.**Reusable Select Component** - ControlledSelect di edit form
6.**3 Master Data Tables** - Jenis Kelamin, Rating, Kelompok Umur
**Best Practices:**
1.**Chart implementation** - Best practice untuk data visualization
2.**Data processing** - Comprehensive calculation & grouping
3.**Reusable components** - ControlledSelect untuk dropdowns
4.**Loading state management** - Proper dengan finally block
5.**Original data tracking** - Edit form reset yang proper
6.**Master data management** - Separate states untuk masing-masing
**Critical Issues:**
1.**5 models dengan deletedAt SALAH** - Most affected module!
2.**Fetch pattern inconsistency** - findUnique, update pakai fetch manual
3.**Type safety** - any usage di findMany
---
**Catatan:** **IKM adalah MODULE DENGAN CHARTS & DATA VISUALIZATION TERBAIK** dengan Mantine Charts implementation yang excellent. Module ini juga punya **BEST EDIT FORM** dengan reusable ControlledSelect component. Tapi juga **MODULE DENGAN PALING BANYAK MODEL AFFECTED** oleh deletedAt issue (5 models!).
**Unique Strengths:**
1.**Charts EXCELLENT** - Best data visualization
2.**Data processing** - Automatic calculation & grouping
3.**Edit form EXCELLENT** - Reusable ControlledSelect
4.**Master data management** - 3 separate tables
5.**Monthly trends** - Bar chart dengan grouping
**Priority Action:**
```diff
🔴 FIX INI SEKARANG (1 JAM + MIGRATION):
File: prisma/schema.prisma
Line: 266-297
# Fix 5 models:
model Responden {
// ...
- deletedAt DateTime @default(now())
+ deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
model JenisKelaminResponden {
// ...
- deletedAt DateTime @default(now())
+ deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
model PilihanRatingResponden {
// ...
- deletedAt DateTime @default(now())
+ deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
model UmurResponden {
// ...
- deletedAt DateTime @default(now())
+ deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
# Lalu jalankan:
bunx prisma db push
# atau
bunx prisma migrate dev --name fix_deletedat_ikm
```
Setelah fix critical issues, module ini **PRODUCTION-READY** dengan **BEST CHARTS & DATA VISUALIZATION**! 🎉
---
## 📚 RECOMMENDED AS REFERENCE FOR OTHER MODULES
**IKM Module adalah BEST PRACTICE untuk:**
1.**Charts & Data Visualization** - Mantine Charts implementation
2.**Data Processing** - Automatic calculation & grouping
3.**Reusable Components** - ControlledSelect untuk dropdowns
4.**Edit Form** - Original data tracking dengan reusable components
5.**Master Data Management** - Separate states untuk multiple tables
**Modules lain bisa belajar dari IKM:**
- **ALL MODULES WITH CHARTS:** Use Mantine Charts (PieChart, BarChart)
- **ALL MODULES WITH DROPDOWNS:** Use reusable ControlledSelect component
- **ALL MODULES:** Automatic data calculation untuk charts
- **ALL MODULES:** Master data management dengan separate states
- **ALL MODULES:** Edit form dengan original data tracking
---
**File Location:** `QC/PPID/QC-IKM-MODULE.md` 📄

View File

@@ -1,844 +0,0 @@
# QC Summary - Permohonan Informasi Publik PPID Module
**Scope:** List Permohonan Informasi Publik, Detail Permohonan
**Date:** 2026-02-23
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
---
## 📊 OVERVIEW
| Aspect | Schema | API | UI Admin | State Management | Overall |
|--------|--------|-----|----------|-----------------|---------|
| Permohonan Informasi Publik | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 Perlu fix |
---
## ✅ YANG SUDAH BAIK
### **1. UI/UX Design**
- ✅ Preview layout yang clean dengan responsive design
- ✅ Loading states dengan Skeleton
- ✅ Empty state handling yang informatif dengan icon
- ✅ Search functionality dengan debounce (1000ms)
- ✅ Pagination yang konsisten
- ✅ Desktop table + mobile cards responsive
- ✅ Icon integration (User, ID, Phone, Info) untuk visual clarity
### **2. Table & Card Layout**
- ✅ Fixed layout table untuk consistency
- ✅ Column headers dengan icon yang descriptive
- ✅ Row numbering otomatis (index + 1)
- ✅ Text truncation dengan lineClamp untuk long text
- ✅ Mobile card view dengan proper information hierarchy
**Code Example (✅ GOOD):**
```typescript
// page.tsx - Line ~130-180
<Table highlightOnHover
layout="fixed" // ✅ PENTING - consistent column widths
withColumnBorders={false}>
<TableThead>
<TableTr>
<TableTh fz="sm" fw={600} ta="center" w={60}>No</TableTh>
<TableTh fz="sm" fw={600}>
<Group gap={5}>
<IconUser size={16} />
Nama
</Group>
</TableTh>
<TableTh fz="sm" fw={600}>
<Group gap={5}>
<IconId size={16} />
NIK
</Group>
</TableTh>
// ...
</TableTr>
</TableThead>
```
**Verdict:****BAIK** - Table layout dengan icon yang helpful!
---
### **3. State Management**
- ✅ Proper typing dengan Prisma types
- ✅ Loading state management dengan finally block
- ✅ Error handling yang comprehensive
-**ApiFetch consistency** untuk create & findMany! ✅
- ✅ Zod validation untuk form data dengan specific rules
- ✅ Separate proxy states untuk related data (jenisInformasi, caraMemperoleh, dll)
**Code Example (✅ GOOD):**
```typescript
// state file - Line ~110-150
findMany: {
data: null as Prisma.PermohonanInformasiPublikGetPayload<{...}>[] | null,
page: 1,
totalPages: 1,
total: 0,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
statepermohonanInformasiPublik.findMany.loading = true; // ✅ Start loading
statepermohonanInformasiPublik.findMany.page = page;
statepermohonanInformasiPublik.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.ppid.permohonaninformasipublik["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
statepermohonanInformasiPublik.findMany.data = res.data.data || [];
statepermohonanInformasiPublik.findMany.total = res.data.total || 0;
statepermohonanInformasiPublik.findMany.totalPages = res.data.totalPages || 1;
}
} catch (error) {
console.error("Error loading permohonan:", error);
statepermohonanInformasiPublik.findMany.data = [];
// ...
} finally {
statepermohonanInformasiPublik.findMany.loading = false; // ✅ Stop loading
}
},
}
```
**Verdict:****BAIK** - State management sudah proper dengan ApiFetch!
---
### **4. Zod Schema Validation**
- ✅ Comprehensive validation untuk semua fields
- ✅ Specific error messages untuk setiap field
- ✅ Phone number length validation (3-15 chars)
- ✅ NIK length validation (3-16 chars)
- ✅ Email format validation
- ✅ Required field validation untuk dropdowns
**Code Example (✅ EXCELLENT):**
```typescript
// state file - Line ~8-22
const templateForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"),
nik: z
.string()
.min(3, "NIK minimal 3 karakter")
.max(16, "NIK maksimal 16 angka"), // ✅ Specific validation
notelp: z
.string()
.min(3, "Nomor Telepon minimal 3 karakter")
.max(15, "Nomor Telepon maksimal 15 angka"), // ✅ Specific validation
alamat: z.string().min(3, "Alamat minimal 3 karakter"),
email: z.string().min(3, "Email minimal 3 karakter"),
jenisInformasiDimintaId: z.string().nonempty(), // ✅ Required dropdown
caraMemperolehInformasiId: z.string().nonempty(), // ✅ Required dropdown
caraMemperolehSalinanInformasiId: z.string().nonempty(), // ✅ Required dropdown
});
```
**Verdict:****EXCELLENT** - Validation yang comprehensive!
---
### **5. Related Data Management**
- ✅ Separate proxy states untuk dropdown data
- ✅ JenisInformasiDiminta, CaraMemperolehInformasi, CaraMemperolehSalinanInformasi
- ✅ Proper typing dengan Prisma types
- ✅ ApiFetch consistency untuk load dropdown data
**Code Example (✅ GOOD):**
```typescript
// state file - Line ~24-40
const jenisInformasiDiminta = proxy({
findMany: {
data: null as Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[] | null,
async load() {
const res = await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi["find-many"].get();
if (res.status === 200) {
jenisInformasiDiminta.findMany.data = res.data?.data ?? [];
}
},
},
});
```
**Verdict:****BAIK** - Related data management yang proper!
---
## ⚠️ ISSUES & SARAN PERBAIKAN
### **🔴 CRITICAL**
#### **1. Schema - deletedAt Default Value SALAH (MULTIPLE MODELS)**
**Lokasi:** `prisma/schema.prisma` (line 435-467)
**Masalah:**
```prisma
model PermohonanInformasiPublik {
// ...
deletedAt DateTime @default(now()) // ❌ SALAH - selalu punya default value
isActive Boolean @default(true)
}
model JenisInformasiDiminta {
// ...
deletedAt DateTime @default(now()) // ❌ SALAH
isActive Boolean @default(true)
}
model CaraMemperolehInformasi {
// ...
deletedAt DateTime @default(now()) // ❌ SALAH
isActive Boolean @default(true)
}
model CaraMemperolehSalinanInformasi {
// ...
deletedAt DateTime @default(now()) // ❌ SALAH
isActive Boolean @default(true)
}
```
**Dampak:**
- **LOGIC ERROR!** Setiap record baru langsung punya `deletedAt` value (timestamp creation)
- Soft delete tidak berfungsi dengan benar
- Query dengan `where: { deletedAt: null }` tidak akan pernah return data
- Data yang "dihapus" vs data "aktif" tidak bisa dibedakan
- **4 models affected!** (PermohonanInformasiPublik + 3 related models)
**Rekomendasi:** Fix semua schema:
```prisma
model PermohonanInformasiPublik {
// ...
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
isActive Boolean @default(true)
}
model JenisInformasiDiminta {
// ...
deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
model CaraMemperolehInformasi {
// ...
deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
model CaraMemperolehSalinanInformasi {
// ...
deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
```
**Priority:** 🔴 **CRITICAL**
**Effort:** Medium (perlu migration untuk 4 models)
**Impact:** **HIGH** (data integrity & soft delete logic)
---
#### **2. State Management - Fetch Pattern Inconsistency**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/permohonan_informasi_publik/permohonanInformasiPublik.ts`
**Masalah:** Ada 2 pattern berbeda untuk fetch API:
```typescript
// ❌ Pattern 1: ApiFetch (create, findMany, dropdowns)
const res = await ApiFetch.api.ppid.permohonaninformasipublik["create"].post(form);
const res = await ApiFetch.api.ppid.permohonaninformasipublik["find-many"].get({ query });
const res = await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi["find-many"].get();
// ❌ Pattern 2: fetch manual (findUnique)
const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`);
```
**Dampak:**
- Code consistency buruk
- Sulit maintenance
- Type safety tidak konsisten
- Duplikasi logic error handling
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
```typescript
// ✅ Unified pattern
async load(id: string) {
try {
const res = await ApiFetch.api.ppid.permohonaninformasipublik[id].get();
if (res.data?.success) {
statepermohonanInformasiPublik.findUnique.data = res.data.data;
} else {
toast.error(res.data?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error:", error);
toast.error("Gagal memuat data");
}
}
```
**Priority:** 🔴 High
**Effort:** Medium (refactor di findUnique method)
---
#### **3. Console.log di Production**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/permohonan_informasi_publik/permohonanInformasiPublik.ts`
**Masalah:**
```typescript
// Line ~70
console.log(caraMemperolehSalinanInformasi); // ❌ Debug log
// Line ~160
console.error("Failed to load permohonan keberatan informasi:", res.data?.message);
// Line ~165
console.error("Error loading permohonan keberatan informasi:", error);
// Line ~185
console.error("Failed to fetch program inovasi:", res.statusText);
// Line ~188
console.error("Error fetching program inovasi:", error);
```
**Rekomendasi:** Gunakan conditional logging:
```typescript
if (process.env.NODE_ENV === 'development') {
console.error("Error:", error);
}
```
**Priority:** 🟡 Low
**Effort:** Low
---
#### **4. Missing Delete/Hard Delete Protection**
**Lokasi:** `page.tsx`, `[id]/page.tsx`
**Masalah:**
- ❌ Tidak ada tombol delete untuk Permohonan Informasi (correct - read-only data)
-**GOOD:** Read-only pattern yang benar untuk data permohonan
- ⚠️ **ISSUE:** Tidak ada fitur untuk mark sebagai "processed" atau "completed"
**Issue:** User tidak bisa update status permohonan (pending → processed → completed).
**Rekomendasi:** Add status management:
```prisma
// Add to schema
model PermohonanInformasiPublik {
// ...
status String @default("pending") // pending, processed, completed
processedAt DateTime?
processedBy String?
}
```
```typescript
// Add action buttons di detail page
<Group>
<Button color="yellow" onClick={() => updateStatus("processed")}>
Mark as Processed
</Button>
<Button color="green" onClick={() => updateStatus("completed")}>
Mark as Completed
</Button>
</Group>
```
**Priority:** 🔴 Medium
**Effort:** Medium (perlu schema change + UI update)
---
### **🟡 MEDIUM**
#### **5. Type Safety - Any Usage**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/permohonan_informasi_publik/permohonanInformasiPublik.ts`
**Masalah:**
```typescript
// Line ~145
const query: any = { page, limit }; // ❌ Using 'any'
if (search) query.search = search;
```
**Rekomendasi:** Gunakan typed query:
```typescript
// Define type
interface FindManyQuery {
page: number | string;
limit?: number | string;
search?: string;
}
// Use typed query
const query: FindManyQuery = { page, limit };
if (search) query.search = search;
```
**Priority:** 🟡 Medium
**Effort:** Low
---
#### **6. Error Message Tidak Konsisten**
**Lokasi:** Multiple places
**Masalah:**
```typescript
// Line ~160
console.error("Failed to load permohonan keberatan informasi:", res.data?.message);
// ⚠️ Wrong module name - ini "permohonan informasi publik" bukan "keberatan"
// Line ~165
console.error("Error loading permohonan keberatan informasi:", error);
// ⚠️ Same issue
// Line ~185
console.error("Failed to fetch program inovasi:", res.statusText);
// ⚠️ Wrong module name - ini "permohonan informasi" bukan "program inovasi"
// Line ~188
console.error("Error fetching program inovasi:", error);
// ⚠️ Same issue
```
**Issue:** Copy-paste error dari module lain!
**Rekomendasi:** Fix error messages:
```typescript
console.error("Failed to load permohonan informasi publik:", res.data?.message);
console.error("Error loading permohonan informasi publik:", error);
console.error("Failed to fetch permohonan informasi:", res.statusText);
console.error("Error fetching permohonan informasi:", error);
```
**Priority:** 🟡 Medium
**Effort:** Low
---
#### **7. Pagination onChange Tidak Include Search**
**Lokasi:** `page.tsx`
**Masalah:**
```typescript
// Line ~250-260
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10); // ⚠️ Missing search parameter
window.scrollTo(0, 0);
}}
total={totalPages}
// ...
/>
```
**Issue:** Saat ganti page, search query hilang.
**Rekomendasi:** Include search:
```typescript
onChange={(newPage) => {
load(newPage, 10, debouncedSearch); // ✅ Include search
window.scrollTo(0, 0);
}}
```
**Priority:** 🟡 Low
**Effort:** Low
---
### **🟢 LOW (Minor Polish)**
#### **8. Missing Loading State di Detail Page**
**Lokasi:** `[id]/page.tsx`
**Masalah:**
```typescript
// Line ~20-25
useShallowEffect(() => {
state.findUnique.load(params?.id as string)
}, [params?.id])
if (!state.findUnique.data) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
)
}
```
**Issue:** Skeleton ditampilkan untuk semua kondisi (loading, error, not found).
**Rekomendasi:** Add proper loading state:
```typescript
if (state.findUnique.loading) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
if (!state.findUnique.data) {
return (
<Alert icon={<IconAlertCircle />} color="red">
Data tidak ditemukan
</Alert>
);
}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **9. Duplicate Error Logging**
**Lokasi:** `page.tsx`, state file
**Masalah:**
```typescript
// page.tsx - Line ~160-165
console.error("Failed to load permohonan keberatan informasi:", res.data?.message);
console.error("Error loading permohonan keberatan informasi:", error);
// state file - Line ~185-188
console.error("Failed to fetch program inovasi:", res.statusText);
console.error("Error fetching program inovasi:", error);
```
**Rekomendasi:** Cukup satu logging yang informatif:
```typescript
console.error('Failed to load Permohonan Informasi Publik:', err);
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **10. Search Placeholder Tidak Spesifik**
**Lokasi:** `page.tsx`
**Masalah:**
```typescript
// Line ~70, 110
<TextInput
placeholder={"Cari nama..."} // ⚠️ Generic
// ...
/>
```
**Rekomendasi:** Lebih spesifik:
```typescript
placeholder={"Cari nama pemohon..."}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **11. Missing Data Relationships di Detail Page**
**Lokasi:** `[id]/page.tsx`
**Masalah:**
```typescript
// Line ~60-90
<Box>
<Text fz="lg" fw="bold" mb={4}>Jenis Informasi</Text>
<Text fz="md" c="dimmed">{data.jenisInformasiDiminta?.name || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold" mb={4}>Cara Akses Informasi</Text>
<Text fz="md" c="dimmed">{data.caraMemperolehInformasi?.name || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold" mb={4}>Cara Akses Salinan Informasi</Text>
<Text fz="md" c="dimmed">{data.caraMemperolehSalinanInformasi?.name || '-'}</Text>
</Box>
```
**Issue:** Tidak menampilkan data `alamat` yang ada di schema.
**Rekomendasi:** Add missing field:
```typescript
<Box>
<Text fz="lg" fw="bold" mb={4}>Alamat</Text>
<Text fz="md" c="dimmed">{data.alamat || '-'}</Text>
</Box>
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **12. Unused Console.log**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/permohonan_informasi_publik/permohonanInformasiPublik.ts`
**Masalah:**
```typescript
// Line ~70
console.log(caraMemperolehSalinanInformasi); // ❌ Debug log yang tidak terpakai
```
**Rekomendasi:** Remove:
```typescript
// Remove this line completely
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **13. Missing Empty State Icon di Mobile**
**Lokasi:** `page.tsx`
**Masalah:**
```typescript
// Line ~60-75 (Desktop empty state)
<Stack align="center" py="xl" ta="center">
<IconInfoCircle size={40} stroke={1.5} color={colors['blue-button']} />
<Text fz={{ base: 'sm', md: 'md' }} fw={500} c="dimmed" lh={1.5}>
{search
? 'Tidak ditemukan data yang sesuai dengan pencarian'
: 'Belum ada permohonan yang tercatat'
}
</Text>
</Stack>
// Line ~120-130 (Mobile - missing icon)
<Stack align="center" py={{ base: 'xl', md: 'xl' }}>
<IconInfoCircle size={40} stroke={1.5} color={colors['blue-button']} />
// ✅ Icon ada di sini juga
<Text fz={{ base: 'sm', md: 'md' }} fw={500} c="dimmed" lh={1.5}>
Belum ada permohonan informasi yang tercatat
</Text>
</Stack>
```
**Verdict:****SUDAH BENAR** - Icon ada di kedua empty states!
**Priority:** 🟢 None
**Effort:** None
---
## 📋 RINGKASAN ACTION ITEMS
| Priority | Issue | Module | Impact | Effort | Status |
|----------|-------|--------|--------|--------|--------|
| 🔴 P0 | **Schema deletedAt default SALAH (4 models)** | Schema | **CRITICAL** | Medium | **MUST FIX** |
| 🔴 P0 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
| 🔴 P1 | Missing status management | UI/Schema | Medium | Medium | Should add |
| 🟡 M | Console.log in production | State | Low | Low | Optional |
| 🟡 M | Type safety (any usage) | State | Low | Low | Optional |
| 🟡 M | Error message inconsistency (copy-paste) | State | Low | Low | Should fix |
| 🟡 M | Pagination missing search param | UI | Low | Low | Should fix |
| 🟢 L | Missing loading state di detail page | UI | Low | Low | Optional |
| 🟢 L | Duplicate error logging | UI/State | Low | Low | Optional |
| 🟢 L | Search placeholder tidak spesifik | UI | Low | Low | Optional |
| 🟢 L | Missing alamat field di detail page | UI | Low | Low | Optional |
| 🟢 L | Unused console.log | State | Low | Low | Optional |
---
## ✅ KESIMPULAN
### **Overall Quality: 🟢 BAIK (7.5/10)**
**Strengths:**
1. ✅ UI/UX clean & responsive
2. ✅ Table layout dengan icon yang helpful
3. ✅ Search functionality dengan debounce
4. ✅ Empty state handling yang informatif
5.**Zod validation comprehensive** dengan specific rules
6.**Related data management** proper (dropdowns)
7. ✅ State management dengan ApiFetch untuk create & findMany
8. ✅ Loading state management dengan finally block
9. ✅ Mobile cards responsive
**Critical Issues:**
1. ⚠️ **Schema deletedAt default SALAH** - 4 models affected (CRITICAL)
2. ⚠️ Fetch method pattern inconsistency (ApiFetch vs fetch manual)
3. ⚠️ Missing status management untuk permohonan (pending → processed → completed)
**Areas for Improvement:**
1. ⚠️ **Fix schema deletedAt** untuk 4 models dari `@default(now())` ke `@default(null)` dengan nullable
2. ⚠️ **Refactor fetch methods** untuk gunakan ApiFetch consistently
3. ⚠️ **Add status management** untuk tracking status permohonan
4. ⚠️ **Fix error messages** (copy-paste error dari module lain)
5. ⚠️ **Improve type safety** dengan remove `any` usage
**Recommended Next Steps:**
1. **🔴 CRITICAL: Fix schema deletedAt** untuk 4 models - 1 jam (perlu migration)
2. **🔴 HIGH: Refactor findUnique** ke ApiFetch - 30 menit
3. **🔴 HIGH: Add status management** - 1 jam (schema + UI)
4. **🟡 MEDIUM: Fix error messages** (copy-paste) - 10 menit
5. **🟢 LOW: Add pagination search param** - 10 menit
6. **🟢 LOW: Polish minor issues** - 30 menit
---
## 📈 COMPARISON WITH OTHER MODULES
| Module | Fetch Pattern | State | Validation | Schema | Status Mgmt | Overall |
|--------|--------------|-------|------------|--------|-------------|---------|
| Profil | ⚠️ Mixed | ⚠️ Good | ✅ Good | ⚠️ deletedAt | N/A | 🟢 |
| Desa Anti Korupsi | ⚠️ Mixed | ⚠️ Good | ✅ Good | ⚠️ deletedAt | N/A | 🟢 |
| SDGs Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ⚠️ deletedAt | N/A | 🟢 |
| APBDes | ⚠️ Mixed | ⚠️ Good | ✅ Good | ✅ Good | N/A | 🟢 |
| Prestasi Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ WRONG | N/A | 🟢 |
| PPID Profil | ⚠️ Mixed | ✅ Best | ✅ Good | ❌ WRONG | N/A | 🟢⭐ |
| Struktur PPID | ⚠️ Mixed | ✅ Good | ✅ Good | ⚠️ Inconsistent | ✅ Active/Non-active | 🟢 |
| Visi Misi PPID | ✅ **100% ApiFetch!** | ✅ Best | ✅ Good | ❌ WRONG | N/A | 🟢⭐⭐ |
| Dasar Hukum PPID | ✅ **100% ApiFetch!** | ✅ Best | ✅ Good | ❌ WRONG | N/A | 🟢⭐⭐ |
| **Permohonan Informasi** | ⚠️ Mixed | ⚠️ Good | ✅ **Best** | ❌ **4 models WRONG** | ❌ Missing | 🟡 |
**Permohonan Informasi PPID Highlights:**
-**Best validation** - Comprehensive Zod schema dengan specific rules
-**Related data management** - Separate proxy states untuk dropdowns
-**Icon integration** - Table headers dengan icon yang helpful
- ⚠️ **4 models affected** - deletedAt issue (most affected module!)
- ⚠️ **Missing status management** - No workflow tracking
- ⚠️ **Copy-paste errors** - Error messages dari module lain
---
## 🎯 UNIQUE FEATURES OF PERMOHONAN INFORMASI MODULE
**Most Complex Data Structure:**
1.**3 related dropdown models** - JenisInformasi, CaraMemperoleh, CaraMemperolehSalinan
2.**Comprehensive validation** - Phone length, NIK length, email format
3.**Icon integration** - User, ID, Phone, Info icons di table headers
4.**Auto-increment nomor** - Automatic numbering system
5.**Missing status workflow** - Should have pending → processed → completed
**Best Practices:**
1.**Validation comprehensive** - Best Zod schema dengan specific rules
2.**Related data management** - Separate proxy states
3.**Icon integration** - Visual clarity di table headers
4.**Loading state management** - Proper dengan finally block
**Critical Issues:**
1.**4 models dengan deletedAt SALAH** - Most affected module!
2.**Fetch pattern inconsistency** - findUnique pakai fetch manual
3.**Missing status workflow** - No tracking untuk permohonan status
4.**Copy-paste error messages** - Dari module lain
---
**Catatan:** **Permohonan Informasi PPID adalah MODULE DENGAN VALIDATION TERBAIK** tapi juga **MODULE DENGAN PALING BANYAK MODEL AFFECTED** oleh deletedAt issue (4 models!). Module ini butuh status management workflow untuk tracking status permohonan.
**Unique Strengths:**
1.**Best validation** - Comprehensive Zod schema
2.**Related data management** - 3 dropdown models handled properly
3.**Icon integration** - Visual clarity
4.**Auto-increment nomor** - Automatic numbering
**Priority Action:**
```diff
🔴 FIX INI SEKARANG (1 JAM + MIGRATION):
File: prisma/schema.prisma
Line: 435-467
model PermohonanInformasiPublik {
// ...
- deletedAt DateTime @default(now())
+ deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
model JenisInformasiDiminta {
// ...
- deletedAt DateTime @default(now())
+ deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
model CaraMemperolehInformasi {
// ...
- deletedAt DateTime @default(now())
+ deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
model CaraMemperolehSalinanInformasi {
// ...
- deletedAt DateTime @default(now())
+ deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
# Lalu jalankan:
bunx prisma db push
# atau
bunx prisma migrate dev --name fix_deletedat_permohonan_informasi
```
```diff
🔴 ADD STATUS MANAGEMENT (1 JAM):
File: prisma/schema.prisma
model PermohonanInformasiPublik {
// ...
+ status String @default("pending") // pending, processed, completed
+ processedAt DateTime?
+ processedBy String?
}
```
Setelah fix critical issues, module ini **PRODUCTION-READY** dengan **BEST VALIDATION**! 🎉
---
## 📚 RECOMMENDED AS REFERENCE FOR OTHER MODULES
**Permohonan Informasi PPID Module adalah BEST PRACTICE untuk:**
1.**Comprehensive validation** - Zod schema dengan specific rules (phone, NIK length)
2.**Related data management** - Separate proxy states untuk dropdowns
3.**Icon integration** - Visual clarity di table headers
4.**Auto-increment numbering** - Automatic nomor urut
**Modules lain bisa belajar dari Permohonan Informasi:**
- **ALL MODULES:** Use specific validation rules (min/max length)
- **MODULES WITH DROPDOWNS:** Separate proxy states untuk related data
- **ALL MODULES:** Icon integration untuk visual clarity
- **ALL MODULES:** Auto-increment untuk numbering systems
---
**File Location:** `QC/PPID/QC-PERMOHONAN-INFORMASI-PUBLIK-MODULE.md` 📄

View File

@@ -1,771 +0,0 @@
# QC Summary - Permohonan Keberatan Informasi Publik PPID Module
**Scope:** List Permohonan Keberatan, Detail Permohonan Keberatan
**Date:** 2026-02-23
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
---
## 📊 OVERVIEW
| Aspect | Schema | API | UI Admin | State Management | Overall |
|--------|--------|-----|----------|-----------------|---------|
| Permohonan Keberatan | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 Perlu fix |
---
## ✅ YANG SUDAH BAIK
### **1. UI/UX Design**
- ✅ Preview layout yang clean dengan responsive design
- ✅ Loading states dengan Skeleton
- ✅ Empty state handling yang informatif dengan icon
- ✅ Search functionality dengan debounce (1000ms)
- ✅ Pagination yang konsisten
- ✅ Desktop table + mobile cards responsive
- ✅ Icon integration (User, Mail, Phone, Info) untuk visual clarity
- ✅ Consistent empty state messages
### **2. Table & Card Layout**
- ✅ Fixed layout table untuk consistency
- ✅ Column headers dengan icon yang descriptive
- ✅ Row numbering otomatis (index + 1)
- ✅ Text truncation dengan lineClamp untuk long text
- ✅ Mobile card view dengan proper information hierarchy
- ✅ Proper spacing dan gap untuk readability
**Code Example (✅ GOOD):**
```typescript
// page.tsx - Line ~130-180
<Table highlightOnHover
layout="fixed" // ✅ PENTING - consistent column widths
withColumnBorders={false}>
<TableThead>
<TableTr>
<TableTh fz="sm" fw={600} lh={1.4} ta="center">No</TableTh>
<TableTh fz="sm" fw={600} lh={1.4}>
<Group gap={5}>
<IconUser size={16} />
Nama
</Group>
</TableTh>
<TableTh fz="sm" fw={600} lh={1.4}>
<Group gap={5}>
<IconMail size={16} />
Email
</Group>
</TableTh>
// ...
</TableTr>
</TableThead>
```
**Verdict:****BAIK** - Table layout dengan icon yang helpful!
---
### **3. State Management**
- ✅ Proper typing dengan Prisma types
- ✅ Loading state management dengan finally block
- ✅ Error handling yang comprehensive
-**ApiFetch consistency** untuk create & findMany! ✅
- ✅ Zod validation untuk form data dengan specific rules
- ✅ Return boolean untuk create operation (success/failure handling)
**Code Example (✅ EXCELLENT):**
```typescript
// state file - Line ~30-55
create: {
form: {} as PermohonanKeberatanInformasiForm,
loading: false,
async create() {
const cek = templateForm.safeParse(permohonanKeberatanInformasi.create.form);
if (!cek.success) {
toast.error(cek.error.issues.map((i) => i.message).join("\n"));
return false; // ✅ GOOD - Return false untuk failure
}
try {
permohonanKeberatanInformasi.create.loading = true;
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["create"].post(form);
if (res.data?.success === false) {
toast.error(res.data?.message);
return false; // ✅ GOOD - Return false untuk API failure
}
toast.success("Sukses menambahkan");
return true; // ✅ GOOD - Return true untuk success
} catch {
toast.error("Terjadi kesalahan server");
return false;
} finally {
permohonanKeberatanInformasi.create.loading = false;
}
},
}
```
**Verdict:****EXCELLENT** - Proper return value handling untuk create operation!
---
### **4. Zod Schema Validation**
- ✅ Comprehensive validation untuk semua fields
- ✅ Specific error messages untuk setiap field
- ✅ Phone number length validation (3-15 chars)
- ✅ Minimum character validation (3 characters)
**Code Example (✅ GOOD):**
```typescript
// state file - Line ~8-15
const templateForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"),
email: z.string().min(3, "Email minimal 3 karakter"),
notelp: z
.string()
.min(3, "Nomor Telepon minimal 3 karakter")
.max(15, "Nomor Telepon maksimal 15 angka"), // ✅ Specific validation
alasan: z.string().min(3, "Alasan minimal 3 karakter"),
});
```
**Verdict:****BAIK** - Validation yang proper dengan specific rules!
---
### **5. Empty State Handling**
- ✅ Different messages untuk search vs empty data
- ✅ Icon integration untuk visual clarity
- ✅ Proper text formatting dan centering
**Code Example (✅ GOOD):**
```typescript
// page.tsx - Line ~70-85
<Stack align="center" py="xl" ta="center">
<IconInfoCircle size={40} stroke={1.5} color={colors['blue-button']} />
<Text fz={{ base: 'sm', md: 'md' }} fw={500} c="dimmed" lh={1.5}>
{search
? 'Tidak ditemukan data yang sesuai dengan pencarian'
: 'Belum ada permohonan keberatan yang tercatat'
}
</Text>
</Stack>
```
**Verdict:****BAIK** - Empty state dengan conditional messages yang helpful!
---
## ⚠️ ISSUES & SARAN PERBAIKAN
### **🔴 CRITICAL**
#### **1. Schema - deletedAt Default Value SALAH**
**Lokasi:** `prisma/schema.prisma` (line 478)
**Masalah:**
```prisma
model FormulirPermohonanKeberatan {
id String @id @default(cuid())
name String
email String
notelp String
alasan String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) // ❌ SALAH - selalu punya default value
isActive Boolean @default(true)
}
```
**Dampak:**
- **LOGIC ERROR!** Setiap record baru langsung punya `deletedAt` value (timestamp creation)
- Soft delete tidak berfungsi dengan benar
- Query dengan `where: { deletedAt: null }` tidak akan pernah return data
- Data yang "dihapus" vs data "aktif" tidak bisa dibedakan
**Rekomendasi:** Fix schema:
```prisma
model FormulirPermohonanKeberatan {
id String @id @default(cuid())
name String
email String
notelp String
alasan String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
isActive Boolean @default(true)
}
```
**Priority:** 🔴 **CRITICAL**
**Effort:** Medium (perlu migration)
**Impact:** **HIGH** (data integrity & soft delete logic)
---
#### **2. State Management - Fetch Pattern Inconsistency**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/permohonan_keberatan_informasi_publik/permohonanKeberatanInformasi.ts`
**Masalah:** Ada 2 pattern berbeda untuk fetch API:
```typescript
// ❌ Pattern 1: ApiFetch (create, findMany)
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["create"].post(form);
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["find-many"].get({ query });
// ❌ Pattern 2: fetch manual (findUnique)
const res = await fetch(`/api/ppid/permohonankeberataninformasipublik/${id}`);
```
**Dampak:**
- Code consistency buruk
- Sulit maintenance
- Type safety tidak konsisten
- Duplikasi logic error handling
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
```typescript
// ✅ Unified pattern
async load(id: string) {
try {
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[id].get();
if (res.data?.success) {
permohonanKeberatanInformasi.findUnique.data = res.data.data;
} else {
toast.error(res.data?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error:", error);
toast.error("Gagal memuat data");
}
}
```
**Priority:** 🔴 High
**Effort:** Medium (refactor di findUnique method)
---
#### **3. Missing Delete Function**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/permohonan_keberatan_informasi_publik/permohonanKeberatanInformasi.ts`
**Masalah:**
```typescript
// state file - Line ~100-120
// ❌ MISSING: delete method
const permohonanKeberatanInformasi = proxy({
create: { ... },
findMany: { ... },
findUnique: { ... },
// ❌ NO delete method!
});
```
**Issue:** Tidak ada cara untuk menghapus data permohonan keberatan.
**Rekomendasi:** Add delete method:
```typescript
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
permohonanKeberatanInformasi.delete.loading = true;
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["del"][id].delete();
if (res.data?.success) {
toast.success(res.data.message || "Berhasil hapus permohonan keberatan");
await permohonanKeberatanInformasi.findMany.load();
} else {
toast.error(res.data?.message || "Gagal hapus permohonan keberatan");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus");
} finally {
permohonanKeberatanInformasi.delete.loading = false;
}
},
}
```
**Priority:** 🔴 Medium
**Effort:** Medium (perlu add method + API endpoint)
---
### **🟡 MEDIUM**
#### **4. Console.log di Production**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/permohonan_keberatan_informasi_publik/permohonanKeberatanInformasi.ts`
**Masalah:**
```typescript
// Line ~85
console.error("Failed to load permohonan keberatan informasi:", res.data?.message);
// Line ~90
console.error("Error loading permohonan keberatan informasi:", error);
// Line ~110
console.error("Failed to fetch permohonan keberatan informasi:", res.statusText);
// Line ~114
console.error("Error fetching permohonan keberatan informasi:", error);
```
**Rekomendasi:** Gunakan conditional logging:
```typescript
if (process.env.NODE_ENV === 'development') {
console.error("Error:", error);
}
```
**Priority:** 🟡 Low
**Effort:** Low
---
#### **5. Type Safety - Any Usage**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/permohonan_keberatan_informasi_publik/permohonanKeberatanInformasi.ts`
**Masalah:**
```typescript
// Line ~75
const query: any = { page, limit }; // ❌ Using 'any'
if (search) query.search = search;
```
**Rekomendasi:** Gunakan typed query:
```typescript
// Define type
interface FindManyQuery {
page: number | string;
limit?: number | string;
search?: string;
}
// Use typed query
const query: FindManyQuery = { page, limit };
if (search) query.search = search;
```
**Priority:** 🟡 Medium
**Effort:** Low
---
#### **6. Missing Edit Function**
**Lokasi:** Module structure
**Masalah:**
- ❌ Tidak ada halaman edit untuk permohonan keberatan
- ❌ Tidak ada edit method di state
- ⚠️ **QUESTION:** Apakah permohonan keberatan harus bisa diedit?
**Issue:** Jika ada kesalahan input, user tidak bisa mengoreksi data.
**Rekomendasi:** Consider adding edit functionality jika diperlukan:
```typescript
// Add edit method di state
edit: {
id: "",
form: { ... },
loading: false,
async load(id: string) { ... },
async update() { ... },
}
```
**Priority:** 🟡 Low (depends on business requirement)
**Effort:** Medium
---
#### **7. Pagination onChange Tidak Include Search**
**Lokasi:** `page.tsx`
**Masalah:**
```typescript
// Line ~250-260
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10); // ⚠️ Missing search parameter
window.scrollTo(0, 0);
}}
total={totalPages}
// ...
/>
```
**Issue:** Saat ganti page, search query hilang.
**Rekomendasi:** Include search:
```typescript
onChange={(newPage) => {
load(newPage, 10, debouncedSearch); // ✅ Include search
window.scrollTo(0, 0);
}}
```
**Priority:** 🟡 Low
**Effort:** Low
---
### **🟢 LOW (Minor Polish)**
#### **8. Missing Loading State di Detail Page**
**Lokasi:** `[id]/page.tsx`
**Masalah:**
```typescript
// Line ~20-25
useShallowEffect(() => {
state.findUnique.load(params?.id as string)
}, [params?.id])
if (!state.findUnique.data) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
)
}
```
**Issue:** Skeleton ditampilkan untuk semua kondisi (loading, error, not found).
**Rekomendasi:** Add proper loading state:
```typescript
if (state.findUnique.loading) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
if (!state.findUnique.data) {
return (
<Alert icon={<IconAlertCircle />} color="red">
Data tidak ditemukan
</Alert>
);
}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **9. Duplicate Error Logging**
**Lokasi:** `page.tsx`, state file
**Masalah:**
```typescript
// state file - Line ~85-90
console.error("Failed to load permohonan keberatan informasi:", res.data?.message);
console.error("Error loading permohonan keberatan informasi:", error);
// state file - Line ~110-114
console.error("Failed to fetch permohonan keberatan informasi:", res.statusText);
console.error("Error fetching permohonan keberatan informasi:", error);
```
**Rekomendasi:** Cukup satu logging yang informatif:
```typescript
console.error('Failed to load Permohonan Keberatan:', err);
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **10. Search Placeholder Tidak Spesifik**
**Lokasi:** `page.tsx`
**Masalah:**
```typescript
// Line ~70, 110
<TextInput
placeholder={"Cari nama..."} // ⚠️ Generic
// ...
/>
```
**Rekomendasi:** Lebih spesifik:
```typescript
placeholder={"Cari nama pemohon..."}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **11. Missing Data di Detail Page**
**Lokasi:** `[id]/page.tsx`
**Masalah:**
```typescript
// Line ~50-80
// Menampilkan: name, notelp, email, alasan
// ❌ MISSING: createdAt, updatedAt, atau status
```
**Issue:** Tidak menampilkan timestamp atau status permohonan.
**Rekomendasi:** Add missing fields jika ada di schema:
```typescript
<Box>
<Text fz="lg" fw="bold" mb={4}>Tanggal Pengajuan</Text>
<Text fz="md" c="dimmed">
{data.createdAt ? new Date(data.createdAt).toLocaleDateString('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric'
}) : '-'}
</Text>
</Box>
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **12. Title Inconsistency di Detail Page**
**Lokasi:** `[id]/page.tsx`
**Masalah:**
```typescript
// Line ~40
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Informasi Publik // ⚠️ Generic title
</Text>
```
**Issue:** Title seharusnya lebih spesifik "Detail Permohonan Keberatan".
**Rekomendasi:** Fix title:
```typescript
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Permohonan Keberatan Informasi Publik
</Text>
```
**Priority:** 🟢 Low
**Effort:** Low
---
## 📋 RINGKASAN ACTION ITEMS
| Priority | Issue | Module | Impact | Effort | Status |
|----------|-------|--------|--------|--------|--------|
| 🔴 P0 | **Schema deletedAt default SALAH** | Schema | **CRITICAL** | Medium | **MUST FIX** |
| 🔴 P0 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
| 🔴 P1 | Missing delete function | State | Medium | Medium | Should add |
| 🟡 M | Console.log in production | State | Low | Low | Optional |
| 🟡 M | Type safety (any usage) | State | Low | Low | Optional |
| 🟡 M | Missing edit function | State/UI | Low | Medium | Optional (business decision) |
| 🟡 M | Pagination missing search param | UI | Low | Low | Should fix |
| 🟢 L | Missing loading state di detail page | UI | Low | Low | Optional |
| 🟢 L | Duplicate error logging | UI/State | Low | Low | Optional |
| 🟢 L | Search placeholder tidak spesifik | UI | Low | Low | Optional |
| 🟢 L | Missing data di detail page | UI | Low | Low | Optional |
| 🟢 L | Title inconsistency di detail page | UI | Low | Low | Should fix |
---
## ✅ KESIMPULAN
### **Overall Quality: 🟢 BAIK (7.5/10)**
**Strengths:**
1. ✅ UI/UX clean & responsive
2. ✅ Table layout dengan icon yang helpful
3. ✅ Search functionality dengan debounce
4. ✅ Empty state handling yang informatif (conditional messages)
5.**Zod validation** comprehensive dengan specific rules
6.**Proper return value handling** untuk create operation (return true/false)
7. ✅ State management dengan ApiFetch untuk create & findMany
8. ✅ Loading state management dengan finally block
9. ✅ Mobile cards responsive
10. ✅ Icon integration (User, Mail, Phone, Info)
**Critical Issues:**
1. ⚠️ **Schema deletedAt default SALAH** - Logic error untuk soft delete (CRITICAL)
2. ⚠️ Fetch method pattern inconsistency (ApiFetch vs fetch manual)
3. ⚠️ Missing delete function untuk hapus data
**Areas for Improvement:**
1. ⚠️ **Fix schema deletedAt** dari `@default(now())` ke `@default(null)` dengan nullable
2. ⚠️ **Refactor fetch methods** untuk gunakan ApiFetch consistently
3. ⚠️ **Add delete method** untuk hapus data
4. ⚠️ **Consider adding edit functionality** (business decision)
5. ⚠️ **Improve type safety** dengan remove `any` usage
**Recommended Next Steps:**
1. **🔴 CRITICAL: Fix schema deletedAt** - 30 menit (perlu migration)
2. **🔴 HIGH: Refactor findUnique** ke ApiFetch - 30 menit
3. **🔴 HIGH: Add delete method** - 45 menit
4. **🟡 MEDIUM: Add pagination search param** - 10 menit
5. **🟢 LOW: Fix title di detail page** - 5 menit
6. **🟢 LOW: Polish minor issues** - 30 menit
---
## 📈 COMPARISON WITH OTHER MODULES
| Module | Fetch Pattern | State | Validation | Schema | Delete | Edit | Overall |
|--------|--------------|-------|------------|--------|--------|------|---------|
| Profil | ⚠️ Mixed | ⚠️ Good | ✅ Good | ⚠️ deletedAt | ✅ Yes | ✅ Yes | 🟢 |
| Desa Anti Korupsi | ⚠️ Mixed | ⚠️ Good | ✅ Good | ⚠️ deletedAt | ✅ Yes | ✅ Yes | 🟢 |
| SDGs Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ⚠️ deletedAt | ✅ Yes | ✅ Yes | 🟢 |
| APBDes | ⚠️ Mixed | ⚠️ Good | ✅ Good | ✅ Good | ✅ Yes | ✅ Yes | 🟢 |
| Prestasi Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ WRONG | ✅ Yes | ✅ Yes | 🟢 |
| PPID Profil | ⚠️ Mixed | ✅ **Best** | ✅ Good | ❌ WRONG | N/A | ✅ Yes | 🟢⭐ |
| Struktur PPID | ⚠️ Mixed | ✅ Good | ✅ Good | ⚠️ Inconsistent | ✅ Yes | ✅ Yes | 🟢 |
| Visi Misi PPID | ✅ **100% ApiFetch!** | ✅ Best | ✅ Good | ❌ WRONG | N/A | ✅ Yes | 🟢⭐⭐ |
| Dasar Hukum PPID | ✅ **100% ApiFetch!** | ✅ Best | ✅ Good | ❌ WRONG | N/A | ✅ Yes | 🟢⭐⭐ |
| Permohonan Informasi | ⚠️ Mixed | ⚠️ Good | ✅ **Best** | ❌ **4 models WRONG** | ❌ Missing | ❌ Missing | 🟡 |
| **Permohonan Keberatan** | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ WRONG | ❌ **MISSING** | ❌ **MISSING** | 🟡 |
**Permohonan Keberatan PPID Highlights:**
-**Proper return value handling** - Return true/false untuk create operation
-**Icon integration** - User, Mail, Phone, Info icons di table headers
-**Conditional empty state messages** - Different messages untuk search vs empty
- ⚠️ **Same deletedAt issue** seperti modul PPID lain
- ⚠️ **Missing delete function** - Cannot delete data
- ⚠️ **Missing edit function** - Cannot edit data (same as Permohonan Informasi)
---
## 🎯 UNIQUE FEATURES OF PERMOHONAN KEBERATAN MODULE
**Simplest Read-Only Module:**
1.**Proper return value handling** - Return true/false untuk create operation (UNIQUE!)
2.**Conditional empty state messages** - Different messages untuk search vs empty
3.**Icon integration** - User, Mail, Phone, Info icons
4.**Missing delete function** - Cannot delete data
5.**Missing edit function** - Cannot edit data
**Best Practices:**
1.**Return value handling** - Best practice untuk create operation
2.**Conditional empty state** - Good UX untuk search feedback
3.**Loading state management** - Proper dengan finally block
4.**Icon integration** - Visual clarity di table headers
**Critical Issues:**
1.**Schema deletedAt SALAH** - Same issue seperti modul PPID lain
2.**Fetch pattern inconsistency** - findUnique pakai fetch manual
3.**Missing delete function** - Cannot delete data
4.**Missing edit function** - Cannot edit data (same as Permohonan Informasi)
---
**Catatan:** **Permohonan Keberatan PPID adalah MODULE DENGAN RETURN VALUE HANDLING TERBAIK** tapi juga **MISSING DELETE & EDIT FUNCTIONS**. Module ini mirip dengan Permohonan Informasi (read-only, no delete/edit).
**Unique Strengths:**
1.**Return value handling** - Best practice (return true/false)
2.**Conditional empty state** - Good UX
3.**Icon integration** - Visual clarity
4.**Validation comprehensive** - Phone length validation
**Priority Action:**
```diff
🔴 FIX INI SEKARANG (30 MENIT + MIGRATION):
File: prisma/schema.prisma
Line: 478
model FormulirPermohonanKeberatan {
id String @id @default(cuid())
name String
email String
notelp String
alasan String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- deletedAt DateTime @default(now())
+ deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
# Lalu jalankan:
bunx prisma db push
# atau
bunx prisma migrate dev --name fix_deletedat_keberatan
```
```diff
🔴 ADD DELETE FUNCTION (45 MENIT):
File: state file
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
permohonanKeberatanInformasi.delete.loading = true;
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["del"][id].delete();
if (res.data?.success) {
toast.success(res.data.message || "Berhasil hapus permohonan keberatan");
await permohonanKeberatanInformasi.findMany.load();
} else {
toast.error(res.data?.message || "Gagal hapus permohonan keberatan");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus");
} finally {
permohonanKeberatanInformasi.delete.loading = false;
}
},
}
```
Setelah fix critical issues, module ini **PRODUCTION-READY** dengan **BEST RETURN VALUE HANDLING**! 🎉
---
## 📚 RECOMMENDED AS REFERENCE FOR OTHER MODULES
**Permohonan Keberatan PPID Module adalah BEST PRACTICE untuk:**
1.**Return value handling** - Return true/false untuk create operation
2.**Conditional empty state** - Different messages untuk search vs empty
3.**Icon integration** - Visual clarity di table headers
4.**Phone validation** - Min/max length validation
**Modules lain bisa belajar dari Permohonan Keberatan:**
- **ALL MODULES:** Use return values untuk handle create success/failure
- **ALL MODULES:** Conditional empty state messages untuk better UX
- **ALL MODULES:** Icon integration untuk visual clarity
- **ALL MODULES:** Specific validation rules (min/max length)
---
**File Location:** `QC/PPID/QC-PERMOHONAN-KEBERATAN-INFORMASI-MODULE.md` 📄

View File

@@ -1,802 +0,0 @@
# QC Summary - PPID Profil Module
**Scope:** Profil PPID (Preview & Edit), Rich Text Editor Forms
**Date:** 2026-02-23
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
---
## 📊 OVERVIEW
| Aspect | Schema | API | UI Admin | State Management | Overall |
|--------|--------|-----|----------|-----------------|---------|
| Profil PPID | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ✅ Baik | 🟡 Perlu fix |
---
## ✅ YANG SUDAH BAIK
### **1. UI/UX Design**
- ✅ Preview layout yang clean dengan logo desa
- ✅ Responsive design (mobile & desktop)
- ✅ Loading states dengan Skeleton
- ✅ Error handling dengan Alert component
- ✅ Empty state handling yang informatif
- ✅ Edit button yang prominent
### **2. File Upload Handling**
- ✅ Dropzone dengan preview image
- ✅ Validasi format gambar (JPEG, JPG, PNG, WEBP)
- ✅ Validasi ukuran file (max 5MB)
- ✅ Tombol hapus preview (IconX di pojok kanan atas)
- ✅ URL.createObjectURL untuk preview lokal
- ✅ Error handling untuk image load (onError fallback)
### **3. Rich Text Editor (Tiptap)**
- ✅ Full-featured editor dengan toolbar lengkap
- ✅ Extensions: Bold, Italic, Underline, Highlight, Link, dll
- ✅ Text alignment (left, center, justify, right)
- ✅ Heading levels (H1-H4)
- ✅ Lists (bullet & ordered)
- ✅ Blockquote, code, superscript, subscript
- ✅ Undo/Redo
- ✅ Sticky toolbar untuk UX yang lebih baik
### **4. Form Component Structure**
- ✅ Modular form components (Biodata, Riwayat, Pengalaman, Unggulan)
- ✅ Reusable EditPPIDEditor component
- ✅ Proper TypeScript typing
- ✅ Error display untuk setiap field
- ✅ Controlled components dengan onChange handler
### **5. State Management - BEST PRACTICES**
- ✅ Proper typing dengan Prisma types
- ✅ Loading state management dengan finally block
- ✅ Error handling yang comprehensive
- ✅ Reset function untuk cleanup
-**originalForm tracking** untuk reset ke data awal
**Code Example (✅ EXCELLENT):**
```typescript
// state file - Line ~85-105
editForm: {
id: "",
form: { ...defaultForm },
originalForm: { ...defaultForm }, // ✅ Track original data
loading: false,
error: null as string | null,
initialize(profileData: ProfilePPIDForm) {
this.id = profileData.id;
const data = {
name: profileData.name || "",
biodata: profileData.biodata || "",
riwayat: profileData.riwayat || "",
pengalaman: profileData.pengalaman || "",
unggulan: profileData.unggulan || "",
imageId: profileData.imageId || "",
};
this.form = { ...data };
this.originalForm = { ...data }; // ✅ Save original
},
updateField(field: keyof typeof defaultForm, value: string) {
this.form[field] = value;
},
// ✅ Reset to original
resetToOriginal() {
this.form = { ...this.originalForm };
toast.info("Data dikembalikan ke kondisi awal");
},
};
```
**Verdict:****SANGAT BAIK** - State management paling baik dibanding modul lain!
---
### **6. Edit Form - Original Data Tracking**
- ✅ Original data state untuk reset form
- ✅ Load data existing dengan benar
- ✅ Preview image dari data lama
- ✅ Reset form mengembalikan ke data original
- ✅ File replacement logic (upload baru jika ada perubahan)
**Code Example (✅ EXCELLENT):**
```typescript
// edit/page.tsx - Line ~100-115
const handleResetForm = () => {
if (!allState.profile.data) return;
// Reset form ke data awal yang di-load
const original = allState.profile.data;
stateProfilePPID.editForm.form = {
name: original.name || '',
imageId: original.imageId || '',
biodata: original.biodata || '',
riwayat: original.riwayat || '',
pengalaman: original.pengalaman || '',
unggulan: original.unggulan || '',
};
// Reset preview gambar juga
setPreviewImage(original.image?.link || null);
setFile(null);
toast.info('Perubahan dibatalkan');
};
```
**Verdict:****SANGAT BAIK** - Original data tracking sudah implementasi dengan sempurna!
---
## ⚠️ ISSUES & SARAN PERBAIKAN
### **🔴 CRITICAL**
#### **1. Schema - deletedAt Default Value SALAH**
**Lokasi:** `prisma/schema.prisma` (line 401)
**Masalah:**
```prisma
model ProfilePPID {
// ...
deletedAt DateTime @default(now()) // ❌ SALAH - selalu punya default value
isActive Boolean @default(true)
}
```
**Dampak:**
- **LOGIC ERROR!** Setiap record baru langsung punya `deletedAt` value (timestamp creation)
- Soft delete tidak berfungsi dengan benar
- Query dengan `where: { deletedAt: null }` tidak akan pernah return data
- Data yang "dihapus" vs data "aktif" tidak bisa dibedakan
**Contoh Issue:**
```prisma
// Record baru dibuat
CREATE ProfilePPID {
name: "PPID 1",
// deletedAt otomatis ter-set ke now() ❌
// isActive: true ✅
}
// Query untuk data aktif (seharusnya return data ini)
prisma.profilePPID.findMany({
where: { deletedAt: null, isActive: true }
})
// ❌ Return kosong! Karena deletedAt sudah ter-set
```
**Rekomendasi:** Fix schema:
```prisma
model ProfilePPID {
// ...
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
isActive Boolean @default(true)
}
```
**Priority:** 🔴 **CRITICAL**
**Effort:** Medium (perlu migration)
**Impact:** **HIGH** (data integrity & soft delete logic)
---
#### **2. HTML Injection Risk - dangerouslySetInnerHTML**
**Lokasi:** `page.tsx` (preview page)
**Masalah:**
```typescript
// Line ~105-110
<Text
fz={{ base: 'sm', md: 'md' }}
ta="justify"
c={colors['blue-button']}
lh={1.5}
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: item.biodata }} // ❌ No sanitization
/>
// Line ~115-120 (Riwayat)
dangerouslySetInnerHTML={{ __html: item.riwayat }} // ❌ No sanitization
// Line ~125-130 (Pengalaman)
dangerouslySetInnerHTML={{ __html: item.pengalaman }} // ❌ No sanitization
// Line ~135-140 (Unggulan)
dangerouslySetInnerHTML={{ __html: item.unggulan }} // ❌ No sanitization
```
**Risk:**
- XSS attack jika admin input script malicious
- Bisa inject iframe, script tag, dll
- Security vulnerability
**Rekomendasi:** Gunakan DOMPurify atau library sanitization:
```typescript
import DOMPurify from 'dompurify';
// Sanitize sebelum render
const sanitizedBiodata = DOMPurify.sanitize(item.biodata);
const sanitizedRiwayat = DOMPurify.sanitize(item.riwayat);
const sanitizedPengalaman = DOMPurify.sanitize(item.pengalaman);
const sanitizedUnggulan = DOMPurify.sanitize(item.unggulan);
<Text
dangerouslySetInnerHTML={{ __html: sanitizedBiodata }}
// ...
/>
```
Atau validasi di backend untuk whitelist tag HTML yang diperbolehkan (hanya `<p>`, `<ul>`, `<li>`, `<strong>`, dll).
**Priority:** 🔴 **HIGH** (**Security concern**)
**Effort:** Low
---
#### **3. State Management - Fetch Pattern Inconsistency**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/profile_ppid/profile_PPID.ts`
**Masalah:** Ada 2 pattern berbeda untuk fetch API:
```typescript
// ❌ Pattern 1: fetch manual (profile.load)
const res = await fetch(`/api/ppid/profileppid/${id}`);
// ❌ Pattern 2: fetch manual (editForm.submit)
const res = await fetch(`/api/ppid/profileppid/${this.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
});
```
**Dampak:**
- Code consistency buruk
- Sulit maintenance
- Type safety tidak konsisten
- Duplikasi logic error handling
- Tidak konsisten dengan modul lain yang sudah migrate ke ApiFetch
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
```typescript
import ApiFetch from "@/lib/api-fetch";
// profile.load
async load(id: string) {
try {
this.loading = true;
this.error = null;
const res = await ApiFetch.api.ppid.profileppid[id].get();
if (res.data?.success) {
this.data = res.data.data;
return res.data.data;
} else {
if (res.data?.message === "Data tidak ditemukan" ||
res.data?.message === "Belum ada data profil PPID yang aktif") {
this.error = res.data.message;
return null;
} else {
throw new Error(res.data?.message || "Gagal memuat data profile");
}
}
} catch (err) {
const msg = (err as Error).message;
this.error = msg;
console.error("Load profile error:", msg);
if (msg !== "Data tidak ditemukan" && msg !== "Belum ada data profil PPID yang aktif") {
toast.error("Gagal memuat data profile");
}
return null;
} finally {
this.loading = false;
}
}
// editForm.submit
async submit() {
const check = templateForm.safeParse(this.form);
if (!check.success) {
toast.error(
check.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")
);
return false;
}
this.loading = true;
this.error = null;
try {
const res = await ApiFetch.api.ppid.profileppid[this.id].put(this.form);
if (res.data?.success) {
toast.success("Berhasil update profile");
this.originalForm = { ...this.form };
return true;
} else {
throw new Error(res.data?.message || "Gagal update profile");
}
} catch (err) {
const msg = (err as Error).message;
this.error = msg;
toast.error(msg);
return false;
} finally {
this.loading = false;
}
}
```
**Priority:** 🔴 High
**Effort:** Medium (refactor di semua methods)
---
### **🟡 MEDIUM**
#### **4. Console.log di Production**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/profile_ppid/profile_PPID.ts`
**Masalah:**
```typescript
// Line ~65
console.error("Load profile error:", msg);
// edit/page.tsx - Line ~65
console.error("Error updating profile:", error);
```
**Rekomendasi:** Gunakan conditional logging:
```typescript
if (process.env.NODE_ENV === 'development') {
console.error("Load profile error:", msg);
}
```
**Priority:** 🟡 Low
**Effort:** Low
---
#### **5. Zod Schema - Error Message Tidak Konsisten**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/profile_ppid/profile_PPID.ts`
**Masalah:**
```typescript
// Line ~6
const templateForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"), // ✅ OK
biodata: z.string().min(3, "Biodata minimal 3 karakter"), // ✅ OK
riwayat: z.string().min(3, "Riwayat minimal 3 karakter"), // ✅ OK
pengalaman: z.string().min(3, "Pengalaman minimal 3 karakter"), // ✅ OK
unggulan: z.string().min(3, "Unggulan minimal 3 karakter"), // ✅ OK
imageId: z.string().min(1, "Gambar wajib dipilih"), // ✅ OK
});
```
**Verdict:****SUDAH BENAR** - Error messages sudah spesifik dan konsisten!
**Priority:** 🟢 None
**Effort:** None
---
#### **6. Missing Validation di Submit Button**
**Lokasi:** `edit/page.tsx`
**Masalah:**
```typescript
// Line ~270-280
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{ ... }}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
```
**Issue:** Button tidak disabled saat submitting atau form invalid. User bisa click multiple times.
**Rekomendasi:** Add disabled state:
```typescript
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={isSubmitting || allState.editForm.loading}
style={{
background: isSubmitting || allState.editForm.loading
? 'linear-gradient(135deg, #cccccc, #999999)'
: `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>
```
**Priority:** 🟡 Low
**Effort:** Low
---
### **🟢 LOW (Minor Polish)**
#### **7. Duplicate useEffect di Editor Component**
**Lokasi:** `editPPIDEditor.tsx`
**Masalah:**
```typescript
// Line ~25-30
useEffect(() => {
if (editor && value && value !== editor.getHTML()) {
editor.commands.setContent(value);
}
}, [editor, value]);
// Line ~32-40
useEffect(() => {
if (!editor) return;
const updateHandler = () => onChange(editor.getHTML());
editor.on('update', updateHandler);
return () => {
editor.off('update', updateHandler);
};
}, [editor, onChange]);
```
**Issue:** Ada 2 useEffect yang handle editor update. Yang pertama set content, yang kedua handle onChange. Bisa digabung untuk clarity.
**Rekomendasi:** Simplify:
```typescript
const editor = useEditor({
extensions: [...],
content: value, // Set content directly
onUpdate({ editor }) {
onChange(editor.getHTML());
},
});
// Remove first useEffect, keep second for cleanup
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **8. Form Label Inconsistency**
**Lokasi:** `edit/page.tsx`
**Masalah:**
```typescript
// Line ~170
<Text fw="bold">Nama Perbekel</Text>
// Should be:
<Text fw="bold">Nama PPID</Text>
```
**Issue:** Label "Nama Perbekel" tidak sesuai dengan context PPID. Ini profil PPID, bukan perbekel.
**Rekomendasi:** Fix label:
```typescript
<Text fw="bold">Nama PPID</Text>
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **9. Image Label Text Size**
**Lokasi:** `edit/page.tsx`
**Masalah:**
```typescript
// Line ~180
<Text fz={"md"} fw={"bold"}>Gambar</Text>
// Should be more specific:
<Text fz={"md"} fw={"bold"}>Foto Profil PPID</Text>
```
**Rekomendasi:** More descriptive label:
```typescript
<Text fz={"md"} fw={"bold"}>Foto Profil PPID</Text>
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **10. Dropzone Accept Format**
**Lokasi:** `edit/page.tsx`
**Masalah:**
```typescript
// Line ~190
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
// Missing mime type specifications
```
**Rekomendasi:** Add full mime types:
```typescript
accept={{
'image/jpeg': ['.jpeg', '.jpg'],
'image/png': ['.png'],
'image/webp': ['.webp'],
}}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **11. Preview Page - Title Order Inconsistency**
**Lokasi:** `page.tsx`
**Masalah:**
```typescript
// Line ~55
<Title order={4} ...>
PROFIL PIMPINAN BADAN PUBLIK DESA DARMASABA
</Title>
// Line ~90
<Title order={3} ...>
{item.name}
</Title>
// Line ~100
<Title order={3} ...>
Biodata
</Title>
```
**Issue:** Title hierarchy tidak konsisten. Subtitle (order 4) lebih kecil dari content titles (order 3).
**Rekomendasi:** Samakan hierarchy:
```typescript
// Main title: order={2} atau order={3}
// Section titles: order={4}
// Name: order={3}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **12. Missing Search Feature**
**Lokasi:** N/A (Single record module)
**Verdict:****NOT APPLICABLE** - Module ini hanya handle single record, search tidak diperlukan.
**Priority:** 🟢 None
**Effort:** None
---
#### **13. Button Loading State Tidak Konsisten**
**Lokasi:** `edit/page.tsx`
**Masalah:**
```typescript
// Line ~270-280
<Button
onClick={handleSubmit}
// ...
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
```
**Issue:** Button hanya check `isSubmitting` local state, tidak check `allState.editForm.loading` dari global state.
**Rekomendasi:** Check both states:
```typescript
disabled={isSubmitting || allState.editForm.loading}
{isSubmitting || allState.editForm.loading ? (
<Loader size="sm" color="white" />
) : (
'Simpan'
)}
```
**Priority:** 🟢 Low
**Effort:** Low
---
## 📋 RINGKASAN ACTION ITEMS
| Priority | Issue | Module | Impact | Effort | Status |
|----------|-------|--------|--------|--------|--------|
| 🔴 P0 | **Schema deletedAt default SALAH** | Schema | **CRITICAL** | Medium | **MUST FIX** |
| 🔴 P0 | **HTML injection risk** | UI | **HIGH (Security)** | Low | **Should fix** |
| 🔴 P1 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
| 🟡 M | Console.log in production | State | Low | Low | Optional |
| 🟡 M | Missing validation di submit button | UI | Low | Low | Should fix |
| 🟢 L | Duplicate useEffect di editor | Editor | Low | Low | Optional |
| 🟢 L | Form label inconsistency | UI | Low | Low | Should fix |
| 🟢 L | Image label text size | UI | Low | Low | Optional |
| 🟢 L | Dropzone accept format | UI | Low | Low | Optional |
| 🟢 L | Title order inconsistency | UI | Low | Low | Optional |
| 🟢 L | Button loading state inconsistency | UI | Low | Low | Optional |
---
## ✅ KESIMPULAN
### **Overall Quality: 🟢 BAIK (8/10)**
**Strengths:**
1. ✅ UI/UX clean & responsive
2. ✅ File upload handling solid
3.**Rich Text Editor** full-featured (Tiptap)
4.**Modular form components** (Biodata, Riwayat, Pengalaman, Unggulan)
5.**State management BEST PRACTICES** (originalForm tracking)
6.**Edit form reset SANGAT BAIK** (original data tracking sempurna)
7. ✅ Error handling comprehensive
8. ✅ Loading state management dengan finally block
9. ✅ Modal konfirmasi hapus untuk user safety
**Critical Issues:**
1. ⚠️ **Schema deletedAt default SALAH** - Logic error untuk soft delete (CRITICAL)
2. ⚠️ **HTML injection risk** - dangerouslySetInnerHTML tanpa sanitization (HIGH Security)
3. ⚠️ Fetch method pattern inconsistency (ApiFetch vs fetch manual)
**Areas for Improvement:**
1. ⚠️ **Fix schema deletedAt** dari `@default(now())` ke `@default(null)` dengan nullable
2. ⚠️ **Fix HTML injection** dengan DOMPurify atau backend validation
3. ⚠️ **Refactor fetch methods** untuk gunakan ApiFetch consistently
4. ⚠️ **Add disabled state** di submit button
5. ⚠️ **Fix form labels** (Nama Perbekel → Nama PPID)
**Recommended Next Steps:**
1. **🔴 CRITICAL: Fix schema deletedAt** - 30 menit (perlu migration)
2. **🔴 HIGH: Fix HTML injection** dengan DOMPurify - 30 menit
3. **🔴 HIGH: Refactor fetch methods** ke ApiFetch - 1 jam
4. **🟡 MEDIUM: Add disabled state** di submit button - 15 menit
5. **🟢 LOW: Fix form labels** - 10 menit
6. **🟢 LOW: Polish minor issues** - 30 menit
---
## 📈 COMPARISON WITH OTHER MODULES
| Aspect | Profil | Desa Anti Korupsi | SDGs Desa | APBDes | Prestasi Desa | **PPID Profil** | Notes |
|--------|--------|-------------------|-----------|--------|---------------|-----------------|-------|
| Fetch Pattern | ⚠️ Mixed | ⚠️ Mixed | ⚠️ Mixed | ⚠️ Mixed | ⚠️ Mixed | ⚠️ Mixed | All perlu refactor |
| Loading State | ⚠️ Some missing | ⚠️ Some missing | ⚠️ Missing | ✅ Good | ⚠️ findUnique missing | ✅ **Good** | PPID salah satu yang terbaik |
| Edit Form Reset | ✅ Good | ✅ Good | ✅ Good | ✅ Good | ✅ Good | ✅ **EXCELLENT** | **PPID paling baik** (originalForm tracking) |
| Type Safety | ⚠️ Some `any` | ⚠️ Some `any` | ⚠️ Some `any` | ⚠️ Some `any` | ⚠️ Some `any` | ✅ **Good** | PPID typing lebih baik |
| File Upload | ✅ Images | ✅ Documents | ✅ Images | ✅ Dual | ✅ Images | ✅ Images | Similar |
| Error Handling | ✅ Good | ✅ Good (better) | ✅ Good | ✅ Good | ✅ Good | ✅ **Good** | Consistent |
| **Schema deletedAt** | ⚠️ Issue | ⚠️ Issue | ⚠️ Issue | ✅ Good | ❌ **WRONG** | ❌ **WRONG** | **PPID CRITICAL** |
| HTML Injection | ⚠️ Present | ⚠️ Present | N/A | N/A | ⚠️ Present | ⚠️ **Present** | Security concern |
| Rich Text Editor | ✅ Present | ✅ Present | N/A | N/A | ✅ Present | ✅ **Best** | **PPID editor paling lengkap** |
| Modular Forms | ❌ None | ❌ None | N/A | ❌ None | ❌ None | ✅ **YES** | **PPID unique feature** |
| State Management | ⚠️ Good | ⚠️ Good | ⚠️ Good | ⚠️ Good | ⚠️ Good | ✅ **BEST** | **PPID state management terbaik** |
---
## 🎯 UNIQUE FEATURES OF PPID PROFIL MODULE
**Most Advanced Module:**
1.**Rich Text Editor (Tiptap)** - Full-featured dengan toolbar lengkap
2.**Modular Form Components** - Biodata, Riwayat, Pengalaman, Unggulan forms
3.**originalForm Tracking** - State management best practice (unique to PPID)
4.**Single Record Pattern** - Handle "edit" special ID untuk single profile
5.**Comprehensive Error Handling** - Special handling untuk "data not found" cases
**Best Practices:**
1.**State management PALING BAIK** dibanding semua modul lain
2.**Edit form reset PALING BAIK** (originalForm tracking sempurna)
3.**Type safety LEBIH BAIK** (minimal any usage)
4.**Loading state management PROPER** (dengan finally block)
5.**Modular component design** (reusable forms)
**Critical Issues:**
1.**Schema deletedAt SALAH** - sama seperti SDGs, Desa Anti Korupsi, Prestasi Desa
2.**HTML injection risk** - sama seperti modul lain yang pakai rich text
---
**Catatan:** Secara keseluruhan, modul **PPID Profil adalah YANG PALING BAIK** dibanding semua modul yang sudah di-QC. State management-nya adalah best practice dengan originalForm tracking yang sempurna. Rich Text Editor implementation juga paling advanced.
**Unique Strengths:**
1.**State management terbaik** - originalForm tracking untuk reset yang sempurna
2.**Rich Text Editor terlengkap** - Tiptap dengan semua extensions
3.**Modular form design** - Reusable components untuk setiap section
4.**Type safety lebih baik** - Minimal any usage
**Priority Action:**
```diff
🔴 FIX INI SEKARANG (30 MENIT + MIGRATION):
File: prisma/schema.prisma
Line: 401
model ProfilePPID {
// ...
- deletedAt DateTime @default(now())
+ deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
# Lalu jalankan:
bunx prisma db push
# atau
bunx prisma migrate dev --name fix_deletedat_default_ppid
```
```diff
🔴 FIX HTML INJECTION (30 MENIT):
File: src/app/admin/(dashboard)/ppid/profil-ppid/page.tsx
+ import DOMPurify from 'dompurify';
// Line ~105
- dangerouslySetInnerHTML={{ __html: item.biodata }}
+ dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.biodata) }}
// Repeat for riwayat, pengalaman, unggulan
```
Setelah fix critical issues, module ini **PRODUCTION-READY** dan bisa jadi **REFERENCE** untuk modul lain! 🎉
---
## 📚 RECOMMENDED AS REFERENCE FOR OTHER MODULES
**PPID Profil Module adalah BEST PRACTICE untuk:**
1.**State management** - originalForm tracking pattern
2.**Edit form reset** - Comprehensive reset logic
3.**Modular form components** - Reusable design pattern
4.**Rich Text Editor** - Tiptap implementation
5.**Type safety** - Proper TypeScript typing
**Modules lain bisa belajar dari PPID Profil:**
- APBDes: Implement originalForm tracking
- Prestasi Desa: Implement originalForm tracking
- SDGs Desa: Implement originalForm tracking
- Desa Anti Korupsi: Implement originalForm tracking
- Profil (Media Sosial, Program Inovasi): Implement originalForm tracking
---
**File Location:** `QC/PPID/QC-PPID-PROFIL-MODULE.md` 📄

View File

@@ -1,936 +0,0 @@
# QC Summary - Struktur PPID Module
**Scope:** Struktur Organisasi (Organization Chart), Pegawai PPID, Posisi Organisasi
**Date:** 2026-02-23
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
---
## 📊 OVERVIEW
| Sub-Module | Schema | API | UI Admin | State Management | Overall |
|------------|--------|-----|----------|-----------------|---------|
| Struktur Organisasi | ✅ Baik | ✅ Baik | ✅ **Excellent** | ✅ Baik | 🟢 |
| Posisi Organisasi | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 |
| Pegawai PPID | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ⚠️ Ada issue | 🟡 |
---
## ✅ YANG SUDAH BAIK
### **1. UI/UX - Organization Chart (UNIQUE FEATURE!)**
-**PrimeReact OrganizationChart** - Visual hierarchy yang excellent
- ✅ Interactive tree structure dengan expand/collapse
- ✅ Custom node template dengan foto, nama, dan posisi
- ✅ Responsive design dengan overflow handling
- ✅ Empty state yang informatif
- ✅ Loading state dengan spinner
**Code Example (✅ EXCELLENT):**
```typescript
// struktur-organisasi/page.tsx - Line ~45-75
const posisiMap = new Map<string, any>();
const aktifPegawai = stateOrganisasi.findManyAll.data?.filter(p => p.isActive);
for (const pegawai of aktifPegawai) {
const posisiId = pegawai.posisi.id;
if (!posisiMap.has(posisiId)) {
posisiMap.set(posisiId, {
...pegawai.posisi,
pegawaiList: [],
children: [],
});
}
posisiMap.get(posisiId)!.pegawaiList.push(pegawai);
}
// Build tree structure
let root: any[] = [];
posisiMap.forEach((posisi) => {
if (posisi.parentId) {
const parent = posisiMap.get(posisi.parentId);
if (parent) {
parent.children.push(posisi);
}
} else {
root.push(posisi);
}
});
// Convert to OrganizationChart format
function toOrgChartFormat(node: any): any {
return {
expanded: true,
type: 'person',
styleClass: 'p-person',
data: {
name: node.pegawaiList?.[0]?.namaLengkap || 'Belum ada pegawai',
status: node.nama,
image: node.pegawaiList?.[0]?.image?.link || '/img/default.png',
},
children: node.children.map(toOrgChartFormat),
};
}
```
**Verdict:****UNIQUE & EXCELLENT** - Satu-satunya modul dengan organization chart visual!
---
### **2. File Upload Handling**
- ✅ Dropzone dengan preview image
- ✅ Validasi format gambar (JPEG, JPG, PNG, WEBP)
- ✅ Validasi ukuran file (max 5MB)
- ✅ Tombol hapus preview (IconX di pojok kanan atas)
- ✅ URL.createObjectURL untuk preview lokal
### **3. Form Validation**
- ✅ Zod schema untuk validasi typed
- ✅ Email validation dengan regex
- ✅ Required field validation
- ✅ isFormValid() check sebelum submit
- ✅ Error toast dengan pesan spesifik
- ✅ Button disabled saat invalid/loading
### **4. CRUD Operations**
- ✅ Create dengan upload file
- ✅ FindMany dengan pagination & search
- ✅ FindUnique untuk detail
- ✅ Delete dengan hard delete
- ✅ Update dengan file replacement
-**Non-active feature** untuk soft disable pegawai
### **5. State Management**
- ✅ Proper typing dengan Prisma types
- ✅ Loading state management dengan finally block
- ✅ Error handling yang comprehensive
- ✅ Reset function untuk cleanup
- ✅ findManyAll untuk organization chart data
**Code Example (✅ GOOD):**
```typescript
// state file - Line ~270-290
findManyAll: {
data: null as Prisma.PegawaiPPIDGetPayload<{...}>[] | null,
loading: false,
search: "",
load: async (search = "") => {
posisiOrganisasi.findManyAll.loading = true; // ✅ Start loading
posisiOrganisasi.findManyAll.search = search;
try {
const query: any = { search };
if (search) query.search = search;
const res = await ApiFetch.api.ppid.strukturppid.pegawai["find-many-all"].get({ query });
if (res.status === 200 && res.data?.success) {
posisiOrganisasi.findManyAll.data = res.data.data || [];
}
} catch (error) {
console.error("Error loading pegawai:", error);
posisiOrganisasi.findManyAll.data = [];
} finally {
posisiOrganisasi.findManyAll.loading = false; // ✅ Stop loading
}
},
}
```
**Verdict:****BAIK** - Loading state management sudah proper!
---
### **6. Edit Form - Original Data Tracking**
- ✅ Original data state untuk reset form
- ✅ Load data existing dengan benar
- ✅ Preview image dari data lama
- ✅ Reset form mengembalikan ke data original
- ✅ File replacement logic (upload baru jika ada perubahan)
**Code Example (✅ GOOD):**
```typescript
// edit/page.tsx - Line ~80-115
const [originalData, setOriginalData] = useState({
namaLengkap: "",
gelarAkademik: "",
imageId: "",
tanggalMasuk: "",
email: "",
telepon: "",
alamat: "",
posisiId: "",
imageUrl: "",
isActive: true,
});
// Load data
const data = await stateOrganisasi.edit.load(id);
setOriginalData({
...data,
imageUrl: data.image?.link || '',
});
setPreviewImage(data.image?.link || null);
// Line ~135 - Handle reset
const handleResetForm = () => {
setFormData({
namaLengkap: originalData.namaLengkap,
gelarAkademik: originalData.gelarAkademik,
imageId: originalData.imageId,
tanggalMasuk: originalData.tanggalMasuk,
email: originalData.email,
telepon: originalData.telepon,
alamat: originalData.alamat,
posisiId: originalData.posisiId,
isActive: originalData.isActive,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
```
**Verdict:****BAIK** - Original data tracking sudah implementasi dengan baik!
---
### **7. Unique Features**
-**Organization Chart** - Visual hierarchy tree (UNIQUE!)
-**Hierarchical Positions** - Parent-child relationships
-**Active/Non-active Toggle** - Soft disable untuk pegawai
-**Email Validation** - Regex validation untuk email format
-**Date Input Handling** - Proper date formatting untuk tanggal masuk
---
## ⚠️ ISSUES & SARAN PERBAIKAN
### **🔴 CRITICAL**
#### **1. Schema - Missing deletedAt for Soft Delete**
**Lokasi:** `prisma/schema.prisma` (line 327-332, 343-351)
**Masalah:**
```prisma
model PosisiOrganisasiPPID {
// ...
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// ❌ MISSING: deletedAt field untuk soft delete
}
model PegawaiPPID {
// ...
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// ❌ MISSING: deletedAt field untuk soft delete
}
```
**Dampak:**
- **INCONSISTENT!** Model `StrukturOrganisasiPPID` punya `deletedAt`, tapi Posisi dan Pegawai tidak
- Hard delete vs soft delete inconsistency
- Data integrity issue saat delete (data hilang permanen)
- Tidak bisa restore data yang ter-delete
**Rekomendasi:** Add deletedAt field:
```prisma
model PosisiOrganisasiPPID {
// ...
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime? @default(null) // ✅ Add for soft delete
}
model PegawaiPPID {
// ...
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime? @default(null) // ✅ Add for soft delete
}
```
**Priority:** 🔴 **HIGH**
**Effort:** Medium (perlu migration)
**Impact:** **HIGH** (data integrity & consistency)
---
#### **2. State Management - Fetch Pattern Inconsistency**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/struktur_ppid/struktur_PPID.ts`
**Masalah:** Ada 2 pattern berbeda untuk fetch API:
```typescript
// ❌ Pattern 1: ApiFetch (create, findMany, findManyAll)
const res = await ApiFetch.api.ppid.strukturppid.pegawai["create"].post(pegawai.create.form);
const res = await ApiFetch.api.ppid.strukturppid.pegawai["find-many"].get({ query });
const res = await ApiFetch.api.ppid.strukturppid.pegawai["find-many-all"].get({ query });
// ❌ Pattern 2: fetch manual (findUnique, edit, delete, nonActive)
const res = await fetch(`/api/ppid/strukturppid/pegawai/${id}`);
const res = await fetch(`/api/ppid/strukturppid/pegawai/del/${id}`, { method: "DELETE" });
const res = await fetch(`/api/ppid/strukturppid/pegawai/non-active/${id}`, { method: "DELETE" });
```
**Dampak:**
- Code consistency buruk
- Sulit maintenance
- Type safety tidak konsisten
- Duplikasi logic error handling
**Rekomendasi:** Gunakan **ApiFetch** untuk semua operasi:
```typescript
// ✅ Unified pattern
async load(id: string) {
try {
const res = await ApiFetch.api.ppid.strukturppid.pegawai[id].get();
if (res.data?.success) {
const data = res.data.data;
this.id = data.id;
this.form = {
namaLengkap: data.namaLengkap,
gelarAkademik: data.gelarAkademik,
imageId: data.imageId,
tanggalMasuk: data.tanggalMasuk,
email: data.email,
telepon: data.telepon,
alamat: data.alamat,
posisiId: data.posisiId,
isActive: data.isActive,
};
return data;
} else {
throw new Error(res.data?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error loading pegawai:", error);
toast.error(error instanceof Error ? error.message : "Gagal memuat data");
return null;
}
}
async byId(id: string) {
try {
const res = await ApiFetch.api.ppid.strukturppid.pegawai["del"][id].delete();
if (res.data?.success) {
toast.success(res.data.message || "Berhasil hapus pegawai");
await pegawai.findMany.load();
} else {
toast.error(res.data?.message || "Gagal hapus pegawai");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus");
}
}
```
**Priority:** 🔴 High
**Effort:** Medium (refactor di semua methods)
---
#### **3. HTML Injection Risk - dangerouslySetInnerHTML**
**Lokasi:**
- `posisi-organisasi/page.tsx` (line ~95, 155)
- `posisi-organisasi/create/page.tsx` (CreateEditor component)
- `posisi-organisasi/[id]/edit/page.tsx` (EditEditor component)
**Masalah:**
```typescript
// ❌ Direct HTML render tanpa sanitization
<Text
fz="sm"
lh={1.5}
c="dimmed"
lineClamp={1}
dangerouslySetInnerHTML={{ __html: item.deskripsi || '-' }}
/>
```
**Risk:**
- XSS attack jika admin input script malicious
- Bisa inject iframe, script tag, dll
- Security vulnerability
**Rekomendasi:** Gunakan DOMPurify atau library sanitization:
```typescript
import DOMPurify from 'dompurify';
// Sanitize sebelum render
const sanitizedDeskripsi = DOMPurify.sanitize(item.deskripsi);
<Text
dangerouslySetInnerHTML={{ __html: sanitizedDeskripsi }}
// ...
/>
```
Atau validasi di backend untuk whitelist tag HTML yang diperbolehkan.
**Priority:** 🔴 **HIGH** (**Security concern**)
**Effort:** Low
---
### **🟡 MEDIUM**
#### **4. Console.log di Production**
**Lokasi:** Multiple places di state file
**Masalah:**
```typescript
// Line ~65
console.error("Load struktur error:", errorMessage);
// Line ~130
console.error("Update struktur error:", errorMessage);
// Line ~220
console.error("Failed to fetch posisiOrganisasi:", res.statusText);
// Line ~224
console.error("Error fetching posisiOrganisasi:", error);
// Line ~370
console.error("Gagal fetch posisi organisasi paginated:", err);
// Line ~400
console.error("Failed to load posisiOrganisasi:", res.data?.message);
// Line ~404
console.error("Error loading posisiOrganisasi:", error);
// ... dan banyak lagi
```
**Rekomendasi:** Gunakan conditional logging:
```typescript
if (process.env.NODE_ENV === 'development') {
console.error("Error:", error);
}
```
**Priority:** 🟡 Low
**Effort:** Low
---
#### **5. Type Safety - Any Usage**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/struktur_ppid/struktur_PPID.ts`
**Masalah:**
```typescript
// Line ~190
const query: any = { page, limit: appliedLimit }; // ❌ Using 'any'
if (search) query.search = search;
// Line ~215
const query: any = { search }; // ❌ Using 'any'
if (search) query.search = search;
// Line ~365
const query: any = { page, limit }; // ❌ Using 'any'
if (search) query.search = search;
// Line ~395
const query: any = { search }; // ❌ Using 'any'
if (search) query.search = search;
```
**Rekomendasi:** Gunakan typed query:
```typescript
// Define type
interface FindManyQuery {
page: number | string;
limit?: number | string;
search?: string;
}
// Use typed query
const query: FindManyQuery = { page, limit: appliedLimit };
if (search) query.search = search;
```
**Priority:** 🟡 Medium
**Effort:** Low
---
#### **6. Error Message Tidak Konsisten**
**Lokasi:** Multiple places
**Masalah:**
```typescript
// Create posisi - Line ~180
toast.error("Terjadi kesalahan saat menambahkan posisi");
// Create pegawai - Line ~280
toast.error("Terjadi kesalahan saat menambahkan pegawai");
// Delete - Line ~430
toast.error("Terjadi kesalahan saat menghapus posisi organisasi");
// Edit - Line ~520
toast.error("Gagal memuat data");
// Update - Line ~560
toast.error("Gagal mengupdate posisi organisasi");
```
**Issue:**
- Generic error messages
- Inconsistent patterns ("Terjadi kesalahan" vs "Gagal")
- Tidak spesifik ke resource type
**Rekomendasi:** Standardisasi error messages:
```typescript
// Pattern: "[Action] [resource] gagal"
toast.error("Menambahkan Posisi Organisasi gagal");
toast.error("Menghapus Posisi Organisasi gagal");
toast.error("Memuat data Posisi Organisasi gagal");
toast.error("Memperbarui data Posisi Organisasi gagal");
```
**Priority:** 🟡 Low
**Effort:** Low
---
#### **7. Zod Schema - Error Message Tidak Konsisten**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/struktur_ppid/struktur_PPID.ts`
**Masalah:**
```typescript
// Line ~170
const templatePosisiOrganisasi = z.object({
nama: z.string().min(1, "Nama harus diisi"), // ✅ OK
deskripsi: z.string().optional(), // ⚠️ No min message
hierarki: z.number().int().positive("Hierarki harus angka positif"), // ✅ OK
});
// Line ~450
const templatePegawai = z.object({
namaLengkap: z.string().min(1, "Nama wajib diisi"), // ✅ OK
gelarAkademik: z.string().min(1, "Gelar Akademik wajib diisi"), // ✅ OK
imageId: z.string().min(1, "Gambar wajib dipilih"), // ✅ OK
tanggalMasuk: z.string().min(1, "Tanggal masuk wajib diisi"), // ✅ OK
email: z.string().email("Email tidak valid").optional(), // ⚠️ Optional tapi ada validation
telepon: z.string().min(1, "Telepom wajib diisi"), // ❌ Typo: "Telepom"
alamat: z.string().min(1, "Alamat wajib diisi"), // ✅ OK
posisiId: z.string().min(1, "Posisi wajib diisi"), // ✅ OK
isActive: z.boolean().default(true), // ✅ OK
});
```
**Rekomendasi:** Fix typo dan standardisasi:
```typescript
const templatePegawai = z.object({
namaLengkap: z.string().min(1, "Nama lengkap wajib diisi"),
gelarAkademik: z.string().min(1, "Gelar akademik wajib diisi"),
imageId: z.string().min(1, "Foto profil wajib diunggah"),
tanggalMasuk: z.string().min(1, "Tanggal masuk wajib diisi"),
email: z.string().email("Format email tidak valid").optional().or(z.literal('')),
telepon: z.string().min(1, "Nomor telepon wajib diisi"), // ✅ Fix typo
alamat: z.string().min(1, "Alamat wajib diisi"),
posisiId: z.string().min(1, "Posisi wajib dipilih"),
isActive: z.boolean().default(true),
});
```
**Priority:** 🟡 Low
**Effort:** Low
---
### **🟢 LOW (Minor Polish)**
#### **8. Pagination onChange Tidak Include Search**
**Lokasi:** `pegawai/page.tsx`
**Masalah:**
```typescript
// Line ~170
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10); // ⚠️ Missing search parameter
window.scrollTo(0, 0);
}}
total={totalPages}
// ...
/>
```
**Issue:** Saat ganti page, search query hilang.
**Rekomendasi:** Include search:
```typescript
onChange={(newPage) => {
load(newPage, 10, debouncedSearch); // ✅ Include search
window.scrollTo(0, 0);
}}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **9. Missing Loading State di Submit Button**
**Lokasi:** `pegawai/create/page.tsx`, `pegawai/[id]/edit/page.tsx`
**Masalah:**
```typescript
// create/page.tsx - Line ~240
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
// ...
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
```
**Issue:** Button tidak check `stateOrganisasi.create.loading` dari global state.
**Rekomendasi:** Check both states:
```typescript
disabled={!isFormValid() || isSubmitting || stateOrganisasi.create.loading}
{isSubmitting || stateOrganisasi.create.loading ? (
<Loader size="sm" color="white" />
) : (
'Simpan'
)}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **10. Duplicate Error Logging**
**Lokasi:** Multiple files
**Masalah:**
```typescript
// edit/page.tsx - Line ~120
} catch (error) {
console.error('Error loading pegawai:', error); // ❌ Duplicate
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui data pegawai');
}
// edit/page.tsx - Line ~160
} catch (error) {
console.error('Error updating pegawai:', error); // ❌ Duplicate
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui data pegawai');
}
```
**Rekomendasi:** Cukup satu logging yang informatif:
```typescript
} catch (error) {
console.error('Failed to load Pegawai:', err);
toast.error('Gagal memuat data Pegawai');
}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **11. Button Label Inconsistency**
**Lokasi:** Multiple files
**Masalah:**
```typescript
// create/page.tsx - Line ~230
<Button ...>Reset</Button>
// edit/page.tsx - Line ~140
<Button ...>Batal</Button>
// Should be consistent: "Reset" atau "Batal"
```
**Rekomendasi:** Standardisasi:
```typescript
// Create: "Reset"
// Edit: "Batal" (lebih descriptive untuk cancel changes)
// OR both: "Reset" / "Batal"
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **12. Search Placeholder Tidak Spesifik**
**Lokasi:**
- `pegawai/page.tsx`: `placeholder='Cari nama pegawai atau posisi...'` ✅ Spesifik
- `posisi-organisasi/page.tsx`: `placeholder='Cari posisi organisasi...'` ✅ OK
**Verdict:****SUDAH BENAR** - Placeholder sudah spesifik.
**Priority:** 🟢 None
**Effort:** None
---
#### **13. Non-Active Endpoint Method**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/struktur_ppid/struktur_PPID.ts`
**Masalah:**
```typescript
// Line ~490
nonActive: {
loading: false,
async byId(id: string) {
// ...
const res = await fetch(`/api/ppid/strukturppid/pegawai/non-active/${id}`, {
method: "DELETE", // ⚠️ Biasanya nonActive pakai PATCH atau PUT
});
// ...
},
}
```
**Issue:** Method "DELETE" untuk non-active agak confusing. Biasanya pakai "PATCH" atau "PUT".
**Rekomendasi:** Consider using PATCH:
```typescript
const res = await fetch(`/api/ppid/strukturppid/pegawai/non-active/${id}`, {
method: "PATCH", // ✅ More semantic for toggle active/inactive
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive: false }),
});
```
**Priority:** 🟢 Low
**Effort:** Low (perlu update API juga)
---
#### **14. OrganizationChart - Missing Expand/Collapse Controls**
**Lokasi:** `struktur-organisasi/page.tsx`
**Masalah:**
```typescript
// Line ~80
<OrganizationChart value={chartData} nodeTemplate={nodeTemplate} />
```
**Issue:** Tidak ada controls untuk expand/collapse all nodes.
**Rekomendasi:** Add toggle button:
```typescript
const [expanded, setExpanded] = useState(true);
const toggleAll = () => {
const newExpanded = !expanded;
setExpanded(newExpanded);
// Update chartData dengan expanded: newExpanded untuk semua nodes
};
return (
<Box>
<Group justify="flex-end" mb="md">
<Button size="xs" onClick={toggleAll}>
{expanded ? 'Collapse All' : 'Expand All'}
</Button>
</Group>
<OrganizationChart value={chartData} nodeTemplate={nodeTemplate} />
</Box>
);
```
**Priority:** 🟢 Low
**Effort:** Low
---
## 📋 RINGKASAN ACTION ITEMS
| Priority | Issue | Module | Impact | Effort | Status |
|----------|-------|--------|--------|--------|--------|
| 🔴 P0 | **Schema missing deletedAt** | Schema | **HIGH** | Medium | **MUST FIX** |
| 🔴 P0 | Fetch method inconsistency | State | Medium | Medium | Perlu refactor |
| 🔴 P1 | **HTML injection risk** | UI | **HIGH (Security)** | Low | **Should fix** |
| 🟡 M | Console.log in production | State | Low | Low | Optional |
| 🟡 M | Type safety (any usage) | State | Low | Low | Optional |
| 🟡 M | Error message inconsistency | State/UI | Low | Low | Optional |
| 🟡 M | Zod schema typo ("Telepom") | State | Low | Low | Should fix |
| 🟢 L | Pagination missing search param | Pegawai UI | Low | Low | Should fix |
| 🟢 L | Missing loading state di submit button | UI | Low | Low | Optional |
| 🟢 L | Duplicate error logging | UI | Low | Low | Optional |
| 🟢 L | Button label inconsistency | UI | Low | Low | Optional |
| 🟢 L | Non-active endpoint method | API | Low | Low | Optional |
| 🟢 L | OrganizationChart expand/collapse controls | UI | Low | Low | Nice to have |
---
## ✅ KESIMPULAN
### **Overall Quality: 🟢 BAIK (8/10)**
**Strengths:**
1.**Organization Chart** - Unique visual hierarchy feature (EXCELLENT!)
2. ✅ UI/UX clean & responsive
3. ✅ File upload handling solid
4. ✅ Form validation comprehensive (email validation, required fields)
5. ✅ State management terstruktur (Valtio)
6.**Edit form reset sudah benar** (original data tracking)
7.**Active/Non-active toggle** untuk pegawai
8. ✅ Loading state management dengan finally block
9. ✅ findManyAll untuk organization chart data
**Critical Issues:**
1. ⚠️ **Schema missing deletedAt** - Inconsistency dengan StrukturOrganisasiPPID (HIGH)
2. ⚠️ Fetch method pattern inconsistency (ApiFetch vs fetch manual)
3. ⚠️ **HTML injection risk** di deskripsi posisi (HIGH Security)
**Areas for Improvement:**
1. ⚠️ **Add deletedAt field** ke PosisiOrganisasiPPID dan PegawaiPPID
2. ⚠️ **Refactor fetch methods** untuk gunakan ApiFetch consistently
3. ⚠️ **Fix HTML injection** dengan DOMPurify atau backend validation
4. ⚠️ **Fix typo** "Telepom" → "Telepon" di Zod schema
5. ⚠️ **Improve type safety** dengan remove `any` usage
**Recommended Next Steps:**
1. **🔴 CRITICAL: Add schema deletedAt** - 30 menit (perlu migration)
2. **🔴 HIGH: Fix HTML injection** dengan DOMPurify - 30 menit
3. **🔴 HIGH: Refactor fetch methods** ke ApiFetch - 1 jam
4. **🟡 MEDIUM: Fix typo** di Zod schema - 5 menit
5. **🟢 LOW: Add pagination search param** - 10 menit
6. **🟢 LOW: Polish minor issues** - 30 menit
---
## 📈 COMPARISON WITH OTHER MODULES
| Module | Unique Features | Schema | State | Edit Reset | Overall |
|--------|----------------|--------|-------|------------|---------|
| Profil | ❌ None | ✅ Good | ⚠️ Good | ✅ Good | 🟢 |
| Desa Anti Korupsi | ❌ None | ⚠️ deletedAt | ⚠️ Good | ✅ Good | 🟢 |
| SDGs Desa | ❌ None | ⚠️ deletedAt | ⚠️ Good | ✅ Good | 🟢 |
| APBDes | ✅ Dual upload, Items hierarchy | ✅ **Best** | ⚠️ Good | ✅ Good | 🟢 |
| Prestasi Desa | ❌ None | ⚠️ deletedAt | ⚠️ Good | ✅ Good | 🟢 |
| PPID Profil | ✅ Rich Text, Modular forms | ⚠️ deletedAt | ✅ **Best** | ✅ **Excellent** | 🟢⭐ |
| **Struktur PPID** | ✅ **Org Chart**, Hierarchy, Non-active | ⚠️ Inconsistent | ✅ Good | ✅ Good | 🟢 |
**Struktur PPID Highlights:**
-**UNIQUE:** Organization Chart visualization (no other module has this!)
-**UNIQUE:** Hierarchical position structure (parent-child)
-**UNIQUE:** Active/Non-active toggle feature
-**GOOD:** Email validation dengan regex
- ⚠️ **ISSUE:** Schema inconsistency (deletedAt missing di 2 models)
---
## 🎯 UNIQUE FEATURES OF STRUKTUR PPID MODULE
**Most Unique Module:**
1.**PrimeReact OrganizationChart** - Visual tree hierarchy (UNIQUE!)
2.**Parent-child position relationships** - Hierarchical structure
3.**Active/Non-active toggle** - Soft disable tanpa delete
4.**Email validation** - Regex validation untuk email format
5.**findManyAll pattern** - Load all data untuk organization chart
**Best Practices:**
1. ✅ Organization chart implementation excellent
2. ✅ Loading state management proper (dengan finally block)
3. ✅ Edit form reset comprehensive (original data tracking)
4. ✅ Email validation di form (create & edit)
5. ✅ Date input handling untuk tanggal masuk
**Critical Issues:**
1.**Schema deletedAt missing** - Inconsistency issue
2.**HTML injection risk** - Same issue as modul lain dengan rich text
---
**Catatan:** Secara keseluruhan, modul **Struktur PPID adalah YANG PALING UNIQUE** dengan Organization Chart visualization yang excellent. Module ini punya fitur-fitur yang tidak ada di modul lain (hierarchical positions, org chart, active/non-active toggle).
**Unique Strengths:**
1.**Organization Chart** - Best visual representation
2.**Hierarchical data structure** - Parent-child relationships
3.**Active/Non-active feature** - Soft disable tanpa delete
4.**Email validation** - Comprehensive form validation
**Priority Action:**
```diff
🔴 FIX INI SEKARANG (30 MENIT + MIGRATION):
File: prisma/schema.prisma
Line: 327-332, 343-351
model PosisiOrganisasiPPID {
// ...
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+ deletedAt DateTime? @default(null) // ✅ Add for soft delete
}
model PegawaiPPID {
// ...
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+ deletedAt DateTime? @default(null) // ✅ Add for soft delete
}
# Lalu jalankan:
bunx prisma db push
# atau
bunx prisma migrate dev --name add_deletedat_struktur_ppid
```
```diff
🔴 FIX HTML INJECTION (30 MENIT):
File: posisi-organisasi/page.tsx
+ import DOMPurify from 'dompurify';
// Line ~95
- dangerouslySetInnerHTML={{ __html: item.deskripsi || '-' }}
+ dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.deskripsi) }}
// Repeat for mobile view line ~155
```
Setelah fix critical issues, module ini **PRODUCTION-READY** dan **ORGANIZATION CHART** adalah fitur yang bisa jadi **SHOWCASE**! 🎉
---
**File Location:** `QC/PPID/QC-STRUKTUR-PPID-MODULE.md` 📄

View File

@@ -1,797 +0,0 @@
# QC Summary - Visi Misi PPID Module
**Scope:** Preview Visi Misi, Edit Visi Misi dengan Rich Text Editor
**Date:** 2026-02-23
**Status:** ✅ Secara umum sudah baik, ada beberapa improvement yang diperlukan
---
## 📊 OVERVIEW
| Aspect | Schema | API | UI Admin | State Management | Overall |
|--------|--------|-----|----------|-----------------|---------|
| Visi Misi PPID | ⚠️ Ada issue | ✅ Baik | ✅ Baik | ✅ Baik | 🟡 Perlu fix |
---
## ✅ YANG SUDAH BAIK
### **1. UI/UX Design**
- ✅ Preview layout yang clean dengan logo desa
- ✅ Responsive design (mobile & desktop)
- ✅ Loading states dengan Skeleton
- ✅ Empty state handling yang informatif
- ✅ Edit button yang prominent
- ✅ Divider visual yang jelas antara Visi dan Misi
### **2. Rich Text Editor (Tiptap)**
- ✅ Full-featured editor dengan toolbar lengkap
- ✅ Extensions: Bold, Italic, Underline, Highlight, Link, dll
- ✅ Text alignment (left, center, justify, right)
- ✅ Heading levels (H1-H4)
- ✅ Lists (bullet & ordered)
- ✅ Blockquote, code, superscript, subscript
- ✅ Undo/Redo
- ✅ Sticky toolbar untuk UX yang lebih baik
-`immediatelyRender: false` untuk menghindari hydration mismatch
### **3. Form Component Structure**
- ✅ Modular form components (VisiPPID, MisiPPID)
- ✅ Reusable PPIDTextEditor component
- ✅ Proper TypeScript typing
- ✅ Controlled components dengan onChange handler
### **4. State Management**
- ✅ Proper typing dengan Prisma types
- ✅ Loading state management dengan finally block
- ✅ Error handling yang comprehensive
-**ApiFetch consistency** - Semua operasi pakai ApiFetch! ✅
- ✅ Zod validation untuk form data
**Code Example (✅ EXCELLENT):**
```typescript
// state file - Line ~30-50
findById: {
data: null as VisiMisiPPIDForm | null,
loading: false,
initialize() {
stateVisiMisiPPID.findById.data = {
id: "",
misi: "",
visi: "",
} as VisiMisiPPIDForm;
},
async load(id: string) {
try {
stateVisiMisiPPID.findById.loading = true; // ✅ Start loading
const res = await ApiFetch.api.ppid.visimisippid["find-by-id"].get({
query: { id },
});
if (res.status === 200) {
stateVisiMisiPPID.findById.data = res.data?.data ?? null;
}
} catch (error) {
console.error((error as Error).message);
toast.error("Terjadi kesalahan saat mengambil data visi misi");
} finally {
stateVisiMisiPPID.findById.loading = false; // ✅ Stop loading
}
},
}
```
**Verdict:****SANGAT BAIK** - State management sudah konsisten dengan ApiFetch!
---
### **5. Edit Form - Original Data Tracking**
- ✅ Original data state untuk reset form
- ✅ Load data existing dengan benar
- ✅ Reset form mengembalikan ke data original
- ✅ Rich text content handling yang proper
**Code Example (✅ GOOD):**
```typescript
// edit/page.tsx - Line ~20-45
const [formData, setFormData] = useState({ visi: '', misi: '' });
const [originalData, setOriginalData] = useState({ visi: '', misi: '' });
// Initialize from global state
useEffect(() => {
if (visiMisi.findById.data) {
setFormData({
visi: visiMisi.findById.data.visi ?? '',
misi: visiMisi.findById.data.misi ?? '',
});
setOriginalData({
visi: visiMisi.findById.data.visi ?? '',
misi: visiMisi.findById.data.misi ?? '',
});
}
}, [visiMisi.findById.data]);
// Line ~60 - Handle reset
const handleResetForm = () => {
setFormData({
visi: originalData.visi,
misi: originalData.misi,
});
toast.info('Form dikembalikan ke data awal');
};
```
**Verdict:****BAIK** - Original data tracking sudah implementasi dengan baik!
---
### **6. Rich Text Validation**
- ✅ Custom validation function untuk rich text content
- ✅ Check empty content setelah remove HTML tags
**Code Example (✅ GOOD):**
```typescript
// edit/page.tsx - Line ~25-35
const isRichTextEmpty = (content: string) => {
// Remove HTML tags and check if the resulting text is empty
const plainText = content.replace(/<[^>]*>/g, '').trim();
return plainText === '' || content.trim() === '<p></p>' || content.trim() === '<p><br></p>';
};
const isFormValid = () => {
return (
!isRichTextEmpty(formData.visi) &&
!isRichTextEmpty(formData.misi)
);
};
```
**Verdict:****EXCELLENT** - Rich text validation yang comprehensive!
---
## ⚠️ ISSUES & SARAN PERBAIKAN
### **🔴 CRITICAL**
#### **1. Schema - deletedAt Default Value SALAH**
**Lokasi:** `prisma/schema.prisma` (line 374)
**Masalah:**
```prisma
model VisiMisiPPID {
id String @id @default(cuid())
visi String @db.Text
misi String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) // ❌ SALAH - selalu punya default value
isActive Boolean @default(true)
}
```
**Dampak:**
- **LOGIC ERROR!** Setiap record baru langsung punya `deletedAt` value (timestamp creation)
- Soft delete tidak berfungsi dengan benar
- Query dengan `where: { deletedAt: null }` tidak akan pernah return data
- Data yang "dihapus" vs data "aktif" tidak bisa dibedakan
**Contoh Issue:**
```prisma
// Record baru dibuat
CREATE VisiMisiPPID {
visi: "Visi 1",
misi: "Misi 1",
// deletedAt otomatis ter-set ke now() ❌
// isActive: true ✅
}
// Query untuk data aktif (seharusnya return data ini)
prisma.visiMisiPPID.findMany({
where: { deletedAt: null, isActive: true }
})
// ❌ Return kosong! Karena deletedAt sudah ter-set
```
**Rekomendasi:** Fix schema:
```prisma
model VisiMisiPPID {
id String @id @default(cuid())
visi String @db.Text
misi String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime? @default(null) // ✅ Nullable, null = not deleted
isActive Boolean @default(true)
}
```
**Priority:** 🔴 **CRITICAL**
**Effort:** Medium (perlu migration)
**Impact:** **HIGH** (data integrity & soft delete logic)
---
#### **2. HTML Injection Risk - dangerouslySetInnerHTML**
**Lokasi:** `page.tsx` (preview page)
**Masalah:**
```typescript
// Line ~85-95
<Text
ta={{ base: "center", md: "justify" }}
dangerouslySetInnerHTML={{ __html: listVisiMisi.findById.data.visi }} // ❌ No sanitization
style={{ ... }}
/>
// Line ~105-115 (Misi)
<Text
ta={"justify"}
dangerouslySetInnerHTML={{ __html: listVisiMisi.findById.data.misi }} // ❌ No sanitization
style={{ ... }}
/>
```
**Risk:**
- XSS attack jika admin input script malicious
- Bisa inject iframe, script tag, dll
- Security vulnerability
**Rekomendasi:** Gunakan DOMPurify atau library sanitization:
```typescript
import DOMPurify from 'dompurify';
// Sanitize sebelum render
const sanitizedVisi = DOMPurify.sanitize(listVisiMisi.findById.data.visi);
const sanitizedMisi = DOMPurify.sanitize(listVisiMisi.findById.data.misi);
<Text
dangerouslySetInnerHTML={{ __html: sanitizedVisi }}
// ...
/>
```
Atau validasi di backend untuk whitelist tag HTML yang diperbolehkan (hanya `<p>`, `<ul>`, `<li>`, `<strong>`, dll).
**Priority:** 🔴 **HIGH** (**Security concern**)
**Effort:** Low
---
#### **3. Missing Delete/Hard Delete Protection**
**Lokasi:** `page.tsx`, `edit/page.tsx`
**Masalah:**
- ❌ Tidak ada tombol delete untuk Visi Misi (correct - single record)
-**GOOD:** Single record pattern yang benar
- ⚠️ **ISSUE:** Tidak ada konfirmasi sebelum update (direct save)
**Issue:** User bisa accidentally save changes tanpa konfirmasi.
**Rekomendasi:** Add confirmation dialog sebelum save:
```typescript
const submit = () => {
// Check if data has changed
if (formData.visi === originalData.visi && formData.misi === originalData.misi) {
toast.info('Tidak ada perubahan');
return;
}
// Show confirmation
const confirmed = window.confirm('Apakah Anda yakin ingin mengubah Visi Misi PPID?');
if (!confirmed) return;
// Then save...
};
```
**Priority:** 🔴 Medium
**Effort:** Low
---
### **🟡 MEDIUM**
#### **4. Console.log di Production**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/visi_misi_ppid/visimisiPPID.ts`
**Masalah:**
```typescript
// Line ~40
console.error((error as Error).message);
// Line ~65
console.error((error as Error).message);
```
**Rekomendasi:** Gunakan conditional logging:
```typescript
if (process.env.NODE_ENV === 'development') {
console.error("Error:", error);
}
```
**Priority:** 🟡 Low
**Effort:** Low
---
#### **5. Missing Loading State di Submit Button**
**Lokasi:** `edit/page.tsx`
**Masalah:**
```typescript
// Line ~120-130
<Button
onClick={submit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
// ...
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
```
**Issue:** Button tidak check `visiMisi.update.loading` dari global state.
**Rekomendasi:** Check both states:
```typescript
disabled={!isFormValid() || isSubmitting || visiMisi.update.loading}
{isSubmitting || visiMisi.update.loading ? (
<Loader size="sm" color="white" />
) : (
'Simpan'
)}
```
**Priority:** 🟡 Low
**Effort:** Low
---
#### **6. Zod Schema - Could Be More Specific**
**Lokasi:** `src/app/admin/(dashboard)/_state/ppid/visi_misi_ppid/visimisiPPID.ts`
**Masalah:**
```typescript
// Line ~7
const templateForm = z.object({
misi: z.string().min(3, "Misi minimal 3 karakter"), // ⚠️ Generic
visi: z.string().min(3, "Visi minimal 3 karakter"), // ⚠️ Generic
});
```
**Rekomendasi:** More specific error messages:
```typescript
const templateForm = z.object({
misi: z.string().min(3, "Misi PPID minimal 3 karakter"),
visi: z.string().min(3, "Visi PPID minimal 3 karakter"),
});
```
**Priority:** 🟡 Low
**Effort:** Low
---
### **🟢 LOW (Minor Polish)**
#### **7. Missing Change Detection**
**Lokasi:** `edit/page.tsx`
**Masalah:**
```typescript
// Line ~70-80
const submit = () => {
try {
setIsSubmitting(true);
if (visiMisi.findById.data) {
// update nilai global hanya saat submit
visiMisi.findById.data.visi = formData.visi;
visiMisi.findById.data.misi = formData.misi;
visiMisi.update.save(visiMisi.findById.data);
}
router.push('/admin/ppid/visi-misi-ppid');
} catch (error) {
console.error("Error updating visi misi:", error);
toast.error("Terjadi kesalahan saat memperbarui visi misi");
} finally {
setIsSubmitting(false);
}
};
```
**Issue:** Tidak ada check apakah data sudah berubah. User bisa save tanpa perubahan.
**Rekomendasi:** Add change detection:
```typescript
const submit = () => {
// Check if data has changed
if (formData.visi === originalData.visi && formData.misi === originalData.misi) {
toast.info('Tidak ada perubahan');
return;
}
try {
setIsSubmitting(true);
// ... rest of save logic
}
};
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **8. Editor - Duplicate useEffect**
**Lokasi:** `PPIDTextEditor.tsx`
**Masalah:**
```typescript
// Line ~30-35
const editor = useEditor({
extensions: [...],
immediatelyRender: false,
content: initialContent, // ✅ Set content directly
onUpdate: ({editor}) => {
onChange(editor.getHTML()) // ✅ Handle changes
}
});
// Line ~37-42
useEffect(() => {
if (editor && initialContent !== editor.getHTML()) {
editor.commands.setContent(initialContent || '<p></p>');
}
}, [initialContent, editor]);
```
**Issue:** Ada useEffect tambahan untuk set content, padahal sudah ada di `useEditor`. Bisa menyebabkan double content update.
**Rekomendasi:** Simplify - remove useEffect:
```typescript
const editor = useEditor({
extensions: [...],
immediatelyRender: false,
content: initialContent || '<p></p>', // ✅ Set content directly
onUpdate: ({editor}) => {
onChange(editor.getHTML())
},
editorProps: {
// Optional: handle content updates better
}
});
// Remove useEffect completely
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **9. Missing Error Boundary**
**Lokasi:** `edit/page.tsx`
**Masalah:**
- Tidak ada error boundary untuk handle unexpected errors
- Jika editor gagal load, tidak ada fallback UI
**Rekomendasi:** Add error boundary:
```typescript
if (visiMisi.findById.error) {
return (
<Alert icon={<IconAlertCircle />} color="red">
<Text fw="bold">Error</Text>
<Text>{visiMisi.findById.error}</Text>
</Alert>
);
}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **10. Preview Page - Hardcoded Moto PPID**
**Lokasi:** `page.tsx`
**Masalah:**
```typescript
// Line ~60-70
<Text
ta="center"
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.5, md: 1.5 }}
mt="sm"
c="black"
>
MEMBERIKAN INFORMASI YANG CEPAT, MUDAH, TEPAT DAN TRANSPARAN
</Text>
```
**Issue:** Moto PPID hardcoded di UI. Seharusnya dari database/config.
**Rekomendasi:** Move to database or config file:
```typescript
// Add to schema
model VisiMisiPPID {
// ...
moto String? @db.Text
}
// Or use config
const PPID_MOTO = "MEMBERIKAN INFORMASI YANG CEPAT, MUDAH, TEPAT DAN TRANSPARAN";
```
**Priority:** 🟢 Low
**Effort:** Medium (perlu schema change)
---
#### **11. Title Order Inconsistency**
**Lokasi:** `page.tsx`
**Masalah:**
```typescript
// Line ~45
<Title order={3} ...>Preview Visi Misi PPID</Title>
// Line ~65
<Title order={2} ...>MOTO PPID DESA DARMASABA</Title>
// Line ~80
<Title order={2} ...>VISI PPID</Title>
// Line ~100
<Title order={2} ...>MISI PPID</Title>
```
**Issue:** Title hierarchy agak confusing. Page title (order 3) lebih kecil dari section titles (order 2).
**Rekomendasi:** Samakan hierarchy:
```typescript
// Page title: order={2}
// Section titles: order={3}
```
**Priority:** 🟢 Low
**Effort:** Low
---
#### **12. Missing Toast Success After Save**
**Lokasi:** `edit/page.tsx`
**Masalah:**
```typescript
// Line ~70-85
const submit = () => {
try {
setIsSubmitting(true);
if (visiMisi.findById.data) {
visiMisi.findById.data.visi = formData.visi;
visiMisi.findById.data.misi = formData.misi;
visiMisi.update.save(visiMisi.findById.data);
}
router.push('/admin/ppid/visi-misi-ppid'); // ✅ Redirect tanpa toast
} catch (error) {
console.error("Error updating visi misi:", error);
toast.error("Terjadi kesalahan saat memperbarui visi misi");
} finally {
setIsSubmitting(false);
}
};
```
**Issue:** Toast success ada di state `update.save()`, tapi user mungkin tidak lihat karena langsung redirect.
**Rekomendasi:** Add toast before redirect atau wait untuk toast selesai:
```typescript
const submit = async () => {
try {
setIsSubmitting(true);
if (visiMisi.findById.data) {
visiMisi.findById.data.visi = formData.visi;
visiMisi.findById.data.misi = formData.misi;
await visiMisi.update.save(visiMisi.findById.data);
toast.success("Visi Misi berhasil diperbarui!");
setTimeout(() => {
router.push('/admin/ppid/visi-misi-ppid');
}, 1000); // Wait 1 second for toast to show
}
} catch (error) {
console.error("Error updating visi misi:", error);
toast.error("Terjadi kesalahan saat memperbarui visi misi");
} finally {
setIsSubmitting(false);
}
};
```
**Priority:** 🟢 Low
**Effort:** Low
---
## 📋 RINGKASAN ACTION ITEMS
| Priority | Issue | Module | Impact | Effort | Status |
|----------|-------|--------|--------|--------|--------|
| 🔴 P0 | **Schema deletedAt default SALAH** | Schema | **CRITICAL** | Medium | **MUST FIX** |
| 🔴 P0 | **HTML injection risk** | UI | **HIGH (Security)** | Low | **Should fix** |
| 🔴 P1 | Missing delete confirmation | UI | Medium | Low | Should fix |
| 🟡 M | Console.log in production | State | Low | Low | Optional |
| 🟡 M | Missing loading state di submit button | UI | Low | Low | Should fix |
| 🟡 M | Zod schema error messages | State | Low | Low | Optional |
| 🟢 L | Missing change detection | Edit UI | Low | Low | Optional |
| 🟢 L | Duplicate useEffect di editor | Editor | Low | Low | Optional |
| 🟢 L | Missing error boundary | UI | Low | Low | Optional |
| 🟢 L | Hardcoded Moto PPID | UI | Low | Medium | Optional |
| 🟢 L | Title order inconsistency | UI | Low | Low | Optional |
| 🟢 L | Missing toast success timing | UI | Low | Low | Optional |
---
## ✅ KESIMPULAN
### **Overall Quality: 🟢 BAIK (8.5/10) - CLEANEST MODULE!**
**Strengths:**
1. ✅ UI/UX clean & responsive
2.**Rich Text Editor** full-featured (Tiptap)
3.**Modular form components** (Visi, Misi)
4.**State management BEST PRACTICES** - **ONLY MODULE YANG 100% ApiFetch!**
5.**Edit form reset sudah benar** (original data tracking)
6.**Rich text validation** comprehensive (check empty content)
7. ✅ Error handling comprehensive
8. ✅ Loading state management dengan finally block
9.`immediatelyRender: false` untuk menghindari hydration mismatch
**Critical Issues:**
1. ⚠️ **Schema deletedAt default SALAH** - Logic error untuk soft delete (CRITICAL)
2. ⚠️ **HTML injection risk** - dangerouslySetInnerHTML tanpa sanitization (HIGH Security)
3. ⚠️ Missing confirmation sebelum save (Medium UX)
**Areas for Improvement:**
1. ⚠️ **Fix schema deletedAt** dari `@default(now())` ke `@default(null)` dengan nullable
2. ⚠️ **Fix HTML injection** dengan DOMPurify atau backend validation
3. ⚠️ **Add confirmation dialog** sebelum save
4. ⚠️ **Add change detection** untuk avoid unnecessary saves
5. ⚠️ **Fix loading state** di submit button
**Recommended Next Steps:**
1. **🔴 CRITICAL: Fix schema deletedAt** - 30 menit (perlu migration)
2. **🔴 HIGH: Fix HTML injection** dengan DOMPurify - 30 menit
3. **🟡 MEDIUM: Add confirmation dialog** - 15 menit
4. **🟢 LOW: Add change detection** - 15 menit
5. **🟢 LOW: Polish minor issues** - 30 menit
---
## 📈 COMPARISON WITH OTHER MODULES
| Module | Fetch Pattern | State | Edit Reset | Rich Text | HTML Injection | deletedAt | Overall |
|--------|--------------|-------|------------|-----------|----------------|-----------|---------|
| Profil | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ None | ⚠️ Present | ⚠️ Issue | 🟢 |
| Desa Anti Korupsi | ⚠️ Mixed | ⚠️ Good | ✅ Good | ✅ Present | ⚠️ Present | ⚠️ Issue | 🟢 |
| SDGs Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ None | N/A | ⚠️ Issue | 🟢 |
| APBDes | ⚠️ Mixed | ⚠️ Good | ✅ Good | ❌ None | N/A | ✅ Good | 🟢 |
| Prestasi Desa | ⚠️ Mixed | ⚠️ Good | ✅ Good | ✅ Present | ⚠️ Present | ❌ WRONG | 🟢 |
| PPID Profil | ⚠️ Mixed | ✅ **Best** | ✅ **Excellent** | ✅ **Best** | ⚠️ Present | ❌ WRONG | 🟢⭐ |
| Struktur PPID | ⚠️ Mixed | ✅ Good | ✅ Good | ✅ Present | ⚠️ Present | ⚠️ Inconsistent | 🟢 |
| **Visi Misi PPID** | ✅ **100% ApiFetch!** | ✅ **Best** | ✅ Good | ✅ Present | ⚠️ Present | ❌ WRONG | 🟢⭐⭐ |
**Visi Misi PPID Highlights:**
-**ONLY MODULE** yang 100% konsisten pakai ApiFetch! (NO fetch manual!)
-**CLEANEST CODE** - Simple, straightforward, no complexity
-**Rich text validation** paling comprehensive (check empty content)
-**Best state management** pattern (ApiFetch consistency)
- ⚠️ **Same deletedAt issue** seperti modul PPID lain
---
## 🎯 UNIQUE FEATURES OF VISI MISI PPID MODULE
**Simplest & Cleanest Module:**
1.**100% ApiFetch consistency** - NO fetch manual sama sekali! (UNIQUE!)
2.**Simple single record pattern** - Only 2 fields (visi, misi)
3.**Rich text validation** - Check empty content after remove HTML tags
4.**Modular editor components** - VisiPPID, MisiPPID separate
5.**No file upload** - Simplest form (text only)
**Best Practices:**
1.**ApiFetch 100%** - Best practice untuk API consistency
2.**Loading state management** proper (dengan finally block)
3.**Rich text validation** comprehensive
4.**Original data tracking** untuk reset form
5.**`immediatelyRender: false`** - Avoid hydration mismatch
**Critical Issues:**
1.**Schema deletedAt SALAH** - Same issue seperti modul PPID lain
2.**HTML injection risk** - Same issue seperti modul dengan rich text lain
---
**Catatan:** **Visi Misi PPID adalah MODULE PALING CLEAN** dengan codebase paling simple dan **SATU-SATUNYA MODULE YANG 100% PAKAI ApiFetch** (no fetch manual sama sekali!). Module ini bisa jadi **REFERENCE** untuk API consistency!
**Unique Strengths:**
1.**100% ApiFetch** - Best API consistency (NO fetch manual!)
2.**Simple & clean** - No unnecessary complexity
3.**Rich text validation** - Most comprehensive
4.**Best state management** pattern
**Priority Action:**
```diff
🔴 FIX INI SEKARANG (30 MENIT + MIGRATION):
File: prisma/schema.prisma
Line: 374
model VisiMisiPPID {
id String @id @default(cuid())
visi String @db.Text
misi String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- deletedAt DateTime @default(now())
+ deletedAt DateTime? @default(null)
isActive Boolean @default(true)
}
# Lalu jalankan:
bunx prisma db push
# atau
bunx prisma migrate dev --name fix_deletedat_visimisi_ppid
```
```diff
🔴 FIX HTML INJECTION (30 MENIT):
File: page.tsx
+ import DOMPurify from 'dompurify';
// Line ~85
- dangerouslySetInnerHTML={{ __html: listVisiMisi.findById.data.visi }}
+ dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(listVisiMisi.findById.data.visi) }}
// Line ~105
- dangerouslySetInnerHTML={{ __html: listVisiMisi.findById.data.misi }}
+ dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(listVisiMisi.findById.data.misi) }}
```
Setelah fix critical issues, module ini **PRODUCTION-READY** dan bisa jadi **REFERENCE untuk API CONSISTENCY**! 🎉
---
## 📚 RECOMMENDED AS REFERENCE FOR OTHER MODULES
**Visi Misi PPID Module adalah BEST PRACTICE untuk:**
1.**API consistency** - 100% ApiFetch, NO fetch manual!
2.**Simple state management** - Clean, straightforward
3.**Rich text validation** - Check empty content pattern
4.**Modular editor components** - Separate Visi & Misi
**Modules lain bisa belajar dari Visi Misi PPID:**
- **ALL MODULES:** Use ApiFetch consistently (NO fetch manual!)
- **ALL MODULES:** Keep it simple (avoid unnecessary complexity)
- **Rich Text Modules:** Implement empty content validation
- **ALL MODULES:** Proper loading state management
---
**File Location:** `QC/PPID/QC-VISI-MISI-PPID-MODULE.md` 📄

BIN
bun.lockb

Binary file not shown.

169
darkMode.md Normal file
View File

@@ -0,0 +1,169 @@
# 🌙 Dark Mode Design Specification
## Admin Darmasaba Dashboard & CMS
Dokumen ini mendefinisikan standar **Dark Mode UI** agar:
- nyaman di mata
- konsisten
- tidak flat
- tetap profesional untuk aplikasi pemerintahan
---
## 🎨 Color Palette (Dark Mode)
### Background Layers
| Layer | Token | Warna | Fungsi |
|------|------|------|------|
| Base | `--bg-base` | `#0B1220` | Background utama aplikasi |
| App | `--bg-app` | `#0F172A` | Area kerja utama |
| Card | `--bg-card` | `#162235` | Card / container |
| Surface | `--bg-surface` | `#1E2A3D` | Table header, tab, input |
---
### Border & Divider
| Token | Warna | Catatan |
|-----|------|--------|
| `--border-default` | `#2A3A52` | Border utama |
| `--border-soft` | `#22314A` | Divider halus |
> ❗ Hindari border terlalu tipis (`opacity < 20%`)
---
### Text Colors
| Jenis | Token | Warna |
|-----|------|------|
| Primary | `--text-primary` | `#E5E7EB` |
| Secondary | `--text-secondary` | `#9CA3AF` |
| Muted | `--text-muted` | `#6B7280` |
| Inverse | `--text-inverse` | `#020617` |
---
### Accent & Action
| Fungsi | Warna |
|------|------|
| Primary Action | `#3B82F6` |
| Hover | `#2563EB` |
| Active | `#1D4ED8` |
| Link | `#60A5FA` |
---
### Status Colors
| Status | Warna |
|------|------|
| Success | `#22C55E` |
| Warning | `#FACC15` |
| Error | `#EF4444` |
| Info | `#38BDF8` |
---
## 🧱 Layout Rules
### Sidebar
- Background: `--bg-app`
- Active menu:
- Background: `rgba(59,130,246,0.15)`
- Text: Primary
- Indicator: kiri (23px accent bar)
- Hover:
- Background: `rgba(255,255,255,0.04)`
---
### Header / Topbar
- Background: `linear-gradient(#0F172A → #0B1220)`
- Border bawah wajib (`--border-soft`)
- Icon:
- Default: muted
- Hover: primary
---
## 📦 Card & Section
### Card
- Background: `--bg-card`
- Border: `--border-default`
- Radius: 1216px
- Jangan pakai shadow hitam
### Section Header
- Font weight lebih besar
- Text: primary
- Spacing jelas dari konten
---
## 📊 Table (Dark Mode Friendly)
### Table Header
- Background: `--bg-surface`
- Text: secondary
- Font weight: medium
### Table Row
- Default: transparent
- Hover:
- Background: `rgba(255,255,255,0.03)`
- Divider antar row wajib terlihat
### Link di Table
- Warna link **lebih terang dari text**
- Hover underline
---
## 🔘 Button Rules
### Primary Button
- Background: Primary Action
- Text: Inverse
- Hover: darker shade
### Secondary Button
- Background: transparent
- Border: `--border-default`
- Text: primary
### Icon Button
- Default: muted
- Hover: primary + bg soft
---
## 🧭 Tab Navigation
- Inactive:
- Text: muted
- Active:
- Background: `rgba(59,130,246,0.15)`
- Text: primary
- Icon ikut berubah
---
## 🌗 Dark vs Light Mode Rule
- Layout, spacing, typography **HARUS SAMA**
- Yang boleh beda:
- warna
- border intensity
- background layer
> ❌ Jangan ganti struktur UI antara dark & light
---
## ✅ Dark Mode Checklist
- [ ] Kontras teks terbaca
- [ ] Active state jelas
- [ ] Hover terasa hidup
- [ ] Tidak flat
- [ ] Tidak silau
---
Dokumen ini adalah **single source of truth** untuk Dark Mode.

View File

@@ -62,6 +62,7 @@
"colors": "^1.4.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"dompurify": "^3.3.1",
"dotenv": "^17.2.3",
"elysia": "^1.3.5",
"embla-carousel": "^8.6.0",
@@ -112,6 +113,7 @@
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.9.1",
"@types/cli-progress": "^3.11.6",
"@types/dompurify": "^3.2.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20",
"@types/react": "^19",

View File

@@ -236,7 +236,7 @@ model PrestasiDesa {
imageId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -245,7 +245,7 @@ model KategoriPrestasiDesa {
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
PrestasiDesa PrestasiDesa[]
}
@@ -263,7 +263,7 @@ model Responden {
kelompokUmurId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -272,7 +272,7 @@ model JenisKelaminResponden {
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
Responden Responden[]
}
@@ -282,7 +282,7 @@ model PilihanRatingResponden {
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
Responden Responden[]
}
@@ -292,7 +292,7 @@ model UmurResponden {
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
Responden Responden[]
}
@@ -326,6 +326,7 @@ model PosisiOrganisasiPPID {
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
parent PosisiOrganisasiPPID? @relation("Parent", fields: [parentId], references: [id])
children PosisiOrganisasiPPID[] @relation("Parent")
StrukturOrganisasiPPID StrukturOrganisasiPPID[]
@@ -345,6 +346,7 @@ model PegawaiPPID {
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
posisi PosisiOrganisasiPPID @relation(fields: [posisiId], references: [id])
strukturOrganisasi StrukturPPID[] // Relasi balik
StrukturOrganisasiPPID StrukturOrganisasiPPID[]
@@ -370,7 +372,7 @@ model VisiMisiPPID {
misi String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -381,7 +383,7 @@ model DasarHukumPPID {
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -398,7 +400,7 @@ model ProfilePPID {
imageId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -410,7 +412,7 @@ model DaftarInformasiPublik {
tanggal DateTime @db.Date
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -431,7 +433,7 @@ model PermohonanInformasiPublik {
caraMemperolehSalinanInformasiId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -440,7 +442,7 @@ model JenisInformasiDiminta {
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
PermohonanInformasiPublik PermohonanInformasiPublik[]
}
@@ -450,7 +452,7 @@ model CaraMemperolehInformasi {
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
PermohonanInformasiPublik PermohonanInformasiPublik[]
}
@@ -460,7 +462,7 @@ model CaraMemperolehSalinanInformasi {
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
PermohonanInformasiPublik PermohonanInformasiPublik[]
}
@@ -474,7 +476,7 @@ model FormulirPermohonanKeberatan {
alasan String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -531,7 +533,7 @@ model SejarahDesa {
deskripsi String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -541,7 +543,7 @@ model VisiMisiDesa {
misi String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -551,7 +553,7 @@ model LambangDesa {
deskripsi String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -562,7 +564,7 @@ model MaskotDesa {
images ProfileDesaImage[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}

View File

@@ -1,7 +1,11 @@
'use client';
import React from 'react';
import { Grid, GridCol, Paper, TextInput, Title } from '@mantine/core';
import { Grid, GridCol, Paper, TextInput } from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import colors from '@/con/colors';
import { useDarkMode } from '@/state/darkModeStore';
import { themeTokens } from '@/utils/themeTokens';
import { UnifiedTitle } from '@/components/admin/UnifiedTypography';
type HeaderSearchProps = {
title: string;
@@ -18,13 +22,16 @@ const HeaderSearch = ({
value,
onChange,
}: HeaderSearchProps) => {
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
return (
<Grid mb={10}>
<GridCol span={{ base: 12, md: 9 }}>
<Title order={3}>{title}</Title>
<UnifiedTitle order={3}>{title}</UnifiedTitle>
</GridCol>
<GridCol span={{ base: 12, md: 3 }}>
<Paper radius="lg" bg={colors['white-1']}>
<Paper radius="lg" bg={tokens.colors.bg.surface}>
<TextInput
radius="lg"
placeholder={placeholder}
@@ -32,6 +39,16 @@ const HeaderSearch = ({
w="100%"
value={value}
onChange={onChange}
style={{
input: {
backgroundColor: tokens.colors.bg.surface,
color: tokens.colors.text.primary,
borderColor: tokens.colors.border.default,
'::placeholder': {
color: tokens.colors.text.muted,
},
},
}}
/>
</Paper>
</GridCol>

View File

@@ -1,12 +1,16 @@
'use client'
import colors from '@/con/colors';
import { Grid, GridCol, Button, Text } from '@mantine/core';
import { Grid, GridCol, Button } from '@mantine/core';
import { IconCircleDashedPlus } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import React from 'react';
import { useDarkMode } from '@/state/darkModeStore';
import { themeTokens } from '@/utils/themeTokens';
import { UnifiedText } from '@/components/admin/UnifiedTypography';
const JudulList = ({ title = "", href = "#" }) => {
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
const router = useRouter();
const handleNavigate = () => {
@@ -16,10 +20,18 @@ const JudulList = ({ title = "", href = "#" }) => {
return (
<Grid align="center" mb={10}>
<GridCol span={{ base: 12, md: 11 }}>
<Text fz={"xl"} fw={"bold"}>{title}</Text>
<UnifiedText size="body" weight="bold" color="primary">{title}</UnifiedText>
</GridCol>
<GridCol span={{ base: 12, md: 1 }} ta="right">
<Button onClick={handleNavigate} bg={colors['blue-button']}>
<Button
onClick={handleNavigate}
bg={tokens.colors.primary}
style={{
background: `linear-gradient(135deg, ${tokens.colors.primary}, ${isDark ? '#60A5FA' : '#4facfe'})`,
color: tokens.colors.text.inverse,
boxShadow: isDark ? 'none' : `0 4px 15px rgba(79, 172, 254, 0.4)`,
}}
>
<IconCircleDashedPlus size={25} />
</Button>
</GridCol>

View File

@@ -1,9 +1,11 @@
'use client'
import colors from '@/con/colors';
import { Grid, GridCol, Button, Text, Paper, TextInput } from '@mantine/core';
import { Grid, GridCol, Button, Paper, TextInput } from '@mantine/core';
import { IconCircleDashedPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import React from 'react';
import { useDarkMode } from '@/state/darkModeStore';
import { themeTokens } from '@/utils/themeTokens';
import { UnifiedText } from '@/components/admin/UnifiedTypography';
type JudulListTabProps = {
title: string;
@@ -14,17 +16,16 @@ type JudulListTabProps = {
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const JudulListTab = ({
title = "",
href = "#",
placeholder = "pencarian",
searchIcon = <IconSearch size={20} />,
value,
onChange
onChange
}: JudulListTabProps) => {
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
const router = useRouter();
const handleNavigate = () => {
@@ -34,10 +35,17 @@ const JudulListTab = ({
return (
<Grid mb={10}>
<GridCol span={{ base: 12, md: 8 }}>
<Text fz={{ base: "md", md: "xl" }} fw={"bold"}>{title}</Text>
<UnifiedText
size="body"
weight="bold"
color="primary"
style={{ fontSize: 'clamp(1rem, 2vw, 1.25rem)' }}
>
{title}
</UnifiedText>
</GridCol>
<GridCol span={{ base: 9, md: 3 }} ta="right">
<Paper radius={"lg"} bg={colors['white-1']}>
<Paper radius={"lg"} bg={tokens.colors.bg.surface}>
<TextInput
radius="lg"
placeholder={placeholder}
@@ -45,11 +53,29 @@ const JudulListTab = ({
w="100%"
value={value}
onChange={onChange}
style={{
input: {
backgroundColor: tokens.colors.bg.surface,
color: tokens.colors.text.primary,
borderColor: tokens.colors.border.default,
'::placeholder': {
color: tokens.colors.text.muted,
},
},
}}
/>
</Paper>
</GridCol>
<GridCol span={{ base: 3, md: 1 }} ta="right">
<Button onClick={handleNavigate} bg={colors['blue-button']}>
<Button
onClick={handleNavigate}
bg={tokens.colors.primary}
style={{
background: `linear-gradient(135deg, ${tokens.colors.primary}, ${isDark ? '#60A5FA' : '#4facfe'})`,
color: tokens.colors.text.inverse,
boxShadow: isDark ? 'none' : `0 4px 15px rgba(79, 172, 254, 0.4)`,
}}
>
<IconCircleDashedPlus size={25} />
</Button>
</GridCol>

View File

@@ -38,11 +38,9 @@ function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer
const anggaran = item.anggaran ?? 0;
const realisasi = item.realisasi ?? 0;
// ✅ Formula yang benar
const selisih = anggaran - realisasi; // positif = sisa anggaran, negatif = over budget
const selisih = realisasi - anggaran; // positif = sisa anggaran, negatif = over budget
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran
return {

View File

@@ -55,10 +55,15 @@ const programInovasi = proxy({
programInovasi.findMany.load();
return toast.success("Sukses menambahkan");
}
console.log(res);
if (process.env.NODE_ENV === 'development') {
console.log(res);
}
return toast.error("failed create");
} catch (error) {
console.log((error as Error).message);
if (process.env.NODE_ENV === 'development') {
console.error("Create error:", error);
}
toast.error("Gagal menambahkan data");
} finally {
programInovasi.create.loading = false;
}
@@ -91,13 +96,17 @@ const programInovasi = proxy({
programInovasi.findMany.total = res.data.total || 0;
programInovasi.findMany.totalPages = res.data.totalPages || 1;
} else {
console.error("Failed to load pegawai:", res.data?.message);
if (process.env.NODE_ENV === 'development') {
console.error("Failed to load pegawai:", res.data?.message);
}
programInovasi.findMany.data = [];
programInovasi.findMany.total = 0;
programInovasi.findMany.totalPages = 1;
}
} catch (error) {
console.error("Error loading pegawai:", error);
if (process.env.NODE_ENV === 'development') {
console.error("Error loading pegawai:", error);
}
programInovasi.findMany.data = [];
programInovasi.findMany.total = 0;
programInovasi.findMany.totalPages = 1;
@@ -112,19 +121,25 @@ const programInovasi = proxy({
image: true;
};
}> | null,
loading: false,
async load(id: string) {
try {
const res = await fetch(`/api/landingpage/programinovasi/${id}`);
if (res.ok) {
const data = await res.json();
programInovasi.findUnique.data = data.data ?? null;
programInovasi.findUnique.loading = true;
const res = await (ApiFetch.api.landingpage.programinovasi as any)[id].get();
if (res.data?.success) {
programInovasi.findUnique.data = res.data.data ?? null;
return res.data.data;
} else {
console.error("Failed to fetch program inovasi:", res.statusText);
toast.error(res.data?.message || "Gagal memuat data program inovasi");
programInovasi.findUnique.data = null;
return null;
}
} catch (error) {
console.error("Error fetching program inovasi:", error);
programInovasi.findUnique.data = null;
return null;
} finally {
programInovasi.findUnique.loading = false;
}
},
},
@@ -135,27 +150,18 @@ const programInovasi = proxy({
try {
programInovasi.delete.loading = true;
const res = await (ApiFetch.api.landingpage.programinovasi as any)["del"][id].delete();
const response = await fetch(
`/api/landingpage/programinovasi/del/${id}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Program inovasi berhasil dihapus");
await programInovasi.findMany.load(); // refresh list
if (res.data?.success) {
toast.success(res.data.message || "Program inovasi berhasil dihapus");
await programInovasi.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus program inovasi");
toast.error(res.data?.message || "Gagal menghapus program inovasi");
}
} catch (error) {
console.error("Gagal delete:", error);
if (process.env.NODE_ENV === 'development') {
console.error("Gagal delete:", error);
}
toast.error("Terjadi kesalahan saat menghapus program inovasi");
} finally {
programInovasi.delete.loading = false;
@@ -174,20 +180,11 @@ const programInovasi = proxy({
}
try {
const response = await fetch(`/api/landingpage/programinovasi/${id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
programInovasi.update.loading = true;
const res = await (ApiFetch.api.landingpage.programinovasi as any)[id].get();
const result = await response.json();
if (result?.success) {
const data = result.data;
if (res.data?.success) {
const data = res.data.data;
this.id = data.id;
this.form = {
name: data.name,
@@ -197,13 +194,15 @@ const programInovasi = proxy({
};
return data;
} else {
throw new Error(
result?.message || "Gagal mengambil data program inovasi"
);
toast.error(res.data?.message || "Gagal mengambil data program inovasi");
return null;
}
} catch (error) {
console.error((error as Error).message);
if (process.env.NODE_ENV === 'development') {
console.error("Error loading program inovasi:", error);
}
toast.error("Terjadi kesalahan saat mengambil data program inovasi");
return null;
} finally {
programInovasi.update.loading = false;
}
@@ -221,41 +220,25 @@ const programInovasi = proxy({
try {
programInovasi.update.loading = true;
const res = await (ApiFetch.api.landingpage.programinovasi as any)[this.id].put({
name: this.form.name,
description: this.form.description,
imageId: this.form.imageId,
link: this.form.link,
});
const response = await fetch(
`/api/landingpage/programinovasi/${this.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: this.form.name,
description: this.form.description,
imageId: this.form.imageId,
link: this.form.link,
}),
}
);
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) {
if (res.data?.success) {
toast.success("Berhasil update program inovasi");
await programInovasi.findMany.load(); // refresh list
await programInovasi.findMany.load();
return true;
} else {
throw new Error(result.message || "Gagal update program inovasi");
toast.error(res.data?.message || "Gagal update program inovasi");
return false;
}
} catch (error) {
console.error("Error updating program inovasi:", error);
if (process.env.NODE_ENV === 'development') {
console.error("Error updating program inovasi:", error);
}
toast.error(
error instanceof Error
? error.message
@@ -443,7 +426,7 @@ const pejabatDesa = proxy({
const templateMediaSosial = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"),
imageId: z.string().nullable().optional(),
iconUrl: z.string().min(3, "Icon URL minimal 3 karakter"),
iconUrl: z.string().optional(), // ✅ Optional - tidak selalu required
icon: z.string().nullable().optional(),
});
@@ -484,10 +467,15 @@ const mediaSosial = proxy({
mediaSosial.findMany.load();
return toast.success("Sukses menambahkan");
}
console.log(res);
if (process.env.NODE_ENV === 'development') {
console.log(res);
}
return toast.error("failed create");
} catch (error) {
console.log((error as Error).message);
if (process.env.NODE_ENV === 'development') {
console.log((error as Error).message);
}
toast.error("Gagal menambahkan data");
} finally {
mediaSosial.create.loading = false;
}
@@ -518,13 +506,17 @@ const mediaSosial = proxy({
mediaSosial.findMany.total = res.data.total || 0;
mediaSosial.findMany.totalPages = res.data.totalPages || 1;
} else {
console.error("Failed to load media sosial:", res.data?.message);
if (process.env.NODE_ENV === 'development') {
console.error("Failed to load media sosial:", res.data?.message);
}
mediaSosial.findMany.data = [];
mediaSosial.findMany.total = 0;
mediaSosial.findMany.totalPages = 1;
}
} catch (error) {
console.error("Error loading media sosial:", error);
if (process.env.NODE_ENV === 'development') {
console.error("Error loading media sosial:", error);
}
mediaSosial.findMany.data = [];
mediaSosial.findMany.total = 0;
mediaSosial.findMany.totalPages = 1;
@@ -539,25 +531,32 @@ const mediaSosial = proxy({
image: true;
};
}> | null,
loading: false,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
mediaSosial.update.loading = true;
mediaSosial.findUnique.loading = true;
try {
const res = await fetch(`/api/landingpage/mediasosial/${id}`);
if (res.ok) {
const data = await res.json();
mediaSosial.findUnique.data = data.data ?? null;
const res = await (ApiFetch.api.landingpage.mediasosial as any)[id].get();
if (res.data?.success) {
mediaSosial.findUnique.data = res.data.data ?? null;
return res.data.data;
} else {
console.error("Failed to fetch media sosial:", res.statusText);
toast.error(res.data?.message || "Gagal memuat data media sosial");
mediaSosial.findUnique.data = null;
return null;
}
} catch (error) {
console.error("Error fetching media sosial:", error);
if (process.env.NODE_ENV === 'development') {
console.error("Error fetching media sosial:", error);
}
mediaSosial.findUnique.data = null;
return null;
} finally {
mediaSosial.findUnique.loading = false;
}
},
},
@@ -568,24 +567,18 @@ const mediaSosial = proxy({
try {
mediaSosial.delete.loading = true;
const res = await (ApiFetch.api.landingpage.mediasosial as any)["del"][id].delete();
const response = await fetch(`/api/landingpage/mediasosial/del/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Media Sosial berhasil dihapus");
await mediaSosial.findMany.load(); // refresh list
if (res.data?.success) {
toast.success(res.data.message || "Media Sosial berhasil dihapus");
await mediaSosial.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus media sosial");
toast.error(res.data?.message || "Gagal menghapus media sosial");
}
} catch (error) {
console.error("Gagal delete:", error);
if (process.env.NODE_ENV === 'development') {
console.error("Gagal delete:", error);
}
toast.error("Terjadi kesalahan saat menghapus media sosial");
} finally {
mediaSosial.delete.loading = false;
@@ -603,43 +596,32 @@ const mediaSosial = proxy({
return null;
}
mediaSosial.update.loading = true; // ✅ Tambahkan ini di awal
mediaSosial.update.loading = true;
try {
const response = await fetch(`/api/landingpage/mediasosial/${id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const res = await (ApiFetch.api.landingpage.mediasosial as any)[id].get();
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result?.success) {
const data = result.data;
if (res.data?.success) {
const data = res.data.data;
this.id = data.id;
this.form = {
name: data.name || "",
imageId: data.imageId || null,
iconUrl: data.iconUrl || "",
icon: data.icon || null,
};
return data;
} else {
throw new Error(
result?.message || "Gagal mengambil data media sosial"
);
toast.error(res.data?.message || "Gagal mengambil data media sosial");
return null;
}
} catch (error) {
console.error((error as Error).message);
if (process.env.NODE_ENV === 'development') {
console.error("Error loading media sosial:", error);
}
toast.error("Terjadi kesalahan saat mengambil data media sosial");
return null;
} finally {
mediaSosial.update.loading = false; // ✅ Supaya berhenti loading walau error
mediaSosial.update.loading = false;
}
},
@@ -655,41 +637,25 @@ const mediaSosial = proxy({
try {
mediaSosial.update.loading = true;
const res = await (ApiFetch.api.landingpage.mediasosial as any)[this.id].put({
name: this.form.name,
imageId: this.form.imageId,
iconUrl: this.form.iconUrl,
icon: this.form.icon,
});
const response = await fetch(
`/api/landingpage/mediasosial/${this.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: this.form.name,
imageId: this.form.imageId,
iconUrl: this.form.iconUrl,
icon: this.form.icon,
}),
}
);
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) {
if (res.data?.success) {
toast.success("Berhasil update media sosial");
await mediaSosial.findMany.load(); // refresh list
await mediaSosial.findMany.load();
return true;
} else {
throw new Error(result.message || "Gagal update media sosial");
toast.error(res.data?.message || "Gagal update media sosial");
return false;
}
} catch (error) {
console.error("Error updating media sosial:", error);
if (process.env.NODE_ENV === 'development') {
console.error("Error updating media sosial:", error);
}
toast.error(
error instanceof Error
? error.message

View File

@@ -95,7 +95,7 @@ function Page() {
fz={{ base: 'md', md: 'lg' }}
lh={{ base: 1.4, md: 1.4 }}
>
I.B. Surya Prabhawa Manuaba, S.H., M.H.
{perbekel.nama || "I.B. Surya Prabhawa Manuaba, S.H., M.H."}
</Text>
</Paper>
</Stack>

View File

@@ -44,6 +44,21 @@ function EditDigitalSmartVillage() {
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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.name?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
useEffect(() => {
const loadData = async () => {
const id = params?.id as string;
@@ -248,8 +263,11 @@ function EditDigitalSmartVillage() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -30,6 +30,22 @@ export default function CreateDesaDigital() {
const router = useRouter();
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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
stateDesaDigital.create.form.name?.trim() !== '' &&
!isHtmlEmpty(stateDesaDigital.create.form.deskripsi) &&
file !== null
);
};
const resetForm = () => {
stateDesaDigital.create.form = {
name: '',
@@ -227,8 +243,11 @@ export default function CreateDesaDigital() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -44,6 +44,21 @@ function EditInfoTeknologiTepatGuna() {
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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.name?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
// Load data pertama kali
useEffect(() => {
const id = params?.id as string;
@@ -260,8 +275,11 @@ function EditInfoTeknologiTepatGuna() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -30,6 +30,22 @@ function CreateInfoTeknologiTepatGuna() {
const router = useRouter();
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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
stateInfoTekno.create.form.name?.trim() !== '' &&
!isHtmlEmpty(stateInfoTekno.create.form.deskripsi) &&
file !== null
);
};
const resetForm = () => {
stateInfoTekno.create.form = {
name: '',
@@ -202,8 +218,11 @@ function CreateInfoTeknologiTepatGuna() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -44,6 +44,23 @@ function EditKolaborasiInovasi() {
kolaborator: "",
});
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.name?.trim() !== '' &&
formData.slug?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi) &&
formData.kolaborator?.trim() !== ''
);
};
// Load data awal dari server
useEffect(() => {
const loadKolaborasi = async () => {
@@ -199,8 +216,11 @@ function EditKolaborasiInovasi() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -16,6 +16,22 @@ function CreateProgramKreatifDesa() {
const router = useRouter();
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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
stateCreate.create.form.name?.trim() !== '' &&
stateCreate.create.form.slug?.trim() !== '' &&
!isHtmlEmpty(stateCreate.create.form.deskripsi)
);
};
const resetForm = () => {
stateCreate.create.form = {
name: "",
@@ -135,8 +151,11 @@ function CreateProgramKreatifDesa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -51,6 +51,11 @@ function EditMitraKolaborasi() {
imageUrl: '',
});
// Check if form is valid
const isFormValid = () => {
return formData.name?.trim() !== '';
};
// Load data ke state lokal sekali saja
useEffect(() => {
const loadData = async () => {
@@ -263,8 +268,11 @@ function EditMitraKolaborasi() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -29,6 +29,14 @@ function CreateMitraKolaborasi() {
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return (
state.create.form.name?.trim() !== '' &&
file !== null
);
};
const resetForm = () => {
state.create.form = {
name: '',
@@ -181,8 +189,11 @@ function CreateMitraKolaborasi() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -51,6 +51,23 @@ function EditProgramKreatifDesa() {
const [isDataChanged, setIsDataChanged] = 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.name?.trim() !== '' &&
formData.slug?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi) &&
formData.icon?.trim() !== ''
);
};
// Load data hanya sekali berdasarkan params.id
useEffect(() => {
const loadProgramKreatif = async () => {
@@ -236,8 +253,11 @@ function EditProgramKreatifDesa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -25,6 +25,23 @@ function CreateProgramKreatifDesa() {
const router = useRouter();
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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
stateCreate.create.form.name?.trim() !== '' &&
stateCreate.create.form.icon?.trim() !== '' &&
stateCreate.create.form.slug?.trim() !== '' &&
!isHtmlEmpty(stateCreate.create.form.deskripsi)
);
};
const resetForm = () => {
stateCreate.create.form = {
name: "",
@@ -127,8 +144,11 @@ function CreateProgramKreatifDesa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -53,7 +53,7 @@ function EditAPBDes() {
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return (
@@ -76,33 +76,62 @@ function EditAPBDes() {
tipe: 'pendapatan',
});
// Type for the API response
interface APBDesResponse {
id: string;
image?: {
link: string;
id: string;
};
file?: {
link: string;
id: string;
};
// Add other properties as needed
}
// Simpan data original untuk reset form
const [originalData, setOriginalData] = useState({
tahun: 0,
imageId: '',
fileId: '',
imageUrl: '',
fileUrl: '',
});
// Load data saat pertama kali
useEffect(() => {
const id = params?.id as string;
if (id) {
apbdesState.edit.load(id).then((response) => {
const data = response as unknown as APBDesResponse;
if (data) {
// ✅ Ambil link langsung dari response
setPreviewImage(data.image?.link || null);
setPreviewDoc(data.file?.link || null);
}
});
}
if (!id) return;
const loadData = async () => {
try {
const data = await apbdesState.edit.load(id);
if (!data) return;
// Set preview dari data lama
setPreviewImage(data.image?.link || null);
setPreviewDoc(data.file?.link || null);
// Simpan data original untuk reset
setOriginalData({
tahun: data.tahun || new Date().getFullYear(),
imageId: data.imageId || '',
fileId: data.fileId || '',
imageUrl: data.image?.link || '',
fileUrl: data.file?.link || '',
});
// Set form dengan data lama (termasuk imageId dan fileId)
apbdesState.edit.form = {
tahun: data.tahun || new Date().getFullYear(),
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',
})),
};
} catch (error) {
console.error('Error loading APBDes:', error);
toast.error('Gagal memuat data APBDes');
}
};
loadData();
}, [params?.id]);
const handleDrop = (fileType: 'image' | 'doc') => (files: File[]) => {
@@ -162,23 +191,38 @@ function EditAPBDes() {
try {
setIsSubmitting(true);
// Upload file baru jika ada
// Upload file baru jika ada perubahan
if (imageFile) {
// Hapus file lama dari form jika ada file baru
const res = await ApiFetch.api.fileStorage.create.post({
file: imageFile,
name: imageFile.name,
});
const imageId = res.data?.data?.id;
if (imageId) apbdesState.edit.form.imageId = imageId;
if (imageId) {
apbdesState.edit.form.imageId = imageId;
}
}
if (docFile) {
// Hapus file lama dari form jika ada file baru
const res = await ApiFetch.api.fileStorage.create.post({
file: docFile,
name: docFile.name,
});
const fileId = res.data?.data?.id;
if (fileId) apbdesState.edit.form.fileId = fileId;
if (fileId) {
apbdesState.edit.form.fileId = fileId;
}
}
// Jika tidak ada file baru, gunakan ID lama (sudah ada di form)
// Pastikan imageId dan fileId tetap ada
if (!apbdesState.edit.form.imageId) {
return toast.warn('Gambar wajib diunggah');
}
if (!apbdesState.edit.form.fileId) {
return toast.warn('Dokumen wajib diunggah');
}
const success = await apbdesState.edit.update();
@@ -194,21 +238,33 @@ function EditAPBDes() {
};
const handleReset = () => {
const id = params?.id as string;
if (id) {
apbdesState.edit.load(id);
setImageFile(null);
setDocFile(null);
setNewItem({
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
toast.info('Form dikembalikan ke data awal');
}
// Reset ke data original (tahun, imageId, fileId)
apbdesState.edit.form = {
tahun: originalData.tahun,
imageId: originalData.imageId,
fileId: originalData.fileId,
items: [...apbdesState.edit.form.items], // keep existing items
};
// 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');
};
return (

View File

@@ -8,6 +8,7 @@ import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import DOMPurify from 'dompurify';
function DetailProgramInovasi() {
const stateProgramInovasi = useProxy(profileLandingPageState.programInovasi)
@@ -85,7 +86,7 @@ function DetailProgramInovasi() {
<Box>
<Text fz="lg" fw="bold">Deskripsi</Text>
<Box pl={5}>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.description || '-' }}></Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(data.description || '-') }}></Text>
</Box>
</Box>

View File

@@ -6,6 +6,7 @@ import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import DOMPurify from 'dompurify';
import HeaderSearch from '../../../_com/header';
import profileLandingPageState from '../../../_state/landing-page/profile';
@@ -90,7 +91,7 @@ function ListProgramInovasi({ search }: { search: string }) {
<Text fw={500}>{item.name}</Text>
</TableTd>
<TableTd style={{ maxWidth: 250 }}>
<Text fz="sm" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.description || '-' }}></Text>
<Text fz="sm" lineClamp={1} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.description || '-') }}></Text>
</TableTd>
<TableTd style={{ maxWidth: 250 }}>
<Tooltip label="Buka tautan program" position="top" withArrow>
@@ -144,7 +145,7 @@ function ListProgramInovasi({ search }: { search: string }) {
{/* Description */}
<Box>
<Text fz="sm" fw={600} lh={1.4}>Deskripsi</Text>
<Text dangerouslySetInnerHTML={{ __html: item.description || '-' }} fz="sm" c="gray.7" lineClamp={2} />
<Text dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.description || '-') }} fz="sm" c="gray.7" lineClamp={2} />
</Box>
{/* Link */}

View File

@@ -67,6 +67,23 @@ export default function EditDataLingkunganDesa() {
icon: '',
});
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.name?.trim() !== '' &&
formData.jumlah?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi) &&
formData.icon?.trim() !== ''
);
};
// Load data saat komponen mount
useEffect(() => {
const loadData = async () => {
@@ -211,8 +228,11 @@ export default function EditDataLingkunganDesa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -25,6 +25,23 @@ function CreateDataLingkunganDesa() {
const router = useRouter();
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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
stateCreate.create.form.name?.trim() !== '' &&
stateCreate.create.form.icon?.trim() !== '' &&
stateCreate.create.form.jumlah?.trim() !== '' &&
!isHtmlEmpty(stateCreate.create.form.deskripsi)
);
};
const resetForm = () => {
stateCreate.create.form = {
name: '',
@@ -129,8 +146,11 @@ function CreateDataLingkunganDesa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -38,6 +38,21 @@ export default function EditContohKegiatanDesaDarmasaba() {
deskripsi: '',
});
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
!isHtmlEmpty(formData.judul) &&
!isHtmlEmpty(formData.deskripsi)
);
};
// load data awal
useShallowEffect(() => {
if (!contohEdukasiState.findById.data) {
@@ -156,8 +171,11 @@ export default function EditContohKegiatanDesaDarmasaba() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -27,6 +27,21 @@ export default function EditMateriEdukasiYangDiberikan() {
content: '',
});
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
!isHtmlEmpty(formData.judul) &&
!isHtmlEmpty(formData.content)
);
};
// Initialize data kalau belum ada
useShallowEffect(() => {
if (!materiEdukasiState.findById.data) {
@@ -139,8 +154,11 @@ export default function EditMateriEdukasiYangDiberikan() {
onClick={submit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -28,6 +28,21 @@ export default function EditTujuanEdukasiLingkungan() {
deskripsi: '',
});
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
!isHtmlEmpty(formData.judul) &&
!isHtmlEmpty(formData.deskripsi)
);
};
// Initialize global state
useShallowEffect(() => {
if (!tujuanEdukasiState.findById.data) {
@@ -147,8 +162,11 @@ export default function EditTujuanEdukasiLingkungan() {
onClick={submit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -21,6 +21,11 @@ function EditKategoriKegiatan() {
const [originalData, setOriginalData] = useState({ nama: '' });
const [loading, setLoading] = useState(true);
// Check if form is valid
const isFormValid = () => {
return formData.nama?.trim() !== '';
};
// Load data once
useEffect(() => {
if (!id) return;
@@ -126,8 +131,11 @@ function EditKategoriKegiatan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -14,6 +14,11 @@ function CreateKategoriKegiatan() {
const stateKategori = useProxy(gotongRoyongState.kategoriKegiatan)
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return stateKategori.create.form.nama?.trim() !== '';
};
useEffect(() => {
stateKategori.findMany.load();
}, []);
@@ -84,8 +89,11 @@ function CreateKategoriKegiatan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -67,6 +67,27 @@ export default function EditKegiatanDesa() {
const [file, setFile] = useState<File | null>(null);
const [previewImage, setPreviewImage] = useState<string | null>(null);
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsiSingkat) &&
!isHtmlEmpty(formData.deskripsiLengkap) &&
formData.tanggal?.trim() !== '' &&
formData.lokasi?.trim() !== '' &&
formData.partisipan !== null &&
formData.partisipan >= 0 &&
formData.kategoriKegiatanId?.trim() !== ''
);
};
const formatDateForInput = (dateString: string) => {
if (!dateString) return '';
return new Date(dateString).toISOString().split('T')[0];
@@ -312,8 +333,11 @@ export default function EditKegiatanDesa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -38,6 +38,28 @@ function CreateKegiatanDesa() {
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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
stateKegiatanDesa.create.form.judul?.trim() !== '' &&
!isHtmlEmpty(stateKegiatanDesa.create.form.deskripsiSingkat) &&
stateKegiatanDesa.create.form.partisipan !== null &&
stateKegiatanDesa.create.form.partisipan >= 0 &&
stateKegiatanDesa.create.form.tanggal !== null &&
stateKegiatanDesa.create.form.lokasi?.trim() !== '' &&
!isHtmlEmpty(stateKegiatanDesa.create.form.deskripsiLengkap) &&
stateKegiatanDesa.create.form.kategoriKegiatanId?.trim() !== '' &&
file !== null
);
};
const resetForm = () => {
stateKegiatanDesa.create.form = {
judul: '',
@@ -273,8 +295,11 @@ function CreateKegiatanDesa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -27,6 +27,21 @@ function EditBentukKonservasiBerdasarkanAdat() {
deskripsi: '',
});
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
!isHtmlEmpty(formData.judul) &&
!isHtmlEmpty(formData.deskripsi)
);
};
// Initialize data dari global state
useShallowEffect(() => {
if (!bentukKonservasiState.findById.data) {
@@ -137,8 +152,11 @@ function EditBentukKonservasiBerdasarkanAdat() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -31,6 +31,21 @@ function EditFilosofiTriHitaKarana() {
content: '',
});
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
!isHtmlEmpty(formData.judul) &&
!isHtmlEmpty(formData.content)
);
};
// Load data dari global state kalau belum ada
useShallowEffect(() => {
if (!filosofiTriHitaState.findById.data) {
@@ -142,8 +157,11 @@ function EditFilosofiTriHitaKarana() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -24,6 +24,21 @@ function EditNilaiKonservasiAdat() {
const [formData, setFormData] = useState({ judul: '', deskripsi: '' });
const [originalData, setOriginalData] = useState({ judul: '', deskripsi: '' });
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
!isHtmlEmpty(formData.judul) &&
!isHtmlEmpty(formData.deskripsi)
);
};
// load data awal
useShallowEffect(() => {
if (!nilaiKonservasiState.findById.data) {
@@ -136,8 +151,11 @@ function EditNilaiKonservasiAdat() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -35,6 +35,16 @@ function EditKeteranganBankSampahTerdekat() {
lng: 0,
});
// Check if form is valid
const isFormValid = () => {
return (
formData.name?.trim() !== '' &&
formData.alamat?.trim() !== '' &&
formData.namaTempatMaps?.trim() !== '' &&
markerPosition !== null
);
};
// Load data ketika component mount
useEffect(() => {
const loadKeterangan = async () => {
@@ -197,8 +207,11 @@ function EditKeteranganBankSampahTerdekat() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -19,6 +19,16 @@ function CreateKeteranganBankSampahTerdekat() {
const [markerPosition, setMarkerPosition] = useState<{ lat: number; lng: number } | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return (
keteranganState.create.form.name?.trim() !== '' &&
keteranganState.create.form.alamat?.trim() !== '' &&
keteranganState.create.form.namaTempatMaps?.trim() !== '' &&
markerPosition !== null
);
};
const resetForm = () => {
keteranganState.create.form = {
name: "",
@@ -135,8 +145,11 @@ function CreateKeteranganBankSampahTerdekat() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -34,6 +34,14 @@ function EditProgramKreatifDesa() {
icon: '',
});
// Check if form is valid
const isFormValid = () => {
return (
formData.name?.trim() !== '' &&
formData.icon?.trim() !== ''
);
};
useEffect(() => {
const loadProgramKreatif = async () => {
const id = params?.id as string;
@@ -143,8 +151,11 @@ function EditProgramKreatifDesa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -13,6 +13,14 @@ function CreatePengelolaanSampahBankSampah() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return (
stateCreate.create.form.name?.trim() !== '' &&
stateCreate.create.form.icon?.trim() !== ''
);
};
const resetForm = () => {
stateCreate.create.form = {
name: "",
@@ -91,8 +99,11 @@ function CreatePengelolaanSampahBankSampah() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -64,6 +64,23 @@ function EditProgramPenghijauan() {
icon: '',
});
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.name?.trim() !== '' &&
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi) &&
formData.icon?.trim() !== ''
);
};
// Load data program penghijauan
useEffect(() => {
const loadProgram = async () => {
@@ -216,8 +233,11 @@ function EditProgramPenghijauan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -25,6 +25,23 @@ function CreateProgramPenghijauan() {
const router = useRouter();
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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
stateCreate.create.form.name?.trim() !== '' &&
stateCreate.create.form.icon?.trim() !== '' &&
stateCreate.create.form.judul?.trim() !== '' &&
!isHtmlEmpty(stateCreate.create.form.deskripsi)
);
};
const resetForm = () => {
stateCreate.create.form = {
name: '',
@@ -128,8 +145,11 @@ function CreateProgramPenghijauan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -24,6 +24,21 @@ function EditProgramKreatifDesa() {
deskripsi: '',
})
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
useEffect(() => {
const loadProgramKreatif = async () => {
const id = params?.id as string;
@@ -160,8 +175,11 @@ function EditProgramKreatifDesa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -16,6 +16,21 @@ function CreateKeunggulanProgram() {
const router = useRouter();
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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
stateCreate.create.form.judul?.trim() !== '' &&
!isHtmlEmpty(stateCreate.create.form.deskripsi)
);
};
const resetForm = () => {
stateCreate.create.form = {
judul: "",
@@ -97,8 +112,11 @@ function CreateKeunggulanProgram() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -42,6 +42,21 @@ function EditFasilitasYangDisediakan() {
deskripsi: '',
});
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
// Load data pertama kali
useShallowEffect(() => {
if (!editState.findById.data) {
@@ -76,11 +91,6 @@ function EditFasilitasYangDisediakan() {
};
const handleSubmit = async () => {
if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return;
}
setIsSubmitting(true);
try {
if (editState.findById.data) {
@@ -180,8 +190,11 @@ function EditFasilitasYangDisediakan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -39,6 +39,21 @@ function EditLokasiDanJadwal() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({ judul: '', deskripsi: '' });
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
// Load data sekali
useShallowEffect(() => {
if (!editState.findById.data) {
@@ -73,11 +88,6 @@ function EditLokasiDanJadwal() {
};
const handleSubmit = async () => {
if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return;
}
setIsSubmitting(true);
try {
if (editState.findById.data) {
@@ -178,8 +188,11 @@ function EditLokasiDanJadwal() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -39,6 +39,21 @@ function EditTujuanProgram() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({ judul: '', deskripsi: '' });
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
// load data sekali
useShallowEffect(() => {
if (!editState.findById.data) editState.findById.initialize();
@@ -71,11 +86,6 @@ function EditTujuanProgram() {
};
const handleSubmit = async () => {
if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return;
}
setIsSubmitting(true);
try {
if (editState.findById.data) {
@@ -170,8 +180,11 @@ function EditTujuanProgram() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -28,6 +28,14 @@ export default function EditDataPendidikan() {
jumlah: '',
});
// Check if form is valid
const isFormValid = () => {
return (
formData.name?.trim() !== '' &&
formData.jumlah?.trim() !== ''
);
};
// Load data saat mount
useEffect(() => {
if (id) {
@@ -127,8 +135,11 @@ export default function EditDataPendidikan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -15,6 +15,14 @@ export default function CreateDataPendidikan() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return (
stateDPM.create.form.name?.trim() !== '' &&
stateDPM.create.form.jumlah?.trim() !== ''
);
};
const resetForm = () => {
stateDPM.create.form = { name: '', jumlah: '' };
};
@@ -90,8 +98,11 @@ export default function CreateDataPendidikan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -31,6 +31,11 @@ function EditJenjangPendidikan() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
// Check if form is valid
const isFormValid = () => {
return formData.nama?.trim() !== '';
};
// Load data sekali saat component mount
useEffect(() => {
if (!id) return;
@@ -136,8 +141,11 @@ function EditJenjangPendidikan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -23,6 +23,11 @@ function CreateJenjangPendidikan() {
const stateJenjang = useProxy(infoSekolahPaud.jenjangPendidikan);
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return stateJenjang.create.form.nama?.trim() !== '';
};
useEffect(() => {
stateJenjang.findMany.load();
}, []);
@@ -101,8 +106,11 @@ function CreateJenjangPendidikan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -37,6 +37,14 @@ export default function EditLembaga() {
jenjangId: '',
});
// Check if form is valid
const isFormValid = () => {
return (
form.nama?.trim() !== '' &&
form.jenjangId?.trim() !== ''
);
};
// Load jenjang pendidikan dan data lembaga
useEffect(() => {
infoSekolahPaud.jenjangPendidikan.findMany.load();
@@ -161,8 +169,11 @@ export default function EditLembaga() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -25,6 +25,14 @@ function CreateLembaga() {
const stateLembaga = useProxy(infoSekolahPaud.lembagaPendidikan);
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return (
stateLembaga.create.form.nama?.trim() !== '' &&
stateLembaga.create.form.jenjangId?.trim() !== ''
);
};
useEffect(() => {
stateLembaga.findMany.load();
infoSekolahPaud.jenjangPendidikan.findMany.load();
@@ -116,8 +124,11 @@ function CreateLembaga() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -40,6 +40,14 @@ function EditPengajar() {
lembagaId: ''
});
// Check if form is valid
const isFormValid = () => {
return (
formData.nama?.trim() !== '' &&
formData.lembagaId?.trim() !== ''
);
};
useEffect(() => {
const loadData = async () => {
const id = params?.id as string;
@@ -157,8 +165,11 @@ function EditPengajar() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -25,6 +25,14 @@ function CreatePengajar() {
const stateCreate = useProxy(infoSekolahPaud.pengajar);
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return (
stateCreate.create.form.nama?.trim() !== '' &&
stateCreate.create.form.lembagaId?.trim() !== ''
);
};
useEffect(() => {
stateCreate.findMany.load();
infoSekolahPaud.lembagaPendidikan.findMany.load();
@@ -116,8 +124,11 @@ function CreatePengajar() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -42,6 +42,14 @@ function EditSiswa() {
lembagaId: '',
});
// Check if form is valid
const isFormValid = () => {
return (
formData.nama?.trim() !== '' &&
formData.lembagaId?.trim() !== ''
);
};
// Load data siswa
useEffect(() => {
const loadSiswa = async () => {
@@ -166,8 +174,11 @@ function EditSiswa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -25,6 +25,14 @@ function CreateSiswa() {
const stateCreate = useProxy(infoSekolahPaud.siswa);
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return (
stateCreate.create.form.nama?.trim() !== '' &&
stateCreate.create.form.lembagaId?.trim() !== ''
);
};
useEffect(() => {
stateCreate.findMany.load();
infoSekolahPaud.lembagaPendidikan.findMany.load();
@@ -115,8 +123,11 @@ function CreateSiswa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -37,6 +37,21 @@ function EditJenisProgramYangDiselenggarakan() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({ judul: '', content: '' });
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.content)
);
};
// Load data pertama kali
useShallowEffect(() => {
if (!editState.findById.data) {
@@ -71,11 +86,6 @@ function EditJenisProgramYangDiselenggarakan() {
};
const handleSubmit = async () => {
if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return;
}
setIsSubmitting(true);
try {
if (editState.findById.data) {
@@ -168,8 +178,11 @@ function EditJenisProgramYangDiselenggarakan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -45,6 +45,21 @@ function EditTempatKegiatan() {
deskripsi: '',
});
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
// load data pertama kali
useShallowEffect(() => {
if (!editState.findById.data) {
@@ -79,11 +94,6 @@ function EditTempatKegiatan() {
};
const handleSubmit = async () => {
if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return;
}
setIsSubmitting(true);
try {
if (editState.findById.data) {
@@ -177,8 +187,11 @@ function EditTempatKegiatan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -38,6 +38,21 @@ function EditTujuanProgram() {
deskripsi: '',
});
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
// Load data pertama kali
useShallowEffect(() => {
if (!editState.findById.data) {
@@ -72,11 +87,6 @@ function EditTujuanProgram() {
};
const handleSubmit = async () => {
if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return;
}
if (!editState.findById.data) return;
setIsSubmitting(true);
@@ -163,8 +173,11 @@ function EditTujuanProgram() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -38,6 +38,22 @@ function EditPerpustakaanDigital() {
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi) &&
formData.kategoriId?.trim() !== ''
);
};
// Load kategori & data awal
useEffect(() => {
perpustakaanDigitalState.kategoriBuku.findManyAll.load();
@@ -254,8 +270,11 @@ function EditPerpustakaanDigital() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -18,6 +18,23 @@ function CreateDataPerpustakaan() {
const [file, setFile] = useState<File | null>(null);
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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
createState.create.form.judul?.trim() !== '' &&
!isHtmlEmpty(createState.create.form.deskripsi) &&
createState.create.form.kategoriId?.trim() !== '' &&
file !== null
);
};
useEffect(() => {
perpustakaanDigitalState.kategoriBuku.findManyAll.load();
}, []);
@@ -196,8 +213,11 @@ function CreateDataPerpustakaan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -23,6 +23,11 @@ function EditKategoriBuku() {
const [formData, setFormData] = useState({ name: '' });
const [loading, setLoading] = useState(true);
// Check if form is valid
const isFormValid = () => {
return formData.name?.trim() !== '';
};
useEffect(() => {
const loadKategori = async () => {
const id = params?.id as string;
@@ -120,8 +125,11 @@ function EditKategoriBuku() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -13,6 +13,11 @@ function CreateKategoriBuku() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return createState.create.form.name?.trim() !== '';
};
const resetForm = () => {
createState.create.form = {
name: "",
@@ -81,8 +86,11 @@ function CreateKategoriBuku() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -70,6 +70,26 @@ function EditPeminjam() {
catatan: "",
})
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.nama?.trim() !== '' &&
formData.noTelp?.trim() !== '' &&
formData.alamat?.trim() !== '' &&
formData.bukuId?.trim() !== '' &&
formData.tanggalPinjam?.trim() !== '' &&
formData.status?.trim() !== '' &&
!isHtmlEmpty(formData.catatan)
);
};
useShallowEffect(() => {
perpustakaanDigitalState.dataPerpustakaan.findManyAll.load()
})
@@ -296,8 +316,11 @@ function EditPeminjam() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -50,6 +50,21 @@ function EditTujuanProgram() {
});
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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
// load data once
useShallowEffect(() => {
if (!editState.findById.data) editState.findById.initialize();
@@ -85,11 +100,6 @@ function EditTujuanProgram() {
};
const handleSubmit = async () => {
if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return;
}
setIsSubmitting(true);
try {
if (editState.findById.data) {
@@ -186,8 +196,11 @@ function EditTujuanProgram() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -38,6 +38,21 @@ function EditTujuanProgram() {
deskripsi: '',
});
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
// Load data pertama kali
useShallowEffect(() => {
if (!editState.findById.data) {
@@ -72,11 +87,6 @@ function EditTujuanProgram() {
};
const handleSubmit = async () => {
if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return;
}
setIsSubmitting(true);
try {
if (editState.findById.data) {
@@ -166,8 +176,11 @@ function EditTujuanProgram() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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)',
}}

View File

@@ -82,17 +82,17 @@ function EditDaftarInformasiPublik() {
await daftarInformasi.edit.update();
router.push('/admin/ppid/daftar-informasi-publik');
} catch (error) {
console.error('Error updating berita:', error);
toast.error('Terjadi kesalahan saat memperbarui berita');
console.error('Error updating daftar informasi:', error);
toast.error('Terjadi kesalahan saat memperbarui daftar informasi');
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<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 Daftar Informasi Publik
</Title>

View File

@@ -6,6 +6,7 @@ import { IconEdit } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils';
import stateDasarHukumPPID from '../../_state/ppid/dasar_hukum/dasarHukum';
import DOMPurify from 'dompurify';
function Page() {
const router = useRouter();
@@ -68,7 +69,7 @@ function Page() {
lh={{ base: 1.15, md: 1.1 }}
fw="bold"
c={colors['blue-button']}
dangerouslySetInnerHTML={{ __html: listDasarHukum.findById.data.judul }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(listDasarHukum.findById.data.judul) }}
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
/>
</GridCol>
@@ -77,7 +78,7 @@ function Page() {
<Divider my="xl" color={colors['blue-button']} />
<Text
dangerouslySetInnerHTML={{ __html: listDasarHukum.findById.data.content }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(listDasarHukum.findById.data.content) }}
style={{
wordBreak: 'break-word',
whiteSpace: 'normal',

View File

@@ -6,6 +6,7 @@ import { IconEdit } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils';
import stateProfilePPID from '../../_state/ppid/profile_ppid/profile_PPID';
import DOMPurify from 'dompurify';
function Page() {
const router = useRouter();
@@ -114,7 +115,7 @@ function Page() {
c={colors['blue-button']}
lh={1.5}
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: item.biodata }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.biodata) }}
/>
</Box>
@@ -129,7 +130,7 @@ function Page() {
c={colors['blue-button']}
lh={1.5}
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: item.riwayat }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.riwayat) }}
/>
</Box>
</Box>
@@ -145,7 +146,7 @@ function Page() {
c={colors['blue-button']}
lh={1.5}
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: item.pengalaman }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.pengalaman) }}
/>
</Box>
</Box>
@@ -161,7 +162,7 @@ function Page() {
c={colors['blue-button']}
lh={1.5}
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: item.unggulan }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.unggulan) }}
/>
</Box>
</Box>

View File

@@ -9,6 +9,7 @@ import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import stateStrukturPPID from '../../../_state/ppid/struktur_ppid/struktur_PPID';
import DOMPurify from 'dompurify';
function PosisiOrganisasiPPID() {
const [search, setSearch] = useState("");
@@ -100,7 +101,7 @@ function ListPosisiOrganisasiPPID({ search }: { search: string }) {
<Text fz="md" fw={600} lh={1.5} truncate="end" lineClamp={1}>{item.nama}</Text>
</TableTd>
<TableTd w={200}>
<Text fz="sm" lh={1.5} c="dimmed" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi || '-' }} />
<Text fz="sm" lh={1.5} c="dimmed" lineClamp={1} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.deskripsi || '-') }} />
</TableTd>
<TableTd>
<Text fz="md" lh={1.5}>{item.hierarki || '-'}</Text>

View File

@@ -6,6 +6,7 @@ import { IconEdit } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils';
import stateVisiMisiPPID from '../../_state/ppid/visi_misi_ppid/visimisiPPID';
import DOMPurify from 'dompurify'
function VisiMisiPPIDList() {
const router = useRouter();
@@ -96,7 +97,7 @@ function VisiMisiPPIDList() {
</Title>
<Text
ta={{ base: "center", md: "justify" }}
dangerouslySetInnerHTML={{ __html: listVisiMisi.findById.data.visi }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(listVisiMisi.findById.data.visi) }}
style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
@@ -121,7 +122,7 @@ function VisiMisiPPIDList() {
</Title>
<Text
ta={"justify"}
dangerouslySetInnerHTML={{ __html: listVisiMisi.findById.data.misi }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(listVisiMisi.findById.data.misi) }}
style={{
wordBreak: 'break-word',
whiteSpace: 'normal',

View File

@@ -1,7 +1,9 @@
'use client'
import colors from "@/con/colors";
import { authStore } from "@/store/authStore";
import { useDarkMode } from "@/state/darkModeStore";
import { themeTokens, getActiveStateStyles } from "@/utils/themeTokens";
import { DarkModeToggle } from "@/components/admin/DarkModeToggle";
import {
ActionIcon,
AppShell,
@@ -33,13 +35,21 @@ import { useEffect, useState } from "react";
import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
export default function Layout({ children }: { children: React.ReactNode }) {
const [opened, { toggle, close }] = useDisclosure(); // ✅ Tambahkan 'close'
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
const [mounted, setMounted] = useState(false);
const [opened, { toggle, close }] = useDisclosure();
const [loading, setLoading] = useState(true);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
const router = useRouter();
const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
// Ensure component is mounted on client side
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const fetchUser = async () => {
@@ -74,7 +84,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
});
const currentPath = window.location.pathname;
if (currentPath === '/admin') {
const expectedPath = getRedirectPath(Number(data.user.roleId));
console.log('🔄 Redirecting from /admin to:', expectedPath);
@@ -112,11 +122,11 @@ export default function Layout({ children }: { children: React.ReactNode }) {
}
};
if (loading) {
if (loading || !mounted) {
return (
<AppShell>
<AppShellMain>
<Center h="100vh">
<Center h="100vh" bg="#f6f9fc">
<Loader />
</Center>
</AppShellMain>
@@ -132,7 +142,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
try {
setIsLoggingOut(true);
const response = await fetch('/api/auth/logout', {
const response = await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include'
});
@@ -158,10 +168,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
}
};
// ✅ Handler untuk menutup mobile menu saat navigasi
const handleNavClick = (path: string) => {
router.push(path);
close(); // Tutup mobile menu
close();
};
return (
@@ -178,11 +187,16 @@ export default function Layout({ children }: { children: React.ReactNode }) {
}}
padding="md"
>
{/*
HEADER / TOPBAR
Spec: Background gradient, border bawah wajib
*/}
<AppShellHeader
style={{
background: "linear-gradient(90deg, #ffffff, #f9fbff)",
borderBottom: `1px solid ${colors["blue-button"]}20`,
background: mounted ? tokens.colors.bg.header : 'linear-gradient(90deg, #ffffff, #f9fbff)',
borderBottom: `1px solid ${mounted ? tokens.colors.border.soft : '#e9ecef'}`,
padding: '0 16px',
transition: 'background 0.3s ease, border-color 0.3s ease',
}}
px={{ base: 'sm', sm: 'md' }}
py={{ base: 'xs', sm: 'sm' }}
@@ -198,30 +212,49 @@ export default function Layout({ children }: { children: React.ReactNode }) {
loading="lazy"
style={{ minWidth: '32px', height: 'auto' }}
/>
<Text fw={700} c={colors["blue-button"]} fz={{ base: 'md', sm: 'xl' }}>
<Text fw={700} c={mounted ? tokens.colors.text.brand : '#0A4E78'} fz={{ base: 'md', sm: 'xl' }}>
Admin Darmasaba
</Text>
</Flex>
<Group gap="xs">
{/* Dark Mode Toggle */}
<DarkModeToggle variant="light" size="lg" showTooltip tooltipPosition="bottom" />
{!desktopOpened && (
<Tooltip label="Buka Navigasi" position="bottom" withArrow>
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={colors["blue-button"]}>
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={mounted ? tokens.colors.primary : '#3B82F6'}>
<IconChevronRight />
</ActionIcon>
</Tooltip>
)}
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="md" color={colors["blue-button"]} mr="xs" />
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="md" color={mounted ? tokens.colors.text.brand : '#0A4E78'} mr="xs" />
<Tooltip label="Kembali ke Website Desa" position="bottom" withArrow>
<ActionIcon onClick={() => router.push("/darmasaba")} color={colors["blue-button"]} radius="xl" size="lg" variant="gradient" gradient={{ from: colors["blue-button"], to: "#228be6" }}>
<ActionIcon
onClick={() => router.push("/darmasaba")}
color={mounted ? tokens.colors.primary : '#3B82F6'}
radius="xl"
size="lg"
variant="gradient"
gradient={mounted ? tokens.colors.gradient : { from: '#3B82F6', to: '#60A5FA' }}
>
<Image src="/assets/images/darmasaba-icon.png" alt="Logo Darmasaba" w={20} h={20} radius="md" loading="lazy" style={{ minWidth: '20px', height: 'auto' }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Keluar" position="bottom" withArrow>
<ActionIcon onClick={handleLogout} color={colors["blue-button"]} radius="xl" size="lg" variant="gradient" gradient={{ from: colors["blue-button"], to: "#228be6" }} loading={isLoggingOut} disabled={isLoggingOut}>
<ActionIcon
onClick={handleLogout}
color={mounted ? tokens.colors.primary : '#3B82F6'}
radius="xl"
size="lg"
variant="gradient"
gradient={mounted ? tokens.colors.gradient : { from: '#3B82F6', to: '#60A5FA' }}
loading={isLoggingOut}
disabled={isLoggingOut}
>
<IconLogout2 size={22} />
</ActionIcon>
</Tooltip>
@@ -229,47 +262,105 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</Group>
</AppShellHeader>
<AppShellNavbar component={ScrollArea} style={{ background: "#ffffff", borderRight: `1px solid ${colors["blue-button"]}20` }} p={{ base: 'xs', sm: 'sm' }}>
{/*
SIDEBAR / NAVBAR
Spec: Background --bg-app, active state dengan accent bar
*/}
<AppShellNavbar
component={ScrollArea}
style={{
background: mounted ? tokens.colors.bg.app : '#ffffff',
borderRight: `1px solid ${mounted ? tokens.colors.border.soft : '#e9ecef'}`,
transition: 'background 0.3s ease, border-color 0.3s ease',
}}
p={{ base: 'xs', sm: 'sm' }}
>
<AppShell.Section p="sm">
{currentNav.map((v, k) => {
const isParentActive = segments.includes(_.lowerCase(v.name));
return (
<NavLink
key={k}
defaultOpened={isParentActive}
c={isParentActive ? colors["blue-button"] : "gray"}
label={<Text fw={isParentActive ? 600 : 400} fz="sm">{v.name}</Text>}
style={{ borderRadius: rem(10), marginBottom: rem(4), transition: "background 150ms ease" }}
styles={{ root: { '&:hover': { backgroundColor: 'rgba(25, 113, 194, 0.05)' } } }}
variant="light"
<NavLink
key={k}
defaultOpened={isParentActive}
c={mounted && isParentActive ? tokens.colors.primary : mounted && isDark ? '#E5E7EB' : tokens.colors.text.secondary}
label={
<Text
fw={isParentActive ? 600 : 400}
fz="sm"
style={{
color: mounted && isDark ? '#E5E7EB' : 'inherit',
transition: 'color 150ms ease',
}}
>
{v.name}
</Text>
}
style={{
borderRadius: rem(10),
marginBottom: rem(4),
transition: "background 150ms ease",
...(mounted && isParentActive && !isDark && {
borderLeft: `3px solid ${tokens.colors.primary}`,
}),
}}
styles={{
root: {
'&:hover': {
backgroundColor: mounted && isDark ? '#1E293B' : tokens.colors.bg.hover,
},
...(mounted && isParentActive && isDark && {
backgroundColor: 'rgba(59,130,246,0.25)',
borderLeft: `3px solid ${tokens.colors.primary}`,
}),
}
}}
variant="light"
active={isParentActive}
>
{v.children.map((child, key) => {
const isChildActive = segments.includes(_.lowerCase(child.name));
return (
<NavLink
key={key}
// ✅ PERBAIKAN: Gunakan onClick untuk handle navigasi dan close menu
<NavLink
key={key}
onClick={(e) => {
e.preventDefault();
handleNavClick(child.path);
}}
href={child.path}
c={isChildActive ? colors["blue-button"] : "gray"}
label={<Text fw={isChildActive ? 600 : 400} fz="sm">{child.name}</Text>}
styles={{
root: {
borderRadius: rem(8),
marginBottom: rem(2),
transition: 'background 150ms ease',
padding: '6px 12px',
'&:hover': {
backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)'
},
...(isChildActive && { backgroundColor: 'rgba(25, 113, 194, 0.1)' })
}
}}
active={isChildActive}
c={mounted && isChildActive ? tokens.colors.primary : mounted && isDark ? '#E5E7EB' : tokens.colors.text.secondary}
label={
<Text
fw={isChildActive ? 600 : 400}
fz="sm"
style={{
color: mounted && isDark ? '#E5E7EB' : 'inherit',
transition: 'color 150ms ease',
}}
>
{child.name}
</Text>
}
styles={{
root: {
borderRadius: rem(8),
marginBottom: rem(2),
transition: 'background 150ms ease',
padding: '6px 12px',
'&:hover': {
backgroundColor: mounted && isDark ? 'rgba(255, 255, 255, 0.05)' : tokens.colors.bg.hover,
},
...(mounted && isChildActive && isDark && {
backgroundColor: 'rgba(59,130,246,0.15)',
borderLeft: `2px solid ${tokens.colors.primary}`,
}),
...(mounted && isChildActive && !isDark && {
backgroundColor: 'rgba(25, 113, 194, 0.1)',
borderLeft: `2px solid ${tokens.colors.primary}`,
}),
}
}}
active={isChildActive}
variant="subtle"
component={Link}
/>
);
@@ -282,7 +373,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<AppShell.Section py="md">
<Group justify="end" pr="sm">
<Tooltip label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"} position="top" withArrow>
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={colors["blue-button"]}>
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={mounted ? tokens.colors.primary : '#3B82F6'}>
<IconChevronLeft />
</ActionIcon>
</Tooltip>
@@ -290,7 +381,17 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</AppShell.Section>
</AppShellNavbar>
<AppShellMain style={{ background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)", minHeight: "100vh" }}>
{/*
MAIN CONTENT
Spec: Background --bg-base
*/}
<AppShellMain
style={{
background: mounted ? tokens.colors.bg.base : '#f6f9fc',
minHeight: "100vh",
transition: 'background 0.3s ease',
}}
>
{children}
</AppShellMain>
</AppShell>

View File

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

View File

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

View File

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

View File

@@ -35,35 +35,35 @@ export async function POST(req: Request) {
// ✅ 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\nKode verifikasi Anda ${codeOtp}`;
// // ✅ OPSI 1: Tanpa encoding (coba dulu ini)
// const waUrl = `https://wa.wibudev.com/code?nom=${nomor}&text=${waMessage}`;
// ✅ OPSI 2: Dengan encoding (kalau opsi 1 gagal)
const waUrl = `https://wa.wibudev.com/code?nom=${nomor}&text=${encodeURIComponent(waMessage)}`;
// 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}`;
console.log("🔍 Debug WA URL:", waUrl); // Untuk debugging
// console.log("🔍 Debug WA URL:", waUrl); // Untuk debugging
const res = await fetch(waUrl);
const sendWa = await res.json();
// const res = await fetch(waUrl);
// const sendWa = await res.json();
console.log("📱 WA Response:", sendWa); // Debug response
// 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") {
// return NextResponse.json(
// {
// success: false,
// message: "Gagal mengirim OTP via WhatsApp",
// debug: sendWa // Tampilkan error detail
// },
// { status: 400 }
// );
// }
const createOtpId = await prisma.kodeOtp.create({
data: { nomor, otp: otpNumber, isActive: true },

View File

@@ -12,6 +12,25 @@ function Page() {
const [opened, { open, close }] = useDisclosure(false);
const ideInovatif = useProxy(ajukanIdeInovatifState);
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
ideInovatif.create.form.name?.trim() !== '' &&
ideInovatif.create.form.alamat?.trim() !== '' &&
ideInovatif.create.form.namaIde?.trim() !== '' &&
!isHtmlEmpty(ideInovatif.create.form.deskripsi) &&
ideInovatif.create.form.masalah?.trim() !== '' &&
ideInovatif.create.form.benefit?.trim() !== ''
);
};
const resetForm = () => {
ideInovatif.create.form = {
name: "",
@@ -168,7 +187,11 @@ function Page() {
ideInovatif.create.form.benefit = val.target.value;
}}
/>
<Button bg={colors['blue-button']} onClick={handleSubmit}>
<Button
bg={colors['blue-button']}
onClick={handleSubmit}
disabled={!isFormValid()}
>
Simpan
</Button>
</Stack>

View File

@@ -24,6 +24,16 @@ function AdministrasiOnline() {
const [opened, { open, close }] = useDisclosure(false);
const state = useProxy(layananonlineDesa);
// Check if form is valid
const isFormValid = () => {
return (
state.administrasiOnline.create.form.name?.trim() !== '' &&
state.administrasiOnline.create.form.alamat?.trim() !== '' &&
state.administrasiOnline.create.form.nomorTelepon?.trim() !== '' &&
state.administrasiOnline.create.form.jenisLayananId?.trim() !== ''
);
};
useEffect(() => {
// ✅ Panggil load data jenis layanan dari backend
if (!state.jenisLayanan.findMany.data) {
@@ -104,7 +114,11 @@ function AdministrasiOnline() {
}
/>
<Button bg={colors['blue-button']} onClick={handleSubmit}>
<Button
bg={colors['blue-button']}
onClick={handleSubmit}
disabled={!isFormValid()}
>
Simpan
</Button>
</Stack>

View File

@@ -19,6 +19,28 @@ function PengaduanMasyarakat() {
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
state.pengaduanMasyarakat.create.form.name?.trim() !== '' &&
state.pengaduanMasyarakat.create.form.email?.trim() !== '' &&
state.pengaduanMasyarakat.create.form.nomorTelepon?.trim() !== '' &&
state.pengaduanMasyarakat.create.form.nik?.trim() !== '' &&
state.pengaduanMasyarakat.create.form.judulPengaduan?.trim() !== '' &&
state.pengaduanMasyarakat.create.form.lokasiKejadian?.trim() !== '' &&
!isHtmlEmpty(state.pengaduanMasyarakat.create.form.deskripsiPengaduan) &&
state.pengaduanMasyarakat.create.form.jenisPengaduanId?.trim() !== '' &&
file !== null
);
};
useEffect(() => {
// ✅ Panggil load data jenis layanan dari backend
if (!state.jenisPengaduan.findMany.data) {
@@ -207,7 +229,11 @@ function PengaduanMasyarakat() {
</Box>
</Box>
<Button bg={colors['blue-button']} onClick={handleSubmit}>
<Button
bg={colors['blue-button']}
onClick={handleSubmit}
disabled={!isFormValid()}
>
Simpan
</Button>
</Stack>

View File

@@ -37,6 +37,24 @@ function Page() {
};
};
// Check if form is valid
const isFormValid = () => {
return (
beasiswaDesa.create.form.namaLengkap?.trim() !== '' &&
beasiswaDesa.create.form.nis?.trim() !== '' &&
beasiswaDesa.create.form.kelas?.trim() !== '' &&
beasiswaDesa.create.form.jenisKelamin?.trim() !== '' &&
beasiswaDesa.create.form.alamatDomisili?.trim() !== '' &&
beasiswaDesa.create.form.tempatLahir?.trim() !== '' &&
beasiswaDesa.create.form.tanggalLahir?.trim() !== '' &&
beasiswaDesa.create.form.namaOrtu?.trim() !== '' &&
beasiswaDesa.create.form.nik?.trim() !== '' &&
beasiswaDesa.create.form.pekerjaanOrtu?.trim() !== '' &&
beasiswaDesa.create.form.penghasilan?.trim() !== '' &&
beasiswaDesa.create.form.noHp?.trim() !== ''
);
};
const { data, page, totalPages, loading, load } = ungggulanDesa.findMany;
useShallowEffect(() => {
@@ -238,7 +256,7 @@ function Page() {
onChange={(val) => { beasiswaDesa.create.form.noHp = val.target.value }} />
<Group justify="flex-end" mt="md">
<Button variant="default" radius="xl" onClick={close}>Batal</Button>
<Button radius="xl" bg={colors['blue-button']} onClick={handleSubmit}>Kirim</Button>
<Button radius="xl" bg={colors['blue-button']} onClick={handleSubmit} disabled={!isFormValid()}>Kirim</Button>
</Group>
</Stack>
</Paper>

View File

@@ -46,6 +46,24 @@ export default function BeasiswaPage() {
};
};
// Check if form is valid
const isFormValid = () => {
return (
beasiswaDesa.create.form.namaLengkap?.trim() !== '' &&
beasiswaDesa.create.form.nis?.trim() !== '' &&
beasiswaDesa.create.form.kelas?.trim() !== '' &&
beasiswaDesa.create.form.jenisKelamin?.trim() !== '' &&
beasiswaDesa.create.form.alamatDomisili?.trim() !== '' &&
beasiswaDesa.create.form.tempatLahir?.trim() !== '' &&
beasiswaDesa.create.form.tanggalLahir?.trim() !== '' &&
beasiswaDesa.create.form.namaOrtu?.trim() !== '' &&
beasiswaDesa.create.form.nik?.trim() !== '' &&
beasiswaDesa.create.form.pekerjaanOrtu?.trim() !== '' &&
beasiswaDesa.create.form.penghasilan?.trim() !== '' &&
beasiswaDesa.create.form.noHp?.trim() !== ''
);
};
const handleSubmit = async () => {
await beasiswaDesa.create.create();
resetForm();
@@ -391,6 +409,7 @@ export default function BeasiswaPage() {
radius="xl"
bg={colors['blue-button']}
onClick={handleSubmit}
disabled={!isFormValid()}
style={{ fontSize: '0.9375rem', fontWeight: 600, lineHeight: 1.4 }}
>
Kirim

View File

@@ -42,6 +42,24 @@ export default function ModalPeminjaman({
const BATAS_HARI_PINJAM = 4;
// 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 === '';
};
// Check if form is valid
const isFormValid = () => {
return (
snap.create.form.nama?.trim() !== '' &&
snap.create.form.noTelp?.trim() !== '' &&
snap.create.form.alamat?.trim() !== '' &&
snap.create.form.tanggalPinjam?.trim() !== '' &&
!isHtmlEmpty(snap.create.form.catatan)
);
};
// Reset form setiap modal dibuka
useEffect(() => {
if (opened && buku) {
@@ -222,13 +240,13 @@ export default function ModalPeminjaman({
<Button
onClick={handleSubmit}
loading={snap.create.loading}
disabled={
!snap.create.form.nama || !snap.create.form.tanggalPinjam
}
disabled={!isFormValid() || snap.create.loading}
rightSection={<IconArrowRight size={16} />}
radius="xl"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || snap.create.loading
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -170,7 +170,7 @@ function Slider() {
const handleWheel = (e: React.WheelEvent) => {
if (!containerRef.current || mobile) return;
// Horizontal scroll dengan wheel delta
e.preventDefault();
containerRef.current.scrollLeft += e.deltaY;
scrollPosRef.current = containerRef.current.scrollLeft;
lastScrollRef.current = Date.now();

View File

@@ -13,7 +13,7 @@ import { NavbarSubMenu } from "./NavbarSubMenu"
import { authStore } from "@/store/authStore";
// contoh state auth (dummy aja dulu, bisa diganti sesuai sistem auth kamu)
const isAdmin = authStore.user?.roleId === 0 || authStore.user?.roleId === 1 || authStore.user?.roleId === 2 || authStore.user?.roleId === 3 || authStore.user?.roleId === 4;
const isAdmin = authStore.user?.roleId === 0 || authStore.user?.roleId === 1 || authStore.user?.roleId === 2 || authStore.user?.roleId === 3 || authStore.user?.roleId === 4;
export function NavbarMainMenu({ listNavbar }: { listNavbar: MenuItem[] }) {
const { item, isSearch } = useSnapshot(stateNav)
@@ -46,11 +46,11 @@ export function NavbarMainMenu({ listNavbar }: { listNavbar: MenuItem[] }) {
</Tooltip>
{listNavbar.map((item, k) => (
<MenuItemCom
key={k}
item={item}
isActive={item.href && pathname.startsWith(item.href) ||
(item.children?.some(child => child.href && pathname.startsWith(child.href)))}
<MenuItemCom
key={k}
item={item}
isActive={item.href && pathname.startsWith(item.href) ||
(item.children?.some(child => child.href && pathname.startsWith(child.href)))}
/>
))}
@@ -73,7 +73,7 @@ export function NavbarMainMenu({ listNavbar }: { listNavbar: MenuItem[] }) {
<Tooltip label="Kembali ke Admin" position="bottom" withArrow>
<ActionIcon
onClick={() => {
next.push("/admin/landing-page/profil/program-inovasi")
next.push("/admin/landing-page/profile/program-inovasi")
}}
color={colors["blue-button"]}
radius="xl"

View File

@@ -231,7 +231,7 @@ function Slider() {
const handleWheel = (e: React.WheelEvent) => {
if (!containerRef.current || mobile) return;
// Horizontal scroll dengan wheel delta
e.preventDefault();
containerRef.current.scrollLeft += e.deltaY;
scrollPosRef.current = containerRef.current.scrollLeft;
lastScrollRef.current = Date.now();

View File

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

Some files were not shown because too many files have changed in this diff Show More