Compare commits
6 Commits
62aa9b63b2
...
tasks/refa
| Author | SHA1 | Date | |
|---|---|---|---|
| e286cb4f2b | |||
| a2d157ee02 | |||
| ece84fabf0 | |||
| 59981683db | |||
| 1a74a1f683 | |||
| b673e36a45 |
274
GEMINI.md
274
GEMINI.md
@@ -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
|
||||
24
MIND/PLAN/fix-umkm-bugs.md
Normal file
24
MIND/PLAN/fix-umkm-bugs.md
Normal 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.
|
||||
26
MIND/PLAN/refactor-umkm-pasar-desa.md
Normal file
26
MIND/PLAN/refactor-umkm-pasar-desa.md
Normal 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.
|
||||
6
MIND/PLAN/task-fix-umkm-bugs.md
Normal file
6
MIND/PLAN/task-fix-umkm-bugs.md
Normal 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 -->
|
||||
10
MIND/PLAN/task-refactor-umkm-pasar-desa.md
Normal file
10
MIND/PLAN/task-refactor-umkm-pasar-desa.md
Normal 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 -->
|
||||
@@ -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
|
||||
|
||||
20
MIND/SUMMARY/fix-umkm-bugs-summary.md
Normal file
20
MIND/SUMMARY/fix-umkm-bugs-summary.md
Normal 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.
|
||||
20
MIND/SUMMARY/refactor-umkm-pasar-desa-summary.md
Normal file
20
MIND/SUMMARY/refactor-umkm-pasar-desa-summary.md
Normal 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.
|
||||
@@ -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
10
QWEN.md
@@ -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"`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "desa-darmasaba",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.17",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -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])
|
||||
|
||||
291
src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts
Normal file
291
src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts
Normal 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;
|
||||
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;
|
||||
162
src/app/admin/(dashboard)/ekonomi/umkm/dashboard/page.tsx
Normal file
162
src/app/admin/(dashboard)/ekonomi/umkm/dashboard/page.tsx
Normal 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;
|
||||
224
src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/create/page.tsx
Normal file
224
src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/create/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/page.tsx
Normal file
111
src/app/admin/(dashboard)/ekonomi/umkm/data-umkm/page.tsx
Normal 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;
|
||||
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;
|
||||
203
src/app/admin/(dashboard)/ekonomi/umkm/produk/create/page.tsx
Normal file
203
src/app/admin/(dashboard)/ekonomi/umkm/produk/create/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
src/app/admin/(dashboard)/ekonomi/umkm/produk/page.tsx
Normal file
117
src/app/admin/(dashboard)/ekonomi/umkm/produk/page.tsx
Normal 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;
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 } })
|
||||
]);
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}));
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
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, 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;
|
||||
Reference in New Issue
Block a user