feat(admin-ui): implement UMKM admin dashboard and CRUD pages

This commit is contained in:
2026-04-20 17:15:54 +08:00
parent 62aa9b63b2
commit b673e36a45
11 changed files with 966 additions and 2 deletions

View File

@@ -0,0 +1,168 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import {
Box,
ScrollArea,
Stack,
Tabs,
TabsList,
TabsPanel,
TabsTab,
Title
} from '@mantine/core';
import { IconDashboard, IconBuildingStore, IconPackage, IconShoppingCart } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
function LayoutTabs({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const tabs = [
{
label: "Dashboard",
value: "dashboard",
href: "/admin/ekonomi/umkm/dashboard",
icon: <IconDashboard size={18} stroke={1.8} />
},
{
label: "Data UMKM",
value: "data-umkm",
href: "/admin/ekonomi/umkm/data-umkm",
icon: <IconBuildingStore size={18} stroke={1.8} />
},
{
label: "Produk",
value: "produk",
href: "/admin/ekonomi/umkm/produk",
icon: <IconPackage size={18} stroke={1.8} />
},
{
label: "Penjualan",
value: "penjualan",
href: "/admin/ekonomi/umkm/penjualan",
icon: <IconShoppingCart size={18} stroke={1.8} />
},
];
const currentTab = tabs.find((tab) => pathname.startsWith(tab.href));
const [activeTab, setActiveTab] = useState<string | null>(
currentTab?.value || tabs[0].value
);
const handleTabChange = (value: string | null) => {
const tab = tabs.find((t) => t.value === value);
if (tab) {
router.push(tab.href);
}
setActiveTab(value);
};
useEffect(() => {
const match = tabs.find((tab) => pathname.startsWith(tab.href));
if (match) {
setActiveTab(match.value);
}
}, [pathname]);
return (
<Stack gap="lg">
<Title order={2} fw={700} style={{ color: "#1A1B1E" }}>
Manajemen UMKM
</Title>
<Tabs
color={colors['blue-button']}
variant="pills"
value={activeTab}
onChange={handleTabChange}
radius="lg"
keepMounted={false}
>
<Box visibleFrom='md' pb={10}>
<ScrollArea type="auto" offsetScrollbars>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem",
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
flexShrink: 0,
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
</Box>
<Box hiddenFrom='md' pb={10}>
<ScrollArea type="auto" offsetScrollbars={false} w="100%">
<TabsList
p="xs"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
width: "max-content",
maxWidth: "100%",
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
paddingInline: "0.75rem",
flexShrink: 0,
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
</Box>
{tabs.map((tab, i) => (
<TabsPanel
key={i}
value={tab.value}
style={{
padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}}
>
{children}
</TabsPanel>
))}
</Tabs>
</Stack>
);
}
export default LayoutTabs;

View File

@@ -0,0 +1,140 @@
'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 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={{ base: 12, md: 4 }}>
<Card withBorder radius="md" p="lg" shadow="sm">
<Title order={4} mb="md">Top 3 Produk</Title>
<Stack gap="sm">
{topProduk.map((item, i) => (
<Group key={i} justify="space-between">
<Box>
<Text fw={500}>{item.namaProduk}</Text>
<Text size="xs" c="dimmed">{item.namaUmkm}</Text>
</Box>
<Text fw={600} c="blue">Rp {item.totalPenjualan.toLocaleString()}</Text>
</Group>
))}
</Stack>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 8 }}>
<Card withBorder radius="md" p="lg" shadow="sm">
<Title order={4} mb="md">Detail Penjualan & Stok</Title>
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Produk</TableTh>
<TableTh>Penjualan</TableTh>
<TableTh>Trend</TableTh>
<TableTh>Stok</TableTh>
<TableTh>Status</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{detail.map((item, i) => (
<TableTr key={i}>
<TableTd>{item.namaProduk}</TableTd>
<TableTd>Rp {item.penjualanBulanIni.toLocaleString()}</TableTd>
<TableTd>{renderTrend(item.trend)}</TableTd>
<TableTd>{item.stok}</TableTd>
<TableTd>
<Badge color={getStatusColor(item.statusStok)}>
{item.statusStok}
</Badge>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</Box>
</Card>
</Grid.Col>
</Grid>
</Stack>
);
}
function KpiCard({ title, value, subValue, trend }: any) {
return (
<Card withBorder radius="md" p="lg" shadow="sm">
<Text size="xs" c="dimmed" fw={700} tt="uppercase">{title}</Text>
<Group align="flex-end" gap="xs" mt="sm">
<Text fz="xl" fw={700} lh={1}>{value}</Text>
{trend !== undefined && (
<Text c={trend >= 0 ? 'teal' : 'red'} fz="sm" fw={500}>
{trend >= 0 ? '+' : ''}{trend}%
</Text>
)}
</Group>
{subValue && <Text size="xs" c="dimmed" mt={4}>{subValue}</Text>}
</Card>
);
}
function renderTrend(trend: string) {
if (trend === 'up') return <IconArrowUpRight size={18} color="green" />;
if (trend === 'down') return <IconArrowDownRight size={18} color="red" />;
return <IconMinus size={18} color="gray" />;
}
function getStatusColor(status: string) {
if (status === 'Aman') return 'green';
if (status === 'Menipis') return 'yellow';
return 'red';
}
export default UmkmDashboard;

View File

@@ -0,0 +1,105 @@
'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 { useState } from 'react';
import { useProxy } from 'valtio/utils';
import umkmState from '../../../_state/ekonomi/umkm/umkm';
function DataUmkm() {
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">
Tambah UMKM
</Button>
</Group>
<Paper withBorder p="md" radius="md">
<TextInput
placeholder="Cari UMKM atau Pemilik..."
leftSection={<IconSearch size={18} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
mb="md"
/>
{state.loading ? (
<Skeleton height={400} />
) : (
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Nama UMKM</TableTh>
<TableTh>Pemilik</TableTh>
<TableTh>Kategori</TableTh>
<TableTh>Kontak</TableTh>
<TableTh>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{state.data.map((item) => (
<TableTr key={item.id}>
<TableTd fw={500}>{item.nama}</TableTd>
<TableTd>{item.pemilik}</TableTd>
<TableTd>{item.kategori?.nama || '-'}</TableTd>
<TableTd>{item.kontak || '-'}</TableTd>
<TableTd>
<Group gap="xs">
<Button variant="subtle" color="blue" size="xs">
<IconEdit size={16} />
</Button>
<Button variant="subtle" color="red" size="xs">
<IconTrash size={16} />
</Button>
</Group>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</Box>
)}
<Center mt="md">
<Pagination
total={state.totalPages}
value={state.page}
onChange={(p) => state.load(p, 10, debouncedSearch)}
/>
</Center>
</Paper>
</Stack>
);
}
export default DataUmkm;

View File

@@ -0,0 +1,28 @@
'use client'
import { usePathname } from "next/navigation";
import LayoutTabs from "./_lib/layoutTabs"
import { Box } from "@mantine/core";
export default function Layout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const segments = pathname.split('/').filter(Boolean);
// Path /admin/ekonomi/umkm/dashboard -> length 4
// Path detail usually adds an ID -> length >= 5
const isDetailPage = segments.length >= 5;
if (isDetailPage) {
return (
<Box>
{children}
</Box>
);
}
return (
<LayoutTabs>
{children}
</LayoutTabs>
)
}

View File

@@ -0,0 +1,89 @@
'use client'
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconPlus, IconTrash } from '@tabler/icons-react';
import { useProxy } from 'valtio/utils';
import umkmState from '../../../_state/ekonomi/umkm/umkm';
function PenjualanUmkm() {
const state = useProxy(umkmState.penjualan.findMany);
useShallowEffect(() => {
state.load(state.page, 10);
}, [state.page]);
return (
<Stack gap="lg">
<Group justify="space-between">
<Title order={3}>Histori Penjualan UMKM</Title>
<Button leftSection={<IconPlus size={18} />} color="blue">
Catat Penjualan
</Button>
</Group>
<Paper withBorder p="md" radius="md">
{state.loading ? (
<Skeleton height={400} />
) : (
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Tanggal</TableTh>
<TableTh>Produk</TableTh>
<TableTh>UMKM</TableTh>
<TableTh>Jumlah</TableTh>
<TableTh>Total Nilai</TableTh>
<TableTh>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{state.data.map((item) => (
<TableTr key={item.id}>
<TableTd>{new Date(item.tanggal).toLocaleDateString('id-ID')}</TableTd>
<TableTd fw={500}>{item.produk?.nama}</TableTd>
<TableTd>{item.produk?.umkm?.nama}</TableTd>
<TableTd>{item.jumlah}</TableTd>
<TableTd fw={600}>Rp {item.totalNilai.toLocaleString()}</TableTd>
<TableTd>
<Button variant="subtle" color="red" size="xs">
<IconTrash size={16} />
</Button>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</Box>
)}
<Center mt="md">
<Pagination
total={state.totalPages}
value={state.page}
onChange={(p) => state.load(p, 10)}
/>
</Center>
</Paper>
</Stack>
);
}
export default PenjualanUmkm;

View File

@@ -0,0 +1,111 @@
'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 { useState } from 'react';
import { useProxy } from 'valtio/utils';
import umkmState from '../../../_state/ekonomi/umkm/umkm';
function ProdukUmkm() {
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">
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;