Compare commits

..

36 Commits

Author SHA1 Message Date
0a5d17f45e docs: add AI collaboration contract and fix KegiatanCard image handling - bump to 0.1.48 2026-04-30 15:30:24 +08:00
83a2dece57 refactor(kegiatan-desa): redesign public list page to card grid + kategori filter
- Remove hero/featured section and tab navigation
- Redesign to pecalang-style: 3-col card grid (image, title, desc, Detail button)
- Replace tabs with Select dropdown filter by kategori
- Search + kategori filter use query params, stay on /semua route
- Image hidden when empty (no placeholder)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 15:09:33 +08:00
e0a5177257 feat(kegiatan-desa): add full CRUD frontend + public detail page - bump to 0.1.47
- API: add GET /:id endpoint (findUnique) for KegiatanDesa
- Admin CMS: add pages for list-kegiatan-desa and kategori-kegiatan-desa (list, create, detail, edit)
- Public: add detail page at /desa/kegiatan-desa/[kategori]/[id]
- Refactor: move KegiatanCard to _com to fix Next.js page export constraint
- Nav: register kegiatan-desa in navbar and admin page list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:27:28 +08:00
23c955597e feat(api): add KategoriKegiatan CRUD API and register module - bump to 0.1.46
- Add KategoriKegiatan CRUD (create, findMany, findUnique, update, del)
- Register KategoriKegiatan in Desa API router
- Support soft delete for categories
2026-04-30 11:33:29 +08:00
28a22e8d77 chore(merge): merge sosial seeder branch into stg
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 11:02:14 +08:00
67efe6ce35 feat(seeder): add seeders for Sosial dashboard APIs
- Add seed_ringkasan_kesehatan.ts (ibuHamil=87, balita=342, stunting=12)
- Add seed_beasiswa_config.ts (tahun 2025/2026, dana Rp 1.2M)
- Add seed_kegiatan_desa.ts (KategoriKegiatan + KegiatanDesa incl. Budaya)
- Add kategori-kegiatan.json + kegiatan-desa.json data files
- Update posyandu.json: 1 → 8 posyandu (Mawar, Melati, Dahlia, Anggrek, dll)
- Update program-kesehatan.json: add persentase field + 4 stat entries
- Update seed_program_kesehatan.ts: include persentase in upsert
- Update seed.ts: import + call new seeders

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 11:00:44 +08:00
b39f9b39da feat(sosial): add schema fields + API endpoints for Sosial dashboard - bump to 0.1.45
- Add persentase field to ProgramKesehatan (for health stats bar chart)
- Add BeasiswaConfig model (dana tersalurkan + tahun ajaran)
- Add RingkasanKesehatanDesa model (ibu hamil, balita, alert stunting)
- Add KegiatanDesa CRUD API (GET /api/desa/kegiatandesa/find-many?kategori=Budaya)
- Add BeasiswaConfig API (GET/PUT /api/pendidikan/beasiswa/beasiswaconfig/...)
- Add RingkasanKesehatan API (GET/PUT /api/kesehatan/ringkasankesehatan/...)
- Migration: 20260430000000_add_sosial_fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 00:16:25 +08:00
6041cdf552 chore: bump version to 0.1.44 for stg deploy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 14:35:03 +08:00
5cd6e3aa99 fix(api): update swagger path and add scalarConfig - bump to 0.1.43 2026-04-29 14:15:51 +08:00
f31ab0eda5 chore: bump version to 0.1.42 for stg deploy 2026-04-29 11:54:36 +08:00
0517d50c8e feat(umkm): dashboard frontend - mode toggle, kategoriAktif, jumlahKategoriTerbanyak, growth badge, trendPersen, filter dropdowns - bump to 0.1.41
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 07:41:42 +08:00
fa7a52a0f3 fix(umkm): dashboard backend - jumlahKategoriTerbanyak, kategoriAktif, growth, trendPersen, mode=week - bump to 0.1.40
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 02:34:36 +08:00
ef237aea2f chore: bump version to 0.1.40 for stg deploy 2026-04-29 02:33:21 +08:00
f6107e971d fix(umkm): use KategoriProdukUmkm in KPI and fix product detail description - bump to 0.1.39 2026-04-28 16:22:55 +08:00
550961d524 fix(umkm): make PasarDesa.kategoriProdukId nullable, null-out orphaned refs - bump to 0.1.38 2026-04-28 15:50:20 +08:00
34d49fa073 fix(umkm): seed KategoriProdukUmkm from PasarDesa FK data - bump to 0.1.37 2026-04-28 15:21:39 +08:00
1631e273a4 fix(umkm): migrate PasarDesa.kategoriProdukId FK to KategoriProdukUmkm - bump to 0.1.36 2026-04-28 14:26:23 +08:00
faf78064c7 chore: bump version to 0.1.35 for stg deploy 2026-04-28 11:08:13 +08:00
9dd5d1545f feat(umkm): migrate kategori produk to KategoriProdukUmkm model - bump to 0.1.34 2026-04-28 00:51:00 +08:00
a4c7a97593 feat(umkm): migrate KategoriProduk to KategoriProdukUmkm for UMKM isolation
- update prisma schema to use KategoriProdukUmkm for Umkm model
- add @@map to KategoriProdukUmkm for lowercase table naming
- update API endpoints and KPI dashboard to use new model
- bump version to 0.1.33
2026-04-28 00:47:22 +08:00
5ab014281a feat(umkm): implement full CRUD for product categories
- added CRUD endpoints for KategoriProduk in Elysia API
- updated umkmState with category management logic
- added 'Kategori Produk' tab in admin dashboard
- created list, create, and edit pages for category management
- bumped version to 0.1.32
2026-04-27 17:37:16 +08:00
865074a310 fix(umkm): fix toast.error return value bug in kategoriProduk state and deploy v0.1.31 2026-04-27 17:33:27 +08:00
b640bb3919 fix(migration): also delete KategoriToPasar rows before PasarDesa FK cleanup - bump to 0.1.30 2026-04-27 15:20:01 +08:00
f48b982b3c fix(migration): delete invalid PasarDesa rows before FK constraint - bump to 0.1.29 2026-04-27 14:54:53 +08:00
cfe06137d8 chore: bump version to 0.1.28 for stg deploy 2026-04-27 14:08:01 +08:00
f0504c9dc0 fix(migration): make migration idempotent and auto-resolve failed state on deploy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 12:02:21 +08:00
1916c616de fix(env): add MinIO placeholder vars to .env.example for Docker build
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 11:35:20 +08:00
e3345c71f5 fix(docker): copy bun.lock text lockfile and update lockfile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 11:22:41 +08:00
68da360cea chore: update bun lockfile for stg deploy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 11:17:34 +08:00
b9b2b65294 chore: bump version to 0.1.27 for stg deploy 2026-04-27 11:11:03 +08:00
71e23dea1a feat(api): add /version endpoint and increment version to 0.1.25 2026-04-27 10:19:56 +08:00
cd7425292c feat(ekonomi): refactor umkm module with sales delete, stock validation, and ordering system 2026-04-24 16:57:43 +08:00
187e3a2115 feat(admin): refactor UMKM edit pages to match berita pattern with interfaces 2026-04-24 14:34:02 +08:00
7f5588f69e feat(admin): refactor UMKM edit pages to match berita pattern 2026-04-24 14:20:40 +08:00
30fbed73c9 fix(admin): resolve 404 on kategoriProduk API and correct Valtio state endpoint mismatches
- Created missing API endpoint
- Corrected UMKM and Produk update/delete routes in Valtio state to match Elysia API:
  - UMKM Update:
  - UMKM Delete:
  - Produk Update:
  - Produk Delete:
2026-04-24 12:19:24 +08:00
67c51302fe docs: add plan, task, and summary for admin-umkm-edit 2026-04-24 11:49:15 +08:00
112 changed files with 7900 additions and 571 deletions

44
.claude/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,44 @@
# Architecture
## Tech Stack
- **Framework**: Next.js 15 (App Router) + React 19
- **Runtime/Package manager**: Bun (not npm)
- **API server**: Elysia.js (mounted at `/api/[[...slugs]]`)
- **ORM**: Prisma + PostgreSQL
- **UI**: Mantine UI v7-8
- **State**: Jotai (atoms), Valtio (proxies), SWR (data fetching)
- **Auth**: iron-session + JWT
- **File storage**: Local uploads + MinIO (object storage) + Seafile (self-hosted fallback)
## Request Flow
```
Browser → Next.js middleware (src/middleware.ts)
→ Public pages: src/app/darmasaba/
→ Admin pages: src/app/admin/
→ API: src/app/api/[[...slugs]]/route.ts (Elysia.js)
└── _lib/*.ts (domain modules)
```
The Elysia server is a single entry point with domain-specific modules: `desa/`, `kesehatan/`, `ekonomi/`, `keamanan/`, `lingkungan/`, `pendidikan/`, `kependudukan/`, `ppid/`, `inovasi/`, `landing_page/`, `search/`, `auth/`, `user/`, `fileStorage/`. Swagger docs are auto-generated at `/api/docs`.
## Domain Modules
Each domain (desa, kesehatan, ekonomi, etc.) has:
- API handler in `src/app/api/[[...slugs]]/_lib/<domain>/`
- Admin CMS pages in `src/app/admin/(dashboard)/<domain>/`
- Public pages in `src/app/darmasaba/(pages)/<domain>/`
Active domains: `desa`, `ekonomi`, `inovasi`, `keamanan`, `kependudukan`, `kesehatan`, `lingkungan`, `musik`, `pendidikan`, `ppid` — plus `landing_page` and `search` (API-only, no public/admin pages).
## Key Files
| File | Purpose |
|------|---------|
| `src/middleware.ts` | Route guards and auth |
| `src/lib/prisma.ts` | Prisma client singleton |
| `src/lib/api-auth.ts` | JWT/session validation |
| `src/lib/api-fetch.ts` | Typed fetch wrapper used by frontend |
| `src/lib/session.ts` | iron-session config |
| `next.config.ts` | Next.js config (cache headers, allowed origins) |
| `postcss.config.cjs` | Mantine CSS preset and breakpoints |
| `docker-entrypoint.sh` | Runs `prisma migrate deploy` then starts app |

16
.claude/DATABASE.md Normal file
View File

@@ -0,0 +1,16 @@
# Database & Data Layer
## Prisma Schema
- Schema at `prisma/schema.prisma` (~2400 lines, 100+ models)
- Common model conventions: `@default(cuid())` IDs, `createdAt`/`updatedAt` timestamps, `deletedAt DateTime?` (soft delete), `isActive Boolean @default(true)`
- Seeders per-module in `prisma/_seeder_list/`, orchestrated by `prisma/seed.ts`
## Authentication Flow
1. User submits phone → OTP sent (email/SMS)
2. OTP validated → JWT created + iron-session stored
3. `UserSession` model tracks active sessions
4. `src/middleware.ts` validates on each request
5. `src/lib/api-auth.ts` handles JWT/session checks in API routes
## File Handling
All uploaded files reference the `FileStorage` Prisma model. Uploads land in `WIBU_UPLOAD_DIR` (default: `uploads/`). Seafile is the external storage fallback.

34
.claude/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,34 @@
# Deployment
## Environment Variables
Copy `.env.example` to `.env`. Required variables:
```env
DATABASE_URL="postgresql://..."
NEXT_PUBLIC_BASE_URL="/"
BASE_SESSION_KEY="..." # random string
BASE_TOKEN_KEY="..." # random string
SESSION_PASSWORD="..." # min 32 chars
SEAFILE_TOKEN="..."
SEAFILE_REPO_ID="..."
SEAFILE_URL="..."
MINIO_ENDPOINT="..."
MINIO_ACCESS_KEY="..."
MINIO_SECRET_KEY="..."
MINIO_BUCKET="..."
MINIO_USE_SSL="..."
```
## Docker
Multi-stage build: `oven/bun:1-debian` → builder → runner. The runner creates a `nextjs` user (UID 1001), exposes port 3000, and mounts `/app/uploads` as a volume. Entrypoint runs migrations automatically.
## CI/CD
GitHub Actions workflows in `.github/workflows/`:
- `docker-publish.yml` — triggers on `v*` tags, pushes to GHCR
- `publish.yml` — manual build & push
- `re-pull.yml` — triggers Portainer to redeploy latest image
To release: tag with `git tag -a v0.1.x -m "..."` and push the tag.

View File

@@ -0,0 +1,120 @@
# deploy-stg
Deploy ke staging environment secara penuh menggunakan MCP server `deploy-stg`.
**Repo GitHub:** `bipprojectbali/desa-darmasaba`
**Branch stg:** `stg`
**STG URL:** `https://desa-darmasaba-stg.wibudev.com`
---
## Alur Eksekusi
### Langkah 0 — Cek /api/version endpoint
Pastikan endpoint `/api/version` sudah ada di API:
```bash
grep -n '"/version"' src/app/api/\[\[...slugs\]\]/route.ts
```
Jika belum ada, tambahkan ke main API group yang membaca `version` dari `package.json`.
---
### Langkah 1 — Version Bump
Gunakan MCP tool `bump_version` (server: `deploy-stg`).
Tool otomatis baca `package.json`, increment patch (+1), tulis kembali.
Catat `new_version` dari response — akan dipakai di Langkah 5 dan 6.
---
### Langkah 2 — Cek & Buat Migration
Gunakan MCP tool `check_migrations` (server: `deploy-stg`).
- Jika `needs_migration: true` → jalankan `create_migration` dengan `name: bump-stg-<new_version>`
- Jika `is_up_to_date: true` → lanjut ke Langkah 3
---
### Langkah 3 — Build Check
```bash
bun run build
```
Jika build gagal → **stop**, perbaiki error sebelum melanjutkan.
---
### Langkah 4 — Commit & Push ke stg
Gunakan MCP tool `commit_and_push_stg` (server: `deploy-stg`).
Tool otomatis stage `package.json` + `prisma/migrations/`, commit dengan message yang menyertakan versi baru, lalu push ke branch `stg` menggunakan `GH_TOKEN`.
---
### Langkah 5 — Trigger publish.yml
Gunakan MCP tool `trigger_publish` (server: `deploy-stg`):
- `stack_env`: `stg` (default)
- `tag`: nilai `new_version` dari Langkah 1 (sama persis dengan versi di `package.json`)
Tool mengembalikan `run_id`. Poll dengan `watch_workflow_run` setiap 30 detik hingga `status == "completed"`:
- `conclusion == "success"` → lanjut ke Langkah 6
- `conclusion != "success"`**stop**, tampilkan error, jangan lanjut ke re-pull
---
### Langkah 6 — Trigger re-pull.yml
Setelah publish berhasil, gunakan MCP tool `trigger_repull` (server: `deploy-stg`):
- `stack_name`: otomatis dari env `STACK_NAME` (tidak perlu diisi manual)
- `stack_env`: `stg`
Stack yang di-deploy: `<STACK_NAME>-stg`. Poll dengan `watch_workflow_run` setiap 30 detik hingga `status == "completed"`.
---
### Langkah 7 — Verifikasi Versi
Gunakan MCP tool `check_stg_version` (server: `deploy-stg`):
- `wait_seconds`: `30` (tunggu container siap)
Tool otomatis fetch `BASE_URL/api/version` dan bandingkan dengan versi lokal.
- `match: true`**Deploy berhasil!**
- `match: false` → cek container logs di Portainer atau jalankan `gh run view <run_id> --repo bipprojectbali/desa-darmasaba --log`
---
## Ringkasan MCP Tools
| Tool | Server | Tujuan |
|------|--------|--------|
| `bump_version` | `deploy-stg` | Increment patch version di package.json |
| `check_migrations` | `deploy-stg` | Cek status Prisma migrations |
| `create_migration` | `deploy-stg` | Buat migration baru jika diperlukan |
| `commit_and_push_stg` | `deploy-stg` | Commit + push ke branch stg |
| `trigger_publish` | `deploy-stg` | Trigger publish.yml (build Docker image) |
| `watch_workflow_run` | `deploy-stg` | Poll status workflow run |
| `trigger_repull` | `deploy-stg` | Trigger re-pull.yml (redeploy di Portainer) |
| `check_stg_version` | `deploy-stg` | Bandingkan versi lokal vs STG |
## Ringkasan Workflow Inputs
| Workflow | Input | Value |
|----------|-------|-------|
| `publish.yml` | `stack_env` | `stg` |
| `publish.yml` | `tag` | versi dari `package.json` (e.g. `0.1.26`) |
| `publish.yml` | `stack_name` | dari env `STACK_NAME` (opsional) |
| `re-pull.yml` | `stack_name` | dari env `STACK_NAME` (otomatis) |
| `re-pull.yml` | `stack_env` | `stg` |
## Catatan
- Jangan jalankan `re-pull.yml` jika `publish.yml` belum selesai/berhasil.
- Isi `GH_TOKEN` di `.env` sebelum menjalankan deploy (bisa sama dengan `GH_TOKEN`).
- `BASE_URL` di `.env` sudah diset ke `https://desa-darmasaba-stg.wibudev.com`.
- `STACK_NAME` di `.env` diset ke nama stack (e.g. `desa-darmasaba`) — dipakai otomatis oleh `trigger_publish` dan `trigger_repull`.
- Verifikasi versi via `/api/version` (bukan `/api/utils/version`).

View File

@@ -0,0 +1,478 @@
#!/usr/bin/env node
/**
* MCP Server: GitHub Actions Workflow Tools + Deploy-STG Pre/Post Steps
* Tools: trigger_publish, trigger_repull, get_workflow_runs, watch_workflow_run,
* check_migrations, create_migration, bump_version, commit_and_push_stg, check_stg_version
*/
import { spawnSync } from "child_process";
import { readFileSync, writeFileSync } from "fs";
import { join } from "path";
const REPO = "bipprojectbali/desa-darmasaba";
const CWD = process.cwd();
// --- MCP Protocol Helpers ---
function send(obj) {
process.stdout.write(JSON.stringify(obj) + "\n");
}
function respond(id, result) {
send({ jsonrpc: "2.0", id, result });
}
function respondError(id, code, message) {
send({ jsonrpc: "2.0", id, error: { code, message } });
}
// --- Shell Helper ---
function runCmd(cmd, opts = {}) {
const r = spawnSync("sh", ["-c", cmd], {
encoding: "utf-8",
cwd: CWD,
...opts,
});
if (r.error) return { ok: false, out: r.error.message };
if (r.status !== 0) return { ok: false, out: (r.stderr || r.stdout || "").trim() };
return { ok: true, out: (r.stdout || "").trim() };
}
function getLatestRunId(workflow, delaySecs = 3) {
const r = runCmd(
`sleep ${delaySecs} && gh run list --workflow=${workflow} --repo ${REPO} --limit 1 --json databaseId -q '.[0].databaseId'`
);
return r.ok ? r.out.trim() : null;
}
// --- Tool Definitions ---
const TOOLS = [
{
name: "check_migrations",
description: "Cek status Prisma migrations. Returns apakah ada pending migrations.",
inputSchema: { type: "object", properties: {} },
},
{
name: "create_migration",
description: "Buat Prisma migration baru (prisma migrate dev). Jalankan hanya jika check_migrations mendeteksi pending/drift.",
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description: "Nama migration (e.g. bump-stg-0.1.26)",
},
},
required: ["name"],
},
},
{
name: "bump_version",
description: "Baca versi dari package.json, increment patch (+1), tulis kembali. Returns old_version dan new_version.",
inputSchema: { type: "object", properties: {} },
},
{
name: "commit_and_push_stg",
description: "Stage package.json + prisma/migrations, commit, lalu push ke branch stg menggunakan GH_TOKEN.",
inputSchema: {
type: "object",
properties: {
message: {
type: "string",
description: "Commit message. Jika tidak diisi, auto-generate dari versi package.json.",
},
},
},
},
{
name: "check_stg_version",
description: "Bandingkan versi lokal (package.json) dengan versi di STG (/api/version). Tunggu container siap jika perlu.",
inputSchema: {
type: "object",
properties: {
wait_seconds: {
type: "number",
description: "Detik tunggu sebelum fetch STG (default: 30, untuk memberi waktu container siap).",
},
},
},
},
{
name: "trigger_publish",
description:
"Trigger publish.yml workflow: build & push Docker image ke GHCR. Returns run ID.",
inputSchema: {
type: "object",
properties: {
stack_env: {
type: "string",
enum: ["dev", "stg", "prod"],
description: "Target environment. Default: stg.",
},
tag: {
type: "string",
description: "Image tag — harus sama persis dengan versi di package.json (e.g. 0.1.25).",
},
stack_name: {
type: "string",
description: `Nama stack. Default: nilai env STACK_NAME (${process.env.STACK_NAME || "belum diset"}).`,
},
},
required: ["stack_env", "tag"],
},
},
{
name: "trigger_repull",
description:
"Trigger re-pull.yml workflow: redeploy stack di Portainer. Jalankan HANYA setelah publish berhasil.",
inputSchema: {
type: "object",
properties: {
stack_name: {
type: "string",
description: `Nama stack (e.g. desa-darmasaba). Stack yang di-deploy: <stack_name>-<stack_env>. Default: nilai env STACK_NAME (${process.env.STACK_NAME || "belum diset"}).`,
},
stack_env: {
type: "string",
enum: ["dev", "stg", "prod"],
description: "Target environment.",
},
},
required: ["stack_env"],
},
},
{
name: "get_workflow_runs",
description: "List run terbaru dari suatu workflow beserta status dan conclusion-nya.",
inputSchema: {
type: "object",
properties: {
workflow: {
type: "string",
description: "Nama file workflow (e.g. publish.yml, re-pull.yml)",
},
limit: {
type: "number",
description: "Jumlah run yang ditampilkan (default: 5)",
},
},
required: ["workflow"],
},
},
{
name: "watch_workflow_run",
description:
"Lihat status detail sebuah workflow run: apakah in_progress, success, atau failure.",
inputSchema: {
type: "object",
properties: {
run_id: {
type: "number",
description: "ID workflow run (dapat dari trigger_publish / trigger_repull / get_workflow_runs)",
},
},
required: ["run_id"],
},
},
];
// --- Tool Handlers ---
function handleCheckMigrations() {
const r = runCmd("bunx prisma migrate status 2>&1", { timeout: 30000 });
const out = r.out || "";
const hasPending =
out.includes("following migration(s) have not yet been applied") ||
out.includes("drift") ||
out.includes("pending");
const isUpToDate =
out.includes("Database schema is up to date") ||
out.includes("up to date");
return {
ok: true,
needs_migration: hasPending && !isUpToDate,
is_up_to_date: isUpToDate,
output: out,
};
}
function handleCreateMigration(args) {
const { name } = args;
const r = runCmd(
`echo "" | bunx prisma migrate dev --name ${name} --skip-generate 2>&1`,
{ timeout: 120000 }
);
return {
ok: r.ok,
output: r.out,
error: r.ok ? undefined : r.out,
};
}
function handleBumpVersion() {
const pkgPath = join(CWD, "package.json");
let pkg;
try {
pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
} catch (e) {
return { ok: false, error: `Gagal baca package.json: ${e.message}` };
}
const oldVersion = pkg.version;
const [maj, min, pat] = oldVersion.split(".").map(Number);
const newVersion = `${maj}.${min}.${pat + 1}`;
pkg.version = newVersion;
try {
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
} catch (e) {
return { ok: false, error: `Gagal tulis package.json: ${e.message}` };
}
return { ok: true, old_version: oldVersion, new_version: newVersion };
}
function handleCommitAndPushStg(args) {
const { message } = args || {};
// Baca versi untuk auto commit message
let version = "unknown";
try {
const pkg = JSON.parse(readFileSync(join(CWD, "package.json"), "utf-8"));
version = pkg.version;
} catch {}
const commitMsg = message || `chore: bump version to ${version} for stg deploy`;
// Stage file
runCmd("git add package.json 2>&1");
runCmd("git add prisma/migrations/ 2>&1");
// Cek apakah ada yang di-stage
const statusR = runCmd("git diff --cached --name-only 2>&1");
if (!statusR.out.trim()) {
return { ok: false, error: "Tidak ada perubahan yang di-stage untuk di-commit." };
}
// Commit
const commitR = runCmd(`git commit -m "${commitMsg}" 2>&1`);
if (!commitR.ok) return { ok: false, step: "commit", error: commitR.out };
// Push ke stg menggunakan GH_TOKEN jika tersedia
const token = process.env.GH_TOKEN;
const pushCmd = token
? `git push https://x-access-token:${token}@github.com/${REPO}.git HEAD:stg 2>&1`
: `git push origin HEAD:stg 2>&1`;
const pushR = runCmd(pushCmd, { timeout: 60000 });
if (!pushR.ok) return { ok: false, step: "push", error: pushR.out };
return {
ok: true,
message: `Berhasil commit "${commitMsg}" dan push ke stg`,
version,
};
}
function handleCheckStgVersion(args) {
const waitSecs = (args && args.wait_seconds) || 30;
const baseUrl = process.env.BASE_URL;
if (!baseUrl) {
return { ok: false, error: "BASE_URL tidak ada di environment — isi di .env" };
}
// Baca versi lokal
let localVersion;
try {
const pkg = JSON.parse(readFileSync(join(CWD, "package.json"), "utf-8"));
localVersion = pkg.version;
} catch (e) {
return { ok: false, error: `Gagal baca package.json: ${e.message}` };
}
// Tunggu container siap
if (waitSecs > 0) {
runCmd(`sleep ${waitSecs}`);
}
// Fetch versi dari STG
const r = runCmd(`curl -sf --max-time 10 "${baseUrl}/api/version" 2>&1`);
if (!r.ok) {
return {
ok: false,
error: `Gagal fetch ${baseUrl}/api/version: ${r.out}`,
local_version: localVersion,
};
}
let stgVersion;
try {
stgVersion = JSON.parse(r.out).version;
} catch {
return {
ok: false,
error: `Gagal parse response: ${r.out}`,
local_version: localVersion,
};
}
const match = localVersion === stgVersion;
return {
ok: true,
local_version: localVersion,
stg_version: stgVersion,
match,
status: match
? "✓ DEPLOY BERHASIL — versi STG sudah sesuai"
: "✗ VERSI BERBEDA — cek container logs di Portainer",
};
}
function handleTriggerPublish(args) {
const { stack_env = "stg", tag, stack_name } = args;
const resolvedStackName = stack_name || process.env.STACK_NAME;
let triggerCmd = `gh workflow run publish.yml --repo ${REPO} --ref ${stack_env} -f stack_env=${stack_env} -f tag=${tag}`;
if (resolvedStackName) triggerCmd += ` -f stack_name=${resolvedStackName}`;
const r = runCmd(triggerCmd);
if (!r.ok) return { ok: false, error: r.out };
const runId = getLatestRunId("publish.yml");
return {
ok: true,
message: `Workflow publish.yml di-trigger (env=${stack_env}, tag=${tag}${resolvedStackName ? `, stack=${resolvedStackName}` : ""})`,
run_id: runId ? Number(runId) : null,
monitor_hint: runId
? `Gunakan watch_workflow_run dengan run_id: ${runId}`
: "Gunakan get_workflow_runs untuk mendapatkan run_id terbaru",
};
}
function handleTriggerRepull(args) {
const resolvedStackName = args.stack_name || process.env.STACK_NAME;
const { stack_env } = args;
if (!resolvedStackName) {
return { ok: false, error: "stack_name wajib diisi atau set env STACK_NAME di .mcp.json" };
}
const stack_name = resolvedStackName;
const triggerCmd = `gh workflow run re-pull.yml --repo ${REPO} --ref main -f stack_name=${stack_name} -f stack_env=${stack_env}`;
const r = runCmd(triggerCmd);
if (!r.ok) return { ok: false, error: r.out };
const runId = getLatestRunId("re-pull.yml");
return {
ok: true,
message: `Workflow re-pull.yml di-trigger (stack=${stack_name}-${stack_env})`,
run_id: runId ? Number(runId) : null,
monitor_hint: runId
? `Gunakan watch_workflow_run dengan run_id: ${runId}`
: "Gunakan get_workflow_runs untuk mendapatkan run_id terbaru",
};
}
function handleGetWorkflowRuns(args) {
const limit = args.limit || 5;
const r = runCmd(
`gh run list --workflow=${args.workflow} --repo ${REPO} --limit ${limit} --json databaseId,status,conclusion,displayTitle,createdAt,headBranch`
);
if (!r.ok) return { ok: false, error: r.out };
try {
return { ok: true, runs: JSON.parse(r.out) };
} catch {
return { ok: false, error: "Gagal parse output", raw: r.out };
}
}
function handleWatchWorkflowRun(args) {
const r = runCmd(
`gh run view ${args.run_id} --repo ${REPO} --json status,conclusion,displayTitle,headBranch,createdAt,updatedAt,jobs`
);
if (!r.ok) return { ok: false, error: r.out };
try {
const data = JSON.parse(r.out);
return {
ok: true,
run_id: args.run_id,
status: data.status,
conclusion: data.conclusion,
title: data.displayTitle,
branch: data.headBranch,
jobs: (data.jobs || []).map((j) => ({
name: j.name,
status: j.status,
conclusion: j.conclusion,
})),
};
} catch {
return { ok: false, error: "Gagal parse output", raw: r.out };
}
}
// --- Request Router ---
function handleMessage(msg) {
const { id, method, params } = msg;
if (method === "initialize") {
respond(id, {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "deploy-stg", version: "2.0.0" },
});
return;
}
if (method === "notifications/initialized") return;
if (method === "ping") {
respond(id, {});
return;
}
if (method === "tools/list") {
respond(id, { tools: TOOLS });
return;
}
if (method === "tools/call") {
const { name, arguments: args = {} } = params;
let result;
if (name === "check_migrations") result = handleCheckMigrations();
else if (name === "create_migration") result = handleCreateMigration(args);
else if (name === "bump_version") result = handleBumpVersion();
else if (name === "commit_and_push_stg") result = handleCommitAndPushStg(args);
else if (name === "check_stg_version") result = handleCheckStgVersion(args);
else if (name === "trigger_publish") result = handleTriggerPublish(args);
else if (name === "trigger_repull") result = handleTriggerRepull(args);
else if (name === "get_workflow_runs") result = handleGetWorkflowRuns(args);
else if (name === "watch_workflow_run") result = handleWatchWorkflowRun(args);
else {
respondError(id, -32601, `Tool tidak ditemukan: ${name}`);
return;
}
respond(id, {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
});
return;
}
respondError(id, -32601, `Method tidak dikenal: ${method}`);
}
// --- Stdin Loop ---
let buf = "";
process.stdin.setEncoding("utf-8");
process.stdin.on("data", (chunk) => {
buf += chunk;
const lines = buf.split("\n");
buf = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
handleMessage(JSON.parse(trimmed));
} catch {
// ignore malformed JSON
}
}
});

View File

@@ -40,5 +40,12 @@ SESSION_PASSWORD="your_session_password_min_32_characters_long_secure"
# ElevenLabs API Key (for TTS features - optional)
ELEVENLABS_API_KEY=your_elevenlabs_api_key
# MinIO Configuration (Object Storage)
MINIO_ENDPOINT=localhost
MINIO_ACCESS_KEY=your_minio_access_key
MINIO_SECRET_KEY=your_minio_secret_key
MINIO_BUCKET=desa-darmasaba
MINIO_USE_SSL=false
# Environment (optional, defaults to development)
# NODE_ENV=development

13
.mcp.json Normal file
View File

@@ -0,0 +1,13 @@
{
"mcpServers": {
"deploy-stg": {
"command": "node",
"args": ["--env-file=.env", ".claude/mcp/github-actions.mjs"],
"env": {
"GH_TOKEN": "${GH_TOKEN}",
"BASE_URL": "${BASE_URL}",
"STACK_NAME": "${STACK_NAME}"
}
}
}
}

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

@@ -1,10 +1,6 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Desa Darmasaba is a full-stack digital village management platform for a village in Badung, Bali. It serves both a public-facing website (`/darmasaba/*`) and an admin CMS (`/admin/*`).
Desa Darmasaba adalah platform manajemen desa digital untuk Desa Darmasaba, Badung, Bali. Melayani website publik (`/darmasaba/*`) dan admin CMS (`/admin/*`).
## Commands
@@ -20,7 +16,7 @@ bun run test:api # Unit tests (Vitest)
bun run test:e2e # E2E tests (Playwright)
# Database
bunx prisma migrate deploy # Apply migrations
bunx prisma migrate deploy # Apply migrations
bunx prisma migrate dev --name <name> # Create migration
bun run prisma/seed.ts # Seed database
bunx prisma studio # Interactive DB viewer
@@ -29,91 +25,12 @@ bunx prisma studio # Interactive DB viewer
bun eslint . --fix
```
## Architecture
## Reference Docs
### Tech Stack
- **Framework**: Next.js 15 (App Router) + React 19
- **Runtime/Package manager**: Bun (not npm)
- **API server**: Elysia.js (mounted at `/api/[[...slugs]]`)
- **ORM**: Prisma + PostgreSQL
- **UI**: Mantine UI v7-8
- **State**: Jotai (atoms), Valtio (proxies), SWR (data fetching)
- **Auth**: iron-session + JWT
- **File storage**: Local uploads + Seafile (self-hosted)
### Request Flow
```
Browser → Next.js middleware (src/middleware.ts)
→ Public pages: src/app/darmasaba/
→ Admin pages: src/app/admin/
→ API: src/app/api/[[...slugs]]/route.ts (Elysia.js)
└── _lib/*.ts (domain modules)
```
The Elysia server is a single entry point with domain-specific modules: `desa.ts`, `kesehatan.ts`, `ekonomi.ts`, `keamanan.ts`, `lingkungan.ts`, `pendidikan.ts`, `kependudukan.ts`, `ppid.ts`, `inovasi.ts`, `auth/`, `user/`, `fileStorage/`. Swagger docs are auto-generated at `/api/docs`.
### Domain Modules
Each domain (desa, kesehatan, ekonomi, etc.) has:
- API handler in `src/app/api/[[...slugs]]/_lib/<domain>.ts`
- Admin CMS pages in `src/app/admin/(dashboard)/<domain>/`
- Public pages in `src/app/darmasaba/(pages)/<domain>/`
### Database (Prisma)
- Schema at `prisma/schema.prisma` (~2400 lines, 100+ models)
- Common model conventions: `@default(cuid())` IDs, `createdAt`/`updatedAt` timestamps, `deletedAt DateTime?` (soft delete), `isActive Boolean @default(true)`
- Seeders per-module in `prisma/_seeder_list/`, orchestrated by `prisma/seed.ts`
### Authentication Flow
1. User submits phone → OTP sent (email/SMS)
2. OTP validated → JWT created + iron-session stored
3. `UserSession` model tracks active sessions
4. `src/middleware.ts` validates on each request
5. `src/lib/api-auth.ts` handles JWT/session checks in API routes
### File Handling
All uploaded files reference the `FileStorage` Prisma model. Uploads land in `WIBU_UPLOAD_DIR` (default: `uploads/`). Seafile is the external storage fallback.
## Key Files
| File | Purpose |
|------|---------|
| `src/middleware.ts` | Route guards and auth |
| `src/lib/prisma.ts` | Prisma client singleton |
| `src/lib/api-auth.ts` | JWT/session validation |
| `src/lib/api-fetch.ts` | Typed fetch wrapper used by frontend |
| `src/lib/session.ts` | iron-session config |
| `next.config.ts` | Next.js config (cache headers, allowed origins) |
| `postcss.config.cjs` | Mantine CSS preset and breakpoints |
| `docker-entrypoint.sh` | Runs `prisma migrate deploy` then starts app |
## Environment Variables
Copy `.env.example` to `.env`. Required variables:
```env
DATABASE_URL="postgresql://..."
NEXT_PUBLIC_BASE_URL="/"
BASE_SESSION_KEY="..." # random string
BASE_TOKEN_KEY="..." # random string
SESSION_PASSWORD="..." # min 32 chars
SEAFILE_TOKEN="..."
SEAFILE_REPO_ID="..."
SEAFILE_URL="..."
```
## Docker
Multi-stage build: `oven/bun:1-debian` → builder → runner. The runner creates a `nextjs` user (UID 1001), exposes port 3000, and mounts `/app/uploads` as a volume. Entrypoint runs migrations automatically.
## CI/CD
GitHub Actions workflows in `.github/workflows/`:
- `docker-publish.yml` — triggers on `v*` tags, pushes to GHCR
- `publish.yml` — manual build & push
- `re-pull.yml` — triggers Portainer to redeploy latest image
To release: tag with `git tag -a v0.1.x -m "..."` and push the tag.
- 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

View File

@@ -12,7 +12,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package.json bun.lockb* ./
COPY package.json bun.lock* bun.lockb* ./
ENV SHARP_IGNORE_GLOBAL_LIBVIPS=1
ENV NEXT_TELEMETRY_DISABLED=1

View File

@@ -0,0 +1,24 @@
# Plan - Admin UMKM & Produk Edit Pages
## Problem
Admin UMKM module list pages have "Edit" buttons that are not functional, and there are no edit pages or update state logic implemented.
## Strategy
1. Update Valtio state in `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts` with `update` modules for UMKM and Produk.
2. Delete Valtio state in `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts` with `del` modules for UMKM and Produk.
3. Add `onClick` handlers to "Edit" buttons in list pages.
4. Create new edit pages.
5. Use `ModalKonfirmasiHapus` component for delete actions.
6. Verify changes with a successful build.
7. Follow deployment workflow.
## Progress
- [x] Update Valtio state with update modules
- [x] Update Valtio state with delete modules
- [x] Wire edit and delete buttons in list pages
- [x] Create UMKM edit page
- [x] Create Produk edit page
- [x] Build and fix errors
- [x] Update version in package.json
- [x] Commit and push to branch
- [x] Merge to stg and push to remotes

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,31 @@
# Plan: Fix and Improve UMKM Module
## Objectives
- Implement delete confirmation modal for UMKM sales histori.
- Improve data handling (null safety) in UMKM and Produk forms.
- Fix field mapping in Produk edit page.
- Enhance UMKM Dashboard KPI calculation.
- Implement stock validation for new sales.
- Fix WhatsApp link formatting across the application.
- Add product ordering functionality for public users.
- Translate and simplify CLAUDE.md.
## Proposed Changes
1. **Admin State**: Update `umkmState` to include `del` for sales and refine form schemas.
2. **Admin UI**:
- Add `ModalKonfirmasiHapus` to Penjualan page.
- Fix card height in Dashboard.
- Refine Edit/Create pages for UMKM and Produk.
3. **API**:
- Refactor `kpi.ts` for more accurate reporting.
- Add stock check in `create.ts` for sales.
4. **Public UI**:
- Update Produk detail page with order modal and WhatsApp integration.
- Fix WhatsApp links in various pages.
5. **Documentation**: Update `CLAUDE.md`.
6. **Maintenance**: Increment version in `package.json`.
## Verification Plan
- Run `bun run build` to ensure no compile errors.
- Manual verification of delete functionality.
- Manual verification of ordering flow.

View File

@@ -0,0 +1,90 @@
# Plan: Migrasi KategoriProduk → KategoriProdukUmkm untuk UMKM
## Tujuan
Ganti relasi kategori di model `Umkm` dan seluruh CRUD kategori UMKM agar menggunakan model `KategoriProdukUmkm` (bukan `KategoriProduk`). Model `KategoriProduk` tetap dipertahankan untuk `PasarDesa` dan `KategoriToPasar`.
## Analisis Kondisi Saat Ini
### Schema Prisma
- `Umkm``kategori KategoriProduk @relation(...)` + `kategoriId String`
- `KategoriProdukUmkm` punya relasi `Umkm[]` tapi Umkm belum punya FK ke sana (schema tidak konsisten)
- `KategoriProduk` dipakai oleh: `Umkm[]`, `PasarDesa[]`, `KategoriToPasar[]`
### File yang Perlu Diubah
1. `prisma/schema.prisma` — ubah relasi Umkm
2. `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/kategori-produk/kategori-produk.ts` — ganti semua `prisma.kategoriProduk``prisma.kategoriProdukUmkm`
3. `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/produk/create.ts` — relasi kategori di PasarDesa tidak berubah (masih `KategoriProduk`)
4. `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/produk/findMany.ts` — include `kategoriProduk` di PasarDesa tidak berubah
5. `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/dashboard/kpi.ts` — groupBy `kategoriId` di Umkm perlu include model baru
6. `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts` — state/form kategori UMKM
7. Admin pages (list, create, edit) — tidak perlu banyak perubahan (UI tetap sama)
---
## Langkah-Langkah
### Fase 1 — Schema Prisma
**File:** `prisma/schema.prisma`
1. Di model `Umkm` (line ~2435):
- Ubah `kategori KategoriProduk @relation(fields: [kategoriId], references: [id])``kategori KategoriProdukUmkm @relation(fields: [kategoriId], references: [id])`
- Field `kategoriId String` tetap sama (nama field tidak perlu berubah)
2. Di model `KategoriProduk` (line ~1453):
- Hapus baris `Umkm Umkm[]` (UMKM tidak lagi relasi ke sini)
3. Model `KategoriProdukUmkm` sudah punya `Umkm Umkm[]` — tidak perlu diubah
### Fase 2 — Database Migration
```bash
bunx prisma migrate dev --name migrate-umkm-kategori-to-kategori-produk-umkm
```
- Migration akan: ubah FK constraint di tabel `Umkm` dari `KategoriProduk``KategoriProdukUmkm`
- **Perhatian:** Data lama di `kategoriId` merujuk ke `KategoriProduk`. Perlu seed/migrasi data atau set nullable dulu.
> **Strategi migrasi data:**
> - Buat `kategoriId` di `Umkm` menjadi nullable sementara (`String?`)
> - Jalankan migration
> - Seed `KategoriProdukUmkm` dengan data yang sama seperti `KategoriProduk`
> - Update data Umkm yang ada agar `kategoriId` menunjuk ke `KategoriProdukUmkm` yang sesuai
> - Set kembali `kategoriId` menjadi required (`String`)
### Fase 3 — API Handler: CRUD Kategori Produk UMKM
**File:** `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/kategori-produk/kategori-produk.ts`
Ganti semua `prisma.kategoriProduk``prisma.kategoriProdukUmkm`:
- `/find-many-all``prisma.kategoriProdukUmkm.findMany()`
- `/find-many``prisma.kategoriProdukUmkm.findMany()`
- `/create``prisma.kategoriProdukUmkm.create()`
- `PUT /:id``prisma.kategoriProdukUmkm.update()`
- `DELETE /del/:id``prisma.kategoriProdukUmkm.update()`
### Fase 4 — API Handler: Dashboard KPI
**File:** `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/dashboard/kpi.ts`
- Bagian `groupBy kategoriId` untuk Umkm: ubah include/join agar ambil nama dari `KategoriProdukUmkm` bukan `KategoriProduk`
### Fase 5 — State Management
**File:** `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts`
- Pastikan tipe data / form state kategori UMKM masih sesuai (field name tidak berubah, hanya model di backend)
### Fase 6 — Build & Verify
```bash
bun run tsc --noEmit # Type check
bun run build # Full build
```
---
## Yang TIDAK Berubah
- `KategoriProduk` model tetap ada (masih digunakan `PasarDesa` dan `KategoriToPasar`)
- Relasi `PasarDesa.kategoriProdukId → KategoriProduk` tidak berubah
- Admin UI pages (kategori-produk/page.tsx, create, edit) — logika UI tidak berubah, hanya backend model berbeda
- API route path tetap sama (`/api/ekonomi/kategoriproduk/*`)
---
## Risiko
- **Data migration:** Umkm yang ada punya `kategoriId` yang merujuk ke `KategoriProduk`. Perlu migrasi data atau data akan hilang relasi.
- **Solusi:** Buat `KategoriProdukUmkm` dengan data yang sama, lalu update `Umkm.kategoriId` via SQL migration script.

View File

@@ -0,0 +1,22 @@
# Plan - Refactor UMKM Edit Pages Pattern
## Problem
The edit pages for UMKM (Data UMKM and Produk) use an older UI pattern. The user wants to align them with the newer pattern used in the Berita edit page.
## Strategy
1. Analyze the pattern in `src/app/admin/(dashboard)/desa/berita/list-berita/[id]/edit/page.tsx`.
2. Refactor `src/app/admin/(dashboard)/ekonomi/umkm/produk/[id]/edit/page.tsx` to match the pattern.
3. Refactor `src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/[id]/edit/page.tsx` to match the pattern.
4. Add "Batal" (Reset) functionality to both pages.
5. Standardize UI components (Header, Paper, Dropzone, Action buttons).
6. Verify with a production build.
7. Follow the versioning and deployment workflow.
## Progress
- [x] Analyze Berita edit page pattern
- [x] Refactor UMKM Produk edit page (with interfaces)
- [x] Refactor Data UMKM edit page (with interfaces)
- [x] Run build and fix any errors
- [ ] Update version in package.json
- [ ] Commit and push to task branch
- [ ] Merge to stg branch

View File

@@ -0,0 +1,14 @@
# Task - Admin UMKM & Produk Edit functionality
## Description
Implement Edit and Delete functionality for UMKM and Produk modules in the admin dashboard.
## Tasks
- [x] Update Valtio state with update/delete modules for UMKM and Produk
- [x] Wire edit/delete buttons in `src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/page.tsx`
- [x] Wire edit/delete buttons in `src/app/admin/(dashboard)/ekonomi/umkm/produk/page.tsx`
- [x] Create edit page for UMKM at `src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/[id]/edit/page.tsx`
- [x] Create edit page for Produk at `src/app/admin/(dashboard)/ekonomi/umkm/produk/[id]/edit/page.tsx`
- [x] Ensure `ModalKonfirmasiHapus` is used correctly
- [x] Run `bun run build` and fix errors
- [x] Push to task branch and merge to stg

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

@@ -0,0 +1,18 @@
# Task: Fix and Improve UMKM Module
## Status
- [x] Implement delete confirmation modal for UMKM sales.
- [x] Improve null handling in UMKM/Produk forms.
- [x] Fix field mapping in Produk edit page.
- [x] Refactor UMKM Dashboard KPI logic.
- [x] Add stock validation in sales API.
- [x] Fix WhatsApp link formatting.
- [x] Implement public product ordering system.
- [x] Update CLAUDE.md.
- [ ] Run build and fix errors.
- [ ] Update version in package.json.
- [ ] Commit changes.
## Progress Notes
- Changes have been implemented in the editor.
- Proceeding to build and commit.

View File

@@ -0,0 +1,12 @@
# Task: Migrasi KategoriProduk → KategoriProdukUmkm
## Progress
- [x] Phase 1: Schema Update (`prisma/schema.prisma`) <!-- id: 0 -->
- [x] Phase 2: Data Migration (Manual SQL/Script) <!-- id: 1 -->
- [x] Phase 3: Update API CRUD UMKM Kategori <!-- id: 2 -->
- [x] Phase 4: Update KPI Dashboard UMKM <!-- id: 3 -->
- [x] Phase 5: Verification & Build <!-- id: 4 -->
## Notes
- `KategoriProduk` tetap dipertahankan untuk `PasarDesa`.
- `KategoriProdukUmkm` akan digunakan secara eksklusif oleh `Umkm`.

View File

@@ -0,0 +1,12 @@
# Task - Refactor UMKM Edit Pages Pattern
Refactor Data UMKM and Produk edit pages to match the Berita edit page UI pattern and logic.
## Steps
1. [x] Analyze `berita/list-berita/[id]/edit/page.tsx` for the desired pattern.
2. [x] Implement the pattern in `ekonomi/umkm/produk/[id]/edit/page.tsx` (using interfaces).
3. [x] Implement the pattern in `ekonomi/umkm/data-umkm/[id]/edit/page.tsx` (using interfaces).
4. [x] Run `bun run build` to verify.
5. [x] Update `package.json` version.
6. [x] Commit with message: "feat(admin): refactor UMKM edit pages to match berita pattern with interfaces".
7. [ ] Create summary in `MIND/SUMMARY/refactor-umkm-edit-pages-pattern-summary.md`.

View File

@@ -0,0 +1,15 @@
# Summary - Admin UMKM & Produk Edit functionality
## Changes
- **Valtio State**: Added `update` and `del` methods for UMKM and Produk modules in `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts`.
- **List Pages**: Updated `data-umkm/page.tsx` and `produk/page.tsx` to handle edit (navigation) and delete (confirmation modal + state action).
- **Edit Pages**:
- Created `src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/[id]/edit/page.tsx`
- Created `src/app/admin/(dashboard)/ekonomi/umkm/produk/[id]/edit/page.tsx`
- **Component**: Integrated `ModalKonfirmasiHapus` with named import.
- **Version**: Bumped to `0.1.21`.
## Verification
- Successfully ran `bun run build`.
- Pushed to `tasks/admin-umkm-edit/implement-edit-delete/2026-04-24-11-44`.
- Merged to `stg` and pushed to `origin` and `deploy` remotes.

View File

@@ -0,0 +1,15 @@
# Summary: Fix and Improve UMKM Module
Successfully implemented various improvements and fixes for the UMKM module.
## Key Changes
- **Sales Management**: Added delete confirmation modal for sales history in the admin panel.
- **Data Integrity**: Improved null handling and field mapping in UMKM and Produk forms. Added stock validation when recording new sales.
- **Analytics**: Refactored dashboard KPI logic to better reflect top categories based on actual sales.
- **Public Features**: Implemented a "Pesan Sekarang" (Order Now) feature on the product detail page, which calculates totals and redirects to WhatsApp with a pre-filled message.
- **Bug Fixes**: Standardized WhatsApp link formatting across the site to use the `62` prefix correctly.
- **Documentation**: Translated and streamlined `CLAUDE.md` for better clarity.
## Verification Results
- Build successful.
- Manual check of ordering and delete flows completed.

View File

@@ -0,0 +1,26 @@
# Summary: Migrasi KategoriProduk → KategoriProdukUmkm
## Perubahan yang Dilakukan
1. **Schema Prisma**:
- Memisahkan model kategori untuk `Umkm` dan `PasarDesa`.
- `Umkm` sekarang menggunakan `KategoriProdukUmkm`.
- `PasarDesa` tetap menggunakan `KategoriProduk`.
- Menghapus relasi `Umkm` dari model `KategoriProduk`.
2. **Database**:
- Menjalankan `prisma db push` untuk memperbarui tabel di PostgreSQL.
- Menyiapkan dan menguji script migrasi data (tabel saat ini kosong, namun script sudah diverifikasi).
3. **Backend API**:
- Mengubah `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/kategori-produk/kategori-produk.ts` agar menggunakan `prisma.kategoriProdukUmkm`.
- Memperbarui logic KPI dashboard di `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/dashboard/kpi.ts` untuk menggunakan model kategori yang tepat berdasarkan konteks (UMKM vs Penjualan).
4. **Validasi**:
- Berhasil menjalankan `bun run build` tanpa error TypeScript baru.
## Dampak
- Admin UMKM sekarang memiliki manajemen kategori yang terisolasi dari PasarDesa.
- Tidak ada perubahan pada UI karena path API dan struktur data tetap sama.
- Kompabilitas data tetap terjaga karena relasi menggunakan ID yang sama.
## File Terkait
- `prisma/schema.prisma`
- `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/kategori-produk/kategori-produk.ts`
- `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/dashboard/kpi.ts`

View File

@@ -0,0 +1,14 @@
# Summary - Refactor UMKM Edit Pages Pattern
## Changes
1. **UMKM Produk Edit Page**: Refactored `src/app/admin/(dashboard)/ekonomi/umkm/produk/[id]/edit/page.tsx` to match the "Berita" edit page pattern. Added explicit `ProdukData` and `ProdukForm` interfaces. Added Reset ("Batal") functionality, standardized header, paper, and dropzone styling, and used `EditEditor`.
2. **Data UMKM Edit Page**: Refactored `src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/[id]/edit/page.tsx` with the same pattern and interfaces (`UmkmData`, `UmkmForm`).
3. **Type Safety**: Improved type safety by using explicit interfaces for data fetching and form state management.
4. **UI Consistency**: Standardized colors and component usage across UMKM edit pages.
5. **UX Improvement**: Added a "Batal" button that resets the form to its original data state.
6. **Build Verification**: Confirmed that the project builds successfully with `bun run build`.
## Verification Results
- `bun run build`: Success.
- Pattern Match: Both pages now follow the consistent layout and logic of the Berita edit page.
- Reset Functionality: Implemented and verified via logic review.

View File

@@ -71,6 +71,7 @@
"list": "^2.0.19",
"lodash": "^4.17.21",
"mime-types": "^3.0.2",
"minio": "^8.0.7",
"motion": "^12.4.1",
"nanoid": "^5.1.5",
"next": "^15.5.2",
@@ -401,6 +402,8 @@
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-+W7eFf3RS7m4G6tppVTOSyP9Y6FsJXfOuKzav1qKniiFm3KFByQfPEcouHdjlZmysl4zJGuGLQ/M9XyVeyeNEg=="],
"@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -981,6 +984,8 @@
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
"async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="],
@@ -1007,15 +1012,19 @@
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
@@ -1147,6 +1156,8 @@
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
@@ -1297,6 +1308,10 @@
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="],
"fast-xml-parser": ["fast-xml-parser@5.7.2", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
@@ -1315,6 +1330,8 @@
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="],
@@ -1427,7 +1444,7 @@
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
"iron-session": ["iron-session@8.0.4", "", { "dependencies": { "cookie": "^0.7.2", "iron-webcrypto": "^1.2.1", "uncrypto": "^0.1.3" } }, "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA=="],
@@ -1633,6 +1650,8 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minio": ["minio@8.0.7", "", { "dependencies": { "async": "^3.2.4", "block-stream2": "^2.1.0", "browser-or-node": "^2.1.1", "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^5.3.4", "ipaddr.js": "^2.0.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "query-string": "^7.1.3", "stream-json": "^1.8.0", "through2": "^4.0.2", "xml2js": "^0.5.0 || ^0.6.2" } }, "sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ=="],
"motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="],
"motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
@@ -1729,6 +1748,8 @@
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
@@ -1831,6 +1852,8 @@
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"ramda": ["ramda@0.27.2", "", {}, "sha512-SbiLPU40JuJniHexQSAgad32hfwd+DRUdwF2PlVuI5RZD0/vahUco7R8vD86J/tcEKKF9vZrUVwgtmGCqlCKyA=="],
@@ -1875,6 +1898,8 @@
"react-zoom-pan-pinch": ["react-zoom-pan-pinch@3.7.0", "", { "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"recharts": ["recharts@3.8.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
@@ -1925,6 +1950,8 @@
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@@ -1967,6 +1994,8 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
@@ -1977,8 +2006,14 @@
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
"stream-chain": ["stream-chain@2.2.5", "", {}, "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA=="],
"stream-json": ["stream-json@1.9.1", "", { "dependencies": { "stream-chain": "^2.2.5" } }, "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw=="],
"strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="],
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
@@ -1993,6 +2028,8 @@
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
@@ -2001,6 +2038,8 @@
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="],
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
@@ -2023,6 +2062,8 @@
"term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="],
"through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
@@ -2159,6 +2200,10 @@
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
@@ -2253,6 +2298,8 @@
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"minio/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"msgpackr-extract/node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
@@ -2267,12 +2314,16 @@
"postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
"yauzl/buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
"@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"@parcel/packager-js/globals/type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="],
@@ -2289,6 +2340,8 @@
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"minio/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],

View File

@@ -3,6 +3,10 @@ set -e
echo "🔄 Running database migrations..."
cd /app
# Resolve any previously failed migrations before re-applying
bunx prisma migrate resolve --rolled-back 20260423072135_add_stok_to_pasar_desa 2>/dev/null || true
bunx prisma migrate deploy || {
echo "❌ Migration failed!"
exit 1

View File

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

View File

@@ -0,0 +1,48 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const kategoriKegiatanJson = loadJsonData("desa/kegiatan-desa/kategori-kegiatan.json");
const kegiatanDesaJson = loadJsonData("desa/kegiatan-desa/kegiatan-desa.json");
export async function seedKegiatanDesa() {
console.log("🔄 Seeding Kategori Kegiatan Desa...");
for (const k of kategoriKegiatanJson) {
await prisma.kategoriKegiatan.upsert({
where: { id: k.id },
update: { nama: k.nama },
create: { id: k.id, nama: k.nama },
});
console.log(` ✅ Kategori: ${k.nama}`);
}
console.log("🔄 Seeding Kegiatan Desa...");
for (const item of kegiatanDesaJson) {
await prisma.kegiatanDesa.upsert({
where: { id: item.id },
update: {
judul: item.judul,
deskripsiSingkat: item.deskripsiSingkat,
deskripsiLengkap: item.deskripsiLengkap,
tanggal: new Date(item.tanggal),
lokasi: item.lokasi,
partisipan: item.partisipan,
kategoriKegiatanId: item.kategoriKegiatanId,
},
create: {
id: item.id,
judul: item.judul,
deskripsiSingkat: item.deskripsiSingkat,
deskripsiLengkap: item.deskripsiLengkap,
tanggal: new Date(item.tanggal),
lokasi: item.lokasi,
partisipan: item.partisipan,
kategoriKegiatanId: item.kategoriKegiatanId,
},
});
console.log(` ✅ Kegiatan: ${item.judul}`);
}
console.log("🎉 Kegiatan Desa seed selesai");
}

View File

@@ -1,5 +1,13 @@
import prisma from "@/lib/prisma";
const kategoriUmkmData = [
{ id: "4b95bge6-012e-5ged-9552-4d8g65d44959", nama: "Makanan" },
{ id: "5c06chf7-123f-6hfe-0663-5e9h76e55060", nama: "Minuman" },
{ id: "5c06chf7-123f-7igd-0663-5e9h76e55060", nama: "Sembako" },
{ id: "5c06chf7-123f-8jhe-0663-5e9h76e55060", nama: "Sayur Mayur" },
{ id: "5c06chf7-123f-9kif-0663-5e9h76e55060", nama: "Protein Hewani" },
];
export const umkmData = [
{
id: "umkm-1",
@@ -40,6 +48,15 @@ export const umkmData = [
];
export async function seedUmkm() {
console.log("🔄 Seeding Kategori Produk UMKM...");
for (const k of kategoriUmkmData) {
await prisma.kategoriProdukUmkm.upsert({
where: { id: k.id },
update: { nama: k.nama },
create: { id: k.id, nama: k.nama },
});
}
console.log("🔄 Seeding UMKM...");
for (const u of umkmData) {
await prisma.umkm.upsert({

View File

@@ -28,6 +28,7 @@ export async function seedProgramKesehatan() {
name: p.name,
deskripsiSingkat: p.deskripsiSingkat,
deskripsi: p.deskripsi,
persentase: p.persentase ?? 0,
imageId,
},
create: {
@@ -35,6 +36,7 @@ export async function seedProgramKesehatan() {
name: p.name,
deskripsiSingkat: p.deskripsiSingkat,
deskripsi: p.deskripsi,
persentase: p.persentase ?? 0,
imageId,
},
});

View File

@@ -0,0 +1,24 @@
import prisma from "@/lib/prisma";
const SINGLETON_ID = "ringkasan_kesehatan_desa_001";
export async function seedRingkasanKesehatan() {
console.log("🔄 Seeding Ringkasan Kesehatan Desa...");
await prisma.ringkasanKesehatanDesa.upsert({
where: { id: SINGLETON_ID },
update: {
ibuHamilAkh: 87,
balitaTerdaftar: 342,
alertStunting: 12,
},
create: {
id: SINGLETON_ID,
ibuHamilAkh: 87,
balitaTerdaftar: 342,
alertStunting: 12,
},
});
console.log("✅ Ringkasan Kesehatan Desa seeded");
}

View File

@@ -0,0 +1,22 @@
import prisma from "@/lib/prisma";
const SINGLETON_ID = "beasiswa_config_desa_001";
export async function seedBeasiswaConfig() {
console.log("🔄 Seeding Beasiswa Config...");
await prisma.beasiswaConfig.upsert({
where: { id: SINGLETON_ID },
update: {
tahunAjaran: "2025/2026",
danaTersalurkan: BigInt(1200000000),
},
create: {
id: SINGLETON_ID,
tahunAjaran: "2025/2026",
danaTersalurkan: BigInt(1200000000),
},
});
console.log("✅ Beasiswa Config seeded");
}

View File

@@ -0,0 +1,22 @@
[
{
"id": "katbudaya000001",
"nama": "Budaya"
},
{
"id": "katsosia000001",
"nama": "Sosial"
},
{
"id": "katolahraga0001",
"nama": "Olahraga"
},
{
"id": "katkeagamaan01",
"nama": "Keagamaan"
},
{
"id": "katpemberday01",
"nama": "Pemberdayaan Masyarakat"
}
]

View File

@@ -0,0 +1,52 @@
[
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567801",
"judul": "Hari Kesaktian Pancasila",
"deskripsiSingkat": "Peringatan Hari Kesaktian Pancasila di Balai Desa Darmasaba sebagai momentum menguatkan nilai-nilai Pancasila dalam kehidupan bermasyarakat.",
"deskripsiLengkap": "Pemerintah Desa Darmasaba menyelenggarakan upacara peringatan Hari Kesaktian Pancasila yang dihadiri oleh perangkat desa, tokoh masyarakat, perwakilan pemuda, dan warga desa. Kegiatan ini sebagai bentuk penghormatan atas perjuangan bangsa dan komitmen untuk terus mengamalkan nilai-nilai Pancasila dalam kehidupan sehari-hari di lingkungan desa.",
"tanggal": "2025-10-01T00:00:00.000Z",
"lokasi": "Balai Desa",
"partisipan": 250,
"kategoriKegiatanId": "katbudaya000001"
},
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567802",
"judul": "Festival Budaya Desa",
"deskripsiSingkat": "Festival tahunan menampilkan kesenian dan budaya lokal Bali sebagai upaya pelestarian warisan budaya Desa Darmasaba.",
"deskripsiLengkap": "Festival Budaya Desa Darmasaba merupakan agenda tahunan yang menampilkan berbagai pertunjukan seni tradisional Bali seperti tari Kecak, Legong, Barong, serta pameran kerajinan tangan dan kuliner khas desa. Festival ini bertujuan untuk melestarikan warisan budaya leluhur, memperkenalkan kekayaan budaya kepada generasi muda, dan meningkatkan daya tarik wisata budaya Desa Darmasaba.",
"tanggal": "2026-05-20T00:00:00.000Z",
"lokasi": "Lapangan Desa",
"partisipan": 500,
"kategoriKegiatanId": "katbudaya000001"
},
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567803",
"judul": "Perayaan HUT Desa",
"deskripsiSingkat": "Perayaan hari ulang tahun Desa Darmasaba dengan berbagai kegiatan seni budaya dan olahraga yang melibatkan seluruh lapisan masyarakat.",
"deskripsiLengkap": "Hari Ulang Tahun Desa Darmasaba dirayakan dengan serangkaian kegiatan meriah meliputi upacara adat, pertunjukan seni budaya Bali, lomba olahraga tradisional, dan pameran produk unggulan desa. Perayaan ini menjadi ajang mempererat kebersamaan warga, mengenang sejarah desa, dan merayakan pencapaian pembangunan yang telah diraih bersama.",
"tanggal": "2026-08-17T00:00:00.000Z",
"lokasi": "Balai Desa",
"partisipan": 600,
"kategoriKegiatanId": "katbudaya000001"
},
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567804",
"judul": "Gotong Royong Pembersihan Desa",
"deskripsiSingkat": "Kegiatan gotong royong rutin membersihkan lingkungan desa sebagai wujud kebersamaan dan kepedulian masyarakat terhadap kebersihan.",
"deskripsiLengkap": "Kegiatan gotong royong pembersihan lingkungan desa dilaksanakan secara rutin setiap bulan dengan melibatkan seluruh warga, kader PKK, karang taruna, dan perangkat desa. Kegiatan ini mencakup pembersihan jalan desa, sungai, tempat ibadah, dan fasilitas umum sebagai bentuk nyata kepedulian masyarakat terhadap kebersihan dan keindahan lingkungan.",
"tanggal": "2026-03-15T00:00:00.000Z",
"lokasi": "Seluruh Wilayah Desa",
"partisipan": 400,
"kategoriKegiatanId": "katsosia000001"
},
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567805",
"judul": "Turnamen Olahraga Antar Banjar",
"deskripsiSingkat": "Turnamen olahraga tahunan antar banjar untuk mempererat tali persaudaraan dan mendorong gaya hidup sehat masyarakat desa.",
"deskripsiLengkap": "Turnamen olahraga antar banjar di Desa Darmasaba menampilkan berbagai cabang olahraga seperti bola voli, bulu tangkis, tenis meja, dan sepak bola. Kegiatan ini diikuti oleh perwakilan dari setiap banjar di desa dan bertujuan untuk menumbuhkan semangat sportivitas, mempererat silaturahmi antar warga, serta mendorong pola hidup sehat melalui olahraga.",
"tanggal": "2026-06-10T00:00:00.000Z",
"lokasi": "Lapangan Desa",
"partisipan": 350,
"kategoriKegiatanId": "katolahraga0001"
}
]

View File

@@ -3,8 +3,57 @@
"id": "cmkanjnmx0006vntz1cn7owpb",
"name": "Posyandu Pudak Amara",
"nomor": "(0361) 8463263",
"deskripsi": "<p>Posyandu Pudak Amara merupakan salah satu posyandu aktif di Desa Darmasaba dan pernah berkompetisi dalam lomba kader dan posyandu berprestasi tingkat Provinsi Bali tahun 2025.</p><p>Kegiatan ini melibatkan kader posyandu serta didampingi pihak desa dan puskesmas setempat untuk meningkatkan pelayanan kesehatan ibu dan anak.</p>",
"jadwalPelayanan": "<p>Setiap bulan pada satu hari tertentu (mis. minggu ke-2): 08:00 12:00 WITA (posyandu balita & ibu hamil)</p>",
"deskripsi": "<p>Posyandu Pudak Amara merupakan salah satu posyandu aktif di Desa Darmasaba dan pernah berkompetisi dalam lomba kader dan posyandu berprestasi tingkat Provinsi Bali tahun 2025.</p>",
"jadwalPelayanan": "Senin, 10 Feb 2026, 08:00 - 11:00 WITA",
"imageName": "TDQReg1lQ73s39crXW0ra-mobile.webp"
},
{
"id": "posyandu_mawar_001",
"name": "Posyandu Mawar",
"nomor": "(0361) 8463264",
"deskripsi": "<p>Posyandu Mawar melayani kesehatan ibu dan anak di wilayah Banjar Mawar, Desa Darmasaba, dengan fokus pada pemantauan tumbuh kembang balita dan kesehatan ibu hamil.</p>",
"jadwalPelayanan": "Senin, 15 Feb 2026, 08:00 - 11:00 WITA"
},
{
"id": "posyandu_melati_001",
"name": "Posyandu Melati",
"nomor": "(0361) 8463265",
"deskripsi": "<p>Posyandu Melati berperan aktif dalam pelayanan kesehatan dasar masyarakat di Banjar Melati, meliputi imunisasi, penimbangan balita, dan konsultasi gizi.</p>",
"jadwalPelayanan": "Selasa, 16 Feb 2026, 08:00 - 11:00 WITA"
},
{
"id": "posyandu_dahlia_001",
"name": "Posyandu Dahlia",
"nomor": "(0361) 8463266",
"deskripsi": "<p>Posyandu Dahlia aktif melayani masyarakat Banjar Dahlia dengan program unggulan pemantauan stunting dan pemberian makanan tambahan bagi balita berisiko.</p>",
"jadwalPelayanan": "Rabu, 17 Feb 2026, 08:00 - 11:00 WITA"
},
{
"id": "posyandu_anggrek_001",
"name": "Posyandu Anggrek",
"nomor": "(0361) 8463267",
"deskripsi": "<p>Posyandu Anggrek melayani ibu hamil, ibu menyusui, dan balita di wilayah Banjar Anggrek dengan dukungan tenaga kesehatan dari Puskesmas Abiansemal 3.</p>",
"jadwalPelayanan": "Kamis, 18 Feb 2026, 08:00 - 11:00 WITA"
},
{
"id": "posyandu_kamboja_001",
"name": "Posyandu Kamboja",
"nomor": "(0361) 8463268",
"deskripsi": "<p>Posyandu Kamboja hadir untuk mendukung kesehatan masyarakat Banjar Kamboja melalui layanan pemeriksaan rutin, imunisasi lengkap, dan edukasi gizi keluarga.</p>",
"jadwalPelayanan": "Jumat, 19 Feb 2026, 08:00 - 11:00 WITA"
},
{
"id": "posyandu_melur_001",
"name": "Posyandu Melur",
"nomor": "(0361) 8463269",
"deskripsi": "<p>Posyandu Melur aktif memberikan layanan kesehatan preventif bagi ibu dan anak di Banjar Melur, termasuk deteksi dini stunting dan pemantauan gizi balita.</p>",
"jadwalPelayanan": "Sabtu, 20 Feb 2026, 08:00 - 11:00 WITA"
},
{
"id": "posyandu_kenanga_001",
"name": "Posyandu Kenanga",
"nomor": "(0361) 8463270",
"deskripsi": "<p>Posyandu Kenanga melayani masyarakat Banjar Kenanga dengan program kesehatan ibu dan anak, pemberian vitamin A, dan konseling laktasi bagi ibu menyusui.</p>",
"jadwalPelayanan": "Senin, 23 Feb 2026, 08:00 - 11:00 WITA"
}
]

View File

@@ -1,9 +1,38 @@
[
{
"id": "prog_kes_imunisasi_001",
"name": "Imunisasi Lengkap",
"deskripsiSingkat": "<p>Persentase balita yang mendapatkan imunisasi lengkap sesuai jadwal di Desa Darmasaba.</p>",
"deskripsi": "<p>Program imunisasi lengkap mencakup vaksin BCG, DPT-HB-Hib, Polio, Campak, dan PCV yang diberikan kepada seluruh balita di Desa Darmasaba melalui posyandu dan puskesmas setempat untuk membangun kekebalan komunitas.</p>",
"persentase": 92
},
{
"id": "prog_kes_pemeriksaan_001",
"name": "Pemeriksaan Rutin",
"deskripsiSingkat": "<p>Persentase ibu hamil dan balita yang melakukan pemeriksaan kesehatan rutin secara berkala.</p>",
"deskripsi": "<p>Pemeriksaan kesehatan rutin dilakukan setiap bulan di posyandu dan puskesmas, mencakup penimbangan berat badan, pengukuran tinggi badan, pemeriksaan tekanan darah ibu hamil, dan konsultasi gizi untuk memantau perkembangan kesehatan masyarakat.</p>",
"persentase": 88
},
{
"id": "prog_kes_gizi_001",
"name": "Gizi Baik",
"deskripsiSingkat": "<p>Persentase balita dengan status gizi baik berdasarkan hasil pemantauan tumbuh kembang.</p>",
"deskripsi": "<p>Program pemantauan gizi balita dilaksanakan melalui posyandu dengan penimbangan bulanan dan pengukuran tinggi badan. Balita dengan status gizi baik menunjukkan berat dan tinggi badan sesuai standar WHO, didukung dengan program pemberian makanan tambahan bagi balita berisiko.</p>",
"persentase": 86
},
{
"id": "prog_kes_stunting_001",
"name": "Target Stunting",
"deskripsiSingkat": "<p>Persentase balita yang teridentifikasi berisiko stunting dan perlu penanganan khusus.</p>",
"deskripsi": "<p>Penanganan stunting di Desa Darmasaba dilakukan melalui deteksi dini, intervensi gizi spesifik, dan intervensi gizi sensitif. Program ini melibatkan kader posyandu, bidan desa, dan puskesmas untuk memantau pertumbuhan balita secara berkala dan memberikan penanganan tepat.</p>",
"persentase": 14
},
{
"id": "cmkawkji50002vn6yzyrlqhh1",
"name": "Gerakan Kulkul PKK dan Posyandu Desa Darmasaba",
"deskripsiSingkat": "<p>Kegiatan bersama PKK dan Posyandu untuk meningkatkan pelayanan kesehatan masyarakat.</p>",
"deskripsi": "<p>Pada hari Minggu, 11 Januari 2025, Pemerintah Desa Darmasaba melalui TP PKK dan TP Posyandu melaksanakan kegiatan Gerakan Kulkul PKK dan Posyandu yang berlangsung serentak di seluruh wilayah Desa Darmasaba untuk memperkuat pelayanan kesehatan dasar dan peningkatan partisipasi masyarakat dalam program Posyandu.</p>",
"persentase": 0,
"imageName": "hLeF0GRFZqDUngZnDMAAk-mobile.webp"
},
{
@@ -11,6 +40,7 @@
"name": "Pendampingan Kunjungan Rumah oleh Puskesmas Abiansemal 3",
"deskripsiSingkat": "<p>Pendataan kesehatan penyandang disabilitas lewat kunjungan rumah di Desa Darmasaba.</p>",
"deskripsi": "<p>Pemerintah Desa Darmasaba bersama Kelian Banjar Dinas dan kader kesehatan mendampingi kegiatan kunjungan rumah yang dilaksanakan oleh Puskesmas Abiansemal 3 pada 21 Juli 2025, difokuskan pada pendataan dan pemantauan kondisi kesehatan penyandang disabilitas di Banjar Bersih, Desa Darmasaba.</p>",
"persentase": 0,
"imageName": "hyyTFi8EApjzFEZ9EvJgB-mobile.webp"
},
{
@@ -18,6 +48,7 @@
"name": "Kegiatan Aksi Sosial Tim Penggerak Posyandu Provinsi Bali di Desa Darmasaba",
"deskripsiSingkat": "<p>Aksi sosial TP Posyandu Bali untuk memperkuat pelayanan posyandu di desa.</p>",
"deskripsi": "<p>Pada 10 Desember 2025, Desa Darmasaba menjadi lokasi pelaksanaan Aksi Sosial Tim Penggerak Posyandu Provinsi Bali yang bertujuan memperkuat pelayanan Posyandu serta meningkatkan kesejahteraan masyarakat, khususnya keluarga dan balita.</p>",
"persentase": 0,
"imageName": "l4qsUEw2JiclGAkkrXp9g-mobile.webp"
},
{
@@ -25,6 +56,7 @@
"name": "Inovasi BAJRA dalam Penanggulangan Rabies",
"deskripsiSingkat": "<p>Program BAJRA untuk penanggulangan rabies di Desa Darmasaba.</p>",
"deskripsi": "<p>Desa Darmasaba mengembangkan inovasi BAJRA (Bersama Jaga Rabies), sebuah program berbasis komunitas untuk penanggulangan rabies yang mengintegrasikan pelaporan cepat masyarakat, edukasi berkelanjutan dan koordinasi lintas sektor antara kesehatan hewan, manusia, dan pemerintahan desa.</p>",
"persentase": 0,
"imageName": "Gc79mlIlGuoRQuTqskFj--mobile.webp"
},
{
@@ -32,6 +64,7 @@
"name": "Posyandu Pudak Amara Berkompetisi",
"deskripsiSingkat": "<p>Partisipasi Posyandu Pudak Amara dalam lomba prestasi Posyandu tingkat provinsi.</p>",
"deskripsi": "<p>Kader Posyandu Pudak Amara Br. Cabe mendapat pendampingan dari Perbekel Darmasaba, Dinas Kesehatan Kab. Badung, Puskesmas Abiansemal III, dan Pustu Desa Darmasaba dalam ajang lomba kader dan Posyandu berprestasi tingkat Provinsi Bali tahun 2025.</p>",
"persentase": 0,
"imageName": "OsMY3AYPyGC_CoN1xUjOn-mobile.webp"
},
{
@@ -39,13 +72,15 @@
"name": "Outbound Kader Posyandu Darmasaba",
"deskripsiSingkat": "<p>Program pembinaan dan pengembangan kapasitas kader Posyandu.</p>",
"deskripsi": "<p>Pemdes Darmasaba melaksanakan kegiatan Outbound Posyandu untuk meningkatkan kapasitas dan wawasan Kader Posyandu se-Desa Darmasaba sebagai bagian dari upaya peningkatan kualitas pelayanan kesehatan dasar di masyarakat.</p>",
"persentase": 0,
"imageName": "M9QlgVKIEfCdY3g4F_tRZ-mobile.webp"
},
{
"id": "cmkdu8ki10004vn4lpbxm2zqo",
"name": "PEMBANGUNAN JAMBAN BAGI MASYARAKAT",
"deskripsiSingkat": "<p>Program pengadaan jamban bagi Masyarakat ini diharapkan menjadi stimulus agar masyarakat peduli terhadap lingkungan sehat sehingga Badung Open Defection Free atau terbebas dari buang air besar di tempat terbuka dapat terwujud.</p>",
"deskripsi": "<p>Desa Darmasaba sebagai desa yang berkomitmen selalu selaras dengan pembangunan Pemerintah Kabupaten Badung pada tahun anggaran 2023 ini turut ambil bagian dalam menyukseskan program Bupati Badung I Nyoman Giri Prasta, S.Sos dalam bidang kesehatan sanitasi masyarakat. Program pengadaan jamban bagi Masyarakat ini diharapkan menjadi stimulus agar masyarakat peduli terhadap lingkungan sehat sehingga Badung Open Defection Free atau terbebas dari buang air besar di tempat terbuka dapat terwujud.</p><p style=\"text-align: justify\">Pemberian bantuan jamban ini dilaksanakan di 11 banjar dengan menyasar 22 keluarga yang memang belum memiliki jamban yang sumber dananya sepenuhnya dari APBDes Darmasaba T. A. 2023. Pembangunan Jamban bagi Masyarakat ini juga menjadi bukti komitmen Pemerintah Desa Darmasaba dalam melaksanakan salah satu visi mewujudkan masyarakat yang sejahtera dan berbudaya untuk menjaga lingkungan yang bersih dan sehat.</p>",
"deskripsi": "<p>Desa Darmasaba sebagai desa yang berkomitmen selalu selaras dengan pembangunan Pemerintah Kabupaten Badung pada tahun anggaran 2023 ini turut ambil bagian dalam menyukseskan program Bupati Badung I Nyoman Giri Prasta, S.Sos dalam bidang kesehatan sanitasi masyarakat.</p>",
"persentase": 0,
"imageName": "6DQbAvn0St-xHdPGW3vpY-mobile.webp"
}
]

View File

@@ -5,33 +5,85 @@
- Added the required column `umkmId` to the `PasarDesa` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "PenjualanProduk" DROP CONSTRAINT "PenjualanProduk_produkId_fkey";
-- DropForeignKey (idempotent)
ALTER TABLE "PenjualanProduk" DROP CONSTRAINT IF EXISTS "PenjualanProduk_produkId_fkey";
ALTER TABLE "ProdukUmkm" DROP CONSTRAINT IF EXISTS "ProdukUmkm_imageId_fkey";
ALTER TABLE "ProdukUmkm" DROP CONSTRAINT IF EXISTS "ProdukUmkm_umkmId_fkey";
-- DropForeignKey
ALTER TABLE "ProdukUmkm" DROP CONSTRAINT "ProdukUmkm_imageId_fkey";
-- AlterTable KategoriProduk (idempotent via DO block)
DO $$
BEGIN
BEGIN
ALTER TABLE "KategoriProduk" ALTER COLUMN "deletedAt" DROP NOT NULL;
EXCEPTION WHEN others THEN NULL;
END;
BEGIN
ALTER TABLE "KategoriProduk" ALTER COLUMN "deletedAt" DROP DEFAULT;
EXCEPTION WHEN others THEN NULL;
END;
END $$;
-- DropForeignKey
ALTER TABLE "ProdukUmkm" DROP CONSTRAINT "ProdukUmkm_umkmId_fkey";
-- AlterTable PasarDesa: add columns if not exists, handle NOT NULL safely
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'PasarDesa' AND column_name = 'stok'
) THEN
ALTER TABLE "PasarDesa" ADD COLUMN "stok" INTEGER NOT NULL DEFAULT 0;
END IF;
-- AlterTable
ALTER TABLE "KategoriProduk" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'PasarDesa' AND column_name = 'umkmId'
) THEN
ALTER TABLE "PasarDesa" ADD COLUMN "umkmId" TEXT;
END IF;
END $$;
-- AlterTable
ALTER TABLE "PasarDesa" ADD COLUMN "stok" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "umkmId" TEXT NOT NULL,
ALTER COLUMN "rating" SET DEFAULT 0,
ALTER COLUMN "alamatUsaha" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT,
ALTER COLUMN "kontak" DROP NOT NULL;
-- Set default value for existing rows before making NOT NULL
UPDATE "PasarDesa" SET "umkmId" = '' WHERE "umkmId" IS NULL;
ALTER TABLE "PasarDesa" ALTER COLUMN "umkmId" SET NOT NULL;
-- DropTable
DROP TABLE "ProdukUmkm";
-- Remaining PasarDesa alterations (idempotent)
DO $$
BEGIN
BEGIN ALTER TABLE "PasarDesa" ALTER COLUMN "rating" SET DEFAULT 0; EXCEPTION WHEN others THEN NULL; END;
BEGIN ALTER TABLE "PasarDesa" ALTER COLUMN "alamatUsaha" DROP NOT NULL; EXCEPTION WHEN others THEN NULL; END;
BEGIN ALTER TABLE "PasarDesa" ALTER COLUMN "deletedAt" DROP NOT NULL; EXCEPTION WHEN others THEN NULL; END;
BEGIN ALTER TABLE "PasarDesa" ALTER COLUMN "deletedAt" DROP DEFAULT; EXCEPTION WHEN others THEN NULL; END;
BEGIN ALTER TABLE "PasarDesa" ALTER COLUMN "kontak" DROP NOT NULL; EXCEPTION WHEN others THEN NULL; END;
END $$;
-- AddForeignKey
ALTER TABLE "PasarDesa" ADD CONSTRAINT "PasarDesa_umkmId_fkey" FOREIGN KEY ("umkmId") REFERENCES "Umkm"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- DropTable (idempotent)
DROP TABLE IF EXISTS "ProdukUmkm";
-- AddForeignKey
ALTER TABLE "PenjualanProduk" ADD CONSTRAINT "PenjualanProduk_produkId_fkey" FOREIGN KEY ("produkId") REFERENCES "PasarDesa"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Clean up rows with invalid umkmId before adding FK constraint
-- Must delete child tables first to avoid FK violations
DELETE FROM "KategoriToPasar" WHERE "pasarDesaId" IN (
SELECT id FROM "PasarDesa" WHERE "umkmId" = '' OR "umkmId" NOT IN (SELECT id FROM "Umkm")
);
DELETE FROM "PenjualanProduk" WHERE "produkId" IN (
SELECT id FROM "PasarDesa" WHERE "umkmId" = '' OR "umkmId" NOT IN (SELECT id FROM "Umkm")
);
DELETE FROM "PasarDesa" WHERE "umkmId" = '' OR "umkmId" NOT IN (SELECT id FROM "Umkm");
-- AddForeignKey (idempotent)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'PasarDesa_umkmId_fkey'
) THEN
ALTER TABLE "PasarDesa" ADD CONSTRAINT "PasarDesa_umkmId_fkey"
FOREIGN KEY ("umkmId") REFERENCES "Umkm"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'PenjualanProduk_produkId_fkey'
) THEN
ALTER TABLE "PenjualanProduk" ADD CONSTRAINT "PenjualanProduk_produkId_fkey"
FOREIGN KEY ("produkId") REFERENCES "PasarDesa"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;

View File

@@ -0,0 +1,57 @@
-- Create KategoriProdukUmkm table if it doesn't exist
-- (renames existing kategori_produk_umkm if present, otherwise creates fresh)
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'kategori_produk_umkm'
) THEN
ALTER TABLE "kategori_produk_umkm" RENAME TO "KategoriProdukUmkm";
ELSIF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'KategoriProdukUmkm'
) THEN
CREATE TABLE "KategoriProdukUmkm" (
"id" TEXT NOT NULL,
"nama" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deletedAt" TIMESTAMP(3),
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "KategoriProdukUmkm_pkey" PRIMARY KEY ("id")
);
END IF;
END $$;
-- Seed KategoriProdukUmkm: copy from KategoriProduk where referenced by Umkm
-- This ensures existing Umkm.kategoriId values exist in the new table
INSERT INTO "KategoriProdukUmkm" ("id", "nama", "createdAt", "updatedAt", "deletedAt", "isActive")
SELECT DISTINCT kp.id, kp.nama, kp."createdAt", kp."updatedAt", kp."deletedAt", kp."isActive"
FROM "KategoriProduk" kp
WHERE kp.id IN (SELECT DISTINCT "kategoriId" FROM "Umkm")
AND NOT EXISTS (SELECT 1 FROM "KategoriProdukUmkm" WHERE id = kp.id);
-- Update Umkm FK: drop old FK pointing to KategoriProduk, add new one to KategoriProdukUmkm
DO $$
DECLARE
fk_target TEXT;
BEGIN
SELECT ccu.table_name INTO fk_target
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
AND tc.table_name = 'Umkm'
AND kcu.column_name = 'kategoriId'
LIMIT 1;
IF fk_target = 'KategoriProduk' THEN
ALTER TABLE "Umkm" DROP CONSTRAINT "Umkm_kategoriId_fkey";
ALTER TABLE "Umkm" ADD CONSTRAINT "Umkm_kategoriId_fkey"
FOREIGN KEY ("kategoriId") REFERENCES "KategoriProdukUmkm"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;

View File

@@ -0,0 +1,4 @@
-- Migrate PasarDesa.kategoriProdukId FK from KategoriProduk to KategoriProdukUmkm
ALTER TABLE "PasarDesa" DROP CONSTRAINT "PasarDesa_kategoriProdukId_fkey";
ALTER TABLE "PasarDesa" ADD CONSTRAINT "PasarDesa_kategoriProdukId_fkey" FOREIGN KEY ("kategoriProdukId") REFERENCES "KategoriProdukUmkm"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,12 @@
-- Seed KategoriProdukUmkm with any KategoriProduk entries referenced by PasarDesa
-- that are not yet in KategoriProdukUmkm
INSERT INTO "KategoriProdukUmkm" ("id", "nama", "createdAt", "updatedAt", "deletedAt", "isActive")
SELECT DISTINCT kp.id, kp.nama, kp."createdAt", kp."updatedAt", kp."deletedAt", kp."isActive"
FROM "KategoriProduk" kp
WHERE kp.id IN (
SELECT DISTINCT "kategoriProdukId" FROM "PasarDesa"
WHERE "kategoriProdukId" IS NOT NULL
)
AND NOT EXISTS (
SELECT 1 FROM "KategoriProdukUmkm" WHERE id = kp.id
);

View File

@@ -0,0 +1,20 @@
-- Step 1: Seed KategoriProdukUmkm from KategoriProduk for any PasarDesa-referenced entries not yet present
INSERT INTO "KategoriProdukUmkm" ("id", "nama", "createdAt", "updatedAt", "deletedAt", "isActive")
SELECT DISTINCT kp.id, kp.nama, kp."createdAt", kp."updatedAt", kp."deletedAt", kp."isActive"
FROM "KategoriProduk" kp
WHERE kp.id IN (
SELECT DISTINCT "kategoriProdukId" FROM "PasarDesa"
WHERE "kategoriProdukId" IS NOT NULL
)
AND NOT EXISTS (
SELECT 1 FROM "KategoriProdukUmkm" WHERE id = kp.id
);
-- Step 2: Make kategoriProdukId nullable to handle orphaned/legacy data
ALTER TABLE "PasarDesa" ALTER COLUMN "kategoriProdukId" DROP NOT NULL;
-- Step 3: Null out any remaining orphaned references (not in KategoriProdukUmkm)
UPDATE "PasarDesa"
SET "kategoriProdukId" = NULL
WHERE "kategoriProdukId" IS NOT NULL
AND "kategoriProdukId" NOT IN (SELECT id FROM "KategoriProdukUmkm");

View File

@@ -0,0 +1,27 @@
-- Add persentase field to ProgramKesehatan (untuk Statistik Kesehatan bar chart)
ALTER TABLE "ProgramKesehatan" ADD COLUMN "persentase" INTEGER NOT NULL DEFAULT 0;
-- Create BeasiswaConfig (untuk dana tersalurkan + tahun ajaran beasiswa desa)
CREATE TABLE "BeasiswaConfig" (
"id" TEXT NOT NULL,
"tahunAjaran" TEXT NOT NULL,
"danaTersalurkan" BIGINT NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "BeasiswaConfig_pkey" PRIMARY KEY ("id")
);
-- Create RingkasanKesehatanDesa (untuk stat cards: ibu hamil, balita, stunting)
CREATE TABLE "RingkasanKesehatanDesa" (
"id" TEXT NOT NULL,
"ibuHamilAkh" INTEGER NOT NULL DEFAULT 0,
"balitaTerdaftar" INTEGER NOT NULL DEFAULT 0,
"alertStunting" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "RingkasanKesehatanDesa_pkey" PRIMARY KEY ("id")
);

View File

@@ -1213,6 +1213,7 @@ model ProgramKesehatan {
name String
deskripsiSingkat String
deskripsi String
persentase Int @default(0)
image FileStorage? @relation(fields: [imageId], references: [id])
imageId String?
createdAt DateTime @default(now())
@@ -1445,8 +1446,8 @@ model PasarDesa {
deletedAt DateTime?
isActive Boolean @default(true)
kategoriProduk KategoriProduk @relation(fields: [kategoriProdukId], references: [id])
kategoriProdukId String
kategoriProduk KategoriProdukUmkm? @relation(fields: [kategoriProdukId], references: [id])
kategoriProdukId String?
KategoriToPasar KategoriToPasar[]
}
@@ -1458,8 +1459,6 @@ model KategoriProduk {
deletedAt DateTime?
isActive Boolean @default(true)
KategoriToPasar KategoriToPasar[]
PasarDesa PasarDesa[]
Umkm Umkm[]
}
model KategoriToPasar {
@@ -2424,23 +2423,35 @@ model MusikDesa {
}
model Umkm {
id String @id @default(cuid())
id String @id @default(cuid())
nama String
pemilik String
deskripsi String?
alamat String?
kontak String?
image FileStorage? @relation("UmkmImage", fields: [imageId], references: [id])
image FileStorage? @relation("UmkmImage", fields: [imageId], references: [id])
imageId String?
kategori KategoriProduk @relation(fields: [kategoriId], references: [id])
kategori KategoriProdukUmkm @relation(fields: [kategoriId], references: [id])
kategoriId String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
produk PasarDesa[]
}
model KategoriProdukUmkm {
id String @id @default(cuid())
nama String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
isActive Boolean @default(true)
Umkm Umkm[]
PasarDesa PasarDesa[]
}
model PenjualanProduk {
id String @id @default(cuid())
produk PasarDesa @relation(fields: [produkId], references: [id])
@@ -2460,3 +2471,24 @@ model PenjualanProduk {
@@index([tanggal])
}
// ========================================= BEASISWA CONFIG ========================================= //
model BeasiswaConfig {
id String @id @default(cuid())
tahunAjaran String
danaTersalurkan BigInt @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isActive Boolean @default(true)
}
// ========================================= RINGKASAN KESEHATAN DESA ========================================= //
model RingkasanKesehatanDesa {
id String @id @default(cuid())
ibuHamilAkh Int @default(0)
balitaTerdaftar Int @default(0)
alertStunting Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isActive Boolean @default(true)
}

View File

@@ -2,6 +2,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import prisma from "@/lib/prisma";
import { seedBerita } from "./_seeder_list/desa/berita/seed_berita";
import { seedKegiatanDesa } from "./_seeder_list/desa/seed_kegiatan_desa";
import { seedFoto } from "./_seeder_list/desa/gallery/foto/seed_foto";
import { seedVideo } from "./_seeder_list/desa/gallery/video/seed_video";
import { seedLayanan } from "./_seeder_list/desa/layanan/seed_layanan";
@@ -46,6 +47,7 @@ import { seedProgramKesehatan } from "./_seeder_list/kesehatan/program-kesehatan
import { seedPuskesmas } from "./_seeder_list/kesehatan/puskesmas/seed_puskesmas";
import { seedGrafikKepuasan } from "./_seeder_list/kesehatan/seed_grafik_kepuasan";
import { seedKelahiranKematian } from "./_seeder_list/kesehatan/seed_kelahiran_kematian";
import { seedRingkasanKesehatan } from "./_seeder_list/kesehatan/seed_ringkasan_kesehatan";
import { seedDesaAntiKorupsi } from "./_seeder_list/landing-page/desa-anti-korupsi/seed_desa_anti_korupsi";
import { seedPrestasiDesa } from "./_seeder_list/landing-page/prestasi-desa/seed_prestasi_desa";
import { seedMediaSosial } from "./_seeder_list/landing-page/profil_landing_page/seed_media_sosial";
@@ -59,6 +61,7 @@ import { seedKonservasiAdatBali } from "./_seeder_list/lingkungan/seed_konservas
import { seedPengelolaanSampah } from "./_seeder_list/lingkungan/seed_pengelolaan_sampah";
import { seedProgramPenghijauan } from "./_seeder_list/lingkungan/seed_program_penghijauan";
import { seedBeasiswaPendaftar } from "./_seeder_list/pendidikan/seed_beasiswa_pendaftar";
import { seedBeasiswaConfig } from "./_seeder_list/pendidikan/seed_beasiswa_config";
import { seedBimbinganBelajar } from "./_seeder_list/pendidikan/seed_bimbingan_belajar";
import { seedDataPendidikan } from "./_seeder_list/pendidikan/seed_data_pendidikan";
import { seedDataPerpustakaan } from "./_seeder_list/pendidikan/seed_data_perpustakaan";
@@ -378,6 +381,11 @@ import seedAssets from "./seed_assets";
// ===== PENDIDIKAN =====
await seedKeunggulanProgram();
await seedBeasiswaPendaftar();
await seedBeasiswaConfig();
// ===== SOSIAL DASHBOARD =====
await seedRingkasanKesehatan();
await seedKegiatanDesa();
// ===== DESA =====
await seedMusikDesa();

View File

@@ -0,0 +1,409 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ApiFetch from "@/lib/api-fetch";
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
// ========================================= SCHEMAS ========================================= //
const templateForm = z.object({
judul: z.string().min(3, "Judul minimal 3 karakter"),
deskripsiSingkat: z.string().min(3, "Deskripsi singkat minimal 3 karakter"),
deskripsiLengkap: z.string().min(3, "Deskripsi lengkap minimal 3 karakter"),
tanggal: z.string().nonempty("Tanggal harus diisi"),
lokasi: z.string().min(3, "Lokasi minimal 3 karakter"),
partisipan: z.number().optional().default(0),
kategoriKegiatanId: z.string().nonempty("Kategori kegiatan harus dipilih"),
imageId: z.string().optional(),
});
const defaultForm = {
judul: "",
deskripsiSingkat: "",
deskripsiLengkap: "",
tanggal: "",
lokasi: "",
partisipan: 0,
kategoriKegiatanId: "",
imageId: "" as string | undefined,
};
const templateKategori = z.object({
nama: z.string().min(1, "Nama kategori harus diisi"),
});
const defaultKategori = {
nama: "",
};
// ========================================= KEGIATAN DESA ========================================= //
const kegiatan = proxy({
create: {
form: { ...defaultForm },
loading: false,
async create() {
const cek = templateForm.safeParse(kegiatan.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
kegiatan.create.loading = true;
const res = await ApiFetch.api.desa["kegiatandesa"]["create"].post(
kegiatan.create.form
);
if (res.status === 200 && res.data?.success) {
kegiatan.findMany.load();
toast.success("Kegiatan desa berhasil disimpan!");
return true;
}
toast.error(res.data?.message || "Gagal menyimpan kegiatan desa");
return false;
} catch (error) {
console.error("Error creating kegiatan:", error);
toast.error("Terjadi kesalahan saat menyimpan kegiatan");
return false;
} finally {
kegiatan.create.loading = false;
}
},
resetForm() {
kegiatan.create.form = { ...defaultForm };
},
},
findMany: {
data: null as any[] | null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "", kategori = "") => {
const startTime = Date.now();
kegiatan.findMany.loading = true;
kegiatan.findMany.page = page;
kegiatan.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
if (kategori) query.kategori = kategori;
const res = await ApiFetch.api.desa["kegiatandesa"]["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
kegiatan.findMany.data = res.data.data ?? [];
kegiatan.findMany.totalPages = res.data.totalPages ?? 1;
} else {
kegiatan.findMany.data = [];
kegiatan.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch kegiatan paginated:", err);
kegiatan.findMany.data = [];
kegiatan.findMany.totalPages = 1;
} finally {
const elapsed = Date.now() - startTime;
const minDelay = 300;
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
setTimeout(() => {
kegiatan.findMany.loading = false;
}, delay);
}
},
},
findUnique: {
data: null as any | null,
loading: false,
async load(id: string) {
if (!id) return;
this.loading = true;
try {
const res = await fetch(`/api/desa/kegiatandesa/${id}`); // Assuming unique endpoint follows standard
if (res.ok) {
const result = await res.json();
kegiatan.findUnique.data = result.data ?? null;
}
} catch (error) {
console.error("Error fetching unique kegiatan:", error);
} finally {
this.loading = false;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
kegiatan.delete.loading = true;
const response = await fetch(`/api/desa/kegiatandesa/del/${id}`, {
method: "DELETE",
});
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Kegiatan berhasil dihapus");
await kegiatan.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus kegiatan");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus kegiatan");
} finally {
kegiatan.delete.loading = false;
}
},
},
edit: {
id: "",
form: { ...defaultForm },
loading: false,
async load(id: string) {
if (!id) return null;
try {
kegiatan.edit.loading = true;
const response = await fetch(`/api/desa/kegiatandesa/${id}`);
const result = await response.json();
if (result?.success) {
const data = result.data;
this.id = data.id;
this.form = {
judul: data.judul,
deskripsiSingkat: data.deskripsiSingkat,
deskripsiLengkap: data.deskripsiLengkap,
tanggal: data.tanggal ? new Date(data.tanggal).toISOString().split('T')[0] : "",
lokasi: data.lokasi,
partisipan: data.partisipan || 0,
kategoriKegiatanId: data.kategoriKegiatanId || "",
imageId: data.imageId || undefined,
};
return data;
}
} catch (error) {
console.error("Error loading kegiatan for edit:", error);
} finally {
kegiatan.edit.loading = false;
}
return null;
},
async update() {
const cek = templateForm.safeParse(kegiatan.edit.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
toast.error(err);
return false;
}
try {
kegiatan.edit.loading = true;
const response = await fetch(`/api/desa/kegiatandesa/${this.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(kegiatan.edit.form),
});
const result = await response.json();
if (result.success) {
toast.success("Berhasil update kegiatan");
await kegiatan.findMany.load();
return true;
}
throw new Error(result.message || "Gagal update kegiatan");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Terjadi kesalahan saat update");
return false;
} finally {
kegiatan.edit.loading = false;
}
},
reset() {
kegiatan.edit.id = "";
kegiatan.edit.form = { ...defaultForm };
},
},
});
// ========================================= KATEGORI KEGIATAN ========================================= //
const kategoriKegiatan = proxy({
create: {
form: { ...defaultKategori },
loading: false,
async create() {
const cek = templateKategori.safeParse(kategoriKegiatan.create.form);
if (!cek.success) {
return toast.error("Nama kategori harus diisi");
}
try {
kategoriKegiatan.create.loading = true;
const res = await ApiFetch.api.desa["kategorikegiatan"]["create"].post(
kategoriKegiatan.create.form
);
if (res.status === 200 && res.data?.success) {
kategoriKegiatan.findMany.load();
toast.success("Kategori kegiatan berhasil dibuat");
return true;
}
toast.error(res.data?.message || "Gagal membuat kategori");
return false;
} catch (error) {
console.error(error);
toast.error("Terjadi kesalahan");
return false;
} finally {
kategoriKegiatan.create.loading = false;
}
},
resetForm() {
kategoriKegiatan.create.form = { ...defaultKategori };
},
},
findMany: {
data: [] as any[],
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
kategoriKegiatan.findMany.loading = true;
kategoriKegiatan.findMany.page = page;
kategoriKegiatan.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.desa["kategorikegiatan"]["findMany"].get({ query });
if (res.status === 200 && res.data?.success) {
kategoriKegiatan.findMany.data = res.data.data ?? [];
kategoriKegiatan.findMany.totalPages = res.data.totalPages ?? 1;
} else {
kategoriKegiatan.findMany.data = [];
}
} catch (err) {
console.error("Gagal fetch kategori kegiatan:", err);
} finally {
kategoriKegiatan.findMany.loading = false;
}
},
},
findUnique: {
data: null as any | null,
loading: false,
async load(id: string) {
try {
const res = await fetch(`/api/desa/kategorikegiatan/${id}`);
if (res.ok) {
const result = await res.json();
kategoriKegiatan.findUnique.data = result.data ?? null;
}
} catch (error) {
console.error(error);
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return;
try {
kategoriKegiatan.delete.loading = true;
const res = await fetch(`/api/desa/kategorikegiatan/del/${id}`, { method: "DELETE" });
const result = await res.json();
if (result.success) {
toast.success(result.message);
kategoriKegiatan.findMany.load();
} else {
toast.error(result.message);
}
} catch (error) {
console.error(error);
} finally {
kategoriKegiatan.delete.loading = false;
}
},
},
update: {
id: "",
form: { ...defaultKategori },
loading: false,
async load(id: string) {
if (!id) return;
try {
this.loading = true;
const res = await fetch(`/api/desa/kategorikegiatan/${id}`);
const result = await res.json();
if (result.success) {
this.id = result.data.id;
this.form = { nama: result.data.nama };
}
} catch (error) {
console.error(error);
} finally {
this.loading = false;
}
},
async update() {
const cek = templateKategori.safeParse(kategoriKegiatan.update.form);
if (!cek.success) return toast.error("Nama kategori harus diisi");
try {
this.loading = true;
const res = await fetch(`/api/desa/kategorikegiatan/${this.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
});
const result = await res.json();
if (result.success) {
toast.success("Berhasil update kategori");
kategoriKegiatan.findMany.load();
return true;
}
toast.error(result.message);
} catch (error) {
console.error(error);
} finally {
this.loading = false;
}
return false;
},
reset() {
this.id = "";
this.form = { ...defaultKategori };
},
},
});
// ========================================= GLOBAL STATE ========================================= //
const stateDashboardKegiatan = proxy({
kegiatan,
kategoriKegiatan,
});
export default stateDashboardKegiatan;

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

@@ -8,10 +8,10 @@ const umkmFormSchema = z.object({
nama: z.string().min(1, "Nama minimal 1 karakter"),
pemilik: z.string().min(1, "Nama pemilik wajib diisi"),
kategoriId: z.string().min(1, "Kategori wajib dipilih"),
deskripsi: z.string().optional(),
alamat: z.string().optional(),
kontak: z.string().optional(),
imageId: z.string().optional(),
deskripsi: z.string().optional().nullable(),
alamat: z.string().optional().nullable(),
kontak: z.string().optional().nullable(),
imageId: z.string().optional().nullable(),
});
const defaultUmkmForm = {
@@ -21,7 +21,7 @@ const defaultUmkmForm = {
deskripsi: "",
alamat: "",
kontak: "",
imageId: "",
imageId: null as string | null,
isActive: true,
};
@@ -31,8 +31,8 @@ const produkFormSchema = z.object({
harga: z.number().min(0, "Harga tidak boleh negatif"),
stok: z.number().min(0, "Stok tidak boleh negatif"),
umkmId: z.string().min(1, "UMKM wajib dipilih"),
deskripsi: z.string().optional(),
imageId: z.string().optional(),
deskripsi: z.string().optional().nullable(),
imageId: z.string().optional().nullable(),
kategoriId: z.string().min(1, "Kategori wajib dipilih"), // PasarDesa needs category
});
@@ -42,7 +42,7 @@ const defaultProdukForm = {
stok: 0,
umkmId: "",
deskripsi: "",
imageId: "",
imageId: null as string | null,
kategoriId: "",
isActive: true,
};
@@ -63,6 +63,15 @@ const defaultPenjualanForm = {
isActive: true,
};
// Kategori Produk Form Validation
const kategoriProdukFormSchema = z.object({
nama: z.string().min(1, "Nama kategori wajib diisi"),
});
const defaultKategoriProdukForm = {
nama: "",
};
export const umkmState = proxy({
// UMKM Module
umkm: {
@@ -101,7 +110,7 @@ export const umkmState = proxy({
loading: false,
async submit() {
const cek = umkmFormSchema.safeParse(this.form);
if (!cek.success) return toast.error("Cek kembali form anda");
if (!cek.success) { toast.error("Cek kembali form anda"); return false; }
this.loading = true;
try {
const res = await fetch("/api/ekonomi/umkm/create", {
@@ -129,10 +138,10 @@ export const umkmState = proxy({
loading: false,
async submit(id: string) {
const cek = umkmFormSchema.safeParse(this.form);
if (!cek.success) return toast.error("Cek kembali form anda");
if (!cek.success) { toast.error("Cek kembali form anda"); return false; }
this.loading = true;
try {
const res = await fetch(`/api/ekonomi/umkm/update/${id}`, {
const res = await fetch(`/api/ekonomi/umkm/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form)
@@ -157,7 +166,7 @@ export const umkmState = proxy({
async submit(id: string) {
this.loading = true;
try {
const res = await fetch(`/api/ekonomi/umkm/delete/${id}`, {
const res = await fetch(`/api/ekonomi/umkm/del/${id}`, {
method: "DELETE"
});
const result = await res.json();
@@ -237,7 +246,7 @@ export const umkmState = proxy({
loading: false,
async submit() {
const cek = produkFormSchema.safeParse(this.form);
if (!cek.success) return toast.error("Cek kembali form anda");
if (!cek.success) { toast.error("Cek kembali form anda"); return false; }
this.loading = true;
try {
const res = await fetch("/api/ekonomi/umkm/produk/create", {
@@ -260,10 +269,10 @@ export const umkmState = proxy({
loading: false,
async submit(id: string) {
const cek = produkFormSchema.safeParse(this.form);
if (!cek.success) return toast.error("Cek kembali form anda");
if (!cek.success) { toast.error("Cek kembali form anda"); return false; }
this.loading = true;
try {
const res = await fetch(`/api/ekonomi/umkm/produk/update/${id}`, {
const res = await fetch(`/api/ekonomi/umkm/produk/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form)
@@ -283,7 +292,7 @@ export const umkmState = proxy({
async submit(id: string) {
this.loading = true;
try {
const res = await fetch(`/api/ekonomi/umkm/produk/delete/${id}`, {
const res = await fetch(`/api/ekonomi/umkm/produk/del/${id}`, {
method: "DELETE"
});
const result = await res.json();
@@ -323,7 +332,7 @@ export const umkmState = proxy({
loading: false,
async submit() {
const cek = penjualanFormSchema.safeParse(this.form);
if (!cek.success) return toast.error("Cek kembali form anda");
if (!cek.success) { toast.error("Cek kembali form anda"); return false; }
this.loading = true;
try {
const res = await fetch("/api/ekonomi/umkm/penjualan/create", {
@@ -340,11 +349,59 @@ export const umkmState = proxy({
} catch (e) { toast.error("Gagal mencatat penjualan"); } finally { this.loading = false; }
return false;
}
},
del: {
loading: false,
async submit(id: string) {
this.loading = true;
try {
const res = await fetch(`/api/ekonomi/umkm/penjualan/del/${id}`, {
method: "DELETE"
});
const result = await res.json();
if (result.success) {
toast.success("Histori penjualan berhasil dihapus");
umkmState.penjualan.findMany.load();
return true;
}
toast.error(result.message);
} catch (e) {
toast.error("Gagal menghapus histori penjualan");
} finally {
this.loading = false;
}
return false;
}
}
},
// Kategori Produk (Share with Pasar Desa)
kategoriProduk: {
findMany: {
data: [] as any[],
page: 1,
totalPages: 1,
loading: false,
search: "",
async load(page = 1, limit = 10, search = "") {
this.loading = true;
this.page = page;
this.search = search;
try {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
search
});
const res = await fetch(`/api/ekonomi/kategoriproduk/find-many?${params}`);
const result = await res.json();
if (result.success) {
this.data = result.data;
this.totalPages = result.totalPages;
}
} catch (e) { console.error(e); } finally { this.loading = false; }
}
},
findManyAll: {
data: [] as any[],
loading: false,
@@ -358,6 +415,75 @@ export const umkmState = proxy({
}
} catch (e) { console.error(e); } finally { this.loading = false; }
}
},
create: {
form: { ...defaultKategoriProdukForm },
loading: false,
async submit() {
const cek = kategoriProdukFormSchema.safeParse(this.form);
if (!cek.success) { toast.error("Nama kategori wajib diisi"); return false; }
this.loading = true;
try {
const res = await fetch("/api/ekonomi/kategoriproduk/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form)
});
const result = await res.json();
if (result.success) {
toast.success("Kategori berhasil dibuat");
umkmState.kategoriProduk.findMany.load();
umkmState.kategoriProduk.findManyAll.load();
return true;
}
toast.error(result.message || "Gagal membuat kategori");
} catch (e) { toast.error("Gagal membuat kategori"); } finally { this.loading = false; }
return false;
}
},
update: {
form: { ...defaultKategoriProdukForm },
loading: false,
async submit(id: string) {
const cek = kategoriProdukFormSchema.safeParse(this.form);
if (!cek.success) { toast.error("Nama kategori wajib diisi"); return false; }
this.loading = true;
try {
const res = await fetch(`/api/ekonomi/kategoriproduk/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form)
});
const result = await res.json();
if (result.success) {
toast.success("Kategori berhasil diperbarui");
umkmState.kategoriProduk.findMany.load();
umkmState.kategoriProduk.findManyAll.load();
return true;
}
toast.error(result.message || "Gagal memperbarui kategori");
} catch (e) { toast.error("Gagal memperbarui kategori"); } finally { this.loading = false; }
return false;
}
},
del: {
loading: false,
async submit(id: string) {
this.loading = true;
try {
const res = await fetch(`/api/ekonomi/kategoriproduk/del/${id}`, {
method: "DELETE"
});
const result = await res.json();
if (result.success) {
toast.success("Kategori berhasil dihapus");
umkmState.kategoriProduk.findMany.load();
umkmState.kategoriProduk.findManyAll.load();
return true;
}
} catch (e) { toast.error("Gagal menghapus kategori"); } finally { this.loading = false; }
return false;
}
}
},
@@ -367,23 +493,31 @@ export const umkmState = proxy({
summary: { data: null as any, loading: false },
topProduk: { data: [] as any[], loading: false },
detail: { data: [] as any[], loading: false },
async loadAll(periode = "") {
mode: "month" as "week" | "month",
async loadAll(periode = "", mode?: "week" | "month", kategoriId = "", umkmId = "") {
const p = periode || `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
const m = mode ?? this.mode;
const modeParam = m === "week" ? "&mode=week" : "";
const detailFilter = [
kategoriId ? `&kategoriId=${kategoriId}` : "",
umkmId ? `&umkmId=${umkmId}` : "",
].join("");
this.kpi.loading = true;
this.summary.loading = true;
this.topProduk.loading = true;
this.detail.loading = true;
try {
const [kpi, sum, top, det] = await Promise.all([
fetch(`/api/ekonomi/umkm/dashboard/kpi?periode=${p}`).then(r => r.json()),
fetch(`/api/ekonomi/umkm/dashboard/ringkasan-penjualan?periode=${p}`).then(r => r.json()),
fetch(`/api/ekonomi/umkm/dashboard/top-produk?periode=${p}`).then(r => r.json()),
fetch(`/api/ekonomi/umkm/dashboard/detail-penjualan?periode=${p}`).then(r => r.json())
fetch(`/api/ekonomi/umkm/dashboard/kpi?periode=${p}${modeParam}`).then(r => r.json()),
fetch(`/api/ekonomi/umkm/dashboard/ringkasan-penjualan?periode=${p}${modeParam}`).then(r => r.json()),
fetch(`/api/ekonomi/umkm/dashboard/top-produk?periode=${p}${modeParam}`).then(r => r.json()),
fetch(`/api/ekonomi/umkm/dashboard/detail-penjualan?periode=${p}${modeParam}${detailFilter}`).then(r => r.json()),
]);
if (kpi.success) this.kpi.data = kpi.data;
if (sum.success) this.summary.data = sum.data;
if (top.success) this.topProduk.data = top.data;
if (det.success) this.detail.data = det.data;
this.mode = m;
} catch (e) { console.error(e); } finally {
this.kpi.loading = false;
this.summary.loading = false;

View File

@@ -0,0 +1,106 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconCalendarEvent, IconCategory } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
function LayoutTabsKegiatanDesa({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const tabs = [
{
label: "List Kegiatan",
value: "list_kegiatan",
href: "/admin/desa/kegiatan-desa/list-kegiatan-desa",
icon: <IconCalendarEvent size={18} stroke={1.8} />
},
{
label: "Kategori Kegiatan",
value: "kategori_kegiatan",
href: "/admin/desa/kegiatan-desa/kategori-kegiatan-desa",
icon: <IconCategory size={18} stroke={1.8} />
},
];
const currentTab = tabs.find(tab => tab.href === pathname);
const [activeTab, setActiveTab] = useState<string | null>(currentTab?.value || tabs[0].value);
const handleTabChange = (value: string | null) => {
const tab = tabs.find(t => t.value === value);
if (tab) {
router.push(tab.href);
}
setActiveTab(value);
};
useEffect(() => {
const match = tabs.find(tab => tab.href === pathname);
if (match) {
setActiveTab(match.value);
}
}, [pathname]);
return (
<Stack gap="lg">
<Title order={3} fw={700} style={{ color: "#1A1B1E" }}>Kegiatan Desa</Title>
<Tabs
color={colors["blue-button"]}
variant="pills"
value={activeTab}
onChange={handleTabChange}
radius="lg"
keepMounted={false}
>
<ScrollArea type="auto" offsetScrollbars>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem",
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
{tabs.map((tab, i) => (
<TabsPanel
key={i}
value={tab.value}
style={{
padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}}
>
<>{children}</>
</TabsPanel>
))}
</Tabs>
</Stack>
);
}
export default LayoutTabsKegiatanDesa;

View File

@@ -0,0 +1,137 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function EditKategoriKegiatan() {
const editState = useProxy(stateDashboardKegiatan.kategoriKegiatan);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({ nama: '' });
const [originalData, setOriginalData] = useState({ nama: '' });
const isFormValid = () => formData.nama?.trim() !== '';
useEffect(() => {
const loadKategori = async () => {
const id = params?.id as string;
if (!id) return;
try {
await stateDashboardKegiatan.kategoriKegiatan.update.load(id);
const nama = stateDashboardKegiatan.kategoriKegiatan.update.form.nama || '';
setFormData({ nama });
setOriginalData({ nama });
} catch (error) {
console.error('Error loading kategori kegiatan:', error);
toast.error('Gagal memuat data kategori kegiatan');
}
};
loadKategori();
}, [params?.id]);
const handleResetForm = () => {
setFormData({ nama: originalData.nama });
toast.info('Form dikembalikan ke data awal');
};
const handleSubmit = async () => {
if (!formData.nama?.trim()) {
toast.error('Nama kategori kegiatan wajib diisi');
return;
}
try {
setIsSubmitting(true);
editState.update.form = {
...editState.update.form,
nama: formData.nama,
};
const success = await editState.update.update();
if (success) {
router.push('/admin/desa/kegiatan-desa/kategori-kegiatan-desa');
}
} catch (error) {
console.error('Error updating kategori kegiatan:', error);
toast.error('Terjadi kesalahan saat memperbarui kategori kegiatan');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Kategori Kegiatan
</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Nama Kategori Kegiatan"
placeholder="Masukkan nama kategori kegiatan"
value={formData.nama}
onChange={(e) => setFormData((prev) => ({ ...prev, nama: e.target.value }))}
required
/>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" size="md" onClick={handleResetForm}>
Batal
</Button>
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background:
!isFormValid() || isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default EditKategoriKegiatan;

View File

@@ -0,0 +1,107 @@
'use client'
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function CreateKategoriKegiatan() {
const createState = useProxy(stateDashboardKegiatan.kategoriKegiatan);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const isFormValid = () => createState.create.form.nama?.trim() !== '';
const resetForm = () => {
createState.create.resetForm();
};
const handleSubmit = async () => {
if (!createState.create.form.nama?.trim()) {
toast.error('Nama kategori kegiatan wajib diisi');
return;
}
setIsSubmitting(true);
try {
const success = await createState.create.create();
if (success) {
resetForm();
router.push('/admin/desa/kegiatan-desa/kategori-kegiatan-desa');
}
} catch (error) {
console.error('Error creating kategori kegiatan:', error);
toast.error('Gagal menambahkan kategori kegiatan');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Kategori Kegiatan
</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Nama Kategori Kegiatan"
placeholder="Masukkan nama kategori kegiatan"
value={createState.create.form.nama || ''}
onChange={(e) => (createState.create.form.nama = e.target.value)}
required
/>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" size="md" onClick={resetForm}>
Reset
</Button>
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background:
!isFormValid() || isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateKategoriKegiatan;

View File

@@ -0,0 +1,239 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import stateDashboardKegiatan from '../../../_state/desa/kegiatanDesa';
function KategoriKegiatanDesa() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title="Kategori Kegiatan"
placeholder="Cari nama kategori kegiatan..."
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListKategoriKegiatan search={search} />
</Box>
);
}
function ListKategoriKegiatan({ search }: { search: string }) {
const listDataState = useProxy(stateDashboardKegiatan.kategoriKegiatan);
const router = useRouter();
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, loading, load, page, totalPages } = listDataState.findMany;
useEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
const handleDelete = () => {
if (selectedId) {
listDataState.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
}
};
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
);
}
return (
<Box py={{ base: 'sm', md: 'lg' }}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'md', md: 'lg' }}>
<Title order={4} lh={1.2}>
Daftar Kategori Kegiatan
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push('/admin/desa/kegiatan-desa/kategori-kegiatan-desa/create')
}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table highlightOnHover layout="fixed" withColumnBorders={false} miw={0}>
<TableThead>
<TableTr>
<TableTh w="60%">
<Text fz="sm" fw={600} lh={1.4}>Kategori</Text>
</TableTh>
<TableTh w="20%">
<Text fz="sm" fw={600} lh={1.4} ta="center">Edit</Text>
</TableTh>
<TableTh w="20%">
<Text fz="sm" fw={600} lh={1.4} ta="center">Hapus</Text>
</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fz="sm" fw={500} lh={1.45} truncate="end">
{item.nama}
</Text>
</TableTd>
<TableTd ta="center">
<Button
variant="light"
color="green"
onClick={() =>
router.push(
`/admin/desa/kegiatan-desa/kategori-kegiatan-desa/${item.id}`
)
}
size="compact-sm"
>
<IconEdit size={16} />
</Button>
</TableTd>
<TableTd ta="center">
<Button
variant="light"
color="red"
disabled={listDataState.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
size="compact-sm"
>
<IconTrash size={16} />
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={3}>
<Center py={24}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori kegiatan yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Cards */}
<Stack hiddenFrom="md" gap="xs" mt="md">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder radius="md" p="sm" bg="white">
<Box flex={1} ml="md">
<Text fz="sm" fw={600} lh={1.4}>Kategori</Text>
<Text fz="sm" fw={500} lh={1.45} truncate>
{item.nama}
</Text>
</Box>
<Group mt="sm" justify="flex-end" gap="xs">
<Button
variant="light"
color="green"
size="compact-xs"
onClick={() =>
router.push(
`/admin/desa/kegiatan-desa/kategori-kegiatan-desa/${item.id}`
)
}
>
<IconEdit size={14} />
</Button>
<Button
variant="light"
color="red"
size="compact-xs"
disabled={listDataState.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={14} />
</Button>
</Group>
</Paper>
))
) : (
<Center py={32}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori kegiatan yang cocok
</Text>
</Center>
)}
</Stack>
</Paper>
<Center mt={{ base: 'lg', md: 'xl' }}>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
color="blue"
radius="md"
/>
</Center>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleDelete}
text="Apakah anda yakin ingin menghapus kategori kegiatan ini?"
/>
</Box>
);
}
export default KategoriKegiatanDesa;

View File

@@ -0,0 +1,28 @@
'use client'
import React from 'react';
import LayoutTabsKegiatanDesa from './_com/layoutTabs';
import { usePathname } from 'next/navigation';
import { Box } from '@mantine/core';
function Layout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length >= 5;
if (isDetailPage) {
return (
<Box>
{children}
</Box>
);
}
return (
<LayoutTabsKegiatanDesa>
{children}
</LayoutTabsKegiatanDesa>
);
}
export default Layout;

View File

@@ -0,0 +1,335 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
NumberInput,
Paper,
Select,
Stack,
Text,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
const isHtmlEmpty = (html: string) => html.replace(/<[^>]*>/g, '').trim() === '';
interface KegiatanData {
id: string;
judul: string;
deskripsiSingkat: string;
deskripsiLengkap: string;
tanggal: string;
lokasi: string;
partisipan: number;
kategoriKegiatanId: string | null;
imageId: string | null;
image?: { link: string } | null;
}
function EditKegiatanDesa() {
const kegiatanState = useProxy(stateDashboardKegiatan);
const router = useRouter();
const params = useParams();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const emptyForm = {
judul: '',
deskripsiSingkat: '',
deskripsiLengkap: '',
tanggal: '',
lokasi: '',
partisipan: 0,
kategoriKegiatanId: '',
imageId: '',
};
const [formData, setFormData] = useState(emptyForm);
const [originalData, setOriginalData] = useState({ ...emptyForm, imageUrl: '' });
const isFormValid = () =>
formData.judul.trim() !== '' &&
formData.deskripsiSingkat.trim() !== '' &&
!isHtmlEmpty(formData.deskripsiLengkap) &&
formData.tanggal !== '' &&
formData.lokasi.trim() !== '' &&
formData.kategoriKegiatanId !== '';
useEffect(() => {
kegiatanState.kategoriKegiatan.findMany.load();
const load = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await stateDashboardKegiatan.kegiatan.edit.load(id) as KegiatanData | null;
if (data) {
const tanggal = data.tanggal
? new Date(data.tanggal).toISOString().split('T')[0]
: '';
const form = {
judul: data.judul || '',
deskripsiSingkat: data.deskripsiSingkat || '',
deskripsiLengkap: data.deskripsiLengkap || '',
tanggal,
lokasi: data.lokasi || '',
partisipan: data.partisipan || 0,
kategoriKegiatanId: data.kategoriKegiatanId || '',
imageId: data.imageId || '',
};
setFormData(form);
setOriginalData({ ...form, imageUrl: data.image?.link || '' });
if (data.image?.link) setPreviewImage(data.image.link);
}
} catch (error) {
console.error('Error loading kegiatan:', error);
toast.error('Gagal memuat data kegiatan');
}
};
load();
}, [params?.id]);
const handleChange = (field: string, value: string | number) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async () => {
if (!formData.judul.trim()) return toast.error('Judul wajib diisi');
if (!formData.deskripsiSingkat.trim()) return toast.error('Deskripsi singkat wajib diisi');
if (isHtmlEmpty(formData.deskripsiLengkap)) return toast.error('Deskripsi lengkap wajib diisi');
if (!formData.tanggal) return toast.error('Tanggal wajib diisi');
if (!formData.lokasi.trim()) return toast.error('Lokasi wajib diisi');
if (!formData.kategoriKegiatanId) return toast.error('Kategori wajib dipilih');
try {
setIsSubmitting(true);
kegiatanState.kegiatan.edit.form = {
...kegiatanState.kegiatan.edit.form,
...formData,
};
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
const uploaded = res.data?.data;
if (!uploaded?.id) return toast.error('Gagal upload gambar');
kegiatanState.kegiatan.edit.form.imageId = uploaded.id;
}
const success = await kegiatanState.kegiatan.edit.update();
if (success) {
router.push('/admin/desa/kegiatan-desa/list-kegiatan-desa');
}
} catch (error) {
console.error('Error updating kegiatan:', error);
toast.error('Terjadi kesalahan saat memperbarui kegiatan');
} finally {
setIsSubmitting(false);
}
};
const handleReset = () => {
setFormData({
judul: originalData.judul,
deskripsiSingkat: originalData.deskripsiSingkat,
deskripsiLengkap: originalData.deskripsiLengkap,
tanggal: originalData.tanggal,
lokasi: originalData.lokasi,
partisipan: originalData.partisipan,
kategoriKegiatanId: originalData.kategoriKegiatanId,
imageId: originalData.imageId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info('Form dikembalikan ke data awal');
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">Edit Kegiatan Desa</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Judul"
placeholder="Masukkan judul kegiatan"
value={formData.judul}
onChange={(e) => handleChange('judul', e.target.value)}
required
/>
<Select
label="Kategori Kegiatan"
placeholder="Pilih kategori"
data={kegiatanState.kategoriKegiatan.findMany.data.map((item) => ({
label: item.nama,
value: item.id,
}))}
value={formData.kategoriKegiatanId || null}
onChange={(val) => handleChange('kategoriKegiatanId', val || '')}
searchable
clearable
nothingFoundMessage="Tidak ditemukan"
required
/>
<TextInput
label="Tanggal"
type="date"
value={formData.tanggal}
onChange={(e) => handleChange('tanggal', e.target.value)}
required
/>
<TextInput
label="Lokasi"
placeholder="Masukkan lokasi kegiatan"
value={formData.lokasi}
onChange={(e) => handleChange('lokasi', e.target.value)}
required
/>
<NumberInput
label="Jumlah Partisipan"
placeholder="Masukkan jumlah partisipan"
value={formData.partisipan}
onChange={(val) => handleChange('partisipan', Number(val) || 0)}
min={0}
/>
<Textarea
label="Deskripsi Singkat"
placeholder="Masukkan deskripsi singkat kegiatan"
value={formData.deskripsiSingkat}
onChange={(e) => handleChange('deskripsiSingkat', e.target.value)}
minRows={3}
autosize
required
/>
<Box>
<Text fz="sm" fw="bold" mb={6}>Deskripsi Lengkap</Text>
<EditEditor
value={formData.deskripsiLengkap}
onChange={(html) => setFormData((prev) => ({ ...prev, deskripsiLengkap: html }))}
/>
</Box>
<Box>
<Text fw="bold" fz="sm" mb={6}>Gambar (Opsional)</Text>
<Dropzone
onDrop={(files) => {
const f = files[0];
if (f) { setFile(f); setPreviewImage(URL.createObjectURL(f)); }
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={160}>
<Dropzone.Accept>
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview"
radius="md"
style={{
maxHeight: 220,
objectFit: 'contain',
border: `1px solid ${colors['blue-button']}`,
}}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => { setPreviewImage(null); setFile(null); }}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" size="md" onClick={handleReset}>
Batal
</Button>
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background:
!isFormValid() || isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default EditKegiatanDesa;

View File

@@ -0,0 +1,189 @@
'use client'
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
import colors from '@/con/colors';
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
interface KegiatanDetail {
id: string;
judul: string;
deskripsiSingkat: string;
deskripsiLengkap: string;
tanggal: string;
lokasi: string;
partisipan: number;
kategoriKegiatan?: { nama: string } | null;
image?: { link: string } | null;
}
function DetailKegiatanDesa() {
const kegiatanState = useProxy(stateDashboardKegiatan);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const params = useParams();
const router = useRouter();
useShallowEffect(() => {
kegiatanState.kegiatan.findUnique.load(params?.id as string);
}, []);
const handleHapus = () => {
if (selectedId) {
kegiatanState.kegiatan.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push('/admin/desa/kegiatan-desa/list-kegiatan-desa');
}
};
if (!kegiatanState.kegiatan.findUnique.data) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
const data = kegiatanState.kegiatan.findUnique.data as unknown as KegiatanDetail;
const formatTanggal = (val: string) => {
if (!val) return '-';
return new Date(val).toLocaleDateString('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
mb={15}
>
Kembali
</Button>
<Paper
withBorder
w={{ base: '100%', md: '70%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Kegiatan Desa
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">Kategori</Text>
<Text fz="md" c="dimmed">{data.kategoriKegiatan?.nama || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Judul</Text>
<Text fz="md" c="dimmed">{data.judul || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Tanggal</Text>
<Text fz="md" c="dimmed">{formatTanggal(data.tanggal)}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Lokasi</Text>
<Text fz="md" c="dimmed">{data.lokasi || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Partisipan</Text>
<Text fz="md" c="dimmed">{data.partisipan ?? 0} orang</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Deskripsi Singkat</Text>
<Text fz="md" c="dimmed" style={{ whiteSpace: 'pre-wrap' }}>
{data.deskripsiSingkat || '-'}
</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Deskripsi Lengkap</Text>
<Paper bg="white" p="md" radius="md" mt="xs">
<Text
fz="md"
c="dimmed"
dangerouslySetInnerHTML={{ __html: data.deskripsiLengkap || '-' }}
/>
</Paper>
</Box>
{data.image?.link && (
<Box>
<Text fz="lg" fw="bold">Gambar</Text>
<Image
src={data.image.link}
alt={data.judul || 'Gambar Kegiatan'}
w={{ base: '100%', md: 400 }}
h={300}
radius="md"
fit="cover"
loading="lazy"
mt="xs"
/>
</Box>
)}
<Group gap="sm" mt="md">
<Button
color="red"
onClick={() => { setSelectedId(data.id); setModalHapus(true); }}
variant="light"
radius="md"
size="md"
leftSection={<IconTrash size={20} />}
>
Hapus
</Button>
<Button
color="green"
onClick={() =>
router.push(
`/admin/desa/kegiatan-desa/list-kegiatan-desa/${data.id}/edit`
)
}
variant="light"
radius="md"
size="md"
leftSection={<IconEdit size={20} />}
>
Edit
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Paper>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah Anda yakin ingin menghapus kegiatan desa ini?"
/>
</Box>
);
}
export default DetailKegiatanDesa;

View File

@@ -0,0 +1,258 @@
'use client'
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
NumberInput,
Paper,
Select,
Stack,
Text,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
const isHtmlEmpty = (html: string) => html.replace(/<[^>]*>/g, '').trim() === '';
export default function CreateKegiatanDesa() {
const kegiatanState = useProxy(stateDashboardKegiatan);
const router = useRouter();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useShallowEffect(() => {
kegiatanState.kategoriKegiatan.findMany.load();
}, []);
const isFormValid = () => {
const f = kegiatanState.kegiatan.create.form;
return (
f.judul.trim() !== '' &&
f.deskripsiSingkat.trim() !== '' &&
!isHtmlEmpty(f.deskripsiLengkap) &&
f.tanggal !== '' &&
f.lokasi.trim() !== '' &&
f.kategoriKegiatanId !== ''
);
};
const resetForm = () => {
kegiatanState.kegiatan.create.resetForm();
setPreviewImage(null);
setFile(null);
};
const handleSubmit = async () => {
const f = kegiatanState.kegiatan.create.form;
if (!f.judul.trim()) return toast.error('Judul wajib diisi');
if (!f.deskripsiSingkat.trim()) return toast.error('Deskripsi singkat wajib diisi');
if (isHtmlEmpty(f.deskripsiLengkap)) return toast.error('Deskripsi lengkap wajib diisi');
if (!f.tanggal) return toast.error('Tanggal wajib diisi');
if (!f.lokasi.trim()) return toast.error('Lokasi wajib diisi');
if (!f.kategoriKegiatanId) return toast.error('Kategori wajib dipilih');
try {
setIsSubmitting(true);
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
const uploaded = res.data?.data;
if (!uploaded?.id) return toast.error('Gagal mengunggah gambar');
kegiatanState.kegiatan.create.form.imageId = uploaded.id;
}
const success = await kegiatanState.kegiatan.create.create();
if (success) {
resetForm();
router.push('/admin/desa/kegiatan-desa/list-kegiatan-desa');
}
} catch (error) {
console.error('Error creating kegiatan:', error);
toast.error('Terjadi kesalahan saat membuat kegiatan');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">Tambah Kegiatan Desa</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Judul"
placeholder="Masukkan judul kegiatan"
value={kegiatanState.kegiatan.create.form.judul}
onChange={(e) => (kegiatanState.kegiatan.create.form.judul = e.target.value)}
required
/>
<Select
label="Kategori Kegiatan"
placeholder="Pilih kategori"
data={kegiatanState.kategoriKegiatan.findMany.data.map((item) => ({
label: item.nama,
value: item.id,
}))}
value={kegiatanState.kegiatan.create.form.kategoriKegiatanId || null}
onChange={(val) => {
kegiatanState.kegiatan.create.form.kategoriKegiatanId = val || '';
}}
searchable
clearable
nothingFoundMessage="Tidak ditemukan"
required
/>
<TextInput
label="Tanggal"
type="date"
value={kegiatanState.kegiatan.create.form.tanggal}
onChange={(e) => (kegiatanState.kegiatan.create.form.tanggal = e.target.value)}
required
/>
<TextInput
label="Lokasi"
placeholder="Masukkan lokasi kegiatan"
value={kegiatanState.kegiatan.create.form.lokasi}
onChange={(e) => (kegiatanState.kegiatan.create.form.lokasi = e.target.value)}
required
/>
<NumberInput
label="Jumlah Partisipan"
placeholder="Masukkan jumlah partisipan"
value={kegiatanState.kegiatan.create.form.partisipan}
onChange={(val) => (kegiatanState.kegiatan.create.form.partisipan = Number(val) || 0)}
min={0}
/>
<Textarea
label="Deskripsi Singkat"
placeholder="Masukkan deskripsi singkat kegiatan"
value={kegiatanState.kegiatan.create.form.deskripsiSingkat}
onChange={(e) => (kegiatanState.kegiatan.create.form.deskripsiSingkat = e.target.value)}
minRows={3}
autosize
required
/>
<Box>
<Text fz="sm" fw="bold" mb={6}>Deskripsi Lengkap</Text>
<CreateEditor
value={kegiatanState.kegiatan.create.form.deskripsiLengkap}
onChange={(html) => { kegiatanState.kegiatan.create.form.deskripsiLengkap = html; }}
/>
</Box>
<Box>
<Text fw="bold" fz="sm" mb={6}>Gambar (Opsional)</Text>
<Dropzone
onDrop={(files) => {
const f = files[0];
if (f) { setFile(f); setPreviewImage(URL.createObjectURL(f)); }
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={160}>
<Dropzone.Accept>
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih (maks 5MB)
</Text>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview"
radius="md"
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => { setPreviewImage(null); setFile(null); }}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" size="md" onClick={resetForm}>
Reset
</Button>
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background:
!isFormValid() || isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,203 @@
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import stateDashboardKegiatan from '../../../_state/desa/kegiatanDesa';
function KegiatanDesa() {
const [search, setSearch] = useState("");
return (
<Box>
<HeaderSearch
title="Kegiatan Desa"
placeholder="Cari judul atau lokasi kegiatan..."
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListKegiatanDesa search={search} />
</Box>
);
}
function ListKegiatanDesa({ search }: { search: string }) {
const kegiatanState = useProxy(stateDashboardKegiatan);
const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = kegiatanState.kegiatan.findMany;
useShallowEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
if (loading || !data) {
return (
<Stack py="md">
<Skeleton height={600} radius="md" />
</Stack>
);
}
const filteredData = data || [];
return (
<Box py="md">
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Kegiatan Desa</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/kegiatan-desa/list-kegiatan-desa/create')}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table highlightOnHover layout="fixed" withColumnBorders={false} miw={0}>
<TableThead>
<TableTr>
<TableTh w="35%">Judul</TableTh>
<TableTh w="25%">Kategori</TableTh>
<TableTh w="20%">Lokasi</TableTh>
<TableTh w="20%">Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fz="md" fw={600} lh={1.45} truncate="end">
{item.judul}
</Text>
</TableTd>
<TableTd>
<Text fz="sm" c="dimmed" lh={1.45}>
{item.kategoriKegiatan?.nama || '-'}
</Text>
</TableTd>
<TableTd>
<Text fz="sm" c="dimmed" lh={1.45} truncate="end">
{item.lokasi || '-'}
</Text>
</TableTd>
<TableTd>
<Button
variant="light"
color="blue"
onClick={() =>
router.push(`/admin/desa/kegiatan-desa/list-kegiatan-desa/${item.id}`)
}
fz="sm"
px="sm"
h={36}
>
<IconDeviceImacCog size={18} />
<Text ml="xs">Detail</Text>
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kegiatan yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Cards */}
<Stack hiddenFrom="md" gap="sm" mt="sm">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="md" radius="md">
<Stack gap={"xs"}>
<Text fz="sm" fw={600} lh={1.4} c="dimmed">Judul</Text>
<Text fz="sm" fw={500} lh={1.45}>{item.judul}</Text>
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">Kategori</Text>
<Text fz="sm" lh={1.45} fw={500}>{item.kategoriKegiatan?.nama || '-'}</Text>
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">Lokasi</Text>
<Text fz="sm" lh={1.45} fw={500}>{item.lokasi || '-'}</Text>
<Button
variant="light"
color="blue"
fullWidth
mt="sm"
onClick={() =>
router.push(`/admin/desa/kegiatan-desa/list-kegiatan-desa/${item.id}`)
}
fz="sm"
h={36}
>
<IconDeviceImacCog size={18} />
<Text ml="xs">Detail</Text>
</Button>
</Stack>
</Paper>
))
) : (
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kegiatan yang cocok
</Text>
</Center>
)}
</Stack>
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, debouncedSearch);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
</Box>
);
}
export default KegiatanDesa;

View File

@@ -11,7 +11,7 @@ import {
TabsTab,
Title
} from '@mantine/core';
import { IconDashboard, IconBuildingStore, IconPackage, IconShoppingCart } from '@tabler/icons-react';
import { IconDashboard, IconBuildingStore, IconPackage, IconShoppingCart, IconTag } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
@@ -44,6 +44,12 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
href: "/admin/ekonomi/umkm/penjualan",
icon: <IconShoppingCart size={18} stroke={1.8} />
},
{
label: "Kategori Produk",
value: "kategori-produk",
href: "/admin/ekonomi/umkm/kategori-produk",
icon: <IconTag size={18} stroke={1.8} />
},
];
const currentTab = tabs.find((tab) => pathname.startsWith(tab.href));

View File

@@ -1,10 +1,12 @@
'use client'
import colors from '@/con/colors';
import {
Badge,
Box,
Card,
Grid,
Group,
Select,
SimpleGrid,
Skeleton,
Stack,
@@ -16,10 +18,11 @@ import {
TableTr,
Text,
Title,
Badge
SegmentedControl,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowUpRight, IconArrowDownRight, IconMinus } from '@tabler/icons-react';
import { useState, useEffect } from 'react';
import { useProxy } from 'valtio/utils';
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import umkmState from '../../../_state/ekonomi/umkm/umkm';
@@ -27,10 +30,25 @@ import umkmState from '../../../_state/ekonomi/umkm/umkm';
function UmkmDashboard() {
const state = useProxy(umkmState.dashboard);
const [kategoriId, setKategoriId] = useState("");
const [umkmId, setUmkmId] = useState("");
const [kategoriList, setKategoriList] = useState<{ value: string; label: string }[]>([]);
const [umkmList, setUmkmList] = useState<{ value: string; label: string }[]>([]);
useShallowEffect(() => {
state.loadAll();
fetch('/api/ekonomi/kategoriproduk/find-many-all').then(r => r.json()).then(res => {
if (res.success) setKategoriList(res.data.map((k: any) => ({ value: k.id, label: k.nama })));
});
fetch('/api/ekonomi/umkm/find-many-all').then(r => r.json()).then(res => {
if (res.success) setUmkmList(res.data.map((u: any) => ({ value: u.id, label: u.nama })));
});
}, []);
useEffect(() => {
if (state.kpi.data) state.loadAll("", state.mode, kategoriId, umkmId);
}, [kategoriId, umkmId]);
if (state.kpi.loading || !state.kpi.data) {
return <Skeleton height={400} radius="md" />;
}
@@ -42,6 +60,18 @@ function UmkmDashboard() {
return (
<Stack gap="lg">
<Group justify="space-between">
<Title order={4}>Update Penjualan Produk</Title>
<SegmentedControl
value={state.mode}
onChange={(v) => state.loadAll("", v as "week" | "month", kategoriId, umkmId)}
data={[
{ label: 'Minggu ini', value: 'week' },
{ label: 'Bulan ini', value: 'month' },
]}
/>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
<KpiCard title="UMKM Aktif" value={kpi.umkmAktif} subValue={`Total: ${kpi.totalUmkm}`} />
<KpiCard
@@ -49,8 +79,12 @@ function UmkmDashboard() {
value={`Rp ${kpi.omzetBulanan.toLocaleString()}`}
trend={summary?.persentasePerubahan}
/>
<KpiCard title="Produk Aktif" value={summary?.produkAktif || 0} />
<KpiCard title="Kategori Populer" value={kpi.kategoriTerbanyak} />
<KpiCard title="Kategori Aktif" value={summary?.kategoriAktif || 0} subValue="kategori" />
<KpiCard
title="UMKM Terbanyak"
value={kpi.jumlahKategoriTerbanyak || 0}
subValue={kpi.kategoriTerbanyak}
/>
</SimpleGrid>
<Grid>
@@ -59,7 +93,7 @@ function UmkmDashboard() {
<Title order={4} mb="md">Grafik Penjualan per Produk</Title>
<Box style={{ height: 350 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={detail.map(item => ({
<BarChart data={detail.map((item: any) => ({
name: item.namaProduk,
penjualan: item.penjualanBulanIni
}))}>
@@ -76,16 +110,21 @@ function UmkmDashboard() {
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<Card withBorder radius="md" p="lg" shadow="sm">
<Title order={4} mb="md">Top 3 Produk</Title>
<Card h="100%" withBorder radius="md" p="lg" shadow="sm">
<Title order={4} mb="md">Top 3 Produk Terlaris</Title>
<Stack gap="sm">
{topProduk.map((item, i) => (
<Group key={i} justify="space-between">
{topProduk.map((item: any, i: number) => (
<Group key={i} justify="space-between" wrap="nowrap">
<Box>
<Text fw={500}>{item.namaProduk}</Text>
<Text fw={500} size="sm">{item.namaProduk}</Text>
<Text size="xs" c="dimmed">{item.namaUmkm}</Text>
<Text size="xs" c="dimmed">
Rp {item.totalPenjualan.toLocaleString()} · {item.jumlahTerjual} terjual
</Text>
</Box>
<Text fw={600} c="blue">Rp {item.totalPenjualan.toLocaleString()}</Text>
<Badge color={item.growth >= 0 ? 'teal' : 'red'} variant="light" size="sm">
{item.growth >= 0 ? '+' : ''}{item.growth}%
</Badge>
</Group>
))}
</Stack>
@@ -94,24 +133,62 @@ function UmkmDashboard() {
<Grid.Col span={{ base: 12, md: 8 }}>
<Card withBorder radius="md" p="lg" shadow="sm">
<Title order={4} mb="md">Detail Penjualan & Stok</Title>
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Title order={4}>Detail Penjualan Produk</Title>
<Group gap="sm">
<Select
placeholder="Semua Kategori"
data={kategoriList}
value={kategoriId || null}
onChange={(v) => setKategoriId(v || "")}
clearable
size="xs"
style={{ minWidth: 140 }}
/>
<Select
placeholder="Semua UMKM"
data={umkmList}
value={umkmId || null}
onChange={(v) => setUmkmId(v || "")}
clearable
size="xs"
style={{ minWidth: 140 }}
/>
</Group>
</Group>
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Produk</TableTh>
<TableTh>Penjualan</TableTh>
<TableTh>Penjualan Bulan Ini</TableTh>
<TableTh>Bulan Lalu</TableTh>
<TableTh>Trend</TableTh>
<TableTh>Volume</TableTh>
<TableTh>Stok</TableTh>
<TableTh>Status</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{detail.map((item, i) => (
{detail.map((item: any, i: number) => (
<TableTr key={i}>
<TableTd>{item.namaProduk}</TableTd>
<TableTd>Rp {item.penjualanBulanIni.toLocaleString()}</TableTd>
<TableTd>{renderTrend(item.trend)}</TableTd>
<TableTd>Rp {item.penjualanBulanLalu.toLocaleString()}</TableTd>
<TableTd>
<Group gap={4} wrap="nowrap">
{renderTrend(item.trend)}
{item.trendPersen !== 0 && (
<Text
size="xs"
c={item.trend === 'up' ? 'green' : item.trend === 'down' ? 'red' : 'dimmed'}
>
{item.trendPersen > 0 ? '+' : ''}{item.trendPersen}%
</Text>
)}
</Group>
</TableTd>
<TableTd>{item.volume}</TableTd>
<TableTd>{item.stok}</TableTd>
<TableTd>
<Badge color={getStatusColor(item.statusStok)}>

View File

@@ -1,50 +1,107 @@
'use client';
"use client";
import EditEditor from "@/app/admin/(dashboard)/_com/editEditor";
import colors from "@/con/colors";
import ApiFetch from "@/lib/api-fetch";
import {
ActionIcon,
Box,
Button,
Group,
Image,
Paper,
Select,
Stack,
Text,
TextInput,
Title,
Text,
Select,
ActionIcon,
Image,
Loader,
Center
} from '@mantine/core';
import { Dropzone, IMAGE_MIME_TYPE } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter, useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import umkmState from '../../../../../_state/ekonomi/umkm/umkm';
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import ApiFetch from '@/lib/api-fetch';
Center,
} from "@mantine/core";
import { Dropzone, IMAGE_MIME_TYPE } from "@mantine/dropzone";
import {
IconArrowBack,
IconPhoto,
IconUpload,
IconX,
} from "@tabler/icons-react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { useProxy } from "valtio/utils";
import umkmState from "../../../../../_state/ekonomi/umkm/umkm";
export default function EditDataUmkm() {
interface UmkmData {
id: string;
nama: string;
pemilik: string;
kategoriId: string | null;
deskripsi: string | null;
alamat: string | null;
kontak: string | null;
imageId: string | null;
image?: { link: string } | null;
}
interface UmkmForm {
nama: string;
pemilik: string;
kategoriId: string;
deskripsi: string;
alamat: string;
kontak: string;
imageId: string | null;
}
function EditDataUmkm() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const state = useProxy(umkmState.umkm);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [formData, setFormData] = useState<UmkmForm>({
nama: "",
pemilik: "",
kategoriId: "",
deskripsi: "",
alamat: "",
kontak: "",
imageId: null,
});
const [originalData, setOriginalData] = useState<UmkmForm & { imageUrl: string }>({
nama: "",
pemilik: "",
kategoriId: "",
deskripsi: "",
alamat: "",
kontak: "",
imageId: null,
imageUrl: ""
});
const isFormValid = () => {
return (
formData.nama?.trim() !== '' &&
formData.pemilik?.trim() !== '' &&
formData.kategoriId !== ''
);
};
useEffect(() => {
const init = async () => {
await Promise.all([
umkmState.kategoriProduk.findManyAll.load(),
state.findUnique.load(id)
umkmState.umkm.findUnique.load(id)
]);
if (state.findUnique.data) {
const data = state.findUnique.data;
state.update.form = {
const data = umkmState.umkm.findUnique.data as UmkmData | null;
if (data) {
const initialForm: UmkmForm = {
nama: data.nama || "",
pemilik: data.pemilik || "",
kategoriId: data.kategoriId || "",
@@ -52,23 +109,35 @@ export default function EditDataUmkm() {
alamat: data.alamat || "",
kontak: data.kontak || "",
imageId: data.imageId || "",
isActive: data.isActive ?? true,
};
if (data.image?.url) {
setPreviewImage(data.image.url);
setFormData(initialForm);
setOriginalData({
...initialForm,
imageUrl: data.image?.link || ""
});
if (data.image?.link) {
setPreviewImage(data.image.link);
}
}
setIsInitialLoading(false);
};
init();
}, [id, state.findUnique, state.update]);
}, [id]);
const handleChange = (field: keyof UmkmForm, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleUpdate = async () => {
if (!formData.nama?.trim()) return toast.error("Nama UMKM wajib diisi");
if (!formData.pemilik?.trim()) return toast.error("Nama pemilik wajib diisi");
if (!formData.kategoriId) return toast.error("Kategori wajib dipilih");
setIsSubmitting(true);
try {
// 1. Upload image if new file selected
let uploadedImageId = state.update.form.imageId;
let uploadedImageId = formData.imageId;
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({
file,
@@ -84,10 +153,14 @@ export default function EditDataUmkm() {
}
}
// 2. Submit UMKM data
state.update.form.imageId = uploadedImageId;
const success = await state.update.submit(id);
// Update proxy state
umkmState.umkm.update.form = {
...umkmState.umkm.update.form,
...formData,
imageId: uploadedImageId
};
const success = await umkmState.umkm.update.submit(id);
if (success) {
router.push('/admin/ekonomi/umkm/data-umkm');
}
@@ -99,6 +172,21 @@ export default function EditDataUmkm() {
}
};
const handleResetForm = () => {
setFormData({
nama: originalData.nama,
pemilik: originalData.pemilik,
kategoriId: originalData.kategoriId,
deskripsi: originalData.deskripsi,
alamat: originalData.alamat,
kontak: originalData.kontak,
imageId: originalData.imageId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
if (isInitialLoading) {
return (
<Center h={400}>
@@ -108,69 +196,93 @@ export default function EditDataUmkm() {
}
return (
<Box>
<Group mb="lg">
<Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={20} />}
p="xs"
radius="md"
>
Kembali
<IconArrowBack color={colors["blue-button"]} size={24} />
</Button>
<Title order={3}>Edit Data UMKM</Title>
<Title order={4} ml="sm" c="dark">
Edit Data UMKM
</Title>
</Group>
<Paper withBorder p="xl" radius="md" shadow="sm">
<Stack gap="lg">
{/* Logo / Image UMKM */}
{/* Form */}
<Paper
w={{ base: "100%", md: "50%" }}
bg={colors["white-1"]}
p="lg"
radius="md"
shadow="sm"
style={{ border: "1px solid #e0e0e0" }}
>
<Stack gap="md">
{/* Logo / Foto UMKM */}
<Box>
<Text fw={500} size="sm" mb={4}>Logo / Foto UMKM</Text>
{!previewImage ? (
<Dropzone
onDrop={(files) => {
const file = files[0];
setFile(file);
setPreviewImage(URL.createObjectURL(file));
}}
maxSize={3 * 1024 ** 2}
accept={IMAGE_MIME_TYPE}
radius="md"
>
<Group justify="center" gap="xl" mih={120} style={{ pointerEvents: 'none' }}>
<Dropzone.Accept>
<IconUpload size={42} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={42} stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={42} stroke={1.5} />
</Dropzone.Idle>
<Text fw="bold" fz="sm" mb={6}>
Logo / Foto UMKM
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() =>
toast.error("File tidak valid, gunakan format gambar")
}
maxSize={5 * 1024 ** 2}
accept={IMAGE_MIME_TYPE}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={140}>
<Dropzone.Accept>
<IconUpload size={48} color={colors["blue-button"]} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
</Group>
</Dropzone>
<Box>
<Text size="xl" inline>
Klik atau tarik gambar di sini
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
Maksimal 3MB
</Text>
</Box>
</Group>
</Dropzone>
) : (
<Box pos="relative" w="fit-content">
<Image src={previewImage} h={200} radius="md" alt="Preview" />
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Logo"
radius="md"
style={{
maxHeight: 220,
objectFit: "contain",
border: `1px solid ${colors["blue-button"]}`,
}}
loading="lazy"
/>
<ActionIcon
color="red"
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
state.update.form.imageId = "";
setFormData(prev => ({ ...prev, imageId: null }));
}}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconX size={14} />
</ActionIcon>
@@ -183,15 +295,15 @@ export default function EditDataUmkm() {
label="Nama UMKM / Bisnis"
placeholder="Contoh: Warung Sate Bu Komang"
required
value={state.update.form.nama}
onChange={(e) => (state.update.form.nama = e.target.value)}
value={formData.nama}
onChange={(e) => handleChange("nama", e.target.value)}
/>
<TextInput
label="Nama Pemilik"
placeholder="Masukkan nama lengkap pemilik"
required
value={state.update.form.pemilik}
onChange={(e) => (state.update.form.pemilik = e.target.value)}
value={formData.pemilik}
onChange={(e) => handleChange("pemilik", e.target.value)}
/>
</Group>
@@ -200,42 +312,64 @@ export default function EditDataUmkm() {
label="Kategori Bisnis"
placeholder="Pilih kategori"
required
data={umkmState.kategoriProduk.findManyAll.data?.map(v => ({
data={umkmState.kategoriProduk.findManyAll.data?.map((v: any) => ({
value: v.id, label: v.nama
})) || []}
value={state.update.form.kategoriId}
onChange={(val) => (state.update.form.kategoriId = val || "")}
value={formData.kategoriId}
onChange={(val) => handleChange("kategoriId", val || "")}
/>
<TextInput
label="Nomor WA / Kontak"
placeholder="Contoh: 08123456789"
value={state.update.form.kontak}
onChange={(e) => (state.update.form.kontak = e.target.value)}
value={formData.kontak}
onChange={(e) => handleChange("kontak", e.target.value)}
/>
</Group>
<TextInput
label="Alamat Lengkap"
placeholder="Masukkan alamat fisik usaha"
value={state.update.form.alamat}
onChange={(e) => (state.update.form.alamat = e.target.value)}
value={formData.alamat}
onChange={(e) => handleChange("alamat", e.target.value)}
/>
<Box>
<Text fw={500} size="sm" mb={4}>Deskripsi UMKM</Text>
<CreateEditor
value={state.update.form.deskripsi || ""}
onChange={(val) => (state.update.form.deskripsi = val)}
<Text fw="bold" fz="sm" mb={4}>
Deskripsi UMKM
</Text>
<EditEditor
value={formData.deskripsi}
onChange={(htmlContent) =>
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }))
}
/>
</Box>
<Group justify="flex-end" mt="xl">
{/* Action Buttons */}
<Group justify="right" mt="md">
<Button
color="blue"
onClick={handleUpdate}
loading={isSubmitting}
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Simpan Perubahan
Batal
</Button>
<Button
onClick={handleUpdate}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
@@ -243,3 +377,5 @@ export default function EditDataUmkm() {
</Box>
);
}
export default EditDataUmkm;

View File

@@ -42,7 +42,7 @@ export default function CreateDataUmkm() {
deskripsi: "",
alamat: "",
kontak: "",
imageId: "",
imageId: null,
isActive: true,
};
setPreviewImage(null);
@@ -53,7 +53,7 @@ export default function CreateDataUmkm() {
setIsSubmitting(true);
try {
// 1. Upload image first if exists
let uploadedImageId = "";
let uploadedImageId = null;
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({
file,

View File

@@ -0,0 +1,113 @@
'use client';
import {
Box,
Button,
Group,
Paper,
Stack,
TextInput,
Title,
Center,
Loader
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import umkmState from '../../../../../_state/ekonomi/umkm/umkm';
export default function EditKategoriProduk() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const state = useProxy(umkmState.kategoriProduk.findMany);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [nama, setNama] = useState("");
const [originalNama, setOriginalNama] = useState("");
useEffect(() => {
const init = async () => {
// Find the item from the existing list or load it if not available
if (state.data.length === 0) {
await state.load();
}
const item = state.data.find((v: any) => v.id === id);
if (item) {
setNama(item.nama);
setOriginalNama(item.nama);
}
setIsLoading(false);
};
init();
}, [id, state]);
const handleResetForm = () => {
setNama(originalNama);
};
const handleUpdate = async () => {
setIsSubmitting(true);
try {
umkmState.kategoriProduk.update.form.nama = nama;
const success = await umkmState.kategoriProduk.update.submit(id);
if (success) {
router.push('/admin/ekonomi/umkm/kategori-produk');
}
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<Center h={400}>
<Loader size="lg" />
</Center>
);
}
return (
<Box>
<Group mb="lg">
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={20} />}
>
Kembali
</Button>
<Title order={3}>Edit Kategori Produk</Title>
</Group>
<Paper withBorder p="xl" radius="md" shadow="sm" maw={600}>
<Stack gap="lg">
<TextInput
label="Nama Kategori"
placeholder="Contoh: Makanan, Minuman, Kerajinan"
required
value={nama}
onChange={(e) => setNama(e.target.value)}
/>
<Group justify="flex-end" mt="xl">
<Button variant="outline" color="gray" onClick={handleResetForm}>
Reset
</Button>
<Button
color="blue"
onClick={handleUpdate}
loading={isSubmitting}
disabled={!nama.trim()}
>
Simpan Perubahan
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import {
Box,
Button,
Group,
Paper,
Stack,
TextInput,
Title
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import umkmState from '../../../../_state/ekonomi/umkm/umkm';
export default function CreateKategoriProduk() {
const router = useRouter();
const state = useProxy(umkmState.kategoriProduk.create);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleResetForm = () => {
state.form = {
nama: "",
};
};
const handleCreate = async () => {
setIsSubmitting(true);
try {
const success = await umkmState.kategoriProduk.create.submit();
if (success) {
handleResetForm();
router.push('/admin/ekonomi/umkm/kategori-produk');
}
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
}
};
return (
<Box>
<Group mb="lg">
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={20} />}
>
Kembali
</Button>
<Title order={3}>Tambah Kategori Produk Baru</Title>
</Group>
<Paper withBorder p="xl" radius="md" shadow="sm" maw={600}>
<Stack gap="lg">
<TextInput
label="Nama Kategori"
placeholder="Contoh: Makanan, Minuman, Kerajinan"
required
value={state.form.nama}
onChange={(e) => (state.form.nama = e.target.value)}
/>
<Group justify="flex-end" mt="xl">
<Button variant="outline" color="gray" onClick={handleResetForm}>
Reset
</Button>
<Button
color="blue"
onClick={handleCreate}
loading={isSubmitting}
>
Simpan Kategori
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,144 @@
'use client'
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Title,
TextInput,
Badge
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconPlus, IconSearch, IconEdit, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import umkmState from '../../../_state/ekonomi/umkm/umkm';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
function KategoriProdukPage() {
const router = useRouter();
const [search, setSearch] = useState("");
const state = useProxy(umkmState.kategoriProduk.findMany);
const [debouncedSearch] = useDebouncedValue(search, 1000);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
useShallowEffect(() => {
state.load(state.page, 10, debouncedSearch);
}, [state.page, debouncedSearch]);
const handleHapus = async () => {
if (selectedId) {
const success = await umkmState.kategoriProduk.del.submit(selectedId);
if (success) {
setModalHapus(false);
setSelectedId(null);
}
}
};
return (
<Stack gap="lg">
<Group justify="space-between">
<Title order={3}>Kategori Produk</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
onClick={() => router.push('/admin/ekonomi/umkm/kategori-produk/create')}
>
Tambah Kategori
</Button>
</Group>
<Paper withBorder p="md" radius="md">
<TextInput
placeholder="Cari kategori..."
leftSection={<IconSearch size={18} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
mb="md"
/>
{state.loading ? (
<Skeleton height={400} />
) : (
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Nama Kategori</TableTh>
<TableTh>Status</TableTh>
<TableTh>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{state.data.map((item) => (
<TableTr key={item.id}>
<TableTd fw={500}>{item.nama}</TableTd>
<TableTd>
<Badge color={item.isActive ? "green" : "red"}>
{item.isActive ? "Aktif" : "Nonaktif"}
</Badge>
</TableTd>
<TableTd>
<Group gap="xs">
<Button
variant="subtle"
color="blue"
size="xs"
onClick={() => router.push(`/admin/ekonomi/umkm/kategori-produk/${item.id}/edit`)}
>
<IconEdit size={16} />
</Button>
<Button
variant="subtle"
color="red"
size="xs"
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={16} />
</Button>
</Group>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</Box>
)}
<Center mt="md">
<Pagination
total={state.totalPages}
value={state.page}
onChange={(p) => state.load(p, 10, debouncedSearch)}
/>
</Center>
</Paper>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah Anda yakin ingin menghapus kategori ini? Kategori yang dihapus tidak akan muncul di pilihan kategori produk baru."
/>
</Stack>
);
}
export default KategoriProdukPage;

View File

@@ -1,4 +1,5 @@
'use client'
'use client';
import {
Box,
Button,
@@ -14,21 +15,40 @@ import {
TableTh,
TableThead,
TableTr,
Text,
Title
Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconPlus, IconTrash } from '@tabler/icons-react';
import { useProxy } from 'valtio/utils';
import { useState } from 'react';
import umkmState from '../../../_state/ekonomi/umkm/umkm';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
function PenjualanUmkm() {
const state = useProxy(umkmState.penjualan.findMany);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
useShallowEffect(() => {
state.load(state.page, 10);
}, [state.page]);
const handleHapus = async () => {
if (!selectedId) return;
const success = await umkmState.penjualan.del.submit(selectedId);
if (success) {
setModalHapus(false);
setSelectedId(null);
// 🔥 reload data
state.load(state.page, 10);
}
};
return (
<Stack gap="lg">
<Group justify="space-between">
@@ -54,16 +74,30 @@ function PenjualanUmkm() {
<TableTh>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{state.data.map((item) => (
<TableTr key={item.id}>
<TableTd>{new Date(item.tanggal).toLocaleDateString('id-ID')}</TableTd>
<TableTd>
{new Date(item.tanggal).toLocaleDateString('id-ID')}
</TableTd>
<TableTd fw={500}>{item.produk?.nama}</TableTd>
<TableTd>{item.produk?.umkm?.nama}</TableTd>
<TableTd>{item.jumlah}</TableTd>
<TableTd fw={600}>Rp {item.totalNilai.toLocaleString()}</TableTd>
<TableTd fw={600}>
Rp {item.totalNilai.toLocaleString()}
</TableTd>
<TableTd>
<Button variant="subtle" color="red" size="xs">
<Button
variant="subtle"
color="red"
size="xs"
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={16} />
</Button>
</TableTd>
@@ -82,6 +116,14 @@ function PenjualanUmkm() {
/>
</Center>
</Paper>
{/* 🔥 Modal Konfirmasi */}
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah Anda yakin ingin menghapus data penjualan ini?"
/>
</Stack>
);
}

View File

@@ -1,75 +1,147 @@
'use client';
"use client";
import EditEditor from "@/app/admin/(dashboard)/_com/editEditor";
import colors from "@/con/colors";
import ApiFetch from "@/lib/api-fetch";
import {
ActionIcon,
Box,
Button,
Group,
Image,
Paper,
Select,
Stack,
Text,
TextInput,
Title,
Text,
Select,
ActionIcon,
Image,
Loader,
NumberInput,
Center,
Loader
} from '@mantine/core';
import { Dropzone, IMAGE_MIME_TYPE } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter, useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import umkmState from '../../../../../_state/ekonomi/umkm/umkm';
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import ApiFetch from '@/lib/api-fetch';
} from "@mantine/core";
import { Dropzone, IMAGE_MIME_TYPE } from "@mantine/dropzone";
import {
IconArrowBack,
IconPhoto,
IconUpload,
IconX,
} from "@tabler/icons-react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { useProxy } from "valtio/utils";
import umkmState from "../../../../../_state/ekonomi/umkm/umkm";
export default function EditProdukUmkm() {
interface ProdukData {
id: string;
nama: string;
harga: number;
stok: number;
umkmId: string | null;
deskripsi: string | null;
imageId: string | null;
kategoriProdukId: string | null;
image?: { link: string } | null;
}
interface ProdukForm {
nama: string;
harga: number;
stok: number;
umkmId: string;
deskripsi: string;
imageId: string | null;
kategoriId: string;
}
function EditProdukUmkm() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const state = useProxy(umkmState.produk);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [formData, setFormData] = useState<ProdukForm>({
nama: "",
harga: 0,
stok: 0,
umkmId: "",
deskripsi: "",
imageId: null,
kategoriId: "",
});
const [originalData, setOriginalData] = useState<ProdukForm & { imageUrl: string }>({
nama: "",
harga: 0,
stok: 0,
umkmId: "",
deskripsi: "",
imageId: null,
kategoriId: "",
imageUrl: ""
});
const isFormValid = () => {
return (
formData.nama?.trim() !== '' &&
formData.umkmId !== '' &&
formData.kategoriId !== '' &&
formData.harga >= 0 &&
formData.stok >= 0
);
};
useEffect(() => {
const init = async () => {
await Promise.all([
umkmState.umkm.findMany.load(1, 100),
umkmState.umkm.findMany.load(),
umkmState.kategoriProduk.findManyAll.load(),
state.findUnique.load(id)
umkmState.produk.findUnique.load(id)
]);
if (state.findUnique.data) {
const data = state.findUnique.data;
state.update.form = {
const data = umkmState.produk.findUnique.data as ProdukData | null;
if (data) {
const initialForm: ProdukForm = {
nama: data.nama || "",
harga: data.harga || 0,
stok: data.stok || 0,
umkmId: data.umkmId || "",
deskripsi: data.deskripsi || "",
imageId: data.imageId || "",
kategoriId: data.kategoriId || "",
isActive: data.isActive ?? true,
kategoriId: data.kategoriProdukId || "",
};
if (data.image?.url) {
setPreviewImage(data.image.url);
setFormData(initialForm);
setOriginalData({
...initialForm,
imageUrl: data.image?.link || ""
});
if (data.image?.link) {
setPreviewImage(data.image.link);
}
}
setIsInitialLoading(false);
};
init();
}, [id, state.findUnique, state.update]);
}, [id]);
const handleChange = (field: keyof ProdukForm, value: string | number) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleUpdate = async () => {
if (!formData.nama?.trim()) return toast.error("Nama produk wajib diisi");
if (!formData.umkmId) return toast.error("UMKM pemilik wajib dipilih");
if (!formData.kategoriId) return toast.error("Kategori wajib dipilih");
setIsSubmitting(true);
try {
let uploadedImageId = state.update.form.imageId;
let uploadedImageId = formData.imageId;
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({
file,
@@ -85,9 +157,14 @@ export default function EditProdukUmkm() {
}
}
state.update.form.imageId = uploadedImageId;
const success = await state.update.submit(id);
// Update proxy state
umkmState.produk.update.form = {
...umkmState.produk.update.form,
...formData,
imageId: uploadedImageId
};
const success = await umkmState.produk.update.submit(id);
if (success) {
router.push('/admin/ekonomi/umkm/produk');
}
@@ -99,6 +176,21 @@ export default function EditProdukUmkm() {
}
};
const handleResetForm = () => {
setFormData({
nama: originalData.nama,
harga: originalData.harga,
stok: originalData.stok,
umkmId: originalData.umkmId,
deskripsi: originalData.deskripsi,
imageId: originalData.imageId,
kategoriId: originalData.kategoriId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
if (isInitialLoading) {
return (
<Center h={400}>
@@ -108,57 +200,93 @@ export default function EditProdukUmkm() {
}
return (
<Box>
<Group mb="lg">
<Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={20} />}
p="xs"
radius="md"
>
Kembali
<IconArrowBack color={colors["blue-button"]} size={24} />
</Button>
<Title order={3}>Edit Produk UMKM</Title>
<Title order={4} ml="sm" c="dark">
Edit Produk UMKM
</Title>
</Group>
<Paper withBorder p="xl" radius="md" shadow="sm">
<Stack gap="lg">
{/* Form */}
<Paper
w={{ base: "100%", md: "50%" }}
bg={colors["white-1"]}
p="lg"
radius="md"
shadow="sm"
style={{ border: "1px solid #e0e0e0" }}
>
<Stack gap="md">
{/* Foto Produk */}
<Box>
<Text fw={500} size="sm" mb={4}>Foto Produk</Text>
{!previewImage ? (
<Dropzone
onDrop={(files) => {
const file = files[0];
setFile(file);
setPreviewImage(URL.createObjectURL(file));
}}
maxSize={3 * 1024 ** 2}
accept={IMAGE_MIME_TYPE}
radius="md"
>
<Group justify="center" gap="xl" mih={120} style={{ pointerEvents: 'none' }}>
<Dropzone.Idle>
<IconPhoto size={42} stroke={1.5} />
</Dropzone.Idle>
<Box>
<Text size="xl" inline>Pilih gambar produk</Text>
<Text size="sm" c="dimmed" inline mt={7}>Maksimal 3MB</Text>
</Box>
</Group>
</Dropzone>
) : (
<Box pos="relative" w="fit-content">
<Image src={previewImage} h={200} radius="md" alt="Preview" />
<Text fw="bold" fz="sm" mb={6}>
Foto Produk
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() =>
toast.error("File tidak valid, gunakan format gambar")
}
maxSize={5 * 1024 ** 2}
accept={IMAGE_MIME_TYPE}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={140}>
<Dropzone.Accept>
<IconUpload size={48} color={colors["blue-button"]} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Produk"
radius="md"
style={{
maxHeight: 220,
objectFit: "contain",
border: `1px solid ${colors["blue-button"]}`,
}}
loading="lazy"
/>
<ActionIcon
color="red"
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
state.update.form.imageId = "";
setFormData(prev => ({ ...prev, imageId: null }));
}}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconX size={14} />
</ActionIcon>
@@ -166,26 +294,29 @@ export default function EditProdukUmkm() {
)}
</Box>
{/* UMKM Pemilik */}
<Select
label="Pilih UMKM Pemilik"
placeholder="Siapa pemilik produk ini?"
required
searchable
data={umkmState.umkm.findMany.data?.map(v => ({
data={umkmState.umkm.findMany.data?.map((v: any) => ({
value: v.id, label: v.nama
})) || []}
value={state.update.form.umkmId}
onChange={(val) => (state.update.form.umkmId = val || "")}
value={formData.umkmId}
onChange={(val) => handleChange("umkmId", val || "")}
/>
{/* Nama Produk */}
<TextInput
label="Nama Produk"
placeholder="Contoh: Kripik Singkong Pedas"
placeholder="Masukkan nama produk"
value={formData.nama}
onChange={(e) => handleChange("nama", e.target.value)}
required
value={state.update.form.nama}
onChange={(e) => (state.update.form.nama = e.target.value)}
/>
{/* Harga & Stok */}
<Group grow>
<NumberInput
label="Harga Produk (Rp)"
@@ -194,43 +325,75 @@ export default function EditProdukUmkm() {
min={0}
thousandSeparator="."
decimalSeparator=","
value={state.update.form.harga}
onChange={(val) => (state.update.form.harga = Number(val))}
value={formData.harga}
onChange={(val) => handleChange("harga", Number(val))}
/>
<NumberInput
label="Stok"
placeholder="0"
required
min={0}
value={state.update.form.stok}
onChange={(val) => (state.update.form.stok = Number(val))}
value={formData.stok}
onChange={(val) => handleChange("stok", Number(val))}
/>
</Group>
{/* Kategori */}
<Select
label="Kategori Produk"
placeholder="Pilih kategori produk"
required
data={umkmState.kategoriProduk.findManyAll.data?.map(v => ({
data={umkmState.kategoriProduk.findManyAll.data?.map((v: any) => ({
value: v.id, label: v.nama
})) || []}
value={state.update.form.kategoriId}
onChange={(val) => (state.update.form.kategoriId = val || "")}
value={formData.kategoriId}
onChange={(val) => handleChange("kategoriId", val || "")}
/>
{/* Deskripsi */}
<Box>
<Text fw={500} size="sm" mb={4}>Deskripsi Produk</Text>
<CreateEditor
value={state.update.form.deskripsi || ""}
onChange={(val) => (state.update.form.deskripsi = val)}
<Text fz="sm" fw="bold" mb={4}>
Deskripsi Produk
</Text>
<EditEditor
value={formData.deskripsi}
onChange={(htmlContent) =>
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }))
}
/>
</Box>
<Group justify="flex-end" mt="xl">
<Button color="blue" onClick={handleUpdate} loading={isSubmitting}>Simpan Perubahan</Button>
{/* Action Buttons */}
<Group justify="right" mt="md">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
<Button
onClick={handleUpdate}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default EditProdukUmkm;

View File

@@ -14,7 +14,7 @@ import {
NumberInput
} from '@mantine/core';
import { Dropzone, IMAGE_MIME_TYPE } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { IconArrowBack, IconPhoto, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
@@ -43,7 +43,7 @@ export default function CreateProdukUmkm() {
stok: 0,
umkmId: "",
deskripsi: "",
imageId: "",
imageId: null,
kategoriId: "",
isActive: true,
};
@@ -54,7 +54,7 @@ export default function CreateProdukUmkm() {
const handleCreate = async () => {
setIsSubmitting(true);
try {
let uploadedImageId = "";
let uploadedImageId = null;
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({
file,

View File

@@ -118,6 +118,11 @@ export const devBar = [
id: "Desa_7",
name: "Penghargaan",
path: "/admin/desa/penghargaan"
},
{
id: "Desa_8",
name: "Kegiatan Desa",
path: "/admin/desa/kegiatan-desa/list-kegiatan-desa"
}
]
@@ -549,6 +554,11 @@ export const navBar = [
id: "Desa_7",
name: "Penghargaan",
path: "/admin/desa/penghargaan"
},
{
id: "Desa_8",
name: "Kegiatan Desa",
path: "/admin/desa/kegiatan-desa/list-kegiatan-desa"
}
]
@@ -995,6 +1005,11 @@ export const role1 = [
id: "Desa_7",
name: "Penghargaan",
path: "/admin/desa/penghargaan"
},
{
id: "Desa_8",
name: "Kegiatan Desa",
path: "/admin/desa/kegiatan-desa/list-kegiatan-desa"
}
]

View File

@@ -13,6 +13,8 @@ import KategoriPengumuman from "./pengumuman/kategori-pengumuman";
import MantanPerbekel from "./profile/profile-mantan-perbekel";
import AjukanPermohonan from "./layanan/ajukan_permohonan";
import Musik from "./musik";
import KegiatanDesa from "./kegiatan-desa";
import KategoriKegiatan from "./kegiatan-desa/kategori-kegiatan";
const Desa = new Elysia({ prefix: "/desa", tags: ["Desa"] })
@@ -30,6 +32,8 @@ const Desa = new Elysia({ prefix: "/desa", tags: ["Desa"] })
.use(KategoriPengumuman)
.use(AjukanPermohonan)
.use(Musik)
.use(KegiatanDesa)
.use(KategoriKegiatan)
export default Desa;

View File

@@ -0,0 +1,30 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
async function kegiatanDesaCreate(context: Context) {
const body = context.body as any;
try {
const data = await prisma.kegiatanDesa.create({
data: {
judul: body.judul,
deskripsiSingkat: body.deskripsiSingkat,
deskripsiLengkap: body.deskripsiLengkap,
tanggal: new Date(body.tanggal),
lokasi: body.lokasi,
partisipan: Number(body.partisipan) || 0,
kategoriKegiatanId: body.kategoriKegiatanId,
imageId: body.imageId || null,
},
include: { kategoriKegiatan: true, image: true },
});
return { success: true, message: "Kegiatan desa berhasil dibuat", data };
} catch (e) {
console.error("Error di kegiatanDesaCreate:", e);
return { success: false, message: "Gagal membuat kegiatan desa" };
}
}
export default kegiatanDesaCreate;

View File

@@ -0,0 +1,19 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
async function kegiatanDesaDelete(context: Context) {
const { id } = context.params as { id: string };
try {
await prisma.kegiatanDesa.update({
where: { id },
data: { isActive: false },
});
return { success: true, message: "Kegiatan desa berhasil dihapus" };
} catch (e) {
console.error("Error di kegiatanDesaDelete:", e);
return { success: false, message: "Gagal menghapus kegiatan desa" };
}
}
export default kegiatanDesaDelete;

View File

@@ -0,0 +1,57 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
async function kegiatanDesaFindMany(context: Context) {
const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 10;
const search = (context.query.search as string) || '';
const kategori = (context.query.kategori as string) || '';
const skip = (page - 1) * limit;
const where: any = { isActive: true };
if (search) {
where.OR = [
{ judul: { contains: search, mode: 'insensitive' } },
{ lokasi: { contains: search, mode: 'insensitive' } },
];
}
if (kategori) {
where.kategoriKegiatan = {
nama: { contains: kategori, mode: 'insensitive' },
};
}
try {
const [data, total] = await Promise.all([
prisma.kegiatanDesa.findMany({
where,
include: {
kategoriKegiatan: true,
image: true,
},
skip,
take: limit,
orderBy: { tanggal: 'asc' },
}),
prisma.kegiatanDesa.count({ where }),
]);
return {
success: true,
message: "Berhasil ambil kegiatan desa",
data,
page,
limit,
total,
totalPages: Math.ceil(total / limit),
};
} catch (e) {
console.error("Error di kegiatanDesaFindMany:", e);
return { success: false, message: "Gagal mengambil data kegiatan desa" };
}
}
export default kegiatanDesaFindMany;

View File

@@ -0,0 +1,35 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
async function kegiatanDesaFindUnique(context: Context) {
const { id } = context.params as { id: string };
if (!id) {
return { success: false, message: "ID is required" };
}
try {
const data = await prisma.kegiatanDesa.findUnique({
where: { id },
include: {
kategoriKegiatan: true,
image: true,
},
});
if (!data) {
return { success: false, message: "Data tidak ditemukan" };
}
return {
success: true,
message: "Berhasil ambil kegiatan desa",
data,
};
} catch (e) {
console.error("Error di kegiatanDesaFindUnique:", e);
return { success: false, message: "Gagal mengambil data kegiatan desa" };
}
}
export default kegiatanDesaFindUnique;

View File

@@ -0,0 +1,37 @@
import Elysia, { t } from "elysia";
import kegiatanDesaFindMany from "./find-many";
import kegiatanDesaFindUnique from "./findUnique";
import kegiatanDesaCreate from "./create";
import kegiatanDesaDelete from "./del";
import kegiatanDesaUpdate from "./updt";
const KegiatanDesa = new Elysia({ prefix: "/kegiatandesa", tags: ["Desa/Kegiatan Desa"] })
.get("/find-many", kegiatanDesaFindMany)
.get("/:id", kegiatanDesaFindUnique)
.post("/create", kegiatanDesaCreate, {
body: t.Object({
judul: t.String(),
deskripsiSingkat: t.String(),
deskripsiLengkap: t.String(),
tanggal: t.String(),
lokasi: t.String(),
partisipan: t.Optional(t.Number()),
kategoriKegiatanId: t.String(),
imageId: t.Optional(t.String()),
}),
})
.put("/:id", kegiatanDesaUpdate, {
body: t.Object({
judul: t.String(),
deskripsiSingkat: t.String(),
deskripsiLengkap: t.String(),
tanggal: t.String(),
lokasi: t.String(),
partisipan: t.Optional(t.Number()),
kategoriKegiatanId: t.String(),
imageId: t.Optional(t.String()),
}),
})
.delete("/del/:id", kegiatanDesaDelete);
export default KegiatanDesa;

View File

@@ -0,0 +1,26 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
type FormCreate = {
nama: string;
}
export default async function kategoriKegiatanCreate(context: Context) {
const body = (await context.body) as FormCreate;
try {
const result = await prisma.kategoriKegiatan.create({
data: {
nama: body.nama,
},
});
return {
success: true,
message: "Berhasil membuat kategori kegiatan",
data: result,
};
} catch (error) {
console.error("Error creating kategori kegiatan:", error);
throw new Error("Gagal membuat kategori kegiatan: " + (error as Error).message);
}
}

View File

@@ -0,0 +1,50 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function kategoriKegiatanDelete(context: Context) {
try {
const id = context.params?.id as string;
if (!id) {
return Response.json({
success: false,
message: "ID tidak boleh kosong",
}, { status: 400 });
}
// ✅ Cek apakah kategori masih digunakan oleh kegiatan
const kegiatanCount = await prisma.kegiatanDesa.count({
where: {
kategoriKegiatanId: id,
isActive: true,
},
});
if (kegiatanCount > 0) {
return Response.json({
success: false,
message: `Kategori tidak dapat dihapus karena masih digunakan oleh ${kegiatanCount} kegiatan`,
}, { status: 400 });
}
// ✅ Soft delete (bukan hard delete)
await prisma.kategoriKegiatan.update({
where: { id },
data: {
deletedAt: new Date(),
isActive: false,
},
});
return {
success: true,
message: "Kategori kegiatan berhasil dihapus",
};
} catch (error) {
console.error("Delete kategori error:", error);
return Response.json({
success: false,
message: "Gagal menghapus kategori: " + (error instanceof Error ? error.message : 'Unknown error'),
}, { status: 500 });
}
}

View File

@@ -0,0 +1,52 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
async function kategoriKegiatanFindMany(context: Context) {
// Ambil parameter dari query
const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 10;
const search = (context.query.search as string) || '';
const skip = (page - 1) * limit;
// Buat where clause
const where: any = { isActive: true };
// Tambahkan pencarian (jika ada)
if (search) {
where.OR = [
{ nama: { contains: search, mode: 'insensitive' } },
];
}
try {
// Ambil data dan total count secara paralel
const [data, total] = await Promise.all([
prisma.kategoriKegiatan.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'asc' },
}),
prisma.kategoriKegiatan.count({ where }),
]);
return {
success: true,
message: "Berhasil ambil kategori kegiatan dengan pagination",
data,
page,
limit,
total,
totalPages: Math.ceil(total / limit),
};
} catch (e) {
console.error("Error di findMany paginated:", e);
return {
success: false,
message: "Gagal mengambil data kategori kegiatan",
};
}
}
export default kategoriKegiatanFindMany;

View File

@@ -0,0 +1,46 @@
import prisma from "@/lib/prisma";
export default async function kategoriKegiatanFindUnique(request: Request) {
const url = new URL(request.url);
const pathSegments = url.pathname.split('/');
const id = pathSegments[pathSegments.length - 1];
if (!id) {
return {
success: false,
message: "ID is required",
}
}
try {
if (typeof id !== 'string') {
return {
success: false,
message: "ID is required",
}
}
const data = await prisma.kategoriKegiatan.findUnique({
where: { id },
});
if (!data) {
return {
success: false,
message: "Data not found",
}
}
return {
success: true,
message: "Success get kategori kegiatan",
data,
}
} catch (error) {
console.error("Find by ID error:", error);
return {
success: false,
message: "Gagal mengambil data: " + (error instanceof Error ? error.message : 'Unknown error'),
}
}
}

View File

@@ -0,0 +1,33 @@
import Elysia, { t } from "elysia";
import kategoriKegiatanCreate from "./create";
import kategoriKegiatanDelete from "./del";
import kategoriKegiatanFindMany from "./findMany";
import kategoriKegiatanFindUnique from "./findUnique";
import kategoriKegiatanUpdate from "./updt";
const KategoriKegiatan = new Elysia({
prefix: "/kategorikegiatan",
tags: ["Desa / Kegiatan Desa / Kategori Kegiatan"],
})
.post("/create", kategoriKegiatanCreate, {
body: t.Object({
nama: t.String(),
}),
})
.get("/findMany", kategoriKegiatanFindMany)
.get("/:id", async (context) => {
const response = await kategoriKegiatanFindUnique(
new Request(context.request)
);
return response;
})
.put("/:id", kategoriKegiatanUpdate, {
body: t.Object({
nama: t.String(),
}),
})
.delete("/del/:id", kategoriKegiatanDelete);
export default KategoriKegiatan;

View File

@@ -0,0 +1,28 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
type FormUpdate = {
nama: string;
}
export default async function kategoriKegiatanUpdate(context: Context) {
const body = (await context.body) as FormUpdate;
const id = context.params.id as string;
try {
const result = await prisma.kategoriKegiatan.update({
where: { id },
data: {
nama: body.nama,
},
});
return {
success: true,
message: "Berhasil mengupdate kategori kegiatan",
data: result,
};
} catch (error) {
console.error("Error updating kategori kegiatan:", error);
throw new Error("Gagal mengupdate kategori kegiatan: " + (error as Error).message);
}
}

View File

@@ -0,0 +1,32 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
async function kegiatanDesaUpdate(context: Context) {
const { id } = context.params as { id: string };
const body = context.body as any;
try {
const data = await prisma.kegiatanDesa.update({
where: { id },
data: {
judul: body.judul,
deskripsiSingkat: body.deskripsiSingkat,
deskripsiLengkap: body.deskripsiLengkap,
tanggal: new Date(body.tanggal),
lokasi: body.lokasi,
partisipan: Number(body.partisipan) || 0,
kategoriKegiatanId: body.kategoriKegiatanId,
imageId: body.imageId || null,
},
include: { kategoriKegiatan: true, image: true },
});
return { success: true, message: "Kegiatan desa berhasil diupdate", data };
} catch (e) {
console.error("Error di kegiatanDesaUpdate:", e);
return { success: false, message: "Gagal mengupdate kegiatan desa" };
}
}
export default kegiatanDesaUpdate;

View File

@@ -9,6 +9,7 @@ import DemografiPekerjaan from "./demografi-pekerjaan";
import JumlahPengangguran from "./jumlah-pengangguran";
import PendapatanAsliDesa from "./pendapatan-asli-desa";
import StrukturOrganisasi from "./struktur-bumdes";
import KategoriProduk from "./umkm/kategori-produk/kategori-produk";
import Umkm from "./umkm";
import ProdukUmkm from "./umkm/produk";
import PenjualanProduk from "./umkm/penjualan";
@@ -21,6 +22,7 @@ const Ekonomi = new Elysia({
.use(LowonganKerja)
.use(ProgramKemiskinan)
.use(StrukturOrganisasi)
.use(KategoriProduk)
.use(Umkm)
.use(ProdukUmkm)
.use(PenjualanProduk)

View File

@@ -1,49 +1,86 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
function getWeekRange(offsetWeeks = 0) {
const now = new Date();
const day = now.getDay();
const diffToMonday = day === 0 ? -6 : 1 - day;
const monday = new Date(now);
monday.setDate(now.getDate() + diffToMonday + offsetWeeks * 7);
monday.setHours(0, 0, 0, 0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
sunday.setHours(23, 59, 59, 999);
return { start: monday, end: sunday };
}
async function umkmDashboardDetailPenjualan(context: Context) {
const periode = (context.query.periode as string) ||
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
const mode = context.query.mode as string | undefined;
const isWeek = mode === "week";
const periode =
(context.query.periode as string) ||
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`;
const date = new Date(periode + "-01");
date.setMonth(date.getMonth() - 1);
const periodeLalu = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
const periodeLalu = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
const kategoriId = context.query.kategoriId as string | undefined;
const umkmId = context.query.umkmId as string | undefined;
const whereSkrg = isWeek
? { createdAt: { gte: getWeekRange(0).start, lte: getWeekRange(0).end }, deletedAt: null }
: { periode, deletedAt: null };
const whereLalu = isWeek
? { createdAt: { gte: getWeekRange(-1).start, lte: getWeekRange(-1).end }, deletedAt: null }
: { periode: periodeLalu, deletedAt: null };
// Filter produk berdasarkan kategori dan/atau UMKM
const produkFilter: Record<string, unknown> = { deletedAt: null };
if (kategoriId) produkFilter.kategoriProdukId = kategoriId;
if (umkmId) produkFilter.umkmId = umkmId;
try {
// Ambil semua produk yang punya penjualan bulan ini atau bulan lalu
const [produkSkrg, produkLalu, allProduks] = await Promise.all([
prisma.penjualanProduk.groupBy({
by: ['produkId'],
where: { periode, deletedAt: null },
_sum: { totalNilai: true, jumlah: true }
by: ["produkId"],
where: whereSkrg,
_sum: { totalNilai: true, jumlah: true },
}),
prisma.penjualanProduk.groupBy({
by: ['produkId'],
where: { periode: periodeLalu, deletedAt: null },
_sum: { totalNilai: true }
by: ["produkId"],
where: whereLalu,
_sum: { totalNilai: true },
}),
// Use PasarDesa
prisma.pasarDesa.findMany({
where: { deletedAt: null },
select: { id: true, nama: true, stok: true }
})
where: produkFilter,
select: { id: true, nama: true, stok: true },
}),
]);
const data = allProduks.map(p => {
const skrgRaw = produkSkrg.find(s => s.produkId === p.id)?._sum || { totalNilai: 0, jumlah: 0 };
const laluRaw = produkLalu.find(l => l.produkId === p.id)?._sum || { totalNilai: 0 };
const data = allProduks.map((p) => {
const skrgRaw = produkSkrg.find((s) => s.produkId === p.id)?._sum || {};
const laluRaw = produkLalu.find((l) => l.produkId === p.id)?._sum || {};
const skrg = {
totalNilai: (skrgRaw as any).totalNilai || 0,
jumlah: (skrgRaw as any).jumlah || 0
};
const lalu = {
totalNilai: (laluRaw as any).totalNilai || 0
};
const nilaiSkrg = (skrgRaw as any).totalNilai || 0;
const nilaiLalu = (laluRaw as any).totalNilai || 0;
const jumlah = (skrgRaw as any).jumlah || 0;
let trend = "stable";
if (skrg.totalNilai > lalu.totalNilai) trend = "up";
if (skrg.totalNilai < lalu.totalNilai) trend = "down";
if (nilaiSkrg > nilaiLalu) trend = "up";
if (nilaiSkrg < nilaiLalu) trend = "down";
let trendPersen = 0;
if (nilaiLalu > 0) {
trendPersen = Math.round(((nilaiSkrg - nilaiLalu) / nilaiLalu) * 10000) / 100;
} else if (nilaiSkrg > 0) {
trendPersen = 100;
}
let statusStok = "Aman";
if (p.stok < 5) statusStok = "Rendah";
@@ -51,19 +88,17 @@ async function umkmDashboardDetailPenjualan(context: Context) {
return {
namaProduk: p.nama,
penjualanBulanIni: skrg.totalNilai,
penjualanBulanLalu: lalu.totalNilai,
penjualanBulanIni: nilaiSkrg,
penjualanBulanLalu: nilaiLalu,
trend,
volume: skrg.jumlah,
trendPersen,
volume: jumlah,
stok: p.stok,
statusStok
statusStok,
};
});
return {
success: true,
data
};
return { success: true, data };
} catch (e) {
console.error("Error di umkmDashboardDetailPenjualan:", e);
return { success: false, message: "Gagal mengambil detail penjualan dashboard" };

View File

@@ -1,44 +1,117 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
function getWeekRange(offsetWeeks = 0) {
const now = new Date();
const day = now.getDay();
const diffToMonday = day === 0 ? -6 : 1 - day;
const monday = new Date(now);
monday.setDate(now.getDate() + diffToMonday + offsetWeeks * 7);
monday.setHours(0, 0, 0, 0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
sunday.setHours(23, 59, 59, 999);
return { start: monday, end: sunday };
}
async function umkmDashboardKpi(context: Context) {
const periode = (context.query.periode as string) ||
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
const mode = context.query.mode as string | undefined;
const isWeek = mode === "week";
const periode =
(context.query.periode as string) ||
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`;
const whereClause = isWeek
? { createdAt: { gte: getWeekRange(0).start, lte: getWeekRange(0).end }, deletedAt: null }
: { periode, deletedAt: null };
try {
const [umkmAktif, totalUmkm, omzetBulanan, kategoriTerbanyak] = await Promise.all([
const [umkmAktif, totalUmkm, omzetBulanan] = await Promise.all([
prisma.umkm.count({ where: { isActive: true, deletedAt: null } }),
prisma.umkm.count({ where: { deletedAt: null } }),
prisma.penjualanProduk.aggregate({
where: { periode, deletedAt: null },
_sum: { totalNilai: true }
where: whereClause,
_sum: { totalNilai: true },
}),
prisma.umkm.groupBy({
by: ['kategoriId'],
_count: { _all: true },
orderBy: { _count: { kategoriId: 'desc' } },
take: 1
})
]);
// Ambil nama kategori jika ada
// Cari kategori dengan penjualan terbanyak
const salesByCategory = await prisma.penjualanProduk.findMany({
where: whereClause,
select: {
jumlah: true,
produk: { select: { kategoriProdukId: true } },
},
});
let kategoriNama = "-";
if (kategoriTerbanyak.length > 0) {
const kat = await prisma.kategoriProduk.findUnique({
where: { id: kategoriTerbanyak[0].kategoriId },
select: { nama: true }
});
kategoriNama = kat?.nama || "-";
let topCategoryId: string | null = null;
if (salesByCategory.length > 0) {
const categoryCounts: Record<string, number> = {};
for (const sale of salesByCategory) {
const catId = sale.produk.kategoriProdukId;
if (!catId) continue;
categoryCounts[catId] = (categoryCounts[catId] || 0) + sale.jumlah;
}
let maxSales = 0;
for (const [id, count] of Object.entries(categoryCounts)) {
if (count > maxSales) {
maxSales = count;
topCategoryId = id;
}
}
if (topCategoryId) {
const kategori = await prisma.kategoriProdukUmkm.findUnique({
where: { id: topCategoryId },
select: { nama: true },
});
kategoriNama = kategori?.nama || "-";
}
}
// Fallback: kategori dari UMKM terbanyak
if (kategoriNama === "-") {
const kategoriTerbanyakUmkm = await prisma.umkm.groupBy({
by: ["kategoriId"],
_count: { _all: true },
orderBy: { _count: { kategoriId: "desc" } },
take: 1,
});
if (kategoriTerbanyakUmkm.length > 0) {
topCategoryId = kategoriTerbanyakUmkm[0].kategoriId;
const kategori = await prisma.kategoriProdukUmkm.findUnique({
where: { id: topCategoryId },
select: { nama: true },
});
kategoriNama = kategori?.nama || "-";
}
}
// Hitung jumlah produk dalam kategori terbanyak
const jumlahKategoriTerbanyak = topCategoryId
? await prisma.pasarDesa.count({
where: { kategoriProdukId: topCategoryId, deletedAt: null },
})
: 0;
return {
success: true,
data: {
umkmAktif,
totalUmkm,
omzetBulanan: omzetBulanan._sum.totalNilai || 0,
kategoriTerbanyak: kategoriNama
}
kategoriTerbanyak: kategoriNama,
jumlahKategoriTerbanyak,
},
};
} catch (e) {
console.error("Error di umkmDashboardKpi:", e);

View File

@@ -1,30 +1,58 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
function getWeekRange(offsetWeeks = 0) {
const now = new Date();
const day = now.getDay();
const diffToMonday = day === 0 ? -6 : 1 - day;
const monday = new Date(now);
monday.setDate(now.getDate() + diffToMonday + offsetWeeks * 7);
monday.setHours(0, 0, 0, 0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
sunday.setHours(23, 59, 59, 999);
return { start: monday, end: sunday };
}
async function umkmDashboardRingSummary(context: Context) {
const periode = (context.query.periode as string) ||
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
const mode = context.query.mode as string | undefined;
const isWeek = mode === "week";
const periode =
(context.query.periode as string) ||
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`;
// Hitung periode bulan lalu
const date = new Date(periode + "-01");
date.setMonth(date.getMonth() - 1);
const periodeLalu = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
const periodeLalu = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
const whereSkrg = isWeek
? { createdAt: { gte: getWeekRange(0).start, lte: getWeekRange(0).end }, deletedAt: null }
: { periode, deletedAt: null };
const whereLalu = isWeek
? { createdAt: { gte: getWeekRange(-1).start, lte: getWeekRange(-1).end }, deletedAt: null }
: { periode: periodeLalu, deletedAt: null };
try {
const [penjualanSkrg, penjualanLalu, produkAktif, totalTransaksi] = await Promise.all([
const [penjualanSkrg, penjualanLalu, kategoriAktif, totalTransaksi] = await Promise.all([
prisma.penjualanProduk.aggregate({
where: { periode, deletedAt: null },
_sum: { totalNilai: true }
where: whereSkrg,
_sum: { totalNilai: true },
}),
prisma.penjualanProduk.aggregate({
where: { periode: periodeLalu, deletedAt: null },
_sum: { totalNilai: true }
where: whereLalu,
_sum: { totalNilai: true },
}),
// Count from PasarDesa
prisma.pasarDesa.count({
where: { isActive: true, deletedAt: null }
// Hitung jumlah kategori aktif
prisma.kategoriProdukUmkm.count({
where: { isActive: true, deletedAt: null },
}),
prisma.penjualanProduk.count({ where: { periode, deletedAt: null } })
prisma.penjualanProduk.count({ where: whereSkrg }),
]);
const skrg = penjualanSkrg._sum.totalNilai || 0;
@@ -42,9 +70,9 @@ async function umkmDashboardRingSummary(context: Context) {
data: {
totalPenjualan: skrg,
persentasePerubahan: Math.round(persentasePerubahan * 100) / 100,
produkAktif,
totalTransaksi
}
kategoriAktif,
totalTransaksi,
},
};
} catch (e) {
console.error("Error di umkmDashboardRingSummary:", e);

View File

@@ -1,39 +1,91 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
function getWeekRange(offsetWeeks = 0) {
const now = new Date();
const day = now.getDay();
const diffToMonday = day === 0 ? -6 : 1 - day;
const monday = new Date(now);
monday.setDate(now.getDate() + diffToMonday + offsetWeeks * 7);
monday.setHours(0, 0, 0, 0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
sunday.setHours(23, 59, 59, 999);
return { start: monday, end: sunday };
}
async function umkmDashboardTopProduk(context: Context) {
const periode = (context.query.periode as string) ||
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
const mode = context.query.mode as string | undefined;
const isWeek = mode === "week";
const periode =
(context.query.periode as string) ||
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`;
const date = new Date(periode + "-01");
date.setMonth(date.getMonth() - 1);
const periodeLalu = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
const whereSkrg = isWeek
? { createdAt: { gte: getWeekRange(0).start, lte: getWeekRange(0).end }, deletedAt: null }
: { periode, deletedAt: null };
const whereLalu = isWeek
? { createdAt: { gte: getWeekRange(-1).start, lte: getWeekRange(-1).end }, deletedAt: null }
: { periode: periodeLalu, deletedAt: null };
try {
const topPenjualan = await prisma.penjualanProduk.groupBy({
by: ['produkId'],
where: { periode, deletedAt: null },
by: ["produkId"],
where: whereSkrg,
_sum: { totalNilai: true, jumlah: true },
orderBy: { _sum: { totalNilai: 'desc' } },
take: 3
orderBy: { _sum: { totalNilai: "desc" } },
take: 3,
});
const data = await Promise.all(topPenjualan.map(async (item) => {
// Find from PasarDesa now
const produk = await prisma.pasarDesa.findUnique({
where: { id: item.produkId },
include: { umkm: true }
});
// Ambil penjualan periode lalu untuk produk yang sama
const produkIds = topPenjualan.map((p) => p.produkId);
const penjualanLalu = await prisma.penjualanProduk.groupBy({
by: ["produkId"],
where: { ...whereLalu, produkId: { in: produkIds } },
_sum: { totalNilai: true },
});
return {
namaProduk: produk?.nama || "Unknown",
namaUmkm: produk?.umkm?.nama || "Unknown",
totalPenjualan: item._sum.totalNilai || 0,
jumlahTerjual: item._sum.jumlah || 0,
growth: 0
};
}));
const laluMap = new Map(
penjualanLalu.map((p) => [p.produkId, p._sum.totalNilai || 0])
);
return {
success: true,
data
};
const data = await Promise.all(
topPenjualan.map(async (item) => {
const produk = await prisma.pasarDesa.findUnique({
where: { id: item.produkId },
include: { umkm: true },
});
const totalSkrg = item._sum.totalNilai || 0;
const totalLalu = laluMap.get(item.produkId) || 0;
let growth = 0;
if (totalLalu > 0) {
growth = Math.round(((totalSkrg - totalLalu) / totalLalu) * 10000) / 100;
} else if (totalSkrg > 0) {
growth = 100;
}
return {
namaProduk: produk?.nama || "Unknown",
namaUmkm: produk?.umkm?.nama || "Unknown",
totalPenjualan: totalSkrg,
jumlahTerjual: item._sum.jumlah || 0,
growth,
};
})
);
return { success: true, data };
} catch (e) {
console.error("Error di umkmDashboardTopProduk:", e);
return { success: false, message: "Gagal mengambil top produk" };

View File

@@ -0,0 +1,158 @@
import prisma from "@/lib/prisma";
import Elysia, { t } from "elysia";
const KategoriProduk = new Elysia({
prefix: "/kategoriproduk",
})
.get("/find-many-all", async () => {
try {
const data = await prisma.kategoriProdukUmkm.findMany({
where: {
isActive: true,
deletedAt: null,
},
orderBy: { nama: 'asc' },
});
return {
success: true,
message: "Berhasil mengambil semua kategori produk",
data,
};
} catch (e) {
console.error("Error di KategoriProduk find-many-all:", e);
return {
success: false,
message: "Gagal mengambil data kategori produk",
};
}
})
.get("/find-many", async ({ query }) => {
try {
const { page = 1, limit = 10, search = "" } = query;
const skip = (Number(page) - 1) * Number(limit);
const take = Number(limit);
const where = {
isActive: true,
deletedAt: null,
nama: { contains: search, mode: 'insensitive' as const },
};
const [data, total] = await Promise.all([
prisma.kategoriProdukUmkm.findMany({
where,
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.kategoriProdukUmkm.count({ where }),
]);
return {
success: true,
message: "Berhasil mengambil data kategori produk",
data,
total,
page: Number(page),
limit: Number(limit),
totalPages: Math.ceil(total / take),
};
} catch (e) {
console.error("Error di KategoriProduk find-many:", e);
return {
success: false,
message: "Gagal mengambil data kategori produk",
};
}
}, {
query: t.Object({
page: t.Optional(t.String()),
limit: t.Optional(t.String()),
search: t.Optional(t.String()),
})
})
.post("/create", async ({ body }) => {
try {
const data = await prisma.kategoriProdukUmkm.create({
data: {
nama: body.nama,
isActive: true,
},
});
return {
success: true,
message: "Berhasil membuat kategori produk",
data,
};
} catch (e) {
console.error("Error di KategoriProduk create:", e);
return {
success: false,
message: "Gagal membuat kategori produk",
};
}
}, {
body: t.Object({
nama: t.String(),
})
})
.put("/:id", async ({ params, body }) => {
try {
const data = await prisma.kategoriProdukUmkm.update({
where: { id: params.id },
data: {
nama: body.nama,
},
});
return {
success: true,
message: "Berhasil memperbarui kategori produk",
data,
};
} catch (e) {
console.error("Error di KategoriProduk update:", e);
return {
success: false,
message: "Gagal memperbarui kategori produk",
};
}
}, {
params: t.Object({
id: t.String(),
}),
body: t.Object({
nama: t.String(),
})
})
.delete("/del/:id", async ({ params }) => {
try {
const data = await prisma.kategoriProdukUmkm.update({
where: { id: params.id },
data: {
isActive: false,
deletedAt: new Date(),
},
});
return {
success: true,
message: "Berhasil menghapus kategori produk",
data,
};
} catch (e) {
console.error("Error di KategoriProduk delete:", e);
return {
success: false,
message: "Gagal menghapus kategori produk",
};
}
}, {
params: t.Object({
id: t.String(),
})
});
export default KategoriProduk;

View File

@@ -13,7 +13,21 @@ async function penjualanProdukCreate(context: Context) {
try {
// Gunakan transaction untuk update stok produk (PasarDesa)
const result = await prisma.$transaction(async (tx) => {
// 1. Catat penjualan (relasi ke PasarDesa)
// 1. Validasi stok produk
const produk = await tx.pasarDesa.findUnique({
where: { id: body.produkId },
select: { stok: true }
});
if (!produk) {
throw new Error("Produk tidak ditemukan");
}
if (produk.stok < body.jumlah) {
throw new Error(`Stok tidak mencukupi. Tersedia: ${produk.stok}`);
}
// 2. Catat penjualan (relasi ke PasarDesa)
const penjualan = await tx.penjualanProduk.create({
data: {
produkId: body.produkId,
@@ -26,7 +40,7 @@ async function penjualanProdukCreate(context: Context) {
},
});
// 2. Update stok di model PasarDesa
// 3. Update stok di model PasarDesa
await tx.pasarDesa.update({
where: { id: body.produkId },
data: {

View File

@@ -21,6 +21,7 @@ import Kematian from "./data_kesehatan_warga/persentase_kelahiran_kematian/kemat
import DokterTenagaMedis from "./data_kesehatan_warga/fasilitas_kesehatan/dokter-tenaga-medis";
import PendaftaranJadwalKegiatan from "./data_kesehatan_warga/jadwal_kegiatan/pendaftaran";
import TarifLayanan from "./data_kesehatan_warga/fasilitas_kesehatan/tarif-layanan";
import RingkasanKesehatan from "./ringkasan-kesehatan";
const Kesehatan = new Elysia({
@@ -49,4 +50,5 @@ const Kesehatan = new Elysia({
.use(DokterTenagaMedis)
.use(TarifLayanan)
.use(PendaftaranJadwalKegiatan)
.use(RingkasanKesehatan)
export default Kesehatan;

View File

@@ -0,0 +1,17 @@
import prisma from "@/lib/prisma";
async function ringkasanKesehatanFindUnique() {
try {
const data = await prisma.ringkasanKesehatanDesa.findFirst({
where: { isActive: true },
orderBy: { createdAt: 'desc' },
});
return { success: true, data };
} catch (e) {
console.error("Error di ringkasanKesehatanFindUnique:", e);
return { success: false, message: "Gagal mengambil ringkasan kesehatan" };
}
}
export default ringkasanKesehatanFindUnique;

View File

@@ -0,0 +1,15 @@
import Elysia, { t } from "elysia";
import ringkasanKesehatanFindUnique from "./findUnique";
import ringkasanKesehatanUpdate from "./updt";
const RingkasanKesehatan = new Elysia({ prefix: "/ringkasankesehatan", tags: ["Kesehatan/Ringkasan"] })
.get("/find", ringkasanKesehatanFindUnique)
.put("/update", ringkasanKesehatanUpdate, {
body: t.Object({
ibuHamilAkh: t.Number(),
balitaTerdaftar: t.Number(),
alertStunting: t.Number(),
}),
});
export default RingkasanKesehatan;

View File

@@ -0,0 +1,38 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
async function ringkasanKesehatanUpdate(context: Context) {
const body = context.body as any;
try {
const existing = await prisma.ringkasanKesehatanDesa.findFirst({
where: { isActive: true },
orderBy: { createdAt: 'desc' },
});
const data = existing
? await prisma.ringkasanKesehatanDesa.update({
where: { id: existing.id },
data: {
ibuHamilAkh: Number(body.ibuHamilAkh),
balitaTerdaftar: Number(body.balitaTerdaftar),
alertStunting: Number(body.alertStunting),
},
})
: await prisma.ringkasanKesehatanDesa.create({
data: {
ibuHamilAkh: Number(body.ibuHamilAkh),
balitaTerdaftar: Number(body.balitaTerdaftar),
alertStunting: Number(body.alertStunting),
},
});
return { success: true, message: "Ringkasan kesehatan berhasil disimpan", data };
} catch (e) {
console.error("Error di ringkasanKesehatanUpdate:", e);
return { success: false, message: "Gagal menyimpan ringkasan kesehatan" };
}
}
export default ringkasanKesehatanUpdate;

View File

@@ -0,0 +1,17 @@
import prisma from "@/lib/prisma";
async function beasiswaConfigFindUnique() {
try {
const data = await prisma.beasiswaConfig.findFirst({
where: { isActive: true },
orderBy: { createdAt: 'desc' },
});
return { success: true, data };
} catch (e) {
console.error("Error di beasiswaConfigFindUnique:", e);
return { success: false, message: "Gagal mengambil konfigurasi beasiswa" };
}
}
export default beasiswaConfigFindUnique;

View File

@@ -0,0 +1,14 @@
import Elysia, { t } from "elysia";
import beasiswaConfigFindUnique from "./findUnique";
import beasiswaConfigUpdate from "./updt";
const BeasiswaConfig = new Elysia({ prefix: "/beasiswaconfig", tags: ["Pendidikan/Beasiswa Desa/Config"] })
.get("/find", beasiswaConfigFindUnique)
.put("/update", beasiswaConfigUpdate, {
body: t.Object({
tahunAjaran: t.String(),
danaTersalurkan: t.String(),
}),
});
export default BeasiswaConfig;

View File

@@ -0,0 +1,36 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
async function beasiswaConfigUpdate(context: Context) {
const body = context.body as any;
try {
const existing = await prisma.beasiswaConfig.findFirst({
where: { isActive: true },
orderBy: { createdAt: 'desc' },
});
const data = existing
? await prisma.beasiswaConfig.update({
where: { id: existing.id },
data: {
tahunAjaran: body.tahunAjaran,
danaTersalurkan: BigInt(body.danaTersalurkan),
},
})
: await prisma.beasiswaConfig.create({
data: {
tahunAjaran: body.tahunAjaran,
danaTersalurkan: BigInt(body.danaTersalurkan),
},
});
return { success: true, message: "Konfigurasi beasiswa berhasil disimpan", data: { ...data, danaTersalurkan: data.danaTersalurkan.toString() } };
} catch (e) {
console.error("Error di beasiswaConfigUpdate:", e);
return { success: false, message: "Gagal menyimpan konfigurasi beasiswa" };
}
}
export default beasiswaConfigUpdate;

View File

@@ -1,6 +1,7 @@
import Elysia from "elysia";
import BeasiswaPendaftar from "./beasiswa-pendaftar";
import KeunggulanProgram from "./keunggulan-program";
import BeasiswaConfig from "./beasiswa-config";
const Beasiswa = new Elysia({
prefix: "/beasiswa",
@@ -8,5 +9,6 @@ const Beasiswa = new Elysia({
})
.use(BeasiswaPendaftar)
.use(KeunggulanProgram)
.use(BeasiswaConfig)
export default Beasiswa

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