Compare commits

..

32 Commits

Author SHA1 Message Date
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
79 changed files with 3712 additions and 571 deletions

42
.claude/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,42 @@
# 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 + 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>/`
## 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}"
}
}
}
}

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,11 @@ 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
### Workflow for Code Changes
1. **Commit** existing changes before starting new work
@@ -134,4 +50,4 @@ To release: tag with `git tag -a v0.1.x -m "..."` and push the tag.
2. **re-pull.yml**: **Wait for `publish.yml` to complete successfully before running.** Uses branch `main`, stack env and stack name `desa-darmasaba`.
### After Progress
- Always give option to continue to GitHub workflows or not
- Always give option to continue to GitHub workflows or not

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,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,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.45",
"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

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

@@ -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,15 +60,31 @@ 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
title="Omzet Bulan Ini"
value={`Rp ${kpi.omzetBulanan.toLocaleString()}`}
trend={summary?.persentasePerubahan}
<KpiCard
title="Omzet Bulan Ini"
value={`Rp ${kpi.omzetBulanan.toLocaleString()}`}
trend={summary?.persentasePerubahan}
/>
<KpiCard title="Kategori Aktif" value={summary?.kategoriAktif || 0} subValue="kategori" />
<KpiCard
title="UMKM Terbanyak"
value={kpi.jumlahKategoriTerbanyak || 0}
subValue={kpi.kategoriTerbanyak}
/>
<KpiCard title="Produk Aktif" value={summary?.produkAktif || 0} />
<KpiCard title="Kategori Populer" value={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">
<Button
color="blue"
onClick={handleUpdate}
loading={isSubmitting}
{/* Action Buttons */}
<Group justify="right" mt="md">
<Button
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,8 +116,16 @@ function PenjualanUmkm() {
/>
</Center>
</Paper>
{/* 🔥 Modal Konfirmasi */}
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah Anda yakin ingin menghapus data penjualan ini?"
/>
</Stack>
);
}
export default PenjualanUmkm;
export default PenjualanUmkm;

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" />
<ActionIcon
color="red"
variant="filled"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
state.update.form.imageId = "";
<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
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
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

@@ -13,6 +13,7 @@ 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";
const Desa = new Elysia({ prefix: "/desa", tags: ["Desa"] })
@@ -30,6 +31,7 @@ const Desa = new Elysia({ prefix: "/desa", tags: ["Desa"] })
.use(KategoriPengumuman)
.use(AjukanPermohonan)
.use(Musik)
.use(KegiatanDesa)
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 Elysia, { t } from "elysia";
import kegiatanDesaFindMany from "./find-many";
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)
.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,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 skrg = {
totalNilai: (skrgRaw as any).totalNilai || 0,
jumlah: (skrgRaw as any).jumlah || 0
};
const lalu = {
totalNilai: (laluRaw as any).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 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,35 +1,63 @@
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;
const lalu = penjualanLalu._sum.totalNilai || 0;
let persentasePerubahan = 0;
if (lalu > 0) {
persentasePerubahan = ((skrg - lalu) / lalu) * 100;
@@ -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

View File

@@ -63,17 +63,19 @@ const Utils = new Elysia({
});
const ApiServer = new Elysia()
.use(swagger({ path: "/api/docs" }))
.use(cors(corsConfig))
.use(
swagger({
path: "/docs",
path: "/api/docs",
documentation: {
info: {
title: "Desa Darmasaba API Documentation",
version: "1.0.0",
},
},
scalarConfig: {
spec: { url: "/api/docs/json" },
},
}),
)
.onError(({ code }) => {
@@ -87,6 +89,13 @@ const ApiServer = new Elysia()
.group("/api", (app) =>
app
.use(Utils)
.get("/version", async () => {
const packageJson = await fs.readFile(
path.join(ROOT, "package.json"),
"utf-8",
);
return { version: JSON.parse(packageJson).version };
})
.use(FileStorage)
.use(LandingPage)
.use(PPID)

View File

@@ -161,7 +161,7 @@ function DetailLowonganKerjaUser() {
size="md"
mt="md"
bg={colors['blue-button']}
onClick={() => window.open(`https://wa.me/${data.notelp}`, '_blank')}
onClick={() => window.open(`https://wa.me/${data.notelp?.replace(/[^0-9]/g, '').replace(/^0/, '62')}`, '_blank')}
leftSection={<IconBrandWhatsapp size={20} />}
>
Hubungi Sekarang

View File

@@ -68,7 +68,7 @@ function Page() {
color="green"
radius="md"
component="a"
href={`https://wa.me/${u.kontak}`}
href={`https://wa.me/${u.kontak?.replace(/[^0-9]/g, '').replace(/^0/, '62')}`}
target="_blank"
w="fit-content"
>

View File

@@ -1,132 +1,230 @@
'use client'
import colors from '@/con/colors';
import { Box, Button, Paper, Stack, Text, Image, Skeleton, Group, Badge, Divider, Title } from '@mantine/core';
import { IconArrowBack, IconBrandWhatsapp, IconMapPin, IconUser } from '@tabler/icons-react';
'use client';
import React, { useState } from 'react';
import {
Box,
Button,
Paper,
Stack,
Text,
Image,
Skeleton,
Group,
Badge,
Divider,
Title,
Modal,
NumberInput,
TextInput,
Textarea,
Alert,
} from '@mantine/core';
import {
IconArrowBack,
IconBrandWhatsapp,
IconMapPin,
IconUser,
IconShoppingCart,
} from '@tabler/icons-react';
import { useRouter, useParams } from 'next/navigation';
import React from 'react';
import { useProxy } from 'valtio/utils';
import { useShallowEffect } from '@mantine/hooks';
import { toast } from 'react-toastify';
import colors from '@/con/colors';
import umkmState from '@/app/admin/(dashboard)/_state/ekonomi/umkm/umkm';
import ApiFetch from '@/lib/api-fetch';
interface OrderForm {
nama: string;
jumlah: number;
catatan: string;
}
const DEFAULT_FORM: OrderForm = {
nama: '',
jumlah: 1,
catatan: '',
};
function DetailProdukPasarUser() {
const router = useRouter();
const params = useParams();
const state = useProxy(umkmState.produk.findUnique);
const [modalOpen, setModalOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [form, setForm] = useState<OrderForm>(DEFAULT_FORM);
const [error, setError] = useState('');
useShallowEffect(() => {
state.load(params?.id as string);
}, []);
const data = state.data;
const total = data ? form.jumlah * (data.harga ?? 0) : 0;
const handleClose = () => {
setModalOpen(false);
setError('');
setForm(DEFAULT_FORM);
};
const handleOrder = async () => {
if (!data) return;
if (!form.nama.trim()) {
setError('Nama pemesan wajib diisi');
return;
}
if (form.jumlah < 1) {
setError('Jumlah minimal 1');
return;
}
if (form.jumlah > data.stok) {
setError(`Stok tersedia hanya ${data.stok}`);
return;
}
setLoading(true);
setError('');
try {
const res = await ApiFetch.api.ekonomi.umkm.penjualan.create.post({
produkId: data.id,
jumlah: form.jumlah,
hargaSatuan: data.harga || 0,
tanggal: new Date().toISOString(),
});
if (!res.data?.success) {
throw new Error(res.data?.message || 'Gagal membuat pesanan');
}
state.load(params?.id as string);
handleClose();
toast.success('Pesanan berhasil dicatat!');
let kontak = data.umkm?.kontak?.replace(/[^0-9]/g, '') || '';
if (kontak.startsWith('0')) {
kontak = '62' + kontak.slice(1);
}
if (kontak) {
const message = [
`Halo *${data.umkm?.nama}*, saya ingin memesan:`,
'',
`*${data.nama}*`,
`Jumlah: ${form.jumlah}`,
`Harga Satuan: Rp ${data.harga?.toLocaleString('id-ID')}`,
`*Total: Rp ${total.toLocaleString('id-ID')}*`,
'',
`Nama Pemesan: ${form.nama}`,
form.catatan ? `Catatan: ${form.catatan}` : null,
]
.filter(Boolean)
.join('\n');
window.open(
`https://wa.me/${kontak}?text=${encodeURIComponent(message)}`,
'_blank'
);
}
} catch (e: any) {
setError(e.message || 'Terjadi kesalahan');
} finally {
setLoading(false);
}
};
if (state.loading || !data) {
return (
<Stack py={10} px={{ base: 'md', md: 100 }}>
<Skeleton height={400} radius="md" />
</Stack>
);
return <Skeleton h={400} />;
}
return (
<Box py={20} bg={colors.Bg}>
{/* Tombol kembali */}
<Box px={{ base: 'md', md: 100 }}>
<Button
variant="subtle"
onClick={() => router.push('/darmasaba/ekonomi/umkm')}
leftSection={<IconArrowBack size={20} color={colors['blue-button']} />}
mb={15}
leftSection={<IconArrowBack size={16} />}
onClick={() => router.back()}
>
<Text fz={{ base: 'md', md: 'lg' }} lh={1.5}>
Kembali ke Katalog
</Text>
Kembali
</Button>
</Box>
<Paper
w={{ base: '90%', md: '70%' }}
mx="auto"
p="lg"
radius="md"
shadow="sm"
bg="white"
>
<Stack gap="lg">
{/* Gambar Produk */}
<Paper mt="md" mx={{ base: 'md', md: 100 }} p="lg" radius="md">
<Stack>
<Image
src={data.image?.link || '/no-image.jpg'}
alt={data.nama}
radius="md"
h={{ base: 250, md: 400 }}
w="100%"
fit="cover"
fallbackSrc="/no-image.jpg"
/>
{/* Detail Produk */}
<Stack gap="xs">
<Group justify="space-between" align="flex-start">
<Stack gap={5}>
<Badge color="blue" variant="light">{data.kategoriProduk?.nama}</Badge>
<Title order={1} fw={800} c={colors['blue-button']}>
{data.nama}
</Title>
</Stack>
<Badge color={data.stok > 0 ? 'green' : 'red'} size="lg">
{data.stok > 0 ? `Stok: ${data.stok}` : 'Stok Habis'}
</Badge>
</Group>
<Text fz="2rem" fw={900} c="orange">
Rp {data.harga?.toLocaleString('id-ID')}
</Text>
<Title>{data.nama}</Title>
<Divider my="sm" />
<Text fw={900} c="orange">
Rp {data.harga?.toLocaleString('id-ID')}
</Text>
<Stack gap="md">
<Box>
<Title order={3} mb="xs">Informasi Penjual</Title>
<Paper withBorder p="md" radius="md" bg="gray.0">
<Group justify="space-between">
<Stack gap={4}>
<Text fw={700} fz="lg" c="blue" style={{ cursor: 'pointer' }} onClick={() => router.push(`/darmasaba/ekonomi/umkm/${data.umkmId}`)}>
{data.umkm?.nama}
</Text>
<Group gap="xs">
<IconUser size={16} color="gray" />
<Text size="sm" c="dimmed">{data.umkm?.pemilik}</Text>
</Group>
<Group gap="xs">
<IconMapPin size={16} color="red" />
<Text size="sm" c="dimmed">{data.umkm?.alamat || 'Darmasaba'}</Text>
</Group>
</Stack>
{data.umkm?.kontak && (
<Button
color="green"
radius="md"
component="a"
href={`https://wa.me/${data.umkm.kontak.replace(/[^0-9]/g, '')}`}
target="_blank"
leftSection={<IconBrandWhatsapp size={20}/>}
>
WhatsApp
</Button>
)}
</Group>
</Paper>
</Box>
<Badge color={data.stok > 0 ? 'green' : 'red'}>
{data.stok > 0 ? `Stok: ${data.stok}` : 'Stok Habis'}
</Badge>
<Box>
<Title order={3} mb="xs">Deskripsi Produk</Title>
<Text fz="md" lh={1.6} c="dark">
{data.deskripsi || 'Tidak ada deskripsi tersedia untuk produk ini.'}
</Text>
</Box>
</Stack>
</Stack>
<Text
style={{ wordBreak: 'break-word' }}
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
/>
<Button
leftSection={<IconShoppingCart />}
disabled={data.stok === 0}
onClick={() => setModalOpen(true)}
>
Pesan Sekarang
</Button>
</Stack>
</Paper>
{/* Modal */}
<Modal opened={modalOpen} onClose={handleClose} title="Pesanan">
<Stack>
<TextInput
label="Nama"
value={form.nama}
onChange={(e) =>
setForm({ ...form, nama: e.target.value })
}
/>
<NumberInput
label="Jumlah"
min={1}
max={data.stok}
value={form.jumlah}
onChange={(v) =>
setForm({ ...form, jumlah: Number(v) || 1 })
}
/>
<Textarea
label="Catatan"
value={form.catatan}
onChange={(e) =>
setForm({ ...form, catatan: e.target.value })
}
/>
{error && <Alert color="red">{error}</Alert>}
<Button loading={loading} onClick={handleOrder}>
Kirim via WhatsApp
</Button>
</Stack>
</Modal>
</Box>
);
}

View File

@@ -213,7 +213,7 @@ function Page() {
<Button variant="light" leftSection={<IconDeviceLandlinePhone size={18} />} component="a" href={`tel:${kontak.telepon}`} aria-label="Hubungi Telepon">
<Text fz={{ base: 'xs', md: 'sm' }}>Telepon</Text>
</Button>
<Button variant="light" leftSection={<IconBrandWhatsapp size={18} />} component="a" href={`https://wa.me/${kontak.whatsapp.replace(/\D/g, '')}`} target="_blank" aria-label="Hubungi WhatsApp">
<Button variant="light" leftSection={<IconBrandWhatsapp size={18} />} component="a" href={`https://wa.me/${kontak.whatsapp.replace(/\D/g, '').replace(/^0/, '62')}`} target="_blank" aria-label="Hubungi WhatsApp">
<Text fz={{ base: 'xs', md: 'sm' }}>WhatsApp</Text>
</Button>
<Button variant="light" leftSection={<IconMail size={18} />} component="a" href={`mailto:${kontak.email}`} aria-label="Kirim Email">

View File

@@ -1,12 +1,12 @@
import { Client } from "minio";
const minioClient = new Client({
endPoint: process.env.MINIO_ENDPOINT!,
accessKey: process.env.MINIO_ACCESS_KEY!,
secretKey: process.env.MINIO_SECRET_KEY!,
endPoint: process.env.MINIO_ENDPOINT ?? "localhost",
accessKey: process.env.MINIO_ACCESS_KEY ?? "",
secretKey: process.env.MINIO_SECRET_KEY ?? "",
useSSL: process.env.MINIO_USE_SSL === "true",
});
export const MINIO_BUCKET = process.env.MINIO_BUCKET!;
export const MINIO_BUCKET = process.env.MINIO_BUCKET ?? "";
export default minioClient;