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)
20 KiB
Quality Control Report - Pengumuman Desa Admin
Lokasi: /src/app/admin/(dashboard)/desa/pengumuman/
Tanggal QC: 25 Februari 2026
Status: ⚠️ Needs Improvement (ada issue critical yang perlu segera diperbaiki)
📋 Ringkasan Eksekutif
Halaman Pengumuman Desa memiliki implementasi yang cukup baik dengan CRUD lengkap dan state management terstruktur. Namun ditemukan 15 issue dengan rincian:
- 🔴 High Priority: 2 issue
- 🟡 Medium Priority: 7 issue
- 🟢 Low Priority: 6 issue
Overall Score: 6.5/10 - Needs Improvement
📁 Struktur File yang Diperiksa
/src/app/admin/(dashboard)/desa/pengumuman/
├── layout.tsx
├── _com/
│ └── layoutTabs.tsx # Tab navigation component
├── kategori-pengumuman/
│ ├── page.tsx # List kategori dengan search & pagination
│ ├── create/
│ │ └── page.tsx # Form create kategori
│ └── [id]/
│ └── page.tsx # Edit kategori
└── list-pengumuman/
├── page.tsx # List pengumuman dengan search & pagination
├── create/
│ └── page.tsx # Form create pengumuman (rich text)
└── [id]/
├── page.tsx # Detail pengumuman
└── edit/
└── page.tsx # Edit pengumuman
File Terkait:
- State:
/src/app/admin/(dashboard)/_state/desa/pengumuman.ts - API:
/src/app/api/[[...slugs]]/_lib/desa/pengumuman/(9 files) - API:
/src/app/api/[[...slugs]]/_lib/desa/pengumuman/kategori-pengumuman/(6 files) - Schema:
/prisma/schema.prisma(ModelPengumuman&CategoryPengumuman)
🔴 HIGH PRIORITY ISSUES
1. API - Hard Delete vs Soft Delete Mismatch (DATA LOSS RISK)
File: src/app/api/[[...slugs]]/_lib/desa/pengumuman/del.ts
export default async function pengumumanDelete(context: Context) {
const id = context.params?.id as string;
// ❌ HARD DELETE - Data benar-benar terhapus dari database
await prisma.pengumuman.delete({ where: { id } });
return { success: true, message: "Pengumuman berhasil dihapus" };
}
Schema yang Diharapkan:
model Pengumuman {
deletedAt DateTime? @default(null) // Soft delete field
isActive Boolean @default(true)
}
Dampak:
- DATA LOSS - Data pengumuman terhapus permanen, tidak bisa direcover
- Audit trail hilang (riwayat pengumuman tidak ada lagi)
- Inconsistent dengan schema design yang sudah ada soft delete fields
- Bisa melanggar compliance requirements untuk data retention
Solusi:
// Ganti hard delete dengan soft delete
export default async function pengumumanDelete(context: Context) {
const id = context.params?.id as string;
// ✅ SOFT DELETE - Update deletedAt dan isActive
await prisma.pengumuman.update({
where: { id },
data: {
deletedAt: new Date(),
isActive: false
}
});
return { success: true, message: "Pengumuman berhasil dihapus" };
}
File yang Perlu Diperbaiki:
src/app/api/[[...slugs]]/_lib/desa/pengumuman/del.tssrc/app/api/[[...slugs]]/_lib/desa/pengumuman/kategori-pengumuman/del.ts
2. Schema - deletedAt Default Value now() Bermasalah
File: prisma/schema.prisma
model Pengumuman {
id String @id @default(cuid())
judul String
deletedAt DateTime @default(now()) // ❌ PROBLEMATIC DEFAULT
isActive Boolean @default(true)
}
model CategoryPengumuman {
id String @id @default(cuid())
name String @unique
deletedAt DateTime @default(now()) // ❌ PROBLEMATIC DEFAULT
isActive Boolean @default(true)
}
Dampak:
- Setiap record baru langsung ter-mark sebagai deleted saat dibuat
- Query dengan filter
deletedAt: nulltidak akan dapat data baru - Soft delete logic tidak bekerja dengan benar
- Data inconsistency antara
deletedAt(set) danisActive(true)
Solusi:
model Pengumuman {
id String @id @default(cuid())
judul String
deletedAt DateTime? // ✅ Nullable, tanpa default
isActive Boolean @default(true)
}
model CategoryPengumuman {
id String @id @default(cuid())
name String @unique
deletedAt DateTime? // ✅ Nullable, tanpa default
isActive Boolean @default(true)
}
Migration Required:
# Generate migration
bunx prisma migrate dev --name fix_deleted_at_default
# Atau jika tidak pakai migrate
bunx prisma db push
# Data cleanup untuk record yang sudah ter-affected
UPDATE "Pengumuman" SET "deletedAt" = NULL WHERE "isActive" = true;
UPDATE "CategoryPengumuman" SET "deletedAt" = NULL WHERE "isActive" = true;
🟡 MEDIUM PRIORITY ISSUES
3. UI - Search Parameter Hilang Saat Pagination
File: src/app/admin/(dashboard)/desa/pengumuman/list-pengumuman/page.tsx
<Pagination
total={totalPages}
value={page}
onChange={(newPage) => {
load(newPage, 10); // ❌ Missing search parameter
}}
/>
Dampak:
- Saat user ganti halaman, search query hilang
- User harus ketik ulang search query
- UX sangat buruk untuk pagination dengan search
- Inconsistent dengan page lain (berita, potensi)
Solusi:
<Pagination
total={totalPages}
value={page}
onChange={(newPage) => {
load(newPage, 10, debouncedSearch); // ✅ Include search parameter
}}
/>
Note: Pastikan function load menerima parameter search:
const load = async (page: number, limit: number, searchQuery?: string) => {
// ...
};
4. UI - Duplicate State Management
File: src/app/admin/(dashboard)/desa/pengumuman/list-pengumuman/[id]/edit/page.tsx
// Local state
const [formData, setFormData] = useState({
judul: '',
deskripsi: '',
content: '',
categoryPengumumanId: '',
});
const [originalData, setOriginalData] = useState({...formData});
// Global state (Valtio)
editState.pengumuman.edit.form = {
...editState.pengumuman.edit.form,
...formData, // ❌ Duplicate data
};
Dampak:
- Data inconsistency antara local state dan global state
- Sulit debug karena data ada di 2 tempat
- Memory overhead
- Potential bugs saat reset form
Solusi:
Option A - Gunakan hanya global state:
// Hapus local state, gunakan langsung global state
const formData = editState.pengumuman.edit.form;
const handleResetForm = () => {
editState.pengumuman.edit.form = { ...originalData };
};
Option B - Sinkronisasi dengan useEffect:
useEffect(() => {
// Sync local state ke global state
editState.pengumuman.edit.form = { ...formData };
}, [formData]);
5. UI - Error Handling Silent Failures
File: src/app/admin/(dashboard)/_state/desa/pengumuman.ts
// Line 266-268
catch (error) {
console.log((error as Error).message);
// ❌ Error tidak ditampilkan ke user, silent failure
}
Dampak:
- User tidak tahu ada error
- Sulit debug production issues
- User experience buruk (loading forever tanpa feedback)
Solusi:
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Failed to load pengumuman:', errorMessage);
toast.error(`Gagal memuat data: ${errorMessage}`);
}
6. UI - ColSpan Mismatch
File: src/app/admin/(dashboard)/desa/pengumuman/kategori-pengumuman/page.tsx
<TableThead>
<TableTr>
<TableTh>Nama</TableTh>
<TableTh>Dibuat</TableTh>
<TableTh>Aksi</TableTh> {/* 3 kolom total */}
</TableTr>
</TableThead>
<TableTbody>
{loading ? (
<TableTr>
<TableTd colSpan={4}> {/* ❌ colSpan 4, seharusnya 3 */}
<Skeleton height={40} />
</TableTd>
</TableTr>
) : (
// ...
)}
</TableTbody>
Dampak: Layout table tidak rapi, colSpan terlalu lebar.
Solusi:
<TableTd colSpan={3}> // ✅ Match column count
7. State Management - Copy-Paste Error Message
File: src/app/admin/(dashboard)/_state/desa/pengumuman.ts
// Line 68-70
kategoriPengumuman: {
findMany: {
loading: false,
async load(page = 1, limit = 10, search = '') {
try {
// ...
} catch (error) {
console.error("Failed to load potensi desa:", res.data?.message);
// ❌ Copy-paste error dari file potensi! Seharusnya "kategori pengumuman"
}
}
}
}
Dampak:
- Membingungkan saat debug
- Tidak profesional
- Menunjukkan kurangnya attention to detail
Solusi:
console.error("Failed to load kategori pengumuman:", res.data?.message);
8. UI - Button Text "Batal" Membingungkan
File: src/app/admin/(dashboard)/desa/pengumuman/kategori-pengumuman/[id]/page.tsx
<Button
onClick={handleResetForm}
variant="outline"
color="gray"
>
Batal // ❌ Membingungkan - "Batal" biasanya untuk cancel navigation
</Button>
Dampak: User mungkin bingung apakah button ini akan cancel edit atau reset form.
Solusi:
<Button
onClick={handleResetForm}
variant="outline"
color="gray"
>
Reset Form // ✅ Lebih jelas
</Button>
9. UI - Button Order Tidak Mengikuti UX Best Practice
File: src/app/admin/(dashboard)/desa/pengumuman/list-pengumuman/[id]/page.tsx
<Group gap="sm">
<Button color="red"> {/* Delete button first */}
<Button color="green"> {/* Edit button second */}
</Group>
Dampak: Destructive action (delete) lebih prominent daripada primary action (edit).
Solusi:
<Group gap="sm">
<Button color="green"> {/* Edit button first */}
<Button color="red"> {/* Delete button second */}
</Group>
UX Best Practice: Primary action (edit) seharusnya lebih prominent, destructive action (delete) kurang prominent dan lebih sulit diakses.
🟢 LOW PRIORITY ISSUES
10. UI - Inline Styles yang Panjang
File: src/app/admin/(dashboard)/desa/pengumuman/_com/layoutTabs.tsx
<TabsList
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
border: "1px solid #d1d5db",
padding: "0.5rem",
borderRadius: "12px",
display: "flex",
gap: "0.5rem",
// ... 10+ baris inline styles
}}
>
Dampak:
- Sulit maintain
- Tidak reusable
- Code readability buruk
Solusi:
// Option A: CSS module
// layoutTabs.module.css
.tabsList {
background: linear-gradient(135deg, #e7ebf7, #f9faff);
boxShadow: 0 2px 8px rgba(0,0,0,0.08);
// ...
}
// Component
<TabsList className={styles.tabsList}>
Option B: Mantine theme
// theme.ts
const theme = createTheme({
components: {
TabsList: {
styles: {
root: {
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
// ...
}
}
}
}
});
11. UI - Hardcoded Paths
File: src/app/admin/(dashboard)/desa/pengumuman/_com/layoutTabs.tsx
const tabs = [
{ href: "/admin/desa/pengumuman/list-pengumuman" },
{ href: "/admin/desa/pengumuman/kategori-pengumuman" },
];
Dampak: Sulit refactor, jika ada perubahan struktur URL harus update di banyak tempat.
Solusi:
// constants/routes.ts
export const ROUTES = {
PENGUMUMAN_LIST: '/admin/desa/pengumuman/list-pengumuman',
PENGUMUMAN_CREATE: '/admin/desa/pengumuman/list-pengumuman/create',
PENGUMUMAN_EDIT: (id: string) => `/admin/desa/pengumuman/list-pengumuman/${id}/edit`,
KATEGORI_PENGUMUMAN_LIST: '/admin/desa/pengumuman/kategori-pengumuman',
KATEGORI_PENGUMUMAN_CREATE: '/admin/desa/pengumuman/kategori-pengumuman/create',
KATEGORI_PENGUMUMAN_EDIT: (id: string) => `/admin/desa/pengumuman/kategori-pengumuman/${id}/edit`,
};
// Usage
const tabs = [
{ href: ROUTES.PENGUMUMAN_LIST },
{ href: ROUTES.KATEGORI_PENGUMUMAN_LIST },
];
12. UI - HTML Validation Function Bisa False Positive
File: src/app/admin/(dashboard)/desa/pengumuman/list-pengumuman/create/page.tsx
const isHtmlEmpty = (html: string) => {
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
Dampak:
- Konten dengan hanya
<br>atau<p> </p>akan dianggap empty - User bisa submit content yang sebenarnya kosong
Solusi:
const isHtmlEmpty = (html: string) => {
// Strip HTML tags
const tmp = document.createElement('div');
tmp.innerHTML = html;
// Get text content and check if empty
const textContent = tmp.textContent || tmp.innerText || '';
return textContent.trim().length === 0;
};
13. State - Inconsistent API Client Usage
File: src/app/admin/(dashboard)/_state/desa/pengumuman.ts
// ❌ Direct fetch
const res = await fetch(`/api/desa/kategoripengumuman/${id}`);
const data = await res.json();
// ✅ Di tempat lain pakai ApiFetch
const data = await ApiFetch.api.desa.kategoripengumuman[':id'].get({ query: { id } });
Dampak: Code maintainability kurang, tidak konsisten.
Solusi:
// Gunakan ApiFetch untuk semua
const data = await ApiFetch.api.desa.kategoripengumuman[':id'].get({ query: { id } });
14. Layout - isDetailPage Logic Kurang Robust
File: src/app/admin/(dashboard)/desa/pengumuman/layout.tsx
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length >= 5; // ❌ Magic number, bisa false positive
Dampak: Bisa false positive untuk path lain yang length sama.
Contoh False Positive:
/admin/desa/pengumuman/list-pengumuman/create // 6 segments, dianggap detail page ❌
Solusi:
// Check last segment
const lastSegment = segments[segments.length - 1];
const isDetailPage = ['create', 'edit'].includes(lastSegment) ||
/^[a-zA-Z0-9]{20,}$/.test(lastSegment); // CUID pattern
15. API - Missing Validation
File: src/app/api/[[...slugs]]/_lib/desa/pengumuman/create.ts
const body = await context.body;
// ❌ Tidak ada validasi uniqueness untuk judul
// ❌ Tidak ada validasi panjang maksimal
await prisma.pengumuman.create({
data: {
judul: body.judul, // Bisa sangat panjang
// ...
}
});
Dampak:
- User bisa buat pengumuman dengan judul sama
- User bisa input judul/deskripsi sangat panjang
- Database bisa penuh dengan data tidak valid
Solusi:
// Validasi di API
const body = await context.body;
// Check uniqueness
const existing = await prisma.pengumuman.findFirst({
where: {
judul: body.judul,
isActive: true
}
});
if (existing) {
return new Response(
JSON.stringify({
success: false,
message: "Judul pengumuman sudah digunakan"
}),
{ status: 400 }
);
}
// Validate length
if (body.judul.length > 255) {
return new Response(
JSON.stringify({
success: false,
message: "Judul maksimal 255 karakter"
}),
{ status: 400 }
);
}
✅ YANG SUDAH BAIK
Schema:
- ✅ Relasi yang jelas antara Pengumuman dan CategoryPengumuman (one-to-many)
- ✅ Soft delete pattern dengan
deletedAtdanisActive(tapi ada bug di default value) - ✅ Audit trail dengan
createdAtdanupdatedAt - ✅ Unique constraint pada
namedi CategoryPengumuman
API:
- ✅ CRUD lengkap untuk Pengumuman dan Kategori Pengumuman
- ✅ Pagination support dengan
page,limit,search - ✅ Search functionality dengan case-insensitive
- ✅ Include relasi (CategoryPengumuman) di response
- ✅ Validation input menggunakan Elysia
t.Object - ✅ Filter by kategori di find-many
UI/UX:
- ✅ Konsisten design pattern
- ✅ Responsive untuk mobile dan desktop
- ✅ Loading states dan skeleton
- ✅ Toast notifications untuk feedback
- ✅ Form validation yang comprehensive
- ✅ Rich text editor (TipTap) untuk content
- ✅ Search dengan debounce (500ms-1000ms)
- ✅ Modal konfirmasi hapus
- ✅ Empty state message
State Management:
- ✅ Valtio proxy untuk global state
- ✅ Zod validation schema
- ✅ Loading state management
- ✅ Auto-refresh after CRUD operations
📊 Metrics
| Aspek | Score | Keterangan |
|---|---|---|
| Schema Design | 7/10 | Good, tapi ada bug di deletedAt default |
| API Design | 7/10 | RESTful, validation ada, tapi hard delete issue |
| API Security | 6/10 | Tidak ada authentication |
| UI/UX | 7.5/10 | Responsive, comprehensive validation |
| State Management | 7/10 | Valtio works well, ada inconsistency |
| Code Quality | 6.5/10 | Good structure, copy-paste errors, inline styles |
Overall Score: 6.5/10 - Needs Improvement
🎯 Action Plan
Week 1 (Critical Fixes) 🔴
- URGENT: Fix hard delete → soft delete di API del.ts
- URGENT: Fix
deletedAt @default(now())di schema - Fix pagination pass search parameter
- Fix colSpan mismatch
Week 2 (Medium Priority) 🟡
- Consolidate state management (local vs global)
- Improve error handling (no silent failures)
- Fix error message typo ("potensi desa" → "kategori pengumuman")
- Rename button "Batal" → "Reset Form"
- Fix button order (edit before delete)
Week 3 (Polish) 🟢
- Move inline styles to CSS module/theme
- Extract hardcoded paths to constants
- Fix HTML validation function
- Konsisten gunakan ApiFetch
- Fix isDetailPage logic
- Add uniqueness validation di API create
📝 Technical Notes
Database Migration:
Fix deletedAt default dan cleanup data:
# Generate migration
bunx prisma migrate dev --name fix_deleted_at_default
# Atau jika tidak pakai migrate
bunx prisma db push
# Data cleanup
UPDATE "Pengumuman" SET "deletedAt" = NULL WHERE "isActive" = true;
UPDATE "CategoryPengumuman" SET "deletedAt" = NULL WHERE "isActive" = true;
Soft Delete Implementation:
Update semua delete endpoint:
// Before (hard delete)
await prisma.pengumuman.delete({ where: { id } });
// After (soft delete)
await prisma.pengumuman.update({
where: { id },
data: {
deletedAt: new Date(),
isActive: false
}
});
API Testing:
Test soft delete:
# 1. Create pengumuman
POST /api/desa/pengumuman/create
{
"judul": "Test Pengumuman",
"deskripsi": "Test",
"content": "Test content",
"categoryPengumumanId": "<id>"
}
# 2. Delete pengumuman
DELETE /api/desa/pengumuman/del/<id>
# 3. Verify soft delete (data masih ada tapi isActive = false)
GET /api/desa/pengumuman/<id>
# Expected: isActive = false, deletedAt != null
Test pagination dengan search:
- Buka halaman List Pengumuman
- Ketik search query (misal: "desa")
- Klik pagination halaman 2
- Verify search query masih ada dan result sesuai
📚 References
- Prisma Soft Delete Pattern
- Mantine Table Documentation
- React Toastify Documentation
- Zod Documentation
- TipTap Documentation
📈 Comparison dengan QC Sebelumnya
| Aspek | Profil Desa | Potensi Desa | Berita Desa | Pengumuman |
|---|---|---|---|---|
| Schema | 6/10 | 7/10 | 8/10 | 7/10 |
| API Security | 4/10 | 6/10 | 6/10 | 6/10 |
| API Design | 7/10 | 8/10 | 7.5/10 | 7/10 |
| UI/UX | 8/10 | 8.5/10 | 8/10 | 7.5/10 |
| State Mgmt | 7/10 | 8/10 | 8/10 | 7/10 |
| Code Quality | 7/10 | 7.5/10 | 7/10 | 6.5/10 |
| Overall | 6.5/10 | 7.5/10 | 7/10 | 6.5/10 |
Pengumuman memiliki score yang sama dengan Profil Desa karena:
- ✅ Unique constraint pada
name(CategoryPengumuman) - ✅ Validation input di API
- ❌ Hard delete vs soft delete mismatch (critical)
- ❌ Copy-paste error messages
- ❌ Inline styles yang berlebihan
- ❌ Duplicate state management
Dibuat oleh: QC Automation
Review Status: ⏳ Menunggu Review Developer
Next Review: Setelah implementasi fixes