Files
desa-darmasaba/QC/Landing-Page/QC-PRESTASI-DESA-MODULE.md
nico b9b00f0a20 docs(qc): add quality control summaries for various modules
Added comprehensive QC reports and fix summaries for:
- Desa (Berita, Potensi, Profil, Layanan, Penghargaan, Pengumuman)
- Kesehatan (Posyandu)
- Landing Page (APBDes, SDGS, Anti-Korupsi, Profil, Prestasi)
- PPID (Daftar Informasi, Dasar Hukum, IKM, Permohonan, Struktur, Visi Misi)
2026-04-23 12:11:55 +08:00

24 KiB

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):

// 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):

// 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:

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:

// 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:

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:

// ❌ 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:

// ✅ 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:

// 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:

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:

// ❌ 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:

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:

// 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:

// 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:

// 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:

if (process.env.NODE_ENV === 'development') {
  console.error("Error:", error);
}

Priority: 🟡 Low
Effort: Low


7. Error Message Tidak Konsisten

Lokasi: Multiple places

Masalah:

// 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:

// 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:

// 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:

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:

// Line ~11
function ListPrestasiDesa() {
  // ...
}

// Line ~27
function ListPrestasi({ search }: { search: string }) {
  // ...
}

// ⚠️ Function name tidak konsisten dengan file name

Rekomendasi: Rename ke yang lebih descriptive:

function ListPrestasiDesaPage() {
  // ...
}

function ListPrestasiDesaTable({ search }: { search: string }) {
  // ...
}

Priority: 🟢 Low
Effort: Low


Lokasi: list-prestasi-desa/page.tsx

Masalah:

// 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:

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

// Line ~170 (Desktop)
onChange={(newPage) => load(newPage)}  // ⚠️ Missing limit & search

// Line ~200 (Mobile)
onChange={(newPage) => load(newPage)}  // ⚠️ Missing limit & search

Rekomendasi: Include all params:

onChange={(newPage) => load(newPage, 10, debouncedSearch)}

Priority: 🟢 Low
Effort: Low


12. Duplicate Error Logging

Lokasi: Multiple files

Masalah:

// 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:

} 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:

// create/page.tsx - Line ~200
<Button ...>Reset</Button>

// edit/page.tsx - Line ~180
<Button ...>Batal</Button>

// Should be consistent: "Reset" atau "Batal"

Rekomendasi: Standardisasi:

// 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:

// 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:

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:

🔴 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! 🎉