docs: add AI collaboration contract and fix KegiatanCard image handling - bump to 0.1.48

This commit is contained in:
2026-04-30 15:30:24 +08:00
parent 83a2dece57
commit 0a5d17f45e
12 changed files with 1432 additions and 10 deletions

321
AI-CONTRACT.md Normal file
View File

@@ -0,0 +1,321 @@
# AI-CONTRACT.md
Kontrak kerja antara **manusia (developer)** dan **AI assistant** (Claude Code,
Cursor, Copilot, atau agent coding lainnya) di repo ini. Tujuannya satu:
mencegah perbaikan 1 bug berubah jadi 3 bug baru (bug eksponensial). AI
**wajib** baca file ini sebelum menulis/menghapus kode.
---
## 1. Prinsip Dasar
1. **Minimal diff, maximal pemahaman.** Baca kode sebelum ubah. Jangan
refactor yang tidak diminta. Jangan "rapikan" kode di sekitar bug.
2. **Fix akar, bukan gejala.** Kalau error muncul di layer A tapi penyebab
di layer B, perbaiki B. Jangan tambal di A.
3. **Satu masalah = satu perubahan logis.** Jangan campur fix bug dengan
refactor, rename, atau fitur baru dalam satu sesi tanpa izin.
4. **Tidak ada asumsi diam-diam.** Kalau butuh info (nama field, endpoint,
flow, schema), tanya atau baca kode — jangan tebak.
5. **Setiap perubahan harus reversible.** Diff kecil, commit jelas, bisa
di-revert tanpa efek samping.
---
## 2. Sebelum Menulis Kode
Checklist wajib sebelum edit file:
- [ ] Sudah baca file target (bukan cuma potongan)
- [ ] Tahu siapa yang memanggil fungsi/komponen yang akan diubah
- [ ] Tahu apakah ada test/konsumer lain yang bergantung padanya
- [ ] Tahu layer yang benar (route / controller / component / hook /
service / repository / lib / util — sesuai arsitektur project)
- [ ] Cek dokumen panduan project (mis. `CLAUDE.md`, `CONTRIBUTING.md`,
`ARCHITECTURE.md`, ADR) untuk aturan spesifik
- [ ] Kalau ubah tipe/kontrak (API, function signature, schema), cek
semua pemakai
Jika salah satu tidak jelas: **berhenti, baca lagi, atau tanya user.**
---
## 3. Saat Fix Bug
1. **Reproduksi dulu di kepala.** Jelaskan (minimal ke diri sendiri)
kenapa bug terjadi sebelum menyentuh kode.
2. **Temukan akar sebenarnya.** "Karena field X `undefined`/`null`/empty"
bukan akar — akarnya kenapa X bisa kosong.
3. **Perbaiki sekecil mungkin.** Kalau cukup 3 baris, jangan ubah 30.
4. **Jangan tambah try/catch hanya untuk menyembunyikan error** — itu
melahirkan bug baru yang lebih sulit dilacak.
5. **Jangan tambah fallback/default value spekulatif.** Kalau field
seharusnya selalu ada, perbaiki kenapa bisa kosong.
6. **Jangan rename, reorder, atau reformat** di file yang sama kecuali
langsung terkait fix.
7. **Setelah fix, verifikasi**: minimal jalankan typecheck/lint sesuai
tooling project (mis. `tsc`, `eslint`, `ruff`, `mypy`, `cargo check`,
`go vet`, `rspec`, dll). Idealnya jalankan test suite yang relevan.
---
## 4. Yang Dilarang (Akar Bug Eksponensial)
-**Silent catch**: `catch (e) {}`, `except: pass`, `_ = err`, atau
pola serupa — tanpa alasan yang didokumentasi di komentar.
-**Comment-out kode** sebagai "backup". Hapus atau kembalikan, jangan
biarkan mayat — git sudah jadi backup.
-**Copy-paste antar file**. Extract ke shared module/util/helper.
-**Duplikasi util/helper/hook/service** yang sudah ada — cek dulu
sebelum bikin baru.
-**Tambah flag/opsi/parameter baru** hanya untuk menghindari break
konsumer lama — fix konsumernya sekalian.
-**Destructive git command** (`reset --hard`, `push --force`,
`branch -D`, `clean -fdx`) tanpa instruksi eksplisit.
-**Skip hook** (`--no-verify`, `--no-gpg-sign`) tanpa izin.
-**Ubah schema/migrasi database** tanpa migration file yang sesuai.
-**Tambah dependency baru** tanpa izin user.
-**Hardcode credential, secret, URL produksi, atau data user**.
-**Ubah konfigurasi CI/CD, environment, atau infra** tanpa diskusi.
---
## 5. Saat Menambah Fitur
- Baca panduan arsitektur project sebelum mulai.
- Tentukan layer sebelum menulis. Jangan taruh bisnis logika di route,
controller, atau komponen presentasi.
- Jangan buat abstraksi untuk kebutuhan hipotetis. Tulis kode yang
diminta sekarang (YAGNI — *You Aren't Gonna Need It*).
- Hormati batas ukuran file yang sudah disepakati di project. Kalau
belum ada, gunakan rule of thumb: file >500 baris = sinyal untuk
pisah; fungsi >50 baris = sinyal untuk extract.
- Ikuti konvensi naming, struktur folder, dan pattern yang sudah ada —
konsistensi lebih penting dari preferensi pribadi.
---
## 6. Saat Ragu
Urutan tindakan:
1. Baca kode terkait lebih dalam.
2. Cek dokumen panduan project (`CLAUDE.md`, `README.md`, ADR, dll).
3. Cek git history (`git log -p`, `git blame`) kalau pertanyaannya soal
"kenapa ini begini".
4. **Tanya user** — lebih baik tanya 1 pertanyaan daripada menulis 100
baris yang harus dibuang.
Jangan pernah "pokoknya coba dulu, kalau salah revert". Revert itu murah
di local, tapi mahal kalau sudah merusak state (DB, session, file
sistem, deployment, dll).
---
## 7. Saat Selesai
- Jelaskan perubahan **secara singkat**: apa, di mana (file:line), kenapa.
- Sebutkan efek samping kalau ada (perubahan kontrak, breaking change,
perlu migrasi, perlu restart service, dll).
- Jangan ringkas diff yang user sudah lihat — user baca kode langsung.
- Kalau project punya channel notifikasi atau workflow report
(Slack/Discord/Telegram/email), kirim sesuai konvensi.
---
## 8. Eskalasi
Hentikan pekerjaan dan tanya user kalau:
- Fix butuh ubah >5 file untuk bug yang kelihatannya kecil.
- Ketemu bug lain di tengah jalan yang tidak diminta.
- Perubahan berpotensi mengenai data produksi, session aktif, atau
user nyata.
- User memberi instruksi yang bertentangan dengan dokumen panduan
project — konfirmasi dulu sebelum melanggar aturan.
---
## 9. Tools sebagai Mata dan Tangan AI
AI **wajib** memakai tools yang tersedia (MCP server, CLI commands,
debugger, browser automation, log inspector, DB query tool, dll) sebagai
**mata dan tangan**-nya.
- **Mata**: sebelum menebak state sistem, AI harus lihat langsung. Cek
log, query DB read-only, baca file config, jalankan health check, atau
pakai tool inspeksi yang relevan. Jangan berasumsi tentang data,
konfigurasi, atau tampilan — **cek dulu**.
- **Tangan**: gunakan tools untuk verifikasi end-to-end setelah
perubahan. Contoh: setelah fix UI, jalankan/preview halamannya dan
pastikan render + console bersih. Setelah fix logic, jalankan test
atau panggil endpoint yang relevan.
- **Maksimalkan pemakaian.** Kalau ada tool yang relevan, pakai — jangan
memilih jalan manual yang lebih rapuh. Semakin sering tools dipakai
untuk verifikasi, semakin solid project ini.
- **Ajukan tool baru kalau perlu.** Kalau AI merasa butuh tool yang
belum ada, AI **boleh dan didorong** untuk mengajukan pembuatannya
ke user. Format pengajuan:
1. Nama tool + signature (input/output)
2. Kenapa dibutuhkan (masalah konkret yang sedang dihadapi)
3. Sumber data (tabel DB / cache key / endpoint / file system)
4. Estimasi dampak ke kualitas investigasi/perbaikan
- **Jangan buat tool baru tanpa izin.** Ajukan dulu, tunggu persetujuan
user, baru implementasi (+ update dokumentasi).
- **Tools adalah sumber kebenaran runtime.** Kalau memory/log mengatakan
X tapi tool inspeksi langsung mengatakan Y, percayai tool.
Tujuan: AI tidak buta terhadap state sistem nyata, dan setiap perbaikan
diverifikasi secara nyata — bukan "harusnya sudah jalan".
---
## 10. Kontrak Public API / Interface (Wajib Dijaga)
Setiap interface yang dipakai oleh konsumen eksternal — REST/GraphQL
endpoint, MCP tool, library export, CLI command, webhook payload, event
schema, dll — adalah **kontrak publik**. Begitu konsumen (termasuk AI
agent dengan memory) tahu bentuk kontraknya, perubahan diam-diam bisa
bikin mereka bertindak berdasarkan asumsi yang sudah tidak valid — dan
kamu **tidak akan tahu** sampai terjadi kejadian aneh di prod.
### Apa yang dianggap kontrak (freeze)
| Kategori | Contoh | Aturan |
| ----------------- | --------------------------------------- | -------------------------------------------------- |
| Nama interface | endpoint path, tool name, function name | Tidak boleh rename tanpa bump versi |
| Parameter input | nama field, tipe, posisi | Nama & tipe tidak boleh berubah |
| Required flag | field wajib | Tidak boleh naik (optional → required) tanpa versi |
| Enum values | nilai yang valid | Tidak boleh dihapus/diganti |
| Error mode | format error response, exception type | Pola error harus konsisten |
| Field output | bentuk response | Tidak boleh dihapus/diganti tipenya |
### Apa yang boleh berubah (additive)
- Tambah interface baru
- Tambah parameter **optional** baru
- Tambah field output baru (konsumen lama akan mengabaikan yang tidak
mereka tahu, asal parsing-nya tolerant)
- Perbaiki pesan error (tanpa ubah polanya)
- Refactor implementasi internal (query, helper, dll)
### Cara kerja penjaga kontrak
1. **Contract test**: snapshot bentuk kontrak (nama, required,
properties, enum) untuk setiap interface publik. Letakkan di folder
khusus mis. `tests/contract/`.
2. **Kalau contract test merah karena perubahan yang disengaja**:
1. Update dokumentasi kontrak
2. Bump versi (semver, tag, atau version field)
3. Update snapshot di contract test
4. Jelaskan migrasinya di commit message + changelog
3. **Kalau contract test merah karena refactor yang tidak disengaja**:
**Jangan update snapshot untuk menghijaukan test.** Balikkan refactor
atau perbaiki supaya kontrak tetap sama. Snapshot bukan sampah yang
bisa di-regenerate seenaknya — dia alarm kebakaran.
### Larangan spesifik
-**Jangan rename** interface publik tanpa migration plan + bump versi
-**Jangan hapus enum value** — konsumen bisa punya kode/memory yang
memanggil nilai itu
-**Jangan naikkan param dari optional → required** tanpa bump versi
-**Jangan ubah bentuk error** (format response ↔ throw exception) —
ini mengubah handler logic di sisi konsumen
-**Jangan update snapshot contract test** tanpa update dokumentasi
### Apa yang BUKAN tugas contract test
- Memverifikasi logika bisnis (itu unit test biasa)
- Memverifikasi integrasi DB/external service (itu integration test)
- Memastikan data yang di-return benar (itu QA / staging)
Contract test **hanya** menjaga bentuk kontrak — cepat, deterministic,
tanpa dependency eksternal.
---
## 11. Hygiene Dokumen Panduan AI
Dokumen panduan AI (`CLAUDE.md`, `AI-CONTRACT.md`, `.cursorrules`,
`.github/copilot-instructions.md`, dll) di-load **setiap turn** percakapan.
Semakin gemuk file utamanya, semakin banyak token terbuang setiap turn —
dan ironisnya, AI jadi lebih sulit menemukan info penting karena tertimbun
detail. File panduan yang gemuk **bukan** tanda dokumentasi yang baik;
sering justru sebaliknya.
### Pecah, jangan tumpuk
Gunakan **referensi file** alih-alih menumpuk semua di satu file. Banyak
AI agent (termasuk Claude Code) auto-load file yang di-reference dengan
sintaks `@path/to/file.md`. Contoh struktur `CLAUDE.md` yang sehat:
````markdown
## Architecture
See @docs/ARCHITECTURE.md
## Agent Specs
See @docs/AGENTIC_OVERVIEW.md
## ADR History
See @docs/adr/README.md
````
`CLAUDE.md` utama tetap ramping, tapi info detail tetap accessible saat
dibutuhkan.
### Apa yang WAJIB tetap di CLAUDE.md (load setiap turn)
- Konvensi coding inti (naming, formatting, import order)
- Perintah build/test/lint yang sering dipakai
- Aturan komunikasi (bahasa, gaya, format response)
- Struktur folder high-level (1-2 level)
- Larangan absolut (jangan commit ke main, jangan touch folder X, dll)
- **Pointer** (`@path/...`) ke file detail lainnya
### Apa yang DIPINDAH ke file terpisah
- Spec arsitektur lengkap → `docs/ARCHITECTURE.md`
- Detail flow / sequence diagram → `docs/flows/*.md`
- ADR history (Architecture Decision Records) → `docs/adr/`
- Contoh kode panjang → `docs/examples/`
- API/interface reference lengkap → `docs/api/`
- Onboarding & setup detail → `docs/SETUP.md`
- Glossary domain terminology → `docs/GLOSSARY.md`
- Catatan investigasi/post-mortem → `docs/incidents/`
Lokasi alternatif: `.claude/` atau `.ai/` kalau tim ingin memisahkan
khusus untuk AI tooling, di luar dokumentasi developer biasa.
### Cek duplikasi secara rutin
Info yang sama sering muncul di beberapa section seiring waktu — biasanya
karena ditambahkan saat debugging tanpa cek file dulu. Audit berkala:
- [ ] Sama-sama dijelaskan di `README.md` dan `CLAUDE.md`? Pilih satu,
yang lain referensikan.
- [ ] Aturan yang sama disebut di 2-3 section? Konsolidasi ke satu section
kanonik, section lain tinggal pointer.
- [ ] Contoh kode panjang muncul inline? Pindah ke `docs/examples/`.
- [ ] Konvensi yang sudah jadi default di linter/formatter masih ditulis
manual? Hapus — biarkan tooling yang jaga, dokumen tidak perlu
mengulang.
- [ ] Info yang sudah usang (refer ke file/fitur yang dihapus)? Bersihkan
— dokumen yang setengah benar lebih merusak daripada tidak ada.
### Rule of thumb ukuran
Kalau `CLAUDE.md` (atau equivalent) sudah > 300 baris, itu **sinyal kuat**
untuk pecah file. Dokumen panduan AI yang ideal: cukup pendek untuk dibaca
ulang dalam 1 menit oleh manusia, dengan pointer ke detail untuk AI yang
butuh konteks lebih dalam.
---
## 12. Aturan Emas
> **Lebih baik tidak melakukan apa-apa daripada memperburuk kode.**
>
> Kalau setelah 2 kali percobaan fix masih memunculkan bug baru, **stop**.
> Laporkan ke user, jelaskan apa yang sudah dicoba dan kenapa gagal.
> Jangan tambal terus — itu cara bug beranak eksponensial.

View File

@@ -30,6 +30,7 @@ bun eslint . --fix
- Architecture, request flow, domain modules, key files: @.claude/ARCHITECTURE.md - Architecture, request flow, domain modules, key files: @.claude/ARCHITECTURE.md
- Database conventions, auth flow, file handling: @.claude/DATABASE.md - Database conventions, auth flow, file handling: @.claude/DATABASE.md
- Env vars, Docker, CI/CD, releasing: @.claude/DEPLOYMENT.md - Env vars, Docker, CI/CD, releasing: @.claude/DEPLOYMENT.md
- AI collaboration contract, rules, and guidelines: @AI-CONTRACT.md
### Workflow for Code Changes ### Workflow for Code Changes
1. **Commit** existing changes before starting new work 1. **Commit** existing changes before starting new work

View File

@@ -0,0 +1,19 @@
# Plan: Add AI Collaboration Contract and Fix UI Issues
## Background
- Need a clear contract for AI collaboration to prevent "exponential bugs".
- UI fix for `KegiatanCard` to handle missing images gracefully.
## Objectives
- Add `AI-CONTRACT.md` with guidelines.
- Link `AI-CONTRACT.md` in `CLAUDE.md`.
- Fix `KegiatanCard.tsx` image rendering.
- Bump version to 0.1.48.
## Implementation Steps
1. Create `AI-CONTRACT.md`.
2. Update `CLAUDE.md`.
3. Update `KegiatanCard.tsx`.
4. Bump version in `package.json`.
5. Verify build.
6. Commit and push.

View File

@@ -0,0 +1,11 @@
# Task: Add AI Collaboration Contract and Fix UI Issues
## Status
- [x] Create `AI-CONTRACT.md`
- [x] Update `CLAUDE.md` to reference the contract
- [x] Fix `KegiatanCard.tsx` image rendering logic
- [x] Bump version in `package.json` to 0.1.48
- [x] Verify build successful
- [ ] Commit changes
- [ ] Create branch and push
- [ ] Merge to `stg`

View File

@@ -1,6 +1,6 @@
{ {
"name": "desa-darmasaba", "name": "desa-darmasaba",
"version": "0.1.47", "version": "0.1.48",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

View File

@@ -0,0 +1,153 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
import { Prisma } from "@prisma/client";
const lambangDesaForm = z.object({
judul: z.string().min(3, "Judul minimal 3 karakter"),
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
});
const lambangDesaDefaultForm = {
judul: "",
deskripsi: "",
};
type LambangDesaForm = Prisma.LambangDesaGetPayload<{
select: {
id: true;
judul: true;
deskripsi: true;
};
}>;
const lambangDesa = proxy({
findUnique: {
data: null as LambangDesaForm | null,
loading: false,
error: null as string | null,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/desa/profile/lambang/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
this.data = result.data;
return result.data;
} else {
throw new Error(
result.message || "Gagal mengambil data lambang desa"
);
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
console.error("Load lambang desa error:", msg);
toast.error("Terjadi kesalahan saat mengambil data lambang desa");
return null;
} finally {
this.loading = false;
}
},
reset() {
this.data = null;
this.error = null;
this.loading = false;
},
},
update: {
id: "",
form: { ...lambangDesaDefaultForm },
loading: false,
error: null as string | null,
isReadOnly: false,
initialize(lambangDesaData: LambangDesaForm) {
this.id = lambangDesaData.id;
this.isReadOnly = false;
this.form = {
judul: lambangDesaData.judul || "",
deskripsi: lambangDesaData.deskripsi || "",
};
},
updateField(field: keyof typeof lambangDesaDefaultForm, value: string) {
this.form[field] = value;
},
async submit() {
const validation = lambangDesaForm.safeParse(this.form);
if (!validation.success) {
const errors = validation.error.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join(", ");
toast.error(`Form tidak valid: ${errors}`);
return false;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/desa/profile/lambang/${this.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(this.form),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
}
const result = await response.json();
if (result.success) {
toast.success("Berhasil update lambang desa");
await lambangDesa.findUnique.load(this.id);
return true;
} else {
throw new Error(result.message || "Gagal update lambang desa");
}
} catch (error) {
const errorMessage = (error as Error).message;
this.error = errorMessage;
console.error("Update lambang desa error:", errorMessage);
toast.error("Terjadi kesalahan saat update lambang desa");
return false;
} finally {
this.loading = false;
}
},
reset() {
this.id = "";
this.form = { ...lambangDesaDefaultForm };
this.error = null;
this.loading = false;
this.isReadOnly = false;
},
},
});
export default lambangDesa;

View File

@@ -0,0 +1,241 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
import { Prisma } from "@prisma/client";
import ApiFetch from "@/lib/api-fetch";
const mantanPerbekelForm = z.object({
nama: z.string().min(3, "Nama minimal 3 karakter"),
daerah: z.string().min(3, "Daerah minimal 3 karakter"),
periode: z.string().min(3, "Periode minimal 3 karakter"),
imageId: z.string().min(1, "Gambar wajib dipilih"),
});
const mantanPerbekelDefaultForm = {
nama: "",
daerah: "",
periode: "",
imageId: "",
};
const mantanPerbekel = proxy({
create: {
form: { ...mantanPerbekelDefaultForm },
loading: false,
async create() {
const cek = mantanPerbekelForm.safeParse(mantanPerbekel.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
mantanPerbekel.create.loading = true;
const res = await ApiFetch.api.desa.mantanperbekel["create"].post(
mantanPerbekel.create.form
);
if (res.status === 200) {
mantanPerbekel.findMany.load();
return toast.success("Foto berhasil disimpan!");
}
return toast.error("Gagal menyimpan foto");
} catch (error) {
console.log((error as Error).message);
} finally {
mantanPerbekel.create.loading = false;
}
},
resetForm() {
mantanPerbekel.create.form = { ...mantanPerbekelDefaultForm };
},
},
findMany: {
data: null as
| Prisma.PerbekelDariMasaKeMasaGetPayload<{
include: {
image: true;
};
}>[]
| null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
mantanPerbekel.findMany.loading = true;
mantanPerbekel.findMany.page = page;
mantanPerbekel.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.desa.mantanperbekel["findMany"].get({
query,
});
if (res.status === 200 && res.data?.success) {
mantanPerbekel.findMany.data = res.data.data ?? [];
mantanPerbekel.findMany.totalPages = res.data.totalPages ?? 1;
} else {
mantanPerbekel.findMany.data = [];
mantanPerbekel.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch mantan perbekel paginated:", err);
mantanPerbekel.findMany.data = [];
mantanPerbekel.findMany.totalPages = 1;
} finally {
mantanPerbekel.findMany.loading = false;
}
},
},
findUnique: {
data: null as Prisma.PerbekelDariMasaKeMasaGetPayload<{
include: {
image: true;
};
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/desa/mantanperbekel/${id}`);
if (res.ok) {
const data = await res.json();
mantanPerbekel.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch mantan perbekel:", res.statusText);
mantanPerbekel.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching mantan perbekel:", error);
mantanPerbekel.findUnique.data = null;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
mantanPerbekel.delete.loading = true;
const response = await fetch(`/api/desa/mantanperbekel/del/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (response.ok) {
toast.success(result.message || "Mantan perbekel berhasil dihapus");
await mantanPerbekel.findMany.load();
} else {
toast.error(result.message || "Gagal menghapus mantan perbekel");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus mantan perbekel");
} finally {
mantanPerbekel.delete.loading = false;
}
},
},
update: {
id: "",
form: { ...mantanPerbekelDefaultForm },
loading: false,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
try {
const response = await fetch(`/api/desa/mantanperbekel/${id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result?.success) {
const data = result.data;
this.id = data.id;
this.form = {
nama: data.nama,
daerah: data.daerah,
periode: data.periode,
imageId: data.imageId || "",
};
return data;
} else {
throw new Error(result.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error loading foto:", error);
toast.error(
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
}
},
async update() {
const cek = mantanPerbekelForm.safeParse(mantanPerbekel.update.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
toast.error(err);
return false;
}
try {
mantanPerbekel.update.loading = true;
const response = await fetch(`/api/desa/mantanperbekel/${this.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
nama: this.form.nama,
daerah: this.form.daerah,
periode: this.form.periode,
imageId: this.form.imageId,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
}
const result = await response.json();
if (result.success) {
toast.success(result.message || "Mantan perbekel berhasil diupdate");
await mantanPerbekel.findMany.load();
return true;
} else {
throw new Error(result.message || "Gagal mengupdate mantan perbekel");
}
} catch (error) {
console.error("Error updating mantan perbekel:", error);
toast.error(
error instanceof Error
? error.message
: "Gagal mengupdate mantan perbekel"
);
return false;
} finally {
mantanPerbekel.update.loading = false;
}
},
reset() {
mantanPerbekel.update.id = "";
mantanPerbekel.update.form = { ...mantanPerbekelDefaultForm };
},
},
});
export default mantanPerbekel;

View File

@@ -0,0 +1,183 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
import { Prisma } from "@prisma/client";
const maskotForm = z.object({
judul: z.string().min(3, "Judul minimal 3 karakter"),
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
images: z
.array(
z.object({
label: z.string().min(1, "Label wajib"),
imageId: z.string().min(1, "Image ID wajib"),
})
)
.min(1, "Minimal 1 gambar harus diisi"),
});
const maskotDefaultForm = {
judul: "",
deskripsi: "",
images: [] as { label: string; imageId: string }[],
};
type FormData = typeof maskotDefaultForm;
type MaskotDesaForm = Prisma.MaskotDesaGetPayload<{
include: {
images: {
include: {
image: {
select: {
id: true;
name: true;
path: true;
link: true;
};
};
};
};
};
}>;
const maskotDesa = proxy({
findUnique: {
data: null as MaskotDesaForm | null,
loading: false,
error: null as string | null,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/desa/profile/maskot/${id}`);
const result = await response.json();
if (response.ok && result.success) {
this.data = result.data;
return result.data;
} else {
throw new Error(result.message || "Gagal mengambil data profile");
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
console.error("Load profile error:", msg);
toast.error("Terjadi kesalahan saat mengambil data profile");
return null;
} finally {
this.loading = false;
}
},
reset() {
this.data = null;
this.error = null;
this.loading = false;
},
},
update: {
id: "",
form: { ...maskotDefaultForm },
loading: false,
error: null as string | null,
isReadOnly: false,
initialize(profileData: MaskotDesaForm) {
this.id = profileData.id;
this.isReadOnly = false;
this.form = {
judul: profileData.judul || "",
deskripsi: profileData.deskripsi || "",
images: (profileData.images || []).map((img) => ({
label: img.label,
imageId: img?.image?.id || "",
})),
};
},
updateField<K extends keyof FormData>(field: K, value: FormData[K]) {
this.form[field] = value;
},
addImage() {
this.form.images.push({ label: "", imageId: "" });
},
removeImage(index: number) {
this.form.images.splice(index, 1);
},
async submit() {
const validation = maskotForm.safeParse(this.form);
if (!validation.success) {
const errors = validation.error.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join(", ");
toast.error(`Form tidak valid: ${errors}`);
return false;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/desa/profile/maskot/${this.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
});
const result = await response.json();
if (response.ok && result.success) {
toast.success("Berhasil update profile");
await maskotDesa.findUnique.load(this.id);
return true;
} else {
throw new Error(result.message || "Gagal update profile");
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
toast.error("Terjadi kesalahan saat update profile");
return false;
} finally {
this.loading = false;
}
},
reset() {
this.id = "";
this.form = { ...maskotDefaultForm };
this.error = null;
this.loading = false;
this.isReadOnly = false;
},
},
async loadForEdit(id: string) {
const data = await this.findUnique.load(id);
if (data) {
this.update.initialize(data);
}
return data;
},
reset() {
this.findUnique.reset();
this.update.reset();
},
});
export default maskotDesa;

View File

@@ -0,0 +1,185 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
import { Prisma } from "@prisma/client";
const profilPerbekelForm = z.object({
biodata: z.string().min(3, "Biodata minimal 3 karakter"),
pengalaman: z.string().min(3, "Pengalaman minimal 3 karakter"),
pengalamanOrganisasi: z
.string()
.min(3, "Pengalaman Organisasi minimal 3 karakter"),
programUnggulan: z.string().min(3, "Program Unggulan minimal 3 karakter"),
imageId: z.string().min(1, "Gambar wajib dipilih"),
});
const profilPerbekelDefaultForm = {
biodata: "",
pengalaman: "",
pengalamanOrganisasi: "",
programUnggulan: "",
imageId: "",
};
type ProfilPerbekelForm = Prisma.ProfilPerbekelGetPayload<{
select: {
id: true;
biodata: true;
pengalaman: true;
pengalamanOrganisasi: true;
programUnggulan: true;
imageId: true;
image?: {
select: {
link: true;
};
};
};
}>;
const profilPerbekel = proxy({
findUnique: {
data: null as ProfilPerbekelForm | null,
loading: false,
error: null as string | null,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/desa/profile/profileperbekel/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
this.data = result.data;
return result.data;
} else {
throw new Error(
result.message || "Gagal mengambil data profil perbekel"
);
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
toast.error("Terjadi kesalahan saat mengambil data profil perbekel");
return null;
} finally {
this.loading = false;
}
},
reset() {
this.data = null;
this.error = null;
this.loading = false;
},
},
edit: {
id: "",
form: { ...profilPerbekelDefaultForm },
loading: false,
error: null as string | null,
isReadOnly: false,
initialize(profilData: ProfilPerbekelForm) {
this.id = profilData.id;
this.isReadOnly = false;
this.form = {
biodata: profilData.biodata || "",
pengalaman: profilData.pengalaman || "",
pengalamanOrganisasi: profilData.pengalamanOrganisasi || "",
programUnggulan: profilData.programUnggulan || "",
imageId: profilData.imageId || "",
};
},
updateField(field: keyof typeof profilPerbekelDefaultForm, value: string) {
this.form[field] = value;
},
async submit() {
const validation = profilPerbekelForm.safeParse(this.form);
if (!validation.success) {
const errors = validation.error.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join(", ");
toast.error(`Form tidak valid: ${errors}`);
return false;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(
`/api/desa/profile/profileperbekel/${this.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
}
const result = await response.json();
if (result.success) {
toast.success("Berhasil update profil perbekel");
await profilPerbekel.findUnique.load(this.id);
return true;
} else {
throw new Error(result.message || "Gagal update profil perbekel");
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
toast.error("Terjadi kesalahan saat update profil perbekel");
return false;
} finally {
this.loading = false;
}
},
reset() {
this.id = "";
this.form = { ...profilPerbekelDefaultForm };
this.error = null;
this.loading = false;
this.isReadOnly = false;
},
},
async loadForEdit(id: string) {
const profileData = await this.findUnique.load(id);
if (profileData) {
this.edit.initialize(profileData);
}
return profileData;
},
reset() {
this.findUnique.reset();
this.edit.reset();
},
});
export default profilPerbekel;

View File

@@ -0,0 +1,153 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
import { Prisma } from "@prisma/client";
const sejarahDesaForm = z.object({
judul: z.string().min(3, "Judul minimal 3 karakter"),
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
});
const sejarahDesaDefaultForm = {
judul: "",
deskripsi: "",
};
type SejarahDesaForm = Prisma.SejarahDesaGetPayload<{
select: {
id: true;
judul: true;
deskripsi: true;
};
}>;
const sejarahDesa = proxy({
findUnique: {
data: null as SejarahDesaForm | null,
loading: false,
error: null as string | null,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/desa/profile/sejarah/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
this.data = result.data;
return result.data;
} else {
throw new Error(
result.message || "Gagal mengambil data sejarah desa"
);
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
console.error("Load sejarah desa error:", msg);
toast.error("Terjadi kesalahan saat mengambil data sejarah desa");
return null;
} finally {
this.loading = false;
}
},
reset() {
this.data = null;
this.error = null;
this.loading = false;
},
},
update: {
id: "",
form: { ...sejarahDesaDefaultForm },
loading: false,
error: null as string | null,
isReadOnly: false,
initialize(sejarahData: SejarahDesaForm) {
this.id = sejarahData.id;
this.isReadOnly = false;
this.form = {
judul: sejarahData.judul || "",
deskripsi: sejarahData.deskripsi || "",
};
},
updateField(field: keyof typeof sejarahDesaDefaultForm, value: string) {
this.form[field] = value;
},
async submit() {
const validation = sejarahDesaForm.safeParse(this.form);
if (!validation.success) {
const errors = validation.error.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join(", ");
toast.error(`Form tidak valid: ${errors}`);
return false;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/desa/profile/sejarah/${this.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(this.form),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
}
const result = await response.json();
if (result.success) {
toast.success("Berhasil update profile");
await sejarahDesa.findUnique.load(this.id);
return true;
} else {
throw new Error(result.message || "Gagal update profile");
}
} catch (error) {
const errorMessage = (error as Error).message;
this.error = errorMessage;
console.error("Update profile error:", errorMessage);
toast.error("Terjadi kesalahan saat update profile");
return false;
} finally {
this.loading = false;
}
},
reset() {
this.id = "";
this.form = { ...sejarahDesaDefaultForm };
this.error = null;
this.loading = false;
this.isReadOnly = false;
},
},
});
export default sejarahDesa;

View File

@@ -0,0 +1,153 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
import { Prisma } from "@prisma/client";
const visiMisiDesaForm = z.object({
visi: z.string().min(3, "Visi minimal 3 karakter"),
misi: z.string().min(3, "Misi minimal 3 karakter"),
});
const visiMisiDesaDefaultForm = {
visi: "",
misi: "",
};
type VisiMisiDesaForm = Prisma.VisiMisiDesaGetPayload<{
select: {
id: true;
visi: true;
misi: true;
};
}>;
const visiMisiDesa = proxy({
findUnique: {
data: null as VisiMisiDesaForm | null,
loading: false,
error: null as string | null,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/desa/profile/visi-misi/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
this.data = result.data;
return result.data;
} else {
throw new Error(
result.message || "Gagal mengambil data visi misi desa"
);
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
console.error("Load visi misi desa error:", msg);
toast.error("Terjadi kesalahan saat mengambil data visi misi desa");
return null;
} finally {
this.loading = false;
}
},
reset() {
this.data = null;
this.error = null;
this.loading = false;
},
},
update: {
id: "",
form: { ...visiMisiDesaDefaultForm },
loading: false,
error: null as string | null,
isReadOnly: false,
initialize(visiMisiData: VisiMisiDesaForm) {
this.id = visiMisiData.id;
this.isReadOnly = false;
this.form = {
visi: visiMisiData.visi || "",
misi: visiMisiData.misi || "",
};
},
updateField(field: keyof typeof visiMisiDesaDefaultForm, value: string) {
this.form[field] = value;
},
async submit() {
const validation = visiMisiDesaForm.safeParse(this.form);
if (!validation.success) {
const errors = validation.error.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join(", ");
toast.error(`Form tidak valid: ${errors}`);
return false;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/desa/profile/visi-misi/${this.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(this.form),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
}
const result = await response.json();
if (result.success) {
toast.success("Berhasil update visi misi desa");
await visiMisiDesa.findUnique.load(this.id);
return true;
} else {
throw new Error(result.message || "Gagal update visi misi desa");
}
} catch (error) {
const errorMessage = (error as Error).message;
this.error = errorMessage;
console.error("Update visi misi desa error:", errorMessage);
toast.error("Terjadi kesalahan saat update visi misi desa");
return false;
} finally {
this.loading = false;
}
},
reset() {
this.id = "";
this.form = { ...visiMisiDesaDefaultForm };
this.error = null;
this.loading = false;
this.isReadOnly = false;
},
},
});
export default visiMisiDesa;

View File

@@ -43,15 +43,17 @@ export function KegiatanCard({
style={{ cursor: 'pointer', transition: 'box-shadow 0.2s' }} style={{ cursor: 'pointer', transition: 'box-shadow 0.2s' }}
onClick={onNavigate} onClick={onNavigate}
> >
<Card.Section> {item.image?.link && (
<Image <Card.Section>
src={item.image?.link || '/images/placeholder-small.jpg'} <Image
height={200} src={item.image.link}
alt={item.judul} height={200}
fit="cover" alt={item.judul}
loading="lazy" fit="cover"
/> loading="lazy"
</Card.Section> />
</Card.Section>
)}
<Stack mt="md" gap="xs"> <Stack mt="md" gap="xs">
<Badge color="blue" variant="light" size="sm" radius="md" w="fit-content"> <Badge color="blue" variant="light" size="sm" radius="md" w="fit-content">