feat(umkm): implement full CRUD for product categories

- added CRUD endpoints for KategoriProduk in Elysia API
- updated umkmState with category management logic
- added 'Kategori Produk' tab in admin dashboard
- created list, create, and edit pages for category management
- bumped version to 0.1.32
This commit is contained in:
2026-04-27 17:37:16 +08:00
parent 865074a310
commit 5ab014281a
9 changed files with 614 additions and 39 deletions

View File

@@ -63,6 +63,15 @@ const defaultPenjualanForm = {
isActive: true,
};
// Kategori Produk Form Validation
const kategoriProdukFormSchema = z.object({
nama: z.string().min(1, "Nama kategori wajib diisi"),
});
const defaultKategoriProdukForm = {
nama: "",
};
export const umkmState = proxy({
// UMKM Module
umkm: {
@@ -101,7 +110,7 @@ export const umkmState = proxy({
loading: false,
async submit() {
const cek = umkmFormSchema.safeParse(this.form);
if (!cek.success) return toast.error("Cek kembali form anda");
if (!cek.success) { toast.error("Cek kembali form anda"); return false; }
this.loading = true;
try {
const res = await fetch("/api/ekonomi/umkm/create", {
@@ -129,7 +138,7 @@ export const umkmState = proxy({
loading: false,
async submit(id: string) {
const cek = umkmFormSchema.safeParse(this.form);
if (!cek.success) return toast.error("Cek kembali form anda");
if (!cek.success) { toast.error("Cek kembali form anda"); return false; }
this.loading = true;
try {
const res = await fetch(`/api/ekonomi/umkm/${id}`, {
@@ -237,7 +246,7 @@ export const umkmState = proxy({
loading: false,
async submit() {
const cek = produkFormSchema.safeParse(this.form);
if (!cek.success) return toast.error("Cek kembali form anda");
if (!cek.success) { toast.error("Cek kembali form anda"); return false; }
this.loading = true;
try {
const res = await fetch("/api/ekonomi/umkm/produk/create", {
@@ -260,7 +269,7 @@ export const umkmState = proxy({
loading: false,
async submit(id: string) {
const cek = produkFormSchema.safeParse(this.form);
if (!cek.success) return toast.error("Cek kembali form anda");
if (!cek.success) { toast.error("Cek kembali form anda"); return false; }
this.loading = true;
try {
const res = await fetch(`/api/ekonomi/umkm/produk/${id}`, {
@@ -323,7 +332,7 @@ export const umkmState = proxy({
loading: false,
async submit() {
const cek = penjualanFormSchema.safeParse(this.form);
if (!cek.success) return toast.error("Cek kembali form anda");
if (!cek.success) { toast.error("Cek kembali form anda"); return false; }
this.loading = true;
try {
const res = await fetch("/api/ekonomi/umkm/penjualan/create", {
@@ -368,6 +377,31 @@ export const umkmState = proxy({
// Kategori Produk (Share with Pasar Desa)
kategoriProduk: {
findMany: {
data: [] as any[],
page: 1,
totalPages: 1,
loading: false,
search: "",
async load(page = 1, limit = 10, search = "") {
this.loading = true;
this.page = page;
this.search = search;
try {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
search
});
const res = await fetch(`/api/ekonomi/kategoriproduk/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; }
}
},
findManyAll: {
data: [] as any[],
loading: false,
@@ -381,6 +415,75 @@ export const umkmState = proxy({
}
} catch (e) { console.error(e); } finally { this.loading = false; }
}
},
create: {
form: { ...defaultKategoriProdukForm },
loading: false,
async submit() {
const cek = kategoriProdukFormSchema.safeParse(this.form);
if (!cek.success) { toast.error("Nama kategori wajib diisi"); return false; }
this.loading = true;
try {
const res = await fetch("/api/ekonomi/kategoriproduk/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form)
});
const result = await res.json();
if (result.success) {
toast.success("Kategori berhasil dibuat");
umkmState.kategoriProduk.findMany.load();
umkmState.kategoriProduk.findManyAll.load();
return true;
}
toast.error(result.message || "Gagal membuat kategori");
} catch (e) { toast.error("Gagal membuat kategori"); } finally { this.loading = false; }
return false;
}
},
update: {
form: { ...defaultKategoriProdukForm },
loading: false,
async submit(id: string) {
const cek = kategoriProdukFormSchema.safeParse(this.form);
if (!cek.success) { toast.error("Nama kategori wajib diisi"); return false; }
this.loading = true;
try {
const res = await fetch(`/api/ekonomi/kategoriproduk/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form)
});
const result = await res.json();
if (result.success) {
toast.success("Kategori berhasil diperbarui");
umkmState.kategoriProduk.findMany.load();
umkmState.kategoriProduk.findManyAll.load();
return true;
}
toast.error(result.message || "Gagal memperbarui kategori");
} catch (e) { toast.error("Gagal memperbarui kategori"); } finally { this.loading = false; }
return false;
}
},
del: {
loading: false,
async submit(id: string) {
this.loading = true;
try {
const res = await fetch(`/api/ekonomi/kategoriproduk/del/${id}`, {
method: "DELETE"
});
const result = await res.json();
if (result.success) {
toast.success("Kategori berhasil dihapus");
umkmState.kategoriProduk.findMany.load();
umkmState.kategoriProduk.findManyAll.load();
return true;
}
} catch (e) { toast.error("Gagal menghapus kategori"); } finally { this.loading = false; }
return false;
}
}
},

View File

@@ -11,7 +11,7 @@ import {
TabsTab,
Title
} from '@mantine/core';
import { IconDashboard, IconBuildingStore, IconPackage, IconShoppingCart } from '@tabler/icons-react';
import { IconDashboard, IconBuildingStore, IconPackage, IconShoppingCart, IconTag } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
@@ -44,6 +44,12 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
href: "/admin/ekonomi/umkm/penjualan",
icon: <IconShoppingCart size={18} stroke={1.8} />
},
{
label: "Kategori Produk",
value: "kategori-produk",
href: "/admin/ekonomi/umkm/kategori-produk",
icon: <IconTag size={18} stroke={1.8} />
},
];
const currentTab = tabs.find((tab) => pathname.startsWith(tab.href));

View File

@@ -0,0 +1,113 @@
'use client';
import {
Box,
Button,
Group,
Paper,
Stack,
TextInput,
Title,
Center,
Loader
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import umkmState from '../../../../../_state/ekonomi/umkm/umkm';
export default function EditKategoriProduk() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const state = useProxy(umkmState.kategoriProduk.findMany);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [nama, setNama] = useState("");
const [originalNama, setOriginalNama] = useState("");
useEffect(() => {
const init = async () => {
// Find the item from the existing list or load it if not available
if (state.data.length === 0) {
await state.load();
}
const item = state.data.find((v: any) => v.id === id);
if (item) {
setNama(item.nama);
setOriginalNama(item.nama);
}
setIsLoading(false);
};
init();
}, [id, state]);
const handleResetForm = () => {
setNama(originalNama);
};
const handleUpdate = async () => {
setIsSubmitting(true);
try {
umkmState.kategoriProduk.update.form.nama = nama;
const success = await umkmState.kategoriProduk.update.submit(id);
if (success) {
router.push('/admin/ekonomi/umkm/kategori-produk');
}
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<Center h={400}>
<Loader size="lg" />
</Center>
);
}
return (
<Box>
<Group mb="lg">
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={20} />}
>
Kembali
</Button>
<Title order={3}>Edit Kategori Produk</Title>
</Group>
<Paper withBorder p="xl" radius="md" shadow="sm" maw={600}>
<Stack gap="lg">
<TextInput
label="Nama Kategori"
placeholder="Contoh: Makanan, Minuman, Kerajinan"
required
value={nama}
onChange={(e) => setNama(e.target.value)}
/>
<Group justify="flex-end" mt="xl">
<Button variant="outline" color="gray" onClick={handleResetForm}>
Reset
</Button>
<Button
color="blue"
onClick={handleUpdate}
loading={isSubmitting}
disabled={!nama.trim()}
>
Simpan Perubahan
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import {
Box,
Button,
Group,
Paper,
Stack,
TextInput,
Title
} from '@mantine/core';
import { IconArrowBack } 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';
export default function CreateKategoriProduk() {
const router = useRouter();
const state = useProxy(umkmState.kategoriProduk.create);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleResetForm = () => {
state.form = {
nama: "",
};
};
const handleCreate = async () => {
setIsSubmitting(true);
try {
const success = await umkmState.kategoriProduk.create.submit();
if (success) {
handleResetForm();
router.push('/admin/ekonomi/umkm/kategori-produk');
}
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
}
};
return (
<Box>
<Group mb="lg">
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={20} />}
>
Kembali
</Button>
<Title order={3}>Tambah Kategori Produk Baru</Title>
</Group>
<Paper withBorder p="xl" radius="md" shadow="sm" maw={600}>
<Stack gap="lg">
<TextInput
label="Nama Kategori"
placeholder="Contoh: Makanan, Minuman, Kerajinan"
required
value={state.form.nama}
onChange={(e) => (state.form.nama = e.target.value)}
/>
<Group justify="flex-end" mt="xl">
<Button variant="outline" color="gray" onClick={handleResetForm}>
Reset
</Button>
<Button
color="blue"
onClick={handleCreate}
loading={isSubmitting}
>
Simpan Kategori
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,144 @@
'use client'
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
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';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
function KategoriProdukPage() {
const router = useRouter();
const [search, setSearch] = useState("");
const state = useProxy(umkmState.kategoriProduk.findMany);
const [debouncedSearch] = useDebouncedValue(search, 1000);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
useShallowEffect(() => {
state.load(state.page, 10, debouncedSearch);
}, [state.page, debouncedSearch]);
const handleHapus = async () => {
if (selectedId) {
const success = await umkmState.kategoriProduk.del.submit(selectedId);
if (success) {
setModalHapus(false);
setSelectedId(null);
}
}
};
return (
<Stack gap="lg">
<Group justify="space-between">
<Title order={3}>Kategori Produk</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
onClick={() => router.push('/admin/ekonomi/umkm/kategori-produk/create')}
>
Tambah Kategori
</Button>
</Group>
<Paper withBorder p="md" radius="md">
<TextInput
placeholder="Cari kategori..."
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 Kategori</TableTh>
<TableTh>Status</TableTh>
<TableTh>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{state.data.map((item) => (
<TableTr key={item.id}>
<TableTd fw={500}>{item.nama}</TableTd>
<TableTd>
<Badge color={item.isActive ? "green" : "red"}>
{item.isActive ? "Aktif" : "Nonaktif"}
</Badge>
</TableTd>
<TableTd>
<Group gap="xs">
<Button
variant="subtle"
color="blue"
size="xs"
onClick={() => router.push(`/admin/ekonomi/umkm/kategori-produk/${item.id}/edit`)}
>
<IconEdit size={16} />
</Button>
<Button
variant="subtle"
color="red"
size="xs"
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<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>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah Anda yakin ingin menghapus kategori ini? Kategori yang dihapus tidak akan muncul di pilihan kategori produk baru."
/>
</Stack>
);
}
export default KategoriProdukPage;