diff --git a/AI-CONTRACT.md b/AI-CONTRACT.md new file mode 100644 index 00000000..49a499b1 --- /dev/null +++ b/AI-CONTRACT.md @@ -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. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index f1b393f9..7561ba9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,7 @@ bun eslint . --fix - Architecture, request flow, domain modules, key files: @.claude/ARCHITECTURE.md - Database conventions, auth flow, file handling: @.claude/DATABASE.md - Env vars, Docker, CI/CD, releasing: @.claude/DEPLOYMENT.md +- AI collaboration contract, rules, and guidelines: @AI-CONTRACT.md ### Workflow for Code Changes 1. **Commit** existing changes before starting new work diff --git a/MIND/PLAN/ai-collaboration-contract.md b/MIND/PLAN/ai-collaboration-contract.md new file mode 100644 index 00000000..2876a552 --- /dev/null +++ b/MIND/PLAN/ai-collaboration-contract.md @@ -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. diff --git a/MIND/PLAN/task-ai-collaboration-contract.md b/MIND/PLAN/task-ai-collaboration-contract.md new file mode 100644 index 00000000..810c6763 --- /dev/null +++ b/MIND/PLAN/task-ai-collaboration-contract.md @@ -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` diff --git a/package.json b/package.json index a322ee6f..1edcb488 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "desa-darmasaba", - "version": "0.1.47", + "version": "0.1.48", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/admin/(dashboard)/_state/desa/profile/lambangDesa.ts b/src/app/admin/(dashboard)/_state/desa/profile/lambangDesa.ts new file mode 100644 index 00000000..27d93e40 --- /dev/null +++ b/src/app/admin/(dashboard)/_state/desa/profile/lambangDesa.ts @@ -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; diff --git a/src/app/admin/(dashboard)/_state/desa/profile/mantanPerbekel.ts b/src/app/admin/(dashboard)/_state/desa/profile/mantanPerbekel.ts new file mode 100644 index 00000000..0bd17d9c --- /dev/null +++ b/src/app/admin/(dashboard)/_state/desa/profile/mantanPerbekel.ts @@ -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; diff --git a/src/app/admin/(dashboard)/_state/desa/profile/maskotDesa.ts b/src/app/admin/(dashboard)/_state/desa/profile/maskotDesa.ts new file mode 100644 index 00000000..0019c459 --- /dev/null +++ b/src/app/admin/(dashboard)/_state/desa/profile/maskotDesa.ts @@ -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(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; diff --git a/src/app/admin/(dashboard)/_state/desa/profile/profilPerbekel.ts b/src/app/admin/(dashboard)/_state/desa/profile/profilPerbekel.ts new file mode 100644 index 00000000..b9fa6325 --- /dev/null +++ b/src/app/admin/(dashboard)/_state/desa/profile/profilPerbekel.ts @@ -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; diff --git a/src/app/admin/(dashboard)/_state/desa/profile/sejarahDesa.ts b/src/app/admin/(dashboard)/_state/desa/profile/sejarahDesa.ts new file mode 100644 index 00000000..7136dc09 --- /dev/null +++ b/src/app/admin/(dashboard)/_state/desa/profile/sejarahDesa.ts @@ -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; diff --git a/src/app/admin/(dashboard)/_state/desa/profile/visiMisiDesa.ts b/src/app/admin/(dashboard)/_state/desa/profile/visiMisiDesa.ts new file mode 100644 index 00000000..5b3bb9da --- /dev/null +++ b/src/app/admin/(dashboard)/_state/desa/profile/visiMisiDesa.ts @@ -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; diff --git a/src/app/darmasaba/(pages)/desa/kegiatan-desa/_com/KegiatanCard.tsx b/src/app/darmasaba/(pages)/desa/kegiatan-desa/_com/KegiatanCard.tsx index 2f908834..f219d494 100644 --- a/src/app/darmasaba/(pages)/desa/kegiatan-desa/_com/KegiatanCard.tsx +++ b/src/app/darmasaba/(pages)/desa/kegiatan-desa/_com/KegiatanCard.tsx @@ -43,15 +43,17 @@ export function KegiatanCard({ style={{ cursor: 'pointer', transition: 'box-shadow 0.2s' }} onClick={onNavigate} > - - {item.judul} - + {item.image?.link && ( + + {item.judul} + + )}