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

@@ -25,7 +25,7 @@ const defaultUmkmForm = {
isActive: true,
};
// Produk Form Validation
// 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"),
@@ -33,6 +33,7 @@ const produkFormSchema = z.object({
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 = {
@@ -42,6 +43,7 @@ const defaultProdukForm = {
umkmId: "",
deskripsi: "",
imageId: "",
kategoriId: "",
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';
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);
@@ -38,7 +40,11 @@ function DataUmkm() {
<Stack gap="lg">
<Group justify="space-between">
<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
</Button>
</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';
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);
@@ -38,7 +40,11 @@ function ProdukUmkm() {
<Stack gap="lg">
<Group justify="space-between">
<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
</Button>
</Group>