Compare commits
36 Commits
tasks/admi
...
stg
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a5d17f45e | |||
| 83a2dece57 | |||
| e0a5177257 | |||
| 23c955597e | |||
| 28a22e8d77 | |||
| 67efe6ce35 | |||
| b39f9b39da | |||
| 6041cdf552 | |||
| 5cd6e3aa99 | |||
| f31ab0eda5 | |||
| 0517d50c8e | |||
| fa7a52a0f3 | |||
| ef237aea2f | |||
| f6107e971d | |||
| 550961d524 | |||
| 34d49fa073 | |||
| 1631e273a4 | |||
| faf78064c7 | |||
| 9dd5d1545f | |||
| a4c7a97593 | |||
| 5ab014281a | |||
| 865074a310 | |||
| b640bb3919 | |||
| f48b982b3c | |||
| cfe06137d8 | |||
| f0504c9dc0 | |||
| 1916c616de | |||
| e3345c71f5 | |||
| 68da360cea | |||
| b9b2b65294 | |||
| 71e23dea1a | |||
| cd7425292c | |||
| 187e3a2115 | |||
| 7f5588f69e | |||
| 30fbed73c9 | |||
| 67c51302fe |
44
.claude/ARCHITECTURE.md
Normal file
44
.claude/ARCHITECTURE.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Architecture
|
||||
|
||||
## Tech Stack
|
||||
- **Framework**: Next.js 15 (App Router) + React 19
|
||||
- **Runtime/Package manager**: Bun (not npm)
|
||||
- **API server**: Elysia.js (mounted at `/api/[[...slugs]]`)
|
||||
- **ORM**: Prisma + PostgreSQL
|
||||
- **UI**: Mantine UI v7-8
|
||||
- **State**: Jotai (atoms), Valtio (proxies), SWR (data fetching)
|
||||
- **Auth**: iron-session + JWT
|
||||
- **File storage**: Local uploads + MinIO (object storage) + Seafile (self-hosted fallback)
|
||||
|
||||
## Request Flow
|
||||
|
||||
```
|
||||
Browser → Next.js middleware (src/middleware.ts)
|
||||
→ Public pages: src/app/darmasaba/
|
||||
→ Admin pages: src/app/admin/
|
||||
→ API: src/app/api/[[...slugs]]/route.ts (Elysia.js)
|
||||
└── _lib/*.ts (domain modules)
|
||||
```
|
||||
|
||||
The Elysia server is a single entry point with domain-specific modules: `desa/`, `kesehatan/`, `ekonomi/`, `keamanan/`, `lingkungan/`, `pendidikan/`, `kependudukan/`, `ppid/`, `inovasi/`, `landing_page/`, `search/`, `auth/`, `user/`, `fileStorage/`. Swagger docs are auto-generated at `/api/docs`.
|
||||
|
||||
## Domain Modules
|
||||
Each domain (desa, kesehatan, ekonomi, etc.) has:
|
||||
- API handler in `src/app/api/[[...slugs]]/_lib/<domain>/`
|
||||
- Admin CMS pages in `src/app/admin/(dashboard)/<domain>/`
|
||||
- Public pages in `src/app/darmasaba/(pages)/<domain>/`
|
||||
|
||||
Active domains: `desa`, `ekonomi`, `inovasi`, `keamanan`, `kependudukan`, `kesehatan`, `lingkungan`, `musik`, `pendidikan`, `ppid` — plus `landing_page` and `search` (API-only, no public/admin pages).
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/middleware.ts` | Route guards and auth |
|
||||
| `src/lib/prisma.ts` | Prisma client singleton |
|
||||
| `src/lib/api-auth.ts` | JWT/session validation |
|
||||
| `src/lib/api-fetch.ts` | Typed fetch wrapper used by frontend |
|
||||
| `src/lib/session.ts` | iron-session config |
|
||||
| `next.config.ts` | Next.js config (cache headers, allowed origins) |
|
||||
| `postcss.config.cjs` | Mantine CSS preset and breakpoints |
|
||||
| `docker-entrypoint.sh` | Runs `prisma migrate deploy` then starts app |
|
||||
16
.claude/DATABASE.md
Normal file
16
.claude/DATABASE.md
Normal 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
34
.claude/DEPLOYMENT.md
Normal 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.
|
||||
120
.claude/commands/deploy-stg.md
Normal file
120
.claude/commands/deploy-stg.md
Normal 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`).
|
||||
478
.claude/mcp/github-actions.mjs
Normal file
478
.claude/mcp/github-actions.mjs
Normal 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
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
13
.mcp.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"deploy-stg": {
|
||||
"command": "node",
|
||||
"args": ["--env-file=.env", ".claude/mcp/github-actions.mjs"],
|
||||
"env": {
|
||||
"GH_TOKEN": "${GH_TOKEN}",
|
||||
"BASE_URL": "${BASE_URL}",
|
||||
"STACK_NAME": "${STACK_NAME}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
321
AI-CONTRACT.md
Normal file
321
AI-CONTRACT.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# AI-CONTRACT.md
|
||||
|
||||
Kontrak kerja antara **manusia (developer)** dan **AI assistant** (Claude Code,
|
||||
Cursor, Copilot, atau agent coding lainnya) di repo ini. Tujuannya satu:
|
||||
mencegah perbaikan 1 bug berubah jadi 3 bug baru (bug eksponensial). AI
|
||||
**wajib** baca file ini sebelum menulis/menghapus kode.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prinsip Dasar
|
||||
|
||||
1. **Minimal diff, maximal pemahaman.** Baca kode sebelum ubah. Jangan
|
||||
refactor yang tidak diminta. Jangan "rapikan" kode di sekitar bug.
|
||||
2. **Fix akar, bukan gejala.** Kalau error muncul di layer A tapi penyebab
|
||||
di layer B, perbaiki B. Jangan tambal di A.
|
||||
3. **Satu masalah = satu perubahan logis.** Jangan campur fix bug dengan
|
||||
refactor, rename, atau fitur baru dalam satu sesi tanpa izin.
|
||||
4. **Tidak ada asumsi diam-diam.** Kalau butuh info (nama field, endpoint,
|
||||
flow, schema), tanya atau baca kode — jangan tebak.
|
||||
5. **Setiap perubahan harus reversible.** Diff kecil, commit jelas, bisa
|
||||
di-revert tanpa efek samping.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sebelum Menulis Kode
|
||||
|
||||
Checklist wajib sebelum edit file:
|
||||
|
||||
- [ ] Sudah baca file target (bukan cuma potongan)
|
||||
- [ ] Tahu siapa yang memanggil fungsi/komponen yang akan diubah
|
||||
- [ ] Tahu apakah ada test/konsumer lain yang bergantung padanya
|
||||
- [ ] Tahu layer yang benar (route / controller / component / hook /
|
||||
service / repository / lib / util — sesuai arsitektur project)
|
||||
- [ ] Cek dokumen panduan project (mis. `CLAUDE.md`, `CONTRIBUTING.md`,
|
||||
`ARCHITECTURE.md`, ADR) untuk aturan spesifik
|
||||
- [ ] Kalau ubah tipe/kontrak (API, function signature, schema), cek
|
||||
semua pemakai
|
||||
|
||||
Jika salah satu tidak jelas: **berhenti, baca lagi, atau tanya user.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Saat Fix Bug
|
||||
|
||||
1. **Reproduksi dulu di kepala.** Jelaskan (minimal ke diri sendiri)
|
||||
kenapa bug terjadi sebelum menyentuh kode.
|
||||
2. **Temukan akar sebenarnya.** "Karena field X `undefined`/`null`/empty"
|
||||
bukan akar — akarnya kenapa X bisa kosong.
|
||||
3. **Perbaiki sekecil mungkin.** Kalau cukup 3 baris, jangan ubah 30.
|
||||
4. **Jangan tambah try/catch hanya untuk menyembunyikan error** — itu
|
||||
melahirkan bug baru yang lebih sulit dilacak.
|
||||
5. **Jangan tambah fallback/default value spekulatif.** Kalau field
|
||||
seharusnya selalu ada, perbaiki kenapa bisa kosong.
|
||||
6. **Jangan rename, reorder, atau reformat** di file yang sama kecuali
|
||||
langsung terkait fix.
|
||||
7. **Setelah fix, verifikasi**: minimal jalankan typecheck/lint sesuai
|
||||
tooling project (mis. `tsc`, `eslint`, `ruff`, `mypy`, `cargo check`,
|
||||
`go vet`, `rspec`, dll). Idealnya jalankan test suite yang relevan.
|
||||
|
||||
---
|
||||
|
||||
## 4. Yang Dilarang (Akar Bug Eksponensial)
|
||||
|
||||
- ❌ **Silent catch**: `catch (e) {}`, `except: pass`, `_ = err`, atau
|
||||
pola serupa — tanpa alasan yang didokumentasi di komentar.
|
||||
- ❌ **Comment-out kode** sebagai "backup". Hapus atau kembalikan, jangan
|
||||
biarkan mayat — git sudah jadi backup.
|
||||
- ❌ **Copy-paste antar file**. Extract ke shared module/util/helper.
|
||||
- ❌ **Duplikasi util/helper/hook/service** yang sudah ada — cek dulu
|
||||
sebelum bikin baru.
|
||||
- ❌ **Tambah flag/opsi/parameter baru** hanya untuk menghindari break
|
||||
konsumer lama — fix konsumernya sekalian.
|
||||
- ❌ **Destructive git command** (`reset --hard`, `push --force`,
|
||||
`branch -D`, `clean -fdx`) tanpa instruksi eksplisit.
|
||||
- ❌ **Skip hook** (`--no-verify`, `--no-gpg-sign`) tanpa izin.
|
||||
- ❌ **Ubah schema/migrasi database** tanpa migration file yang sesuai.
|
||||
- ❌ **Tambah dependency baru** tanpa izin user.
|
||||
- ❌ **Hardcode credential, secret, URL produksi, atau data user**.
|
||||
- ❌ **Ubah konfigurasi CI/CD, environment, atau infra** tanpa diskusi.
|
||||
|
||||
---
|
||||
|
||||
## 5. Saat Menambah Fitur
|
||||
|
||||
- Baca panduan arsitektur project sebelum mulai.
|
||||
- Tentukan layer sebelum menulis. Jangan taruh bisnis logika di route,
|
||||
controller, atau komponen presentasi.
|
||||
- Jangan buat abstraksi untuk kebutuhan hipotetis. Tulis kode yang
|
||||
diminta sekarang (YAGNI — *You Aren't Gonna Need It*).
|
||||
- Hormati batas ukuran file yang sudah disepakati di project. Kalau
|
||||
belum ada, gunakan rule of thumb: file >500 baris = sinyal untuk
|
||||
pisah; fungsi >50 baris = sinyal untuk extract.
|
||||
- Ikuti konvensi naming, struktur folder, dan pattern yang sudah ada —
|
||||
konsistensi lebih penting dari preferensi pribadi.
|
||||
|
||||
---
|
||||
|
||||
## 6. Saat Ragu
|
||||
|
||||
Urutan tindakan:
|
||||
|
||||
1. Baca kode terkait lebih dalam.
|
||||
2. Cek dokumen panduan project (`CLAUDE.md`, `README.md`, ADR, dll).
|
||||
3. Cek git history (`git log -p`, `git blame`) kalau pertanyaannya soal
|
||||
"kenapa ini begini".
|
||||
4. **Tanya user** — lebih baik tanya 1 pertanyaan daripada menulis 100
|
||||
baris yang harus dibuang.
|
||||
|
||||
Jangan pernah "pokoknya coba dulu, kalau salah revert". Revert itu murah
|
||||
di local, tapi mahal kalau sudah merusak state (DB, session, file
|
||||
sistem, deployment, dll).
|
||||
|
||||
---
|
||||
|
||||
## 7. Saat Selesai
|
||||
|
||||
- Jelaskan perubahan **secara singkat**: apa, di mana (file:line), kenapa.
|
||||
- Sebutkan efek samping kalau ada (perubahan kontrak, breaking change,
|
||||
perlu migrasi, perlu restart service, dll).
|
||||
- Jangan ringkas diff yang user sudah lihat — user baca kode langsung.
|
||||
- Kalau project punya channel notifikasi atau workflow report
|
||||
(Slack/Discord/Telegram/email), kirim sesuai konvensi.
|
||||
|
||||
---
|
||||
|
||||
## 8. Eskalasi
|
||||
|
||||
Hentikan pekerjaan dan tanya user kalau:
|
||||
|
||||
- Fix butuh ubah >5 file untuk bug yang kelihatannya kecil.
|
||||
- Ketemu bug lain di tengah jalan yang tidak diminta.
|
||||
- Perubahan berpotensi mengenai data produksi, session aktif, atau
|
||||
user nyata.
|
||||
- User memberi instruksi yang bertentangan dengan dokumen panduan
|
||||
project — konfirmasi dulu sebelum melanggar aturan.
|
||||
|
||||
---
|
||||
|
||||
## 9. Tools sebagai Mata dan Tangan AI
|
||||
|
||||
AI **wajib** memakai tools yang tersedia (MCP server, CLI commands,
|
||||
debugger, browser automation, log inspector, DB query tool, dll) sebagai
|
||||
**mata dan tangan**-nya.
|
||||
|
||||
- **Mata**: sebelum menebak state sistem, AI harus lihat langsung. Cek
|
||||
log, query DB read-only, baca file config, jalankan health check, atau
|
||||
pakai tool inspeksi yang relevan. Jangan berasumsi tentang data,
|
||||
konfigurasi, atau tampilan — **cek dulu**.
|
||||
- **Tangan**: gunakan tools untuk verifikasi end-to-end setelah
|
||||
perubahan. Contoh: setelah fix UI, jalankan/preview halamannya dan
|
||||
pastikan render + console bersih. Setelah fix logic, jalankan test
|
||||
atau panggil endpoint yang relevan.
|
||||
- **Maksimalkan pemakaian.** Kalau ada tool yang relevan, pakai — jangan
|
||||
memilih jalan manual yang lebih rapuh. Semakin sering tools dipakai
|
||||
untuk verifikasi, semakin solid project ini.
|
||||
- **Ajukan tool baru kalau perlu.** Kalau AI merasa butuh tool yang
|
||||
belum ada, AI **boleh dan didorong** untuk mengajukan pembuatannya
|
||||
ke user. Format pengajuan:
|
||||
1. Nama tool + signature (input/output)
|
||||
2. Kenapa dibutuhkan (masalah konkret yang sedang dihadapi)
|
||||
3. Sumber data (tabel DB / cache key / endpoint / file system)
|
||||
4. Estimasi dampak ke kualitas investigasi/perbaikan
|
||||
- **Jangan buat tool baru tanpa izin.** Ajukan dulu, tunggu persetujuan
|
||||
user, baru implementasi (+ update dokumentasi).
|
||||
- **Tools adalah sumber kebenaran runtime.** Kalau memory/log mengatakan
|
||||
X tapi tool inspeksi langsung mengatakan Y, percayai tool.
|
||||
|
||||
Tujuan: AI tidak buta terhadap state sistem nyata, dan setiap perbaikan
|
||||
diverifikasi secara nyata — bukan "harusnya sudah jalan".
|
||||
|
||||
---
|
||||
|
||||
## 10. Kontrak Public API / Interface (Wajib Dijaga)
|
||||
|
||||
Setiap interface yang dipakai oleh konsumen eksternal — REST/GraphQL
|
||||
endpoint, MCP tool, library export, CLI command, webhook payload, event
|
||||
schema, dll — adalah **kontrak publik**. Begitu konsumen (termasuk AI
|
||||
agent dengan memory) tahu bentuk kontraknya, perubahan diam-diam bisa
|
||||
bikin mereka bertindak berdasarkan asumsi yang sudah tidak valid — dan
|
||||
kamu **tidak akan tahu** sampai terjadi kejadian aneh di prod.
|
||||
|
||||
### Apa yang dianggap kontrak (freeze)
|
||||
|
||||
| Kategori | Contoh | Aturan |
|
||||
| ----------------- | --------------------------------------- | -------------------------------------------------- |
|
||||
| Nama interface | endpoint path, tool name, function name | Tidak boleh rename tanpa bump versi |
|
||||
| Parameter input | nama field, tipe, posisi | Nama & tipe tidak boleh berubah |
|
||||
| Required flag | field wajib | Tidak boleh naik (optional → required) tanpa versi |
|
||||
| Enum values | nilai yang valid | Tidak boleh dihapus/diganti |
|
||||
| Error mode | format error response, exception type | Pola error harus konsisten |
|
||||
| Field output | bentuk response | Tidak boleh dihapus/diganti tipenya |
|
||||
|
||||
### Apa yang boleh berubah (additive)
|
||||
|
||||
- Tambah interface baru
|
||||
- Tambah parameter **optional** baru
|
||||
- Tambah field output baru (konsumen lama akan mengabaikan yang tidak
|
||||
mereka tahu, asal parsing-nya tolerant)
|
||||
- Perbaiki pesan error (tanpa ubah polanya)
|
||||
- Refactor implementasi internal (query, helper, dll)
|
||||
|
||||
### Cara kerja penjaga kontrak
|
||||
|
||||
1. **Contract test**: snapshot bentuk kontrak (nama, required,
|
||||
properties, enum) untuk setiap interface publik. Letakkan di folder
|
||||
khusus mis. `tests/contract/`.
|
||||
2. **Kalau contract test merah karena perubahan yang disengaja**:
|
||||
1. Update dokumentasi kontrak
|
||||
2. Bump versi (semver, tag, atau version field)
|
||||
3. Update snapshot di contract test
|
||||
4. Jelaskan migrasinya di commit message + changelog
|
||||
3. **Kalau contract test merah karena refactor yang tidak disengaja**:
|
||||
**Jangan update snapshot untuk menghijaukan test.** Balikkan refactor
|
||||
atau perbaiki supaya kontrak tetap sama. Snapshot bukan sampah yang
|
||||
bisa di-regenerate seenaknya — dia alarm kebakaran.
|
||||
|
||||
### Larangan spesifik
|
||||
|
||||
- ❌ **Jangan rename** interface publik tanpa migration plan + bump versi
|
||||
- ❌ **Jangan hapus enum value** — konsumen bisa punya kode/memory yang
|
||||
memanggil nilai itu
|
||||
- ❌ **Jangan naikkan param dari optional → required** tanpa bump versi
|
||||
- ❌ **Jangan ubah bentuk error** (format response ↔ throw exception) —
|
||||
ini mengubah handler logic di sisi konsumen
|
||||
- ❌ **Jangan update snapshot contract test** tanpa update dokumentasi
|
||||
|
||||
### Apa yang BUKAN tugas contract test
|
||||
|
||||
- Memverifikasi logika bisnis (itu unit test biasa)
|
||||
- Memverifikasi integrasi DB/external service (itu integration test)
|
||||
- Memastikan data yang di-return benar (itu QA / staging)
|
||||
|
||||
Contract test **hanya** menjaga bentuk kontrak — cepat, deterministic,
|
||||
tanpa dependency eksternal.
|
||||
|
||||
---
|
||||
|
||||
## 11. Hygiene Dokumen Panduan AI
|
||||
|
||||
Dokumen panduan AI (`CLAUDE.md`, `AI-CONTRACT.md`, `.cursorrules`,
|
||||
`.github/copilot-instructions.md`, dll) di-load **setiap turn** percakapan.
|
||||
Semakin gemuk file utamanya, semakin banyak token terbuang setiap turn —
|
||||
dan ironisnya, AI jadi lebih sulit menemukan info penting karena tertimbun
|
||||
detail. File panduan yang gemuk **bukan** tanda dokumentasi yang baik;
|
||||
sering justru sebaliknya.
|
||||
|
||||
### Pecah, jangan tumpuk
|
||||
|
||||
Gunakan **referensi file** alih-alih menumpuk semua di satu file. Banyak
|
||||
AI agent (termasuk Claude Code) auto-load file yang di-reference dengan
|
||||
sintaks `@path/to/file.md`. Contoh struktur `CLAUDE.md` yang sehat:
|
||||
|
||||
````markdown
|
||||
## Architecture
|
||||
See @docs/ARCHITECTURE.md
|
||||
|
||||
## Agent Specs
|
||||
See @docs/AGENTIC_OVERVIEW.md
|
||||
|
||||
## ADR History
|
||||
See @docs/adr/README.md
|
||||
````
|
||||
|
||||
`CLAUDE.md` utama tetap ramping, tapi info detail tetap accessible saat
|
||||
dibutuhkan.
|
||||
|
||||
### Apa yang WAJIB tetap di CLAUDE.md (load setiap turn)
|
||||
|
||||
- Konvensi coding inti (naming, formatting, import order)
|
||||
- Perintah build/test/lint yang sering dipakai
|
||||
- Aturan komunikasi (bahasa, gaya, format response)
|
||||
- Struktur folder high-level (1-2 level)
|
||||
- Larangan absolut (jangan commit ke main, jangan touch folder X, dll)
|
||||
- **Pointer** (`@path/...`) ke file detail lainnya
|
||||
|
||||
### Apa yang DIPINDAH ke file terpisah
|
||||
|
||||
- Spec arsitektur lengkap → `docs/ARCHITECTURE.md`
|
||||
- Detail flow / sequence diagram → `docs/flows/*.md`
|
||||
- ADR history (Architecture Decision Records) → `docs/adr/`
|
||||
- Contoh kode panjang → `docs/examples/`
|
||||
- API/interface reference lengkap → `docs/api/`
|
||||
- Onboarding & setup detail → `docs/SETUP.md`
|
||||
- Glossary domain terminology → `docs/GLOSSARY.md`
|
||||
- Catatan investigasi/post-mortem → `docs/incidents/`
|
||||
|
||||
Lokasi alternatif: `.claude/` atau `.ai/` kalau tim ingin memisahkan
|
||||
khusus untuk AI tooling, di luar dokumentasi developer biasa.
|
||||
|
||||
### Cek duplikasi secara rutin
|
||||
|
||||
Info yang sama sering muncul di beberapa section seiring waktu — biasanya
|
||||
karena ditambahkan saat debugging tanpa cek file dulu. Audit berkala:
|
||||
|
||||
- [ ] Sama-sama dijelaskan di `README.md` dan `CLAUDE.md`? Pilih satu,
|
||||
yang lain referensikan.
|
||||
- [ ] Aturan yang sama disebut di 2-3 section? Konsolidasi ke satu section
|
||||
kanonik, section lain tinggal pointer.
|
||||
- [ ] Contoh kode panjang muncul inline? Pindah ke `docs/examples/`.
|
||||
- [ ] Konvensi yang sudah jadi default di linter/formatter masih ditulis
|
||||
manual? Hapus — biarkan tooling yang jaga, dokumen tidak perlu
|
||||
mengulang.
|
||||
- [ ] Info yang sudah usang (refer ke file/fitur yang dihapus)? Bersihkan
|
||||
— dokumen yang setengah benar lebih merusak daripada tidak ada.
|
||||
|
||||
### Rule of thumb ukuran
|
||||
|
||||
Kalau `CLAUDE.md` (atau equivalent) sudah > 300 baris, itu **sinyal kuat**
|
||||
untuk pecah file. Dokumen panduan AI yang ideal: cukup pendek untuk dibaca
|
||||
ulang dalam 1 menit oleh manusia, dengan pointer ke detail untuk AI yang
|
||||
butuh konteks lebih dalam.
|
||||
|
||||
---
|
||||
|
||||
## 12. Aturan Emas
|
||||
|
||||
> **Lebih baik tidak melakukan apa-apa daripada memperburuk kode.**
|
||||
>
|
||||
> Kalau setelah 2 kali percobaan fix masih memunculkan bug baru, **stop**.
|
||||
> Laporkan ke user, jelaskan apa yang sudah dicoba dan kenapa gagal.
|
||||
> Jangan tambal terus — itu cara bug beranak eksponensial.
|
||||
97
CLAUDE.md
97
CLAUDE.md
@@ -1,10 +1,6 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Desa Darmasaba is a full-stack digital village management platform for a village in Badung, Bali. It serves both a public-facing website (`/darmasaba/*`) and an admin CMS (`/admin/*`).
|
||||
Desa Darmasaba adalah platform manajemen desa digital untuk Desa Darmasaba, Badung, Bali. Melayani website publik (`/darmasaba/*`) dan admin CMS (`/admin/*`).
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -20,7 +16,7 @@ bun run test:api # Unit tests (Vitest)
|
||||
bun run test:e2e # E2E tests (Playwright)
|
||||
|
||||
# Database
|
||||
bunx prisma migrate deploy # Apply migrations
|
||||
bunx prisma migrate deploy # Apply migrations
|
||||
bunx prisma migrate dev --name <name> # Create migration
|
||||
bun run prisma/seed.ts # Seed database
|
||||
bunx prisma studio # Interactive DB viewer
|
||||
@@ -29,91 +25,12 @@ bunx prisma studio # Interactive DB viewer
|
||||
bun eslint . --fix
|
||||
```
|
||||
|
||||
## Architecture
|
||||
## Reference Docs
|
||||
|
||||
### Tech Stack
|
||||
- **Framework**: Next.js 15 (App Router) + React 19
|
||||
- **Runtime/Package manager**: Bun (not npm)
|
||||
- **API server**: Elysia.js (mounted at `/api/[[...slugs]]`)
|
||||
- **ORM**: Prisma + PostgreSQL
|
||||
- **UI**: Mantine UI v7-8
|
||||
- **State**: Jotai (atoms), Valtio (proxies), SWR (data fetching)
|
||||
- **Auth**: iron-session + JWT
|
||||
- **File storage**: Local uploads + Seafile (self-hosted)
|
||||
|
||||
### Request Flow
|
||||
|
||||
```
|
||||
Browser → Next.js middleware (src/middleware.ts)
|
||||
→ Public pages: src/app/darmasaba/
|
||||
→ Admin pages: src/app/admin/
|
||||
→ API: src/app/api/[[...slugs]]/route.ts (Elysia.js)
|
||||
└── _lib/*.ts (domain modules)
|
||||
```
|
||||
|
||||
The Elysia server is a single entry point with domain-specific modules: `desa.ts`, `kesehatan.ts`, `ekonomi.ts`, `keamanan.ts`, `lingkungan.ts`, `pendidikan.ts`, `kependudukan.ts`, `ppid.ts`, `inovasi.ts`, `auth/`, `user/`, `fileStorage/`. Swagger docs are auto-generated at `/api/docs`.
|
||||
|
||||
### Domain Modules
|
||||
Each domain (desa, kesehatan, ekonomi, etc.) has:
|
||||
- API handler in `src/app/api/[[...slugs]]/_lib/<domain>.ts`
|
||||
- Admin CMS pages in `src/app/admin/(dashboard)/<domain>/`
|
||||
- Public pages in `src/app/darmasaba/(pages)/<domain>/`
|
||||
|
||||
### Database (Prisma)
|
||||
- Schema at `prisma/schema.prisma` (~2400 lines, 100+ models)
|
||||
- Common model conventions: `@default(cuid())` IDs, `createdAt`/`updatedAt` timestamps, `deletedAt DateTime?` (soft delete), `isActive Boolean @default(true)`
|
||||
- Seeders per-module in `prisma/_seeder_list/`, orchestrated by `prisma/seed.ts`
|
||||
|
||||
### Authentication Flow
|
||||
1. User submits phone → OTP sent (email/SMS)
|
||||
2. OTP validated → JWT created + iron-session stored
|
||||
3. `UserSession` model tracks active sessions
|
||||
4. `src/middleware.ts` validates on each request
|
||||
5. `src/lib/api-auth.ts` handles JWT/session checks in API routes
|
||||
|
||||
### File Handling
|
||||
All uploaded files reference the `FileStorage` Prisma model. Uploads land in `WIBU_UPLOAD_DIR` (default: `uploads/`). Seafile is the external storage fallback.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/middleware.ts` | Route guards and auth |
|
||||
| `src/lib/prisma.ts` | Prisma client singleton |
|
||||
| `src/lib/api-auth.ts` | JWT/session validation |
|
||||
| `src/lib/api-fetch.ts` | Typed fetch wrapper used by frontend |
|
||||
| `src/lib/session.ts` | iron-session config |
|
||||
| `next.config.ts` | Next.js config (cache headers, allowed origins) |
|
||||
| `postcss.config.cjs` | Mantine CSS preset and breakpoints |
|
||||
| `docker-entrypoint.sh` | Runs `prisma migrate deploy` then starts app |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Copy `.env.example` to `.env`. Required variables:
|
||||
|
||||
```env
|
||||
DATABASE_URL="postgresql://..."
|
||||
NEXT_PUBLIC_BASE_URL="/"
|
||||
BASE_SESSION_KEY="..." # random string
|
||||
BASE_TOKEN_KEY="..." # random string
|
||||
SESSION_PASSWORD="..." # min 32 chars
|
||||
SEAFILE_TOKEN="..."
|
||||
SEAFILE_REPO_ID="..."
|
||||
SEAFILE_URL="..."
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
Multi-stage build: `oven/bun:1-debian` → builder → runner. The runner creates a `nextjs` user (UID 1001), exposes port 3000, and mounts `/app/uploads` as a volume. Entrypoint runs migrations automatically.
|
||||
|
||||
## CI/CD
|
||||
|
||||
GitHub Actions workflows in `.github/workflows/`:
|
||||
- `docker-publish.yml` — triggers on `v*` tags, pushes to GHCR
|
||||
- `publish.yml` — manual build & push
|
||||
- `re-pull.yml` — triggers Portainer to redeploy latest image
|
||||
|
||||
To release: tag with `git tag -a v0.1.x -m "..."` and push the tag.
|
||||
- Architecture, request flow, domain modules, key files: @.claude/ARCHITECTURE.md
|
||||
- Database conventions, auth flow, file handling: @.claude/DATABASE.md
|
||||
- Env vars, Docker, CI/CD, releasing: @.claude/DEPLOYMENT.md
|
||||
- AI collaboration contract, rules, and guidelines: @AI-CONTRACT.md
|
||||
|
||||
### Workflow for Code Changes
|
||||
1. **Commit** existing changes before starting new work
|
||||
|
||||
@@ -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
|
||||
|
||||
24
MIND/PLAN/admin-umkm-edit.md
Normal file
24
MIND/PLAN/admin-umkm-edit.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Plan - Admin UMKM & Produk Edit Pages
|
||||
|
||||
## Problem
|
||||
Admin UMKM module list pages have "Edit" buttons that are not functional, and there are no edit pages or update state logic implemented.
|
||||
|
||||
## Strategy
|
||||
1. Update Valtio state in `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts` with `update` modules for UMKM and Produk.
|
||||
2. Delete Valtio state in `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts` with `del` modules for UMKM and Produk.
|
||||
3. Add `onClick` handlers to "Edit" buttons in list pages.
|
||||
4. Create new edit pages.
|
||||
5. Use `ModalKonfirmasiHapus` component for delete actions.
|
||||
6. Verify changes with a successful build.
|
||||
7. Follow deployment workflow.
|
||||
|
||||
## Progress
|
||||
- [x] Update Valtio state with update modules
|
||||
- [x] Update Valtio state with delete modules
|
||||
- [x] Wire edit and delete buttons in list pages
|
||||
- [x] Create UMKM edit page
|
||||
- [x] Create Produk edit page
|
||||
- [x] Build and fix errors
|
||||
- [x] Update version in package.json
|
||||
- [x] Commit and push to branch
|
||||
- [x] Merge to stg and push to remotes
|
||||
19
MIND/PLAN/ai-collaboration-contract.md
Normal file
19
MIND/PLAN/ai-collaboration-contract.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Plan: Add AI Collaboration Contract and Fix UI Issues
|
||||
|
||||
## Background
|
||||
- Need a clear contract for AI collaboration to prevent "exponential bugs".
|
||||
- UI fix for `KegiatanCard` to handle missing images gracefully.
|
||||
|
||||
## Objectives
|
||||
- Add `AI-CONTRACT.md` with guidelines.
|
||||
- Link `AI-CONTRACT.md` in `CLAUDE.md`.
|
||||
- Fix `KegiatanCard.tsx` image rendering.
|
||||
- Bump version to 0.1.48.
|
||||
|
||||
## Implementation Steps
|
||||
1. Create `AI-CONTRACT.md`.
|
||||
2. Update `CLAUDE.md`.
|
||||
3. Update `KegiatanCard.tsx`.
|
||||
4. Bump version in `package.json`.
|
||||
5. Verify build.
|
||||
6. Commit and push.
|
||||
31
MIND/PLAN/fix-and-improve-umkm-module.md
Normal file
31
MIND/PLAN/fix-and-improve-umkm-module.md
Normal 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.
|
||||
90
MIND/PLAN/migrate-kategori-produk-umkm.md
Normal file
90
MIND/PLAN/migrate-kategori-produk-umkm.md
Normal 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.
|
||||
22
MIND/PLAN/refactor-umkm-edit-pages-pattern.md
Normal file
22
MIND/PLAN/refactor-umkm-edit-pages-pattern.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Plan - Refactor UMKM Edit Pages Pattern
|
||||
|
||||
## Problem
|
||||
The edit pages for UMKM (Data UMKM and Produk) use an older UI pattern. The user wants to align them with the newer pattern used in the Berita edit page.
|
||||
|
||||
## Strategy
|
||||
1. Analyze the pattern in `src/app/admin/(dashboard)/desa/berita/list-berita/[id]/edit/page.tsx`.
|
||||
2. Refactor `src/app/admin/(dashboard)/ekonomi/umkm/produk/[id]/edit/page.tsx` to match the pattern.
|
||||
3. Refactor `src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/[id]/edit/page.tsx` to match the pattern.
|
||||
4. Add "Batal" (Reset) functionality to both pages.
|
||||
5. Standardize UI components (Header, Paper, Dropzone, Action buttons).
|
||||
6. Verify with a production build.
|
||||
7. Follow the versioning and deployment workflow.
|
||||
|
||||
## Progress
|
||||
- [x] Analyze Berita edit page pattern
|
||||
- [x] Refactor UMKM Produk edit page (with interfaces)
|
||||
- [x] Refactor Data UMKM edit page (with interfaces)
|
||||
- [x] Run build and fix any errors
|
||||
- [ ] Update version in package.json
|
||||
- [ ] Commit and push to task branch
|
||||
- [ ] Merge to stg branch
|
||||
14
MIND/PLAN/task-admin-umkm-edit.md
Normal file
14
MIND/PLAN/task-admin-umkm-edit.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Task - Admin UMKM & Produk Edit functionality
|
||||
|
||||
## Description
|
||||
Implement Edit and Delete functionality for UMKM and Produk modules in the admin dashboard.
|
||||
|
||||
## Tasks
|
||||
- [x] Update Valtio state with update/delete modules for UMKM and Produk
|
||||
- [x] Wire edit/delete buttons in `src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/page.tsx`
|
||||
- [x] Wire edit/delete buttons in `src/app/admin/(dashboard)/ekonomi/umkm/produk/page.tsx`
|
||||
- [x] Create edit page for UMKM at `src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/[id]/edit/page.tsx`
|
||||
- [x] Create edit page for Produk at `src/app/admin/(dashboard)/ekonomi/umkm/produk/[id]/edit/page.tsx`
|
||||
- [x] Ensure `ModalKonfirmasiHapus` is used correctly
|
||||
- [x] Run `bun run build` and fix errors
|
||||
- [x] Push to task branch and merge to stg
|
||||
11
MIND/PLAN/task-ai-collaboration-contract.md
Normal file
11
MIND/PLAN/task-ai-collaboration-contract.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Task: Add AI Collaboration Contract and Fix UI Issues
|
||||
|
||||
## Status
|
||||
- [x] Create `AI-CONTRACT.md`
|
||||
- [x] Update `CLAUDE.md` to reference the contract
|
||||
- [x] Fix `KegiatanCard.tsx` image rendering logic
|
||||
- [x] Bump version in `package.json` to 0.1.48
|
||||
- [x] Verify build successful
|
||||
- [ ] Commit changes
|
||||
- [ ] Create branch and push
|
||||
- [ ] Merge to `stg`
|
||||
18
MIND/PLAN/task-fix-and-improve-umkm-module.md
Normal file
18
MIND/PLAN/task-fix-and-improve-umkm-module.md
Normal 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.
|
||||
12
MIND/PLAN/task-migrate-kategori-produk-umkm.md
Normal file
12
MIND/PLAN/task-migrate-kategori-produk-umkm.md
Normal 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`.
|
||||
12
MIND/PLAN/task-refactor-umkm-edit-pages-pattern.md
Normal file
12
MIND/PLAN/task-refactor-umkm-edit-pages-pattern.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Task - Refactor UMKM Edit Pages Pattern
|
||||
|
||||
Refactor Data UMKM and Produk edit pages to match the Berita edit page UI pattern and logic.
|
||||
|
||||
## Steps
|
||||
1. [x] Analyze `berita/list-berita/[id]/edit/page.tsx` for the desired pattern.
|
||||
2. [x] Implement the pattern in `ekonomi/umkm/produk/[id]/edit/page.tsx` (using interfaces).
|
||||
3. [x] Implement the pattern in `ekonomi/umkm/data-umkm/[id]/edit/page.tsx` (using interfaces).
|
||||
4. [x] Run `bun run build` to verify.
|
||||
5. [x] Update `package.json` version.
|
||||
6. [x] Commit with message: "feat(admin): refactor UMKM edit pages to match berita pattern with interfaces".
|
||||
7. [ ] Create summary in `MIND/SUMMARY/refactor-umkm-edit-pages-pattern-summary.md`.
|
||||
15
MIND/SUMMARY/admin-umkm-edit-summary.md
Normal file
15
MIND/SUMMARY/admin-umkm-edit-summary.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Summary - Admin UMKM & Produk Edit functionality
|
||||
|
||||
## Changes
|
||||
- **Valtio State**: Added `update` and `del` methods for UMKM and Produk modules in `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts`.
|
||||
- **List Pages**: Updated `data-umkm/page.tsx` and `produk/page.tsx` to handle edit (navigation) and delete (confirmation modal + state action).
|
||||
- **Edit Pages**:
|
||||
- Created `src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/[id]/edit/page.tsx`
|
||||
- Created `src/app/admin/(dashboard)/ekonomi/umkm/produk/[id]/edit/page.tsx`
|
||||
- **Component**: Integrated `ModalKonfirmasiHapus` with named import.
|
||||
- **Version**: Bumped to `0.1.21`.
|
||||
|
||||
## Verification
|
||||
- Successfully ran `bun run build`.
|
||||
- Pushed to `tasks/admin-umkm-edit/implement-edit-delete/2026-04-24-11-44`.
|
||||
- Merged to `stg` and pushed to `origin` and `deploy` remotes.
|
||||
15
MIND/SUMMARY/fix-and-improve-umkm-module-summary.md
Normal file
15
MIND/SUMMARY/fix-and-improve-umkm-module-summary.md
Normal 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.
|
||||
26
MIND/SUMMARY/migrate-kategori-produk-umkm-summary.md
Normal file
26
MIND/SUMMARY/migrate-kategori-produk-umkm-summary.md
Normal 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`
|
||||
14
MIND/SUMMARY/refactor-umkm-edit-pages-pattern-summary.md
Normal file
14
MIND/SUMMARY/refactor-umkm-edit-pages-pattern-summary.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Summary - Refactor UMKM Edit Pages Pattern
|
||||
|
||||
## Changes
|
||||
1. **UMKM Produk Edit Page**: Refactored `src/app/admin/(dashboard)/ekonomi/umkm/produk/[id]/edit/page.tsx` to match the "Berita" edit page pattern. Added explicit `ProdukData` and `ProdukForm` interfaces. Added Reset ("Batal") functionality, standardized header, paper, and dropzone styling, and used `EditEditor`.
|
||||
2. **Data UMKM Edit Page**: Refactored `src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/[id]/edit/page.tsx` with the same pattern and interfaces (`UmkmData`, `UmkmForm`).
|
||||
3. **Type Safety**: Improved type safety by using explicit interfaces for data fetching and form state management.
|
||||
4. **UI Consistency**: Standardized colors and component usage across UMKM edit pages.
|
||||
5. **UX Improvement**: Added a "Batal" button that resets the form to its original data state.
|
||||
6. **Build Verification**: Confirmed that the project builds successfully with `bun run build`.
|
||||
|
||||
## Verification Results
|
||||
- `bun run build`: Success.
|
||||
- Pattern Match: Both pages now follow the consistent layout and logic of the Berita edit page.
|
||||
- Reset Functionality: Implemented and verified via logic review.
|
||||
57
bun.lock
57
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "desa-darmasaba",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.48",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
48
prisma/_seeder_list/desa/seed_kegiatan_desa.ts
Normal file
48
prisma/_seeder_list/desa/seed_kegiatan_desa.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const kategoriKegiatanJson = loadJsonData("desa/kegiatan-desa/kategori-kegiatan.json");
|
||||
const kegiatanDesaJson = loadJsonData("desa/kegiatan-desa/kegiatan-desa.json");
|
||||
|
||||
export async function seedKegiatanDesa() {
|
||||
console.log("🔄 Seeding Kategori Kegiatan Desa...");
|
||||
|
||||
for (const k of kategoriKegiatanJson) {
|
||||
await prisma.kategoriKegiatan.upsert({
|
||||
where: { id: k.id },
|
||||
update: { nama: k.nama },
|
||||
create: { id: k.id, nama: k.nama },
|
||||
});
|
||||
console.log(` ✅ Kategori: ${k.nama}`);
|
||||
}
|
||||
|
||||
console.log("🔄 Seeding Kegiatan Desa...");
|
||||
|
||||
for (const item of kegiatanDesaJson) {
|
||||
await prisma.kegiatanDesa.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
judul: item.judul,
|
||||
deskripsiSingkat: item.deskripsiSingkat,
|
||||
deskripsiLengkap: item.deskripsiLengkap,
|
||||
tanggal: new Date(item.tanggal),
|
||||
lokasi: item.lokasi,
|
||||
partisipan: item.partisipan,
|
||||
kategoriKegiatanId: item.kategoriKegiatanId,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
judul: item.judul,
|
||||
deskripsiSingkat: item.deskripsiSingkat,
|
||||
deskripsiLengkap: item.deskripsiLengkap,
|
||||
tanggal: new Date(item.tanggal),
|
||||
lokasi: item.lokasi,
|
||||
partisipan: item.partisipan,
|
||||
kategoriKegiatanId: item.kategoriKegiatanId,
|
||||
},
|
||||
});
|
||||
console.log(` ✅ Kegiatan: ${item.judul}`);
|
||||
}
|
||||
|
||||
console.log("🎉 Kegiatan Desa seed selesai");
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -28,6 +28,7 @@ export async function seedProgramKesehatan() {
|
||||
name: p.name,
|
||||
deskripsiSingkat: p.deskripsiSingkat,
|
||||
deskripsi: p.deskripsi,
|
||||
persentase: p.persentase ?? 0,
|
||||
imageId,
|
||||
},
|
||||
create: {
|
||||
@@ -35,6 +36,7 @@ export async function seedProgramKesehatan() {
|
||||
name: p.name,
|
||||
deskripsiSingkat: p.deskripsiSingkat,
|
||||
deskripsi: p.deskripsi,
|
||||
persentase: p.persentase ?? 0,
|
||||
imageId,
|
||||
},
|
||||
});
|
||||
|
||||
24
prisma/_seeder_list/kesehatan/seed_ringkasan_kesehatan.ts
Normal file
24
prisma/_seeder_list/kesehatan/seed_ringkasan_kesehatan.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
const SINGLETON_ID = "ringkasan_kesehatan_desa_001";
|
||||
|
||||
export async function seedRingkasanKesehatan() {
|
||||
console.log("🔄 Seeding Ringkasan Kesehatan Desa...");
|
||||
|
||||
await prisma.ringkasanKesehatanDesa.upsert({
|
||||
where: { id: SINGLETON_ID },
|
||||
update: {
|
||||
ibuHamilAkh: 87,
|
||||
balitaTerdaftar: 342,
|
||||
alertStunting: 12,
|
||||
},
|
||||
create: {
|
||||
id: SINGLETON_ID,
|
||||
ibuHamilAkh: 87,
|
||||
balitaTerdaftar: 342,
|
||||
alertStunting: 12,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("✅ Ringkasan Kesehatan Desa seeded");
|
||||
}
|
||||
22
prisma/_seeder_list/pendidikan/seed_beasiswa_config.ts
Normal file
22
prisma/_seeder_list/pendidikan/seed_beasiswa_config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
const SINGLETON_ID = "beasiswa_config_desa_001";
|
||||
|
||||
export async function seedBeasiswaConfig() {
|
||||
console.log("🔄 Seeding Beasiswa Config...");
|
||||
|
||||
await prisma.beasiswaConfig.upsert({
|
||||
where: { id: SINGLETON_ID },
|
||||
update: {
|
||||
tahunAjaran: "2025/2026",
|
||||
danaTersalurkan: BigInt(1200000000),
|
||||
},
|
||||
create: {
|
||||
id: SINGLETON_ID,
|
||||
tahunAjaran: "2025/2026",
|
||||
danaTersalurkan: BigInt(1200000000),
|
||||
},
|
||||
});
|
||||
|
||||
console.log("✅ Beasiswa Config seeded");
|
||||
}
|
||||
22
prisma/data/desa/kegiatan-desa/kategori-kegiatan.json
Normal file
22
prisma/data/desa/kegiatan-desa/kategori-kegiatan.json
Normal file
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"id": "katbudaya000001",
|
||||
"nama": "Budaya"
|
||||
},
|
||||
{
|
||||
"id": "katsosia000001",
|
||||
"nama": "Sosial"
|
||||
},
|
||||
{
|
||||
"id": "katolahraga0001",
|
||||
"nama": "Olahraga"
|
||||
},
|
||||
{
|
||||
"id": "katkeagamaan01",
|
||||
"nama": "Keagamaan"
|
||||
},
|
||||
{
|
||||
"id": "katpemberday01",
|
||||
"nama": "Pemberdayaan Masyarakat"
|
||||
}
|
||||
]
|
||||
52
prisma/data/desa/kegiatan-desa/kegiatan-desa.json
Normal file
52
prisma/data/desa/kegiatan-desa/kegiatan-desa.json
Normal file
@@ -0,0 +1,52 @@
|
||||
[
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567801",
|
||||
"judul": "Hari Kesaktian Pancasila",
|
||||
"deskripsiSingkat": "Peringatan Hari Kesaktian Pancasila di Balai Desa Darmasaba sebagai momentum menguatkan nilai-nilai Pancasila dalam kehidupan bermasyarakat.",
|
||||
"deskripsiLengkap": "Pemerintah Desa Darmasaba menyelenggarakan upacara peringatan Hari Kesaktian Pancasila yang dihadiri oleh perangkat desa, tokoh masyarakat, perwakilan pemuda, dan warga desa. Kegiatan ini sebagai bentuk penghormatan atas perjuangan bangsa dan komitmen untuk terus mengamalkan nilai-nilai Pancasila dalam kehidupan sehari-hari di lingkungan desa.",
|
||||
"tanggal": "2025-10-01T00:00:00.000Z",
|
||||
"lokasi": "Balai Desa",
|
||||
"partisipan": 250,
|
||||
"kategoriKegiatanId": "katbudaya000001"
|
||||
},
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567802",
|
||||
"judul": "Festival Budaya Desa",
|
||||
"deskripsiSingkat": "Festival tahunan menampilkan kesenian dan budaya lokal Bali sebagai upaya pelestarian warisan budaya Desa Darmasaba.",
|
||||
"deskripsiLengkap": "Festival Budaya Desa Darmasaba merupakan agenda tahunan yang menampilkan berbagai pertunjukan seni tradisional Bali seperti tari Kecak, Legong, Barong, serta pameran kerajinan tangan dan kuliner khas desa. Festival ini bertujuan untuk melestarikan warisan budaya leluhur, memperkenalkan kekayaan budaya kepada generasi muda, dan meningkatkan daya tarik wisata budaya Desa Darmasaba.",
|
||||
"tanggal": "2026-05-20T00:00:00.000Z",
|
||||
"lokasi": "Lapangan Desa",
|
||||
"partisipan": 500,
|
||||
"kategoriKegiatanId": "katbudaya000001"
|
||||
},
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567803",
|
||||
"judul": "Perayaan HUT Desa",
|
||||
"deskripsiSingkat": "Perayaan hari ulang tahun Desa Darmasaba dengan berbagai kegiatan seni budaya dan olahraga yang melibatkan seluruh lapisan masyarakat.",
|
||||
"deskripsiLengkap": "Hari Ulang Tahun Desa Darmasaba dirayakan dengan serangkaian kegiatan meriah meliputi upacara adat, pertunjukan seni budaya Bali, lomba olahraga tradisional, dan pameran produk unggulan desa. Perayaan ini menjadi ajang mempererat kebersamaan warga, mengenang sejarah desa, dan merayakan pencapaian pembangunan yang telah diraih bersama.",
|
||||
"tanggal": "2026-08-17T00:00:00.000Z",
|
||||
"lokasi": "Balai Desa",
|
||||
"partisipan": 600,
|
||||
"kategoriKegiatanId": "katbudaya000001"
|
||||
},
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567804",
|
||||
"judul": "Gotong Royong Pembersihan Desa",
|
||||
"deskripsiSingkat": "Kegiatan gotong royong rutin membersihkan lingkungan desa sebagai wujud kebersamaan dan kepedulian masyarakat terhadap kebersihan.",
|
||||
"deskripsiLengkap": "Kegiatan gotong royong pembersihan lingkungan desa dilaksanakan secara rutin setiap bulan dengan melibatkan seluruh warga, kader PKK, karang taruna, dan perangkat desa. Kegiatan ini mencakup pembersihan jalan desa, sungai, tempat ibadah, dan fasilitas umum sebagai bentuk nyata kepedulian masyarakat terhadap kebersihan dan keindahan lingkungan.",
|
||||
"tanggal": "2026-03-15T00:00:00.000Z",
|
||||
"lokasi": "Seluruh Wilayah Desa",
|
||||
"partisipan": 400,
|
||||
"kategoriKegiatanId": "katsosia000001"
|
||||
},
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567805",
|
||||
"judul": "Turnamen Olahraga Antar Banjar",
|
||||
"deskripsiSingkat": "Turnamen olahraga tahunan antar banjar untuk mempererat tali persaudaraan dan mendorong gaya hidup sehat masyarakat desa.",
|
||||
"deskripsiLengkap": "Turnamen olahraga antar banjar di Desa Darmasaba menampilkan berbagai cabang olahraga seperti bola voli, bulu tangkis, tenis meja, dan sepak bola. Kegiatan ini diikuti oleh perwakilan dari setiap banjar di desa dan bertujuan untuk menumbuhkan semangat sportivitas, mempererat silaturahmi antar warga, serta mendorong pola hidup sehat melalui olahraga.",
|
||||
"tanggal": "2026-06-10T00:00:00.000Z",
|
||||
"lokasi": "Lapangan Desa",
|
||||
"partisipan": 350,
|
||||
"kategoriKegiatanId": "katolahraga0001"
|
||||
}
|
||||
]
|
||||
@@ -3,8 +3,57 @@
|
||||
"id": "cmkanjnmx0006vntz1cn7owpb",
|
||||
"name": "Posyandu Pudak Amara",
|
||||
"nomor": "(0361) 8463263",
|
||||
"deskripsi": "<p>Posyandu Pudak Amara merupakan salah satu posyandu aktif di Desa Darmasaba dan pernah berkompetisi dalam lomba kader dan posyandu berprestasi tingkat Provinsi Bali tahun 2025.</p><p>Kegiatan ini melibatkan kader posyandu serta didampingi pihak desa dan puskesmas setempat untuk meningkatkan pelayanan kesehatan ibu dan anak.</p>",
|
||||
"jadwalPelayanan": "<p>Setiap bulan pada satu hari tertentu (mis. minggu ke-2): 08:00 – 12:00 WITA (posyandu balita & ibu hamil)</p>",
|
||||
"deskripsi": "<p>Posyandu Pudak Amara merupakan salah satu posyandu aktif di Desa Darmasaba dan pernah berkompetisi dalam lomba kader dan posyandu berprestasi tingkat Provinsi Bali tahun 2025.</p>",
|
||||
"jadwalPelayanan": "Senin, 10 Feb 2026, 08:00 - 11:00 WITA",
|
||||
"imageName": "TDQReg1lQ73s39crXW0ra-mobile.webp"
|
||||
},
|
||||
{
|
||||
"id": "posyandu_mawar_001",
|
||||
"name": "Posyandu Mawar",
|
||||
"nomor": "(0361) 8463264",
|
||||
"deskripsi": "<p>Posyandu Mawar melayani kesehatan ibu dan anak di wilayah Banjar Mawar, Desa Darmasaba, dengan fokus pada pemantauan tumbuh kembang balita dan kesehatan ibu hamil.</p>",
|
||||
"jadwalPelayanan": "Senin, 15 Feb 2026, 08:00 - 11:00 WITA"
|
||||
},
|
||||
{
|
||||
"id": "posyandu_melati_001",
|
||||
"name": "Posyandu Melati",
|
||||
"nomor": "(0361) 8463265",
|
||||
"deskripsi": "<p>Posyandu Melati berperan aktif dalam pelayanan kesehatan dasar masyarakat di Banjar Melati, meliputi imunisasi, penimbangan balita, dan konsultasi gizi.</p>",
|
||||
"jadwalPelayanan": "Selasa, 16 Feb 2026, 08:00 - 11:00 WITA"
|
||||
},
|
||||
{
|
||||
"id": "posyandu_dahlia_001",
|
||||
"name": "Posyandu Dahlia",
|
||||
"nomor": "(0361) 8463266",
|
||||
"deskripsi": "<p>Posyandu Dahlia aktif melayani masyarakat Banjar Dahlia dengan program unggulan pemantauan stunting dan pemberian makanan tambahan bagi balita berisiko.</p>",
|
||||
"jadwalPelayanan": "Rabu, 17 Feb 2026, 08:00 - 11:00 WITA"
|
||||
},
|
||||
{
|
||||
"id": "posyandu_anggrek_001",
|
||||
"name": "Posyandu Anggrek",
|
||||
"nomor": "(0361) 8463267",
|
||||
"deskripsi": "<p>Posyandu Anggrek melayani ibu hamil, ibu menyusui, dan balita di wilayah Banjar Anggrek dengan dukungan tenaga kesehatan dari Puskesmas Abiansemal 3.</p>",
|
||||
"jadwalPelayanan": "Kamis, 18 Feb 2026, 08:00 - 11:00 WITA"
|
||||
},
|
||||
{
|
||||
"id": "posyandu_kamboja_001",
|
||||
"name": "Posyandu Kamboja",
|
||||
"nomor": "(0361) 8463268",
|
||||
"deskripsi": "<p>Posyandu Kamboja hadir untuk mendukung kesehatan masyarakat Banjar Kamboja melalui layanan pemeriksaan rutin, imunisasi lengkap, dan edukasi gizi keluarga.</p>",
|
||||
"jadwalPelayanan": "Jumat, 19 Feb 2026, 08:00 - 11:00 WITA"
|
||||
},
|
||||
{
|
||||
"id": "posyandu_melur_001",
|
||||
"name": "Posyandu Melur",
|
||||
"nomor": "(0361) 8463269",
|
||||
"deskripsi": "<p>Posyandu Melur aktif memberikan layanan kesehatan preventif bagi ibu dan anak di Banjar Melur, termasuk deteksi dini stunting dan pemantauan gizi balita.</p>",
|
||||
"jadwalPelayanan": "Sabtu, 20 Feb 2026, 08:00 - 11:00 WITA"
|
||||
},
|
||||
{
|
||||
"id": "posyandu_kenanga_001",
|
||||
"name": "Posyandu Kenanga",
|
||||
"nomor": "(0361) 8463270",
|
||||
"deskripsi": "<p>Posyandu Kenanga melayani masyarakat Banjar Kenanga dengan program kesehatan ibu dan anak, pemberian vitamin A, dan konseling laktasi bagi ibu menyusui.</p>",
|
||||
"jadwalPelayanan": "Senin, 23 Feb 2026, 08:00 - 11:00 WITA"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
[
|
||||
{
|
||||
"id": "prog_kes_imunisasi_001",
|
||||
"name": "Imunisasi Lengkap",
|
||||
"deskripsiSingkat": "<p>Persentase balita yang mendapatkan imunisasi lengkap sesuai jadwal di Desa Darmasaba.</p>",
|
||||
"deskripsi": "<p>Program imunisasi lengkap mencakup vaksin BCG, DPT-HB-Hib, Polio, Campak, dan PCV yang diberikan kepada seluruh balita di Desa Darmasaba melalui posyandu dan puskesmas setempat untuk membangun kekebalan komunitas.</p>",
|
||||
"persentase": 92
|
||||
},
|
||||
{
|
||||
"id": "prog_kes_pemeriksaan_001",
|
||||
"name": "Pemeriksaan Rutin",
|
||||
"deskripsiSingkat": "<p>Persentase ibu hamil dan balita yang melakukan pemeriksaan kesehatan rutin secara berkala.</p>",
|
||||
"deskripsi": "<p>Pemeriksaan kesehatan rutin dilakukan setiap bulan di posyandu dan puskesmas, mencakup penimbangan berat badan, pengukuran tinggi badan, pemeriksaan tekanan darah ibu hamil, dan konsultasi gizi untuk memantau perkembangan kesehatan masyarakat.</p>",
|
||||
"persentase": 88
|
||||
},
|
||||
{
|
||||
"id": "prog_kes_gizi_001",
|
||||
"name": "Gizi Baik",
|
||||
"deskripsiSingkat": "<p>Persentase balita dengan status gizi baik berdasarkan hasil pemantauan tumbuh kembang.</p>",
|
||||
"deskripsi": "<p>Program pemantauan gizi balita dilaksanakan melalui posyandu dengan penimbangan bulanan dan pengukuran tinggi badan. Balita dengan status gizi baik menunjukkan berat dan tinggi badan sesuai standar WHO, didukung dengan program pemberian makanan tambahan bagi balita berisiko.</p>",
|
||||
"persentase": 86
|
||||
},
|
||||
{
|
||||
"id": "prog_kes_stunting_001",
|
||||
"name": "Target Stunting",
|
||||
"deskripsiSingkat": "<p>Persentase balita yang teridentifikasi berisiko stunting dan perlu penanganan khusus.</p>",
|
||||
"deskripsi": "<p>Penanganan stunting di Desa Darmasaba dilakukan melalui deteksi dini, intervensi gizi spesifik, dan intervensi gizi sensitif. Program ini melibatkan kader posyandu, bidan desa, dan puskesmas untuk memantau pertumbuhan balita secara berkala dan memberikan penanganan tepat.</p>",
|
||||
"persentase": 14
|
||||
},
|
||||
{
|
||||
"id": "cmkawkji50002vn6yzyrlqhh1",
|
||||
"name": "Gerakan Kulkul PKK dan Posyandu Desa Darmasaba",
|
||||
"deskripsiSingkat": "<p>Kegiatan bersama PKK dan Posyandu untuk meningkatkan pelayanan kesehatan masyarakat.</p>",
|
||||
"deskripsi": "<p>Pada hari Minggu, 11 Januari 2025, Pemerintah Desa Darmasaba melalui TP PKK dan TP Posyandu melaksanakan kegiatan Gerakan Kulkul PKK dan Posyandu yang berlangsung serentak di seluruh wilayah Desa Darmasaba untuk memperkuat pelayanan kesehatan dasar dan peningkatan partisipasi masyarakat dalam program Posyandu.</p>",
|
||||
"persentase": 0,
|
||||
"imageName": "hLeF0GRFZqDUngZnDMAAk-mobile.webp"
|
||||
},
|
||||
{
|
||||
@@ -11,6 +40,7 @@
|
||||
"name": "Pendampingan Kunjungan Rumah oleh Puskesmas Abiansemal 3",
|
||||
"deskripsiSingkat": "<p>Pendataan kesehatan penyandang disabilitas lewat kunjungan rumah di Desa Darmasaba.</p>",
|
||||
"deskripsi": "<p>Pemerintah Desa Darmasaba bersama Kelian Banjar Dinas dan kader kesehatan mendampingi kegiatan kunjungan rumah yang dilaksanakan oleh Puskesmas Abiansemal 3 pada 21 Juli 2025, difokuskan pada pendataan dan pemantauan kondisi kesehatan penyandang disabilitas di Banjar Bersih, Desa Darmasaba.</p>",
|
||||
"persentase": 0,
|
||||
"imageName": "hyyTFi8EApjzFEZ9EvJgB-mobile.webp"
|
||||
},
|
||||
{
|
||||
@@ -18,6 +48,7 @@
|
||||
"name": "Kegiatan Aksi Sosial Tim Penggerak Posyandu Provinsi Bali di Desa Darmasaba",
|
||||
"deskripsiSingkat": "<p>Aksi sosial TP Posyandu Bali untuk memperkuat pelayanan posyandu di desa.</p>",
|
||||
"deskripsi": "<p>Pada 10 Desember 2025, Desa Darmasaba menjadi lokasi pelaksanaan Aksi Sosial Tim Penggerak Posyandu Provinsi Bali yang bertujuan memperkuat pelayanan Posyandu serta meningkatkan kesejahteraan masyarakat, khususnya keluarga dan balita.</p>",
|
||||
"persentase": 0,
|
||||
"imageName": "l4qsUEw2JiclGAkkrXp9g-mobile.webp"
|
||||
},
|
||||
{
|
||||
@@ -25,6 +56,7 @@
|
||||
"name": "Inovasi BAJRA dalam Penanggulangan Rabies",
|
||||
"deskripsiSingkat": "<p>Program BAJRA untuk penanggulangan rabies di Desa Darmasaba.</p>",
|
||||
"deskripsi": "<p>Desa Darmasaba mengembangkan inovasi BAJRA (Bersama Jaga Rabies), sebuah program berbasis komunitas untuk penanggulangan rabies yang mengintegrasikan pelaporan cepat masyarakat, edukasi berkelanjutan dan koordinasi lintas sektor antara kesehatan hewan, manusia, dan pemerintahan desa.</p>",
|
||||
"persentase": 0,
|
||||
"imageName": "Gc79mlIlGuoRQuTqskFj--mobile.webp"
|
||||
},
|
||||
{
|
||||
@@ -32,6 +64,7 @@
|
||||
"name": "Posyandu Pudak Amara Berkompetisi",
|
||||
"deskripsiSingkat": "<p>Partisipasi Posyandu Pudak Amara dalam lomba prestasi Posyandu tingkat provinsi.</p>",
|
||||
"deskripsi": "<p>Kader Posyandu Pudak Amara Br. Cabe mendapat pendampingan dari Perbekel Darmasaba, Dinas Kesehatan Kab. Badung, Puskesmas Abiansemal III, dan Pustu Desa Darmasaba dalam ajang lomba kader dan Posyandu berprestasi tingkat Provinsi Bali tahun 2025.</p>",
|
||||
"persentase": 0,
|
||||
"imageName": "OsMY3AYPyGC_CoN1xUjOn-mobile.webp"
|
||||
},
|
||||
{
|
||||
@@ -39,13 +72,15 @@
|
||||
"name": "Outbound Kader Posyandu Darmasaba",
|
||||
"deskripsiSingkat": "<p>Program pembinaan dan pengembangan kapasitas kader Posyandu.</p>",
|
||||
"deskripsi": "<p>Pemdes Darmasaba melaksanakan kegiatan Outbound Posyandu untuk meningkatkan kapasitas dan wawasan Kader Posyandu se-Desa Darmasaba sebagai bagian dari upaya peningkatan kualitas pelayanan kesehatan dasar di masyarakat.</p>",
|
||||
"persentase": 0,
|
||||
"imageName": "M9QlgVKIEfCdY3g4F_tRZ-mobile.webp"
|
||||
},
|
||||
{
|
||||
"id": "cmkdu8ki10004vn4lpbxm2zqo",
|
||||
"name": "PEMBANGUNAN JAMBAN BAGI MASYARAKAT",
|
||||
"deskripsiSingkat": "<p>Program pengadaan jamban bagi Masyarakat ini diharapkan menjadi stimulus agar masyarakat peduli terhadap lingkungan sehat sehingga Badung Open Defection Free atau terbebas dari buang air besar di tempat terbuka dapat terwujud.</p>",
|
||||
"deskripsi": "<p>Desa Darmasaba sebagai desa yang berkomitmen selalu selaras dengan pembangunan Pemerintah Kabupaten Badung pada tahun anggaran 2023 ini turut ambil bagian dalam menyukseskan program Bupati Badung I Nyoman Giri Prasta, S.Sos dalam bidang kesehatan sanitasi masyarakat. Program pengadaan jamban bagi Masyarakat ini diharapkan menjadi stimulus agar masyarakat peduli terhadap lingkungan sehat sehingga Badung Open Defection Free atau terbebas dari buang air besar di tempat terbuka dapat terwujud.</p><p style=\"text-align: justify\">Pemberian bantuan jamban ini dilaksanakan di 11 banjar dengan menyasar 22 keluarga yang memang belum memiliki jamban yang sumber dananya sepenuhnya dari APBDes Darmasaba T. A. 2023. Pembangunan Jamban bagi Masyarakat ini juga menjadi bukti komitmen Pemerintah Desa Darmasaba dalam melaksanakan salah satu visi mewujudkan masyarakat yang sejahtera dan berbudaya untuk menjaga lingkungan yang bersih dan sehat.</p>",
|
||||
"deskripsi": "<p>Desa Darmasaba sebagai desa yang berkomitmen selalu selaras dengan pembangunan Pemerintah Kabupaten Badung pada tahun anggaran 2023 ini turut ambil bagian dalam menyukseskan program Bupati Badung I Nyoman Giri Prasta, S.Sos dalam bidang kesehatan sanitasi masyarakat.</p>",
|
||||
"persentase": 0,
|
||||
"imageName": "6DQbAvn0St-xHdPGW3vpY-mobile.webp"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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 $$;
|
||||
|
||||
@@ -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 $$;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
@@ -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");
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Add persentase field to ProgramKesehatan (untuk Statistik Kesehatan bar chart)
|
||||
ALTER TABLE "ProgramKesehatan" ADD COLUMN "persentase" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Create BeasiswaConfig (untuk dana tersalurkan + tahun ajaran beasiswa desa)
|
||||
CREATE TABLE "BeasiswaConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tahunAjaran" TEXT NOT NULL,
|
||||
"danaTersalurkan" BIGINT NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "BeasiswaConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Create RingkasanKesehatanDesa (untuk stat cards: ibu hamil, balita, stunting)
|
||||
CREATE TABLE "RingkasanKesehatanDesa" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ibuHamilAkh" INTEGER NOT NULL DEFAULT 0,
|
||||
"balitaTerdaftar" INTEGER NOT NULL DEFAULT 0,
|
||||
"alertStunting" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "RingkasanKesehatanDesa_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -1213,6 +1213,7 @@ model ProgramKesehatan {
|
||||
name String
|
||||
deskripsiSingkat String
|
||||
deskripsi String
|
||||
persentase Int @default(0)
|
||||
image FileStorage? @relation(fields: [imageId], references: [id])
|
||||
imageId String?
|
||||
createdAt DateTime @default(now())
|
||||
@@ -1445,8 +1446,8 @@ model PasarDesa {
|
||||
deletedAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
kategoriProduk KategoriProduk @relation(fields: [kategoriProdukId], references: [id])
|
||||
kategoriProdukId String
|
||||
kategoriProduk KategoriProdukUmkm? @relation(fields: [kategoriProdukId], references: [id])
|
||||
kategoriProdukId String?
|
||||
KategoriToPasar KategoriToPasar[]
|
||||
}
|
||||
|
||||
@@ -1458,8 +1459,6 @@ model KategoriProduk {
|
||||
deletedAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
KategoriToPasar KategoriToPasar[]
|
||||
PasarDesa PasarDesa[]
|
||||
Umkm Umkm[]
|
||||
}
|
||||
|
||||
model KategoriToPasar {
|
||||
@@ -2424,23 +2423,35 @@ model MusikDesa {
|
||||
}
|
||||
|
||||
model Umkm {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
nama String
|
||||
pemilik String
|
||||
deskripsi String?
|
||||
alamat String?
|
||||
kontak String?
|
||||
image FileStorage? @relation("UmkmImage", fields: [imageId], references: [id])
|
||||
image FileStorage? @relation("UmkmImage", fields: [imageId], references: [id])
|
||||
imageId String?
|
||||
kategori KategoriProduk @relation(fields: [kategoriId], references: [id])
|
||||
kategori KategoriProdukUmkm @relation(fields: [kategoriId], references: [id])
|
||||
kategoriId String
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
produk PasarDesa[]
|
||||
}
|
||||
|
||||
model KategoriProdukUmkm {
|
||||
id String @id @default(cuid())
|
||||
nama String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
Umkm Umkm[]
|
||||
PasarDesa PasarDesa[]
|
||||
}
|
||||
|
||||
|
||||
model PenjualanProduk {
|
||||
id String @id @default(cuid())
|
||||
produk PasarDesa @relation(fields: [produkId], references: [id])
|
||||
@@ -2460,3 +2471,24 @@ model PenjualanProduk {
|
||||
@@index([tanggal])
|
||||
}
|
||||
|
||||
// ========================================= BEASISWA CONFIG ========================================= //
|
||||
model BeasiswaConfig {
|
||||
id String @id @default(cuid())
|
||||
tahunAjaran String
|
||||
danaTersalurkan BigInt @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
// ========================================= RINGKASAN KESEHATAN DESA ========================================= //
|
||||
model RingkasanKesehatanDesa {
|
||||
id String @id @default(cuid())
|
||||
ibuHamilAkh Int @default(0)
|
||||
balitaTerdaftar Int @default(0)
|
||||
alertStunting Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import prisma from "@/lib/prisma";
|
||||
import { seedBerita } from "./_seeder_list/desa/berita/seed_berita";
|
||||
import { seedKegiatanDesa } from "./_seeder_list/desa/seed_kegiatan_desa";
|
||||
import { seedFoto } from "./_seeder_list/desa/gallery/foto/seed_foto";
|
||||
import { seedVideo } from "./_seeder_list/desa/gallery/video/seed_video";
|
||||
import { seedLayanan } from "./_seeder_list/desa/layanan/seed_layanan";
|
||||
@@ -46,6 +47,7 @@ import { seedProgramKesehatan } from "./_seeder_list/kesehatan/program-kesehatan
|
||||
import { seedPuskesmas } from "./_seeder_list/kesehatan/puskesmas/seed_puskesmas";
|
||||
import { seedGrafikKepuasan } from "./_seeder_list/kesehatan/seed_grafik_kepuasan";
|
||||
import { seedKelahiranKematian } from "./_seeder_list/kesehatan/seed_kelahiran_kematian";
|
||||
import { seedRingkasanKesehatan } from "./_seeder_list/kesehatan/seed_ringkasan_kesehatan";
|
||||
import { seedDesaAntiKorupsi } from "./_seeder_list/landing-page/desa-anti-korupsi/seed_desa_anti_korupsi";
|
||||
import { seedPrestasiDesa } from "./_seeder_list/landing-page/prestasi-desa/seed_prestasi_desa";
|
||||
import { seedMediaSosial } from "./_seeder_list/landing-page/profil_landing_page/seed_media_sosial";
|
||||
@@ -59,6 +61,7 @@ import { seedKonservasiAdatBali } from "./_seeder_list/lingkungan/seed_konservas
|
||||
import { seedPengelolaanSampah } from "./_seeder_list/lingkungan/seed_pengelolaan_sampah";
|
||||
import { seedProgramPenghijauan } from "./_seeder_list/lingkungan/seed_program_penghijauan";
|
||||
import { seedBeasiswaPendaftar } from "./_seeder_list/pendidikan/seed_beasiswa_pendaftar";
|
||||
import { seedBeasiswaConfig } from "./_seeder_list/pendidikan/seed_beasiswa_config";
|
||||
import { seedBimbinganBelajar } from "./_seeder_list/pendidikan/seed_bimbingan_belajar";
|
||||
import { seedDataPendidikan } from "./_seeder_list/pendidikan/seed_data_pendidikan";
|
||||
import { seedDataPerpustakaan } from "./_seeder_list/pendidikan/seed_data_perpustakaan";
|
||||
@@ -378,6 +381,11 @@ import seedAssets from "./seed_assets";
|
||||
// ===== PENDIDIKAN =====
|
||||
await seedKeunggulanProgram();
|
||||
await seedBeasiswaPendaftar();
|
||||
await seedBeasiswaConfig();
|
||||
|
||||
// ===== SOSIAL DASHBOARD =====
|
||||
await seedRingkasanKesehatan();
|
||||
await seedKegiatanDesa();
|
||||
|
||||
// ===== DESA =====
|
||||
await seedMusikDesa();
|
||||
|
||||
409
src/app/admin/(dashboard)/_state/desa/kegiatanDesa.ts
Normal file
409
src/app/admin/(dashboard)/_state/desa/kegiatanDesa.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import ApiFetch from "@/lib/api-fetch";
|
||||
import { toast } from "react-toastify";
|
||||
import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
|
||||
// ========================================= SCHEMAS ========================================= //
|
||||
|
||||
const templateForm = z.object({
|
||||
judul: z.string().min(3, "Judul minimal 3 karakter"),
|
||||
deskripsiSingkat: z.string().min(3, "Deskripsi singkat minimal 3 karakter"),
|
||||
deskripsiLengkap: z.string().min(3, "Deskripsi lengkap minimal 3 karakter"),
|
||||
tanggal: z.string().nonempty("Tanggal harus diisi"),
|
||||
lokasi: z.string().min(3, "Lokasi minimal 3 karakter"),
|
||||
partisipan: z.number().optional().default(0),
|
||||
kategoriKegiatanId: z.string().nonempty("Kategori kegiatan harus dipilih"),
|
||||
imageId: z.string().optional(),
|
||||
});
|
||||
|
||||
const defaultForm = {
|
||||
judul: "",
|
||||
deskripsiSingkat: "",
|
||||
deskripsiLengkap: "",
|
||||
tanggal: "",
|
||||
lokasi: "",
|
||||
partisipan: 0,
|
||||
kategoriKegiatanId: "",
|
||||
imageId: "" as string | undefined,
|
||||
};
|
||||
|
||||
const templateKategori = z.object({
|
||||
nama: z.string().min(1, "Nama kategori harus diisi"),
|
||||
});
|
||||
|
||||
const defaultKategori = {
|
||||
nama: "",
|
||||
};
|
||||
|
||||
// ========================================= KEGIATAN DESA ========================================= //
|
||||
|
||||
const kegiatan = proxy({
|
||||
create: {
|
||||
form: { ...defaultForm },
|
||||
loading: false,
|
||||
async create() {
|
||||
const cek = templateForm.safeParse(kegiatan.create.form);
|
||||
if (!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
return toast.error(err);
|
||||
}
|
||||
|
||||
try {
|
||||
kegiatan.create.loading = true;
|
||||
const res = await ApiFetch.api.desa["kegiatandesa"]["create"].post(
|
||||
kegiatan.create.form
|
||||
);
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
kegiatan.findMany.load();
|
||||
toast.success("Kegiatan desa berhasil disimpan!");
|
||||
return true;
|
||||
}
|
||||
|
||||
toast.error(res.data?.message || "Gagal menyimpan kegiatan desa");
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error creating kegiatan:", error);
|
||||
toast.error("Terjadi kesalahan saat menyimpan kegiatan");
|
||||
return false;
|
||||
} finally {
|
||||
kegiatan.create.loading = false;
|
||||
}
|
||||
},
|
||||
resetForm() {
|
||||
kegiatan.create.form = { ...defaultForm };
|
||||
},
|
||||
},
|
||||
|
||||
findMany: {
|
||||
data: null as any[] | null,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
loading: false,
|
||||
search: "",
|
||||
load: async (page = 1, limit = 10, search = "", kategori = "") => {
|
||||
const startTime = Date.now();
|
||||
kegiatan.findMany.loading = true;
|
||||
kegiatan.findMany.page = page;
|
||||
kegiatan.findMany.search = search;
|
||||
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
if (search) query.search = search;
|
||||
if (kategori) query.kategori = kategori;
|
||||
|
||||
const res = await ApiFetch.api.desa["kegiatandesa"]["find-many"].get({ query });
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
kegiatan.findMany.data = res.data.data ?? [];
|
||||
kegiatan.findMany.totalPages = res.data.totalPages ?? 1;
|
||||
} else {
|
||||
kegiatan.findMany.data = [];
|
||||
kegiatan.findMany.totalPages = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Gagal fetch kegiatan paginated:", err);
|
||||
kegiatan.findMany.data = [];
|
||||
kegiatan.findMany.totalPages = 1;
|
||||
} finally {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const minDelay = 300;
|
||||
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
|
||||
|
||||
setTimeout(() => {
|
||||
kegiatan.findMany.loading = false;
|
||||
}, delay);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
findUnique: {
|
||||
data: null as any | null,
|
||||
loading: false,
|
||||
async load(id: string) {
|
||||
if (!id) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/desa/kegiatandesa/${id}`); // Assuming unique endpoint follows standard
|
||||
if (res.ok) {
|
||||
const result = await res.json();
|
||||
kegiatan.findUnique.data = result.data ?? null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching unique kegiatan:", error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
delete: {
|
||||
loading: false,
|
||||
async byId(id: string) {
|
||||
if (!id) return toast.warn("ID tidak valid");
|
||||
|
||||
try {
|
||||
kegiatan.delete.loading = true;
|
||||
const response = await fetch(`/api/desa/kegiatandesa/del/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (response.ok && result?.success) {
|
||||
toast.success(result.message || "Kegiatan berhasil dihapus");
|
||||
await kegiatan.findMany.load();
|
||||
} else {
|
||||
toast.error(result?.message || "Gagal menghapus kegiatan");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Gagal delete:", error);
|
||||
toast.error("Terjadi kesalahan saat menghapus kegiatan");
|
||||
} finally {
|
||||
kegiatan.delete.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
edit: {
|
||||
id: "",
|
||||
form: { ...defaultForm },
|
||||
loading: false,
|
||||
|
||||
async load(id: string) {
|
||||
if (!id) return null;
|
||||
try {
|
||||
kegiatan.edit.loading = true;
|
||||
const response = await fetch(`/api/desa/kegiatandesa/${id}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result?.success) {
|
||||
const data = result.data;
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
judul: data.judul,
|
||||
deskripsiSingkat: data.deskripsiSingkat,
|
||||
deskripsiLengkap: data.deskripsiLengkap,
|
||||
tanggal: data.tanggal ? new Date(data.tanggal).toISOString().split('T')[0] : "",
|
||||
lokasi: data.lokasi,
|
||||
partisipan: data.partisipan || 0,
|
||||
kategoriKegiatanId: data.kategoriKegiatanId || "",
|
||||
imageId: data.imageId || undefined,
|
||||
};
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading kegiatan for edit:", error);
|
||||
} finally {
|
||||
kegiatan.edit.loading = false;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
async update() {
|
||||
const cek = templateForm.safeParse(kegiatan.edit.form);
|
||||
if (!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
toast.error(err);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
kegiatan.edit.loading = true;
|
||||
const response = await fetch(`/api/desa/kegiatandesa/${this.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(kegiatan.edit.form),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
toast.success("Berhasil update kegiatan");
|
||||
await kegiatan.findMany.load();
|
||||
return true;
|
||||
}
|
||||
throw new Error(result.message || "Gagal update kegiatan");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Terjadi kesalahan saat update");
|
||||
return false;
|
||||
} finally {
|
||||
kegiatan.edit.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
kegiatan.edit.id = "";
|
||||
kegiatan.edit.form = { ...defaultForm };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// ========================================= KATEGORI KEGIATAN ========================================= //
|
||||
|
||||
const kategoriKegiatan = proxy({
|
||||
create: {
|
||||
form: { ...defaultKategori },
|
||||
loading: false,
|
||||
async create() {
|
||||
const cek = templateKategori.safeParse(kategoriKegiatan.create.form);
|
||||
if (!cek.success) {
|
||||
return toast.error("Nama kategori harus diisi");
|
||||
}
|
||||
|
||||
try {
|
||||
kategoriKegiatan.create.loading = true;
|
||||
const res = await ApiFetch.api.desa["kategorikegiatan"]["create"].post(
|
||||
kategoriKegiatan.create.form
|
||||
);
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
kategoriKegiatan.findMany.load();
|
||||
toast.success("Kategori kegiatan berhasil dibuat");
|
||||
return true;
|
||||
}
|
||||
toast.error(res.data?.message || "Gagal membuat kategori");
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Terjadi kesalahan");
|
||||
return false;
|
||||
} finally {
|
||||
kategoriKegiatan.create.loading = false;
|
||||
}
|
||||
},
|
||||
resetForm() {
|
||||
kategoriKegiatan.create.form = { ...defaultKategori };
|
||||
},
|
||||
},
|
||||
|
||||
findMany: {
|
||||
data: [] as any[],
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
loading: false,
|
||||
search: "",
|
||||
load: async (page = 1, limit = 10, search = "") => {
|
||||
kategoriKegiatan.findMany.loading = true;
|
||||
kategoriKegiatan.findMany.page = page;
|
||||
kategoriKegiatan.findMany.search = search;
|
||||
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
if (search) query.search = search;
|
||||
|
||||
const res = await ApiFetch.api.desa["kategorikegiatan"]["findMany"].get({ query });
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
kategoriKegiatan.findMany.data = res.data.data ?? [];
|
||||
kategoriKegiatan.findMany.totalPages = res.data.totalPages ?? 1;
|
||||
} else {
|
||||
kategoriKegiatan.findMany.data = [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Gagal fetch kategori kegiatan:", err);
|
||||
} finally {
|
||||
kategoriKegiatan.findMany.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
findUnique: {
|
||||
data: null as any | null,
|
||||
loading: false,
|
||||
async load(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/desa/kategorikegiatan/${id}`);
|
||||
if (res.ok) {
|
||||
const result = await res.json();
|
||||
kategoriKegiatan.findUnique.data = result.data ?? null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
delete: {
|
||||
loading: false,
|
||||
async byId(id: string) {
|
||||
if (!id) return;
|
||||
try {
|
||||
kategoriKegiatan.delete.loading = true;
|
||||
const res = await fetch(`/api/desa/kategorikegiatan/del/${id}`, { method: "DELETE" });
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
kategoriKegiatan.findMany.load();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
kategoriKegiatan.delete.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
update: {
|
||||
id: "",
|
||||
form: { ...defaultKategori },
|
||||
loading: false,
|
||||
async load(id: string) {
|
||||
if (!id) return;
|
||||
try {
|
||||
this.loading = true;
|
||||
const res = await fetch(`/api/desa/kategorikegiatan/${id}`);
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
this.id = result.data.id;
|
||||
this.form = { nama: result.data.nama };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async update() {
|
||||
const cek = templateKategori.safeParse(kategoriKegiatan.update.form);
|
||||
if (!cek.success) return toast.error("Nama kategori harus diisi");
|
||||
|
||||
try {
|
||||
this.loading = true;
|
||||
const res = await fetch(`/api/desa/kategorikegiatan/${this.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(this.form),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
toast.success("Berhasil update kategori");
|
||||
kategoriKegiatan.findMany.load();
|
||||
return true;
|
||||
}
|
||||
toast.error(result.message);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
reset() {
|
||||
this.id = "";
|
||||
this.form = { ...defaultKategori };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// ========================================= GLOBAL STATE ========================================= //
|
||||
|
||||
const stateDashboardKegiatan = proxy({
|
||||
kegiatan,
|
||||
kategoriKegiatan,
|
||||
});
|
||||
|
||||
export default stateDashboardKegiatan;
|
||||
153
src/app/admin/(dashboard)/_state/desa/profile/lambangDesa.ts
Normal file
153
src/app/admin/(dashboard)/_state/desa/profile/lambangDesa.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { toast } from "react-toastify";
|
||||
import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
const lambangDesaForm = z.object({
|
||||
judul: z.string().min(3, "Judul minimal 3 karakter"),
|
||||
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
|
||||
});
|
||||
|
||||
const lambangDesaDefaultForm = {
|
||||
judul: "",
|
||||
deskripsi: "",
|
||||
};
|
||||
|
||||
type LambangDesaForm = Prisma.LambangDesaGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
judul: true;
|
||||
deskripsi: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
const lambangDesa = proxy({
|
||||
findUnique: {
|
||||
data: null as LambangDesaForm | null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
|
||||
async load(id: string) {
|
||||
if (!id) {
|
||||
toast.warn("ID tidak valid");
|
||||
return null;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/desa/profile/lambang/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.data = result.data;
|
||||
return result.data;
|
||||
} else {
|
||||
throw new Error(
|
||||
result.message || "Gagal mengambil data lambang desa"
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
this.error = msg;
|
||||
console.error("Load lambang desa error:", msg);
|
||||
toast.error("Terjadi kesalahan saat mengambil data lambang desa");
|
||||
return null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.data = null;
|
||||
this.error = null;
|
||||
this.loading = false;
|
||||
},
|
||||
},
|
||||
update: {
|
||||
id: "",
|
||||
form: { ...lambangDesaDefaultForm },
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
isReadOnly: false,
|
||||
|
||||
initialize(lambangDesaData: LambangDesaForm) {
|
||||
this.id = lambangDesaData.id;
|
||||
this.isReadOnly = false;
|
||||
this.form = {
|
||||
judul: lambangDesaData.judul || "",
|
||||
deskripsi: lambangDesaData.deskripsi || "",
|
||||
};
|
||||
},
|
||||
|
||||
updateField(field: keyof typeof lambangDesaDefaultForm, value: string) {
|
||||
this.form[field] = value;
|
||||
},
|
||||
|
||||
async submit() {
|
||||
const validation = lambangDesaForm.safeParse(this.form);
|
||||
|
||||
if (!validation.success) {
|
||||
const errors = validation.error.issues
|
||||
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
||||
.join(", ");
|
||||
toast.error(`Form tidak valid: ${errors}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/desa/profile/lambang/${this.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(this.form),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.message || `HTTP error! status: ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Berhasil update lambang desa");
|
||||
await lambangDesa.findUnique.load(this.id);
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.message || "Gagal update lambang desa");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message;
|
||||
this.error = errorMessage;
|
||||
console.error("Update lambang desa error:", errorMessage);
|
||||
toast.error("Terjadi kesalahan saat update lambang desa");
|
||||
return false;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.id = "";
|
||||
this.form = { ...lambangDesaDefaultForm };
|
||||
this.error = null;
|
||||
this.loading = false;
|
||||
this.isReadOnly = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default lambangDesa;
|
||||
241
src/app/admin/(dashboard)/_state/desa/profile/mantanPerbekel.ts
Normal file
241
src/app/admin/(dashboard)/_state/desa/profile/mantanPerbekel.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { toast } from "react-toastify";
|
||||
import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import ApiFetch from "@/lib/api-fetch";
|
||||
|
||||
const mantanPerbekelForm = z.object({
|
||||
nama: z.string().min(3, "Nama minimal 3 karakter"),
|
||||
daerah: z.string().min(3, "Daerah minimal 3 karakter"),
|
||||
periode: z.string().min(3, "Periode minimal 3 karakter"),
|
||||
imageId: z.string().min(1, "Gambar wajib dipilih"),
|
||||
});
|
||||
|
||||
const mantanPerbekelDefaultForm = {
|
||||
nama: "",
|
||||
daerah: "",
|
||||
periode: "",
|
||||
imageId: "",
|
||||
};
|
||||
|
||||
const mantanPerbekel = proxy({
|
||||
create: {
|
||||
form: { ...mantanPerbekelDefaultForm },
|
||||
loading: false,
|
||||
async create() {
|
||||
const cek = mantanPerbekelForm.safeParse(mantanPerbekel.create.form);
|
||||
if (!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
return toast.error(err);
|
||||
}
|
||||
try {
|
||||
mantanPerbekel.create.loading = true;
|
||||
const res = await ApiFetch.api.desa.mantanperbekel["create"].post(
|
||||
mantanPerbekel.create.form
|
||||
);
|
||||
if (res.status === 200) {
|
||||
mantanPerbekel.findMany.load();
|
||||
return toast.success("Foto berhasil disimpan!");
|
||||
}
|
||||
return toast.error("Gagal menyimpan foto");
|
||||
} catch (error) {
|
||||
console.log((error as Error).message);
|
||||
} finally {
|
||||
mantanPerbekel.create.loading = false;
|
||||
}
|
||||
},
|
||||
resetForm() {
|
||||
mantanPerbekel.create.form = { ...mantanPerbekelDefaultForm };
|
||||
},
|
||||
},
|
||||
findMany: {
|
||||
data: null as
|
||||
| Prisma.PerbekelDariMasaKeMasaGetPayload<{
|
||||
include: {
|
||||
image: true;
|
||||
};
|
||||
}>[]
|
||||
| null,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
loading: false,
|
||||
search: "",
|
||||
load: async (page = 1, limit = 10, search = "") => {
|
||||
mantanPerbekel.findMany.loading = true;
|
||||
mantanPerbekel.findMany.page = page;
|
||||
mantanPerbekel.findMany.search = search;
|
||||
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
if (search) query.search = search;
|
||||
|
||||
const res = await ApiFetch.api.desa.mantanperbekel["findMany"].get({
|
||||
query,
|
||||
});
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
mantanPerbekel.findMany.data = res.data.data ?? [];
|
||||
mantanPerbekel.findMany.totalPages = res.data.totalPages ?? 1;
|
||||
} else {
|
||||
mantanPerbekel.findMany.data = [];
|
||||
mantanPerbekel.findMany.totalPages = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Gagal fetch mantan perbekel paginated:", err);
|
||||
mantanPerbekel.findMany.data = [];
|
||||
mantanPerbekel.findMany.totalPages = 1;
|
||||
} finally {
|
||||
mantanPerbekel.findMany.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
findUnique: {
|
||||
data: null as Prisma.PerbekelDariMasaKeMasaGetPayload<{
|
||||
include: {
|
||||
image: true;
|
||||
};
|
||||
}> | null,
|
||||
async load(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/desa/mantanperbekel/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
mantanPerbekel.findUnique.data = data.data ?? null;
|
||||
} else {
|
||||
console.error("Failed to fetch mantan perbekel:", res.statusText);
|
||||
mantanPerbekel.findUnique.data = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching mantan perbekel:", error);
|
||||
mantanPerbekel.findUnique.data = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
loading: false,
|
||||
async byId(id: string) {
|
||||
if (!id) return toast.warn("ID tidak valid");
|
||||
try {
|
||||
mantanPerbekel.delete.loading = true;
|
||||
const response = await fetch(`/api/desa/mantanperbekel/del/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
toast.success(result.message || "Mantan perbekel berhasil dihapus");
|
||||
await mantanPerbekel.findMany.load();
|
||||
} else {
|
||||
toast.error(result.message || "Gagal menghapus mantan perbekel");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Gagal delete:", error);
|
||||
toast.error("Terjadi kesalahan saat menghapus mantan perbekel");
|
||||
} finally {
|
||||
mantanPerbekel.delete.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
update: {
|
||||
id: "",
|
||||
form: { ...mantanPerbekelDefaultForm },
|
||||
loading: false,
|
||||
async load(id: string) {
|
||||
if (!id) {
|
||||
toast.warn("ID tidak valid");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/desa/mantanperbekel/${id}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
if (result?.success) {
|
||||
const data = result.data;
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
nama: data.nama,
|
||||
daerah: data.daerah,
|
||||
periode: data.periode,
|
||||
imageId: data.imageId || "",
|
||||
};
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(result.message || "Gagal memuat data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading foto:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Gagal memuat data"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async update() {
|
||||
const cek = mantanPerbekelForm.safeParse(mantanPerbekel.update.form);
|
||||
if (!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
toast.error(err);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
mantanPerbekel.update.loading = true;
|
||||
const response = await fetch(`/api/desa/mantanperbekel/${this.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
nama: this.form.nama,
|
||||
daerah: this.form.daerah,
|
||||
periode: this.form.periode,
|
||||
imageId: this.form.imageId,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.message || `HTTP error! status: ${response.status}`
|
||||
);
|
||||
}
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
toast.success(result.message || "Mantan perbekel berhasil diupdate");
|
||||
await mantanPerbekel.findMany.load();
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.message || "Gagal mengupdate mantan perbekel");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating mantan perbekel:", error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Gagal mengupdate mantan perbekel"
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
mantanPerbekel.update.loading = false;
|
||||
}
|
||||
},
|
||||
reset() {
|
||||
mantanPerbekel.update.id = "";
|
||||
mantanPerbekel.update.form = { ...mantanPerbekelDefaultForm };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default mantanPerbekel;
|
||||
183
src/app/admin/(dashboard)/_state/desa/profile/maskotDesa.ts
Normal file
183
src/app/admin/(dashboard)/_state/desa/profile/maskotDesa.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { toast } from "react-toastify";
|
||||
import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
const maskotForm = z.object({
|
||||
judul: z.string().min(3, "Judul minimal 3 karakter"),
|
||||
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
|
||||
images: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string().min(1, "Label wajib"),
|
||||
imageId: z.string().min(1, "Image ID wajib"),
|
||||
})
|
||||
)
|
||||
.min(1, "Minimal 1 gambar harus diisi"),
|
||||
});
|
||||
|
||||
const maskotDefaultForm = {
|
||||
judul: "",
|
||||
deskripsi: "",
|
||||
images: [] as { label: string; imageId: string }[],
|
||||
};
|
||||
|
||||
type FormData = typeof maskotDefaultForm;
|
||||
|
||||
type MaskotDesaForm = Prisma.MaskotDesaGetPayload<{
|
||||
include: {
|
||||
images: {
|
||||
include: {
|
||||
image: {
|
||||
select: {
|
||||
id: true;
|
||||
name: true;
|
||||
path: true;
|
||||
link: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
const maskotDesa = proxy({
|
||||
findUnique: {
|
||||
data: null as MaskotDesaForm | null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
|
||||
async load(id: string) {
|
||||
if (!id) {
|
||||
toast.warn("ID tidak valid");
|
||||
return null;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/desa/profile/maskot/${id}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
this.data = result.data;
|
||||
return result.data;
|
||||
} else {
|
||||
throw new Error(result.message || "Gagal mengambil data profile");
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
this.error = msg;
|
||||
console.error("Load profile error:", msg);
|
||||
toast.error("Terjadi kesalahan saat mengambil data profile");
|
||||
return null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.data = null;
|
||||
this.error = null;
|
||||
this.loading = false;
|
||||
},
|
||||
},
|
||||
|
||||
update: {
|
||||
id: "",
|
||||
form: { ...maskotDefaultForm },
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
isReadOnly: false,
|
||||
|
||||
initialize(profileData: MaskotDesaForm) {
|
||||
this.id = profileData.id;
|
||||
this.isReadOnly = false;
|
||||
this.form = {
|
||||
judul: profileData.judul || "",
|
||||
deskripsi: profileData.deskripsi || "",
|
||||
images: (profileData.images || []).map((img) => ({
|
||||
label: img.label,
|
||||
imageId: img?.image?.id || "",
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
updateField<K extends keyof FormData>(field: K, value: FormData[K]) {
|
||||
this.form[field] = value;
|
||||
},
|
||||
|
||||
addImage() {
|
||||
this.form.images.push({ label: "", imageId: "" });
|
||||
},
|
||||
|
||||
removeImage(index: number) {
|
||||
this.form.images.splice(index, 1);
|
||||
},
|
||||
|
||||
async submit() {
|
||||
const validation = maskotForm.safeParse(this.form);
|
||||
|
||||
if (!validation.success) {
|
||||
const errors = validation.error.issues
|
||||
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
||||
.join(", ");
|
||||
toast.error(`Form tidak valid: ${errors}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/desa/profile/maskot/${this.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(this.form),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
toast.success("Berhasil update profile");
|
||||
await maskotDesa.findUnique.load(this.id);
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.message || "Gagal update profile");
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
this.error = msg;
|
||||
toast.error("Terjadi kesalahan saat update profile");
|
||||
return false;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.id = "";
|
||||
this.form = { ...maskotDefaultForm };
|
||||
this.error = null;
|
||||
this.loading = false;
|
||||
this.isReadOnly = false;
|
||||
},
|
||||
},
|
||||
|
||||
async loadForEdit(id: string) {
|
||||
const data = await this.findUnique.load(id);
|
||||
if (data) {
|
||||
this.update.initialize(data);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.findUnique.reset();
|
||||
this.update.reset();
|
||||
},
|
||||
});
|
||||
|
||||
export default maskotDesa;
|
||||
185
src/app/admin/(dashboard)/_state/desa/profile/profilPerbekel.ts
Normal file
185
src/app/admin/(dashboard)/_state/desa/profile/profilPerbekel.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { toast } from "react-toastify";
|
||||
import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
const profilPerbekelForm = z.object({
|
||||
biodata: z.string().min(3, "Biodata minimal 3 karakter"),
|
||||
pengalaman: z.string().min(3, "Pengalaman minimal 3 karakter"),
|
||||
pengalamanOrganisasi: z
|
||||
.string()
|
||||
.min(3, "Pengalaman Organisasi minimal 3 karakter"),
|
||||
programUnggulan: z.string().min(3, "Program Unggulan minimal 3 karakter"),
|
||||
imageId: z.string().min(1, "Gambar wajib dipilih"),
|
||||
});
|
||||
|
||||
const profilPerbekelDefaultForm = {
|
||||
biodata: "",
|
||||
pengalaman: "",
|
||||
pengalamanOrganisasi: "",
|
||||
programUnggulan: "",
|
||||
imageId: "",
|
||||
};
|
||||
|
||||
type ProfilPerbekelForm = Prisma.ProfilPerbekelGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
biodata: true;
|
||||
pengalaman: true;
|
||||
pengalamanOrganisasi: true;
|
||||
programUnggulan: true;
|
||||
imageId: true;
|
||||
image?: {
|
||||
select: {
|
||||
link: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
const profilPerbekel = proxy({
|
||||
findUnique: {
|
||||
data: null as ProfilPerbekelForm | null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
|
||||
async load(id: string) {
|
||||
if (!id) {
|
||||
toast.warn("ID tidak valid");
|
||||
return null;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/desa/profile/profileperbekel/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.data = result.data;
|
||||
return result.data;
|
||||
} else {
|
||||
throw new Error(
|
||||
result.message || "Gagal mengambil data profil perbekel"
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
this.error = msg;
|
||||
toast.error("Terjadi kesalahan saat mengambil data profil perbekel");
|
||||
return null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.data = null;
|
||||
this.error = null;
|
||||
this.loading = false;
|
||||
},
|
||||
},
|
||||
|
||||
edit: {
|
||||
id: "",
|
||||
form: { ...profilPerbekelDefaultForm },
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
isReadOnly: false,
|
||||
|
||||
initialize(profilData: ProfilPerbekelForm) {
|
||||
this.id = profilData.id;
|
||||
this.isReadOnly = false;
|
||||
this.form = {
|
||||
biodata: profilData.biodata || "",
|
||||
pengalaman: profilData.pengalaman || "",
|
||||
pengalamanOrganisasi: profilData.pengalamanOrganisasi || "",
|
||||
programUnggulan: profilData.programUnggulan || "",
|
||||
imageId: profilData.imageId || "",
|
||||
};
|
||||
},
|
||||
|
||||
updateField(field: keyof typeof profilPerbekelDefaultForm, value: string) {
|
||||
this.form[field] = value;
|
||||
},
|
||||
|
||||
async submit() {
|
||||
const validation = profilPerbekelForm.safeParse(this.form);
|
||||
|
||||
if (!validation.success) {
|
||||
const errors = validation.error.issues
|
||||
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
||||
.join(", ");
|
||||
toast.error(`Form tidak valid: ${errors}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/desa/profile/profileperbekel/${this.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(this.form),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.message || `HTTP error! status: ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Berhasil update profil perbekel");
|
||||
await profilPerbekel.findUnique.load(this.id);
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.message || "Gagal update profil perbekel");
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
this.error = msg;
|
||||
toast.error("Terjadi kesalahan saat update profil perbekel");
|
||||
return false;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
reset() {
|
||||
this.id = "";
|
||||
this.form = { ...profilPerbekelDefaultForm };
|
||||
this.error = null;
|
||||
this.loading = false;
|
||||
this.isReadOnly = false;
|
||||
},
|
||||
},
|
||||
|
||||
async loadForEdit(id: string) {
|
||||
const profileData = await this.findUnique.load(id);
|
||||
if (profileData) {
|
||||
this.edit.initialize(profileData);
|
||||
}
|
||||
return profileData;
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.findUnique.reset();
|
||||
this.edit.reset();
|
||||
},
|
||||
});
|
||||
|
||||
export default profilPerbekel;
|
||||
153
src/app/admin/(dashboard)/_state/desa/profile/sejarahDesa.ts
Normal file
153
src/app/admin/(dashboard)/_state/desa/profile/sejarahDesa.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { toast } from "react-toastify";
|
||||
import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
const sejarahDesaForm = z.object({
|
||||
judul: z.string().min(3, "Judul minimal 3 karakter"),
|
||||
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
|
||||
});
|
||||
|
||||
const sejarahDesaDefaultForm = {
|
||||
judul: "",
|
||||
deskripsi: "",
|
||||
};
|
||||
|
||||
type SejarahDesaForm = Prisma.SejarahDesaGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
judul: true;
|
||||
deskripsi: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
const sejarahDesa = proxy({
|
||||
findUnique: {
|
||||
data: null as SejarahDesaForm | null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
|
||||
async load(id: string) {
|
||||
if (!id) {
|
||||
toast.warn("ID tidak valid");
|
||||
return null;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/desa/profile/sejarah/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.data = result.data;
|
||||
return result.data;
|
||||
} else {
|
||||
throw new Error(
|
||||
result.message || "Gagal mengambil data sejarah desa"
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
this.error = msg;
|
||||
console.error("Load sejarah desa error:", msg);
|
||||
toast.error("Terjadi kesalahan saat mengambil data sejarah desa");
|
||||
return null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.data = null;
|
||||
this.error = null;
|
||||
this.loading = false;
|
||||
},
|
||||
},
|
||||
update: {
|
||||
id: "",
|
||||
form: { ...sejarahDesaDefaultForm },
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
isReadOnly: false,
|
||||
|
||||
initialize(sejarahData: SejarahDesaForm) {
|
||||
this.id = sejarahData.id;
|
||||
this.isReadOnly = false;
|
||||
this.form = {
|
||||
judul: sejarahData.judul || "",
|
||||
deskripsi: sejarahData.deskripsi || "",
|
||||
};
|
||||
},
|
||||
|
||||
updateField(field: keyof typeof sejarahDesaDefaultForm, value: string) {
|
||||
this.form[field] = value;
|
||||
},
|
||||
|
||||
async submit() {
|
||||
const validation = sejarahDesaForm.safeParse(this.form);
|
||||
|
||||
if (!validation.success) {
|
||||
const errors = validation.error.issues
|
||||
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
||||
.join(", ");
|
||||
toast.error(`Form tidak valid: ${errors}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/desa/profile/sejarah/${this.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(this.form),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.message || `HTTP error! status: ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Berhasil update profile");
|
||||
await sejarahDesa.findUnique.load(this.id);
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.message || "Gagal update profile");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message;
|
||||
this.error = errorMessage;
|
||||
console.error("Update profile error:", errorMessage);
|
||||
toast.error("Terjadi kesalahan saat update profile");
|
||||
return false;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.id = "";
|
||||
this.form = { ...sejarahDesaDefaultForm };
|
||||
this.error = null;
|
||||
this.loading = false;
|
||||
this.isReadOnly = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default sejarahDesa;
|
||||
153
src/app/admin/(dashboard)/_state/desa/profile/visiMisiDesa.ts
Normal file
153
src/app/admin/(dashboard)/_state/desa/profile/visiMisiDesa.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { toast } from "react-toastify";
|
||||
import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
const visiMisiDesaForm = z.object({
|
||||
visi: z.string().min(3, "Visi minimal 3 karakter"),
|
||||
misi: z.string().min(3, "Misi minimal 3 karakter"),
|
||||
});
|
||||
|
||||
const visiMisiDesaDefaultForm = {
|
||||
visi: "",
|
||||
misi: "",
|
||||
};
|
||||
|
||||
type VisiMisiDesaForm = Prisma.VisiMisiDesaGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
visi: true;
|
||||
misi: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
const visiMisiDesa = proxy({
|
||||
findUnique: {
|
||||
data: null as VisiMisiDesaForm | null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
|
||||
async load(id: string) {
|
||||
if (!id) {
|
||||
toast.warn("ID tidak valid");
|
||||
return null;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/desa/profile/visi-misi/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.data = result.data;
|
||||
return result.data;
|
||||
} else {
|
||||
throw new Error(
|
||||
result.message || "Gagal mengambil data visi misi desa"
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
this.error = msg;
|
||||
console.error("Load visi misi desa error:", msg);
|
||||
toast.error("Terjadi kesalahan saat mengambil data visi misi desa");
|
||||
return null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.data = null;
|
||||
this.error = null;
|
||||
this.loading = false;
|
||||
},
|
||||
},
|
||||
update: {
|
||||
id: "",
|
||||
form: { ...visiMisiDesaDefaultForm },
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
isReadOnly: false,
|
||||
|
||||
initialize(visiMisiData: VisiMisiDesaForm) {
|
||||
this.id = visiMisiData.id;
|
||||
this.isReadOnly = false;
|
||||
this.form = {
|
||||
visi: visiMisiData.visi || "",
|
||||
misi: visiMisiData.misi || "",
|
||||
};
|
||||
},
|
||||
|
||||
updateField(field: keyof typeof visiMisiDesaDefaultForm, value: string) {
|
||||
this.form[field] = value;
|
||||
},
|
||||
|
||||
async submit() {
|
||||
const validation = visiMisiDesaForm.safeParse(this.form);
|
||||
|
||||
if (!validation.success) {
|
||||
const errors = validation.error.issues
|
||||
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
||||
.join(", ");
|
||||
toast.error(`Form tidak valid: ${errors}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/desa/profile/visi-misi/${this.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(this.form),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.message || `HTTP error! status: ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Berhasil update visi misi desa");
|
||||
await visiMisiDesa.findUnique.load(this.id);
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.message || "Gagal update visi misi desa");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message;
|
||||
this.error = errorMessage;
|
||||
console.error("Update visi misi desa error:", errorMessage);
|
||||
toast.error("Terjadi kesalahan saat update visi misi desa");
|
||||
return false;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.id = "";
|
||||
this.form = { ...visiMisiDesaDefaultForm };
|
||||
this.error = null;
|
||||
this.loading = false;
|
||||
this.isReadOnly = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default visiMisiDesa;
|
||||
@@ -8,10 +8,10 @@ const umkmFormSchema = z.object({
|
||||
nama: z.string().min(1, "Nama minimal 1 karakter"),
|
||||
pemilik: z.string().min(1, "Nama pemilik wajib diisi"),
|
||||
kategoriId: z.string().min(1, "Kategori wajib dipilih"),
|
||||
deskripsi: z.string().optional(),
|
||||
alamat: z.string().optional(),
|
||||
kontak: z.string().optional(),
|
||||
imageId: z.string().optional(),
|
||||
deskripsi: z.string().optional().nullable(),
|
||||
alamat: z.string().optional().nullable(),
|
||||
kontak: z.string().optional().nullable(),
|
||||
imageId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
const defaultUmkmForm = {
|
||||
@@ -21,7 +21,7 @@ const defaultUmkmForm = {
|
||||
deskripsi: "",
|
||||
alamat: "",
|
||||
kontak: "",
|
||||
imageId: "",
|
||||
imageId: null as string | null,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
@@ -31,8 +31,8 @@ const produkFormSchema = z.object({
|
||||
harga: z.number().min(0, "Harga tidak boleh negatif"),
|
||||
stok: z.number().min(0, "Stok tidak boleh negatif"),
|
||||
umkmId: z.string().min(1, "UMKM wajib dipilih"),
|
||||
deskripsi: z.string().optional(),
|
||||
imageId: z.string().optional(),
|
||||
deskripsi: z.string().optional().nullable(),
|
||||
imageId: z.string().optional().nullable(),
|
||||
kategoriId: z.string().min(1, "Kategori wajib dipilih"), // PasarDesa needs category
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ const defaultProdukForm = {
|
||||
stok: 0,
|
||||
umkmId: "",
|
||||
deskripsi: "",
|
||||
imageId: "",
|
||||
imageId: null as string | null,
|
||||
kategoriId: "",
|
||||
isActive: true,
|
||||
};
|
||||
@@ -63,6 +63,15 @@ const defaultPenjualanForm = {
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
// Kategori Produk Form Validation
|
||||
const kategoriProdukFormSchema = z.object({
|
||||
nama: z.string().min(1, "Nama kategori wajib diisi"),
|
||||
});
|
||||
|
||||
const defaultKategoriProdukForm = {
|
||||
nama: "",
|
||||
};
|
||||
|
||||
export const umkmState = proxy({
|
||||
// UMKM Module
|
||||
umkm: {
|
||||
@@ -101,7 +110,7 @@ export const umkmState = proxy({
|
||||
loading: false,
|
||||
async submit() {
|
||||
const cek = umkmFormSchema.safeParse(this.form);
|
||||
if (!cek.success) return toast.error("Cek kembali form anda");
|
||||
if (!cek.success) { toast.error("Cek kembali form anda"); return false; }
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch("/api/ekonomi/umkm/create", {
|
||||
@@ -129,10 +138,10 @@ export const umkmState = proxy({
|
||||
loading: false,
|
||||
async submit(id: string) {
|
||||
const cek = umkmFormSchema.safeParse(this.form);
|
||||
if (!cek.success) return toast.error("Cek kembali form anda");
|
||||
if (!cek.success) { toast.error("Cek kembali form anda"); return false; }
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/ekonomi/umkm/update/${id}`, {
|
||||
const res = await fetch(`/api/ekonomi/umkm/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(this.form)
|
||||
@@ -157,7 +166,7 @@ export const umkmState = proxy({
|
||||
async submit(id: string) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/ekonomi/umkm/delete/${id}`, {
|
||||
const res = await fetch(`/api/ekonomi/umkm/del/${id}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
const result = await res.json();
|
||||
@@ -237,7 +246,7 @@ export const umkmState = proxy({
|
||||
loading: false,
|
||||
async submit() {
|
||||
const cek = produkFormSchema.safeParse(this.form);
|
||||
if (!cek.success) return toast.error("Cek kembali form anda");
|
||||
if (!cek.success) { toast.error("Cek kembali form anda"); return false; }
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch("/api/ekonomi/umkm/produk/create", {
|
||||
@@ -260,10 +269,10 @@ export const umkmState = proxy({
|
||||
loading: false,
|
||||
async submit(id: string) {
|
||||
const cek = produkFormSchema.safeParse(this.form);
|
||||
if (!cek.success) return toast.error("Cek kembali form anda");
|
||||
if (!cek.success) { toast.error("Cek kembali form anda"); return false; }
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/ekonomi/umkm/produk/update/${id}`, {
|
||||
const res = await fetch(`/api/ekonomi/umkm/produk/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(this.form)
|
||||
@@ -283,7 +292,7 @@ export const umkmState = proxy({
|
||||
async submit(id: string) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/ekonomi/umkm/produk/delete/${id}`, {
|
||||
const res = await fetch(`/api/ekonomi/umkm/produk/del/${id}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
const result = await res.json();
|
||||
@@ -323,7 +332,7 @@ export const umkmState = proxy({
|
||||
loading: false,
|
||||
async submit() {
|
||||
const cek = penjualanFormSchema.safeParse(this.form);
|
||||
if (!cek.success) return toast.error("Cek kembali form anda");
|
||||
if (!cek.success) { toast.error("Cek kembali form anda"); return false; }
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch("/api/ekonomi/umkm/penjualan/create", {
|
||||
@@ -340,11 +349,59 @@ export const umkmState = proxy({
|
||||
} catch (e) { toast.error("Gagal mencatat penjualan"); } finally { this.loading = false; }
|
||||
return false;
|
||||
}
|
||||
},
|
||||
del: {
|
||||
loading: false,
|
||||
async submit(id: string) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/ekonomi/umkm/penjualan/del/${id}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
toast.success("Histori penjualan berhasil dihapus");
|
||||
umkmState.penjualan.findMany.load();
|
||||
return true;
|
||||
}
|
||||
toast.error(result.message);
|
||||
} catch (e) {
|
||||
toast.error("Gagal menghapus histori penjualan");
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Kategori Produk (Share with Pasar Desa)
|
||||
kategoriProduk: {
|
||||
findMany: {
|
||||
data: [] as any[],
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
loading: false,
|
||||
search: "",
|
||||
async load(page = 1, limit = 10, search = "") {
|
||||
this.loading = true;
|
||||
this.page = page;
|
||||
this.search = search;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
search
|
||||
});
|
||||
const res = await fetch(`/api/ekonomi/kategoriproduk/find-many?${params}`);
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
this.data = result.data;
|
||||
this.totalPages = result.totalPages;
|
||||
}
|
||||
} catch (e) { console.error(e); } finally { this.loading = false; }
|
||||
}
|
||||
},
|
||||
findManyAll: {
|
||||
data: [] as any[],
|
||||
loading: false,
|
||||
@@ -358,6 +415,75 @@ export const umkmState = proxy({
|
||||
}
|
||||
} catch (e) { console.error(e); } finally { this.loading = false; }
|
||||
}
|
||||
},
|
||||
create: {
|
||||
form: { ...defaultKategoriProdukForm },
|
||||
loading: false,
|
||||
async submit() {
|
||||
const cek = kategoriProdukFormSchema.safeParse(this.form);
|
||||
if (!cek.success) { toast.error("Nama kategori wajib diisi"); return false; }
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch("/api/ekonomi/kategoriproduk/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(this.form)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
toast.success("Kategori berhasil dibuat");
|
||||
umkmState.kategoriProduk.findMany.load();
|
||||
umkmState.kategoriProduk.findManyAll.load();
|
||||
return true;
|
||||
}
|
||||
toast.error(result.message || "Gagal membuat kategori");
|
||||
} catch (e) { toast.error("Gagal membuat kategori"); } finally { this.loading = false; }
|
||||
return false;
|
||||
}
|
||||
},
|
||||
update: {
|
||||
form: { ...defaultKategoriProdukForm },
|
||||
loading: false,
|
||||
async submit(id: string) {
|
||||
const cek = kategoriProdukFormSchema.safeParse(this.form);
|
||||
if (!cek.success) { toast.error("Nama kategori wajib diisi"); return false; }
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/ekonomi/kategoriproduk/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(this.form)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
toast.success("Kategori berhasil diperbarui");
|
||||
umkmState.kategoriProduk.findMany.load();
|
||||
umkmState.kategoriProduk.findManyAll.load();
|
||||
return true;
|
||||
}
|
||||
toast.error(result.message || "Gagal memperbarui kategori");
|
||||
} catch (e) { toast.error("Gagal memperbarui kategori"); } finally { this.loading = false; }
|
||||
return false;
|
||||
}
|
||||
},
|
||||
del: {
|
||||
loading: false,
|
||||
async submit(id: string) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/ekonomi/kategoriproduk/del/${id}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
toast.success("Kategori berhasil dihapus");
|
||||
umkmState.kategoriProduk.findMany.load();
|
||||
umkmState.kategoriProduk.findManyAll.load();
|
||||
return true;
|
||||
}
|
||||
} catch (e) { toast.error("Gagal menghapus kategori"); } finally { this.loading = false; }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -367,23 +493,31 @@ export const umkmState = proxy({
|
||||
summary: { data: null as any, loading: false },
|
||||
topProduk: { data: [] as any[], loading: false },
|
||||
detail: { data: [] as any[], loading: false },
|
||||
async loadAll(periode = "") {
|
||||
mode: "month" as "week" | "month",
|
||||
async loadAll(periode = "", mode?: "week" | "month", kategoriId = "", umkmId = "") {
|
||||
const p = periode || `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
|
||||
const m = mode ?? this.mode;
|
||||
const modeParam = m === "week" ? "&mode=week" : "";
|
||||
const detailFilter = [
|
||||
kategoriId ? `&kategoriId=${kategoriId}` : "",
|
||||
umkmId ? `&umkmId=${umkmId}` : "",
|
||||
].join("");
|
||||
this.kpi.loading = true;
|
||||
this.summary.loading = true;
|
||||
this.topProduk.loading = true;
|
||||
this.detail.loading = true;
|
||||
try {
|
||||
const [kpi, sum, top, det] = await Promise.all([
|
||||
fetch(`/api/ekonomi/umkm/dashboard/kpi?periode=${p}`).then(r => r.json()),
|
||||
fetch(`/api/ekonomi/umkm/dashboard/ringkasan-penjualan?periode=${p}`).then(r => r.json()),
|
||||
fetch(`/api/ekonomi/umkm/dashboard/top-produk?periode=${p}`).then(r => r.json()),
|
||||
fetch(`/api/ekonomi/umkm/dashboard/detail-penjualan?periode=${p}`).then(r => r.json())
|
||||
fetch(`/api/ekonomi/umkm/dashboard/kpi?periode=${p}${modeParam}`).then(r => r.json()),
|
||||
fetch(`/api/ekonomi/umkm/dashboard/ringkasan-penjualan?periode=${p}${modeParam}`).then(r => r.json()),
|
||||
fetch(`/api/ekonomi/umkm/dashboard/top-produk?periode=${p}${modeParam}`).then(r => r.json()),
|
||||
fetch(`/api/ekonomi/umkm/dashboard/detail-penjualan?periode=${p}${modeParam}${detailFilter}`).then(r => r.json()),
|
||||
]);
|
||||
if (kpi.success) this.kpi.data = kpi.data;
|
||||
if (sum.success) this.summary.data = sum.data;
|
||||
if (top.success) this.topProduk.data = top.data;
|
||||
if (det.success) this.detail.data = det.data;
|
||||
this.mode = m;
|
||||
} catch (e) { console.error(e); } finally {
|
||||
this.kpi.loading = false;
|
||||
this.summary.loading = false;
|
||||
|
||||
106
src/app/admin/(dashboard)/desa/kegiatan-desa/_com/layoutTabs.tsx
Normal file
106
src/app/admin/(dashboard)/desa/kegiatan-desa/_com/layoutTabs.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
|
||||
import { IconCalendarEvent, IconCategory } from '@tabler/icons-react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
function LayoutTabsKegiatanDesa({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
label: "List Kegiatan",
|
||||
value: "list_kegiatan",
|
||||
href: "/admin/desa/kegiatan-desa/list-kegiatan-desa",
|
||||
icon: <IconCalendarEvent size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Kategori Kegiatan",
|
||||
value: "kategori_kegiatan",
|
||||
href: "/admin/desa/kegiatan-desa/kategori-kegiatan-desa",
|
||||
icon: <IconCategory size={18} stroke={1.8} />
|
||||
},
|
||||
];
|
||||
|
||||
const currentTab = tabs.find(tab => tab.href === pathname);
|
||||
const [activeTab, setActiveTab] = useState<string | null>(currentTab?.value || tabs[0].value);
|
||||
|
||||
const handleTabChange = (value: string | null) => {
|
||||
const tab = tabs.find(t => t.value === value);
|
||||
if (tab) {
|
||||
router.push(tab.href);
|
||||
}
|
||||
setActiveTab(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const match = tabs.find(tab => tab.href === pathname);
|
||||
if (match) {
|
||||
setActiveTab(match.value);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={3} fw={700} style={{ color: "#1A1B1E" }}>Kegiatan Desa</Title>
|
||||
<Tabs
|
||||
color={colors["blue-button"]}
|
||||
variant="pills"
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
radius="lg"
|
||||
keepMounted={false}
|
||||
>
|
||||
<ScrollArea type="auto" offsetScrollbars>
|
||||
<TabsList
|
||||
p="sm"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
|
||||
borderRadius: "1rem",
|
||||
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
|
||||
display: "flex",
|
||||
flexWrap: "nowrap",
|
||||
gap: "0.5rem",
|
||||
paddingInline: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, i) => (
|
||||
<TabsTab
|
||||
key={i}
|
||||
value={tab.value}
|
||||
leftSection={tab.icon}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
))}
|
||||
</TabsList>
|
||||
</ScrollArea>
|
||||
|
||||
{tabs.map((tab, i) => (
|
||||
<TabsPanel
|
||||
key={i}
|
||||
value={tab.value}
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
|
||||
borderRadius: "1rem",
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
|
||||
}}
|
||||
>
|
||||
<>{children}</>
|
||||
</TabsPanel>
|
||||
))}
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default LayoutTabsKegiatanDesa;
|
||||
@@ -0,0 +1,137 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
|
||||
function EditKategoriKegiatan() {
|
||||
const editState = useProxy(stateDashboardKegiatan.kategoriKegiatan);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({ nama: '' });
|
||||
const [originalData, setOriginalData] = useState({ nama: '' });
|
||||
|
||||
const isFormValid = () => formData.nama?.trim() !== '';
|
||||
|
||||
useEffect(() => {
|
||||
const loadKategori = async () => {
|
||||
const id = params?.id as string;
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await stateDashboardKegiatan.kategoriKegiatan.update.load(id);
|
||||
const nama = stateDashboardKegiatan.kategoriKegiatan.update.form.nama || '';
|
||||
setFormData({ nama });
|
||||
setOriginalData({ nama });
|
||||
} catch (error) {
|
||||
console.error('Error loading kategori kegiatan:', error);
|
||||
toast.error('Gagal memuat data kategori kegiatan');
|
||||
}
|
||||
};
|
||||
|
||||
loadKategori();
|
||||
}, [params?.id]);
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({ nama: originalData.nama });
|
||||
toast.info('Form dikembalikan ke data awal');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.nama?.trim()) {
|
||||
toast.error('Nama kategori kegiatan wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
editState.update.form = {
|
||||
...editState.update.form,
|
||||
nama: formData.nama,
|
||||
};
|
||||
|
||||
const success = await editState.update.update();
|
||||
if (success) {
|
||||
router.push('/admin/desa/kegiatan-desa/kategori-kegiatan-desa');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating kategori kegiatan:', error);
|
||||
toast.error('Terjadi kesalahan saat memperbarui kategori kegiatan');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
<Group mb="md">
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
<Title order={4} ml="sm" c="dark">
|
||||
Edit Kategori Kegiatan
|
||||
</Title>
|
||||
</Group>
|
||||
|
||||
<Paper
|
||||
w={{ base: '100%', md: '50%' }}
|
||||
bg={colors['white-1']}
|
||||
p="lg"
|
||||
radius="md"
|
||||
shadow="sm"
|
||||
style={{ border: '1px solid #e0e0e0' }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Nama Kategori Kegiatan"
|
||||
placeholder="Masukkan nama kategori kegiatan"
|
||||
value={formData.nama}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, nama: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
|
||||
<Group justify="right">
|
||||
<Button variant="outline" color="gray" radius="md" size="md" onClick={handleResetForm}>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
size="md"
|
||||
disabled={!isFormValid() || isSubmitting}
|
||||
style={{
|
||||
background:
|
||||
!isFormValid() || isSubmitting
|
||||
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
|
||||
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditKategoriKegiatan;
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
|
||||
function CreateKategoriKegiatan() {
|
||||
const createState = useProxy(stateDashboardKegiatan.kategoriKegiatan);
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const isFormValid = () => createState.create.form.nama?.trim() !== '';
|
||||
|
||||
const resetForm = () => {
|
||||
createState.create.resetForm();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!createState.create.form.nama?.trim()) {
|
||||
toast.error('Nama kategori kegiatan wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const success = await createState.create.create();
|
||||
if (success) {
|
||||
resetForm();
|
||||
router.push('/admin/desa/kegiatan-desa/kategori-kegiatan-desa');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating kategori kegiatan:', error);
|
||||
toast.error('Gagal menambahkan kategori kegiatan');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
<Group mb="md">
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
<Title order={4} ml="sm" c="dark">
|
||||
Tambah Kategori Kegiatan
|
||||
</Title>
|
||||
</Group>
|
||||
|
||||
<Paper
|
||||
w={{ base: '100%', md: '50%' }}
|
||||
bg={colors['white-1']}
|
||||
p="lg"
|
||||
radius="md"
|
||||
shadow="sm"
|
||||
style={{ border: '1px solid #e0e0e0' }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Nama Kategori Kegiatan"
|
||||
placeholder="Masukkan nama kategori kegiatan"
|
||||
value={createState.create.form.nama || ''}
|
||||
onChange={(e) => (createState.create.form.nama = e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Group justify="right">
|
||||
<Button variant="outline" color="gray" radius="md" size="md" onClick={resetForm}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
size="md"
|
||||
disabled={!isFormValid() || isSubmitting}
|
||||
style={{
|
||||
background:
|
||||
!isFormValid() || isSubmitting
|
||||
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
|
||||
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateKategoriKegiatan;
|
||||
@@ -0,0 +1,239 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Pagination,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import HeaderSearch from '../../../_com/header';
|
||||
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
|
||||
import stateDashboardKegiatan from '../../../_state/desa/kegiatanDesa';
|
||||
|
||||
function KategoriKegiatanDesa() {
|
||||
const [search, setSearch] = useState('');
|
||||
return (
|
||||
<Box>
|
||||
<HeaderSearch
|
||||
title="Kategori Kegiatan"
|
||||
placeholder="Cari nama kategori kegiatan..."
|
||||
searchIcon={<IconSearch size={20} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<ListKategoriKegiatan search={search} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ListKategoriKegiatan({ search }: { search: string }) {
|
||||
const listDataState = useProxy(stateDashboardKegiatan.kategoriKegiatan);
|
||||
const router = useRouter();
|
||||
const [modalHapus, setModalHapus] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||
|
||||
const { data, loading, load, page, totalPages } = listDataState.findMany;
|
||||
|
||||
useEffect(() => {
|
||||
load(page, 10, debouncedSearch);
|
||||
}, [page, debouncedSearch]);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedId) {
|
||||
listDataState.delete.byId(selectedId);
|
||||
setModalHapus(false);
|
||||
setSelectedId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredData = data || [];
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<Stack py={10}>
|
||||
<Skeleton height={600} radius="md" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box py={{ base: 'sm', md: 'lg' }}>
|
||||
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
|
||||
<Group justify="space-between" mb={{ base: 'md', md: 'lg' }}>
|
||||
<Title order={4} lh={1.2}>
|
||||
Daftar Kategori Kegiatan
|
||||
</Title>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
router.push('/admin/desa/kegiatan-desa/kategori-kegiatan-desa/create')
|
||||
}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Desktop Table */}
|
||||
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
|
||||
<Table highlightOnHover layout="fixed" withColumnBorders={false} miw={0}>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh w="60%">
|
||||
<Text fz="sm" fw={600} lh={1.4}>Kategori</Text>
|
||||
</TableTh>
|
||||
<TableTh w="20%">
|
||||
<Text fz="sm" fw={600} lh={1.4} ta="center">Edit</Text>
|
||||
</TableTh>
|
||||
<TableTh w="20%">
|
||||
<Text fz="sm" fw={600} lh={1.4} ta="center">Hapus</Text>
|
||||
</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd>
|
||||
<Text fz="sm" fw={500} lh={1.45} truncate="end">
|
||||
{item.nama}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd ta="center">
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/admin/desa/kegiatan-desa/kategori-kegiatan-desa/${item.id}`
|
||||
)
|
||||
}
|
||||
size="compact-sm"
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</Button>
|
||||
</TableTd>
|
||||
<TableTd ta="center">
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
disabled={listDataState.delete.loading}
|
||||
onClick={() => {
|
||||
setSelectedId(item.id);
|
||||
setModalHapus(true);
|
||||
}}
|
||||
size="compact-sm"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</Button>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))
|
||||
) : (
|
||||
<TableTr>
|
||||
<TableTd colSpan={3}>
|
||||
<Center py={24}>
|
||||
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||
Tidak ada data kategori kegiatan yang cocok
|
||||
</Text>
|
||||
</Center>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
)}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<Stack hiddenFrom="md" gap="xs" mt="md">
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((item) => (
|
||||
<Paper key={item.id} withBorder radius="md" p="sm" bg="white">
|
||||
<Box flex={1} ml="md">
|
||||
<Text fz="sm" fw={600} lh={1.4}>Kategori</Text>
|
||||
<Text fz="sm" fw={500} lh={1.45} truncate>
|
||||
{item.nama}
|
||||
</Text>
|
||||
</Box>
|
||||
<Group mt="sm" justify="flex-end" gap="xs">
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
size="compact-xs"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/admin/desa/kegiatan-desa/kategori-kegiatan-desa/${item.id}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="compact-xs"
|
||||
disabled={listDataState.delete.loading}
|
||||
onClick={() => {
|
||||
setSelectedId(item.id);
|
||||
setModalHapus(true);
|
||||
}}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
))
|
||||
) : (
|
||||
<Center py={32}>
|
||||
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||
Tidak ada data kategori kegiatan yang cocok
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Center mt={{ base: 'lg', md: 'xl' }}>
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10, search);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
total={totalPages}
|
||||
color="blue"
|
||||
radius="md"
|
||||
/>
|
||||
</Center>
|
||||
|
||||
<ModalKonfirmasiHapus
|
||||
opened={modalHapus}
|
||||
onClose={() => setModalHapus(false)}
|
||||
onConfirm={handleDelete}
|
||||
text="Apakah anda yakin ingin menghapus kategori kegiatan ini?"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default KategoriKegiatanDesa;
|
||||
28
src/app/admin/(dashboard)/desa/kegiatan-desa/layout.tsx
Normal file
28
src/app/admin/(dashboard)/desa/kegiatan-desa/layout.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
import React from 'react';
|
||||
import LayoutTabsKegiatanDesa from './_com/layoutTabs';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Box } from '@mantine/core';
|
||||
|
||||
function Layout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const isDetailPage = segments.length >= 5;
|
||||
|
||||
if (isDetailPage) {
|
||||
return (
|
||||
<Box>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutTabsKegiatanDesa>
|
||||
{children}
|
||||
</LayoutTabsKegiatanDesa>
|
||||
);
|
||||
}
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,335 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
|
||||
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
|
||||
import colors from '@/con/colors';
|
||||
import ApiFetch from '@/lib/api-fetch';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
|
||||
const isHtmlEmpty = (html: string) => html.replace(/<[^>]*>/g, '').trim() === '';
|
||||
|
||||
interface KegiatanData {
|
||||
id: string;
|
||||
judul: string;
|
||||
deskripsiSingkat: string;
|
||||
deskripsiLengkap: string;
|
||||
tanggal: string;
|
||||
lokasi: string;
|
||||
partisipan: number;
|
||||
kategoriKegiatanId: string | null;
|
||||
imageId: string | null;
|
||||
image?: { link: string } | null;
|
||||
}
|
||||
|
||||
function EditKegiatanDesa() {
|
||||
const kegiatanState = useProxy(stateDashboardKegiatan);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const emptyForm = {
|
||||
judul: '',
|
||||
deskripsiSingkat: '',
|
||||
deskripsiLengkap: '',
|
||||
tanggal: '',
|
||||
lokasi: '',
|
||||
partisipan: 0,
|
||||
kategoriKegiatanId: '',
|
||||
imageId: '',
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState(emptyForm);
|
||||
const [originalData, setOriginalData] = useState({ ...emptyForm, imageUrl: '' });
|
||||
|
||||
const isFormValid = () =>
|
||||
formData.judul.trim() !== '' &&
|
||||
formData.deskripsiSingkat.trim() !== '' &&
|
||||
!isHtmlEmpty(formData.deskripsiLengkap) &&
|
||||
formData.tanggal !== '' &&
|
||||
formData.lokasi.trim() !== '' &&
|
||||
formData.kategoriKegiatanId !== '';
|
||||
|
||||
useEffect(() => {
|
||||
kegiatanState.kategoriKegiatan.findMany.load();
|
||||
|
||||
const load = async () => {
|
||||
const id = params?.id as string;
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const data = await stateDashboardKegiatan.kegiatan.edit.load(id) as KegiatanData | null;
|
||||
if (data) {
|
||||
const tanggal = data.tanggal
|
||||
? new Date(data.tanggal).toISOString().split('T')[0]
|
||||
: '';
|
||||
|
||||
const form = {
|
||||
judul: data.judul || '',
|
||||
deskripsiSingkat: data.deskripsiSingkat || '',
|
||||
deskripsiLengkap: data.deskripsiLengkap || '',
|
||||
tanggal,
|
||||
lokasi: data.lokasi || '',
|
||||
partisipan: data.partisipan || 0,
|
||||
kategoriKegiatanId: data.kategoriKegiatanId || '',
|
||||
imageId: data.imageId || '',
|
||||
};
|
||||
|
||||
setFormData(form);
|
||||
setOriginalData({ ...form, imageUrl: data.image?.link || '' });
|
||||
if (data.image?.link) setPreviewImage(data.image.link);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading kegiatan:', error);
|
||||
toast.error('Gagal memuat data kegiatan');
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
}, [params?.id]);
|
||||
|
||||
const handleChange = (field: string, value: string | number) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.judul.trim()) return toast.error('Judul wajib diisi');
|
||||
if (!formData.deskripsiSingkat.trim()) return toast.error('Deskripsi singkat wajib diisi');
|
||||
if (isHtmlEmpty(formData.deskripsiLengkap)) return toast.error('Deskripsi lengkap wajib diisi');
|
||||
if (!formData.tanggal) return toast.error('Tanggal wajib diisi');
|
||||
if (!formData.lokasi.trim()) return toast.error('Lokasi wajib diisi');
|
||||
if (!formData.kategoriKegiatanId) return toast.error('Kategori wajib dipilih');
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
kegiatanState.kegiatan.edit.form = {
|
||||
...kegiatanState.kegiatan.edit.form,
|
||||
...formData,
|
||||
};
|
||||
|
||||
if (file) {
|
||||
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
|
||||
const uploaded = res.data?.data;
|
||||
if (!uploaded?.id) return toast.error('Gagal upload gambar');
|
||||
kegiatanState.kegiatan.edit.form.imageId = uploaded.id;
|
||||
}
|
||||
|
||||
const success = await kegiatanState.kegiatan.edit.update();
|
||||
if (success) {
|
||||
router.push('/admin/desa/kegiatan-desa/list-kegiatan-desa');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating kegiatan:', error);
|
||||
toast.error('Terjadi kesalahan saat memperbarui kegiatan');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFormData({
|
||||
judul: originalData.judul,
|
||||
deskripsiSingkat: originalData.deskripsiSingkat,
|
||||
deskripsiLengkap: originalData.deskripsiLengkap,
|
||||
tanggal: originalData.tanggal,
|
||||
lokasi: originalData.lokasi,
|
||||
partisipan: originalData.partisipan,
|
||||
kategoriKegiatanId: originalData.kategoriKegiatanId,
|
||||
imageId: originalData.imageId,
|
||||
});
|
||||
setPreviewImage(originalData.imageUrl || null);
|
||||
setFile(null);
|
||||
toast.info('Form dikembalikan ke data awal');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
<Group mb="md">
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
<Title order={4} ml="sm" c="dark">Edit Kegiatan Desa</Title>
|
||||
</Group>
|
||||
|
||||
<Paper
|
||||
w={{ base: '100%', md: '50%' }}
|
||||
bg={colors['white-1']}
|
||||
p="lg"
|
||||
radius="md"
|
||||
shadow="sm"
|
||||
style={{ border: '1px solid #e0e0e0' }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Judul"
|
||||
placeholder="Masukkan judul kegiatan"
|
||||
value={formData.judul}
|
||||
onChange={(e) => handleChange('judul', e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Kategori Kegiatan"
|
||||
placeholder="Pilih kategori"
|
||||
data={kegiatanState.kategoriKegiatan.findMany.data.map((item) => ({
|
||||
label: item.nama,
|
||||
value: item.id,
|
||||
}))}
|
||||
value={formData.kategoriKegiatanId || null}
|
||||
onChange={(val) => handleChange('kategoriKegiatanId', val || '')}
|
||||
searchable
|
||||
clearable
|
||||
nothingFoundMessage="Tidak ditemukan"
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Tanggal"
|
||||
type="date"
|
||||
value={formData.tanggal}
|
||||
onChange={(e) => handleChange('tanggal', e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Lokasi"
|
||||
placeholder="Masukkan lokasi kegiatan"
|
||||
value={formData.lokasi}
|
||||
onChange={(e) => handleChange('lokasi', e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Jumlah Partisipan"
|
||||
placeholder="Masukkan jumlah partisipan"
|
||||
value={formData.partisipan}
|
||||
onChange={(val) => handleChange('partisipan', Number(val) || 0)}
|
||||
min={0}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Deskripsi Singkat"
|
||||
placeholder="Masukkan deskripsi singkat kegiatan"
|
||||
value={formData.deskripsiSingkat}
|
||||
onChange={(e) => handleChange('deskripsiSingkat', e.target.value)}
|
||||
minRows={3}
|
||||
autosize
|
||||
required
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Text fz="sm" fw="bold" mb={6}>Deskripsi Lengkap</Text>
|
||||
<EditEditor
|
||||
value={formData.deskripsiLengkap}
|
||||
onChange={(html) => setFormData((prev) => ({ ...prev, deskripsiLengkap: html }))}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>Gambar (Opsional)</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
const f = files[0];
|
||||
if (f) { setFile(f); setPreviewImage(URL.createObjectURL(f)); }
|
||||
}}
|
||||
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||
maxSize={5 * 1024 ** 2}
|
||||
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||
radius="md"
|
||||
p="xl"
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={160}>
|
||||
<Dropzone.Accept>
|
||||
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<IconX size={48} color="red" stroke={1.5} />
|
||||
</Dropzone.Reject>
|
||||
<Dropzone.Idle>
|
||||
<IconPhoto size={48} color="#868e96" stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
|
||||
{previewImage && (
|
||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview"
|
||||
radius="md"
|
||||
style={{
|
||||
maxHeight: 220,
|
||||
objectFit: 'contain',
|
||||
border: `1px solid ${colors['blue-button']}`,
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={() => { setPreviewImage(null); setFile(null); }}
|
||||
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Group justify="right">
|
||||
<Button variant="outline" color="gray" radius="md" size="md" onClick={handleReset}>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
size="md"
|
||||
disabled={!isFormValid() || isSubmitting}
|
||||
style={{
|
||||
background:
|
||||
!isFormValid() || isSubmitting
|
||||
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
|
||||
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditKegiatanDesa;
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
|
||||
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
|
||||
interface KegiatanDetail {
|
||||
id: string;
|
||||
judul: string;
|
||||
deskripsiSingkat: string;
|
||||
deskripsiLengkap: string;
|
||||
tanggal: string;
|
||||
lokasi: string;
|
||||
partisipan: number;
|
||||
kategoriKegiatan?: { nama: string } | null;
|
||||
image?: { link: string } | null;
|
||||
}
|
||||
|
||||
function DetailKegiatanDesa() {
|
||||
const kegiatanState = useProxy(stateDashboardKegiatan);
|
||||
const [modalHapus, setModalHapus] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
useShallowEffect(() => {
|
||||
kegiatanState.kegiatan.findUnique.load(params?.id as string);
|
||||
}, []);
|
||||
|
||||
const handleHapus = () => {
|
||||
if (selectedId) {
|
||||
kegiatanState.kegiatan.delete.byId(selectedId);
|
||||
setModalHapus(false);
|
||||
setSelectedId(null);
|
||||
router.push('/admin/desa/kegiatan-desa/list-kegiatan-desa');
|
||||
}
|
||||
};
|
||||
|
||||
if (!kegiatanState.kegiatan.findUnique.data) {
|
||||
return (
|
||||
<Stack py={10}>
|
||||
<Skeleton height={500} radius="md" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const data = kegiatanState.kegiatan.findUnique.data as unknown as KegiatanDetail;
|
||||
|
||||
const formatTanggal = (val: string) => {
|
||||
if (!val) return '-';
|
||||
return new Date(val).toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
|
||||
mb={15}
|
||||
>
|
||||
Kembali
|
||||
</Button>
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
w={{ base: '100%', md: '70%' }}
|
||||
bg={colors['white-1']}
|
||||
p="lg"
|
||||
radius="md"
|
||||
shadow="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
|
||||
Detail Kegiatan Desa
|
||||
</Text>
|
||||
|
||||
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
|
||||
<Stack gap="sm">
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Kategori</Text>
|
||||
<Text fz="md" c="dimmed">{data.kategoriKegiatan?.nama || '-'}</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Judul</Text>
|
||||
<Text fz="md" c="dimmed">{data.judul || '-'}</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Tanggal</Text>
|
||||
<Text fz="md" c="dimmed">{formatTanggal(data.tanggal)}</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Lokasi</Text>
|
||||
<Text fz="md" c="dimmed">{data.lokasi || '-'}</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Partisipan</Text>
|
||||
<Text fz="md" c="dimmed">{data.partisipan ?? 0} orang</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Deskripsi Singkat</Text>
|
||||
<Text fz="md" c="dimmed" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{data.deskripsiSingkat || '-'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Deskripsi Lengkap</Text>
|
||||
<Paper bg="white" p="md" radius="md" mt="xs">
|
||||
<Text
|
||||
fz="md"
|
||||
c="dimmed"
|
||||
dangerouslySetInnerHTML={{ __html: data.deskripsiLengkap || '-' }}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{data.image?.link && (
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Gambar</Text>
|
||||
<Image
|
||||
src={data.image.link}
|
||||
alt={data.judul || 'Gambar Kegiatan'}
|
||||
w={{ base: '100%', md: 400 }}
|
||||
h={300}
|
||||
radius="md"
|
||||
fit="cover"
|
||||
loading="lazy"
|
||||
mt="xs"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Group gap="sm" mt="md">
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => { setSelectedId(data.id); setModalHapus(true); }}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<IconTrash size={20} />}
|
||||
>
|
||||
Hapus
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/admin/desa/kegiatan-desa/list-kegiatan-desa/${data.id}/edit`
|
||||
)
|
||||
}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<IconEdit size={20} />}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<ModalKonfirmasiHapus
|
||||
opened={modalHapus}
|
||||
onClose={() => setModalHapus(false)}
|
||||
onConfirm={handleHapus}
|
||||
text="Apakah Anda yakin ingin menghapus kegiatan desa ini?"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailKegiatanDesa;
|
||||
@@ -0,0 +1,258 @@
|
||||
'use client'
|
||||
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
|
||||
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
|
||||
import colors from '@/con/colors';
|
||||
import ApiFetch from '@/lib/api-fetch';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
|
||||
const isHtmlEmpty = (html: string) => html.replace(/<[^>]*>/g, '').trim() === '';
|
||||
|
||||
export default function CreateKegiatanDesa() {
|
||||
const kegiatanState = useProxy(stateDashboardKegiatan);
|
||||
const router = useRouter();
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useShallowEffect(() => {
|
||||
kegiatanState.kategoriKegiatan.findMany.load();
|
||||
}, []);
|
||||
|
||||
const isFormValid = () => {
|
||||
const f = kegiatanState.kegiatan.create.form;
|
||||
return (
|
||||
f.judul.trim() !== '' &&
|
||||
f.deskripsiSingkat.trim() !== '' &&
|
||||
!isHtmlEmpty(f.deskripsiLengkap) &&
|
||||
f.tanggal !== '' &&
|
||||
f.lokasi.trim() !== '' &&
|
||||
f.kategoriKegiatanId !== ''
|
||||
);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
kegiatanState.kegiatan.create.resetForm();
|
||||
setPreviewImage(null);
|
||||
setFile(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const f = kegiatanState.kegiatan.create.form;
|
||||
if (!f.judul.trim()) return toast.error('Judul wajib diisi');
|
||||
if (!f.deskripsiSingkat.trim()) return toast.error('Deskripsi singkat wajib diisi');
|
||||
if (isHtmlEmpty(f.deskripsiLengkap)) return toast.error('Deskripsi lengkap wajib diisi');
|
||||
if (!f.tanggal) return toast.error('Tanggal wajib diisi');
|
||||
if (!f.lokasi.trim()) return toast.error('Lokasi wajib diisi');
|
||||
if (!f.kategoriKegiatanId) return toast.error('Kategori wajib dipilih');
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
if (file) {
|
||||
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
|
||||
const uploaded = res.data?.data;
|
||||
if (!uploaded?.id) return toast.error('Gagal mengunggah gambar');
|
||||
kegiatanState.kegiatan.create.form.imageId = uploaded.id;
|
||||
}
|
||||
|
||||
const success = await kegiatanState.kegiatan.create.create();
|
||||
if (success) {
|
||||
resetForm();
|
||||
router.push('/admin/desa/kegiatan-desa/list-kegiatan-desa');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating kegiatan:', error);
|
||||
toast.error('Terjadi kesalahan saat membuat kegiatan');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
<Group mb="md">
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
<Title order={4} ml="sm" c="dark">Tambah Kegiatan Desa</Title>
|
||||
</Group>
|
||||
|
||||
<Paper
|
||||
w={{ base: '100%', md: '50%' }}
|
||||
bg={colors['white-1']}
|
||||
p="lg"
|
||||
radius="md"
|
||||
shadow="sm"
|
||||
style={{ border: '1px solid #e0e0e0' }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Judul"
|
||||
placeholder="Masukkan judul kegiatan"
|
||||
value={kegiatanState.kegiatan.create.form.judul}
|
||||
onChange={(e) => (kegiatanState.kegiatan.create.form.judul = e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Kategori Kegiatan"
|
||||
placeholder="Pilih kategori"
|
||||
data={kegiatanState.kategoriKegiatan.findMany.data.map((item) => ({
|
||||
label: item.nama,
|
||||
value: item.id,
|
||||
}))}
|
||||
value={kegiatanState.kegiatan.create.form.kategoriKegiatanId || null}
|
||||
onChange={(val) => {
|
||||
kegiatanState.kegiatan.create.form.kategoriKegiatanId = val || '';
|
||||
}}
|
||||
searchable
|
||||
clearable
|
||||
nothingFoundMessage="Tidak ditemukan"
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Tanggal"
|
||||
type="date"
|
||||
value={kegiatanState.kegiatan.create.form.tanggal}
|
||||
onChange={(e) => (kegiatanState.kegiatan.create.form.tanggal = e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Lokasi"
|
||||
placeholder="Masukkan lokasi kegiatan"
|
||||
value={kegiatanState.kegiatan.create.form.lokasi}
|
||||
onChange={(e) => (kegiatanState.kegiatan.create.form.lokasi = e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Jumlah Partisipan"
|
||||
placeholder="Masukkan jumlah partisipan"
|
||||
value={kegiatanState.kegiatan.create.form.partisipan}
|
||||
onChange={(val) => (kegiatanState.kegiatan.create.form.partisipan = Number(val) || 0)}
|
||||
min={0}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Deskripsi Singkat"
|
||||
placeholder="Masukkan deskripsi singkat kegiatan"
|
||||
value={kegiatanState.kegiatan.create.form.deskripsiSingkat}
|
||||
onChange={(e) => (kegiatanState.kegiatan.create.form.deskripsiSingkat = e.target.value)}
|
||||
minRows={3}
|
||||
autosize
|
||||
required
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Text fz="sm" fw="bold" mb={6}>Deskripsi Lengkap</Text>
|
||||
<CreateEditor
|
||||
value={kegiatanState.kegiatan.create.form.deskripsiLengkap}
|
||||
onChange={(html) => { kegiatanState.kegiatan.create.form.deskripsiLengkap = html; }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>Gambar (Opsional)</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
const f = files[0];
|
||||
if (f) { setFile(f); setPreviewImage(URL.createObjectURL(f)); }
|
||||
}}
|
||||
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||
maxSize={5 * 1024 ** 2}
|
||||
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||
radius="md"
|
||||
p="xl"
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={160}>
|
||||
<Dropzone.Accept>
|
||||
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
|
||||
</Dropzone.Reject>
|
||||
<Dropzone.Idle>
|
||||
<IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
</Group>
|
||||
<Text ta="center" mt="sm" size="sm" color="dimmed">
|
||||
Seret gambar atau klik untuk memilih (maks 5MB)
|
||||
</Text>
|
||||
</Dropzone>
|
||||
|
||||
{previewImage && (
|
||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview"
|
||||
radius="md"
|
||||
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={() => { setPreviewImage(null); setFile(null); }}
|
||||
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Group justify="right">
|
||||
<Button variant="outline" color="gray" radius="md" size="md" onClick={resetForm}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
size="md"
|
||||
disabled={!isFormValid() || isSubmitting}
|
||||
style={{
|
||||
background:
|
||||
!isFormValid() || isSubmitting
|
||||
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
|
||||
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Pagination,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||
import { IconPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import HeaderSearch from '../../../_com/header';
|
||||
import stateDashboardKegiatan from '../../../_state/desa/kegiatanDesa';
|
||||
|
||||
function KegiatanDesa() {
|
||||
const [search, setSearch] = useState("");
|
||||
return (
|
||||
<Box>
|
||||
<HeaderSearch
|
||||
title="Kegiatan Desa"
|
||||
placeholder="Cari judul atau lokasi kegiatan..."
|
||||
searchIcon={<IconSearch size={20} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<ListKegiatanDesa search={search} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ListKegiatanDesa({ search }: { search: string }) {
|
||||
const kegiatanState = useProxy(stateDashboardKegiatan);
|
||||
const router = useRouter();
|
||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||
|
||||
const { data, page, totalPages, loading, load } = kegiatanState.kegiatan.findMany;
|
||||
|
||||
useShallowEffect(() => {
|
||||
load(page, 10, debouncedSearch);
|
||||
}, [page, debouncedSearch]);
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<Stack py="md">
|
||||
<Skeleton height={600} radius="md" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredData = data || [];
|
||||
|
||||
return (
|
||||
<Box py="md">
|
||||
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>Daftar Kegiatan Desa</Title>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() => router.push('/admin/desa/kegiatan-desa/list-kegiatan-desa/create')}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Desktop Table */}
|
||||
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
|
||||
<Table highlightOnHover layout="fixed" withColumnBorders={false} miw={0}>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh w="35%">Judul</TableTh>
|
||||
<TableTh w="25%">Kategori</TableTh>
|
||||
<TableTh w="20%">Lokasi</TableTh>
|
||||
<TableTh w="20%">Aksi</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd>
|
||||
<Text fz="md" fw={600} lh={1.45} truncate="end">
|
||||
{item.judul}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Text fz="sm" c="dimmed" lh={1.45}>
|
||||
{item.kategoriKegiatan?.nama || '-'}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Text fz="sm" c="dimmed" lh={1.45} truncate="end">
|
||||
{item.lokasi || '-'}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={() =>
|
||||
router.push(`/admin/desa/kegiatan-desa/list-kegiatan-desa/${item.id}`)
|
||||
}
|
||||
fz="sm"
|
||||
px="sm"
|
||||
h={36}
|
||||
>
|
||||
<IconDeviceImacCog size={18} />
|
||||
<Text ml="xs">Detail</Text>
|
||||
</Button>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))
|
||||
) : (
|
||||
<TableTr>
|
||||
<TableTd colSpan={4}>
|
||||
<Center py="xl">
|
||||
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||
Tidak ada data kegiatan yang cocok
|
||||
</Text>
|
||||
</Center>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
)}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<Stack hiddenFrom="md" gap="sm" mt="sm">
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((item) => (
|
||||
<Paper key={item.id} withBorder p="md" radius="md">
|
||||
<Stack gap={"xs"}>
|
||||
<Text fz="sm" fw={600} lh={1.4} c="dimmed">Judul</Text>
|
||||
<Text fz="sm" fw={500} lh={1.45}>{item.judul}</Text>
|
||||
|
||||
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">Kategori</Text>
|
||||
<Text fz="sm" lh={1.45} fw={500}>{item.kategoriKegiatan?.nama || '-'}</Text>
|
||||
|
||||
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">Lokasi</Text>
|
||||
<Text fz="sm" lh={1.45} fw={500}>{item.lokasi || '-'}</Text>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
fullWidth
|
||||
mt="sm"
|
||||
onClick={() =>
|
||||
router.push(`/admin/desa/kegiatan-desa/list-kegiatan-desa/${item.id}`)
|
||||
}
|
||||
fz="sm"
|
||||
h={36}
|
||||
>
|
||||
<IconDeviceImacCog size={18} />
|
||||
<Text ml="xs">Detail</Text>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))
|
||||
) : (
|
||||
<Center py="xl">
|
||||
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||
Tidak ada data kegiatan yang cocok
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Center>
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10, debouncedSearch);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
total={totalPages}
|
||||
mt="md"
|
||||
mb="md"
|
||||
color="blue"
|
||||
radius="md"
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default KegiatanDesa;
|
||||
@@ -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));
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Grid,
|
||||
Group,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
@@ -16,10 +18,11 @@ import {
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
Badge
|
||||
SegmentedControl,
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowUpRight, IconArrowDownRight, IconMinus } from '@tabler/icons-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import umkmState from '../../../_state/ekonomi/umkm/umkm';
|
||||
@@ -27,10 +30,25 @@ import umkmState from '../../../_state/ekonomi/umkm/umkm';
|
||||
function UmkmDashboard() {
|
||||
const state = useProxy(umkmState.dashboard);
|
||||
|
||||
const [kategoriId, setKategoriId] = useState("");
|
||||
const [umkmId, setUmkmId] = useState("");
|
||||
const [kategoriList, setKategoriList] = useState<{ value: string; label: string }[]>([]);
|
||||
const [umkmList, setUmkmList] = useState<{ value: string; label: string }[]>([]);
|
||||
|
||||
useShallowEffect(() => {
|
||||
state.loadAll();
|
||||
fetch('/api/ekonomi/kategoriproduk/find-many-all').then(r => r.json()).then(res => {
|
||||
if (res.success) setKategoriList(res.data.map((k: any) => ({ value: k.id, label: k.nama })));
|
||||
});
|
||||
fetch('/api/ekonomi/umkm/find-many-all').then(r => r.json()).then(res => {
|
||||
if (res.success) setUmkmList(res.data.map((u: any) => ({ value: u.id, label: u.nama })));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.kpi.data) state.loadAll("", state.mode, kategoriId, umkmId);
|
||||
}, [kategoriId, umkmId]);
|
||||
|
||||
if (state.kpi.loading || !state.kpi.data) {
|
||||
return <Skeleton height={400} radius="md" />;
|
||||
}
|
||||
@@ -42,6 +60,18 @@ function UmkmDashboard() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between">
|
||||
<Title order={4}>Update Penjualan Produk</Title>
|
||||
<SegmentedControl
|
||||
value={state.mode}
|
||||
onChange={(v) => state.loadAll("", v as "week" | "month", kategoriId, umkmId)}
|
||||
data={[
|
||||
{ label: 'Minggu ini', value: 'week' },
|
||||
{ label: 'Bulan ini', value: 'month' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
|
||||
<KpiCard title="UMKM Aktif" value={kpi.umkmAktif} subValue={`Total: ${kpi.totalUmkm}`} />
|
||||
<KpiCard
|
||||
@@ -49,8 +79,12 @@ function UmkmDashboard() {
|
||||
value={`Rp ${kpi.omzetBulanan.toLocaleString()}`}
|
||||
trend={summary?.persentasePerubahan}
|
||||
/>
|
||||
<KpiCard title="Produk Aktif" value={summary?.produkAktif || 0} />
|
||||
<KpiCard title="Kategori Populer" value={kpi.kategoriTerbanyak} />
|
||||
<KpiCard title="Kategori Aktif" value={summary?.kategoriAktif || 0} subValue="kategori" />
|
||||
<KpiCard
|
||||
title="UMKM Terbanyak"
|
||||
value={kpi.jumlahKategoriTerbanyak || 0}
|
||||
subValue={kpi.kategoriTerbanyak}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Grid>
|
||||
@@ -59,7 +93,7 @@ function UmkmDashboard() {
|
||||
<Title order={4} mb="md">Grafik Penjualan per Produk</Title>
|
||||
<Box style={{ height: 350 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={detail.map(item => ({
|
||||
<BarChart data={detail.map((item: any) => ({
|
||||
name: item.namaProduk,
|
||||
penjualan: item.penjualanBulanIni
|
||||
}))}>
|
||||
@@ -76,16 +110,21 @@ function UmkmDashboard() {
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<Card withBorder radius="md" p="lg" shadow="sm">
|
||||
<Title order={4} mb="md">Top 3 Produk</Title>
|
||||
<Card h="100%" withBorder radius="md" p="lg" shadow="sm">
|
||||
<Title order={4} mb="md">Top 3 Produk Terlaris</Title>
|
||||
<Stack gap="sm">
|
||||
{topProduk.map((item, i) => (
|
||||
<Group key={i} justify="space-between">
|
||||
{topProduk.map((item: any, i: number) => (
|
||||
<Group key={i} justify="space-between" wrap="nowrap">
|
||||
<Box>
|
||||
<Text fw={500}>{item.namaProduk}</Text>
|
||||
<Text fw={500} size="sm">{item.namaProduk}</Text>
|
||||
<Text size="xs" c="dimmed">{item.namaUmkm}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Rp {item.totalPenjualan.toLocaleString()} · {item.jumlahTerjual} terjual
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fw={600} c="blue">Rp {item.totalPenjualan.toLocaleString()}</Text>
|
||||
<Badge color={item.growth >= 0 ? 'teal' : 'red'} variant="light" size="sm">
|
||||
{item.growth >= 0 ? '+' : ''}{item.growth}%
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -94,24 +133,62 @@ function UmkmDashboard() {
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 8 }}>
|
||||
<Card withBorder radius="md" p="lg" shadow="sm">
|
||||
<Title order={4} mb="md">Detail Penjualan & Stok</Title>
|
||||
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
||||
<Title order={4}>Detail Penjualan Produk</Title>
|
||||
<Group gap="sm">
|
||||
<Select
|
||||
placeholder="Semua Kategori"
|
||||
data={kategoriList}
|
||||
value={kategoriId || null}
|
||||
onChange={(v) => setKategoriId(v || "")}
|
||||
clearable
|
||||
size="xs"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Semua UMKM"
|
||||
data={umkmList}
|
||||
value={umkmId || null}
|
||||
onChange={(v) => setUmkmId(v || "")}
|
||||
clearable
|
||||
size="xs"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>Produk</TableTh>
|
||||
<TableTh>Penjualan</TableTh>
|
||||
<TableTh>Penjualan Bulan Ini</TableTh>
|
||||
<TableTh>Bulan Lalu</TableTh>
|
||||
<TableTh>Trend</TableTh>
|
||||
<TableTh>Volume</TableTh>
|
||||
<TableTh>Stok</TableTh>
|
||||
<TableTh>Status</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{detail.map((item, i) => (
|
||||
{detail.map((item: any, i: number) => (
|
||||
<TableTr key={i}>
|
||||
<TableTd>{item.namaProduk}</TableTd>
|
||||
<TableTd>Rp {item.penjualanBulanIni.toLocaleString()}</TableTd>
|
||||
<TableTd>{renderTrend(item.trend)}</TableTd>
|
||||
<TableTd>Rp {item.penjualanBulanLalu.toLocaleString()}</TableTd>
|
||||
<TableTd>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{renderTrend(item.trend)}
|
||||
{item.trendPersen !== 0 && (
|
||||
<Text
|
||||
size="xs"
|
||||
c={item.trend === 'up' ? 'green' : item.trend === 'down' ? 'red' : 'dimmed'}
|
||||
>
|
||||
{item.trendPersen > 0 ? '+' : ''}{item.trendPersen}%
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</TableTd>
|
||||
<TableTd>{item.volume}</TableTd>
|
||||
<TableTd>{item.stok}</TableTd>
|
||||
<TableTd>
|
||||
<Badge color={getStatusColor(item.statusStok)}>
|
||||
|
||||
@@ -1,50 +1,107 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import EditEditor from "@/app/admin/(dashboard)/_com/editEditor";
|
||||
import colors from "@/con/colors";
|
||||
import ApiFetch from "@/lib/api-fetch";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Text,
|
||||
Select,
|
||||
ActionIcon,
|
||||
Image,
|
||||
Loader,
|
||||
Center
|
||||
} from '@mantine/core';
|
||||
import { Dropzone, IMAGE_MIME_TYPE } from '@mantine/dropzone';
|
||||
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import umkmState from '../../../../../_state/ekonomi/umkm/umkm';
|
||||
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
|
||||
import ApiFetch from '@/lib/api-fetch';
|
||||
Center,
|
||||
} from "@mantine/core";
|
||||
import { Dropzone, IMAGE_MIME_TYPE } from "@mantine/dropzone";
|
||||
import {
|
||||
IconArrowBack,
|
||||
IconPhoto,
|
||||
IconUpload,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { useProxy } from "valtio/utils";
|
||||
import umkmState from "../../../../../_state/ekonomi/umkm/umkm";
|
||||
|
||||
export default function EditDataUmkm() {
|
||||
interface UmkmData {
|
||||
id: string;
|
||||
nama: string;
|
||||
pemilik: string;
|
||||
kategoriId: string | null;
|
||||
deskripsi: string | null;
|
||||
alamat: string | null;
|
||||
kontak: string | null;
|
||||
imageId: string | null;
|
||||
image?: { link: string } | null;
|
||||
}
|
||||
|
||||
interface UmkmForm {
|
||||
nama: string;
|
||||
pemilik: string;
|
||||
kategoriId: string;
|
||||
deskripsi: string;
|
||||
alamat: string;
|
||||
kontak: string;
|
||||
imageId: string | null;
|
||||
}
|
||||
|
||||
function EditDataUmkm() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
const state = useProxy(umkmState.umkm);
|
||||
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
|
||||
const [formData, setFormData] = useState<UmkmForm>({
|
||||
nama: "",
|
||||
pemilik: "",
|
||||
kategoriId: "",
|
||||
deskripsi: "",
|
||||
alamat: "",
|
||||
kontak: "",
|
||||
imageId: null,
|
||||
});
|
||||
|
||||
const [originalData, setOriginalData] = useState<UmkmForm & { imageUrl: string }>({
|
||||
nama: "",
|
||||
pemilik: "",
|
||||
kategoriId: "",
|
||||
deskripsi: "",
|
||||
alamat: "",
|
||||
kontak: "",
|
||||
imageId: null,
|
||||
imageUrl: ""
|
||||
});
|
||||
|
||||
const isFormValid = () => {
|
||||
return (
|
||||
formData.nama?.trim() !== '' &&
|
||||
formData.pemilik?.trim() !== '' &&
|
||||
formData.kategoriId !== ''
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
await Promise.all([
|
||||
umkmState.kategoriProduk.findManyAll.load(),
|
||||
state.findUnique.load(id)
|
||||
umkmState.umkm.findUnique.load(id)
|
||||
]);
|
||||
|
||||
if (state.findUnique.data) {
|
||||
const data = state.findUnique.data;
|
||||
state.update.form = {
|
||||
const data = umkmState.umkm.findUnique.data as UmkmData | null;
|
||||
if (data) {
|
||||
const initialForm: UmkmForm = {
|
||||
nama: data.nama || "",
|
||||
pemilik: data.pemilik || "",
|
||||
kategoriId: data.kategoriId || "",
|
||||
@@ -52,23 +109,35 @@ export default function EditDataUmkm() {
|
||||
alamat: data.alamat || "",
|
||||
kontak: data.kontak || "",
|
||||
imageId: data.imageId || "",
|
||||
isActive: data.isActive ?? true,
|
||||
};
|
||||
|
||||
if (data.image?.url) {
|
||||
setPreviewImage(data.image.url);
|
||||
setFormData(initialForm);
|
||||
setOriginalData({
|
||||
...initialForm,
|
||||
imageUrl: data.image?.link || ""
|
||||
});
|
||||
|
||||
if (data.image?.link) {
|
||||
setPreviewImage(data.image.link);
|
||||
}
|
||||
}
|
||||
setIsInitialLoading(false);
|
||||
};
|
||||
init();
|
||||
}, [id, state.findUnique, state.update]);
|
||||
}, [id]);
|
||||
|
||||
const handleChange = (field: keyof UmkmForm, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!formData.nama?.trim()) return toast.error("Nama UMKM wajib diisi");
|
||||
if (!formData.pemilik?.trim()) return toast.error("Nama pemilik wajib diisi");
|
||||
if (!formData.kategoriId) return toast.error("Kategori wajib dipilih");
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// 1. Upload image if new file selected
|
||||
let uploadedImageId = state.update.form.imageId;
|
||||
let uploadedImageId = formData.imageId;
|
||||
if (file) {
|
||||
const res = await ApiFetch.api.fileStorage.create.post({
|
||||
file,
|
||||
@@ -84,10 +153,14 @@ export default function EditDataUmkm() {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Submit UMKM data
|
||||
state.update.form.imageId = uploadedImageId;
|
||||
const success = await state.update.submit(id);
|
||||
// Update proxy state
|
||||
umkmState.umkm.update.form = {
|
||||
...umkmState.umkm.update.form,
|
||||
...formData,
|
||||
imageId: uploadedImageId
|
||||
};
|
||||
|
||||
const success = await umkmState.umkm.update.submit(id);
|
||||
if (success) {
|
||||
router.push('/admin/ekonomi/umkm/data-umkm');
|
||||
}
|
||||
@@ -99,6 +172,21 @@ export default function EditDataUmkm() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
nama: originalData.nama,
|
||||
pemilik: originalData.pemilik,
|
||||
kategoriId: originalData.kategoriId,
|
||||
deskripsi: originalData.deskripsi,
|
||||
alamat: originalData.alamat,
|
||||
kontak: originalData.kontak,
|
||||
imageId: originalData.imageId,
|
||||
});
|
||||
setPreviewImage(originalData.imageUrl || null);
|
||||
setFile(null);
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
<Center h={400}>
|
||||
@@ -108,69 +196,93 @@ export default function EditDataUmkm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Group mb="lg">
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
leftSection={<IconArrowBack size={20} />}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
Kembali
|
||||
<IconArrowBack color={colors["blue-button"]} size={24} />
|
||||
</Button>
|
||||
<Title order={3}>Edit Data UMKM</Title>
|
||||
<Title order={4} ml="sm" c="dark">
|
||||
Edit Data UMKM
|
||||
</Title>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder p="xl" radius="md" shadow="sm">
|
||||
<Stack gap="lg">
|
||||
{/* Logo / Image UMKM */}
|
||||
{/* Form */}
|
||||
<Paper
|
||||
w={{ base: "100%", md: "50%" }}
|
||||
bg={colors["white-1"]}
|
||||
p="lg"
|
||||
radius="md"
|
||||
shadow="sm"
|
||||
style={{ border: "1px solid #e0e0e0" }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Logo / Foto UMKM */}
|
||||
<Box>
|
||||
<Text fw={500} size="sm" mb={4}>Logo / Foto UMKM</Text>
|
||||
{!previewImage ? (
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
const file = files[0];
|
||||
setFile(file);
|
||||
setPreviewImage(URL.createObjectURL(file));
|
||||
}}
|
||||
maxSize={3 * 1024 ** 2}
|
||||
accept={IMAGE_MIME_TYPE}
|
||||
radius="md"
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={120} style={{ pointerEvents: 'none' }}>
|
||||
<Dropzone.Accept>
|
||||
<IconUpload size={42} stroke={1.5} />
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<IconX size={42} stroke={1.5} />
|
||||
</Dropzone.Reject>
|
||||
<Dropzone.Idle>
|
||||
<IconPhoto size={42} stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Logo / Foto UMKM
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
const selectedFile = files[0];
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile);
|
||||
setPreviewImage(URL.createObjectURL(selectedFile));
|
||||
}
|
||||
}}
|
||||
onReject={() =>
|
||||
toast.error("File tidak valid, gunakan format gambar")
|
||||
}
|
||||
maxSize={5 * 1024 ** 2}
|
||||
accept={IMAGE_MIME_TYPE}
|
||||
radius="md"
|
||||
p="xl"
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={140}>
|
||||
<Dropzone.Accept>
|
||||
<IconUpload size={48} color={colors["blue-button"]} stroke={1.5} />
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<IconX size={48} color="red" stroke={1.5} />
|
||||
</Dropzone.Reject>
|
||||
<Dropzone.Idle>
|
||||
<IconPhoto size={48} color="#868e96" stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
|
||||
<Box>
|
||||
<Text size="xl" inline>
|
||||
Klik atau tarik gambar di sini
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" inline mt={7}>
|
||||
Maksimal 3MB
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
) : (
|
||||
<Box pos="relative" w="fit-content">
|
||||
<Image src={previewImage} h={200} radius="md" alt="Preview" />
|
||||
{previewImage && (
|
||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview Logo"
|
||||
radius="md"
|
||||
style={{
|
||||
maxHeight: 220,
|
||||
objectFit: "contain",
|
||||
border: `1px solid ${colors["blue-button"]}`,
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={() => {
|
||||
setPreviewImage(null);
|
||||
setFile(null);
|
||||
state.update.form.imageId = "";
|
||||
setFormData(prev => ({ ...prev, imageId: null }));
|
||||
}}
|
||||
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
@@ -183,15 +295,15 @@ export default function EditDataUmkm() {
|
||||
label="Nama UMKM / Bisnis"
|
||||
placeholder="Contoh: Warung Sate Bu Komang"
|
||||
required
|
||||
value={state.update.form.nama}
|
||||
onChange={(e) => (state.update.form.nama = e.target.value)}
|
||||
value={formData.nama}
|
||||
onChange={(e) => handleChange("nama", e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Nama Pemilik"
|
||||
placeholder="Masukkan nama lengkap pemilik"
|
||||
required
|
||||
value={state.update.form.pemilik}
|
||||
onChange={(e) => (state.update.form.pemilik = e.target.value)}
|
||||
value={formData.pemilik}
|
||||
onChange={(e) => handleChange("pemilik", e.target.value)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -200,42 +312,64 @@ export default function EditDataUmkm() {
|
||||
label="Kategori Bisnis"
|
||||
placeholder="Pilih kategori"
|
||||
required
|
||||
data={umkmState.kategoriProduk.findManyAll.data?.map(v => ({
|
||||
data={umkmState.kategoriProduk.findManyAll.data?.map((v: any) => ({
|
||||
value: v.id, label: v.nama
|
||||
})) || []}
|
||||
value={state.update.form.kategoriId}
|
||||
onChange={(val) => (state.update.form.kategoriId = val || "")}
|
||||
value={formData.kategoriId}
|
||||
onChange={(val) => handleChange("kategoriId", val || "")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Nomor WA / Kontak"
|
||||
placeholder="Contoh: 08123456789"
|
||||
value={state.update.form.kontak}
|
||||
onChange={(e) => (state.update.form.kontak = e.target.value)}
|
||||
value={formData.kontak}
|
||||
onChange={(e) => handleChange("kontak", e.target.value)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
label="Alamat Lengkap"
|
||||
placeholder="Masukkan alamat fisik usaha"
|
||||
value={state.update.form.alamat}
|
||||
onChange={(e) => (state.update.form.alamat = e.target.value)}
|
||||
value={formData.alamat}
|
||||
onChange={(e) => handleChange("alamat", e.target.value)}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Text fw={500} size="sm" mb={4}>Deskripsi UMKM</Text>
|
||||
<CreateEditor
|
||||
value={state.update.form.deskripsi || ""}
|
||||
onChange={(val) => (state.update.form.deskripsi = val)}
|
||||
<Text fw="bold" fz="sm" mb={4}>
|
||||
Deskripsi UMKM
|
||||
</Text>
|
||||
<EditEditor
|
||||
value={formData.deskripsi}
|
||||
onChange={(htmlContent) =>
|
||||
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }))
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Group justify="flex-end" mt="xl">
|
||||
{/* Action Buttons */}
|
||||
<Group justify="right" mt="md">
|
||||
<Button
|
||||
color="blue"
|
||||
onClick={handleUpdate}
|
||||
loading={isSubmitting}
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={handleResetForm}
|
||||
>
|
||||
Simpan Perubahan
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
radius="md"
|
||||
size="md"
|
||||
disabled={!isFormValid() || isSubmitting}
|
||||
style={{
|
||||
background: !isFormValid() || isSubmitting
|
||||
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -243,3 +377,5 @@ export default function EditDataUmkm() {
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditDataUmkm;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
144
src/app/admin/(dashboard)/ekonomi/umkm/kategori-produk/page.tsx
Normal file
144
src/app/admin/(dashboard)/ekonomi/umkm/kategori-produk/page.tsx
Normal 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;
|
||||
@@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -14,21 +15,40 @@ import {
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import { useState } from 'react';
|
||||
|
||||
import umkmState from '../../../_state/ekonomi/umkm/umkm';
|
||||
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
|
||||
|
||||
function PenjualanUmkm() {
|
||||
const state = useProxy(umkmState.penjualan.findMany);
|
||||
|
||||
const [modalHapus, setModalHapus] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
useShallowEffect(() => {
|
||||
state.load(state.page, 10);
|
||||
}, [state.page]);
|
||||
|
||||
const handleHapus = async () => {
|
||||
if (!selectedId) return;
|
||||
|
||||
const success = await umkmState.penjualan.del.submit(selectedId);
|
||||
|
||||
if (success) {
|
||||
setModalHapus(false);
|
||||
setSelectedId(null);
|
||||
|
||||
// 🔥 reload data
|
||||
state.load(state.page, 10);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between">
|
||||
@@ -54,16 +74,30 @@ function PenjualanUmkm() {
|
||||
<TableTh>Aksi</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
|
||||
<TableTbody>
|
||||
{state.data.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd>{new Date(item.tanggal).toLocaleDateString('id-ID')}</TableTd>
|
||||
<TableTd>
|
||||
{new Date(item.tanggal).toLocaleDateString('id-ID')}
|
||||
</TableTd>
|
||||
<TableTd fw={500}>{item.produk?.nama}</TableTd>
|
||||
<TableTd>{item.produk?.umkm?.nama}</TableTd>
|
||||
<TableTd>{item.jumlah}</TableTd>
|
||||
<TableTd fw={600}>Rp {item.totalNilai.toLocaleString()}</TableTd>
|
||||
<TableTd fw={600}>
|
||||
Rp {item.totalNilai.toLocaleString()}
|
||||
</TableTd>
|
||||
|
||||
<TableTd>
|
||||
<Button variant="subtle" color="red" size="xs">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setSelectedId(item.id);
|
||||
setModalHapus(true);
|
||||
}}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</Button>
|
||||
</TableTd>
|
||||
@@ -82,6 +116,14 @@ function PenjualanUmkm() {
|
||||
/>
|
||||
</Center>
|
||||
</Paper>
|
||||
|
||||
{/* 🔥 Modal Konfirmasi */}
|
||||
<ModalKonfirmasiHapus
|
||||
opened={modalHapus}
|
||||
onClose={() => setModalHapus(false)}
|
||||
onConfirm={handleHapus}
|
||||
text="Apakah Anda yakin ingin menghapus data penjualan ini?"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,75 +1,147 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import EditEditor from "@/app/admin/(dashboard)/_com/editEditor";
|
||||
import colors from "@/con/colors";
|
||||
import ApiFetch from "@/lib/api-fetch";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Text,
|
||||
Select,
|
||||
ActionIcon,
|
||||
Image,
|
||||
Loader,
|
||||
NumberInput,
|
||||
Center,
|
||||
Loader
|
||||
} from '@mantine/core';
|
||||
import { Dropzone, IMAGE_MIME_TYPE } from '@mantine/dropzone';
|
||||
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import umkmState from '../../../../../_state/ekonomi/umkm/umkm';
|
||||
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
|
||||
import ApiFetch from '@/lib/api-fetch';
|
||||
} from "@mantine/core";
|
||||
import { Dropzone, IMAGE_MIME_TYPE } from "@mantine/dropzone";
|
||||
import {
|
||||
IconArrowBack,
|
||||
IconPhoto,
|
||||
IconUpload,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { useProxy } from "valtio/utils";
|
||||
import umkmState from "../../../../../_state/ekonomi/umkm/umkm";
|
||||
|
||||
export default function EditProdukUmkm() {
|
||||
interface ProdukData {
|
||||
id: string;
|
||||
nama: string;
|
||||
harga: number;
|
||||
stok: number;
|
||||
umkmId: string | null;
|
||||
deskripsi: string | null;
|
||||
imageId: string | null;
|
||||
kategoriProdukId: string | null;
|
||||
image?: { link: string } | null;
|
||||
}
|
||||
|
||||
interface ProdukForm {
|
||||
nama: string;
|
||||
harga: number;
|
||||
stok: number;
|
||||
umkmId: string;
|
||||
deskripsi: string;
|
||||
imageId: string | null;
|
||||
kategoriId: string;
|
||||
}
|
||||
|
||||
function EditProdukUmkm() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
const state = useProxy(umkmState.produk);
|
||||
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
|
||||
const [formData, setFormData] = useState<ProdukForm>({
|
||||
nama: "",
|
||||
harga: 0,
|
||||
stok: 0,
|
||||
umkmId: "",
|
||||
deskripsi: "",
|
||||
imageId: null,
|
||||
kategoriId: "",
|
||||
});
|
||||
|
||||
const [originalData, setOriginalData] = useState<ProdukForm & { imageUrl: string }>({
|
||||
nama: "",
|
||||
harga: 0,
|
||||
stok: 0,
|
||||
umkmId: "",
|
||||
deskripsi: "",
|
||||
imageId: null,
|
||||
kategoriId: "",
|
||||
imageUrl: ""
|
||||
});
|
||||
|
||||
const isFormValid = () => {
|
||||
return (
|
||||
formData.nama?.trim() !== '' &&
|
||||
formData.umkmId !== '' &&
|
||||
formData.kategoriId !== '' &&
|
||||
formData.harga >= 0 &&
|
||||
formData.stok >= 0
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
await Promise.all([
|
||||
umkmState.umkm.findMany.load(1, 100),
|
||||
umkmState.umkm.findMany.load(),
|
||||
umkmState.kategoriProduk.findManyAll.load(),
|
||||
state.findUnique.load(id)
|
||||
umkmState.produk.findUnique.load(id)
|
||||
]);
|
||||
|
||||
if (state.findUnique.data) {
|
||||
const data = state.findUnique.data;
|
||||
state.update.form = {
|
||||
const data = umkmState.produk.findUnique.data as ProdukData | null;
|
||||
if (data) {
|
||||
const initialForm: ProdukForm = {
|
||||
nama: data.nama || "",
|
||||
harga: data.harga || 0,
|
||||
stok: data.stok || 0,
|
||||
umkmId: data.umkmId || "",
|
||||
deskripsi: data.deskripsi || "",
|
||||
imageId: data.imageId || "",
|
||||
kategoriId: data.kategoriId || "",
|
||||
isActive: data.isActive ?? true,
|
||||
kategoriId: data.kategoriProdukId || "",
|
||||
};
|
||||
|
||||
if (data.image?.url) {
|
||||
setPreviewImage(data.image.url);
|
||||
setFormData(initialForm);
|
||||
setOriginalData({
|
||||
...initialForm,
|
||||
imageUrl: data.image?.link || ""
|
||||
});
|
||||
|
||||
if (data.image?.link) {
|
||||
setPreviewImage(data.image.link);
|
||||
}
|
||||
}
|
||||
setIsInitialLoading(false);
|
||||
};
|
||||
init();
|
||||
}, [id, state.findUnique, state.update]);
|
||||
}, [id]);
|
||||
|
||||
const handleChange = (field: keyof ProdukForm, value: string | number) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!formData.nama?.trim()) return toast.error("Nama produk wajib diisi");
|
||||
if (!formData.umkmId) return toast.error("UMKM pemilik wajib dipilih");
|
||||
if (!formData.kategoriId) return toast.error("Kategori wajib dipilih");
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let uploadedImageId = state.update.form.imageId;
|
||||
let uploadedImageId = formData.imageId;
|
||||
if (file) {
|
||||
const res = await ApiFetch.api.fileStorage.create.post({
|
||||
file,
|
||||
@@ -85,9 +157,14 @@ export default function EditProdukUmkm() {
|
||||
}
|
||||
}
|
||||
|
||||
state.update.form.imageId = uploadedImageId;
|
||||
const success = await state.update.submit(id);
|
||||
// Update proxy state
|
||||
umkmState.produk.update.form = {
|
||||
...umkmState.produk.update.form,
|
||||
...formData,
|
||||
imageId: uploadedImageId
|
||||
};
|
||||
|
||||
const success = await umkmState.produk.update.submit(id);
|
||||
if (success) {
|
||||
router.push('/admin/ekonomi/umkm/produk');
|
||||
}
|
||||
@@ -99,6 +176,21 @@ export default function EditProdukUmkm() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
nama: originalData.nama,
|
||||
harga: originalData.harga,
|
||||
stok: originalData.stok,
|
||||
umkmId: originalData.umkmId,
|
||||
deskripsi: originalData.deskripsi,
|
||||
imageId: originalData.imageId,
|
||||
kategoriId: originalData.kategoriId,
|
||||
});
|
||||
setPreviewImage(originalData.imageUrl || null);
|
||||
setFile(null);
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
<Center h={400}>
|
||||
@@ -108,57 +200,93 @@ export default function EditProdukUmkm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Group mb="lg">
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
leftSection={<IconArrowBack size={20} />}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
Kembali
|
||||
<IconArrowBack color={colors["blue-button"]} size={24} />
|
||||
</Button>
|
||||
<Title order={3}>Edit Produk UMKM</Title>
|
||||
<Title order={4} ml="sm" c="dark">
|
||||
Edit Produk UMKM
|
||||
</Title>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder p="xl" radius="md" shadow="sm">
|
||||
<Stack gap="lg">
|
||||
{/* Form */}
|
||||
<Paper
|
||||
w={{ base: "100%", md: "50%" }}
|
||||
bg={colors["white-1"]}
|
||||
p="lg"
|
||||
radius="md"
|
||||
shadow="sm"
|
||||
style={{ border: "1px solid #e0e0e0" }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Foto Produk */}
|
||||
<Box>
|
||||
<Text fw={500} size="sm" mb={4}>Foto Produk</Text>
|
||||
{!previewImage ? (
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
const file = files[0];
|
||||
setFile(file);
|
||||
setPreviewImage(URL.createObjectURL(file));
|
||||
}}
|
||||
maxSize={3 * 1024 ** 2}
|
||||
accept={IMAGE_MIME_TYPE}
|
||||
radius="md"
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={120} style={{ pointerEvents: 'none' }}>
|
||||
<Dropzone.Idle>
|
||||
<IconPhoto size={42} stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
<Box>
|
||||
<Text size="xl" inline>Pilih gambar produk</Text>
|
||||
<Text size="sm" c="dimmed" inline mt={7}>Maksimal 3MB</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
) : (
|
||||
<Box pos="relative" w="fit-content">
|
||||
<Image src={previewImage} h={200} radius="md" alt="Preview" />
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Foto Produk
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
const selectedFile = files[0];
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile);
|
||||
setPreviewImage(URL.createObjectURL(selectedFile));
|
||||
}
|
||||
}}
|
||||
onReject={() =>
|
||||
toast.error("File tidak valid, gunakan format gambar")
|
||||
}
|
||||
maxSize={5 * 1024 ** 2}
|
||||
accept={IMAGE_MIME_TYPE}
|
||||
radius="md"
|
||||
p="xl"
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={140}>
|
||||
<Dropzone.Accept>
|
||||
<IconUpload size={48} color={colors["blue-button"]} stroke={1.5} />
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<IconX size={48} color="red" stroke={1.5} />
|
||||
</Dropzone.Reject>
|
||||
<Dropzone.Idle>
|
||||
<IconPhoto size={48} color="#868e96" stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
|
||||
{previewImage && (
|
||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview Produk"
|
||||
radius="md"
|
||||
style={{
|
||||
maxHeight: 220,
|
||||
objectFit: "contain",
|
||||
border: `1px solid ${colors["blue-button"]}`,
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={() => {
|
||||
setPreviewImage(null);
|
||||
setFile(null);
|
||||
state.update.form.imageId = "";
|
||||
setFormData(prev => ({ ...prev, imageId: null }));
|
||||
}}
|
||||
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
@@ -166,26 +294,29 @@ export default function EditProdukUmkm() {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* UMKM Pemilik */}
|
||||
<Select
|
||||
label="Pilih UMKM Pemilik"
|
||||
placeholder="Siapa pemilik produk ini?"
|
||||
required
|
||||
searchable
|
||||
data={umkmState.umkm.findMany.data?.map(v => ({
|
||||
data={umkmState.umkm.findMany.data?.map((v: any) => ({
|
||||
value: v.id, label: v.nama
|
||||
})) || []}
|
||||
value={state.update.form.umkmId}
|
||||
onChange={(val) => (state.update.form.umkmId = val || "")}
|
||||
value={formData.umkmId}
|
||||
onChange={(val) => handleChange("umkmId", val || "")}
|
||||
/>
|
||||
|
||||
{/* Nama Produk */}
|
||||
<TextInput
|
||||
label="Nama Produk"
|
||||
placeholder="Contoh: Kripik Singkong Pedas"
|
||||
placeholder="Masukkan nama produk"
|
||||
value={formData.nama}
|
||||
onChange={(e) => handleChange("nama", e.target.value)}
|
||||
required
|
||||
value={state.update.form.nama}
|
||||
onChange={(e) => (state.update.form.nama = e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Harga & Stok */}
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Harga Produk (Rp)"
|
||||
@@ -194,43 +325,75 @@ export default function EditProdukUmkm() {
|
||||
min={0}
|
||||
thousandSeparator="."
|
||||
decimalSeparator=","
|
||||
value={state.update.form.harga}
|
||||
onChange={(val) => (state.update.form.harga = Number(val))}
|
||||
value={formData.harga}
|
||||
onChange={(val) => handleChange("harga", Number(val))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Stok"
|
||||
placeholder="0"
|
||||
required
|
||||
min={0}
|
||||
value={state.update.form.stok}
|
||||
onChange={(val) => (state.update.form.stok = Number(val))}
|
||||
value={formData.stok}
|
||||
onChange={(val) => handleChange("stok", Number(val))}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Kategori */}
|
||||
<Select
|
||||
label="Kategori Produk"
|
||||
placeholder="Pilih kategori produk"
|
||||
required
|
||||
data={umkmState.kategoriProduk.findManyAll.data?.map(v => ({
|
||||
data={umkmState.kategoriProduk.findManyAll.data?.map((v: any) => ({
|
||||
value: v.id, label: v.nama
|
||||
})) || []}
|
||||
value={state.update.form.kategoriId}
|
||||
onChange={(val) => (state.update.form.kategoriId = val || "")}
|
||||
value={formData.kategoriId}
|
||||
onChange={(val) => handleChange("kategoriId", val || "")}
|
||||
/>
|
||||
|
||||
{/* Deskripsi */}
|
||||
<Box>
|
||||
<Text fw={500} size="sm" mb={4}>Deskripsi Produk</Text>
|
||||
<CreateEditor
|
||||
value={state.update.form.deskripsi || ""}
|
||||
onChange={(val) => (state.update.form.deskripsi = val)}
|
||||
<Text fz="sm" fw="bold" mb={4}>
|
||||
Deskripsi Produk
|
||||
</Text>
|
||||
<EditEditor
|
||||
value={formData.deskripsi}
|
||||
onChange={(htmlContent) =>
|
||||
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }))
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button color="blue" onClick={handleUpdate} loading={isSubmitting}>Simpan Perubahan</Button>
|
||||
{/* Action Buttons */}
|
||||
<Group justify="right" mt="md">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={handleResetForm}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
radius="md"
|
||||
size="md"
|
||||
disabled={!isFormValid() || isSubmitting}
|
||||
style={{
|
||||
background: !isFormValid() || isSubmitting
|
||||
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditProdukUmkm;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -118,6 +118,11 @@ export const devBar = [
|
||||
id: "Desa_7",
|
||||
name: "Penghargaan",
|
||||
path: "/admin/desa/penghargaan"
|
||||
},
|
||||
{
|
||||
id: "Desa_8",
|
||||
name: "Kegiatan Desa",
|
||||
path: "/admin/desa/kegiatan-desa/list-kegiatan-desa"
|
||||
}
|
||||
|
||||
]
|
||||
@@ -549,6 +554,11 @@ export const navBar = [
|
||||
id: "Desa_7",
|
||||
name: "Penghargaan",
|
||||
path: "/admin/desa/penghargaan"
|
||||
},
|
||||
{
|
||||
id: "Desa_8",
|
||||
name: "Kegiatan Desa",
|
||||
path: "/admin/desa/kegiatan-desa/list-kegiatan-desa"
|
||||
}
|
||||
|
||||
]
|
||||
@@ -995,6 +1005,11 @@ export const role1 = [
|
||||
id: "Desa_7",
|
||||
name: "Penghargaan",
|
||||
path: "/admin/desa/penghargaan"
|
||||
},
|
||||
{
|
||||
id: "Desa_8",
|
||||
name: "Kegiatan Desa",
|
||||
path: "/admin/desa/kegiatan-desa/list-kegiatan-desa"
|
||||
}
|
||||
|
||||
]
|
||||
|
||||
@@ -13,6 +13,8 @@ import KategoriPengumuman from "./pengumuman/kategori-pengumuman";
|
||||
import MantanPerbekel from "./profile/profile-mantan-perbekel";
|
||||
import AjukanPermohonan from "./layanan/ajukan_permohonan";
|
||||
import Musik from "./musik";
|
||||
import KegiatanDesa from "./kegiatan-desa";
|
||||
import KategoriKegiatan from "./kegiatan-desa/kategori-kegiatan";
|
||||
|
||||
|
||||
const Desa = new Elysia({ prefix: "/desa", tags: ["Desa"] })
|
||||
@@ -30,6 +32,8 @@ const Desa = new Elysia({ prefix: "/desa", tags: ["Desa"] })
|
||||
.use(KategoriPengumuman)
|
||||
.use(AjukanPermohonan)
|
||||
.use(Musik)
|
||||
.use(KegiatanDesa)
|
||||
.use(KategoriKegiatan)
|
||||
|
||||
|
||||
export default Desa;
|
||||
|
||||
30
src/app/api/[[...slugs]]/_lib/desa/kegiatan-desa/create.ts
Normal file
30
src/app/api/[[...slugs]]/_lib/desa/kegiatan-desa/create.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function kegiatanDesaCreate(context: Context) {
|
||||
const body = context.body as any;
|
||||
|
||||
try {
|
||||
const data = await prisma.kegiatanDesa.create({
|
||||
data: {
|
||||
judul: body.judul,
|
||||
deskripsiSingkat: body.deskripsiSingkat,
|
||||
deskripsiLengkap: body.deskripsiLengkap,
|
||||
tanggal: new Date(body.tanggal),
|
||||
lokasi: body.lokasi,
|
||||
partisipan: Number(body.partisipan) || 0,
|
||||
kategoriKegiatanId: body.kategoriKegiatanId,
|
||||
imageId: body.imageId || null,
|
||||
},
|
||||
include: { kategoriKegiatan: true, image: true },
|
||||
});
|
||||
|
||||
return { success: true, message: "Kegiatan desa berhasil dibuat", data };
|
||||
} catch (e) {
|
||||
console.error("Error di kegiatanDesaCreate:", e);
|
||||
return { success: false, message: "Gagal membuat kegiatan desa" };
|
||||
}
|
||||
}
|
||||
|
||||
export default kegiatanDesaCreate;
|
||||
19
src/app/api/[[...slugs]]/_lib/desa/kegiatan-desa/del.ts
Normal file
19
src/app/api/[[...slugs]]/_lib/desa/kegiatan-desa/del.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function kegiatanDesaDelete(context: Context) {
|
||||
const { id } = context.params as { id: string };
|
||||
|
||||
try {
|
||||
await prisma.kegiatanDesa.update({
|
||||
where: { id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
return { success: true, message: "Kegiatan desa berhasil dihapus" };
|
||||
} catch (e) {
|
||||
console.error("Error di kegiatanDesaDelete:", e);
|
||||
return { success: false, message: "Gagal menghapus kegiatan desa" };
|
||||
}
|
||||
}
|
||||
|
||||
export default kegiatanDesaDelete;
|
||||
@@ -0,0 +1,57 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function kegiatanDesaFindMany(context: Context) {
|
||||
const page = Number(context.query.page) || 1;
|
||||
const limit = Number(context.query.limit) || 10;
|
||||
const search = (context.query.search as string) || '';
|
||||
const kategori = (context.query.kategori as string) || '';
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = { isActive: true };
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ judul: { contains: search, mode: 'insensitive' } },
|
||||
{ lokasi: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
if (kategori) {
|
||||
where.kategoriKegiatan = {
|
||||
nama: { contains: kategori, mode: 'insensitive' },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.kegiatanDesa.findMany({
|
||||
where,
|
||||
include: {
|
||||
kategoriKegiatan: true,
|
||||
image: true,
|
||||
},
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { tanggal: 'asc' },
|
||||
}),
|
||||
prisma.kegiatanDesa.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil ambil kegiatan desa",
|
||||
data,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di kegiatanDesaFindMany:", e);
|
||||
return { success: false, message: "Gagal mengambil data kegiatan desa" };
|
||||
}
|
||||
}
|
||||
|
||||
export default kegiatanDesaFindMany;
|
||||
@@ -0,0 +1,35 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function kegiatanDesaFindUnique(context: Context) {
|
||||
const { id } = context.params as { id: string };
|
||||
|
||||
if (!id) {
|
||||
return { success: false, message: "ID is required" };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await prisma.kegiatanDesa.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
kategoriKegiatan: true,
|
||||
image: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return { success: false, message: "Data tidak ditemukan" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil ambil kegiatan desa",
|
||||
data,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di kegiatanDesaFindUnique:", e);
|
||||
return { success: false, message: "Gagal mengambil data kegiatan desa" };
|
||||
}
|
||||
}
|
||||
|
||||
export default kegiatanDesaFindUnique;
|
||||
37
src/app/api/[[...slugs]]/_lib/desa/kegiatan-desa/index.ts
Normal file
37
src/app/api/[[...slugs]]/_lib/desa/kegiatan-desa/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import kegiatanDesaFindMany from "./find-many";
|
||||
import kegiatanDesaFindUnique from "./findUnique";
|
||||
import kegiatanDesaCreate from "./create";
|
||||
import kegiatanDesaDelete from "./del";
|
||||
import kegiatanDesaUpdate from "./updt";
|
||||
|
||||
const KegiatanDesa = new Elysia({ prefix: "/kegiatandesa", tags: ["Desa/Kegiatan Desa"] })
|
||||
.get("/find-many", kegiatanDesaFindMany)
|
||||
.get("/:id", kegiatanDesaFindUnique)
|
||||
.post("/create", kegiatanDesaCreate, {
|
||||
body: t.Object({
|
||||
judul: t.String(),
|
||||
deskripsiSingkat: t.String(),
|
||||
deskripsiLengkap: t.String(),
|
||||
tanggal: t.String(),
|
||||
lokasi: t.String(),
|
||||
partisipan: t.Optional(t.Number()),
|
||||
kategoriKegiatanId: t.String(),
|
||||
imageId: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
.put("/:id", kegiatanDesaUpdate, {
|
||||
body: t.Object({
|
||||
judul: t.String(),
|
||||
deskripsiSingkat: t.String(),
|
||||
deskripsiLengkap: t.String(),
|
||||
tanggal: t.String(),
|
||||
lokasi: t.String(),
|
||||
partisipan: t.Optional(t.Number()),
|
||||
kategoriKegiatanId: t.String(),
|
||||
imageId: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
.delete("/del/:id", kegiatanDesaDelete);
|
||||
|
||||
export default KegiatanDesa;
|
||||
@@ -0,0 +1,26 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
type FormCreate = {
|
||||
nama: string;
|
||||
}
|
||||
|
||||
export default async function kategoriKegiatanCreate(context: Context) {
|
||||
const body = (await context.body) as FormCreate;
|
||||
|
||||
try {
|
||||
const result = await prisma.kategoriKegiatan.create({
|
||||
data: {
|
||||
nama: body.nama,
|
||||
},
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil membuat kategori kegiatan",
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error creating kategori kegiatan:", error);
|
||||
throw new Error("Gagal membuat kategori kegiatan: " + (error as Error).message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
export default async function kategoriKegiatanDelete(context: Context) {
|
||||
try {
|
||||
const id = context.params?.id as string;
|
||||
|
||||
if (!id) {
|
||||
return Response.json({
|
||||
success: false,
|
||||
message: "ID tidak boleh kosong",
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// ✅ Cek apakah kategori masih digunakan oleh kegiatan
|
||||
const kegiatanCount = await prisma.kegiatanDesa.count({
|
||||
where: {
|
||||
kategoriKegiatanId: id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (kegiatanCount > 0) {
|
||||
return Response.json({
|
||||
success: false,
|
||||
message: `Kategori tidak dapat dihapus karena masih digunakan oleh ${kegiatanCount} kegiatan`,
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// ✅ Soft delete (bukan hard delete)
|
||||
await prisma.kategoriKegiatan.update({
|
||||
where: { id },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
isActive: false,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Kategori kegiatan berhasil dihapus",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Delete kategori error:", error);
|
||||
return Response.json({
|
||||
success: false,
|
||||
message: "Gagal menghapus kategori: " + (error instanceof Error ? error.message : 'Unknown error'),
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function kategoriKegiatanFindMany(context: Context) {
|
||||
// Ambil parameter dari query
|
||||
const page = Number(context.query.page) || 1;
|
||||
const limit = Number(context.query.limit) || 10;
|
||||
const search = (context.query.search as string) || '';
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Buat where clause
|
||||
const where: any = { isActive: true };
|
||||
|
||||
// Tambahkan pencarian (jika ada)
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ nama: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
// Ambil data dan total count secara paralel
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.kategoriKegiatan.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
prisma.kategoriKegiatan.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil ambil kategori kegiatan dengan pagination",
|
||||
data,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di findMany paginated:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal mengambil data kategori kegiatan",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default kategoriKegiatanFindMany;
|
||||
@@ -0,0 +1,46 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export default async function kategoriKegiatanFindUnique(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const pathSegments = url.pathname.split('/');
|
||||
const id = pathSegments[pathSegments.length - 1];
|
||||
|
||||
if (!id) {
|
||||
return {
|
||||
success: false,
|
||||
message: "ID is required",
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
message: "ID is required",
|
||||
}
|
||||
}
|
||||
|
||||
const data = await prisma.kategoriKegiatan.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Data not found",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Success get kategori kegiatan",
|
||||
data,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Find by ID error:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal mengambil data: " + (error instanceof Error ? error.message : 'Unknown error'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import kategoriKegiatanCreate from "./create";
|
||||
import kategoriKegiatanDelete from "./del";
|
||||
import kategoriKegiatanFindMany from "./findMany";
|
||||
import kategoriKegiatanFindUnique from "./findUnique";
|
||||
import kategoriKegiatanUpdate from "./updt";
|
||||
|
||||
const KategoriKegiatan = new Elysia({
|
||||
prefix: "/kategorikegiatan",
|
||||
tags: ["Desa / Kegiatan Desa / Kategori Kegiatan"],
|
||||
})
|
||||
|
||||
.post("/create", kategoriKegiatanCreate, {
|
||||
body: t.Object({
|
||||
nama: t.String(),
|
||||
}),
|
||||
})
|
||||
|
||||
.get("/findMany", kategoriKegiatanFindMany)
|
||||
.get("/:id", async (context) => {
|
||||
const response = await kategoriKegiatanFindUnique(
|
||||
new Request(context.request)
|
||||
);
|
||||
return response;
|
||||
})
|
||||
.put("/:id", kategoriKegiatanUpdate, {
|
||||
body: t.Object({
|
||||
nama: t.String(),
|
||||
}),
|
||||
})
|
||||
.delete("/del/:id", kategoriKegiatanDelete);
|
||||
|
||||
export default KategoriKegiatan;
|
||||
@@ -0,0 +1,28 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
type FormUpdate = {
|
||||
nama: string;
|
||||
}
|
||||
|
||||
export default async function kategoriKegiatanUpdate(context: Context) {
|
||||
const body = (await context.body) as FormUpdate;
|
||||
const id = context.params.id as string;
|
||||
|
||||
try {
|
||||
const result = await prisma.kategoriKegiatan.update({
|
||||
where: { id },
|
||||
data: {
|
||||
nama: body.nama,
|
||||
},
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil mengupdate kategori kegiatan",
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error updating kategori kegiatan:", error);
|
||||
throw new Error("Gagal mengupdate kategori kegiatan: " + (error as Error).message);
|
||||
}
|
||||
}
|
||||
32
src/app/api/[[...slugs]]/_lib/desa/kegiatan-desa/updt.ts
Normal file
32
src/app/api/[[...slugs]]/_lib/desa/kegiatan-desa/updt.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function kegiatanDesaUpdate(context: Context) {
|
||||
const { id } = context.params as { id: string };
|
||||
const body = context.body as any;
|
||||
|
||||
try {
|
||||
const data = await prisma.kegiatanDesa.update({
|
||||
where: { id },
|
||||
data: {
|
||||
judul: body.judul,
|
||||
deskripsiSingkat: body.deskripsiSingkat,
|
||||
deskripsiLengkap: body.deskripsiLengkap,
|
||||
tanggal: new Date(body.tanggal),
|
||||
lokasi: body.lokasi,
|
||||
partisipan: Number(body.partisipan) || 0,
|
||||
kategoriKegiatanId: body.kategoriKegiatanId,
|
||||
imageId: body.imageId || null,
|
||||
},
|
||||
include: { kategoriKegiatan: true, image: true },
|
||||
});
|
||||
|
||||
return { success: true, message: "Kegiatan desa berhasil diupdate", data };
|
||||
} catch (e) {
|
||||
console.error("Error di kegiatanDesaUpdate:", e);
|
||||
return { success: false, message: "Gagal mengupdate kegiatan desa" };
|
||||
}
|
||||
}
|
||||
|
||||
export default kegiatanDesaUpdate;
|
||||
@@ -9,6 +9,7 @@ import DemografiPekerjaan from "./demografi-pekerjaan";
|
||||
import JumlahPengangguran from "./jumlah-pengangguran";
|
||||
import PendapatanAsliDesa from "./pendapatan-asli-desa";
|
||||
import StrukturOrganisasi from "./struktur-bumdes";
|
||||
import KategoriProduk from "./umkm/kategori-produk/kategori-produk";
|
||||
import Umkm from "./umkm";
|
||||
import ProdukUmkm from "./umkm/produk";
|
||||
import PenjualanProduk from "./umkm/penjualan";
|
||||
@@ -21,6 +22,7 @@ const Ekonomi = new Elysia({
|
||||
.use(LowonganKerja)
|
||||
.use(ProgramKemiskinan)
|
||||
.use(StrukturOrganisasi)
|
||||
.use(KategoriProduk)
|
||||
.use(Umkm)
|
||||
.use(ProdukUmkm)
|
||||
.use(PenjualanProduk)
|
||||
|
||||
@@ -1,49 +1,86 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
function getWeekRange(offsetWeeks = 0) {
|
||||
const now = new Date();
|
||||
const day = now.getDay();
|
||||
const diffToMonday = day === 0 ? -6 : 1 - day;
|
||||
|
||||
const monday = new Date(now);
|
||||
monday.setDate(now.getDate() + diffToMonday + offsetWeeks * 7);
|
||||
monday.setHours(0, 0, 0, 0);
|
||||
|
||||
const sunday = new Date(monday);
|
||||
sunday.setDate(monday.getDate() + 6);
|
||||
sunday.setHours(23, 59, 59, 999);
|
||||
|
||||
return { start: monday, end: sunday };
|
||||
}
|
||||
|
||||
async function umkmDashboardDetailPenjualan(context: Context) {
|
||||
const periode = (context.query.periode as string) ||
|
||||
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
|
||||
const mode = context.query.mode as string | undefined;
|
||||
const isWeek = mode === "week";
|
||||
|
||||
const periode =
|
||||
(context.query.periode as string) ||
|
||||
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`;
|
||||
|
||||
const date = new Date(periode + "-01");
|
||||
date.setMonth(date.getMonth() - 1);
|
||||
const periodeLalu = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
const periodeLalu = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
|
||||
const kategoriId = context.query.kategoriId as string | undefined;
|
||||
const umkmId = context.query.umkmId as string | undefined;
|
||||
|
||||
const whereSkrg = isWeek
|
||||
? { createdAt: { gte: getWeekRange(0).start, lte: getWeekRange(0).end }, deletedAt: null }
|
||||
: { periode, deletedAt: null };
|
||||
|
||||
const whereLalu = isWeek
|
||||
? { createdAt: { gte: getWeekRange(-1).start, lte: getWeekRange(-1).end }, deletedAt: null }
|
||||
: { periode: periodeLalu, deletedAt: null };
|
||||
|
||||
// Filter produk berdasarkan kategori dan/atau UMKM
|
||||
const produkFilter: Record<string, unknown> = { deletedAt: null };
|
||||
if (kategoriId) produkFilter.kategoriProdukId = kategoriId;
|
||||
if (umkmId) produkFilter.umkmId = umkmId;
|
||||
|
||||
try {
|
||||
// Ambil semua produk yang punya penjualan bulan ini atau bulan lalu
|
||||
const [produkSkrg, produkLalu, allProduks] = await Promise.all([
|
||||
prisma.penjualanProduk.groupBy({
|
||||
by: ['produkId'],
|
||||
where: { periode, deletedAt: null },
|
||||
_sum: { totalNilai: true, jumlah: true }
|
||||
by: ["produkId"],
|
||||
where: whereSkrg,
|
||||
_sum: { totalNilai: true, jumlah: true },
|
||||
}),
|
||||
prisma.penjualanProduk.groupBy({
|
||||
by: ['produkId'],
|
||||
where: { periode: periodeLalu, deletedAt: null },
|
||||
_sum: { totalNilai: true }
|
||||
by: ["produkId"],
|
||||
where: whereLalu,
|
||||
_sum: { totalNilai: true },
|
||||
}),
|
||||
// Use PasarDesa
|
||||
prisma.pasarDesa.findMany({
|
||||
where: { deletedAt: null },
|
||||
select: { id: true, nama: true, stok: true }
|
||||
})
|
||||
where: produkFilter,
|
||||
select: { id: true, nama: true, stok: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const data = allProduks.map(p => {
|
||||
const skrgRaw = produkSkrg.find(s => s.produkId === p.id)?._sum || { totalNilai: 0, jumlah: 0 };
|
||||
const laluRaw = produkLalu.find(l => l.produkId === p.id)?._sum || { totalNilai: 0 };
|
||||
const data = allProduks.map((p) => {
|
||||
const skrgRaw = produkSkrg.find((s) => s.produkId === p.id)?._sum || {};
|
||||
const laluRaw = produkLalu.find((l) => l.produkId === p.id)?._sum || {};
|
||||
|
||||
const skrg = {
|
||||
totalNilai: (skrgRaw as any).totalNilai || 0,
|
||||
jumlah: (skrgRaw as any).jumlah || 0
|
||||
};
|
||||
const lalu = {
|
||||
totalNilai: (laluRaw as any).totalNilai || 0
|
||||
};
|
||||
const nilaiSkrg = (skrgRaw as any).totalNilai || 0;
|
||||
const nilaiLalu = (laluRaw as any).totalNilai || 0;
|
||||
const jumlah = (skrgRaw as any).jumlah || 0;
|
||||
|
||||
let trend = "stable";
|
||||
if (skrg.totalNilai > lalu.totalNilai) trend = "up";
|
||||
if (skrg.totalNilai < lalu.totalNilai) trend = "down";
|
||||
if (nilaiSkrg > nilaiLalu) trend = "up";
|
||||
if (nilaiSkrg < nilaiLalu) trend = "down";
|
||||
|
||||
let trendPersen = 0;
|
||||
if (nilaiLalu > 0) {
|
||||
trendPersen = Math.round(((nilaiSkrg - nilaiLalu) / nilaiLalu) * 10000) / 100;
|
||||
} else if (nilaiSkrg > 0) {
|
||||
trendPersen = 100;
|
||||
}
|
||||
|
||||
let statusStok = "Aman";
|
||||
if (p.stok < 5) statusStok = "Rendah";
|
||||
@@ -51,19 +88,17 @@ async function umkmDashboardDetailPenjualan(context: Context) {
|
||||
|
||||
return {
|
||||
namaProduk: p.nama,
|
||||
penjualanBulanIni: skrg.totalNilai,
|
||||
penjualanBulanLalu: lalu.totalNilai,
|
||||
penjualanBulanIni: nilaiSkrg,
|
||||
penjualanBulanLalu: nilaiLalu,
|
||||
trend,
|
||||
volume: skrg.jumlah,
|
||||
trendPersen,
|
||||
volume: jumlah,
|
||||
stok: p.stok,
|
||||
statusStok
|
||||
statusStok,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data
|
||||
};
|
||||
return { success: true, data };
|
||||
} catch (e) {
|
||||
console.error("Error di umkmDashboardDetailPenjualan:", e);
|
||||
return { success: false, message: "Gagal mengambil detail penjualan dashboard" };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,30 +1,58 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
function getWeekRange(offsetWeeks = 0) {
|
||||
const now = new Date();
|
||||
const day = now.getDay();
|
||||
const diffToMonday = day === 0 ? -6 : 1 - day;
|
||||
|
||||
const monday = new Date(now);
|
||||
monday.setDate(now.getDate() + diffToMonday + offsetWeeks * 7);
|
||||
monday.setHours(0, 0, 0, 0);
|
||||
|
||||
const sunday = new Date(monday);
|
||||
sunday.setDate(monday.getDate() + 6);
|
||||
sunday.setHours(23, 59, 59, 999);
|
||||
|
||||
return { start: monday, end: sunday };
|
||||
}
|
||||
|
||||
async function umkmDashboardRingSummary(context: Context) {
|
||||
const periode = (context.query.periode as string) ||
|
||||
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
|
||||
const mode = context.query.mode as string | undefined;
|
||||
const isWeek = mode === "week";
|
||||
|
||||
const periode =
|
||||
(context.query.periode as string) ||
|
||||
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`;
|
||||
|
||||
// Hitung periode bulan lalu
|
||||
const date = new Date(periode + "-01");
|
||||
date.setMonth(date.getMonth() - 1);
|
||||
const periodeLalu = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
const periodeLalu = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
|
||||
const whereSkrg = isWeek
|
||||
? { createdAt: { gte: getWeekRange(0).start, lte: getWeekRange(0).end }, deletedAt: null }
|
||||
: { periode, deletedAt: null };
|
||||
|
||||
const whereLalu = isWeek
|
||||
? { createdAt: { gte: getWeekRange(-1).start, lte: getWeekRange(-1).end }, deletedAt: null }
|
||||
: { periode: periodeLalu, deletedAt: null };
|
||||
|
||||
try {
|
||||
const [penjualanSkrg, penjualanLalu, produkAktif, totalTransaksi] = await Promise.all([
|
||||
const [penjualanSkrg, penjualanLalu, kategoriAktif, totalTransaksi] = await Promise.all([
|
||||
prisma.penjualanProduk.aggregate({
|
||||
where: { periode, deletedAt: null },
|
||||
_sum: { totalNilai: true }
|
||||
where: whereSkrg,
|
||||
_sum: { totalNilai: true },
|
||||
}),
|
||||
prisma.penjualanProduk.aggregate({
|
||||
where: { periode: periodeLalu, deletedAt: null },
|
||||
_sum: { totalNilai: true }
|
||||
where: whereLalu,
|
||||
_sum: { totalNilai: true },
|
||||
}),
|
||||
// Count from PasarDesa
|
||||
prisma.pasarDesa.count({
|
||||
where: { isActive: true, deletedAt: null }
|
||||
// Hitung jumlah kategori aktif
|
||||
prisma.kategoriProdukUmkm.count({
|
||||
where: { isActive: true, deletedAt: null },
|
||||
}),
|
||||
prisma.penjualanProduk.count({ where: { periode, deletedAt: null } })
|
||||
prisma.penjualanProduk.count({ where: whereSkrg }),
|
||||
]);
|
||||
|
||||
const skrg = penjualanSkrg._sum.totalNilai || 0;
|
||||
@@ -42,9 +70,9 @@ async function umkmDashboardRingSummary(context: Context) {
|
||||
data: {
|
||||
totalPenjualan: skrg,
|
||||
persentasePerubahan: Math.round(persentasePerubahan * 100) / 100,
|
||||
produkAktif,
|
||||
totalTransaksi
|
||||
}
|
||||
kategoriAktif,
|
||||
totalTransaksi,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di umkmDashboardRingSummary:", e);
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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;
|
||||
@@ -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: {
|
||||
|
||||
@@ -21,6 +21,7 @@ import Kematian from "./data_kesehatan_warga/persentase_kelahiran_kematian/kemat
|
||||
import DokterTenagaMedis from "./data_kesehatan_warga/fasilitas_kesehatan/dokter-tenaga-medis";
|
||||
import PendaftaranJadwalKegiatan from "./data_kesehatan_warga/jadwal_kegiatan/pendaftaran";
|
||||
import TarifLayanan from "./data_kesehatan_warga/fasilitas_kesehatan/tarif-layanan";
|
||||
import RingkasanKesehatan from "./ringkasan-kesehatan";
|
||||
|
||||
|
||||
const Kesehatan = new Elysia({
|
||||
@@ -49,4 +50,5 @@ const Kesehatan = new Elysia({
|
||||
.use(DokterTenagaMedis)
|
||||
.use(TarifLayanan)
|
||||
.use(PendaftaranJadwalKegiatan)
|
||||
.use(RingkasanKesehatan)
|
||||
export default Kesehatan;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
async function ringkasanKesehatanFindUnique() {
|
||||
try {
|
||||
const data = await prisma.ringkasanKesehatanDesa.findFirst({
|
||||
where: { isActive: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return { success: true, data };
|
||||
} catch (e) {
|
||||
console.error("Error di ringkasanKesehatanFindUnique:", e);
|
||||
return { success: false, message: "Gagal mengambil ringkasan kesehatan" };
|
||||
}
|
||||
}
|
||||
|
||||
export default ringkasanKesehatanFindUnique;
|
||||
@@ -0,0 +1,15 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import ringkasanKesehatanFindUnique from "./findUnique";
|
||||
import ringkasanKesehatanUpdate from "./updt";
|
||||
|
||||
const RingkasanKesehatan = new Elysia({ prefix: "/ringkasankesehatan", tags: ["Kesehatan/Ringkasan"] })
|
||||
.get("/find", ringkasanKesehatanFindUnique)
|
||||
.put("/update", ringkasanKesehatanUpdate, {
|
||||
body: t.Object({
|
||||
ibuHamilAkh: t.Number(),
|
||||
balitaTerdaftar: t.Number(),
|
||||
alertStunting: t.Number(),
|
||||
}),
|
||||
});
|
||||
|
||||
export default RingkasanKesehatan;
|
||||
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function ringkasanKesehatanUpdate(context: Context) {
|
||||
const body = context.body as any;
|
||||
|
||||
try {
|
||||
const existing = await prisma.ringkasanKesehatanDesa.findFirst({
|
||||
where: { isActive: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const data = existing
|
||||
? await prisma.ringkasanKesehatanDesa.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
ibuHamilAkh: Number(body.ibuHamilAkh),
|
||||
balitaTerdaftar: Number(body.balitaTerdaftar),
|
||||
alertStunting: Number(body.alertStunting),
|
||||
},
|
||||
})
|
||||
: await prisma.ringkasanKesehatanDesa.create({
|
||||
data: {
|
||||
ibuHamilAkh: Number(body.ibuHamilAkh),
|
||||
balitaTerdaftar: Number(body.balitaTerdaftar),
|
||||
alertStunting: Number(body.alertStunting),
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, message: "Ringkasan kesehatan berhasil disimpan", data };
|
||||
} catch (e) {
|
||||
console.error("Error di ringkasanKesehatanUpdate:", e);
|
||||
return { success: false, message: "Gagal menyimpan ringkasan kesehatan" };
|
||||
}
|
||||
}
|
||||
|
||||
export default ringkasanKesehatanUpdate;
|
||||
@@ -0,0 +1,17 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
async function beasiswaConfigFindUnique() {
|
||||
try {
|
||||
const data = await prisma.beasiswaConfig.findFirst({
|
||||
where: { isActive: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return { success: true, data };
|
||||
} catch (e) {
|
||||
console.error("Error di beasiswaConfigFindUnique:", e);
|
||||
return { success: false, message: "Gagal mengambil konfigurasi beasiswa" };
|
||||
}
|
||||
}
|
||||
|
||||
export default beasiswaConfigFindUnique;
|
||||
@@ -0,0 +1,14 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import beasiswaConfigFindUnique from "./findUnique";
|
||||
import beasiswaConfigUpdate from "./updt";
|
||||
|
||||
const BeasiswaConfig = new Elysia({ prefix: "/beasiswaconfig", tags: ["Pendidikan/Beasiswa Desa/Config"] })
|
||||
.get("/find", beasiswaConfigFindUnique)
|
||||
.put("/update", beasiswaConfigUpdate, {
|
||||
body: t.Object({
|
||||
tahunAjaran: t.String(),
|
||||
danaTersalurkan: t.String(),
|
||||
}),
|
||||
});
|
||||
|
||||
export default BeasiswaConfig;
|
||||
@@ -0,0 +1,36 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function beasiswaConfigUpdate(context: Context) {
|
||||
const body = context.body as any;
|
||||
|
||||
try {
|
||||
const existing = await prisma.beasiswaConfig.findFirst({
|
||||
where: { isActive: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const data = existing
|
||||
? await prisma.beasiswaConfig.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
tahunAjaran: body.tahunAjaran,
|
||||
danaTersalurkan: BigInt(body.danaTersalurkan),
|
||||
},
|
||||
})
|
||||
: await prisma.beasiswaConfig.create({
|
||||
data: {
|
||||
tahunAjaran: body.tahunAjaran,
|
||||
danaTersalurkan: BigInt(body.danaTersalurkan),
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, message: "Konfigurasi beasiswa berhasil disimpan", data: { ...data, danaTersalurkan: data.danaTersalurkan.toString() } };
|
||||
} catch (e) {
|
||||
console.error("Error di beasiswaConfigUpdate:", e);
|
||||
return { success: false, message: "Gagal menyimpan konfigurasi beasiswa" };
|
||||
}
|
||||
}
|
||||
|
||||
export default beasiswaConfigUpdate;
|
||||
@@ -1,6 +1,7 @@
|
||||
import Elysia from "elysia";
|
||||
import BeasiswaPendaftar from "./beasiswa-pendaftar";
|
||||
import KeunggulanProgram from "./keunggulan-program";
|
||||
import BeasiswaConfig from "./beasiswa-config";
|
||||
|
||||
const Beasiswa = new Elysia({
|
||||
prefix: "/beasiswa",
|
||||
@@ -8,5 +9,6 @@ const Beasiswa = new Elysia({
|
||||
})
|
||||
.use(BeasiswaPendaftar)
|
||||
.use(KeunggulanProgram)
|
||||
.use(BeasiswaConfig)
|
||||
|
||||
export default Beasiswa
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user