Compare commits

...

25 Commits

Author SHA1 Message Date
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
53 changed files with 2571 additions and 452 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,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

@@ -14,9 +14,9 @@ The edit pages for UMKM (Data UMKM and Produk) use an older UI pattern. The user
## Progress
- [x] Analyze Berita edit page pattern
- [x] Refactor UMKM Produk edit page
- [x] Refactor Data UMKM edit page
- [ ] Run build and fix any errors
- [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,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

@@ -4,9 +4,9 @@ Refactor Data UMKM and Produk edit pages to match the Berita edit page UI patter
## 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`.
3. [x] Implement the pattern in `ekonomi/umkm/data-umkm/[id]/edit/page.tsx`.
4. [ ] Run `bun run build` to verify.
5. [ ] Update `package.json` version.
6. [ ] Commit with message: "feat(admin): refactor UMKM edit pages to match berita 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: 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

@@ -1,11 +1,12 @@
# 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 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 improvements.
3. **UI Consistency**: Standardized colors and component usage across UMKM edit pages.
4. **UX Improvement**: Added a "Batal" button that resets the form to its original data state.
5. **Build Verification**: Confirmed that the project builds successfully with `bun run build`.
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.

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.22",
"version": "0.1.43",
"private": true,
"scripts": {
"dev": "next dev",

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

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

@@ -1445,8 +1445,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 +1458,6 @@ model KategoriProduk {
deletedAt DateTime?
isActive Boolean @default(true)
KategoriToPasar KategoriToPasar[]
PasarDesa PasarDesa[]
Umkm Umkm[]
}
model KategoriToPasar {
@@ -2424,23 +2422,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])

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,7 +138,7 @@ 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/${id}`, {
@@ -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,7 +269,7 @@ 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/${id}`, {
@@ -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,4 +1,3 @@
/* eslint-disable react-hooks/exhaustive-deps */
"use client";
import EditEditor from "@/app/admin/(dashboard)/_com/editEditor";
@@ -32,35 +31,56 @@ import { toast } from "react-toastify";
import { useProxy } from "valtio/utils";
import umkmState from "../../../../../_state/ekonomi/umkm/umkm";
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({
const [formData, setFormData] = useState<UmkmForm>({
nama: "",
pemilik: "",
kategoriId: "",
deskripsi: "",
alamat: "",
kontak: "",
imageId: "",
imageId: null,
});
const [originalData, setOriginalData] = useState({
const [originalData, setOriginalData] = useState<UmkmForm & { imageUrl: string }>({
nama: "",
pemilik: "",
kategoriId: "",
deskripsi: "",
alamat: "",
kontak: "",
imageId: "",
imageId: null,
imageUrl: ""
});
@@ -79,9 +99,9 @@ function EditDataUmkm() {
umkmState.umkm.findUnique.load(id)
]);
const data = umkmState.umkm.findUnique.data;
const data = umkmState.umkm.findUnique.data as UmkmData | null;
if (data) {
const initialForm = {
const initialForm: UmkmForm = {
nama: data.nama || "",
pemilik: data.pemilik || "",
kategoriId: data.kategoriId || "",
@@ -94,11 +114,11 @@ function EditDataUmkm() {
setFormData(initialForm);
setOriginalData({
...initialForm,
imageUrl: data.image?.url || ""
imageUrl: data.image?.link || ""
});
if (data.image?.url) {
setPreviewImage(data.image.url);
if (data.image?.link) {
setPreviewImage(data.image.link);
}
}
setIsInitialLoading(false);
@@ -106,7 +126,7 @@ function EditDataUmkm() {
init();
}, [id]);
const handleChange = (field: string, value: string) => {
const handleChange = (field: keyof UmkmForm, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
@@ -260,6 +280,7 @@ function EditDataUmkm() {
onClick={() => {
setPreviewImage(null);
setFile(null);
setFormData(prev => ({ ...prev, imageId: null }));
}}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
@@ -291,7 +312,7 @@ 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={formData.kategoriId}

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,4 +1,3 @@
/* eslint-disable react-hooks/exhaustive-deps */
"use client";
import EditEditor from "@/app/admin/(dashboard)/_com/editEditor";
@@ -33,43 +32,59 @@ import { toast } from "react-toastify";
import { useProxy } from "valtio/utils";
import umkmState from "../../../../../_state/ekonomi/umkm/umkm";
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({
const [formData, setFormData] = useState<ProdukForm>({
nama: "",
harga: 0,
stok: 0,
umkmId: "",
deskripsi: "",
imageId: "",
imageId: null,
kategoriId: "",
});
const [originalData, setOriginalData] = useState({
const [originalData, setOriginalData] = useState<ProdukForm & { imageUrl: string }>({
nama: "",
harga: 0,
stok: 0,
umkmId: "",
deskripsi: "",
imageId: "",
imageId: null,
kategoriId: "",
imageUrl: ""
});
const isHtmlEmpty = (html: string) => {
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
const isFormValid = () => {
return (
formData.nama?.trim() !== '' &&
@@ -88,26 +103,26 @@ function EditProdukUmkm() {
umkmState.produk.findUnique.load(id)
]);
const data = umkmState.produk.findUnique.data;
const data = umkmState.produk.findUnique.data as ProdukData | null;
if (data) {
const initialForm = {
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 || "",
kategoriId: data.kategoriProdukId || "",
};
setFormData(initialForm);
setOriginalData({
...initialForm,
imageUrl: data.image?.url || ""
imageUrl: data.image?.link || ""
});
if (data.image?.url) {
setPreviewImage(data.image.url);
if (data.image?.link) {
setPreviewImage(data.image.link);
}
}
setIsInitialLoading(false);
@@ -115,7 +130,7 @@ function EditProdukUmkm() {
init();
}, [id]);
const handleChange = (field: string, value: any) => {
const handleChange = (field: keyof ProdukForm, value: string | number) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
@@ -269,6 +284,7 @@ function EditProdukUmkm() {
onClick={() => {
setPreviewImage(null);
setFile(null);
setFormData(prev => ({ ...prev, imageId: null }));
}}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
@@ -284,7 +300,7 @@ function EditProdukUmkm() {
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={formData.umkmId}
@@ -327,7 +343,7 @@ function EditProdukUmkm() {
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={formData.kategoriId}

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

@@ -9,7 +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 "./kategori-produk";
import KategoriProduk from "./umkm/kategori-produk/kategori-produk";
import Umkm from "./umkm";
import ProdukUmkm from "./umkm/produk";
import PenjualanProduk from "./umkm/penjualan";

View File

@@ -1,31 +0,0 @@
import prisma from "@/lib/prisma";
import Elysia from "elysia";
const KategoriProduk = new Elysia({
prefix: "/kategoriproduk",
})
.get("/find-many-all", async () => {
try {
const data = await prisma.kategoriProduk.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",
};
}
});
export default KategoriProduk;

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

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