Compare commits
24 Commits
656ffcc561
...
stg
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a74a1f683 | |||
| b673e36a45 | |||
| 62aa9b63b2 | |||
| 58ab306428 | |||
| 97902f6277 | |||
| ef7d1752de | |||
| f9de4b7a35 | |||
| 13873c9fe7 | |||
| 03b084d9d4 | |||
| 5df9698599 | |||
| 3d3e5ffc87 | |||
| e80e333eed | |||
| b1289831f3 | |||
| 3c4e273e26 | |||
| de4563c914 | |||
| 11ff5f5c01 | |||
| ed44222594 | |||
| fd7579d6d3 | |||
| e7c3c020c2 | |||
| 6873e84848 | |||
| 74dc9e5c18 | |||
| 04001c905b | |||
| 62a9a49502 | |||
| e669dcee25 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -55,3 +55,6 @@ next-env.d.ts
|
||||
|
||||
*.tar.gz
|
||||
|
||||
# local scripts
|
||||
ai.sh
|
||||
|
||||
|
||||
116
CLAUDE.md
Normal file
116
CLAUDE.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# 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/*`).
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
bun run dev # Start dev server (port 3000)
|
||||
bun run build # Production build
|
||||
bun run tsc --noEmit # Type-check only
|
||||
|
||||
# Testing
|
||||
bun run test # All tests
|
||||
bun run test:api # Unit tests (Vitest)
|
||||
bun run test:e2e # E2E tests (Playwright)
|
||||
|
||||
# Database
|
||||
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
|
||||
|
||||
# Linting
|
||||
bun eslint . --fix
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Tech Stack
|
||||
- **Framework**: Next.js 15 (App Router) + React 19
|
||||
- **Runtime/Package manager**: Bun (not npm)
|
||||
- **API server**: Elysia.js (mounted at `/api/[[...slugs]]`)
|
||||
- **ORM**: Prisma + PostgreSQL
|
||||
- **UI**: Mantine UI v7-8
|
||||
- **State**: Jotai (atoms), Valtio (proxies), SWR (data fetching)
|
||||
- **Auth**: iron-session + JWT
|
||||
- **File storage**: Local uploads + Seafile (self-hosted)
|
||||
|
||||
### Request Flow
|
||||
|
||||
```
|
||||
Browser → Next.js middleware (src/middleware.ts)
|
||||
→ Public pages: src/app/darmasaba/
|
||||
→ Admin pages: src/app/admin/
|
||||
→ API: src/app/api/[[...slugs]]/route.ts (Elysia.js)
|
||||
└── _lib/*.ts (domain modules)
|
||||
```
|
||||
|
||||
The Elysia server is a single entry point with domain-specific modules: `desa.ts`, `kesehatan.ts`, `ekonomi.ts`, `keamanan.ts`, `lingkungan.ts`, `pendidikan.ts`, `kependudukan.ts`, `ppid.ts`, `inovasi.ts`, `auth/`, `user/`, `fileStorage/`. Swagger docs are auto-generated at `/api/docs`.
|
||||
|
||||
### Domain Modules
|
||||
Each domain (desa, kesehatan, ekonomi, etc.) has:
|
||||
- API handler in `src/app/api/[[...slugs]]/_lib/<domain>.ts`
|
||||
- Admin CMS pages in `src/app/admin/(dashboard)/<domain>/`
|
||||
- Public pages in `src/app/darmasaba/(pages)/<domain>/`
|
||||
|
||||
### 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.
|
||||
10
Dockerfile
10
Dockerfile
@@ -58,11 +58,19 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/src/lib ./src/lib
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/next.config.* ./
|
||||
COPY --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
|
||||
# Create uploads directory with proper permissions
|
||||
RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads
|
||||
|
||||
USER nextjs
|
||||
|
||||
# Persistent storage for uploaded files
|
||||
VOLUME ["/app/uploads"]
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["/app/docker-entrypoint.sh"]
|
||||
CMD ["/app/docker-entrypoint.sh"]
|
||||
|
||||
34
MIND/PLAN/umkm-module.md
Normal file
34
MIND/PLAN/umkm-module.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Plan: UMKM Module Implementation
|
||||
|
||||
## Goal
|
||||
Implement UMKM, ProdukUmkm, and PenjualanProduk module with CRUD API and Dashboard analytics.
|
||||
|
||||
## Steps
|
||||
1. Update Prisma Schema (already done in file).
|
||||
2. Run database migration and seed data.
|
||||
3. Implement UMKM CRUD API.
|
||||
4. Implement ProdukUmkm CRUD API.
|
||||
5. Implement PenjualanProduk CRUD API.
|
||||
6. Implement Dashboard API (KPI, Summary, Top Produk, Detail Penjualan).
|
||||
7. Register all routers in the ekonomi module.
|
||||
8. Verify with type check and build.
|
||||
|
||||
## Progress
|
||||
- [x] Step 1: Update Prisma Schema
|
||||
- [x] Step 2: Run database migration
|
||||
- [x] Step 3: Implement UMKM CRUD API
|
||||
- [x] Step 4: Implement ProdukUmkm CRUD API
|
||||
- [x] Step 5: Implement PenjualanProduk CRUD API
|
||||
- [x] Step 6: Implement Dashboard API
|
||||
- [x] Step 7: Register routers
|
||||
- [x] Step 8: Verify changes
|
||||
- [x] Step 9: Implement Admin UI Layout and Tabs
|
||||
- [x] Step 10: Implement Dashboard UI Page
|
||||
- [x] Step 11: Implement Data UMKM UI Page
|
||||
- [x] Step 12: Implement Produk UI Page
|
||||
- [x] Step 13: Implement Penjualan UI Page
|
||||
- [x] Step 14: Register UI pages in Admin Menu
|
||||
- [x] Step 15: Implement Public UMKM Directory Page
|
||||
- [x] Step 16: Implement Public UMKM Detail Page
|
||||
- [x] Step 17: Implement Public Product Catalog Page
|
||||
- [x] Step 18: Register public pages in Navbar
|
||||
37
MIND/SUMMARY/umkm-module-summary.md
Normal file
37
MIND/SUMMARY/umkm-module-summary.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Summary: UMKM Module Implementation
|
||||
|
||||
## Accomplishments
|
||||
- Successfully migrated the database to include `Umkm`, `ProdukUmkm`, and `PenjualanProduk` tables.
|
||||
- Implemented a complete set of CRUD API endpoints for UMKM, Products, and Sales.
|
||||
- Developed a comprehensive Dashboard API providing KPIs, sales summaries, top products, and detailed stock analytics.
|
||||
- Integrated the new module into the existing `ekonomi` router.
|
||||
- Implemented the Admin UI with a modern tab-based layout.
|
||||
- Created four main admin pages: Dashboard, Data UMKM, Produk, and Penjualan.
|
||||
- Registered the new UMKM module in the Admin Navigation Menu for all roles.
|
||||
- Implemented the Public UI for citizens to browse local businesses.
|
||||
- Created three public pages: Direktori UMKM, UMKM Detail, and Katalog Produk.
|
||||
- Registered the public UMKM pages in the main Website Navbar under the Ekonomi section.
|
||||
- Verified the implementation with `tsc` and `bun run build`.
|
||||
|
||||
## Files Created/Modified
|
||||
### Modified
|
||||
- `prisma/schema.prisma`: Added relations and models.
|
||||
- `src/app/api/[[...slugs]]/_lib/ekonomi/index.ts`: Registered new routers.
|
||||
- `src/app/admin/_com/list_PageAdmin.tsx`: Registered new UI pages in menu.
|
||||
|
||||
### Created
|
||||
- `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/`: CRUD for UMKM.
|
||||
- `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/produk/`: CRUD for Products.
|
||||
- `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/penjualan/`: CRUD for Sales with stock management.
|
||||
- `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/dashboard/`: Analytics endpoints.
|
||||
- `src/app/admin/(dashboard)/ekonomi/umkm/`: Admin UI pages and layouts.
|
||||
- `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts`: Valtio state for the UMKM module.
|
||||
|
||||
## Stock Management Logic
|
||||
- Creating a sale decrements product stock.
|
||||
- Updating a sale adjusts stock based on the difference in quantity.
|
||||
- Deleting a sale increments stock back.
|
||||
|
||||
## Next Steps
|
||||
- Implement frontend UI for the UMKM module.
|
||||
- Add more comprehensive tests for the stock management logic.
|
||||
16
QWEN.md
16
QWEN.md
@@ -240,16 +240,18 @@ Common issues and solutions:
|
||||
Workflow bisa dijalankan via GitHub CLI: `gh workflow run <nama.yml> -f param=value --ref branch`
|
||||
|
||||
Setelah commit ke branch deployment (dev/stg/prod), otomatis trigger workflow publish + re-pull untuk deploy ke server.
|
||||
|
||||
- **Deployment Workflow Sistematis**:
|
||||
1. **Version Bump** - Update `version` di `package.json` sebelum deploy (ikuti semver: major.minor.patch)
|
||||
2. **Commit** - Commit perubahan + version bump dengan pesan yang jelas
|
||||
3. **Push ke Branch** - Push ke branch target (biasanya `stg` untuk staging atau `prod` untuk production)
|
||||
4. **Trigger Publish** - Jalankan `gh workflow run publish.yml --ref <branch> -f stack_env=<env> -f tag=<version>`
|
||||
5. **Trigger Re-Pull** - Jalankan `gh workflow run re-pull.yml -f stack_name=desa-darmasaba -f stack_env=<env>`
|
||||
6. **Verifikasi** - Cek workflow berhasil dan aplikasi berjalan
|
||||
4. **Trigger publish.yml** - Gunakan GitHub API atau CLI dengan: `ref: main`, `stack_env: stg`, `tag: <versi-dari-package.json>`
|
||||
5. **Tunggu publish selesai** - Workflow harus completed baru lanjut ke re-pull
|
||||
6. **Trigger re-pull.yml** - Gunakan GitHub API atau CLI dengan: `ref: main`, `stack_name: desa-darmasaba`, `stack_env: stg`
|
||||
|
||||
Branch deployment: `stg` (staging) atau `prod` (production)
|
||||
Version format di package.json: `"version": "major.minor.patch"`
|
||||
|
||||
- **Deployment Workflow HARUS Sequential (Berurutan)**:
|
||||
|
||||
Saat deploy ke stg atau prod, workflow TIDAK BOLEH dijalankan bersamaan. Harus menunggu yang pertama SELESAI total baru trigger yang kedua.
|
||||
@@ -260,13 +262,7 @@ Saat deploy ke stg atau prod, workflow TIDAK BOLEH dijalankan bersamaan. Harus m
|
||||
|
||||
**JANGAN trigger keduanya bersamaan!** Ini akan menyebabkan race condition karena re-pull akan menarik image yang belum selesai di-build.
|
||||
|
||||
**Cara cek workflow selesai:**
|
||||
```bash
|
||||
gh run view <run_id> --json status --jq '.status'
|
||||
# Harus return "completed" baru lanjut ke re-pull
|
||||
```
|
||||
|
||||
**Atau polling sampai selesai:**
|
||||
**Cara cek workflow selesai via GitHub CLI:**
|
||||
```bash
|
||||
gh run watch <publish_run_id>
|
||||
# Tunggu sampai ada checkmark ✓
|
||||
|
||||
678
STRUKTUR-PROJEK.md
Normal file
678
STRUKTUR-PROJEK.md
Normal file
@@ -0,0 +1,678 @@
|
||||
# Dokumentasi Struktur Proyek - Desa Darmasaba
|
||||
|
||||
## 1. Ringkasan Proyek
|
||||
|
||||
**Desa Darmasaba** adalah aplikasi web komprehensif untuk layanan pemerintahan desa di Desa Darmasaba, Kabupaten Badung, Bali. Aplikasi ini berfungsi sebagai platform digital untuk layanan pemerintah, informasi publik, dan keterlibatan masyarakat.
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Kategori | Teknologi |
|
||||
|----------|-----------|
|
||||
| **Framework Frontend** | Next.js 15 dengan App Router |
|
||||
| **Bahasa** | TypeScript (strict mode) |
|
||||
| **Styling** | Mantine UI v7/v8 + Custom CSS |
|
||||
| **Backend API** | Elysia.js (high-performance TypeScript framework) |
|
||||
| **Database** | PostgreSQL |
|
||||
| **ORM** | Prisma 6.3.1 |
|
||||
| **Runtime** | Bun |
|
||||
| **State Management** | Jotai + Valtio + SWR |
|
||||
| **Autentikasi** | iron-session + JWT |
|
||||
| **File Storage** | Seafile |
|
||||
| **Rich Text Editor** | TipTap |
|
||||
| **Charts** | Recharts + Chart.js |
|
||||
| **Maps** | Leaflet + react-leaflet |
|
||||
| **UI Components** | Mantine, PrimeReact, Framer Motion |
|
||||
| **Validasi** | Zod |
|
||||
| **Testing** | Vitest (unit), Playwright (E2E) |
|
||||
| **Deployment** | Docker + GitHub Actions + Portainer |
|
||||
| **Registry** | GitHub Container Registry (GHCR) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Struktur Direktori
|
||||
|
||||
```
|
||||
desa-darmasaba/
|
||||
├── .github/workflows/ # GitHub Actions CI/CD
|
||||
│ ├── docker-publish.yml # Auto build & push saat tag v*
|
||||
│ ├── publish.yml # Manual build & push ke GHCR
|
||||
│ ├── re-pull.yml # Manual re-pull image di Portainer
|
||||
│ └── script/ # Script deployment
|
||||
│
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # Database schema (2413 baris, 100+ model)
|
||||
│ ├── seed.ts # Database seeder utama
|
||||
│ └── _seeder_list/ # Data seed per modul
|
||||
│ ├── desa/ # Seed berita, gallery, layanan, dll
|
||||
│ ├── ekonomi/ # Seed APBDes, demografi, dll
|
||||
│ ├── inovasi/ # Seed ide inovatif, desa digital
|
||||
│ ├── keamanan/ # Seed keamanan, kontak darurat
|
||||
│ ├── kesehatan/ # Seed fasilitas kesehatan, posyandu
|
||||
│ ├── kependudukan/ # Seed data penduduk
|
||||
│ ├── lingkungan/ # Seed lingkungan desa
|
||||
│ ├── pendidikan/ # Seed sekolah, beasiswa
|
||||
│ ├── ppid/ # Seed PPID
|
||||
│ └── landing-page/ # Seed landing page
|
||||
│
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router
|
||||
│ │ ├── _com/ # Komponen global (SplashScreen, WebVitals)
|
||||
│ │ ├── admin/ # Panel administrasi (protected)
|
||||
│ │ │ ├── _com/ # Komponen admin shared
|
||||
│ │ │ ├── (dashboard)/ # Dashboard admin dengan route groups
|
||||
│ │ │ │ ├── _com/ # Komponen dashboard shared
|
||||
│ │ │ │ ├── _state/ # State khusus dashboard
|
||||
│ │ │ │ ├── _utils/ # Utilitas dashboard
|
||||
│ │ │ │ ├── auth/ # Autentikasi admin
|
||||
│ │ │ │ ├── desa/ # Admin: berita, gallery, profil, layanan
|
||||
│ │ │ │ ├── ekonomi/ # Admin: APBDes, demografi, BUMDes
|
||||
│ │ │ │ ├── inovasi/ # Admin: ide inovatif, desa digital
|
||||
│ │ │ │ ├── keamanan/ # Admin: keamanan, kontak darurat
|
||||
│ │ │ │ ├── kependudukan/# Admin: banjar, agama, umur, migrasi
|
||||
│ │ │ │ ├── kesehatan/ # Admin: puskesmas, posyandu, wabah
|
||||
│ │ │ │ ├── landing-page/# Admin: konten landing page
|
||||
│ │ │ │ ├── lingkungan/ # Admin: konservasi, sampah, penghijauan
|
||||
│ │ │ │ ├── musik/ # Admin: musik desa
|
||||
│ │ │ │ ├── pendidikan/ # Admin: sekolah, beasiswa, perpustakaan
|
||||
│ │ │ │ ├── ppid/ # Admin: PPID, IKM, permohonan
|
||||
│ │ │ │ └── user&role/ # Admin: manajemen user & role
|
||||
│ │ │ ├── auth/ # Halaman login admin
|
||||
│ │ │ ├── csv/ # Upload/demo CSV
|
||||
│ │ │ ├── images/ # Manajemen gambar
|
||||
│ │ │ └── upload-demo/ # Demo upload
|
||||
│ │ │
|
||||
│ │ ├── api/ # API routes (Elysia.js)
|
||||
│ │ │ ├── [[...slugs]]/ # Catch-all route untuk Elysia
|
||||
│ │ │ │ ├── _lib/ # Modul API per domain
|
||||
│ │ │ │ │ ├── auth/ # Autentikasi API
|
||||
│ │ │ │ │ ├── desa/ # API modul desa
|
||||
│ │ │ │ │ ├── ekonomi/ # API modul ekonomi
|
||||
│ │ │ │ │ ├── fileStorage/ # API file storage
|
||||
│ │ │ │ │ ├── inovasi/ # API modul inovasi
|
||||
│ │ │ │ │ ├── keamanan/# API modul keamanan
|
||||
│ │ │ │ │ ├── kependudukan/ # API modul kependudukan
|
||||
│ │ │ │ │ ├── kesehatan/ # API modul kesehatan
|
||||
│ │ │ │ │ ├── landing_page/ # API landing page
|
||||
│ │ │ │ │ ├── lingkungan/ # API modul lingkungan
|
||||
│ │ │ │ │ ├── pendidikan/ # API modul pendidikan
|
||||
│ │ │ │ │ ├── ppid/ # API modul PPID
|
||||
│ │ │ │ │ ├── search/ # API pencarian global
|
||||
│ │ │ │ │ └── user/ # API user management
|
||||
│ │ │ │ └── route.ts # Entry point Elysia server
|
||||
│ │ │ ├── admin/ # API khusus admin
|
||||
│ │ │ ├── auth/ # API autentikasi
|
||||
│ │ │ ├── health/ # Health check endpoint
|
||||
│ │ │ ├── layout/ # API layout
|
||||
│ │ │ ├── news/ # API berita
|
||||
│ │ │ ├── subscribe/ # API subscription (email)
|
||||
│ │ │ └── tts/ # Text-to-Speech (ElevenLabs)
|
||||
│ │ │
|
||||
│ │ ├── context/ # React contexts
|
||||
│ │ │ └── MusicContext.tsx # Context untuk pemutar musik
|
||||
│ │ │
|
||||
│ │ ├── darmasaba/ # Halaman publik (front-facing)
|
||||
│ │ │ ├── _com/ # Komponen shared publik
|
||||
│ │ │ │ ├── main-page/ # Komponen halaman utama
|
||||
│ │ │ │ ├── Navbar.tsx # Navigasi utama
|
||||
│ │ │ │ ├── Footer.tsx # Footer
|
||||
│ │ │ │ ├── FixedPlayerBar.tsx # Music player bar
|
||||
│ │ │ │ ├── LoadDataFirstClient.tsx # Data prefetching
|
||||
│ │ │ │ ├── NewsReader.tsx # Component pembaca berita
|
||||
│ │ │ │ ├── globalSearch.tsx # Pencarian global
|
||||
│ │ │ │ └── scrollToTopButton.tsx
|
||||
│ │ │ ├── (pages)/ # Halaman publik utama
|
||||
│ │ │ │ ├── desa/ # Halaman: profil, berita, gallery, layanan
|
||||
│ │ │ │ ├── ekonomi/ # Halaman: APBDes, BUMDes, demografi
|
||||
│ │ │ │ ├── inovasi/ # Halaman: inovasi desa
|
||||
│ │ │ │ ├── keamanan/ # Halaman: keamanan lingkungan
|
||||
│ │ │ │ ├── kependudukan/# Halaman: data penduduk
|
||||
│ │ │ │ ├── kesehatan/ # Halaman: fasilitas kesehatan
|
||||
│ │ │ │ ├── lingkungan/ # Halaman: lingkungan desa
|
||||
│ │ │ │ ├── module/ # Halaman modul tambahan
|
||||
│ │ │ │ ├── musik/ # Halaman: musik desa
|
||||
│ │ │ │ ├── pendidikan/ # Halaman: pendidikan
|
||||
│ │ │ │ └── ppid/ # Halaman: PPID publik
|
||||
│ │ │ ├── (tambahan)/ # Halaman tambahan
|
||||
│ │ │ ├── layout.tsx # Layout utama publik
|
||||
│ │ │ └── page.tsx # Landing page utama
|
||||
│ │ │
|
||||
│ │ ├── login/ # Halaman login
|
||||
│ │ ├── registrasi/ # Halaman registrasi
|
||||
│ │ ├── waiting-room/ # Halaman waiting room
|
||||
│ │ ├── terms-of-service/ # Halaman syarat layanan
|
||||
│ │ ├── test-upload/ # Halaman tes upload
|
||||
│ │ ├── validasi/ # Halaman validasi
|
||||
│ │ ├── coba/ # Halaman percobaan
|
||||
│ │ ├── percobaan/ # Halaman percobaan lainnya
|
||||
│ │ ├── layout.tsx # Root layout (MantineProvider)
|
||||
│ │ ├── page.tsx # Root page
|
||||
│ │ ├── error.tsx # Error boundary
|
||||
│ │ ├── not-found.tsx # 404 page
|
||||
│ │ ├── globals.css # Global styles
|
||||
│ │ └── favicon.ico
|
||||
│ │
|
||||
│ ├── components/
|
||||
│ │ └── admin/ # Komponen admin reusable
|
||||
│ │ ├── AdminThemeProvider.tsx
|
||||
│ │ ├── DarkModeToggle.tsx
|
||||
│ │ ├── UnifiedSurface.tsx
|
||||
│ │ └── UnifiedTypography.tsx
|
||||
│ │
|
||||
│ ├── con/ # Constants & konfigurasi
|
||||
│ │ └── colors.ts # Palet warna
|
||||
│ │
|
||||
│ ├── lib/ # Utility functions
|
||||
│ │ ├── router/ # Router utilities
|
||||
│ │ ├── api-auth.ts # Autentikasi API
|
||||
│ │ ├── api-fetch.ts # Helper fetch API
|
||||
│ │ ├── EnvStringParse.ts # Parser environment variables
|
||||
│ │ ├── prisma.ts # Prisma client instance
|
||||
│ │ ├── seafile-auth-service.ts # Integrasi Seafile
|
||||
│ │ └── session.ts # iron-session helper
|
||||
│ │
|
||||
│ ├── middlewares/ # Next.js middleware
|
||||
│ ├── state/ # Global state (Jotai/Valtio)
|
||||
│ │ ├── darkModeStore.ts # State dark mode
|
||||
│ │ ├── state-layanan.ts # State layanan
|
||||
│ │ ├── state-list-image.ts # State daftar gambar
|
||||
│ │ └── state-nav.ts # State navigasi
|
||||
│ │
|
||||
│ ├── store/ # State management tambahan
|
||||
│ └── types/ # TypeScript type definitions
|
||||
│
|
||||
├── public/ # Static assets
|
||||
│ └── assets/ # Gambar, icon, dll
|
||||
│
|
||||
├── uploads/ # Directory upload (runtime)
|
||||
│ └── image/ # Upload gambar
|
||||
│
|
||||
├── .env.example # Contoh environment variables
|
||||
├── .gitignore
|
||||
├── AGENTS.md # Panduan untuk AI coding agents
|
||||
├── Dockerfile # Docker image definition
|
||||
├── docker-entrypoint.sh # Entry point container
|
||||
├── next.config.ts # Next.js configuration
|
||||
├── package.json # Dependencies & scripts
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
├── biome.json # Biome linter config
|
||||
├── eslint.config.mjs # ESLint config
|
||||
├── NOTE.md # Catatan deployment
|
||||
└── QWEN.md # Konteks & memori proyek
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Arsitektur Aplikasi
|
||||
|
||||
### 3.1 Arsitektur Keseluruhan
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Client (Browser) │
|
||||
└────────────┬────────────────────────────┬────────────────┘
|
||||
│ │
|
||||
│ Next.js Pages │ API Calls
|
||||
│ (SSR/CSR) │
|
||||
▼ ▼
|
||||
┌────────────────────────┐ ┌────────────────────────────┐
|
||||
│ Next.js 15 App Router│ │ Elysia.js API Server │
|
||||
│ - Pages publik │ │ - RESTful endpoints │
|
||||
│ - Admin dashboard │ │ - File upload │
|
||||
│ - Server components │ │ - Swagger docs (/api/docs│
|
||||
│ - Client components │ │ - Static file serving │
|
||||
└────────────┬───────────┘ └────────────┬───────────────┘
|
||||
│ │
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ PostgreSQL Database │
|
||||
│ (via Prisma ORM) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Seafile File Storage │
|
||||
│ (Images & Documents) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 Next.js App Router
|
||||
|
||||
- Menggunakan **App Router** (bukan Pages Router)
|
||||
- Route groups `(dashboard)`, `(pages)`, `(tambahan)` untuk organisasi tanpa mempengaruhi URL
|
||||
- Layout bersarang: root layout -> admin/darmasaba layout -> page layouts
|
||||
- `force-dynamic` digunakan untuk menghindari error prerendering
|
||||
- View Transitions API diaktifkan via `next-view-transitions`
|
||||
|
||||
### 3.3 Elysia.js API Server
|
||||
|
||||
- Terintegrasi sebagai **catch-all route** di `/api/[[...slugs]]/route.ts`
|
||||
- Semua HTTP methods (GET, POST, PATCH, DELETE, PUT) di-handle oleh Elysia
|
||||
- Plugin yang digunakan:
|
||||
- `@elysiajs/cors` - CORS configuration
|
||||
- `@elysiajs/static` - Static file serving dari `/uploads`
|
||||
- `@elysiajs/swagger` - API documentation di `/api/docs`
|
||||
- `@elysiajs/jwt` - JWT authentication
|
||||
- `@elysiajs/cookie` - Cookie handling
|
||||
- Endpoint file upload: `/api/upl-img`, `/api/upl-img-single`, `/api/upl-csv`
|
||||
- Image serving: `/api/img/:name` dengan resize support
|
||||
|
||||
### 3.4 Rendering Strategy
|
||||
|
||||
- **Server Components**: Halaman publik untuk SEO optimal
|
||||
- **Client Components**: Komponen interaktif (form, state, animasi)
|
||||
- **Force Dynamic**: Beberapa halaman menggunakan `force-dynamic`
|
||||
- **ISR**: Caching header untuk assets (1 jam cache)
|
||||
|
||||
---
|
||||
|
||||
## 4. Modul Domain
|
||||
|
||||
### 4.1 Profil Desa (Desa)
|
||||
**Admin**: `/admin/desa/*` | **Publik**: `/darmasaba/desa/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `berita` | CRUD berita/pengumuman desa |
|
||||
| `gallery` | Galeri foto dan video |
|
||||
| `layanan` | Manajemen layanan desa |
|
||||
| `penghargaan` | Penghargaan yang diraih |
|
||||
| `pengumuman` | Pengumuman publik |
|
||||
| `potensi` | Potensi desa (pertanian, pariwisata, dll) |
|
||||
| `profil` | Profil desa (sejarah, visi misi, lambang, maskot, perangkat) |
|
||||
|
||||
### 4.2 PPID (Pejabat Pengelola Informasi dan Dokumentasi)
|
||||
**Admin**: `/admin/ppid/*` | **Publik**: `/darmasaba/ppid/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `profil-ppid` | Profil pejabat PPID |
|
||||
| `struktur-ppid` | Struktur organisasi PPID |
|
||||
| `visi-misi-ppid` | Visi dan misi PPID |
|
||||
| `daftar-informasi-publik` | Daftar informasi yang tersedia |
|
||||
| `dasar-hukum` | Dasar hukum PPID |
|
||||
| `permohonan-informasi-publik` | Form permohonan informasi |
|
||||
| `permohonan-keberatan-informasi-publik` | Form keberatan |
|
||||
| `indeks-kepuasan-masyarakat` | Survei kepuasan masyarakat (IKM) |
|
||||
|
||||
### 4.3 Kesehatan
|
||||
**Admin**: `/admin/kesehatan/*` | **Publik**: `/darmasaba/kesehatan/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `fasilitas-kesehatan` | Data puskesmas, klinik, dokter |
|
||||
| `posyandu` | Manajemen posyandu |
|
||||
| `program-kesehatan` | Program kesehatan desa |
|
||||
| `info-wabah-penyakit` | Informasi wabah |
|
||||
| `penanganan-darurat` | Prosedur penanganan darurat |
|
||||
| `kontak-darurat` | Kontak darurat kesehatan |
|
||||
| `data-kesehatan-warga` | Statistik kesehatan warga |
|
||||
| `artikel-kesehatan` | Artikel kesehatan |
|
||||
|
||||
### 4.4 Ekonomi
|
||||
**Admin**: `/admin/ekonomi/*` | **Publik**: `/darmasaba/ekonomi/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `APBDes` | Anggaran Pendapatan dan Belanja Desa (hierarki items + realisasi) |
|
||||
| `PADesa-pendapatan-asli-desa` | Pendapatan asli desa |
|
||||
| `demografi-pekerjaan` | Demografi pekerjaan penduduk |
|
||||
| `jumlah-penduduk-miskin` | Data penduduk miskin |
|
||||
| `jumlah-pengangguran` | Data pengangguran |
|
||||
| `lowongan-kerja-lokal` | Lowongan kerja lokal |
|
||||
| `pasar-desa` | Data pasar desa |
|
||||
| `program-kemiskinan` | Program penanganan kemiskinan |
|
||||
| `sektor-unggulan-desa` | Sektor unggulan ekonomi |
|
||||
| `Struktur-Organisasi-Dan-Sk-Pengurus-BumDes` | Struktur BUMDes |
|
||||
|
||||
### 4.5 Kependudukan
|
||||
**Admin**: `/admin/kependudukan/*` | **Publik**: `/darmasaba/kependudukan/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `data-banjar` | Data banjar (unit wilayah tradisional Bali) |
|
||||
| `distribusi-agama` | Distribusi agama penduduk |
|
||||
| `distribusi-umur` | Distribusi umur penduduk |
|
||||
| `migrasi-penduduk` | Data migrasi (masuk/keluar) |
|
||||
|
||||
### 4.6 Pendidikan
|
||||
**Admin**: `/admin/pendidikan/*` | **Publik**: `/darmasaba/pendidikan/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `beasiswa-desa` | Program beasiswa |
|
||||
| `bimbingan-belajar-desa` | Bimbingan belajar |
|
||||
| `data-pendidikan` | Data statistik pendidikan |
|
||||
| `info-sekolah` | Informasi sekolah |
|
||||
| `pendidikan-non-formal` | Pendidikan non-formal |
|
||||
| `perpustakaan-digital` | Perpustakaan digital |
|
||||
| `program-pendidikan-anak` | Program pendidikan anak |
|
||||
|
||||
### 4.7 Keamanan
|
||||
**Admin**: `/admin/keamanan/*` | **Publik**: `/darmasaba/keamanan/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `keamanan-lingkungan-pecalang-patwal` | Keamanan lingkungan (pecalang Bali) |
|
||||
| `kontak-darurat` | Kontak darurat keamanan |
|
||||
| `laporan-publik` | Laporan publik |
|
||||
| `pencegahan-kriminalitas` | Pencegahan kriminalitas |
|
||||
| `polsek-terdekat` | Data polsek terdekat |
|
||||
| `tips-keamanan` | Tips keamanan |
|
||||
|
||||
### 4.8 Lingkungan
|
||||
**Admin**: `/admin/lingkungan/*` | **Publik**: `/darmasaba/lingkungan/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `data-lingkungan-desa` | Data lingkungan desa |
|
||||
| `edukasi-lingkungan` | Edukasi lingkungan |
|
||||
| `gotong-royong` | Kegiatan gotong royong |
|
||||
| `konservasi-adat-bali` | Konservasi adat Bali |
|
||||
| `pengelolaan-sampah-bank-sampah` | Bank sampah |
|
||||
| `program-penghijauan` | Program penghijauan |
|
||||
|
||||
### 4.9 Inovasi
|
||||
**Admin**: `/admin/inovasi/*` | **Publik**: `/darmasaba/inovasi/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `ajukan-ide-inovatif` | Form pengajuan ide inovatif |
|
||||
| `desa-digital-smart-village` | Program desa digital |
|
||||
| `info-teknologi-tepat-guna` | Info teknologi tepat guna |
|
||||
| `kolaborasi-inovasi` | Kolaborasi inovasi |
|
||||
| `layanan-online-desa` | Layanan online desa |
|
||||
| `program-kreatif-desa` | Program kreatif desa |
|
||||
|
||||
### 4.10 Musik Desa
|
||||
**Admin**: `/admin/musik/*` | **Publik**: `/darmasaba/musik/*`
|
||||
|
||||
- Manajemen audio dan cover musik desa
|
||||
- Fixed player bar di halaman publik
|
||||
- Context provider untuk state pemutar musik
|
||||
|
||||
### 4.11 Landing Page
|
||||
**Admin**: `/admin/landing-page/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `desa-anti-korupsi` | Konten anti-korupsi |
|
||||
| `prestasi-desa` | Prestasi yang diraih |
|
||||
| `sdgs-desa` | SDGs (Sustainable Development Goals) |
|
||||
| `profil-landing-page` | Profil dan media sosial |
|
||||
|
||||
### 4.12 User & Role
|
||||
**Admin**: `/admin/user&role/*`
|
||||
|
||||
- Manajemen pengguna admin
|
||||
- Manajemen role dan permission
|
||||
- Manajemen menu akses
|
||||
|
||||
---
|
||||
|
||||
## 5. Database Schema
|
||||
|
||||
### 5.1 Overview
|
||||
|
||||
Database menggunakan **PostgreSQL** dengan **Prisma ORM** (versi 6.3.1).
|
||||
Schema terdiri dari **2413 baris** dengan **100+ model**.
|
||||
|
||||
### 5.2 Model Utama
|
||||
|
||||
#### FileStorage
|
||||
Model sentral untuk semua file (gambar, dokumen, audio):
|
||||
```prisma
|
||||
model FileStorage {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
realName String
|
||||
path String
|
||||
mimeType String
|
||||
category String // "image" / "document" / "audio" / "other"
|
||||
link String
|
||||
isActive Boolean @default(true)
|
||||
// Relasi ke 50+ model lain (Berita, PotensiDesa, GalleryFoto, dll)
|
||||
}
|
||||
```
|
||||
|
||||
#### AppMenu & AppMenuChild
|
||||
Menu navigasi aplikasi:
|
||||
```prisma
|
||||
model AppMenu {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
link String
|
||||
isActive Boolean @default(true)
|
||||
AppMenuChild AppMenuChild[]
|
||||
}
|
||||
```
|
||||
|
||||
#### User & Role (Autentikasi Admin)
|
||||
- `User` - Data pengguna admin
|
||||
- `Role` - Role/peran pengguna
|
||||
- `Menu` - Menu akses per role
|
||||
|
||||
#### Modul Desa
|
||||
- `Berita` - Berita desa (dengan featured image & gallery)
|
||||
- `GalleryFoto` / `GalleryVideo` - Galeri media
|
||||
- `Layanan` - Layanan desa
|
||||
- `Pengumuman` - Pengumuman
|
||||
- `PotensiDesa` - Potensi desa
|
||||
- `ProfileDesaImage` - Gambar profil desa
|
||||
- `ProfilPerbekel` - Profil perbekel (kepala desa)
|
||||
- `PejabatDesa` - Pejabat desa
|
||||
- `Penghargaan` - Penghargaan
|
||||
- `PrestasiDesa` - Prestasi
|
||||
- `MediaSosial` - Media sosial desa
|
||||
|
||||
#### Modul PPID
|
||||
- `StrukturPPID` - Struktur organisasi
|
||||
- `PosisiOrganisasiPPID` - Posisi dengan hierarki
|
||||
- `PegawaiPPID` - Data pegawai
|
||||
- `ProfilePPID` - Profil PPID
|
||||
- `VisiMisiPPID` - Visi misi
|
||||
- `DasarHukumPPID` - Dasar hukum
|
||||
- `DaftarInformasiPublik` - Daftar informasi
|
||||
- `PermohonanInformasiPublik` - Permohonan informasi
|
||||
- `FormulirPermohonanKeberatan` - Formulir keberatan
|
||||
- `IndeksKepuasanMasyarakat` - IKM
|
||||
- `Responden` + lookup tables - Data responden IKM
|
||||
|
||||
#### Modul Kesehatan
|
||||
- `Puskesmas` - Data puskesmas
|
||||
- `Posyandu` - Data posyandu
|
||||
- `ProgramKesehatan` - Program kesehatan
|
||||
- `FasilitasKesehatan` - Fasilitas
|
||||
- `InfoWabahPenyakit` - Info wabah
|
||||
- `PenangananDarurat` - Penanganan darurat
|
||||
- `KontakDarurat` - Kontak darurat
|
||||
- `ArtikelKesehatan` - Artikel
|
||||
|
||||
#### Modul Ekonomi
|
||||
- `APBDes` & `APBDesItem` - Anggaran desa (hierarki tree structure)
|
||||
- `RealisasiItem` - Realisasi anggaran (multiple per item)
|
||||
- `PasarDesa` - Pasar desa
|
||||
- `PegawaiBumDes` - Pegawai BUMDes
|
||||
- `StrukturBumDes` - Struktur BUMDes
|
||||
- `DemografiPekerjaan` - Demografi pekerjaan
|
||||
- `JumlahPendudukMiskin` - Data kemiskinan
|
||||
- `JumlahPengangguran` - Data pengangguran
|
||||
- `LowonganKerjaLokal` - Lowongan kerja
|
||||
- `ProgramKemiskinan` - Program kemiskinan
|
||||
- `SektorUnggulanDesa` - Sektor unggulan
|
||||
- `PendapatanAsli` - Pendapatan asli desa
|
||||
|
||||
#### Modul Kependudukan
|
||||
- `DataBanjar` - Data banjar
|
||||
- `DistribusiAgama` - Distribusi agama
|
||||
- `DistribusiUmur` - Distribusi umur
|
||||
- `MigrasiPenduduk` - Migrasi
|
||||
|
||||
#### Modul Pendidikan
|
||||
- `InfoSekolah` - Data sekolah
|
||||
- `BeasiswaDesa` - Beasiswa
|
||||
- `BimbinganBelajar` - Bimbingan belajar
|
||||
- `PendidikanNonFormal` - Pendidikan non-formal
|
||||
- `DataPerpustakaan` - Perpustakaan
|
||||
|
||||
#### Modul Keamanan
|
||||
- `KeamananLingkungan` - Keamanan lingkungan
|
||||
- `MenuTipsKeamanan` - Tips keamanan
|
||||
- `PencegahanKriminalitas` - Pencegahan kriminalitas
|
||||
- `PolsekTerdekat` - Polsek terdekat
|
||||
- `LaporanPublik` - Laporan publik
|
||||
|
||||
#### Modul Lingkungan
|
||||
- `DataLingkunganDesa` - Data lingkungan
|
||||
- `KonservasiAdatBali` - Konservasi adat
|
||||
- `BankSampah` - Bank sampah
|
||||
- `ProgramPenghijauan` - Penghijauan
|
||||
- `GotongRoyong` - Gotong royong
|
||||
- `EdukasiLingkungan` - Edukasi
|
||||
|
||||
#### Modul Inovasi
|
||||
- `ProgramInovasi` - Program inovasi
|
||||
- `DesaDigital` - Desa digital
|
||||
- `InfoTekno` - Info teknologi
|
||||
- `KolaborasiInovasi` + `MitraKolaborasi` - Kolaborasi
|
||||
- `LayananOnlineDesa` - Layanan online
|
||||
- `ProgramKreatifDesa` - Program kreatif
|
||||
- `Ajukan` - Pengajuan ide
|
||||
|
||||
#### Modul Musik
|
||||
- `MusikDesa` - Musik desa
|
||||
- `audioFile` -> FileStorage
|
||||
- `coverImage` -> FileStorage
|
||||
|
||||
#### Landing Page
|
||||
- `DesaAntiKorupsi` + `KategoriDesaAntiKorupsi`
|
||||
- `SdgsDesa` - SDGs
|
||||
- `PrestasiDesa` + `KategoriPrestasiDesa`
|
||||
- `MediaSosial`
|
||||
- `LandingPage_Layanan`
|
||||
|
||||
#### APBDes (Struktur Hierarki)
|
||||
```prisma
|
||||
model APBDesItem {
|
||||
kode String // "4", "4.1", "4.1.2"
|
||||
uraian String // Nama item
|
||||
anggaran Float // Anggaran dalam Rupiah
|
||||
tipe String? // "pendapatan" | "belanja" | "pembiayaan"
|
||||
level Int // 1, 2, 3
|
||||
parentId String? // Self-referencing untuk tree
|
||||
children APBDesItem[]
|
||||
totalRealisasi Float @default(0) // Auto-calculated
|
||||
selisih Float @default(0) // totalRealisasi - anggaran
|
||||
persentase Float @default(0) // (totalRealisasi / anggaran) * 100
|
||||
realisasiItems RealisasiItem[]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Pola Umum Model
|
||||
|
||||
Hampir semua model mengikuti pola:
|
||||
```prisma
|
||||
model Contoh {
|
||||
id String @id @default(cuid())
|
||||
// ... fields
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime? @default(now()) // Soft delete
|
||||
isActive Boolean @default(true) // Soft delete flag
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. API Routes
|
||||
|
||||
### 6.1 Struktur API
|
||||
|
||||
Semua API routes ditangani oleh **Elysia.js** di `/src/app/api/[[...slugs]]/route.ts`
|
||||
|
||||
### 6.2 API Groups
|
||||
|
||||
| Prefix | Modul | Contoh Endpoints |
|
||||
|--------|-------|------------------|
|
||||
| `/api/layanan` | Layanan | `GET /api/layanan` |
|
||||
| `/api/potensi` | Potensi | `GET /api/potensi` |
|
||||
| `/api/desa/*` | Desa | CRUD berita, gallery, profil, dll |
|
||||
| `/api/ppid/*` | PPID | CRUD struktur, profil, permohonan |
|
||||
| `/api/kesehatan/*` | Kesehatan | CRUD puskesmas, posyandu, dll |
|
||||
| `/api/ekonomi/*` | Ekonomi | CRUD APBDes, BUMDes, demografi |
|
||||
| `/api/kependudukan/*` | Kependudukan | CRUD banjar, demografi |
|
||||
| `/api/pendidikan/*` | Pendidikan | CRUD sekolah, beasiswa |
|
||||
| `/api/keamanan/*` | Keamanan | CRUD keamanan, kontak darurat |
|
||||
| `/api/lingkungan/*` | Lingkungan | CRUD data lingkungan |
|
||||
| `/api/inovasi/*` | Inovasi | CRUD program inovasi |
|
||||
| `/api/landing-page/*` | Landing Page | CRUD konten landing page |
|
||||
| `/api/user/*` | User | CRUD user admin |
|
||||
| `/api/user/role/*` | Role | CRUD role & permission |
|
||||
| `/api/search` | Search | Pencarian global |
|
||||
| `/api/file-storage/*` | File Storage | CRUD file storage |
|
||||
| `/api/img/:name` | Image | GET gambar dengan resize |
|
||||
| `/api/upl-img` | Upload | Upload multiple images |
|
||||
| `/api/upl-img-single` | Upload | Upload single image |
|
||||
| `/api/upl-csv` | Upload | Upload CSV files |
|
||||
| `/api/utils/version` | Utils | GET versi aplikasi |
|
||||
|
||||
### 6.3 API Documentation
|
||||
|
||||
Swagger UI tersedia di: **`/api/docs`**
|
||||
|
||||
### 6.4 API Route Lainnya
|
||||
|
||||
| Route | Fungsi |
|
||||
|-------|--------|
|
||||
| `/api/health` | Health check endpoint |
|
||||
| `/api/news` | API berita (standalone) |
|
||||
| `/api/subscribe` | Subscription email |
|
||||
| `/api/tts` | Text-to-Speech (ElevenLabs) |
|
||||
| `/api/admin/*` | API khusus admin |
|
||||
| `/api/auth/*` | API autentikasi |
|
||||
|
||||
---
|
||||
|
||||
## 7. Halaman Admin
|
||||
|
||||
### 7.1 Struktur
|
||||
|
||||
Admin dashboard berada di `/admin` dengan route group `(dashboard)`.
|
||||
|
||||
| Section | Path | Fungsi |
|
||||
|---------|------|--------|
|
||||
| **Dashboard** | `/admin` | Dashboard utama |
|
||||
| **Autentikasi** | `/admin/auth` | Login admin |
|
||||
| **Desa** | `/admin/desa/*` | Berita, gallery, profil, layanan, penghargaan, pengumuman, potensi |
|
||||
| **PPID** | `/admin/ppid/*` | Profil, struktur, visi-misi, daftar informasi, dasar hukum, permohonan, IKM |
|
||||
| **Kesehatan** | `/admin/kesehatan/*` | Puskesmas, posyandu, program kesehatan, wabah, kontak darurat |
|
||||
| **Ekonomi** | `/admin/ekonomi/*` | APBDes, PAD, demografi, pengangguran, kemiskinan, BUMDes, pasar desa |
|
||||
| **Kependudukan** | `/admin/kependudukan/*` | Banjar, distribusi agama, distribusi umur, migrasi |
|
||||
| **Pendidikan** | `/admin/pendidikan/*` | Sekolah, beasiswa, bimbingan belajar, perpustakaan digital |
|
||||
| **Keamanan** | `/admin/keamanan/*` | Keamanan lingkungan, kontak darurat, pencegahan kriminalitas, polsek |
|
||||
| **Lingkungan** | `/admin/lingkungan/*` | Data lingkungan, konservasi, bank sampah, penghijauan, gotong royong |
|
||||
| **Inovasi** | `/admin/inovasi/*` | Ide inovatif, desa digital, teknologi tepat guna, kolaborasi |
|
||||
| **Musik** | `/admin/musik/*` | Manajemen musik desa |
|
||||
| **Landing Page** | `/admin/landing-page/*` | Anti-korupsi, prestasi, SDGs, media sosial |
|
||||
| **User & Role** | `/admin/user&role/*` | Manajemen user dan role |
|
||||
| **Images** | `/admin/images/*` | Manajemen gambar |
|
||||
| **CSV** | `/admin/csv/*` | Upload/import CSV |
|
||||
|
||||
### 7.2 Komponen Admin Shared
|
||||
|
||||
- `AdminThemeProvider.tsx` - Theme provider untuk dark/light mode
|
||||
- `DarkModeToggle.tsx` - Toggle dark mode
|
||||
- `UnifiedSurface.tsx` - Komponen surface/card unified
|
||||
- `UnifiedTypography.tsx` - Tipografi unified
|
||||
|
||||
---
|
||||
|
||||
## 8. Halaman Publik
|
||||
|
||||
### 8.1 Struktur
|
||||
|
||||
Halaman publik berada di `/darmasaba` dengan layout yang mencakup Navbar, Footer, dan Fixed Music Player.
|
||||
|
||||
| Halaman | Path | Konten |
|
||||
|---------|------|--------|
|
||||
| **Landing Page
|
||||
842
STRUKTUR.md
Normal file
842
STRUKTUR.md
Normal file
@@ -0,0 +1,842 @@
|
||||
# Dokumentasi Struktur Proyek Desa Darmasaba
|
||||
|
||||
## 1. Ringkasan Proyek
|
||||
|
||||
**Desa Darmasaba** adalah aplikasi web manajemen desa digital untuk Desa Darmasaba, Kabupaten Badung, Bali. Aplikasi ini berfungsi sebagai platform layanan publik digital yang mencakup informasi pemerintahan, layanan kesehatan, keamanan, pendidikan, ekonomi, lingkungan, dan inovasi desa.
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Kategori | Teknologi |
|
||||
|----------|-----------|
|
||||
| **Framework** | Next.js 15 (App Router) |
|
||||
| **Language** | TypeScript (strict mode) |
|
||||
| **Runtime** | Bun |
|
||||
| **Backend API** | Elysia.js (high-performance HTTP server) |
|
||||
| **Database** | PostgreSQL |
|
||||
| **ORM** | Prisma 6.3.1 |
|
||||
| **UI Framework** | Mantine UI v7-v8 |
|
||||
| **State Management** | Jotai + Valtio + SWR |
|
||||
| **Authentication** | iron-session + JWT (@elysiajs/jwt) |
|
||||
| **File Storage** | Seafile (self-hosted) |
|
||||
| **Text Editor** | Tiptap (Rich text editor) |
|
||||
| **Charts** | Recharts + Chart.js |
|
||||
| **Maps** | Leaflet + react-leaflet |
|
||||
| **Testing** | Vitest (unit) + Playwright (E2E) |
|
||||
| **Styling** | Mantine + PostCSS + Framer Motion |
|
||||
| **Deployment** | Docker + GHCR + Portainer + GitHub Actions |
|
||||
| **Version** | 0.1.11 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Struktur Direktori
|
||||
|
||||
```
|
||||
desa-darmasaba/
|
||||
├── .github/workflows/ # GitHub Actions CI/CD
|
||||
│ ├── docker-publish.yml # Auto build & push saat tag v*
|
||||
│ ├── publish.yml # Manual build & push ke GHCR
|
||||
│ ├── re-pull.yml # Manual re-pull di Portainer
|
||||
│ └── script/ # Shell scripts untuk deploy
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # Database schema (2413 baris, 100+ model)
|
||||
│ └── seed.ts # Database seeder (400+ baris)
|
||||
│ └── _seeder_list/ # Seed data per modul
|
||||
├── public/ # Static assets
|
||||
│ └── assets/
|
||||
│ └── images/
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router
|
||||
│ │ ├── _com/ # Global components (SplashScreen, WebVitals)
|
||||
│ │ ├── admin/ # ADMIN DASHBOARD
|
||||
│ │ │ ├── (dashboard)/ # Route group dashboard
|
||||
│ │ │ │ ├── desa/ # - Berita, Gallery, Layanan, dll
|
||||
│ │ │ │ ├── ppid/ # - Informasi publik, struktur, dasar hukum
|
||||
│ │ │ │ ├── kesehatan/ # - Fasilitas, posyandu, puskesmas, wabah
|
||||
│ │ │ │ ├── ekonomi/ # - APBDes, pasar desa, BUMDes, dll
|
||||
│ │ │ │ ├── kependudukan/ # - Banjar, agama, umur, migrasi
|
||||
│ │ │ │ ├── pendidikan/ # - Sekolah, beasiswa, perpustakaan
|
||||
│ │ │ │ ├── keamanan/ # - Keamanan lingkungan, polsek, dll
|
||||
│ │ │ │ ├── lingkungan/ # - Sampah, penghijauan, gotong royong
|
||||
│ │ │ │ ├── inovasi/ # - Desa digital, kolaborasi, dll
|
||||
│ │ │ │ ├── landing-page/ # - Profil, prestasi, anti-korupsi
|
||||
│ │ │ │ ├── musik/ # - Musik desa
|
||||
│ │ │ │ ├── user&role/ # - Manajemen user & role
|
||||
│ │ │ │ └── _com/ # - Shared admin components
|
||||
│ │ │ ├── auth/ # Login OTP untuk admin
|
||||
│ │ │ ├── csv/ # Demo CSV upload
|
||||
│ │ │ └── layout.tsx # Admin shell (AppShell Mantine)
|
||||
│ │ ├── api/ # ELYSIA.JS API SERVER
|
||||
│ │ │ ├── [[...slugs]]/ # Catch-all route -> Elysia handler
|
||||
│ │ │ │ ├── route.ts # - Main Elysia server export
|
||||
│ │ │ │ └── _lib/ # - Domain route modules
|
||||
│ │ │ │ ├── desa.ts
|
||||
│ │ │ │ ├── ppid.ts
|
||||
│ │ │ │ ├── kesehatan.ts
|
||||
│ │ │ │ ├── ekonomi.ts
|
||||
│ │ │ │ ├── keamanan.ts
|
||||
│ │ │ │ ├── inovasi.ts
|
||||
│ │ │ │ ├── lingkungan.ts
|
||||
│ │ │ │ ├── pendidikan.ts
|
||||
│ │ │ │ ├── kependudukan.ts
|
||||
│ │ │ │ ├── landing_page.ts
|
||||
│ │ │ │ ├── user/ # - User & Role management
|
||||
│ │ │ │ ├── fileStorage/
|
||||
│ │ │ │ ├── search/
|
||||
│ │ │ │ ├── auth/
|
||||
│ │ │ │ ├── upl-img.ts, upl-img-single.ts
|
||||
│ │ │ │ ├── upl-csv.ts, upl-csv-single.ts
|
||||
│ │ │ │ └── img.ts, img-del.ts, imgs.ts
|
||||
│ │ │ ├── auth/ # Auth endpoints (login, logout, me)
|
||||
│ │ │ └── ... # Other API routes
|
||||
│ │ ├── darmasaba/ # PUBLIC-FACING WEBSITE
|
||||
│ │ │ ├── _com/ # Shared components (Navbar, Footer, etc)
|
||||
│ │ │ ├── (pages)/ # Public pages route group
|
||||
│ │ │ │ ├── desa/ # - Profil, berita, gallery, layanan
|
||||
│ │ │ │ ├── ppid/ # - PPID public pages
|
||||
│ │ │ │ ├── kesehatan/ # - Health info pages
|
||||
│ │ │ │ ├── ekonomi/ # - Economy pages
|
||||
│ │ │ │ ├── kependudukan/
|
||||
│ │ │ │ ├── pendidikan/
|
||||
│ │ │ │ ├── keamanan/
|
||||
│ │ │ │ ├── lingkungan/
|
||||
│ │ │ │ ├── inovasi/
|
||||
│ │ │ │ ├── musik/
|
||||
│ │ │ │ └── module/ # - External module links
|
||||
│ │ │ └── (tambahan)/ # Additional pages
|
||||
│ │ ├── login/ # Login page
|
||||
│ │ ├── registrasi/ # Registration page
|
||||
│ │ ├── waiting-room/ # Waiting room (inactive users)
|
||||
│ │ ├── terms-of-service/
|
||||
│ │ ├── layout.tsx # Root layout (MantineProvider, ViewTransitions)
|
||||
│ │ └── page.tsx # Homepage redirect
|
||||
│ ├── components/
|
||||
│ │ └── admin/ # Admin shared components
|
||||
│ │ ├── AdminThemeProvider.tsx
|
||||
│ │ ├── DarkModeToggle.tsx
|
||||
│ │ ├── UnifiedSurface.tsx
|
||||
│ │ └── UnifiedTypography.tsx
|
||||
│ ├── con/ # Constants & configuration
|
||||
│ │ ├── colors.ts # Color palette definitions
|
||||
│ │ ├── images.ts
|
||||
│ │ ├── navbar-list-menu.ts
|
||||
│ │ ├── router.ts # Route mapping
|
||||
│ │ └── sosmed.ts
|
||||
│ ├── context/ # React contexts
|
||||
│ │ └── MusicContext.tsx # Music player context
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ ├── lib/ # Utility libraries
|
||||
│ │ ├── router/
|
||||
│ │ ├── api-auth.ts # API authentication helpers
|
||||
│ │ ├── api-fetch.ts # API fetch wrapper
|
||||
│ │ ├── EnvStringParse.ts
|
||||
│ │ ├── prisma.ts # Prisma client singleton
|
||||
│ │ ├── seafile-auth-service.ts
|
||||
│ │ └── session.ts # iron-session helper
|
||||
│ ├── state/ # Global state (Jotai/Valtio)
|
||||
│ │ ├── darkModeStore.ts
|
||||
│ │ ├── state-layanan.ts
|
||||
│ │ ├── state-list-image.ts
|
||||
│ │ └── state-nav.ts
|
||||
│ ├── store/ # Additional stores
|
||||
│ │ └── authStore.ts # Auth state (Jotai)
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ └── utils/ # Utility functions
|
||||
│ └── themeTokens.ts # Dark/light theme tokens
|
||||
├── uploads/ # Local upload directory (images/files)
|
||||
├── Dockerfile # Multi-stage Docker build (Bun)
|
||||
├── docker-entrypoint.sh # Entry script (migrate + start)
|
||||
├── next.config.ts # Next.js configuration
|
||||
├── package.json # Dependencies & scripts
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
├── biome.json # Biome linter config
|
||||
├── eslint.config.mjs # ESLint config
|
||||
├── NOTE.md # Deployment notes
|
||||
├── QWEN.md # Project memory & workflow
|
||||
└── AGENTS.md # Agent coding guidelines
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Arsitektur
|
||||
|
||||
### Pola Arsitektur: Full-Stack Monolith dengan App Router
|
||||
|
||||
```
|
||||
Browser
|
||||
|
|
||||
+-- Next.js 15 (App Router) -- Server Components + Client Components
|
||||
|
|
||||
+-- /darmasaba/* -> Public pages (SSR/CSR)
|
||||
+-- /admin/* -> Admin dashboard (protected)
|
||||
+-- /api/* -> Elysia.js API server
|
||||
|
|
||||
+-- Elysia Server (src/app/api/[[...slugs]]/route.ts)
|
||||
|
|
||||
+-- CORS enabled
|
||||
+-- Swagger docs di /api/docs
|
||||
+-- Static file serving (/api/uploads)
|
||||
+-- Domain modules: Desa, PPID, Kesehatan, Ekonomi, dll
|
||||
+-- Image upload handlers
|
||||
|
|
||||
+-- Prisma ORM --> PostgreSQL
|
||||
+-- Seafile API --> File Storage
|
||||
```
|
||||
|
||||
### Key Architectural Decisions:
|
||||
|
||||
1. **Next.js 15 App Router**: Menggunakan React Server Components sebagai default, dengan `"use client"` untuk interaktivitas
|
||||
2. **Elysia.js di dalam API Routes**: Catch-all route `[[...slugs]]` meneruskan semua request ke Elysia handler
|
||||
3. **Route Groups**: `(dashboard)` dan `(pages)` untuk organisasi tanpa mempengaruhi URL path
|
||||
4. **Multi-tenant Ready**: Role-based access control dengan dynamic navbar berdasarkan roleId
|
||||
5. **File Uploads**: Local uploads + Seafile integration untuk distributed storage
|
||||
|
||||
---
|
||||
|
||||
## 4. Modul Domain
|
||||
|
||||
### A. PPID (Pejabat Pengelola Informasi dan Dokumentasi)
|
||||
**Lokasi**: `src/app/admin/(dashboard)/ppid/` dan `src/app/darmasaba/(pages)/ppid/`
|
||||
|
||||
| Sub-modul | Deskripsi |
|
||||
|-----------|-----------|
|
||||
| Profil PPID | Profil pejabat pengelola informasi |
|
||||
| Struktur PPID | Struktur organisasi PPID dengan hierarki |
|
||||
| Visi & Misi PPID | Visi dan misi PPID desa |
|
||||
| Daftar Informasi Publik | Katalog informasi publik yang tersedia |
|
||||
| Dasar Hukum | Regulasi dan dasar hukum PPID |
|
||||
| Permohonan Informasi Publik | Form permohonan informasi (NIK, kontak, jenis) |
|
||||
| Permohonan Keberatan | Formulir keberatan informasi |
|
||||
| Indeks Kepuasan Masyarakat | Survey kepuasan dengan grafik demografis |
|
||||
|
||||
### B. Desa (Landing Page & Umum)
|
||||
**Lokasi**: `src/app/admin/(dashboard)/desa/` dan `src/app/darmasaba/(pages)/desa/`
|
||||
|
||||
| Sub-modul | Deskripsi |
|
||||
|-----------|-----------|
|
||||
| Profil Desa | Sejarah, visi-misi, lambang, maskot |
|
||||
| Profil Perbekel | Biodata, pengalaman, program unggulan perbekel |
|
||||
| Perbekel dari Masa ke Masa | Historis perbekel per periode |
|
||||
| Berita | Artikel berita dengan kategori & multi-image |
|
||||
| Gallery | Foto dan video galeri |
|
||||
| Pengumuman | Pengumuman desa dengan kategori |
|
||||
| Potensi Desa | Potensi desa dengan kategori |
|
||||
| Layanan Desa | Surat keterangan, ajukan permohonan |
|
||||
| Penghargaan | Prestasi dan penghargaan desa |
|
||||
| Desa Anti Korupsi | Transparansi anti-korupsi |
|
||||
| SDGs Desa | Sustainable Development Goals desa |
|
||||
| APBDes | Anggaran desa dengan hierarki item & realisasi |
|
||||
| Prestasi Desa | Katalog prestasi |
|
||||
|
||||
### C. Kesehatan
|
||||
**Lokasi**: `src/app/admin/(dashboard)/kesehatan/` dan `src/app/darmasaba/(pages)/kesehatan/`
|
||||
|
||||
| Sub-modul | Deskripsi |
|
||||
|-----------|-----------|
|
||||
| Fasilitas Kesehatan | Info rumah sakit/klinik (jam, dokter, tarif) |
|
||||
| Puskesmas | Data puskesmas dengan jam operasional & kontak |
|
||||
| Posyandu | Jadwal dan informasi posyandu |
|
||||
| Program Kesehatan | Program-program kesehatan desa |
|
||||
| Penanganan Darurat | Prosedur penanganan darurat |
|
||||
| Kontak Darurat | Kontak emergency dengan WhatsApp |
|
||||
| Info Wabah Penyakit | Informasi wabah penyakit |
|
||||
| Artikel Kesehatan | Artikel kesehatan lengkap |
|
||||
| Data Kesehatan Warga | Statistik kesehatan warga |
|
||||
| Kelahiran & Kematian | Data vital statistik |
|
||||
| Grafik Kepuasan | Grafik kepuasan layanan kesehatan |
|
||||
|
||||
### D. Ekonomi
|
||||
**Lokasi**: `src/app/admin/(dashboard)/ekonomi/` dan `src/app/darmasaba/(pages)/ekonomi/`
|
||||
|
||||
| Sub-modul | Deskripsi |
|
||||
|-----------|-----------|
|
||||
| Pasar Desa | Katalog pasar desa dengan produk & rating |
|
||||
| Struktur BUMDes | Organisasi BUMDes dengan pengurus |
|
||||
| APBDes (PADesa) | Pendapatan Asli Desa |
|
||||
| Program Kemiskinan | Program dan statistik kemiskinan |
|
||||
| Sektor Unggulan | Sektor ekonomi unggulan desa |
|
||||
| Lowongan Kerja Lokal | Info lowongan pekerjaan |
|
||||
| Demografi Pekerjaan | Distribusi pekerjaan penduduk |
|
||||
| Jumlah Pengangguran | Statistik pengangguran |
|
||||
| Penduduk Usia Kerja Menganggur | Analisis pengangguran by usia & pendidikan |
|
||||
| Jumlah Penduduk Miskin | Tren kemiskinan tahunan |
|
||||
|
||||
### E. Kependudukan
|
||||
**Lokasi**: `src/app/admin/(dashboard)/kependudukan/` dan `src/app/darmasaba/(pages)/kependudukan/`
|
||||
|
||||
| Sub-modul | Deskripsi |
|
||||
|-----------|-----------|
|
||||
| Data Banjar | Data penduduk per banjar |
|
||||
| Distribusi Agama | Statistik agama penduduk |
|
||||
| Distribusi Umur | Piramida umur penduduk |
|
||||
| Migrasi Penduduk | Data migrasi masuk/keluar |
|
||||
| Dinamika Penduduk | Kelahiran, kematian, migrasi per tahun |
|
||||
|
||||
### F. Pendidikan
|
||||
**Lokasi**: `src/app/admin/(dashboard)/pendidikan/` dan `src/app/darmasaba/(pages)/pendidikan/`
|
||||
|
||||
| Sub-modul | Deskripsi |
|
||||
|-----------|-----------|
|
||||
| Info Sekolah & PAUD | Data sekolah per jenjang (TK, SD, SMP, SMA) |
|
||||
| Beasiswa Desa | Program beasiswa & pendaftar |
|
||||
| Program Pendidikan Anak | Program pendidikan anak |
|
||||
| Bimbingan Belajar | Informasi bimbingan belajar |
|
||||
| Pendidikan Non Formal | Tempat & program non-formal |
|
||||
| Perpustakaan Digital | Katalog buku & peminjaman |
|
||||
| Data Pendidikan | Statistik pendidikan |
|
||||
|
||||
### G. Keamanan
|
||||
**Lokasi**: `src/app/admin/(dashboard)/keamanan/` dan `src/app/darmasaba/(pages)/keamanan/`
|
||||
|
||||
| Sub-modul | Deskripsi |
|
||||
|-----------|-----------|
|
||||
| Keamanan Lingkungan (Pecalang/Patwal) | Sistem keamanan tradisional Bali |
|
||||
| Polsek Terdekat | Data polsek dengan layanan & map |
|
||||
| Kontak Darurat | Kontak darurat keamanan |
|
||||
| Pencegahan Kriminalitas | Info pencegahan kriminal |
|
||||
| Laporan Publik | Laporan masyarakat dengan tracking status |
|
||||
| Tips Keamanan | Tips dan panduan keamanan |
|
||||
|
||||
### H. Lingkungan
|
||||
**Lokasi**: `src/app/admin/(dashboard)/lingkungan/` dan `src/app/darmasaba/(pages)/lingkungan/`
|
||||
|
||||
| Sub-modul | Deskripsi |
|
||||
|-----------|-----------|
|
||||
| Pengelolaan Sampah | Bank sampah & pengelolaan |
|
||||
| Program Penghijauan | Program penghijauan desa |
|
||||
| Data Lingkungan | Data lingkungan desa |
|
||||
| Gotong Royong | Kegiatan gotong royong |
|
||||
| Edukasi Lingkungan | Edukasi lingkungan hidup |
|
||||
| Konservasi Adat Bali | Tri Hita Karana & konservasi adat |
|
||||
|
||||
### I. Inovasi
|
||||
**Lokasi**: `src/app/admin/(dashboard)/inovasi/` dan `src/app/darmasaba/(pages)/inovasi/`
|
||||
|
||||
| Sub-modul | Deskripsi |
|
||||
|-----------|-----------|
|
||||
| Desa Digital (Smart Village) | Transformasi digital desa |
|
||||
| Program Kreatif Desa | Program kreatif & inovatif |
|
||||
| Kolaborasi Inovasi | Kolaborasi dengan mitra |
|
||||
| Info Teknologi Tepat Guna | Info teknologi untuk desa |
|
||||
| Ajukan Ide Inovatif | Form pengajuan ide dari warga |
|
||||
| Layanan Online Desa | Layanan administrasi online |
|
||||
|
||||
### J. Musik Desa
|
||||
**Lokasi**: `src/app/admin/(dashboard)/musik/` dan `src/app/darmasaba/(pages)/musik/`
|
||||
|
||||
Model `MusikDesa` dengan audio file, cover image, genre, dan durasi. Dilengkapi dengan `FixedPlayerBar` di layout publik.
|
||||
|
||||
### K. User & Role (Admin)
|
||||
**Lokasi**: `src/app/admin/(dashboard)/user&role/`
|
||||
|
||||
- **Role-based Access Control**: Role dengan permission JSON
|
||||
- **User Session Management**: Multiple sessions per user dengan JWT
|
||||
- **OTP Authentication**: Login dengan nomor telepon + OTP
|
||||
- **Menu Access Control**: Dynamic navbar berdasarkan menu akses user
|
||||
|
||||
---
|
||||
|
||||
## 5. Database Schema (Prisma)
|
||||
|
||||
Schema terdiri dari **2413 baris** dengan **100+ model** dan **berbagai enum**. Berikut model-model utama:
|
||||
|
||||
### Core Models
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `FileStorage` | Central file storage untuk semua uploaded files |
|
||||
| `AppMenu` / `AppMenuChild` | Menu navigasi aplikasi |
|
||||
| `User` / `Role` / `UserSession` / `UserMenuAccess` | Sistem autentikasi & otorisasi |
|
||||
| `KodeOtp` | OTP codes untuk login |
|
||||
|
||||
### Landing Page & Desa
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `PejabatDesa` | Pejabat desa dengan foto |
|
||||
| `ProfilPerbekel` | Profil perbekel (biodata, pengalaman, program) |
|
||||
| `PerbekelDariMasaKeMasa` | Historis perbekel |
|
||||
| `Berita` / `KategoriBerita` | Berita desa |
|
||||
| `PotensiDesa` / `KategoriPotensi` | Potensi desa |
|
||||
| `Pengumuman` / `CategoryPengumuman` | Pengumuman |
|
||||
| `GalleryFoto` / `GalleryVideo` | Gallery media |
|
||||
| `Penghargaan` | Penghargaan desa |
|
||||
| `APBDes` / `APBDesItem` / `RealisasiItem` | Anggaran dengan realisasi |
|
||||
| `DesaAntiKorupsi` / `KategoriDesaAntiKorupsi` | Transparansi |
|
||||
| `SdgsDesa` | SDGs desa |
|
||||
| `PrestasiDesa` / `KategoriPrestasiDesa` | Prestasi |
|
||||
| `MusikDesa` | Musik desa |
|
||||
|
||||
### PPID
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `StrukturPPID` / `PosisiOrganisasiPPID` / `PegawaiPPID` | Struktur organisasi |
|
||||
| `VisiMisiPPID` | Visi misi |
|
||||
| `ProfilePPID` | Profil pejabat |
|
||||
| `DasarHukumPPID` | Regulasi |
|
||||
| `DaftarInformasiPublik` | Katalog informasi |
|
||||
| `PermohonanInformasiPublik` | Permohonan + lookup tables |
|
||||
| `FormulirPermohonanKeberatan` | Keberatan |
|
||||
| `IndeksKepuasanMasyarakat` + grafik breakdown | Survey kepuasan |
|
||||
|
||||
### Kesehatan
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `FasilitasKesehatan` | Fasilitas lengkap (dokter, tarif, prosedur) |
|
||||
| `Puskesmas` / `JamOperasional` / `KontakPuskesmas` | Puskesmas |
|
||||
| `Posyandu` | Pos pelayanan terpadu |
|
||||
| `ProgramKesehatan` | Program kesehatan |
|
||||
| `ArtikelKesehatan` | Artikel lengkap (gejala, pencegahan, P3K, dll) |
|
||||
| `PenangananDarurat` / `KontakDarurat` | Darurat |
|
||||
| `InfoWabahPenyakit` | Wabah |
|
||||
| `DataKematian_Kelahiran` / `Kelahiran` / `Kematian` | Vital statistik |
|
||||
| `GrafikKepuasan` | Kepuasan |
|
||||
|
||||
### Ekonomi
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `PasarDesa` / `KategoriProduk` / `KategoriToPasar` | Pasar desa |
|
||||
| `StrukturBumDes` / `PosisiOrganisasiBumDes` / `PegawaiBumDes` | BUMDes |
|
||||
| `ProgramKemiskinan` / `StatistikKemiskinan` | Kemiskinan |
|
||||
| `SektorUnggulanDesa` | Sektor unggulan |
|
||||
| `LowonganPekerjaan` | Lowongan |
|
||||
| `DataDemografiPekerjaan` | Demografi pekerjaan |
|
||||
| `DetailDataPengangguran` | Pengangguran |
|
||||
| `GrafikJumlahPendudukMiskin` | Tren kemiskinan |
|
||||
|
||||
### Kependudukan
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `DataBanjar` | Data per banjar |
|
||||
| `DistribusiAgama` | Distribusi agama |
|
||||
| `DistribusiUmur` | Distribusi umur |
|
||||
| `MigrasiPenduduk` | Migrasi (MASUK/KELUAR) |
|
||||
| `DinamikaPenduduk` | Dinamika tahunan |
|
||||
|
||||
### Pendidikan
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `JenjangPendidikan` / `Lembaga` / `Siswa` / `Pengajar` | Data sekolah |
|
||||
| `BeasiswaPendaftar` | Beasiswa (dengan enum lengkap) |
|
||||
| `DataPerpustakaan` / `KategoriBuku` / `PeminjamanBuku` | Perpustakaan |
|
||||
| `DataPendidikan` | Statistik |
|
||||
|
||||
### Keamanan
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `KeamananLingkungan` | Keamanan lingkungan |
|
||||
| `PolsekTerdekat` / `LayananPolsek` / `LayananToPolsek` | Polsek |
|
||||
| `KontakDaruratKeamanan` / `KontakItem` | Kontak darurat |
|
||||
| `PencegahanKriminalitas` | Pencegahan |
|
||||
| `LaporanPublik` / `PenangananLaporanPublik` (enum StatusLaporan) | Laporan |
|
||||
| `Pelapor` | Pelapor |
|
||||
| `MenuTipsKeamanan` | Tips |
|
||||
|
||||
### Lingkungan
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `PengelolaanSampah` | Pengelolaan sampah |
|
||||
| `KeteranganBankSampahTerdekat` | Bank sampah |
|
||||
| `ProgramPenghijauan` | Penghijauan |
|
||||
| `DataLingkunganDesa` | Data lingkungan |
|
||||
| `KegiatanDesa` / `KategoriKegiatan` | Gotong royong |
|
||||
| `FilosofiTriHita` / `BentukKonservasiBerdasarkanAdat` | Konservasi Bali |
|
||||
|
||||
### Inovasi
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `DesaDigital` | Smart village |
|
||||
| `ProgramKreatif` | Program kreatif |
|
||||
| `KolaborasiInovasi` / `MitraKolaborasi` | Kolaborasi |
|
||||
| `InfoTekno` | Teknologi tepat guna |
|
||||
| `AjukanIdeInovatif` | Ide dari warga |
|
||||
| `AdministrasiOnline` / `JenisLayanan` | Layanan online |
|
||||
| `PengaduanMasyarakat` / `JenisPengaduan` | Pengaduan |
|
||||
|
||||
---
|
||||
|
||||
## 6. API Routes
|
||||
|
||||
Semua API ditangani oleh **Elysia.js** di `src/app/api/[[...slugs]]/route.ts`:
|
||||
|
||||
| Endpoint Group | Prefix | Deskripsi |
|
||||
|---------------|--------|-----------|
|
||||
| **File Storage** | `/api/file-storage` | CRUD file storage |
|
||||
| **Landing Page** | `/api/landing-page` | Profil, prestasi, anti-korupsi, SDGs, APBDes |
|
||||
| **Desa** | `/api/desa` | Berita, gallery, potensi, pengumuman, layanan |
|
||||
| **PPID** | `/api/ppid` | Semua endpoint PPID |
|
||||
| **Kesehatan** | `/api/kesehatan` | Fasilitas, puskesmas, posyandu, artikel, wabah |
|
||||
| **Ekonomi** | `/api/ekonomi` | Pasar desa, BUMDes, APBDes, pengangguran |
|
||||
| **Keamanan** | `/api/keamanan` | Keamanan, polsek, laporan, kriminalitas |
|
||||
| **Lingkungan** | `/api/lingkungan` | Sampah, penghijauan, gotong royong |
|
||||
| **Pendidikan** | `/api/pendidikan` | Sekolah, beasiswa, perpustakaan |
|
||||
| **Kependudukan** | `/api/kependudukan` | Banjar, agama, umur, migrasi |
|
||||
| **Inovasi** | `/api/inovasi` | Desa digital, kolaborasi, pengaduan |
|
||||
| **User** | `/api/admin/user` | CRUD user |
|
||||
| **Role** | `/api/admin/role` | CRUD role |
|
||||
| **Search** | `/api/search` | Global search |
|
||||
| **Utils** | `/api/utils/version` | Version info |
|
||||
|
||||
### Utility Endpoints
|
||||
|
||||
| Endpoint | Method | Deskripsi |
|
||||
|----------|--------|-----------|
|
||||
| `/api/img/:name` | GET | Serve image dengan resize |
|
||||
| `/api/img/:name` | DELETE | Delete image |
|
||||
| `/api/imgs` | GET | List images dengan pagination |
|
||||
| `/api/upl-img` | POST | Upload multiple images |
|
||||
| `/api/upl-img-single` | POST | Upload single image |
|
||||
| `/api/upl-csv` | POST | Upload CSV multiple |
|
||||
| `/api/upl-csv-single` | POST | Upload single CSV |
|
||||
|
||||
### Auth Endpoints
|
||||
|
||||
| Endpoint | Method | Deskripsi |
|
||||
|----------|--------|-----------|
|
||||
| `/api/auth/login` | POST | Login dengan OTP |
|
||||
| `/api/auth/logout` | POST | Logout |
|
||||
| `/api/auth/me` | GET | Get current user |
|
||||
|
||||
**Swagger Documentation**: Tersedia di `/api/docs`
|
||||
|
||||
---
|
||||
|
||||
## 7. Halaman Admin
|
||||
|
||||
Admin dashboard menggunakan **Mantine AppShell** dengan sidebar navigasi dinamis berbasis role.
|
||||
|
||||
### Route Group: `/admin`
|
||||
|
||||
| Section | Path | Deskripsi |
|
||||
|---------|------|-----------|
|
||||
| **Landing Page** | `/admin/landing-page/` | Profil desa, prestasi, anti-korupsi, SDGs, media sosial |
|
||||
| **Desa** | `/admin/desa/` | Berita, gallery, layanan, penghargaan, pengumuman, potensi, profil |
|
||||
| **PPID** | `/admin/ppid/` | 8 sub-modul PPID lengkap |
|
||||
| **Kesehatan** | `/admin/kesehatan/` | 8 sub-modul kesehatan |
|
||||
| **Ekonomi** | `/admin/ekonomi/` | 10 sub-modul ekonomi |
|
||||
| **Kependudukan** | `/admin/kependudukan/` | 4 sub-modul kependudukan |
|
||||
| **Pendidikan** | `/admin/pendidikan/` | 7 sub-modul pendidikan |
|
||||
| **Keamanan** | `/admin/keamanan/` | 6 sub-modul keamanan |
|
||||
| **Lingkungan** | `/admin/lingkungan/` | 6 sub-modul lingkungan |
|
||||
| **Inovasi** | `/admin/inovasi/` | 6 sub-modul inovasi |
|
||||
| **Musik** | `/admin/musik/` | Manajemen musik desa |
|
||||
| **User & Role** | `/admin/user&role/` | Manajemen user, role, menu access |
|
||||
|
||||
### Fitur Admin:
|
||||
- **Role-based Dynamic Navbar**: Navbar berubah berdasarkan roleId user
|
||||
- **Dark Mode Toggle**: Tema gelap/terang
|
||||
- **OTP Login**: Login dengan nomor telepon + kode OTP
|
||||
- **Session Management**: Multiple sessions per user dengan JWT tokens
|
||||
- **CSV Upload**: Import data via CSV
|
||||
- **Image Upload**: Upload dengan preview dan management
|
||||
- **Rich Text Editor**: Tiptap untuk konten HTML
|
||||
|
||||
### Role-Based Redirect:
|
||||
| roleId | Role | Default Redirect |
|
||||
|--------|------|-----------------|
|
||||
| 0, 1, 2 | Super Admin / Admin Desa | `/admin/landing-page/profil/program-inovasi` |
|
||||
| 3 | Admin Kesehatan | `/admin/kesehatan/posyandu` |
|
||||
| 4 | Admin Pendidikan | `/admin/pendidikan/info-sekolah/jenjang-pendidikan` |
|
||||
|
||||
---
|
||||
|
||||
## 8. Halaman Publik
|
||||
|
||||
Public website di `/darmasaba/` dengan layout yang mencakup **Navbar**, **Footer**, dan **Fixed Music Player Bar**.
|
||||
|
||||
### Route Group: `/darmasaba`
|
||||
|
||||
| Section | Path | Deskripsi |
|
||||
|---------|------|-----------|
|
||||
| **Home** | `/darmasaba` | Landing page utama |
|
||||
| **Desa** | `/darmasaba/desa` | Profil, berita, gallery, layanan, pengumuman, potensi |
|
||||
| **PPID** | `/darmasaba/ppid` | 7 sub-halaman PPID publik |
|
||||
| **Kesehatan** | `/darmasaba/kesehatan` | Info kesehatan publik |
|
||||
| **Ekonomi** | `/darmasaba/ekonomi` | Info ekonomi desa |
|
||||
| **Kependudukan** | `/darmasaba/kependudukan` | Data kependudukan |
|
||||
| **Pendidikan** | `/darmasaba/pendidikan` | Info pendidikan |
|
||||
| **Keamanan** | `/darmasaba/keamanan` | Info keamanan |
|
||||
| **Lingkungan** | `/darmasaba/lingkungan` | Info lingkungan |
|
||||
| **Inovasi** | `/darmasaba/inovasi` | Info inovasi |
|
||||
| **Musik** | `/darmasaba/musik` | Musik desa |
|
||||
| **Module** | `/darmasaba/module/*` | Link ke modul eksternal (DAVES, MANGAN, Bicara-Darma, BARES, dll) |
|
||||
|
||||
### Fitur Publik:
|
||||
- **Fixed Music Player Bar**: Player musik yang selalu tampil di bottom
|
||||
- **Global Search**: Pencarian global
|
||||
- **News Reader**: Notifikasi berita modern
|
||||
- **View Transitions**: Smooth page transitions
|
||||
- **Responsive Design**: Mobile-first dengan Mantine breakpoints
|
||||
|
||||
---
|
||||
|
||||
## 9. Komponen Utama
|
||||
|
||||
### Admin Components (`src/components/admin/`)
|
||||
|
||||
| Komponen | Deskripsi |
|
||||
|----------|-----------|
|
||||
| `AdminThemeProvider.tsx` | Theme provider untuk admin |
|
||||
| `DarkModeToggle.tsx` | Toggle dark/light mode |
|
||||
| `UnifiedSurface.tsx` | Consistent surface/card component |
|
||||
| `UnifiedTypography.tsx` | Consistent typography system |
|
||||
|
||||
### Public Shared Components (`src/app/darmasaba/_com/`)
|
||||
|
||||
| Komponen | Deskripsi |
|
||||
|----------|-----------|
|
||||
| `Navbar.tsx` | Main navigation bar |
|
||||
| `NavbarMainMenu.tsx` | Main menu dengan kategori |
|
||||
| `NavbarSubMenu.tsx` | Submenu dropdown |
|
||||
| `Footer.tsx` | Footer dengan info desa |
|
||||
| `FixedPlayerBar.tsx` | Music player bar fixed di bottom |
|
||||
| `LoadDataFirstClient.tsx` | Client-side data preloader |
|
||||
| `globalSearch.tsx` | Global search component |
|
||||
| `NewsReader.tsx` | News notification reader |
|
||||
| `ModernNewsNotification.tsx` | News toast notifications |
|
||||
|
||||
### Global Components (`src/app/_com/`)
|
||||
|
||||
| Komponen | Deskripsi |
|
||||
|----------|-----------|
|
||||
| `SpashScreen.tsx` | Splash screen on load |
|
||||
| `WebVitals.tsx` | Web Vitals monitoring |
|
||||
|
||||
---
|
||||
|
||||
## 10. State Management
|
||||
|
||||
Proyek menggunakan **multi-layer state management**:
|
||||
|
||||
| Library | Penggunaan | Lokasi |
|
||||
|---------|-----------|--------|
|
||||
| **Jotai** | Auth state (`authStore`) | `src/store/authStore.ts` |
|
||||
| **Valtio** | Dark mode, layanan, image list, nav state | `src/state/*.ts` |
|
||||
| **SWR** | Server state fetching & caching | Digunakan di components |
|
||||
| **React Context** | Music player context | `src/app/context/MusicContext.tsx` |
|
||||
| **React useState** | Local component state | Di components |
|
||||
|
||||
### State Files:
|
||||
|
||||
```
|
||||
src/state/
|
||||
darkModeStore.ts -- Valtio proxy untuk dark mode
|
||||
state-layanan.ts -- State layanan desa
|
||||
state-list-image.ts -- State list image untuk upload
|
||||
state-nav.ts -- State navigasi
|
||||
|
||||
src/store/
|
||||
authStore.ts -- Jotai atom untuk auth user state
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Autentikasi
|
||||
|
||||
Sistem autentikasi menggunakan **OTP (One-Time Password)** via WhatsApp/Telepon dengan **iron-session** untuk session management.
|
||||
|
||||
### Flow Autentikasi:
|
||||
1. User memasukkan **nomor telepon** di `/login`
|
||||
2. Sistem mengirim **kode OTP** via WhatsApp Server
|
||||
3. OTP disimpan di model `KodeOtp`
|
||||
4. User memasukkan kode OTP
|
||||
5. Jika valid, session dibuat dengan **iron-session** + **JWT token**
|
||||
6. Session disimpan di `UserSession` model dengan expiry
|
||||
|
||||
### Session Structure:
|
||||
```typescript
|
||||
// src/lib/session.ts
|
||||
type SessionData = {
|
||||
user?: {
|
||||
id: string;
|
||||
name: string;
|
||||
roleId: number;
|
||||
menuIds?: string[] | null;
|
||||
isActive?: boolean;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Role-Based Access:
|
||||
| roleId | Role | Default Redirect |
|
||||
|--------|------|-----------------|
|
||||
| 0, 1, 2 | Super Admin / Admin Desa | `/admin/landing-page/profil/program-inovasi` |
|
||||
| 3 | Admin Kesehatan | `/admin/kesehatan/posyandu` |
|
||||
| 4 | Admin Pendidikan | `/admin/pendidikan/info-sekolah/jenjang-pendidikan` |
|
||||
|
||||
### Authorization:
|
||||
- **UserMenuAccess**: Mapping user ke menu yang boleh diakses
|
||||
- **Dynamic Navbar**: Navbar dirender berdasarkan `menuIds` user
|
||||
- **Inactive Users**: Dialihkan ke `/waiting-room`
|
||||
|
||||
---
|
||||
|
||||
## 12. Deployment
|
||||
|
||||
### Docker Setup
|
||||
|
||||
**Dockerfile** menggunakan **multi-stage build** dengan base image `oven/bun:1-debian`:
|
||||
|
||||
```
|
||||
Stage 1: Builder
|
||||
- Install dependencies (bun install --frozen-lockfile)
|
||||
- Generate Prisma client
|
||||
- Build Next.js (bun run build)
|
||||
|
||||
Stage 2: Runner
|
||||
- Copy .next, node_modules, public, prisma, src/lib, tsconfig.json
|
||||
- Non-root user (nextjs:nodejs)
|
||||
- Volume /app/uploads untuk file uploads
|
||||
- Port 3000
|
||||
```
|
||||
|
||||
### Entry Point (`docker-entrypoint.sh`):
|
||||
```bash
|
||||
bunx prisma migrate deploy # Run migrations
|
||||
exec bun start # Start Next.js production server
|
||||
```
|
||||
|
||||
### CI/CD dengan GitHub Actions
|
||||
|
||||
Terdapat **3 workflow**:
|
||||
|
||||
| Workflow | Trigger | Fungsi |
|
||||
|----------|---------|--------|
|
||||
| `docker-publish.yml` | Push tag `v*` | Auto build & push ke GHCR |
|
||||
| `publish.yml` | Manual (workflow_dispatch) | Build & push ke GHCR dengan input `stack_env` + `tag` |
|
||||
| `re-pull.yml` | Manual (workflow_dispatch) | Re-pull image di Portainer dengan input `stack_name` + `stack_env` |
|
||||
|
||||
### Deployment Workflow (Sequential):
|
||||
|
||||
```
|
||||
1. Update version di package.json (semver)
|
||||
2. Commit perubahan
|
||||
3. Push ke branch target (stg/prod)
|
||||
4. Trigger publish.yml:
|
||||
gh workflow run publish.yml --ref main -f stack_env=stg -f tag=<version>
|
||||
5. Tunggu sampai publish selesai (status: completed)
|
||||
6. Trigger re-pull.yml:
|
||||
gh workflow run re-pull.yml --ref main -f stack_name=desa-darmasaba -f stack_env=stg
|
||||
7. Verifikasi di Portainer
|
||||
```
|
||||
|
||||
**PENTING**: `publish.yml` dan `re-pull.yml` TIDAK boleh dijalankan bersamaan (race condition).
|
||||
|
||||
### Environments:
|
||||
- **dev**: Development
|
||||
- **stg**: Staging (`desa-darmasaba-stg.wibudev.com`)
|
||||
- **prod**: Production
|
||||
|
||||
### Notification:
|
||||
- Telegram notification via `notify.sh` script setelah setiap workflow
|
||||
|
||||
---
|
||||
|
||||
## 13. Scripts
|
||||
|
||||
| Script | Command | Deskripsi |
|
||||
|--------|---------|-----------|
|
||||
| `dev` | `next dev` | Development server |
|
||||
| `build` | `next build` | Production build |
|
||||
| `start` | `next start` | Production server |
|
||||
| `test:api` | `vitest run` | Run API unit tests |
|
||||
| `test:e2e` | `playwright test` | Run E2E tests |
|
||||
| `test` | `bun run test:api && bun run test:e2e` | Run all tests |
|
||||
| `seed` | `bun run prisma/seed.ts` | Seed database |
|
||||
| `prisma:generate` | `bunx prisma generate` | Generate Prisma client |
|
||||
| `prisma:push` | `bunx prisma db push` | Push schema to database |
|
||||
| `prisma:studio` | `bunx prisma studio` | Open Prisma Studio GUI |
|
||||
| `gen:api` | *(empty)* | Generate API types (placeholder) |
|
||||
|
||||
### Prisma Seed Configuration:
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"prisma": {
|
||||
"seed": "bun run prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Environment Variables
|
||||
|
||||
File: `.env.example`
|
||||
|
||||
| Variable | Deskripsi | Contoh |
|
||||
|----------|-----------|--------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/desa-darmasaba` |
|
||||
| `SEAFILE_TOKEN` | Seafile API token | `your_seafile_token` |
|
||||
| `SEAFILE_REPO_ID` | Seafile repository ID | `your_repo_id` |
|
||||
| `SEAFILE_URL` | Seafile instance URL | `https://seafile.example.com` |
|
||||
| `SEAFILE_PUBLIC_SHARE_TOKEN` | Token untuk public share | `your_share_token` |
|
||||
| `WIBU_UPLOAD_DIR` | Upload directory path | `uploads` |
|
||||
| `WA_SERVER_TOKEN` | WhatsApp server token | `your_wa_token` |
|
||||
| `NEXT_PUBLIC_BASE_URL` | Base URL aplikasi | `/` (relative) |
|
||||
| `EMAIL_USER` | Email untuk notifikasi | `your_email@gmail.com` |
|
||||
| `EMAIL_PASS` | Email app password | `your_app_password` |
|
||||
| `BASE_TOKEN_KEY` | JWT secret key | `your_jwt_secret` |
|
||||
| `BOT_TOKEN` | Telegram bot token | `your_bot_token` |
|
||||
| `CHAT_ID` | Telegram chat ID | `your_chat_id` |
|
||||
| `SESSION_PASSWORD` | iron-session password (min 32 chars) | `secure_32_char_password` |
|
||||
| `ELEVENLABS_API_KEY` | ElevenLabs API (TTS - optional) | `your_elevenlabs_key` |
|
||||
|
||||
---
|
||||
|
||||
## 15. Layanan Eksternal
|
||||
|
||||
### PostgreSQL
|
||||
- **Provider**: PostgreSQL via Prisma ORM
|
||||
- **Schema**: `public`
|
||||
- **Connection**: Via `DATABASE_URL` environment variable
|
||||
- **Migrations**: `prisma migrate deploy` di docker entrypoint
|
||||
|
||||
### Seafile (File Storage)
|
||||
- **Tipe**: Self-hosted file sync & share
|
||||
- **Penggunaan**: Storage untuk images, documents, audio files
|
||||
- **Integrasi**: `src/lib/seafile-auth-service.ts`
|
||||
- **CDN**: URL generation untuk public sharing
|
||||
- **Config**: Token, repo ID, base URL
|
||||
|
||||
### WhatsApp Server
|
||||
- **Penggunaan**: Kirim OTP codes saat login
|
||||
- **Config**: `WA_SERVER_TOKEN`
|
||||
|
||||
### Telegram Bot
|
||||
- **Penggunaan**: Notifikasi deployment & sistem
|
||||
- **Config**: `BOT_TOKEN` + `CHAT_ID`
|
||||
- **Integration**: `notify.sh` script di GitHub Actions
|
||||
|
||||
### ElevenLabs (Optional)
|
||||
- **Penggunaan**: Text-to-Speech (TTS) features
|
||||
- **Config**: `ELEVENLABS_API_KEY`
|
||||
|
||||
### Email (Nodemailer)
|
||||
- **Penggunaan**: Notifikasi email untuk subscription/pengumuman
|
||||
- **Config**: `EMAIL_USER` + `EMAIL_PASS`
|
||||
- **Provider**: Gmail (app password)
|
||||
|
||||
---
|
||||
|
||||
## Ringkasan Cepat
|
||||
|
||||
| Aspek | Detail |
|
||||
|-------|--------|
|
||||
| **Framework** | Next.js 15 (App Router) + Elysia.js |
|
||||
| **Database** | PostgreSQL + Prisma (100+ models) |
|
||||
| **Auth** | OTP + iron-session + JWT |
|
||||
| **Storage** | Seafile + local uploads |
|
||||
| **UI** | Mantine UI + Tiptap + Framer Motion |
|
||||
| **State** | Jotai + Valtio + SWR |
|
||||
| **Deploy** | Docker + GHCR + Portainer + GitHub Actions |
|
||||
| **Runtime** | Bun |
|
||||
| **Testing** | Vitest + Playwright |
|
||||
| **Version** | 0.1.11 |
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "desa-darmasaba",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.14",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -131,5 +131,6 @@
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "JenisMigrasi" AS ENUM ('MASUK', 'KELUAR');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DataBanjar" (
|
||||
"id" TEXT NOT NULL,
|
||||
@@ -107,6 +110,3 @@ CREATE INDEX "DinamikaPenduduk_isActive_idx" ON "DinamikaPenduduk"("isActive");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DinamikaPenduduk_tahun_key" ON "DinamikaPenduduk"("tahun");
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "JenisMigrasi" AS ENUM ('MASUK', 'KELUAR');
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Umkm" (
|
||||
"id" TEXT NOT NULL,
|
||||
"nama" TEXT NOT NULL,
|
||||
"pemilik" TEXT NOT NULL,
|
||||
"deskripsi" TEXT,
|
||||
"alamat" TEXT,
|
||||
"kontak" TEXT,
|
||||
"imageId" TEXT,
|
||||
"kategoriId" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "Umkm_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProdukUmkm" (
|
||||
"id" TEXT NOT NULL,
|
||||
"nama" TEXT NOT NULL,
|
||||
"harga" INTEGER NOT NULL,
|
||||
"stok" INTEGER NOT NULL DEFAULT 0,
|
||||
"deskripsi" TEXT,
|
||||
"imageId" TEXT,
|
||||
"umkmId" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "ProdukUmkm_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PenjualanProduk" (
|
||||
"id" TEXT NOT NULL,
|
||||
"produkId" TEXT NOT NULL,
|
||||
"jumlah" INTEGER NOT NULL,
|
||||
"hargaSatuan" INTEGER NOT NULL,
|
||||
"totalNilai" INTEGER NOT NULL,
|
||||
"tanggal" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"periode" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "PenjualanProduk_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PenjualanProduk_periode_idx" ON "PenjualanProduk"("periode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PenjualanProduk_produkId_idx" ON "PenjualanProduk"("produkId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PenjualanProduk_tanggal_idx" ON "PenjualanProduk"("tanggal");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Umkm" ADD CONSTRAINT "Umkm_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Umkm" ADD CONSTRAINT "Umkm_kategoriId_fkey" FOREIGN KEY ("kategoriId") REFERENCES "KategoriProduk"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProdukUmkm" ADD CONSTRAINT "ProdukUmkm_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProdukUmkm" ADD CONSTRAINT "ProdukUmkm_umkmId_fkey" FOREIGN KEY ("umkmId") REFERENCES "Umkm"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PenjualanProduk" ADD CONSTRAINT "PenjualanProduk_produkId_fkey" FOREIGN KEY ("produkId") REFERENCES "ProdukUmkm"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -106,6 +106,8 @@ model FileStorage {
|
||||
|
||||
MusikDesaAudio MusikDesa[] @relation("MusikAudioFile")
|
||||
MusikDesaCover MusikDesa[] @relation("MusikCoverImage")
|
||||
UmkmImage Umkm[] @relation("UmkmImage")
|
||||
ProdukUmkmImage ProdukUmkm[] @relation("ProdukUmkmImage")
|
||||
}
|
||||
|
||||
//========================================= MENU LANDING PAGE ========================================= //
|
||||
@@ -1448,6 +1450,7 @@ model KategoriProduk {
|
||||
isActive Boolean @default(true)
|
||||
KategoriToPasar KategoriToPasar[]
|
||||
PasarDesa PasarDesa[]
|
||||
Umkm Umkm[]
|
||||
}
|
||||
|
||||
model KategoriToPasar {
|
||||
@@ -2410,3 +2413,58 @@ model MusikDesa {
|
||||
@@index([judul])
|
||||
@@index([artis])
|
||||
}
|
||||
|
||||
model Umkm {
|
||||
id String @id @default(cuid())
|
||||
nama String
|
||||
pemilik String
|
||||
deskripsi String?
|
||||
alamat String?
|
||||
kontak String?
|
||||
image FileStorage? @relation("UmkmImage", fields: [imageId], references: [id])
|
||||
imageId String?
|
||||
kategori KategoriProduk @relation(fields: [kategoriId], references: [id])
|
||||
kategoriId String
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
produk ProdukUmkm[]
|
||||
}
|
||||
|
||||
model ProdukUmkm {
|
||||
id String @id @default(cuid())
|
||||
nama String
|
||||
harga Int
|
||||
stok Int @default(0)
|
||||
deskripsi String?
|
||||
image FileStorage? @relation("ProdukUmkmImage", fields: [imageId], references: [id])
|
||||
imageId String?
|
||||
umkm Umkm @relation(fields: [umkmId], references: [id])
|
||||
umkmId String
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
penjualan PenjualanProduk[]
|
||||
}
|
||||
|
||||
model PenjualanProduk {
|
||||
id String @id @default(cuid())
|
||||
produk ProdukUmkm @relation(fields: [produkId], references: [id])
|
||||
produkId String
|
||||
jumlah Int
|
||||
hargaSatuan Int // snapshot harga saat transaksi, agar histori tetap akurat
|
||||
totalNilai Int // hargaSatuan * jumlah
|
||||
tanggal DateTime @default(now())
|
||||
periode String // format "YYYY-MM" untuk grouping bulanan
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
@@index([periode])
|
||||
@@index([produkId])
|
||||
@@index([tanggal])
|
||||
}
|
||||
|
||||
|
||||
290
src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts
Normal file
290
src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { toast } from "react-toastify";
|
||||
import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
|
||||
// UMKM Form Validation
|
||||
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(),
|
||||
});
|
||||
|
||||
const defaultUmkmForm = {
|
||||
nama: "",
|
||||
pemilik: "",
|
||||
kategoriId: "",
|
||||
deskripsi: "",
|
||||
alamat: "",
|
||||
kontak: "",
|
||||
imageId: "",
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
// Produk Form Validation
|
||||
const produkFormSchema = z.object({
|
||||
nama: z.string().min(1, "Nama produk minimal 1 karakter"),
|
||||
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(),
|
||||
});
|
||||
|
||||
const defaultProdukForm = {
|
||||
nama: "",
|
||||
harga: 0,
|
||||
stok: 0,
|
||||
umkmId: "",
|
||||
deskripsi: "",
|
||||
imageId: "",
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
// Penjualan Form Validation
|
||||
const penjualanFormSchema = z.object({
|
||||
produkId: z.string().min(1, "Produk wajib dipilih"),
|
||||
jumlah: z.number().min(1, "Jumlah minimal 1"),
|
||||
hargaSatuan: z.number().min(0, "Harga tidak boleh negatif"),
|
||||
tanggal: z.string().optional(),
|
||||
});
|
||||
|
||||
const defaultPenjualanForm = {
|
||||
produkId: "",
|
||||
jumlah: 0,
|
||||
hargaSatuan: 0,
|
||||
tanggal: new Date().toISOString().split('T')[0],
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
export const umkmState = proxy({
|
||||
// UMKM Module
|
||||
umkm: {
|
||||
findMany: {
|
||||
data: [] as any[],
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
loading: false,
|
||||
search: "",
|
||||
async load(page = 1, limit = 10, search = "", kategoriId = "") {
|
||||
this.loading = true;
|
||||
this.page = page;
|
||||
this.search = search;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
search,
|
||||
kategoriId
|
||||
});
|
||||
const res = await fetch(`/api/ekonomi/umkm/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;
|
||||
}
|
||||
}
|
||||
},
|
||||
create: {
|
||||
form: { ...defaultUmkmForm },
|
||||
loading: false,
|
||||
async submit() {
|
||||
const cek = umkmFormSchema.safeParse(this.form);
|
||||
if (!cek.success) return toast.error("Cek kembali form anda");
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch("/api/ekonomi/umkm/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(this.form)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
toast.success("UMKM berhasil dibuat");
|
||||
umkmState.umkm.findMany.load();
|
||||
return true;
|
||||
}
|
||||
toast.error(result.message);
|
||||
} catch (e) {
|
||||
toast.error("Gagal membuat UMKM");
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
findUnique: {
|
||||
data: null as any,
|
||||
loading: false,
|
||||
async load(id: string) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/ekonomi/umkm/${id}`);
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
this.data = result.data;
|
||||
}
|
||||
} catch (e) { console.error(e); } finally { this.loading = false; }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Produk Module
|
||||
produk: {
|
||||
findMany: {
|
||||
data: [] as any[],
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
loading: false,
|
||||
async load(page = 1, limit = 10, search = "", umkmId = "", kategoriId = "") {
|
||||
this.loading = true;
|
||||
this.page = page;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
search,
|
||||
umkmId,
|
||||
kategoriId
|
||||
});
|
||||
const res = await fetch(`/api/ekonomi/umkm/produk/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; }
|
||||
}
|
||||
},
|
||||
create: {
|
||||
form: { ...defaultProdukForm },
|
||||
loading: false,
|
||||
async submit() {
|
||||
const cek = produkFormSchema.safeParse(this.form);
|
||||
if (!cek.success) return toast.error("Cek kembali form anda");
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch("/api/ekonomi/umkm/produk/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(this.form)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
toast.success("Produk berhasil dibuat");
|
||||
umkmState.produk.findMany.load();
|
||||
return true;
|
||||
}
|
||||
} catch (e) { toast.error("Gagal membuat produk"); } finally { this.loading = false; }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Penjualan Module
|
||||
penjualan: {
|
||||
findMany: {
|
||||
data: [] as any[],
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
loading: false,
|
||||
async load(page = 1, limit = 10, produkId = "", periode = "") {
|
||||
this.loading = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ page: page.toString(), limit: limit.toString(), produkId, periode });
|
||||
const res = await fetch(`/api/ekonomi/umkm/penjualan/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; }
|
||||
}
|
||||
},
|
||||
create: {
|
||||
form: { ...defaultPenjualanForm },
|
||||
loading: false,
|
||||
async submit() {
|
||||
const cek = penjualanFormSchema.safeParse(this.form);
|
||||
if (!cek.success) return toast.error("Cek kembali form anda");
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch("/api/ekonomi/umkm/penjualan/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(this.form)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
toast.success("Penjualan berhasil dicatat");
|
||||
umkmState.penjualan.findMany.load();
|
||||
return true;
|
||||
}
|
||||
} catch (e) { toast.error("Gagal mencatat penjualan"); } finally { this.loading = false; }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Kategori Produk (Share with Pasar Desa)
|
||||
kategoriProduk: {
|
||||
findManyAll: {
|
||||
data: [] as any[],
|
||||
loading: false,
|
||||
async load() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch("/api/ekonomi/pasar-desa/kategori-produk/find-many-all");
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
this.data = result.data;
|
||||
}
|
||||
} catch (e) { console.error(e); } finally { this.loading = false; }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Dashboard Module
|
||||
dashboard: {
|
||||
kpi: { data: null as any, loading: false },
|
||||
summary: { data: null as any, loading: false },
|
||||
topProduk: { data: [] as any[], loading: false },
|
||||
detail: { data: [] as any[], loading: false },
|
||||
async loadAll(periode = "") {
|
||||
const p = periode || `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
|
||||
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())
|
||||
]);
|
||||
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;
|
||||
} catch (e) { console.error(e); } finally {
|
||||
this.kpi.loading = false;
|
||||
this.summary.loading = false;
|
||||
this.topProduk.loading = false;
|
||||
this.detail.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default umkmState;
|
||||
168
src/app/admin/(dashboard)/ekonomi/umkm/_lib/layoutTabs.tsx
Normal file
168
src/app/admin/(dashboard)/ekonomi/umkm/_lib/layoutTabs.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
Box,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsPanel,
|
||||
TabsTab,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconDashboard, IconBuildingStore, IconPackage, IconShoppingCart } from '@tabler/icons-react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
function LayoutTabs({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
label: "Dashboard",
|
||||
value: "dashboard",
|
||||
href: "/admin/ekonomi/umkm/dashboard",
|
||||
icon: <IconDashboard size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Data UMKM",
|
||||
value: "data-umkm",
|
||||
href: "/admin/ekonomi/umkm/data-umkm",
|
||||
icon: <IconBuildingStore size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Produk",
|
||||
value: "produk",
|
||||
href: "/admin/ekonomi/umkm/produk",
|
||||
icon: <IconPackage size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Penjualan",
|
||||
value: "penjualan",
|
||||
href: "/admin/ekonomi/umkm/penjualan",
|
||||
icon: <IconShoppingCart size={18} stroke={1.8} />
|
||||
},
|
||||
];
|
||||
|
||||
const currentTab = tabs.find((tab) => pathname.startsWith(tab.href));
|
||||
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) => pathname.startsWith(tab.href));
|
||||
if (match) {
|
||||
setActiveTab(match.value);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={2} fw={700} style={{ color: "#1A1B1E" }}>
|
||||
Manajemen UMKM
|
||||
</Title>
|
||||
|
||||
<Tabs
|
||||
color={colors['blue-button']}
|
||||
variant="pills"
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
radius="lg"
|
||||
keepMounted={false}
|
||||
>
|
||||
<Box visibleFrom='md' pb={10}>
|
||||
<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",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
))}
|
||||
</TabsList>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
|
||||
<Box hiddenFrom='md' pb={10}>
|
||||
<ScrollArea type="auto" offsetScrollbars={false} w="100%">
|
||||
<TabsList
|
||||
p="xs"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
|
||||
borderRadius: "1rem",
|
||||
display: "flex",
|
||||
flexWrap: "nowrap",
|
||||
gap: "0.5rem",
|
||||
width: "max-content",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, i) => (
|
||||
<TabsTab
|
||||
key={i}
|
||||
value={tab.value}
|
||||
leftSection={tab.icon}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
paddingInline: "0.75rem",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
))}
|
||||
</TabsList>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
|
||||
{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 LayoutTabs;
|
||||
140
src/app/admin/(dashboard)/ekonomi/umkm/dashboard/page.tsx
Normal file
140
src/app/admin/(dashboard)/ekonomi/umkm/dashboard/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Grid,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
Badge
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowUpRight, IconArrowDownRight, IconMinus } from '@tabler/icons-react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import umkmState from '../../../_state/ekonomi/umkm/umkm';
|
||||
|
||||
function UmkmDashboard() {
|
||||
const state = useProxy(umkmState.dashboard);
|
||||
|
||||
useShallowEffect(() => {
|
||||
state.loadAll();
|
||||
}, []);
|
||||
|
||||
if (state.kpi.loading || !state.kpi.data) {
|
||||
return <Skeleton height={400} radius="md" />;
|
||||
}
|
||||
|
||||
const kpi = state.kpi.data;
|
||||
const summary = state.summary.data;
|
||||
const topProduk = state.topProduk.data;
|
||||
const detail = state.detail.data;
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
|
||||
<KpiCard title="UMKM Aktif" value={kpi.umkmAktif} subValue={`Total: ${kpi.totalUmkm}`} />
|
||||
<KpiCard
|
||||
title="Omzet Bulan Ini"
|
||||
value={`Rp ${kpi.omzetBulanan.toLocaleString()}`}
|
||||
trend={summary?.persentasePerubahan}
|
||||
/>
|
||||
<KpiCard title="Produk Aktif" value={summary?.produkAktif || 0} />
|
||||
<KpiCard title="Kategori Populer" value={kpi.kategoriTerbanyak} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<Card withBorder radius="md" p="lg" shadow="sm">
|
||||
<Title order={4} mb="md">Top 3 Produk</Title>
|
||||
<Stack gap="sm">
|
||||
{topProduk.map((item, i) => (
|
||||
<Group key={i} justify="space-between">
|
||||
<Box>
|
||||
<Text fw={500}>{item.namaProduk}</Text>
|
||||
<Text size="xs" c="dimmed">{item.namaUmkm}</Text>
|
||||
</Box>
|
||||
<Text fw={600} c="blue">Rp {item.totalPenjualan.toLocaleString()}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 8 }}>
|
||||
<Card withBorder radius="md" p="lg" shadow="sm">
|
||||
<Title order={4} mb="md">Detail Penjualan & Stok</Title>
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>Produk</TableTh>
|
||||
<TableTh>Penjualan</TableTh>
|
||||
<TableTh>Trend</TableTh>
|
||||
<TableTh>Stok</TableTh>
|
||||
<TableTh>Status</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{detail.map((item, i) => (
|
||||
<TableTr key={i}>
|
||||
<TableTd>{item.namaProduk}</TableTd>
|
||||
<TableTd>Rp {item.penjualanBulanIni.toLocaleString()}</TableTd>
|
||||
<TableTd>{renderTrend(item.trend)}</TableTd>
|
||||
<TableTd>{item.stok}</TableTd>
|
||||
<TableTd>
|
||||
<Badge color={getStatusColor(item.statusStok)}>
|
||||
{item.statusStok}
|
||||
</Badge>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({ title, value, subValue, trend }: any) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="lg" shadow="sm">
|
||||
<Text size="xs" c="dimmed" fw={700} tt="uppercase">{title}</Text>
|
||||
<Group align="flex-end" gap="xs" mt="sm">
|
||||
<Text fz="xl" fw={700} lh={1}>{value}</Text>
|
||||
{trend !== undefined && (
|
||||
<Text c={trend >= 0 ? 'teal' : 'red'} fz="sm" fw={500}>
|
||||
{trend >= 0 ? '+' : ''}{trend}%
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{subValue && <Text size="xs" c="dimmed" mt={4}>{subValue}</Text>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTrend(trend: string) {
|
||||
if (trend === 'up') return <IconArrowUpRight size={18} color="green" />;
|
||||
if (trend === 'down') return <IconArrowDownRight size={18} color="red" />;
|
||||
return <IconMinus size={18} color="gray" />;
|
||||
}
|
||||
|
||||
function getStatusColor(status: string) {
|
||||
if (status === 'Aman') return 'green';
|
||||
if (status === 'Menipis') return 'yellow';
|
||||
return 'red';
|
||||
}
|
||||
|
||||
export default UmkmDashboard;
|
||||
105
src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/page.tsx
Normal file
105
src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Pagination,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
TextInput
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||
import { IconPlus, IconSearch, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import umkmState from '../../../_state/ekonomi/umkm/umkm';
|
||||
|
||||
function DataUmkm() {
|
||||
const [search, setSearch] = useState("");
|
||||
const state = useProxy(umkmState.umkm.findMany);
|
||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||
|
||||
useShallowEffect(() => {
|
||||
state.load(state.page, 10, debouncedSearch);
|
||||
}, [state.page, debouncedSearch]);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>Data UMKM</Title>
|
||||
<Button leftSection={<IconPlus size={18} />} color="blue">
|
||||
Tambah UMKM
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<TextInput
|
||||
placeholder="Cari UMKM atau Pemilik..."
|
||||
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 UMKM</TableTh>
|
||||
<TableTh>Pemilik</TableTh>
|
||||
<TableTh>Kategori</TableTh>
|
||||
<TableTh>Kontak</TableTh>
|
||||
<TableTh>Aksi</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{state.data.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd fw={500}>{item.nama}</TableTd>
|
||||
<TableTd>{item.pemilik}</TableTd>
|
||||
<TableTd>{item.kategori?.nama || '-'}</TableTd>
|
||||
<TableTd>{item.kontak || '-'}</TableTd>
|
||||
<TableTd>
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" color="blue" size="xs">
|
||||
<IconEdit size={16} />
|
||||
</Button>
|
||||
<Button variant="subtle" color="red" size="xs">
|
||||
<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>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default DataUmkm;
|
||||
28
src/app/admin/(dashboard)/ekonomi/umkm/layout.tsx
Normal file
28
src/app/admin/(dashboard)/ekonomi/umkm/layout.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import LayoutTabs from "./_lib/layoutTabs"
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
// Path /admin/ekonomi/umkm/dashboard -> length 4
|
||||
// Path detail usually adds an ID -> length >= 5
|
||||
const isDetailPage = segments.length >= 5;
|
||||
|
||||
if (isDetailPage) {
|
||||
return (
|
||||
<Box>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<LayoutTabs>
|
||||
{children}
|
||||
</LayoutTabs>
|
||||
)
|
||||
}
|
||||
89
src/app/admin/(dashboard)/ekonomi/umkm/penjualan/page.tsx
Normal file
89
src/app/admin/(dashboard)/ekonomi/umkm/penjualan/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Pagination,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import umkmState from '../../../_state/ekonomi/umkm/umkm';
|
||||
|
||||
function PenjualanUmkm() {
|
||||
const state = useProxy(umkmState.penjualan.findMany);
|
||||
|
||||
useShallowEffect(() => {
|
||||
state.load(state.page, 10);
|
||||
}, [state.page]);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>Histori Penjualan UMKM</Title>
|
||||
<Button leftSection={<IconPlus size={18} />} color="blue">
|
||||
Catat Penjualan
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
{state.loading ? (
|
||||
<Skeleton height={400} />
|
||||
) : (
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>Tanggal</TableTh>
|
||||
<TableTh>Produk</TableTh>
|
||||
<TableTh>UMKM</TableTh>
|
||||
<TableTh>Jumlah</TableTh>
|
||||
<TableTh>Total Nilai</TableTh>
|
||||
<TableTh>Aksi</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{state.data.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<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>
|
||||
<Button variant="subtle" color="red" size="xs">
|
||||
<IconTrash size={16} />
|
||||
</Button>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Center mt="md">
|
||||
<Pagination
|
||||
total={state.totalPages}
|
||||
value={state.page}
|
||||
onChange={(p) => state.load(p, 10)}
|
||||
/>
|
||||
</Center>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default PenjualanUmkm;
|
||||
111
src/app/admin/(dashboard)/ekonomi/umkm/produk/page.tsx
Normal file
111
src/app/admin/(dashboard)/ekonomi/umkm/produk/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Pagination,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
TextInput,
|
||||
Badge
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||
import { IconPlus, IconSearch, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import umkmState from '../../../_state/ekonomi/umkm/umkm';
|
||||
|
||||
function ProdukUmkm() {
|
||||
const [search, setSearch] = useState("");
|
||||
const state = useProxy(umkmState.produk.findMany);
|
||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||
|
||||
useShallowEffect(() => {
|
||||
state.load(state.page, 10, debouncedSearch);
|
||||
}, [state.page, debouncedSearch]);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>Daftar Produk UMKM</Title>
|
||||
<Button leftSection={<IconPlus size={18} />} color="blue">
|
||||
Tambah Produk
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<TextInput
|
||||
placeholder="Cari nama produk..."
|
||||
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 Produk</TableTh>
|
||||
<TableTh>UMKM</TableTh>
|
||||
<TableTh>Harga</TableTh>
|
||||
<TableTh>Stok</TableTh>
|
||||
<TableTh>Status Stok</TableTh>
|
||||
<TableTh>Aksi</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{state.data.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd fw={500}>{item.nama}</TableTd>
|
||||
<TableTd>{item.umkm?.nama || '-'}</TableTd>
|
||||
<TableTd>Rp {item.harga.toLocaleString()}</TableTd>
|
||||
<TableTd>{item.stok}</TableTd>
|
||||
<TableTd>
|
||||
<Badge color={item.stok < 5 ? 'red' : item.stok < 20 ? 'yellow' : 'green'}>
|
||||
{item.stok < 5 ? 'Rendah' : item.stok < 20 ? 'Menipis' : 'Aman'}
|
||||
</Badge>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" color="blue" size="xs">
|
||||
<IconEdit size={16} />
|
||||
</Button>
|
||||
<Button variant="subtle" color="red" size="xs">
|
||||
<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>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProdukUmkm;
|
||||
@@ -206,6 +206,26 @@ export const devBar = [
|
||||
name: "Ekonomi",
|
||||
path: "",
|
||||
children: [
|
||||
{
|
||||
id: "Ekonomi_UMKM_1",
|
||||
name: "UMKM - Dashboard",
|
||||
path: "/admin/ekonomi/umkm/dashboard"
|
||||
},
|
||||
{
|
||||
id: "Ekonomi_UMKM_2",
|
||||
name: "UMKM - Data UMKM",
|
||||
path: "/admin/ekonomi/umkm/data-umkm"
|
||||
},
|
||||
{
|
||||
id: "Ekonomi_UMKM_3",
|
||||
name: "UMKM - Produk",
|
||||
path: "/admin/ekonomi/umkm/produk"
|
||||
},
|
||||
{
|
||||
id: "Ekonomi_UMKM_4",
|
||||
name: "UMKM - Penjualan",
|
||||
path: "/admin/ekonomi/umkm/penjualan"
|
||||
},
|
||||
{
|
||||
id: "Ekonomi_1",
|
||||
name: "Pasar Desa",
|
||||
@@ -637,6 +657,26 @@ export const navBar = [
|
||||
name: "Ekonomi",
|
||||
path: "",
|
||||
children: [
|
||||
{
|
||||
id: "Ekonomi_UMKM_1",
|
||||
name: "UMKM - Dashboard",
|
||||
path: "/admin/ekonomi/umkm/dashboard"
|
||||
},
|
||||
{
|
||||
id: "Ekonomi_UMKM_2",
|
||||
name: "UMKM - Data UMKM",
|
||||
path: "/admin/ekonomi/umkm/data-umkm"
|
||||
},
|
||||
{
|
||||
id: "Ekonomi_UMKM_3",
|
||||
name: "UMKM - Produk",
|
||||
path: "/admin/ekonomi/umkm/produk"
|
||||
},
|
||||
{
|
||||
id: "Ekonomi_UMKM_4",
|
||||
name: "UMKM - Penjualan",
|
||||
path: "/admin/ekonomi/umkm/penjualan"
|
||||
},
|
||||
{
|
||||
id: "Ekonomi_1",
|
||||
name: "Pasar Desa",
|
||||
@@ -1026,6 +1066,26 @@ export const role1 = [
|
||||
name: "Ekonomi",
|
||||
path: "",
|
||||
children: [
|
||||
{
|
||||
id: "Ekonomi_UMKM_1",
|
||||
name: "UMKM - Dashboard",
|
||||
path: "/admin/ekonomi/umkm/dashboard"
|
||||
},
|
||||
{
|
||||
id: "Ekonomi_UMKM_2",
|
||||
name: "UMKM - Data UMKM",
|
||||
path: "/admin/ekonomi/umkm/data-umkm"
|
||||
},
|
||||
{
|
||||
id: "Ekonomi_UMKM_3",
|
||||
name: "UMKM - Produk",
|
||||
path: "/admin/ekonomi/umkm/produk"
|
||||
},
|
||||
{
|
||||
id: "Ekonomi_UMKM_4",
|
||||
name: "UMKM - Penjualan",
|
||||
path: "/admin/ekonomi/umkm/penjualan"
|
||||
},
|
||||
{
|
||||
id: "Ekonomi_1",
|
||||
name: "Pasar Desa",
|
||||
|
||||
@@ -11,6 +11,10 @@ import DemografiPekerjaan from "./demografi-pekerjaan";
|
||||
import JumlahPengangguran from "./jumlah-pengangguran";
|
||||
import PendapatanAsliDesa from "./pendapatan-asli-desa";
|
||||
import StrukturOrganisasi from "./struktur-bumdes";
|
||||
import Umkm from "./umkm";
|
||||
import ProdukUmkm from "./umkm/produk";
|
||||
import PenjualanProduk from "./umkm/penjualan";
|
||||
import UmkmDashboard from "./umkm/dashboard";
|
||||
|
||||
const Ekonomi = new Elysia({
|
||||
prefix: "/ekonomi",
|
||||
@@ -21,6 +25,10 @@ const Ekonomi = new Elysia({
|
||||
.use(LowonganKerja)
|
||||
.use(ProgramKemiskinan)
|
||||
.use(StrukturOrganisasi)
|
||||
.use(Umkm)
|
||||
.use(ProdukUmkm)
|
||||
.use(PenjualanProduk)
|
||||
.use(UmkmDashboard)
|
||||
.use(GrafikUsiaKerjaYangMenganggur)
|
||||
.use(GrafikMenganggurBerdasarkanPendidikan)
|
||||
.use(JumlahPendudukMiskin)
|
||||
|
||||
35
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/create.ts
Normal file
35
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/create.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function umkmCreate(context: Context) {
|
||||
const body = context.body as any;
|
||||
|
||||
try {
|
||||
const data = await prisma.umkm.create({
|
||||
data: {
|
||||
nama: body.nama,
|
||||
pemilik: body.pemilik,
|
||||
deskripsi: body.deskripsi,
|
||||
alamat: body.alamat,
|
||||
kontak: body.kontak,
|
||||
imageId: body.imageId,
|
||||
kategoriId: body.kategoriId,
|
||||
isActive: body.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil membuat UMKM baru",
|
||||
data,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di umkmCreate:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal membuat UMKM baru",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default umkmCreate;
|
||||
@@ -0,0 +1,72 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function umkmDashboardDetailPenjualan(context: Context) {
|
||||
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')}`;
|
||||
|
||||
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 }
|
||||
}),
|
||||
prisma.penjualanProduk.groupBy({
|
||||
by: ['produkId'],
|
||||
where: { periode: periodeLalu, deletedAt: null },
|
||||
_sum: { totalNilai: true }
|
||||
}),
|
||||
prisma.produkUmkm.findMany({
|
||||
where: { deletedAt: null },
|
||||
select: { id: true, nama: true, stok: true }
|
||||
})
|
||||
]);
|
||||
|
||||
const data = allProduks.map(p => {
|
||||
const skrgRaw = produkSkrg.find(s => s.produkId === p.id)?._sum || { totalNilai: 0, jumlah: 0 };
|
||||
const laluRaw = produkLalu.find(l => l.produkId === p.id)?._sum || { totalNilai: 0 };
|
||||
|
||||
const skrg = {
|
||||
totalNilai: skrgRaw.totalNilai || 0,
|
||||
jumlah: skrgRaw.jumlah || 0
|
||||
};
|
||||
const lalu = {
|
||||
totalNilai: laluRaw.totalNilai || 0
|
||||
};
|
||||
|
||||
let trend = "stable";
|
||||
if (skrg.totalNilai > lalu.totalNilai) trend = "up";
|
||||
if (skrg.totalNilai < lalu.totalNilai) trend = "down";
|
||||
|
||||
let statusStok = "Aman";
|
||||
if (p.stok < 5) statusStok = "Rendah";
|
||||
else if (p.stok < 20) statusStok = "Menipis";
|
||||
|
||||
return {
|
||||
namaProduk: p.nama,
|
||||
penjualanBulanIni: skrg.totalNilai,
|
||||
penjualanBulanLalu: lalu.totalNilai,
|
||||
trend,
|
||||
volume: skrg.jumlah,
|
||||
stok: p.stok,
|
||||
statusStok
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di umkmDashboardDetailPenjualan:", e);
|
||||
return { success: false, message: "Gagal mengambil detail penjualan dashboard" };
|
||||
}
|
||||
}
|
||||
|
||||
export default umkmDashboardDetailPenjualan;
|
||||
@@ -0,0 +1,16 @@
|
||||
import Elysia from "elysia";
|
||||
import umkmDashboardKpi from "./kpi";
|
||||
import umkmDashboardRingSummary from "./ringSummary";
|
||||
import umkmDashboardTopProduk from "./topProduk";
|
||||
import umkmDashboardDetailPenjualan from "./detailPenjualan";
|
||||
|
||||
const UmkmDashboard = new Elysia({
|
||||
prefix: "/umkm/dashboard",
|
||||
tags: ["Ekonomi/UMKM Dashboard"],
|
||||
})
|
||||
.get("/kpi", umkmDashboardKpi)
|
||||
.get("/ringkasan-penjualan", umkmDashboardRingSummary)
|
||||
.get("/top-produk", umkmDashboardTopProduk)
|
||||
.get("/detail-penjualan", umkmDashboardDetailPenjualan);
|
||||
|
||||
export default UmkmDashboard;
|
||||
49
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/dashboard/kpi.ts
Normal file
49
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/dashboard/kpi.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function umkmDashboardKpi(context: Context) {
|
||||
const periode = (context.query.periode as string) ||
|
||||
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
try {
|
||||
const [umkmAktif, totalUmkm, omzetBulanan, kategoriTerbanyak] = 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 }
|
||||
}),
|
||||
prisma.umkm.groupBy({
|
||||
by: ['kategoriId'],
|
||||
_count: { _all: true },
|
||||
orderBy: { _count: { kategoriId: 'desc' } },
|
||||
take: 1
|
||||
})
|
||||
]);
|
||||
|
||||
// Ambil nama kategori jika ada
|
||||
let kategoriNama = "-";
|
||||
if (kategoriTerbanyak.length > 0) {
|
||||
const kat = await prisma.kategoriProduk.findUnique({
|
||||
where: { id: kategoriTerbanyak[0].kategoriId },
|
||||
select: { nama: true }
|
||||
});
|
||||
kategoriNama = kat?.nama || "-";
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
umkmAktif,
|
||||
totalUmkm,
|
||||
omzetBulanan: omzetBulanan._sum.totalNilai || 0,
|
||||
kategoriTerbanyak: kategoriNama
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di umkmDashboardKpi:", e);
|
||||
return { success: false, message: "Gagal mengambil data KPI dashboard" };
|
||||
}
|
||||
}
|
||||
|
||||
export default umkmDashboardKpi;
|
||||
@@ -0,0 +1,52 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function umkmDashboardRingSummary(context: Context) {
|
||||
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')}`;
|
||||
|
||||
try {
|
||||
const [penjualanSkrg, penjualanLalu, produkAktif, totalTransaksi] = await Promise.all([
|
||||
prisma.penjualanProduk.aggregate({
|
||||
where: { periode, deletedAt: null },
|
||||
_sum: { totalNilai: true }
|
||||
}),
|
||||
prisma.penjualanProduk.aggregate({
|
||||
where: { periode: periodeLalu, deletedAt: null },
|
||||
_sum: { totalNilai: true }
|
||||
}),
|
||||
prisma.produkUmkm.count({ where: { isActive: true, deletedAt: null } }),
|
||||
prisma.penjualanProduk.count({ where: { periode, deletedAt: null } })
|
||||
]);
|
||||
|
||||
const skrg = penjualanSkrg._sum.totalNilai || 0;
|
||||
const lalu = penjualanLalu._sum.totalNilai || 0;
|
||||
|
||||
let persentasePerubahan = 0;
|
||||
if (lalu > 0) {
|
||||
persentasePerubahan = ((skrg - lalu) / lalu) * 100;
|
||||
} else if (skrg > 0) {
|
||||
persentasePerubahan = 100;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalPenjualan: skrg,
|
||||
persentasePerubahan: Math.round(persentasePerubahan * 100) / 100,
|
||||
produkAktif,
|
||||
totalTransaksi
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di umkmDashboardRingSummary:", e);
|
||||
return { success: false, message: "Gagal mengambil ringkasan dashboard" };
|
||||
}
|
||||
}
|
||||
|
||||
export default umkmDashboardRingSummary;
|
||||
@@ -0,0 +1,42 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function umkmDashboardTopProduk(context: Context) {
|
||||
const periode = (context.query.periode as string) ||
|
||||
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
try {
|
||||
const topPenjualan = await prisma.penjualanProduk.groupBy({
|
||||
by: ['produkId'],
|
||||
where: { periode, deletedAt: null },
|
||||
_sum: { totalNilai: true, jumlah: true },
|
||||
orderBy: { _sum: { totalNilai: 'desc' } },
|
||||
take: 3
|
||||
});
|
||||
|
||||
const data = await Promise.all(topPenjualan.map(async (item) => {
|
||||
const produk = await prisma.produkUmkm.findUnique({
|
||||
where: { id: item.produkId },
|
||||
include: { umkm: true }
|
||||
});
|
||||
|
||||
return {
|
||||
namaProduk: produk?.nama || "Unknown",
|
||||
namaUmkm: produk?.umkm.nama || "Unknown",
|
||||
totalPenjualan: item._sum.totalNilai || 0,
|
||||
jumlahTerjual: item._sum.jumlah || 0,
|
||||
growth: 0 // logic growth bisa ditambah jika diperlukan
|
||||
};
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di umkmDashboardTopProduk:", e);
|
||||
return { success: false, message: "Gagal mengambil top produk" };
|
||||
}
|
||||
}
|
||||
|
||||
export default umkmDashboardTopProduk;
|
||||
31
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/del.ts
Normal file
31
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/del.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function umkmDelete(context: Context) {
|
||||
const id = context.params.id;
|
||||
|
||||
try {
|
||||
// Soft delete
|
||||
const data = await prisma.umkm.update({
|
||||
where: { id },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
isActive: false,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil menghapus UMKM",
|
||||
data,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di umkmDelete:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal menghapus UMKM",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default umkmDelete;
|
||||
59
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/findMany.ts
Normal file
59
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/findMany.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function umkmFindMany(context: Context) {
|
||||
const page = Number(context.query.page) || 1;
|
||||
const limit = Number(context.query.limit) || 10;
|
||||
const search = (context.query.search as string) || '';
|
||||
const kategoriId = context.query.kategoriId as string | undefined;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = { deletedAt: null };
|
||||
|
||||
if (kategoriId) {
|
||||
where.kategoriId = kategoriId;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ nama: { contains: search, mode: 'insensitive' } },
|
||||
{ pemilik: { contains: search, mode: 'insensitive' } },
|
||||
{ alamat: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.umkm.findMany({
|
||||
where,
|
||||
include: {
|
||||
image: true,
|
||||
kategori: true,
|
||||
},
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.umkm.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil mengambil data UMKM",
|
||||
data,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di umkmFindMany:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal mengambil data UMKM",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default umkmFindMany;
|
||||
31
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/findManyAll.ts
Normal file
31
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/findManyAll.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
async function umkmFindManyAll() {
|
||||
try {
|
||||
const data = await prisma.umkm.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
nama: true,
|
||||
},
|
||||
orderBy: { nama: 'asc' },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil mengambil semua UMKM aktif",
|
||||
data,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di umkmFindManyAll:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal mengambil data UMKM",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default umkmFindManyAll;
|
||||
41
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/findUnique.ts
Normal file
41
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/findUnique.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function umkmFindUnique(context: Context) {
|
||||
const id = context.params.id;
|
||||
|
||||
try {
|
||||
const data = await prisma.umkm.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
image: true,
|
||||
kategori: true,
|
||||
produk: {
|
||||
where: { deletedAt: null },
|
||||
include: { image: true }
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
success: false,
|
||||
message: "UMKM tidak ditemukan",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil mengambil detail UMKM",
|
||||
data,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di umkmFindUnique:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal mengambil detail UMKM",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default umkmFindUnique;
|
||||
53
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/index.ts
Normal file
53
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import umkmCreate from "./create";
|
||||
import umkmDelete from "./del";
|
||||
import umkmFindMany from "./findMany";
|
||||
import umkmFindManyAll from "./findManyAll";
|
||||
import umkmFindUnique from "./findUnique";
|
||||
import umkmUpdate from "./updt";
|
||||
|
||||
const Umkm = new Elysia({
|
||||
prefix: "/umkm",
|
||||
tags: ["Ekonomi/UMKM"],
|
||||
})
|
||||
.get("/find-many", umkmFindMany)
|
||||
.get("/find-many-all", umkmFindManyAll)
|
||||
.get("/:id", umkmFindUnique, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
})
|
||||
.post("/create", umkmCreate, {
|
||||
body: t.Object({
|
||||
nama: t.String(),
|
||||
pemilik: t.String(),
|
||||
kategoriId: t.String(),
|
||||
deskripsi: t.Optional(t.String()),
|
||||
alamat: t.Optional(t.String()),
|
||||
kontak: t.Optional(t.String()),
|
||||
imageId: t.Optional(t.String()),
|
||||
isActive: t.Optional(t.Boolean()),
|
||||
}),
|
||||
})
|
||||
.put("/:id", umkmUpdate, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
body: t.Object({
|
||||
nama: t.String(),
|
||||
pemilik: t.String(),
|
||||
kategoriId: t.String(),
|
||||
deskripsi: t.Optional(t.String()),
|
||||
alamat: t.Optional(t.String()),
|
||||
kontak: t.Optional(t.String()),
|
||||
imageId: t.Optional(t.String()),
|
||||
isActive: t.Boolean(),
|
||||
}),
|
||||
})
|
||||
.delete("/del/:id", umkmDelete, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
});
|
||||
|
||||
export default Umkm;
|
||||
@@ -0,0 +1,56 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function penjualanProdukCreate(context: Context) {
|
||||
const body = context.body as any;
|
||||
const tanggal = body.tanggal ? new Date(body.tanggal) : new Date();
|
||||
|
||||
// Format periode YYYY-MM
|
||||
const periode = `${tanggal.getFullYear()}-${String(tanggal.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
const totalNilai = body.jumlah * body.hargaSatuan;
|
||||
|
||||
try {
|
||||
// Gunakan transaction untuk update stok produk
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 1. Catat penjualan
|
||||
const penjualan = await tx.penjualanProduk.create({
|
||||
data: {
|
||||
produkId: body.produkId,
|
||||
jumlah: body.jumlah,
|
||||
hargaSatuan: body.hargaSatuan,
|
||||
totalNilai: totalNilai,
|
||||
tanggal: tanggal,
|
||||
periode: periode,
|
||||
isActive: body.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Update stok produk
|
||||
await tx.produkUmkm.update({
|
||||
where: { id: body.produkId },
|
||||
data: {
|
||||
stok: {
|
||||
decrement: body.jumlah
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return penjualan;
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil mencatat penjualan produk",
|
||||
data: result,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di penjualanProdukCreate:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal mencatat penjualan produk",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default penjualanProdukCreate;
|
||||
53
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/penjualan/del.ts
Normal file
53
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/penjualan/del.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function penjualanProdukDelete(context: Context) {
|
||||
const id = context.params.id;
|
||||
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 1. Ambil data penjualan
|
||||
const data = await tx.penjualanProduk.findUnique({
|
||||
where: { id },
|
||||
select: { jumlah: true, produkId: true }
|
||||
});
|
||||
|
||||
if (!data) throw new Error("Data penjualan tidak ditemukan");
|
||||
|
||||
// 2. Soft delete
|
||||
const deleted = await tx.penjualanProduk.update({
|
||||
where: { id },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
isActive: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Kembalikan stok produk
|
||||
await tx.produkUmkm.update({
|
||||
where: { id: data.produkId },
|
||||
data: {
|
||||
stok: {
|
||||
increment: data.jumlah
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return deleted;
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil menghapus data penjualan dan mengembalikan stok",
|
||||
data: result,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di penjualanProdukDelete:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal menghapus data penjualan",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default penjualanProdukDelete;
|
||||
@@ -0,0 +1,58 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function penjualanProdukFindMany(context: Context) {
|
||||
const page = Number(context.query.page) || 1;
|
||||
const limit = Number(context.query.limit) || 10;
|
||||
const produkId = context.query.produkId as string | undefined;
|
||||
const periode = context.query.periode as string | undefined; // YYYY-MM
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = { deletedAt: null };
|
||||
|
||||
if (produkId) {
|
||||
where.produkId = produkId;
|
||||
}
|
||||
|
||||
if (periode) {
|
||||
where.periode = periode;
|
||||
}
|
||||
|
||||
try {
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.penjualanProduk.findMany({
|
||||
where,
|
||||
include: {
|
||||
produk: {
|
||||
include: {
|
||||
umkm: true
|
||||
}
|
||||
}
|
||||
},
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { tanggal: 'desc' },
|
||||
}),
|
||||
prisma.penjualanProduk.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil mengambil data penjualan produk",
|
||||
data,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di penjualanProdukFindMany:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal mengambil data penjualan produk",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default penjualanProdukFindMany;
|
||||
@@ -0,0 +1,40 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function penjualanProdukFindUnique(context: Context) {
|
||||
const id = context.params.id;
|
||||
|
||||
try {
|
||||
const data = await prisma.penjualanProduk.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
produk: {
|
||||
include: {
|
||||
umkm: true
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Data penjualan tidak ditemukan",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil mengambil detail penjualan produk",
|
||||
data,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di penjualanProdukFindUnique:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal mengambil detail penjualan produk",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default penjualanProdukFindUnique;
|
||||
@@ -0,0 +1,45 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import penjualanProdukCreate from "./create";
|
||||
import penjualanProdukDelete from "./del";
|
||||
import penjualanProdukFindMany from "./findMany";
|
||||
import penjualanProdukFindUnique from "./findUnique";
|
||||
import penjualanProdukUpdate from "./updt";
|
||||
|
||||
const PenjualanProduk = new Elysia({
|
||||
prefix: "/umkm/penjualan",
|
||||
tags: ["Ekonomi/UMKM Penjualan"],
|
||||
})
|
||||
.get("/find-many", penjualanProdukFindMany)
|
||||
.get("/:id", penjualanProdukFindUnique, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
})
|
||||
.post("/create", penjualanProdukCreate, {
|
||||
body: t.Object({
|
||||
produkId: t.String(),
|
||||
jumlah: t.Number(),
|
||||
hargaSatuan: t.Number(),
|
||||
tanggal: t.Optional(t.String()),
|
||||
isActive: t.Optional(t.Boolean()),
|
||||
}),
|
||||
})
|
||||
.put("/:id", penjualanProdukUpdate, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
body: t.Object({
|
||||
produkId: t.String(),
|
||||
jumlah: t.Number(),
|
||||
hargaSatuan: t.Number(),
|
||||
tanggal: t.Optional(t.String()),
|
||||
isActive: t.Boolean(),
|
||||
}),
|
||||
})
|
||||
.delete("/del/:id", penjualanProdukDelete, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
});
|
||||
|
||||
export default PenjualanProduk;
|
||||
83
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/penjualan/updt.ts
Normal file
83
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/penjualan/updt.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function penjualanProdukUpdate(context: Context) {
|
||||
const body = context.body as any;
|
||||
const id = context.params.id;
|
||||
const tanggal = body.tanggal ? new Date(body.tanggal) : new Date();
|
||||
const periode = `${tanggal.getFullYear()}-${String(tanggal.getMonth() + 1).padStart(2, '0')}`;
|
||||
const totalNilai = body.jumlah * body.hargaSatuan;
|
||||
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 1. Ambil data lama untuk hitung selisih stok
|
||||
const oldData = await tx.penjualanProduk.findUnique({
|
||||
where: { id },
|
||||
select: { jumlah: true, produkId: true }
|
||||
});
|
||||
|
||||
if (!oldData) throw new Error("Data penjualan tidak ditemukan");
|
||||
|
||||
// 2. Update penjualan
|
||||
const updated = await tx.penjualanProduk.update({
|
||||
where: { id },
|
||||
data: {
|
||||
produkId: body.produkId,
|
||||
jumlah: body.jumlah,
|
||||
hargaSatuan: body.hargaSatuan,
|
||||
totalNilai: totalNilai,
|
||||
tanggal: tanggal,
|
||||
periode: periode,
|
||||
isActive: body.isActive,
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Update stok jika produk sama, sesuaikan selisih
|
||||
if (oldData.produkId === body.produkId) {
|
||||
const diff = body.jumlah - oldData.jumlah;
|
||||
await tx.produkUmkm.update({
|
||||
where: { id: body.produkId },
|
||||
data: {
|
||||
stok: {
|
||||
decrement: diff
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Jika produk berubah, kembalikan stok lama dan kurangi stok baru
|
||||
await tx.produkUmkm.update({
|
||||
where: { id: oldData.produkId },
|
||||
data: {
|
||||
stok: {
|
||||
increment: oldData.jumlah
|
||||
}
|
||||
}
|
||||
});
|
||||
await tx.produkUmkm.update({
|
||||
where: { id: body.produkId },
|
||||
data: {
|
||||
stok: {
|
||||
decrement: body.jumlah
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil memperbarui data penjualan",
|
||||
data: result,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di penjualanProdukUpdate:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal memperbarui data penjualan",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default penjualanProdukUpdate;
|
||||
34
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/produk/create.ts
Normal file
34
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/produk/create.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function produkUmkmCreate(context: Context) {
|
||||
const body = context.body as any;
|
||||
|
||||
try {
|
||||
const data = await prisma.produkUmkm.create({
|
||||
data: {
|
||||
nama: body.nama,
|
||||
harga: body.harga,
|
||||
stok: body.stok ?? 0,
|
||||
deskripsi: body.deskripsi,
|
||||
umkmId: body.umkmId,
|
||||
imageId: body.imageId,
|
||||
isActive: body.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil membuat produk UMKM baru",
|
||||
data,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di produkUmkmCreate:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal membuat produk UMKM baru",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default produkUmkmCreate;
|
||||
31
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/produk/del.ts
Normal file
31
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/produk/del.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function produkUmkmDelete(context: Context) {
|
||||
const id = context.params.id;
|
||||
|
||||
try {
|
||||
// Soft delete
|
||||
const data = await prisma.produkUmkm.update({
|
||||
where: { id },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
isActive: false,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil menghapus produk UMKM",
|
||||
data,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di produkUmkmDelete:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal menghapus produk UMKM",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default produkUmkmDelete;
|
||||
@@ -0,0 +1,64 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function produkUmkmFindMany(context: Context) {
|
||||
const page = Number(context.query.page) || 1;
|
||||
const limit = Number(context.query.limit) || 10;
|
||||
const search = (context.query.search as string) || '';
|
||||
const umkmId = context.query.umkmId as string | undefined;
|
||||
const kategoriId = context.query.kategoriId as string | undefined;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = { deletedAt: null };
|
||||
|
||||
if (umkmId) {
|
||||
where.umkmId = umkmId;
|
||||
}
|
||||
|
||||
if (kategoriId) {
|
||||
where.umkm = {
|
||||
kategoriId: kategoriId
|
||||
};
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.nama = { contains: search, mode: 'insensitive' };
|
||||
}
|
||||
|
||||
try {
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.produkUmkm.findMany({
|
||||
where,
|
||||
include: {
|
||||
image: true,
|
||||
umkm: {
|
||||
include: { kategori: true }
|
||||
}
|
||||
},
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.produkUmkm.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil mengambil data produk UMKM",
|
||||
data,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di produkUmkmFindMany:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal mengambil data produk UMKM",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default produkUmkmFindMany;
|
||||
@@ -0,0 +1,42 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function produkUmkmFindUnique(context: Context) {
|
||||
const id = context.params.id;
|
||||
|
||||
try {
|
||||
const data = await prisma.produkUmkm.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
image: true,
|
||||
umkm: true,
|
||||
penjualan: {
|
||||
where: { deletedAt: null },
|
||||
orderBy: { tanggal: 'desc' },
|
||||
take: 10
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Produk UMKM tidak ditemukan",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil mengambil detail produk UMKM",
|
||||
data,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di produkUmkmFindUnique:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal mengambil detail produk UMKM",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default produkUmkmFindUnique;
|
||||
49
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/produk/index.ts
Normal file
49
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/produk/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import produkUmkmCreate from "./create";
|
||||
import produkUmkmDelete from "./del";
|
||||
import produkUmkmFindMany from "./findMany";
|
||||
import produkUmkmFindUnique from "./findUnique";
|
||||
import produkUmkmUpdate from "./updt";
|
||||
|
||||
const ProdukUmkm = new Elysia({
|
||||
prefix: "/umkm/produk",
|
||||
tags: ["Ekonomi/UMKM Produk"],
|
||||
})
|
||||
.get("/find-many", produkUmkmFindMany)
|
||||
.get("/:id", produkUmkmFindUnique, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
})
|
||||
.post("/create", produkUmkmCreate, {
|
||||
body: t.Object({
|
||||
nama: t.String(),
|
||||
harga: t.Number(),
|
||||
umkmId: t.String(),
|
||||
stok: t.Optional(t.Number()),
|
||||
deskripsi: t.Optional(t.String()),
|
||||
imageId: t.Optional(t.String()),
|
||||
isActive: t.Optional(t.Boolean()),
|
||||
}),
|
||||
})
|
||||
.put("/:id", produkUmkmUpdate, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
body: t.Object({
|
||||
nama: t.String(),
|
||||
harga: t.Number(),
|
||||
umkmId: t.String(),
|
||||
stok: t.Number(),
|
||||
deskripsi: t.Optional(t.String()),
|
||||
imageId: t.Optional(t.String()),
|
||||
isActive: t.Boolean(),
|
||||
}),
|
||||
})
|
||||
.delete("/del/:id", produkUmkmDelete, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
});
|
||||
|
||||
export default ProdukUmkm;
|
||||
36
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/produk/updt.ts
Normal file
36
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/produk/updt.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function produkUmkmUpdate(context: Context) {
|
||||
const body = context.body as any;
|
||||
const id = context.params.id;
|
||||
|
||||
try {
|
||||
const data = await prisma.produkUmkm.update({
|
||||
where: { id },
|
||||
data: {
|
||||
nama: body.nama,
|
||||
harga: body.harga,
|
||||
stok: body.stok,
|
||||
deskripsi: body.deskripsi,
|
||||
umkmId: body.umkmId,
|
||||
imageId: body.imageId,
|
||||
isActive: body.isActive,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil memperbarui produk UMKM",
|
||||
data,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di produkUmkmUpdate:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal memperbarui produk UMKM",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default produkUmkmUpdate;
|
||||
37
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/updt.ts
Normal file
37
src/app/api/[[...slugs]]/_lib/ekonomi/umkm/updt.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function umkmUpdate(context: Context) {
|
||||
const body = context.body as any;
|
||||
const id = context.params.id;
|
||||
|
||||
try {
|
||||
const data = await prisma.umkm.update({
|
||||
where: { id },
|
||||
data: {
|
||||
nama: body.nama,
|
||||
pemilik: body.pemilik,
|
||||
deskripsi: body.deskripsi,
|
||||
alamat: body.alamat,
|
||||
kontak: body.kontak,
|
||||
imageId: body.imageId,
|
||||
kategoriId: body.kategoriId,
|
||||
isActive: body.isActive,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil memperbarui data UMKM",
|
||||
data,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di umkmUpdate:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal memperbarui data UMKM",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default umkmUpdate;
|
||||
@@ -79,7 +79,7 @@ const fileStorageCreate = async (context: Context) => {
|
||||
data: {
|
||||
name: finalName,
|
||||
realName: file.name,
|
||||
path: rootPath,
|
||||
path: pathName, // Store relative path (e.g., "images", "audio", "documents")
|
||||
mimeType: finalMimeType,
|
||||
category,
|
||||
link: `/api/fileStorage/findUnique/${finalName}`,
|
||||
|
||||
@@ -36,7 +36,7 @@ const fileStorageDelete = async (context: Context) => {
|
||||
};
|
||||
}
|
||||
|
||||
const filePath = path.join(file.path, file.name);
|
||||
const filePath = path.join(UPLOAD_DIR, file.path, file.name);
|
||||
|
||||
try {
|
||||
// Hapus file dari filesystem
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Context } from "elysia";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const UPLOAD_DIR = process.env.WIBU_UPLOAD_DIR;
|
||||
|
||||
const fileStorageFindUnique = async (context: Context) => {
|
||||
const { name } = context.params;
|
||||
|
||||
@@ -20,9 +22,17 @@ const fileStorageFindUnique = async (context: Context) => {
|
||||
};
|
||||
}
|
||||
|
||||
if (!UPLOAD_DIR) {
|
||||
context.set.status = "Internal Server Error";
|
||||
return {
|
||||
status: 500,
|
||||
message: "UPLOAD_DIR is not defined",
|
||||
};
|
||||
}
|
||||
|
||||
console.log(data);
|
||||
|
||||
const file = await fs.readFile(path.join(data.path, data.name));
|
||||
const file = await fs.readFile(path.join(UPLOAD_DIR, data.path, data.name));
|
||||
context.set.headers = {
|
||||
"Content-Type": data.mimeType,
|
||||
"Content-Length": file.length,
|
||||
|
||||
120
src/app/darmasaba/(pages)/ekonomi/umkm/[id]/page.tsx
Normal file
120
src/app/darmasaba/(pages)/ekonomi/umkm/[id]/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
import umkmState from '@/app/admin/(dashboard)/_state/ekonomi/umkm/umkm';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Card, Flex, Grid, GridCol, Image, Paper, Skeleton, Stack, Text, Title, Badge, SimpleGrid, Group, Divider, Button } from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconBrandWhatsapp, IconMapPinFilled, IconPackage, IconUser } from '@tabler/icons-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import BackButton from '../../../desa/layanan/_com/BackButto';
|
||||
|
||||
function Page() {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
const state = useProxy(umkmState.umkm.findUnique);
|
||||
|
||||
useShallowEffect(() => {
|
||||
if (id) state.load(id);
|
||||
}, [id]);
|
||||
|
||||
if (state.loading || !state.data) {
|
||||
return (
|
||||
<Stack py={10} px={{ base: 'md', md: 100 }}>
|
||||
<Skeleton h={400} radius="md" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const u = state.data;
|
||||
|
||||
return (
|
||||
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
<BackButton />
|
||||
</Box>
|
||||
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
<Grid gutter="xl">
|
||||
<GridCol span={{ base: 12, md: 4 }}>
|
||||
<Image
|
||||
radius="lg"
|
||||
src={u.image?.link || '/no-image.jpg'}
|
||||
alt={u.nama}
|
||||
w="100%"
|
||||
fallbackSrc="/no-image.jpg"
|
||||
/>
|
||||
</GridCol>
|
||||
<GridCol span={{ base: 12, md: 8 }}>
|
||||
<Stack gap="xs">
|
||||
<Badge size="lg" variant="light" color="blue">{u.kategori?.nama}</Badge>
|
||||
<Title order={1} fw="bold" c={colors['blue-button']}>{u.nama}</Title>
|
||||
<Group gap="xl" mt="sm">
|
||||
<Flex align="center" gap="xs">
|
||||
<IconUser size={20} color="gray" />
|
||||
<Text fw={500}>{u.pemilik}</Text>
|
||||
</Flex>
|
||||
<Flex align="center" gap="xs">
|
||||
<IconMapPinFilled size={20} color="red" />
|
||||
<Text>{u.alamat || 'Desa Darmasaba'}</Text>
|
||||
</Flex>
|
||||
</Group>
|
||||
<Divider my="md" />
|
||||
<Text ta="justify" lh={1.6}>{u.deskripsi || 'Tidak ada deskripsi tersedia.'}</Text>
|
||||
{u.kontak && (
|
||||
<Button
|
||||
mt="md"
|
||||
leftSection={<IconBrandWhatsapp size={20} />}
|
||||
color="green"
|
||||
radius="md"
|
||||
component="a"
|
||||
href={`https://wa.me/${u.kontak}`}
|
||||
target="_blank"
|
||||
w="fit-content"
|
||||
>
|
||||
Hubungi Pemilik
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</GridCol>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Box px={{ base: 'md', md: 100 }} mt={40}>
|
||||
<Title order={2} mb="xl">Katalog Produk</Title>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
|
||||
{u.produk?.map((p: any, k: number) => (
|
||||
<Card key={k} withBorder padding="lg" radius="md">
|
||||
<Card.Section>
|
||||
<Image
|
||||
src={p.image?.link || '/no-image.jpg'}
|
||||
height={160}
|
||||
alt={p.nama}
|
||||
fallbackSrc="/no-image.jpg"
|
||||
/>
|
||||
</Card.Section>
|
||||
<Group justify="space-between" mt="md" mb="xs">
|
||||
<Text fw={700} lineClamp={1}>{p.nama}</Text>
|
||||
<Badge color={p.stok > 0 ? 'green' : 'red'}>
|
||||
{p.stok > 0 ? 'Tersedia' : 'Habis'}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="lg" fw={700} c="blue">
|
||||
Rp {p.harga.toLocaleString('id-ID')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt="sm" lineClamp={2}>
|
||||
{p.deskripsi || 'Kualitas terbaik dari UMKM lokal.'}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{(!u.produk || u.produk.length === 0) && (
|
||||
<Center py={40}>
|
||||
<Text c="dimmed">Belum ada produk dari UMKM ini</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default Page;
|
||||
153
src/app/darmasaba/(pages)/ekonomi/umkm/page.tsx
Normal file
153
src/app/darmasaba/(pages)/ekonomi/umkm/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
import umkmState from '@/app/admin/(dashboard)/_state/ekonomi/umkm/umkm';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Center, Flex, Grid, GridCol, Image, Pagination, Paper, Select, SimpleGrid, Skeleton, Stack, Text, TextInput, Title, Badge } from '@mantine/core';
|
||||
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||
import { IconMapPinFilled, IconSearch, IconUser } from '@tabler/icons-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import BackButton from '../../desa/layanan/_com/BackButto';
|
||||
|
||||
function Page() {
|
||||
const router = useRouter()
|
||||
const state = useProxy(umkmState.umkm)
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
|
||||
const { data, page, loading, totalPages, load } = state.findMany
|
||||
|
||||
useShallowEffect(() => {
|
||||
umkmState.kategoriProduk.findManyAll.load()
|
||||
}, [])
|
||||
|
||||
useShallowEffect(() => {
|
||||
load(page, 8, debouncedSearch, selectedCategory || undefined)
|
||||
}, [page, debouncedSearch, selectedCategory])
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<Stack py={10}>
|
||||
<Skeleton h={500} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
<BackButton />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Grid align="center" px={{ base: 'md', md: 100 }}>
|
||||
<GridCol span={{ base: 12, md: 9 }}>
|
||||
<Title order={1} c={colors["blue-button"]} fw="bold" lh={1.15}>
|
||||
Direktori UMKM Desa Darmasaba
|
||||
</Title>
|
||||
</GridCol>
|
||||
<GridCol span={{ base: 12, md: 3 }}>
|
||||
<TextInput
|
||||
radius="lg"
|
||||
placeholder="Cari UMKM"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftSection={<IconSearch size={20} />}
|
||||
w={"100%"}
|
||||
/>
|
||||
</GridCol>
|
||||
</Grid>
|
||||
|
||||
<Text
|
||||
px={{ base: 'md', md: 100 }}
|
||||
pt={20}
|
||||
ta="justify"
|
||||
fz={{ base: 'sm', md: 'md' }}
|
||||
lh={{ base: 1.5, md: 1.55 }}
|
||||
c="black"
|
||||
>
|
||||
Daftar Usaha Mikro, Kecil, dan Menengah yang ada di wilayah Desa Darmasaba.
|
||||
Mendukung pertumbuhan ekonomi lokal melalui pemberdayaan pengusaha desa.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box px={{ base: "md", md: 100 }}>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid pb={10} cols={{ base: 1, md: 2 }}>
|
||||
<Box>
|
||||
<Select
|
||||
placeholder="Filter Kategori"
|
||||
data={umkmState.kategoriProduk.findManyAll.data?.map((v) => ({
|
||||
value: v.id,
|
||||
label: v.nama
|
||||
})) || []}
|
||||
value={selectedCategory}
|
||||
onChange={setSelectedCategory}
|
||||
clearable
|
||||
searchable
|
||||
nothingFoundMessage="Tidak ada kategori ditemukan"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
|
||||
{data?.map((v, k) => (
|
||||
<motion.div
|
||||
key={k}
|
||||
onClick={() => router.push(`/darmasaba/ekonomi/umkm/${v.id}`)}
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Paper p="md" radius="md" withBorder shadow="xs" style={{ cursor: 'pointer', height: '100%' }}>
|
||||
<Image
|
||||
radius="md"
|
||||
src={v.image?.link || '/no-image.jpg'}
|
||||
alt={v.nama}
|
||||
h={180}
|
||||
w="100%"
|
||||
style={{ objectFit: 'cover' }}
|
||||
fallbackSrc="/no-image.jpg"
|
||||
/>
|
||||
<Badge mt="md" variant="light" color="blue">
|
||||
{v.kategori?.nama}
|
||||
</Badge>
|
||||
<Text mt="xs" fw="bold" fz="lg" lh={1.2} lineClamp={1}>
|
||||
{v.nama}
|
||||
</Text>
|
||||
<Flex mt={8} gap="xs" align="center">
|
||||
<IconUser size={16} color="gray" />
|
||||
<Text fz="sm" c="dimmed">{v.pemilik}</Text>
|
||||
</Flex>
|
||||
<Flex mt={4} gap="xs" align="center">
|
||||
<IconMapPinFilled size={16} color="red" />
|
||||
<Text fz="sm" c="dimmed" lineClamp={1}>{v.alamat || 'Darmasaba'}</Text>
|
||||
</Flex>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{data.length === 0 && (
|
||||
<Center py={50}>
|
||||
<Text c="dimmed">Tidak ada UMKM ditemukan</Text>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
<Center>
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => load(newPage)}
|
||||
total={totalPages}
|
||||
my="md"
|
||||
/>
|
||||
</Center>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default Page;
|
||||
190
src/app/darmasaba/(pages)/ekonomi/umkm/produk/page.tsx
Normal file
190
src/app/darmasaba/(pages)/ekonomi/umkm/produk/page.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
'use client'
|
||||
import umkmState from '@/app/admin/(dashboard)/_state/ekonomi/umkm/umkm';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Center, Flex, Grid, GridCol, Image, Pagination, Paper, Select, SimpleGrid, Skeleton, Stack, Text, TextInput, Title, Badge, Card, Group } from '@mantine/core';
|
||||
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||
import { IconBuildingStore, IconSearch, IconTag } from '@tabler/icons-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import BackButton from '../../../desa/layanan/_com/BackButto';
|
||||
|
||||
function Page() {
|
||||
const router = useRouter()
|
||||
const state = useProxy(umkmState.produk)
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [selectedUmkm, setSelectedUmkm] = useState<string | null>(null);
|
||||
|
||||
const { data, page, loading, totalPages, load } = state.findMany
|
||||
|
||||
useShallowEffect(() => {
|
||||
umkmState.kategoriProduk.findManyAll.load()
|
||||
// Load all UMKM for filter
|
||||
fetch('/api/ekonomi/umkm/find-many-all')
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
(umkmState.umkm as any).allList = res.data;
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const allUmkm = useProxy(umkmState.umkm as any).allList || [];
|
||||
|
||||
useShallowEffect(() => {
|
||||
load(page, 12, debouncedSearch, selectedUmkm || undefined, selectedCategory || undefined)
|
||||
}, [page, debouncedSearch, selectedUmkm, selectedCategory])
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<Stack py={10}>
|
||||
<Skeleton h={500} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
<BackButton />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Grid align="center" px={{ base: 'md', md: 100 }}>
|
||||
<GridCol span={{ base: 12, md: 9 }}>
|
||||
<Title order={1} c={colors["blue-button"]} fw="bold" lh={1.15}>
|
||||
Katalog Produk UMKM
|
||||
</Title>
|
||||
</GridCol>
|
||||
<GridCol span={{ base: 12, md: 3 }}>
|
||||
<TextInput
|
||||
radius="lg"
|
||||
placeholder="Cari Produk..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
leftSection={<IconSearch size={20} />}
|
||||
w={"100%"}
|
||||
/>
|
||||
</GridCol>
|
||||
</Grid>
|
||||
|
||||
<Text
|
||||
px={{ base: 'md', md: 100 }}
|
||||
pt={20}
|
||||
ta="justify"
|
||||
fz={{ base: 'sm', md: 'md' }}
|
||||
lh={{ base: 1.5, md: 1.55 }}
|
||||
c="black"
|
||||
>
|
||||
Temukan berbagai produk unggulan dari pelaku UMKM di Desa Darmasaba.
|
||||
Mulai dari kuliner, kerajinan tangan, hingga jasa tersedia untuk memenuhi kebutuhan Anda.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box px={{ base: "md", md: 100 }}>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Select
|
||||
placeholder="Filter Berdasarkan UMKM"
|
||||
data={allUmkm.map((v: any) => ({ value: v.id, label: v.nama }))}
|
||||
value={selectedUmkm}
|
||||
onChange={setSelectedUmkm}
|
||||
clearable
|
||||
searchable
|
||||
nothingFoundMessage="UMKM tidak ditemukan"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Filter Kategori"
|
||||
data={umkmState.kategoriProduk.findManyAll.data?.map((v) => ({
|
||||
value: v.id,
|
||||
label: v.nama
|
||||
})) || []}
|
||||
value={selectedCategory}
|
||||
onChange={setSelectedCategory}
|
||||
clearable
|
||||
searchable
|
||||
nothingFoundMessage="Kategori tidak ditemukan"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
|
||||
{data?.map((v, k) => (
|
||||
<motion.div
|
||||
key={k}
|
||||
whileHover={{ y: -5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Card shadow="sm" padding="lg" radius="md" withBorder style={{ height: '100%' }}>
|
||||
<Card.Section>
|
||||
<Image
|
||||
src={v.image?.link || '/no-image.jpg'}
|
||||
height={180}
|
||||
alt={v.nama}
|
||||
fallbackSrc="/no-image.jpg"
|
||||
/>
|
||||
</Card.Section>
|
||||
|
||||
<Stack justify="space-between" mt="md" h="100%">
|
||||
<Box>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Badge color={v.stok > 0 ? 'teal' : 'red'} variant="light">
|
||||
{v.stok > 0 ? 'Tersedia' : 'Habis'}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">Stok: {v.stok}</Text>
|
||||
</Group>
|
||||
|
||||
<Text fw={700} fz="lg" lineClamp={1}>{v.nama}</Text>
|
||||
|
||||
<Flex align="center" gap={5} mt={5}>
|
||||
<IconBuildingStore size={14} color="gray" />
|
||||
<Text size="sm" c="blue" fw={500} style={{ cursor: 'pointer' }} onClick={() => router.push(`/darmasaba/ekonomi/umkm/${v.umkmId}`)}>
|
||||
{v.umkm?.nama}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex align="center" gap={5} mt={2}>
|
||||
<IconTag size={14} color="gray" />
|
||||
<Text size="xs" c="dimmed">{v.umkm?.kategori?.nama}</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<Box mt="md">
|
||||
<Text fz="xl" fw={800} c="orange">
|
||||
Rp {v.harga.toLocaleString('id-ID')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{data.length === 0 && (
|
||||
<Center py={80}>
|
||||
<Stack align="center">
|
||||
<Text c="dimmed" fz="lg">Produk tidak ditemukan</Text>
|
||||
<Text size="sm" c="dimmed">Coba gunakan kata kunci atau filter lain</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
<Center>
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => load(newPage)}
|
||||
total={totalPages}
|
||||
my="md"
|
||||
color="blue"
|
||||
radius="md"
|
||||
/>
|
||||
</Center>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default Page;
|
||||
@@ -182,41 +182,51 @@ const navbarListMenu = [
|
||||
},
|
||||
{
|
||||
id: "5.3",
|
||||
name: "Direktori UMKM",
|
||||
href: "/darmasaba/ekonomi/umkm"
|
||||
},
|
||||
{
|
||||
id: "5.4",
|
||||
name: "Produk UMKM",
|
||||
href: "/darmasaba/ekonomi/umkm/produk"
|
||||
},
|
||||
{
|
||||
id: "5.5",
|
||||
name: "Struktur Organisasi dan SK Pengurus BUMDesa",
|
||||
href: "/darmasaba/ekonomi/Struktur-Organisasi-Dan-Sk-Pengurus-BumDes"
|
||||
},
|
||||
{
|
||||
id: "5.4",
|
||||
id: "5.6",
|
||||
name: "PADesa (Pendapatan Asli Desa)",
|
||||
href: "/darmasaba/ekonomi/PADesa-pendapatan-asli-desa"
|
||||
},
|
||||
{
|
||||
id: "5.5",
|
||||
id: "5.7",
|
||||
name: "Jumlah Pengangguran",
|
||||
href: "/darmasaba/ekonomi/jumlah-pengangguran"
|
||||
},
|
||||
{
|
||||
id: "5.6",
|
||||
id: "5.8",
|
||||
name: "Jumlah penduduk usia kerja yang menganggur",
|
||||
href: "/darmasaba/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur"
|
||||
},
|
||||
{
|
||||
id: "5.7",
|
||||
id: "5.9",
|
||||
name: "Jumlah Penduduk Miskin",
|
||||
href: "/darmasaba/ekonomi/jumlah-penduduk-miskin"
|
||||
},
|
||||
{
|
||||
id: "5.8",
|
||||
id: "5.10",
|
||||
name: "Program Kemiskinan",
|
||||
href: "/darmasaba/ekonomi/program-kemiskinan"
|
||||
},
|
||||
{
|
||||
id: "5.9",
|
||||
id: "5.11",
|
||||
name: "Sektor Unggulan Desa",
|
||||
href: "/darmasaba/ekonomi/sektor-unggulan-desa"
|
||||
},
|
||||
{
|
||||
id: "5.10",
|
||||
id: "5.12",
|
||||
name: "Demografi Pekerjaan",
|
||||
href: "/darmasaba/ekonomi/demografi-pekerjaan"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user