Compare commits

..

6 Commits

36 changed files with 2301 additions and 290 deletions

274
GEMINI.md
View File

@@ -1,62 +1,244 @@
# Project: Desa Darmasaba
# Desa Darmasaba - Village Management System
## Project Overview
The `desa-darmasaba` project is a Next.js (version 15+) application developed with TypeScript. It serves as an official platform for Desa Darmasaba (a village in Badung, Bali), offering various public services, news, and detailed village profiles.
Desa Darmasaba is a comprehensive Next.js 15 application designed for village management services in Darmasaba, Badung, Bali. The application serves as a digital platform for government services, public information, and community engagement. It features multiple sections including PPID (Public Information Disclosure), health services, security, education, environment, economy, innovation, and more.
**Key Technologies:**
### Key Technologies
- **Framework**: Next.js 15 with App Router
- **Language**: TypeScript with strict mode
- **Styling**: Mantine UI components with custom CSS
- **Backend**: Elysia.js API server integrated with Next.js
- **Database**: PostgreSQL with Prisma ORM
- **State Management**: Valtio for global state
- **Authentication**: JWT with iron-session
* **Frontend Framework:** Next.js (v15+) with React (v19+)
* **Language:** TypeScript
* **UI Library:** Mantine UI
* **Database ORM:** Prisma (v6+)
* **Database:** PostgreSQL (as configured in `prisma/schema.prisma`)
* **API Framework:** Elysia (used for API routes, as seen in dependencies)
* **State Management:** Potentially Jotai and Valtio (listed in dependencies)
* **Image Processing:** Sharp
* **Package Manager:** Likely Bun, given `bun.lockb` and the `prisma:seed` script.
The application architecture follows the Next.js App Router structure, with comprehensive data models defined in `prisma/schema.prisma` covering various domains like public information, health, security, economy, innovation, environment, and education. It also includes configurations for image handling and caching.
### Architecture
The application follows a modular architecture with:
- A main frontend built with Next.js and Mantine UI
- An integrated Elysia.js API server for backend operations
- Prisma ORM for database interactions
- File storage integration with Seafile
- Multiple domain-specific modules (PPID, health, security, education, etc.)
## Building and Running
This project uses `bun` as the package manager. Ensure Bun is installed to run these commands.
### Prerequisites
- Node.js (with Bun runtime)
- PostgreSQL database
- Seafile server for file storage
* **Install Dependencies:**
```bash
bun install
```
### Setup Instructions
1. Install dependencies:
```bash
bun install
```
* **Development Server:**
Runs the Next.js development server.
```bash
bun run dev
```
2. Set up environment variables in `.env.local`:
```
DATABASE_URL=your_postgresql_connection_string
SEAFILE_TOKEN=your_seafile_token
SEAFILE_REPO_ID=your_seafile_repo_id
SEAFILE_BASE_URL=your_seafile_base_url
SEAFILE_PUBLIC_SHARE_TOKEN=your_seafile_public_share_token
SEAFILE_URL=your_seafile_api_url
WIBU_UPLOAD_DIR=your_upload_directory
```
* **Build for Production:**
Builds the Next.js application for production deployment.
```bash
bun run build
```
3. Generate Prisma client:
```bash
bunx prisma generate
```
* **Start Production Server:**
Starts the Next.js application in production mode.
```bash
bun run start
```
4. Push database schema:
```bash
bunx prisma db push
```
* **Database Seeding:**
Executes the Prisma seeding script to populate the database.
```bash
bun run prisma:seed
```
5. Seed the database:
```bash
bun run prisma/seed.ts
```
6. Run the development server:
```bash
bun run dev
```
### Available Scripts
- `bun run dev` - Start development server
- `bun run build` - Build for production
- `bun run start` - Start production server
- `bun run prisma/seed.ts` - Run database seeding
- `bunx prisma generate` - Generate Prisma client
- `bunx prisma db push` - Push schema changes to database
- `bunx prisma studio` - Open Prisma Studio GUI
## Development Conventions
* **Coding Language:** TypeScript is strictly enforced.
* **Frontend Framework:** Next.js App Router for page and component structuring.
* **UI/UX:** Adherence to Mantine UI component library for consistent styling and user experience.
* **Database Interaction:** Prisma ORM is used for all database operations, with a PostgreSQL database.
* **Linting:** ESLint is configured with `next/core-web-vitals` and `next/typescript` to maintain code quality and adherence to Next.js and TypeScript best practices.
* **Styling:** PostCSS is used, with `postcss-preset-mantine` and `postcss-simple-vars` defining Mantine-specific breakpoints and other CSS variables.
* **Imports:** Absolute imports are configured using `@/*` which resolves to the `src/` directory.
### Code Structure
```
src/
├── app/ # Next.js app router pages
├── admin/ # Admin dashboard pages
├── api/ # API routes with Elysia.js
├── darmasaba/ # Public-facing village pages
│ └── ...
├── con/ # Constants and configuration
├── hooks/ # React hooks
├── lib/ # Utility functions and configurations
├── middlewares/ # Next.js middleware
├── state/ # Global state management
├── store/ # Additional state management
├── types/ # TypeScript type definitions
└── utils/ # Utility functions
```
### Import Conventions
- Use absolute imports with `@/` alias (configured in tsconfig.json)
- Group imports: external libraries first, then internal modules
- Keep import statements organized and remove unused imports
```typescript
// External libraries
import { useState } from 'react'
import { Button, Stack } from '@mantine/core'
// Internal modules
import ApiFetch from '@/lib/api-fetch'
import { MyComponent } from '@/components/my-component'
```
### TypeScript Configuration
- Strict mode enabled (`"strict": true`)
- Target: ES2017
- Module resolution: bundler
- Path alias: `@/*` maps to `./src/*`
### Naming Conventions
- **Components**: PascalCase (e.g., `UploadImage.tsx`)
- **Files**: kebab-case for utilities (e.g., `api-fetch.ts`)
- **Variables/Functions**: camelCase
- **Constants**: UPPER_SNAKE_CASE
- **Database Models**: PascalCase (Prisma convention)
### Error Handling
- Use try-catch blocks for async operations
- Implement proper error boundaries in React components
- Log errors appropriately without exposing sensitive data
- Use Zod for runtime validation and type safety
### API Structure
- Backend uses Elysia.js with TypeScript
- API routes are in `src/app/api/[[...slugs]]/` directory
- Use treaty client for type-safe API calls
- Follow RESTful conventions for endpoints
- Include proper HTTP status codes and error responses
### Database Operations
- Use Prisma client from `@/lib/prisma.ts`
- Database connection includes graceful shutdown handling
- Use transactions for complex operations
- Implement proper error handling for database queries
### Component Guidelines
- Use functional components with hooks
- Implement proper prop types with TypeScript interfaces
- Use Mantine components for UI consistency
- Follow atomic design principles when possible
- Add loading states and error states for async operations
### State Management
- Use Valtio proxies for global state
- Keep local state in components when possible
- Use SWR for server state caching
- Implement optimistic updates for better UX
### Styling
- Primary: Mantine UI components
- Use Mantine theme system for customization
- Custom CSS should be minimal and scoped
- Follow responsive design principles
- Use semantic HTML5 elements
### Security Practices
- Validate all user inputs with Zod schemas
- Use JWT tokens for authentication
- Implement proper CORS configuration
- Never expose database credentials or API keys
- Use HTTPS in production
- Implement rate limiting for sensitive endpoints
### Performance Considerations
- Use Next.js Image optimization
- Implement proper caching strategies
- Use React.memo for expensive components
- Optimize bundle size with dynamic imports
- Use Prisma query optimization
## Domain Modules
The application is organized into several domain modules:
1. **PPID (Public Information Disclosure)**: Profile, structure, information requests, legal basis
2. **Health**: Health facilities, programs, emergency response, disease information
3. **Security**: Community security, emergency contacts, crime prevention
4. **Education**: Schools, scholarships, educational programs
5. **Economy**: Local markets, BUMDes, employment data
6. **Environment**: Environmental data, conservation, waste management
7. **Innovation**: Digital services, innovation programs
8. **Culture**: Village traditions, music, cultural preservation
Each module has its own section in both the admin panel and public-facing areas.
## File Storage Integration
The application integrates with Seafile for file storage, with specific handling for:
- Images and documents
- Public sharing capabilities
- CDN URL generation
- Batch processing of assets
## Testing
Currently no formal test framework is configured. When adding tests:
- Consider Jest or Vitest for unit testing
- Use Playwright for E2E testing
- Update this section with specific test commands
## Deployment
The application includes deployment scripts in the `NOTE.md` file that outline:
- Automated deployment with GitHub API integration
- Environment-specific configurations
- PM2 process management
- Release management with versioning
## Troubleshooting
Common issues and solutions:
- **API endpoints returning 404**: Check that environment variables are properly configured
- **Database connection errors**: Verify DATABASE_URL in environment variables
- **File upload issues**: Ensure Seafile integration is properly configured
- **Build failures**: Run `bunx prisma generate` before building
### Workflow for Code Changes
1. **Commit** existing changes before starting new work
2. **Create plan** at `MIND/PLAN/[plan-name].md`
3. **Create task** at `MIND/PLAN/[task-name].md`
4. **Execute the task** and update task progress
5. **Create summary** at `MIND/SUMMARY/[summary-name].md` when done
6. **Run build** (`bun run build`) to ensure no compile errors
7. **Fix any build errors** if they occur
8. **Commit** all changes AFTER successful build
9. **Update version** in `package.json` for every change
10. **Push** to new branch with format: `tasks/[task-name]/[what-is-being-done]/[date-time]`
11. **Push ke 2 Remote** - Push ke 2 remote origin dan deploy
12. **Merge ke Branch** - Merge ke branch target (biasanya `stg` untuk staging atau `prod` untuk production) ke 2 remote origin dan deploy
### GitHub Workflows
1. **publish.yml**: Uses branch `main`, stack env and image tag matching version from `package.json`.
2. **re-pull.yml**: **Wait for `publish.yml` to complete successfully before running.** Uses branch `main`, stack env and stack name `desa-darmasaba`.
### After Progress
- Always give option to continue to GitHub workflows or not

View File

@@ -0,0 +1,24 @@
# Plan: Fix 3 Bugs in UMKM Module
## 1. TypeError: Cannot set properties of undefined (setting 'loading')
- **File**: `src/app/darmasaba/(pages)/ekonomi/pasar-desa/page.tsx`
- **Root Cause**: `load` method is destructured from Valtio proxy, causing `this` binding to be lost.
- **Fix**: Remove `load` from destructuring and call it directly via `umkmState.produk.findMany.load` or `umkmState.umkm.findMany.load`.
## 2. 404 Not Found - Category Product API
- **File**: `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts`
- **Root Cause**: Incorrect API URL for fetching category products.
- **Fix**: Update URL from `/api/ekonomi/pasar-desa/kategori-produk/find-many-all` to `/api/ekonomi/kategoriproduk/find-many-all`.
## 3. Recharts Warning: width(-1) height(-1)
- **Location**: UMKM Admin Dashboard.
- **Root Cause**: Missing explicit height on chart container.
- **Fix**: Add `style={{ height: 300 }}` to the container and wrap charts with `ResponsiveContainer`.
## Steps:
1. Fix `src/app/darmasaba/(pages)/ekonomi/pasar-desa/page.tsx`.
2. Fix `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts`.
3. Locate and fix chart containers in UMKM admin dashboard.
4. Verify changes locally.
5. Run build to ensure no compile errors.
6. Commit and deploy.

View File

@@ -0,0 +1,26 @@
# Plan: Refactor UMKM and Pasar Desa Model
## Objective
Unify `ProdukUmkm` and `PasarDesa` into a single `PasarDesa` model to avoid data redundancy and simplify management.
## Changes:
1. **Schema Refactor**:
- Merge fields from `ProdukUmkm` (`stok`, `umkmId`) into `PasarDesa`.
- Update `PenjualanProduk` to relate directly to `PasarDesa`.
- Remove `ProdukUmkm` model.
- Update `FileStorage` relations.
2. **Backend/API Refactor**:
- Update Pasar Desa `findMany` to only show products where `umkmId` is null.
- Update UMKM Produk APIs (`create`, `updt`, `findMany`, `del`) to use the `PasarDesa` model with `umkmId` filter.
- Update Penjualan logic to adjust `stok` in `PasarDesa`.
- Update UMKM Dashboard analytics to query `PasarDesa`.
3. **Admin UI Refactor**:
- Update `umkmState` to handle `kategoriId` for products.
- Create "Tambah UMKM" form for business profile management.
- Create "Tambah Produk UMKM" form for product management with `umkmId` binding.
- Update list views to link to the new forms.
- Implement logical separation between "Pasar Desa Admin" and "UMKM Admin" contexts.
## Verification:
- Successful build (`bun run build`).
- Verify API responses for both Pasar Desa and UMKM Produk filters.

View File

@@ -0,0 +1,6 @@
# Task: Fix UMKM Module Bugs
- [x] Fix TypeError in `src/app/darmasaba/(pages)/ekonomi/pasar-desa/page.tsx` <!-- id: 0 -->
- [x] Fix 404 API URL in `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts` <!-- id: 1 -->
- [x] Fix Recharts warning in UMKM admin dashboard <!-- id: 2 -->
- [x] Run build and verify <!-- id: 3 -->

View File

@@ -0,0 +1,10 @@
# Task: Refactor UMKM and Pasar Desa Model
- [x] Refactor `prisma/schema.prisma` and run `db push` <!-- id: 0 -->
- [x] Update Pasar Desa `findMany` API with `umkmId: null` filter <!-- id: 1 -->
- [x] Update UMKM Produk APIs (CRUD) to use `PasarDesa` model <!-- id: 2 -->
- [x] Update UMKM Dashboard analytics and Penjualan logic <!-- id: 3 -->
- [x] Create Admin Form for "Data UMKM" (Business Profile) <!-- id: 4 -->
- [x] Create Admin Form for "Produk UMKM" (Product) <!-- id: 5 -->
- [x] Link list views to new forms and update state <!-- id: 6 -->
- [ ] Run build and verify <!-- id: 7 -->

View File

@@ -22,3 +22,13 @@ Implement UMKM, ProdukUmkm, and PenjualanProduk module with CRUD API and Dashboa
- [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

View File

@@ -0,0 +1,20 @@
# Summary: UMKM Module Bug Fixes
## Changes Made:
1. **Fixed TypeError in UMKM/Pasar Desa Public Page**:
- Modified `src/app/darmasaba/(pages)/ekonomi/pasar-desa/page.tsx` to stop destructuring the `load` method from the Valtio proxy.
- Called `load` directly via `pasarDesaState` or `umkmState` to preserve `this` binding.
- Cleaned up unused imports (`Group`, `IconTag`).
2. **Fixed 404 API URL for Category Products**:
- Corrected the URL in `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts` from `/api/ekonomi/pasar-desa/kategori-produk/find-many-all` to `/api/ekonomi/kategoriproduk/find-many-all`.
- Removed unused `Prisma` import.
3. **Resolved Recharts Warning and Improved Dashboard**:
- Added a `BarChart` to the UMKM Admin Dashboard (`src/app/admin/(dashboard)/ekonomi/umkm/dashboard/page.tsx`) to show sales trends by product.
- Wrapped the chart in a `ResponsiveContainer` and provided an explicit height of 350px on the parent `Box`.
- Fixed a compilation error in `src/app/darmasaba/(pages)/ekonomi/umkm/[id]/page.tsx` by adding the missing `Center` import.
## Verification:
- Ran `bun run build` successfully with no compile errors.
- Verified that all three bugs are addressed based on code analysis and build success.

View File

@@ -0,0 +1,20 @@
# Summary: Unified UMKM and Pasar Desa Model
## Changes Made:
1. **Model Unification**:
- `ProdukUmkm` has been removed.
- `PasarDesa` now includes `stok` and an optional `umkmId`.
- `PenjualanProduk` is now directly related to `PasarDesa`.
- Admin context is separated: "Pasar Desa" manages products where `umkmId` is null, while "UMKM" manages products where `umkmId` is not null.
2. **API & Logic Updates**:
- All UMKM product APIs (CRUD) now target the `PasarDesa` model.
- Sales transactions correctly decrement `stok` in the `PasarDesa` table.
- Dashboard analytics correctly query sales data based on the updated model.
3. **UI Enhancements**:
- Added `src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/create/page.tsx` for UMKM business profiles.
- Added `src/app/admin/(dashboard)/ekonomi/umkm/produk/create/page.tsx` for UMKM products with category support.
- Updated list views to separate "Pasar Murni" and "UMKM Produk" logically.
## Verification:
- Database schema synchronized with `prisma db push`.
- API logic updated and tested for consistency.

View File

@@ -3,20 +3,26 @@
## 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.
- Implemented a comprehensive Dashboard API providing KPIs, sales summaries, top products, and detailed stock analytics.
- Integrated the new module into the existing `ekonomi` router.
- Verified the implementation with `tsc` to ensure type safety.
- Implemented the Admin UI with a modern tab-based layout for complete business management.
- Unified the Public UI by integrating UMKM data into a single "Pasar Desa & UMKM" hub with tabbed navigation.
- Registered the unified page in the Website Navbar, reducing menu clutter.
- 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.

10
QWEN.md
View File

@@ -244,10 +244,12 @@ Setelah commit ke branch deployment (dev/stg/prod), otomatis trigger workflow pu
- **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.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`
3. **Buat Branch dan Push ke Branch yang baru dibuat** - Untuk branchnya buat sesuai dengan apa yang dikerjakan dengan format [apa-yang-dikerjakan]-[date-time]
4. **Push ke 2 Remote** - Push ke 2 remote origin dan deploy
5. **Merge ke Branch** - Merge ke branch target (biasanya `stg` untuk staging atau `prod` untuk production) ke 2 remote origin dan deploy
6. **Trigger publish.yml** - Gunakan GitHub API atau CLI dengan: `ref: main`, `stack_env: stg`, `tag: <versi-dari-package.json>`
7. **Tunggu publish selesai** - Workflow harus completed baru lanjut ke re-pull
8. **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"`

View File

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

View File

@@ -107,7 +107,6 @@ model FileStorage {
MusikDesaAudio MusikDesa[] @relation("MusikAudioFile")
MusikDesaCover MusikDesa[] @relation("MusikCoverImage")
UmkmImage Umkm[] @relation("UmkmImage")
ProdukUmkmImage ProdukUmkm[] @relation("ProdukUmkmImage")
}
//========================================= MENU LANDING PAGE ========================================= //
@@ -1428,14 +1427,24 @@ model PasarDesa {
image FileStorage? @relation(fields: [imageId], references: [id])
imageId String?
harga Int
rating Float
alamatUsaha String
kontak String
rating Float @default(0)
alamatUsaha String? // Opsional, bisa ambil dari UMKM
kontak String? // Opsional, bisa ambil dari UMKM
deskripsi String?
// Data Stok & UMKM
stok Int @default(0)
umkm Umkm? @relation(fields: [umkmId], references: [id])
umkmId String?
// Relasi Penjualan
penjualan PenjualanProduk[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
kategoriProduk KategoriProduk @relation(fields: [kategoriProdukId], references: [id])
kategoriProdukId String
KategoriToPasar KategoriToPasar[]
@@ -1446,7 +1455,7 @@ model KategoriProduk {
nama String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
KategoriToPasar KategoriToPasar[]
PasarDesa PasarDesa[]
@@ -2429,39 +2438,22 @@ model Umkm {
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[]
produk PasarDesa[]
}
model PenjualanProduk {
id String @id @default(cuid())
produk ProdukUmkm @relation(fields: [produkId], references: [id])
id String @id @default(cuid())
produk PasarDesa @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
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)
isActive Boolean @default(true)
@@index([periode])
@@index([produkId])

View File

@@ -0,0 +1,291 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
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 (Now using PasarDesa model)
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(),
kategoriId: z.string().min(1, "Kategori wajib dipilih"), // PasarDesa needs category
});
const defaultProdukForm = {
nama: "",
harga: 0,
stok: 0,
umkmId: "",
deskripsi: "",
imageId: "",
kategoriId: "",
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/kategoriproduk/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;

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

View File

@@ -0,0 +1,162 @@
'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 { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
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={12}>
<Card withBorder radius="md" p="lg" shadow="sm">
<Title order={4} mb="md">Grafik Penjualan per Produk</Title>
<Box style={{ height: 350 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={detail.map(item => ({
name: item.namaProduk,
penjualan: item.penjualanBulanIni
}))}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip formatter={(value: any) => `Rp ${value.toLocaleString()}`} />
<Legend />
<Bar dataKey="penjualan" fill={colors['blue-button']} name="Penjualan" />
</BarChart>
</ResponsiveContainer>
</Box>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<Card withBorder radius="md" p="lg" shadow="sm">
<Title order={4} mb="md">Top 3 Produk</Title>
<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;

View File

@@ -0,0 +1,224 @@
'use client';
import {
Box,
Button,
Group,
Paper,
Stack,
TextInput,
Title,
Text,
Select,
ActionIcon,
Image,
Loader
} from '@mantine/core';
import { Dropzone, IMAGE_MIME_TYPE } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import umkmState from '../../../../_state/ekonomi/umkm/umkm';
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import ApiFetch from '@/lib/api-fetch';
export default function CreateDataUmkm() {
const router = useRouter();
const state = useProxy(umkmState.umkm);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
umkmState.kategoriProduk.findManyAll.load();
}, []);
const handleResetForm = () => {
state.create.form = {
nama: "",
pemilik: "",
kategoriId: "",
deskripsi: "",
alamat: "",
kontak: "",
imageId: "",
isActive: true,
};
setPreviewImage(null);
setFile(null);
};
const handleCreate = async () => {
setIsSubmitting(true);
try {
// 1. Upload image first if exists
let uploadedImageId = "";
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name
});
const uploaded = res.data?.data;
if (uploaded?.id) {
uploadedImageId = uploaded.id;
} else {
return toast.error("Gagal mengunggah logo UMKM");
}
}
// 2. Submit UMKM data
state.create.form.imageId = uploadedImageId;
const success = await state.create.submit();
if (success) {
handleResetForm();
router.push('/admin/ekonomi/umkm/data-umkm');
}
} catch (error) {
console.error(error);
toast.error("Terjadi kesalahan sistem");
} finally {
setIsSubmitting(false);
}
};
return (
<Box>
<Group mb="lg">
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={20} />}
>
Kembali
</Button>
<Title order={3}>Daftarkan UMKM Baru</Title>
</Group>
<Paper withBorder p="xl" radius="md" shadow="sm">
<Stack gap="lg">
{/* Logo / Image UMKM */}
<Box>
<Text fw={500} size="sm" mb={4}>Logo / Foto UMKM</Text>
{!previewImage ? (
<Dropzone
onDrop={(files) => {
const file = files[0];
setFile(file);
setPreviewImage(URL.createObjectURL(file));
}}
maxSize={3 * 1024 ** 2}
accept={IMAGE_MIME_TYPE}
radius="md"
>
<Group justify="center" gap="xl" mih={120} style={{ pointerEvents: 'none' }}>
<Dropzone.Accept>
<IconUpload size={42} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={42} stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={42} stroke={1.5} />
</Dropzone.Idle>
<Box>
<Text size="xl" inline>
Klik atau tarik gambar di sini
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
Maksimal 3MB
</Text>
</Box>
</Group>
</Dropzone>
) : (
<Box pos="relative" w="fit-content">
<Image src={previewImage} h={200} radius="md" alt="Preview" />
<ActionIcon
color="red"
variant="filled"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
<Group grow>
<TextInput
label="Nama UMKM / Bisnis"
placeholder="Contoh: Warung Sate Bu Komang"
required
value={state.create.form.nama}
onChange={(e) => (state.create.form.nama = e.target.value)}
/>
<TextInput
label="Nama Pemilik"
placeholder="Masukkan nama lengkap pemilik"
required
value={state.create.form.pemilik}
onChange={(e) => (state.create.form.pemilik = e.target.value)}
/>
</Group>
<Group grow>
<Select
label="Kategori Bisnis"
placeholder="Pilih kategori"
required
data={umkmState.kategoriProduk.findManyAll.data?.map(v => ({
value: v.id, label: v.nama
})) || []}
value={state.create.form.kategoriId}
onChange={(val) => (state.create.form.kategoriId = val || "")}
/>
<TextInput
label="Nomor WA / Kontak"
placeholder="Contoh: 08123456789"
value={state.create.form.kontak}
onChange={(e) => (state.create.form.kontak = e.target.value)}
/>
</Group>
<TextInput
label="Alamat Lengkap"
placeholder="Masukkan alamat fisik usaha"
value={state.create.form.alamat}
onChange={(e) => (state.create.form.alamat = e.target.value)}
/>
<Box>
<Text fw={500} size="sm" mb={4}>Deskripsi UMKM</Text>
<CreateEditor
value={state.create.form.deskripsi || ""}
onChange={(val) => (state.create.form.deskripsi = val)}
/>
</Box>
<Group justify="flex-end" mt="xl">
<Button variant="outline" color="gray" onClick={handleResetForm}>
Reset
</Button>
<Button
color="blue"
onClick={handleCreate}
loading={isSubmitting}
>
Daftarkan UMKM
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,111 @@
'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 { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import umkmState from '../../../_state/ekonomi/umkm/umkm';
function DataUmkm() {
const router = useRouter();
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"
onClick={() => router.push('/admin/ekonomi/umkm/data-umkm/create')}
>
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;

View 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>
)
}

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

View File

@@ -0,0 +1,203 @@
'use client';
import {
Box,
Button,
Group,
Paper,
Stack,
TextInput,
Title,
Text,
Select,
ActionIcon,
Image,
NumberInput
} from '@mantine/core';
import { Dropzone, IMAGE_MIME_TYPE } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import umkmState from '../../../../_state/ekonomi/umkm/umkm';
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import ApiFetch from '@/lib/api-fetch';
export default function CreateProdukUmkm() {
const router = useRouter();
const state = useProxy(umkmState.produk);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
// Load UMKM list for selection and Categories
umkmState.umkm.findMany.load(1, 100);
umkmState.kategoriProduk.findManyAll.load();
}, []);
const handleResetForm = () => {
state.create.form = {
nama: "",
harga: 0,
stok: 0,
umkmId: "",
deskripsi: "",
imageId: "",
kategoriId: "",
isActive: true,
};
setPreviewImage(null);
setFile(null);
};
const handleCreate = async () => {
setIsSubmitting(true);
try {
let uploadedImageId = "";
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name
});
const uploaded = res.data?.data;
if (uploaded?.id) {
uploadedImageId = uploaded.id;
} else {
return toast.error("Gagal mengunggah foto produk");
}
}
state.create.form.imageId = uploadedImageId;
const success = await state.create.submit();
if (success) {
handleResetForm();
router.push('/admin/ekonomi/umkm/produk');
}
} catch (error) {
console.error(error);
toast.error("Terjadi kesalahan sistem");
} finally {
setIsSubmitting(false);
}
};
return (
<Box>
<Group mb="lg">
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={20} />}
>
Kembali
</Button>
<Title order={3}>Tambah Produk UMKM</Title>
</Group>
<Paper withBorder p="xl" radius="md" shadow="sm">
<Stack gap="lg">
<Box>
<Text fw={500} size="sm" mb={4}>Foto Produk</Text>
{!previewImage ? (
<Dropzone
onDrop={(files) => {
const file = files[0];
setFile(file);
setPreviewImage(URL.createObjectURL(file));
}}
maxSize={3 * 1024 ** 2}
accept={IMAGE_MIME_TYPE}
radius="md"
>
<Group justify="center" gap="xl" mih={120} style={{ pointerEvents: 'none' }}>
<Dropzone.Idle>
<IconPhoto size={42} stroke={1.5} />
</Dropzone.Idle>
<Box>
<Text size="xl" inline>Pilih gambar produk</Text>
<Text size="sm" c="dimmed" inline mt={7}>Maksimal 3MB</Text>
</Box>
</Group>
</Dropzone>
) : (
<Box pos="relative" w="fit-content">
<Image src={previewImage} h={200} radius="md" alt="Preview" />
<ActionIcon color="red" variant="filled" pos="absolute" top={5} right={5} onClick={() => { setPreviewImage(null); setFile(null); }}>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
<Select
label="Pilih UMKM Pemilik"
placeholder="Siapa pemilik produk ini?"
required
searchable
data={umkmState.umkm.findMany.data?.map(v => ({
value: v.id, label: v.nama
})) || []}
value={state.create.form.umkmId}
onChange={(val) => (state.create.form.umkmId = val || "")}
/>
<TextInput
label="Nama Produk"
placeholder="Contoh: Kripik Singkong Pedas"
required
value={state.create.form.nama}
onChange={(e) => (state.create.form.nama = e.target.value)}
/>
<Group grow>
<NumberInput
label="Harga Produk (Rp)"
placeholder="0"
required
min={0}
thousandSeparator="."
decimalSeparator=","
value={state.create.form.harga}
onChange={(val) => (state.create.form.harga = Number(val))}
/>
<NumberInput
label="Stok Awal"
placeholder="0"
required
min={0}
value={state.create.form.stok}
onChange={(val) => (state.create.form.stok = Number(val))}
/>
</Group>
<Select
label="Kategori Produk"
placeholder="Pilih kategori produk"
required
data={umkmState.kategoriProduk.findManyAll.data?.map(v => ({
value: v.id, label: v.nama
})) || []}
value={state.create.form.kategoriId}
onChange={(val) => (state.create.form.kategoriId = val || "")}
/>
<Box>
<Text fw={500} size="sm" mb={4}>Deskripsi Produk</Text>
<CreateEditor
value={state.create.form.deskripsi || ""}
onChange={(val) => (state.create.form.deskripsi = val)}
/>
</Box>
<Group justify="flex-end" mt="xl">
<Button variant="outline" color="gray" onClick={handleResetForm}>Reset</Button>
<Button color="blue" onClick={handleCreate} loading={isSubmitting}>Simpan Produk</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,117 @@
'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 { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import umkmState from '../../../_state/ekonomi/umkm/umkm';
function ProdukUmkm() {
const router = useRouter();
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"
onClick={() => router.push('/admin/ekonomi/umkm/produk/create')}
>
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;

View File

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

View File

@@ -11,8 +11,12 @@ async function pasarDesaFindMany(context: Context) {
const categoryId = context.query.categoryId as string | undefined;
const skip = (page - 1) * limit;
// Buat where clause
const where: any = { isActive: true };
// Buat where clause: Tampilkan hanya yang TIDAK punya umkmId (Produk Pasar Murni)
const where: any = {
isActive: true,
deletedAt: null,
umkmId: null
};
// Tambahkan filter kategori (jika ada)
if (categoryId) {
@@ -65,7 +69,7 @@ async function pasarDesaFindMany(context: Context) {
return {
success: true,
message: "Berhasil ambil pasar desa dengan pagination",
message: "Berhasil ambil pasar desa dengan pagination (Non-UMKM)",
data,
page,
limit,

View File

@@ -22,8 +22,9 @@ async function umkmDashboardDetailPenjualan(context: Context) {
where: { periode: periodeLalu, deletedAt: null },
_sum: { totalNilai: true }
}),
prisma.produkUmkm.findMany({
where: { deletedAt: null },
// Use PasarDesa with umkmId filter
prisma.pasarDesa.findMany({
where: { deletedAt: null, umkmId: { not: null } },
select: { id: true, nama: true, stok: true }
})
]);
@@ -33,11 +34,11 @@ async function umkmDashboardDetailPenjualan(context: Context) {
const laluRaw = produkLalu.find(l => l.produkId === p.id)?._sum || { totalNilai: 0 };
const skrg = {
totalNilai: skrgRaw.totalNilai || 0,
jumlah: skrgRaw.jumlah || 0
totalNilai: (skrgRaw as any).totalNilai || 0,
jumlah: (skrgRaw as any).jumlah || 0
};
const lalu = {
totalNilai: laluRaw.totalNilai || 0
totalNilai: (laluRaw as any).totalNilai || 0
};
let trend = "stable";

View File

@@ -20,7 +20,10 @@ async function umkmDashboardRingSummary(context: Context) {
where: { periode: periodeLalu, deletedAt: null },
_sum: { totalNilai: true }
}),
prisma.produkUmkm.count({ where: { isActive: true, deletedAt: null } }),
// Count from PasarDesa with umkmId filter
prisma.pasarDesa.count({
where: { isActive: true, deletedAt: null, umkmId: { not: null } }
}),
prisma.penjualanProduk.count({ where: { periode, deletedAt: null } })
]);

View File

@@ -15,17 +15,18 @@ async function umkmDashboardTopProduk(context: Context) {
});
const data = await Promise.all(topPenjualan.map(async (item) => {
const produk = await prisma.produkUmkm.findUnique({
// Find from PasarDesa now
const produk = await prisma.pasarDesa.findUnique({
where: { id: item.produkId },
include: { umkm: true }
});
return {
namaProduk: produk?.nama || "Unknown",
namaUmkm: produk?.umkm.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
growth: 0
};
}));

View File

@@ -11,9 +11,9 @@ async function penjualanProdukCreate(context: Context) {
const totalNilai = body.jumlah * body.hargaSatuan;
try {
// Gunakan transaction untuk update stok produk
// Gunakan transaction untuk update stok produk (PasarDesa)
const result = await prisma.$transaction(async (tx) => {
// 1. Catat penjualan
// 1. Catat penjualan (relasi ke PasarDesa)
const penjualan = await tx.penjualanProduk.create({
data: {
produkId: body.produkId,
@@ -26,8 +26,8 @@ async function penjualanProdukCreate(context: Context) {
},
});
// 2. Update stok produk
await tx.produkUmkm.update({
// 2. Update stok di model PasarDesa
await tx.pasarDesa.update({
where: { id: body.produkId },
data: {
stok: {
@@ -41,7 +41,7 @@ async function penjualanProdukCreate(context: Context) {
return {
success: true,
message: "Berhasil mencatat penjualan produk",
message: "Berhasil mencatat penjualan produk (PasarDesa)",
data: result,
};
} catch (e) {

View File

@@ -23,8 +23,8 @@ async function penjualanProdukDelete(context: Context) {
},
});
// 3. Kembalikan stok produk
await tx.produkUmkm.update({
// 3. Kembalikan stok produk ke PasarDesa
await tx.pasarDesa.update({
where: { id: data.produkId },
data: {
stok: {
@@ -38,7 +38,7 @@ async function penjualanProdukDelete(context: Context) {
return {
success: true,
message: "Berhasil menghapus data penjualan dan mengembalikan stok",
message: "Berhasil menghapus data penjualan dan mengembalikan stok (PasarDesa)",
data: result,
};
} catch (e) {

View File

@@ -32,10 +32,10 @@ async function penjualanProdukUpdate(context: Context) {
},
});
// 3. Update stok jika produk sama, sesuaikan selisih
// 3. Update stok di PasarDesa jika produk sama, sesuaikan selisih
if (oldData.produkId === body.produkId) {
const diff = body.jumlah - oldData.jumlah;
await tx.produkUmkm.update({
await tx.pasarDesa.update({
where: { id: body.produkId },
data: {
stok: {
@@ -44,8 +44,8 @@ async function penjualanProdukUpdate(context: Context) {
}
});
} else {
// Jika produk berubah, kembalikan stok lama dan kurangi stok baru
await tx.produkUmkm.update({
// Jika produk berubah, kembalikan stok lama dan kurangi stok baru di PasarDesa
await tx.pasarDesa.update({
where: { id: oldData.produkId },
data: {
stok: {
@@ -53,7 +53,7 @@ async function penjualanProdukUpdate(context: Context) {
}
}
});
await tx.produkUmkm.update({
await tx.pasarDesa.update({
where: { id: body.produkId },
data: {
stok: {
@@ -68,7 +68,7 @@ async function penjualanProdukUpdate(context: Context) {
return {
success: true,
message: "Berhasil memperbarui data penjualan",
message: "Berhasil memperbarui data penjualan (PasarDesa)",
data: result,
};
} catch (e) {

View File

@@ -5,7 +5,7 @@ async function produkUmkmCreate(context: Context) {
const body = context.body as any;
try {
const data = await prisma.produkUmkm.create({
const data = await prisma.pasarDesa.create({
data: {
nama: body.nama,
harga: body.harga,
@@ -14,12 +14,14 @@ async function produkUmkmCreate(context: Context) {
umkmId: body.umkmId,
imageId: body.imageId,
isActive: body.isActive ?? true,
rating: 0, // Default for UMKM products
kategoriProdukId: body.kategoriId, // Now required via PasarDesa
},
});
return {
success: true,
message: "Berhasil membuat produk UMKM baru",
message: "Berhasil membuat produk UMKM baru (PasarDesa)",
data,
};
} catch (e) {

View File

@@ -5,8 +5,8 @@ async function produkUmkmDelete(context: Context) {
const id = context.params.id;
try {
// Soft delete
const data = await prisma.produkUmkm.update({
// Soft delete on PasarDesa
const data = await prisma.pasarDesa.update({
where: { id },
data: {
deletedAt: new Date(),
@@ -16,7 +16,7 @@ async function produkUmkmDelete(context: Context) {
return {
success: true,
message: "Berhasil menghapus produk UMKM",
message: "Berhasil menghapus produk UMKM (PasarDesa)",
data,
};
} catch (e) {

View File

@@ -10,16 +10,19 @@ async function produkUmkmFindMany(context: Context) {
const kategoriId = context.query.kategoriId as string | undefined;
const skip = (page - 1) * limit;
const where: any = { deletedAt: null };
// Filter: ONLY products that belong to an UMKM
const where: any = {
deletedAt: null,
isActive: true,
umkmId: { not: null }
};
if (umkmId) {
where.umkmId = umkmId;
}
if (kategoriId) {
where.umkm = {
kategoriId: kategoriId
};
where.kategoriProdukId = kategoriId;
}
if (search) {
@@ -28,19 +31,20 @@ async function produkUmkmFindMany(context: Context) {
try {
const [data, total] = await Promise.all([
prisma.produkUmkm.findMany({
prisma.pasarDesa.findMany({
where,
include: {
image: true,
umkm: {
include: { kategori: true }
}
},
kategoriProduk: true
},
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.produkUmkm.count({ where }),
prisma.pasarDesa.count({ where }),
]);
return {

View File

@@ -5,7 +5,7 @@ async function produkUmkmFindUnique(context: Context) {
const id = context.params.id;
try {
const data = await prisma.produkUmkm.findUnique({
const data = await prisma.pasarDesa.findUnique({
where: { id },
include: {
image: true,
@@ -14,7 +14,8 @@ async function produkUmkmFindUnique(context: Context) {
where: { deletedAt: null },
orderBy: { tanggal: 'desc' },
take: 10
}
},
kategoriProduk: true
},
});
@@ -27,7 +28,7 @@ async function produkUmkmFindUnique(context: Context) {
return {
success: true,
message: "Berhasil mengambil detail produk UMKM",
message: "Berhasil mengambil detail produk UMKM (PasarDesa)",
data,
};
} catch (e) {

View File

@@ -6,7 +6,7 @@ async function produkUmkmUpdate(context: Context) {
const id = context.params.id;
try {
const data = await prisma.produkUmkm.update({
const data = await prisma.pasarDesa.update({
where: { id },
data: {
nama: body.nama,
@@ -16,12 +16,13 @@ async function produkUmkmUpdate(context: Context) {
umkmId: body.umkmId,
imageId: body.imageId,
isActive: body.isActive,
kategoriProdukId: body.kategoriId, // Now editable via PasarDesa
},
});
return {
success: true,
message: "Berhasil memperbarui produk UMKM",
message: "Berhasil memperbarui produk UMKM (PasarDesa)",
data,
};
} catch (e) {

View File

@@ -1,50 +1,27 @@
'use client'
import pasarDesaState from '@/app/admin/(dashboard)/_state/ekonomi/pasar-desa/pasar-desa';
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 } from '@mantine/core';
import {
Box, Center, Flex, Grid, GridCol, Image, Pagination, Paper,
Select, SimpleGrid, Skeleton, Stack, Text, TextInput, Title,
Tabs, Badge, Card
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconBrandWhatsapp, IconMapPinFilled, IconSearch, IconStarFilled } from '@tabler/icons-react';
import {
IconBrandWhatsapp, IconMapPinFilled, IconSearch,
IconStarFilled, IconBuildingStore, IconUser,
IconShoppingBag, IconPackage
} from '@tabler/icons-react';
import { motion } from 'motion/react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import { useProxy } from 'valtio/utils';
import BackButton from '../../desa/layanan/_com/BackButto';
function Page() {
const router = useRouter()
const state = useProxy(pasarDesaState.pasarDesa)
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(() => {
pasarDesaState.kategoriProduk.findManyAll.load()
}, [])
const filteredData = selectedCategory
? data?.filter(item =>
item.KategoriToPasar?.some(kategori => kategori.kategoriId === selectedCategory)
)
: data;
useShallowEffect(() => {
load(page, 4, debouncedSearch, selectedCategory || undefined)
}, [page, debouncedSearch, selectedCategory])
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton h={500} />
</Stack>
)
}
const [activeTab, setActiveTab] = useState<string | null>('produk-pasar');
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">
@@ -52,137 +29,283 @@ function Page() {
<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}>
Pasar Desa
</Title>
</GridCol>
<GridCol span={{ base: 12, md: 3 }}>
<TextInput
radius="lg"
placeholder="Cari Produk"
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"
>
Pasar Desa Online adalah media promosi untuk membantu warga memasarkan dan memperkenalkan produk mereka.
<Box px={{ base: 'md', md: 100 }}>
<Title order={1} c={colors["blue-button"]} fw="bold" lh={1.15} mb="md">
Pasar Desa & UMKM Darmasaba
</Title>
<Text ta="justify" fz={{ base: 'sm', md: 'md' }} lh={1.5} c="black" mb="xl">
Pusat informasi produk lokal dan direktori usaha warga Desa Darmasaba.
Temukan berbagai produk unggulan dan dukung ekonomi pengusaha desa kami.
</Text>
</Box>
<Box px={{ base: "md", md: 100 }}>
<Stack gap="lg">
<SimpleGrid pb={30} cols={{ base: 1, md: 2 }}>
<Box>
<Select
placeholder="Pilih Kategori"
data={pasarDesaState.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%' }}
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.5, md: 1.55 }}
c="black"
/>
</Box>
</SimpleGrid>
<Tabs value={activeTab} onChange={setActiveTab} color="blue" variant="pills" radius="md">
<Tabs.List mb="xl">
<Tabs.Tab value="produk-pasar" leftSection={<IconShoppingBag size={18} />}>
Produk Pasar Desa
</Tabs.Tab>
<Tabs.Tab value="produk-umkm" leftSection={<IconPackage size={18} />}>
Produk UMKM
</Tabs.Tab>
<Tabs.Tab value="direktori-umkm" leftSection={<IconBuildingStore size={18} />}>
Direktori UMKM
</Tabs.Tab>
</Tabs.List>
<SimpleGrid cols={{ base: 1, md: 4 }}>
{filteredData?.map((v, k) => (
<Stack key={k}>
<motion.div
onClick={() => router.push(`/darmasaba/ekonomi/pasar-desa/${v.id}`)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.8 }}
>
<Paper p="lg">
<Image
radius="lg"
src={v.image?.link || '/placeholder-product.jpg'}
alt={v.nama}
h={200}
w="100%"
style={{ objectFit: 'cover' }}
loading="lazy"
/>
<Text
py="sm"
fw="bold"
fz={{ base: 'md', md: 'lg' }}
lh={{ base: 1.3, md: 1.25 }}
c="black"
>
{v.nama}
</Text>
<Text
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.5, md: 1.55 }}
c="black"
>
Rp {v.harga.toLocaleString('id-ID')}
</Text>
<Flex py="sm" gap="md" align="center">
<IconStarFilled size={20} color="#EBCB09" />
<Text
fz={{ base: 'xs', md: 'sm' }}
lh={{ base: 1.4, md: 1.45 }}
c="black"
>
{v.rating}
</Text>
</Flex>
<Flex justify="space-between" align="center">
<Box>
<Flex gap="md" align="center">
<IconMapPinFilled size={20} color="red" />
<Text
fz={{ base: 'xs', md: 'sm' }}
lh={{ base: 1.4, md: 1.45 }}
c="black"
>
{v.alamatUsaha}
</Text>
</Flex>
</Box>
<IconBrandWhatsapp size={20} color={colors['blue-button']} />
</Flex>
</Paper>
</motion.div>
</Stack>
))}
</SimpleGrid>
<Tabs.Panel value="produk-pasar">
<TabProdukPasar router={router} />
</Tabs.Panel>
<Center>
<Pagination
value={page}
onChange={(newPage) => load(newPage)}
total={totalPages}
my="md"
/>
</Center>
</Stack>
<Tabs.Panel value="produk-umkm">
<TabProdukUmkm router={router} />
</Tabs.Panel>
<Tabs.Panel value="direktori-umkm">
<TabDirektoriUmkm router={router} />
</Tabs.Panel>
</Tabs>
</Box>
</Stack>
);
}
export default Page;
// --- TAB COMPONENTS ---
function TabProdukPasar({ router }: { router: any }) {
const state = useProxy(pasarDesaState.pasarDesa);
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 1000);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const { data, page, loading, totalPages } = state.findMany;
useShallowEffect(() => {
pasarDesaState.kategoriProduk.findManyAll.load();
}, []);
useShallowEffect(() => {
pasarDesaState.pasarDesa.findMany.load(page, 8, debouncedSearch, selectedCategory || undefined);
}, [page, debouncedSearch, selectedCategory]);
return (
<Stack gap="lg">
<Grid gutter="md" align="flex-end">
<GridCol span={{ base: 12, md: 8 }}>
<TextInput
label="Cari Produk Pasar"
placeholder="Ketik nama produk..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
leftSection={<IconSearch size={18} />}
radius="md"
/>
</GridCol>
<GridCol span={{ base: 12, md: 4 }}>
<Select
label="Kategori"
placeholder="Pilih Kategori"
data={pasarDesaState.kategoriProduk.findManyAll.data?.map((v) => ({
value: v.id, label: v.nama
})) || []}
value={selectedCategory}
onChange={setSelectedCategory}
clearable
radius="md"
/>
</GridCol>
</Grid>
{loading ? (
<Skeleton h={400} radius="md" />
) : (
<>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
{data?.map((v, k) => (
<motion.div
key={k}
onClick={() => router.push(`/darmasaba/ekonomi/pasar-desa/${v.id}`)}
whileHover={{ scale: 1.03 }}
>
<Paper p="md" radius="md" withBorder shadow="xs" style={{ cursor: 'pointer' }}>
<Image
radius="md"
src={v.image?.link || '/no-image.jpg'}
alt={v.nama}
h={180}
w="100%"
style={{ objectFit: 'cover' }}
/>
<Text mt="md" fw="bold" fz="lg" lineClamp={1}>{v.nama}</Text>
<Text c="blue" fw={700} fz="md">Rp {v.harga.toLocaleString('id-ID')}</Text>
<Flex py="xs" gap="xs" align="center">
<IconStarFilled size={16} color="#EBCB09" />
<Text size="sm">{v.rating}</Text>
</Flex>
<Flex justify="space-between" align="center">
<Flex gap={5} align="center">
<IconMapPinFilled size={16} color="red" />
<Text size="xs" c="dimmed" lineClamp={1}>{v.alamatUsaha}</Text>
</Flex>
<IconBrandWhatsapp size={18} color="green" />
</Flex>
</Paper>
</motion.div>
))}
</SimpleGrid>
<Center>
<Pagination total={totalPages} value={page} onChange={(p) => pasarDesaState.pasarDesa.findMany.load(p)} radius="md" />
</Center>
</>
)}
</Stack>
);
}
function TabProdukUmkm({ router }: { router: any }) {
const state = useProxy(umkmState.produk);
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 1000);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const { data, page, loading, totalPages } = state.findMany;
useShallowEffect(() => {
umkmState.kategoriProduk.findManyAll.load();
}, []);
useShallowEffect(() => {
umkmState.produk.findMany.load(page, 8, debouncedSearch, undefined, selectedCategory || undefined);
}, [page, debouncedSearch, selectedCategory]);
return (
<Stack gap="lg">
<Grid gutter="md" align="flex-end">
<GridCol span={{ base: 12, md: 8 }}>
<TextInput
label="Cari Produk UMKM"
placeholder="Ketik nama produk..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
leftSection={<IconSearch size={18} />}
radius="md"
/>
</GridCol>
<GridCol span={{ base: 12, md: 4 }}>
<Select
label="Kategori"
placeholder="Pilih Kategori"
data={umkmState.kategoriProduk.findManyAll.data?.map((v) => ({
value: v.id, label: v.nama
})) || []}
value={selectedCategory}
onChange={setSelectedCategory}
clearable
radius="md"
/>
</GridCol>
</Grid>
{loading ? (
<Skeleton h={400} radius="md" />
) : (
<>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
{data?.map((v, k) => (
<motion.div key={k} whileHover={{ scale: 1.03 }}>
<Card shadow="xs" padding="md" radius="md" withBorder>
<Card.Section>
<Image
src={v.image?.link || '/no-image.jpg'}
height={160}
alt={v.nama}
/>
</Card.Section>
<Stack mt="md" gap="xs">
<Badge color={v.stok > 0 ? 'teal' : 'red'}>
{v.stok > 0 ? 'Tersedia' : 'Habis'}
</Badge>
<Text fw={700} fz="md" lineClamp={1}>{v.nama}</Text>
<Text size="sm" c="blue" fw={500} style={{ cursor: 'pointer' }} onClick={() => router.push(`/darmasaba/ekonomi/umkm/${v.umkmId}`)}>
{v.umkm?.nama}
</Text>
<Text fz="lg" fw={800} c="orange">Rp {v.harga.toLocaleString('id-ID')}</Text>
</Stack>
</Card>
</motion.div>
))}
</SimpleGrid>
<Center>
<Pagination total={totalPages} value={page} onChange={(p) => umkmState.produk.findMany.load(p)} radius="md" />
</Center>
</>
)}
</Stack>
);
}
function TabDirektoriUmkm({ router }: { router: any }) {
const state = useProxy(umkmState.umkm);
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, loading, totalPages } = state.findMany;
useShallowEffect(() => {
umkmState.umkm.findMany.load(page, 8, debouncedSearch);
}, [page, debouncedSearch]);
return (
<Stack gap="lg">
<TextInput
label="Cari UMKM"
placeholder="Nama UMKM atau Pemilik..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
leftSection={<IconSearch size={18} />}
radius="md"
maw={500}
/>
{loading ? (
<Skeleton h={400} radius="md" />
) : (
<>
<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 }}
>
<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={160}
w="100%"
style={{ objectFit: 'cover' }}
/>
<Badge mt="md" variant="light">{v.kategori?.nama}</Badge>
<Text mt="xs" fw="bold" fz="md" lineClamp={1}>{v.nama}</Text>
<Flex mt={8} gap="xs" align="center">
<IconUser size={14} color="gray" />
<Text fz="xs" c="dimmed">{v.pemilik}</Text>
</Flex>
<Flex mt={4} gap="xs" align="center">
<IconMapPinFilled size={14} color="red" />
<Text fz="xs" c="dimmed" lineClamp={1}>{v.alamat || 'Darmasaba'}</Text>
</Flex>
</Paper>
</motion.div>
))}
</SimpleGrid>
<Center>
<Pagination total={totalPages} value={page} onChange={(p) => umkmState.umkm.findMany.load(p)} radius="md" />
</Center>
</>
)}
</Stack>
);
}
export default Page;

View 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, Center } 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;