feat(umkm): dashboard frontend - mode toggle, kategoriAktif, jumlahKategoriTerbanyak, growth badge, trendPersen, filter dropdowns - bump to 0.1.41
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "desa-darmasaba",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -493,23 +493,31 @@ export const umkmState = proxy({
|
||||
summary: { data: null as any, loading: false },
|
||||
topProduk: { data: [] as any[], loading: false },
|
||||
detail: { data: [] as any[], loading: false },
|
||||
async loadAll(periode = "") {
|
||||
mode: "month" as "week" | "month",
|
||||
async loadAll(periode = "", mode?: "week" | "month", kategoriId = "", umkmId = "") {
|
||||
const p = periode || `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
|
||||
const m = mode ?? this.mode;
|
||||
const modeParam = m === "week" ? "&mode=week" : "";
|
||||
const detailFilter = [
|
||||
kategoriId ? `&kategoriId=${kategoriId}` : "",
|
||||
umkmId ? `&umkmId=${umkmId}` : "",
|
||||
].join("");
|
||||
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())
|
||||
fetch(`/api/ekonomi/umkm/dashboard/kpi?periode=${p}${modeParam}`).then(r => r.json()),
|
||||
fetch(`/api/ekonomi/umkm/dashboard/ringkasan-penjualan?periode=${p}${modeParam}`).then(r => r.json()),
|
||||
fetch(`/api/ekonomi/umkm/dashboard/top-produk?periode=${p}${modeParam}`).then(r => r.json()),
|
||||
fetch(`/api/ekonomi/umkm/dashboard/detail-penjualan?periode=${p}${modeParam}${detailFilter}`).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;
|
||||
this.mode = m;
|
||||
} catch (e) { console.error(e); } finally {
|
||||
this.kpi.loading = false;
|
||||
this.summary.loading = false;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Grid,
|
||||
Group,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
@@ -16,10 +18,11 @@ import {
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
Badge
|
||||
SegmentedControl,
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowUpRight, IconArrowDownRight, IconMinus } from '@tabler/icons-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import umkmState from '../../../_state/ekonomi/umkm/umkm';
|
||||
@@ -27,10 +30,25 @@ import umkmState from '../../../_state/ekonomi/umkm/umkm';
|
||||
function UmkmDashboard() {
|
||||
const state = useProxy(umkmState.dashboard);
|
||||
|
||||
const [kategoriId, setKategoriId] = useState("");
|
||||
const [umkmId, setUmkmId] = useState("");
|
||||
const [kategoriList, setKategoriList] = useState<{ value: string; label: string }[]>([]);
|
||||
const [umkmList, setUmkmList] = useState<{ value: string; label: string }[]>([]);
|
||||
|
||||
useShallowEffect(() => {
|
||||
state.loadAll();
|
||||
fetch('/api/ekonomi/kategoriproduk/find-many-all').then(r => r.json()).then(res => {
|
||||
if (res.success) setKategoriList(res.data.map((k: any) => ({ value: k.id, label: k.nama })));
|
||||
});
|
||||
fetch('/api/ekonomi/umkm/find-many-all').then(r => r.json()).then(res => {
|
||||
if (res.success) setUmkmList(res.data.map((u: any) => ({ value: u.id, label: u.nama })));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.kpi.data) state.loadAll("", state.mode, kategoriId, umkmId);
|
||||
}, [kategoriId, umkmId]);
|
||||
|
||||
if (state.kpi.loading || !state.kpi.data) {
|
||||
return <Skeleton height={400} radius="md" />;
|
||||
}
|
||||
@@ -42,15 +60,31 @@ function UmkmDashboard() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between">
|
||||
<Title order={4}>Update Penjualan Produk</Title>
|
||||
<SegmentedControl
|
||||
value={state.mode}
|
||||
onChange={(v) => state.loadAll("", v as "week" | "month", kategoriId, umkmId)}
|
||||
data={[
|
||||
{ label: 'Minggu ini', value: 'week' },
|
||||
{ label: 'Bulan ini', value: 'month' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<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="Omzet Bulan Ini"
|
||||
value={`Rp ${kpi.omzetBulanan.toLocaleString()}`}
|
||||
trend={summary?.persentasePerubahan}
|
||||
/>
|
||||
<KpiCard title="Kategori Aktif" value={summary?.kategoriAktif || 0} subValue="kategori" />
|
||||
<KpiCard
|
||||
title="UMKM Terbanyak"
|
||||
value={kpi.jumlahKategoriTerbanyak || 0}
|
||||
subValue={kpi.kategoriTerbanyak}
|
||||
/>
|
||||
<KpiCard title="Produk Aktif" value={summary?.produkAktif || 0} />
|
||||
<KpiCard title="Kategori Populer" value={kpi.kategoriTerbanyak} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Grid>
|
||||
@@ -59,7 +93,7 @@ function UmkmDashboard() {
|
||||
<Title order={4} mb="md">Grafik Penjualan per Produk</Title>
|
||||
<Box style={{ height: 350 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={detail.map(item => ({
|
||||
<BarChart data={detail.map((item: any) => ({
|
||||
name: item.namaProduk,
|
||||
penjualan: item.penjualanBulanIni
|
||||
}))}>
|
||||
@@ -76,16 +110,21 @@ function UmkmDashboard() {
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<Card h={"100%"} withBorder radius="md" p="lg" shadow="sm">
|
||||
<Title order={4} mb="md">Top 3 Produk</Title>
|
||||
<Card h="100%" withBorder radius="md" p="lg" shadow="sm">
|
||||
<Title order={4} mb="md">Top 3 Produk Terlaris</Title>
|
||||
<Stack gap="sm">
|
||||
{topProduk.map((item, i) => (
|
||||
<Group key={i} justify="space-between">
|
||||
{topProduk.map((item: any, i: number) => (
|
||||
<Group key={i} justify="space-between" wrap="nowrap">
|
||||
<Box>
|
||||
<Text fw={500}>{item.namaProduk}</Text>
|
||||
<Text fw={500} size="sm">{item.namaProduk}</Text>
|
||||
<Text size="xs" c="dimmed">{item.namaUmkm}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Rp {item.totalPenjualan.toLocaleString()} · {item.jumlahTerjual} terjual
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fw={600} c="blue">Rp {item.totalPenjualan.toLocaleString()}</Text>
|
||||
<Badge color={item.growth >= 0 ? 'teal' : 'red'} variant="light" size="sm">
|
||||
{item.growth >= 0 ? '+' : ''}{item.growth}%
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -94,24 +133,62 @@ function UmkmDashboard() {
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 8 }}>
|
||||
<Card withBorder radius="md" p="lg" shadow="sm">
|
||||
<Title order={4} mb="md">Detail Penjualan & Stok</Title>
|
||||
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
||||
<Title order={4}>Detail Penjualan Produk</Title>
|
||||
<Group gap="sm">
|
||||
<Select
|
||||
placeholder="Semua Kategori"
|
||||
data={kategoriList}
|
||||
value={kategoriId || null}
|
||||
onChange={(v) => setKategoriId(v || "")}
|
||||
clearable
|
||||
size="xs"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Semua UMKM"
|
||||
data={umkmList}
|
||||
value={umkmId || null}
|
||||
onChange={(v) => setUmkmId(v || "")}
|
||||
clearable
|
||||
size="xs"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>Produk</TableTh>
|
||||
<TableTh>Penjualan</TableTh>
|
||||
<TableTh>Penjualan Bulan Ini</TableTh>
|
||||
<TableTh>Bulan Lalu</TableTh>
|
||||
<TableTh>Trend</TableTh>
|
||||
<TableTh>Volume</TableTh>
|
||||
<TableTh>Stok</TableTh>
|
||||
<TableTh>Status</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{detail.map((item, i) => (
|
||||
{detail.map((item: any, i: number) => (
|
||||
<TableTr key={i}>
|
||||
<TableTd>{item.namaProduk}</TableTd>
|
||||
<TableTd>Rp {item.penjualanBulanIni.toLocaleString()}</TableTd>
|
||||
<TableTd>{renderTrend(item.trend)}</TableTd>
|
||||
<TableTd>Rp {item.penjualanBulanLalu.toLocaleString()}</TableTd>
|
||||
<TableTd>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{renderTrend(item.trend)}
|
||||
{item.trendPersen !== 0 && (
|
||||
<Text
|
||||
size="xs"
|
||||
c={item.trend === 'up' ? 'green' : item.trend === 'down' ? 'red' : 'dimmed'}
|
||||
>
|
||||
{item.trendPersen > 0 ? '+' : ''}{item.trendPersen}%
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</TableTd>
|
||||
<TableTd>{item.volume}</TableTd>
|
||||
<TableTd>{item.stok}</TableTd>
|
||||
<TableTd>
|
||||
<Badge color={getStatusColor(item.statusStok)}>
|
||||
|
||||
Reference in New Issue
Block a user