feat(ekonomi): unify UMKM and Pasar Desa models, add business profile and product forms

This commit is contained in:
2026-04-21 15:19:57 +08:00
parent a2d157ee02
commit e286cb4f2b
22 changed files with 586 additions and 80 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ const defaultUmkmForm = {
isActive: true, isActive: true,
}; };
// Produk Form Validation // Produk Form Validation (Now using PasarDesa model)
const produkFormSchema = z.object({ const produkFormSchema = z.object({
nama: z.string().min(1, "Nama produk minimal 1 karakter"), nama: z.string().min(1, "Nama produk minimal 1 karakter"),
harga: z.number().min(0, "Harga tidak boleh negatif"), harga: z.number().min(0, "Harga tidak boleh negatif"),
@@ -33,6 +33,7 @@ const produkFormSchema = z.object({
umkmId: z.string().min(1, "UMKM wajib dipilih"), umkmId: z.string().min(1, "UMKM wajib dipilih"),
deskripsi: z.string().optional(), deskripsi: z.string().optional(),
imageId: z.string().optional(), imageId: z.string().optional(),
kategoriId: z.string().min(1, "Kategori wajib dipilih"), // PasarDesa needs category
}); });
const defaultProdukForm = { const defaultProdukForm = {
@@ -42,6 +43,7 @@ const defaultProdukForm = {
umkmId: "", umkmId: "",
deskripsi: "", deskripsi: "",
imageId: "", imageId: "",
kategoriId: "",
isActive: true, isActive: true,
}; };

View File

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

View File

@@ -21,11 +21,13 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks'; import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconPlus, IconSearch, IconEdit, IconTrash } from '@tabler/icons-react'; import { IconPlus, IconSearch, IconEdit, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import umkmState from '../../../_state/ekonomi/umkm/umkm'; import umkmState from '../../../_state/ekonomi/umkm/umkm';
function DataUmkm() { function DataUmkm() {
const router = useRouter();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const state = useProxy(umkmState.umkm.findMany); const state = useProxy(umkmState.umkm.findMany);
const [debouncedSearch] = useDebouncedValue(search, 1000); const [debouncedSearch] = useDebouncedValue(search, 1000);
@@ -38,7 +40,11 @@ function DataUmkm() {
<Stack gap="lg"> <Stack gap="lg">
<Group justify="space-between"> <Group justify="space-between">
<Title order={3}>Data UMKM</Title> <Title order={3}>Data UMKM</Title>
<Button leftSection={<IconPlus size={18} />} color="blue"> <Button
leftSection={<IconPlus size={18} />}
color="blue"
onClick={() => router.push('/admin/ekonomi/umkm/data-umkm/create')}
>
Tambah UMKM Tambah UMKM
</Button> </Button>
</Group> </Group>

View File

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

View File

@@ -21,11 +21,13 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks'; import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconPlus, IconSearch, IconEdit, IconTrash } from '@tabler/icons-react'; import { IconPlus, IconSearch, IconEdit, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import umkmState from '../../../_state/ekonomi/umkm/umkm'; import umkmState from '../../../_state/ekonomi/umkm/umkm';
function ProdukUmkm() { function ProdukUmkm() {
const router = useRouter();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const state = useProxy(umkmState.produk.findMany); const state = useProxy(umkmState.produk.findMany);
const [debouncedSearch] = useDebouncedValue(search, 1000); const [debouncedSearch] = useDebouncedValue(search, 1000);
@@ -38,7 +40,11 @@ function ProdukUmkm() {
<Stack gap="lg"> <Stack gap="lg">
<Group justify="space-between"> <Group justify="space-between">
<Title order={3}>Daftar Produk UMKM</Title> <Title order={3}>Daftar Produk UMKM</Title>
<Button leftSection={<IconPlus size={18} />} color="blue"> <Button
leftSection={<IconPlus size={18} />}
color="blue"
onClick={() => router.push('/admin/ekonomi/umkm/produk/create')}
>
Tambah Produk Tambah Produk
</Button> </Button>
</Group> </Group>

View File

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

View File

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

View File

@@ -20,7 +20,10 @@ async function umkmDashboardRingSummary(context: Context) {
where: { periode: periodeLalu, deletedAt: null }, where: { periode: periodeLalu, deletedAt: null },
_sum: { totalNilai: true } _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 } }) prisma.penjualanProduk.count({ where: { periode, deletedAt: null } })
]); ]);

View File

@@ -15,17 +15,18 @@ async function umkmDashboardTopProduk(context: Context) {
}); });
const data = await Promise.all(topPenjualan.map(async (item) => { const 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 }, where: { id: item.produkId },
include: { umkm: true } include: { umkm: true }
}); });
return { return {
namaProduk: produk?.nama || "Unknown", namaProduk: produk?.nama || "Unknown",
namaUmkm: produk?.umkm.nama || "Unknown", namaUmkm: produk?.umkm?.nama || "Unknown",
totalPenjualan: item._sum.totalNilai || 0, totalPenjualan: item._sum.totalNilai || 0,
jumlahTerjual: item._sum.jumlah || 0, jumlahTerjual: item._sum.jumlah || 0,
growth: 0 // logic growth bisa ditambah jika diperlukan growth: 0
}; };
})); }));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,16 +10,19 @@ async function produkUmkmFindMany(context: Context) {
const kategoriId = context.query.kategoriId as string | undefined; const kategoriId = context.query.kategoriId as string | undefined;
const skip = (page - 1) * limit; 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) { if (umkmId) {
where.umkmId = umkmId; where.umkmId = umkmId;
} }
if (kategoriId) { if (kategoriId) {
where.umkm = { where.kategoriProdukId = kategoriId;
kategoriId: kategoriId
};
} }
if (search) { if (search) {
@@ -28,19 +31,20 @@ async function produkUmkmFindMany(context: Context) {
try { try {
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
prisma.produkUmkm.findMany({ prisma.pasarDesa.findMany({
where, where,
include: { include: {
image: true, image: true,
umkm: { umkm: {
include: { kategori: true } include: { kategori: true }
} },
kategoriProduk: true
}, },
skip, skip,
take: limit, take: limit,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}), }),
prisma.produkUmkm.count({ where }), prisma.pasarDesa.count({ where }),
]); ]);
return { return {

View File

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

View File

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