Compare commits

..

8 Commits

12 changed files with 394 additions and 149 deletions

View File

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

View File

@@ -0,0 +1,4 @@
-- Migrate PasarDesa.kategoriProdukId FK from KategoriProduk to KategoriProdukUmkm
ALTER TABLE "PasarDesa" DROP CONSTRAINT "PasarDesa_kategoriProdukId_fkey";
ALTER TABLE "PasarDesa" ADD CONSTRAINT "PasarDesa_kategoriProdukId_fkey" FOREIGN KEY ("kategoriProdukId") REFERENCES "KategoriProdukUmkm"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,12 @@
-- Seed KategoriProdukUmkm with any KategoriProduk entries referenced by PasarDesa
-- that are not yet in KategoriProdukUmkm
INSERT INTO "KategoriProdukUmkm" ("id", "nama", "createdAt", "updatedAt", "deletedAt", "isActive")
SELECT DISTINCT kp.id, kp.nama, kp."createdAt", kp."updatedAt", kp."deletedAt", kp."isActive"
FROM "KategoriProduk" kp
WHERE kp.id IN (
SELECT DISTINCT "kategoriProdukId" FROM "PasarDesa"
WHERE "kategoriProdukId" IS NOT NULL
)
AND NOT EXISTS (
SELECT 1 FROM "KategoriProdukUmkm" WHERE id = kp.id
);

View File

@@ -0,0 +1,20 @@
-- Step 1: Seed KategoriProdukUmkm from KategoriProduk for any PasarDesa-referenced entries not yet present
INSERT INTO "KategoriProdukUmkm" ("id", "nama", "createdAt", "updatedAt", "deletedAt", "isActive")
SELECT DISTINCT kp.id, kp.nama, kp."createdAt", kp."updatedAt", kp."deletedAt", kp."isActive"
FROM "KategoriProduk" kp
WHERE kp.id IN (
SELECT DISTINCT "kategoriProdukId" FROM "PasarDesa"
WHERE "kategoriProdukId" IS NOT NULL
)
AND NOT EXISTS (
SELECT 1 FROM "KategoriProdukUmkm" WHERE id = kp.id
);
-- Step 2: Make kategoriProdukId nullable to handle orphaned/legacy data
ALTER TABLE "PasarDesa" ALTER COLUMN "kategoriProdukId" DROP NOT NULL;
-- Step 3: Null out any remaining orphaned references (not in KategoriProdukUmkm)
UPDATE "PasarDesa"
SET "kategoriProdukId" = NULL
WHERE "kategoriProdukId" IS NOT NULL
AND "kategoriProdukId" NOT IN (SELECT id FROM "KategoriProdukUmkm");

View File

@@ -1445,8 +1445,8 @@ model PasarDesa {
deletedAt DateTime? deletedAt DateTime?
isActive Boolean @default(true) isActive Boolean @default(true)
kategoriProduk KategoriProduk @relation(fields: [kategoriProdukId], references: [id]) kategoriProduk KategoriProdukUmkm? @relation(fields: [kategoriProdukId], references: [id])
kategoriProdukId String kategoriProdukId String?
KategoriToPasar KategoriToPasar[] KategoriToPasar KategoriToPasar[]
} }
@@ -1458,7 +1458,6 @@ model KategoriProduk {
deletedAt DateTime? deletedAt DateTime?
isActive Boolean @default(true) isActive Boolean @default(true)
KategoriToPasar KategoriToPasar[] KategoriToPasar KategoriToPasar[]
PasarDesa PasarDesa[]
} }
model KategoriToPasar { model KategoriToPasar {
@@ -2448,6 +2447,7 @@ model KategoriProdukUmkm {
deletedAt DateTime? deletedAt DateTime?
isActive Boolean @default(true) isActive Boolean @default(true)
Umkm Umkm[] Umkm Umkm[]
PasarDesa PasarDesa[]
} }

View File

@@ -493,23 +493,31 @@ export const umkmState = proxy({
summary: { data: null as any, loading: false }, summary: { data: null as any, loading: false },
topProduk: { data: [] as any[], loading: false }, topProduk: { data: [] as any[], loading: false },
detail: { 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 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.kpi.loading = true;
this.summary.loading = true; this.summary.loading = true;
this.topProduk.loading = true; this.topProduk.loading = true;
this.detail.loading = true; this.detail.loading = true;
try { try {
const [kpi, sum, top, det] = await Promise.all([ 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/kpi?periode=${p}${modeParam}`).then(r => r.json()),
fetch(`/api/ekonomi/umkm/dashboard/ringkasan-penjualan?periode=${p}`).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}`).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}`).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 (kpi.success) this.kpi.data = kpi.data;
if (sum.success) this.summary.data = sum.data; if (sum.success) this.summary.data = sum.data;
if (top.success) this.topProduk.data = top.data; if (top.success) this.topProduk.data = top.data;
if (det.success) this.detail.data = det.data; if (det.success) this.detail.data = det.data;
this.mode = m;
} catch (e) { console.error(e); } finally { } catch (e) { console.error(e); } finally {
this.kpi.loading = false; this.kpi.loading = false;
this.summary.loading = false; this.summary.loading = false;

View File

@@ -1,10 +1,12 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
Badge,
Box, Box,
Card, Card,
Grid, Grid,
Group, Group,
Select,
SimpleGrid, SimpleGrid,
Skeleton, Skeleton,
Stack, Stack,
@@ -16,10 +18,11 @@ import {
TableTr, TableTr,
Text, Text,
Title, Title,
Badge SegmentedControl,
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowUpRight, IconArrowDownRight, IconMinus } from '@tabler/icons-react'; import { IconArrowUpRight, IconArrowDownRight, IconMinus } from '@tabler/icons-react';
import { useState, useEffect } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import umkmState from '../../../_state/ekonomi/umkm/umkm'; import umkmState from '../../../_state/ekonomi/umkm/umkm';
@@ -27,10 +30,25 @@ import umkmState from '../../../_state/ekonomi/umkm/umkm';
function UmkmDashboard() { function UmkmDashboard() {
const state = useProxy(umkmState.dashboard); 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(() => { useShallowEffect(() => {
state.loadAll(); 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) { if (state.kpi.loading || !state.kpi.data) {
return <Skeleton height={400} radius="md" />; return <Skeleton height={400} radius="md" />;
} }
@@ -42,6 +60,18 @@ function UmkmDashboard() {
return ( return (
<Stack gap="lg"> <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 }}> <SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
<KpiCard title="UMKM Aktif" value={kpi.umkmAktif} subValue={`Total: ${kpi.totalUmkm}`} /> <KpiCard title="UMKM Aktif" value={kpi.umkmAktif} subValue={`Total: ${kpi.totalUmkm}`} />
<KpiCard <KpiCard
@@ -49,8 +79,12 @@ function UmkmDashboard() {
value={`Rp ${kpi.omzetBulanan.toLocaleString()}`} value={`Rp ${kpi.omzetBulanan.toLocaleString()}`}
trend={summary?.persentasePerubahan} trend={summary?.persentasePerubahan}
/> />
<KpiCard title="Produk Aktif" value={summary?.produkAktif || 0} /> <KpiCard title="Kategori Aktif" value={summary?.kategoriAktif || 0} subValue="kategori" />
<KpiCard title="Kategori Populer" value={kpi.kategoriTerbanyak} /> <KpiCard
title="UMKM Terbanyak"
value={kpi.jumlahKategoriTerbanyak || 0}
subValue={kpi.kategoriTerbanyak}
/>
</SimpleGrid> </SimpleGrid>
<Grid> <Grid>
@@ -59,7 +93,7 @@ function UmkmDashboard() {
<Title order={4} mb="md">Grafik Penjualan per Produk</Title> <Title order={4} mb="md">Grafik Penjualan per Produk</Title>
<Box style={{ height: 350 }}> <Box style={{ height: 350 }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={detail.map(item => ({ <BarChart data={detail.map((item: any) => ({
name: item.namaProduk, name: item.namaProduk,
penjualan: item.penjualanBulanIni penjualan: item.penjualanBulanIni
}))}> }))}>
@@ -76,16 +110,21 @@ function UmkmDashboard() {
</Grid.Col> </Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}> <Grid.Col span={{ base: 12, md: 4 }}>
<Card h={"100%"} withBorder radius="md" p="lg" shadow="sm"> <Card h="100%" withBorder radius="md" p="lg" shadow="sm">
<Title order={4} mb="md">Top 3 Produk</Title> <Title order={4} mb="md">Top 3 Produk Terlaris</Title>
<Stack gap="sm"> <Stack gap="sm">
{topProduk.map((item, i) => ( {topProduk.map((item: any, i: number) => (
<Group key={i} justify="space-between"> <Group key={i} justify="space-between" wrap="nowrap">
<Box> <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">{item.namaUmkm}</Text>
<Text size="xs" c="dimmed">
Rp {item.totalPenjualan.toLocaleString()} · {item.jumlahTerjual} terjual
</Text>
</Box> </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> </Group>
))} ))}
</Stack> </Stack>
@@ -94,24 +133,62 @@ function UmkmDashboard() {
<Grid.Col span={{ base: 12, md: 8 }}> <Grid.Col span={{ base: 12, md: 8 }}>
<Card withBorder radius="md" p="lg" shadow="sm"> <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' }}> <Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover> <Table highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>Produk</TableTh> <TableTh>Produk</TableTh>
<TableTh>Penjualan</TableTh> <TableTh>Penjualan Bulan Ini</TableTh>
<TableTh>Bulan Lalu</TableTh>
<TableTh>Trend</TableTh> <TableTh>Trend</TableTh>
<TableTh>Volume</TableTh>
<TableTh>Stok</TableTh> <TableTh>Stok</TableTh>
<TableTh>Status</TableTh> <TableTh>Status</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{detail.map((item, i) => ( {detail.map((item: any, i: number) => (
<TableTr key={i}> <TableTr key={i}>
<TableTd>{item.namaProduk}</TableTd> <TableTd>{item.namaProduk}</TableTd>
<TableTd>Rp {item.penjualanBulanIni.toLocaleString()}</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>{item.stok}</TableTd>
<TableTd> <TableTd>
<Badge color={getStatusColor(item.statusStok)}> <Badge color={getStatusColor(item.statusStok)}>

View File

@@ -1,49 +1,86 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
function getWeekRange(offsetWeeks = 0) {
const now = new Date();
const day = now.getDay();
const diffToMonday = day === 0 ? -6 : 1 - day;
const monday = new Date(now);
monday.setDate(now.getDate() + diffToMonday + offsetWeeks * 7);
monday.setHours(0, 0, 0, 0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
sunday.setHours(23, 59, 59, 999);
return { start: monday, end: sunday };
}
async function umkmDashboardDetailPenjualan(context: Context) { async function umkmDashboardDetailPenjualan(context: Context) {
const periode = (context.query.periode as string) || const mode = context.query.mode as string | undefined;
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; const isWeek = mode === "week";
const periode =
(context.query.periode as string) ||
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`;
const date = new Date(periode + "-01"); const date = new Date(periode + "-01");
date.setMonth(date.getMonth() - 1); date.setMonth(date.getMonth() - 1);
const periodeLalu = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; const periodeLalu = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
const kategoriId = context.query.kategoriId as string | undefined;
const umkmId = context.query.umkmId as string | undefined;
const whereSkrg = isWeek
? { createdAt: { gte: getWeekRange(0).start, lte: getWeekRange(0).end }, deletedAt: null }
: { periode, deletedAt: null };
const whereLalu = isWeek
? { createdAt: { gte: getWeekRange(-1).start, lte: getWeekRange(-1).end }, deletedAt: null }
: { periode: periodeLalu, deletedAt: null };
// Filter produk berdasarkan kategori dan/atau UMKM
const produkFilter: Record<string, unknown> = { deletedAt: null };
if (kategoriId) produkFilter.kategoriProdukId = kategoriId;
if (umkmId) produkFilter.umkmId = umkmId;
try { try {
// Ambil semua produk yang punya penjualan bulan ini atau bulan lalu
const [produkSkrg, produkLalu, allProduks] = await Promise.all([ const [produkSkrg, produkLalu, allProduks] = await Promise.all([
prisma.penjualanProduk.groupBy({ prisma.penjualanProduk.groupBy({
by: ['produkId'], by: ["produkId"],
where: { periode, deletedAt: null }, where: whereSkrg,
_sum: { totalNilai: true, jumlah: true } _sum: { totalNilai: true, jumlah: true },
}), }),
prisma.penjualanProduk.groupBy({ prisma.penjualanProduk.groupBy({
by: ['produkId'], by: ["produkId"],
where: { periode: periodeLalu, deletedAt: null }, where: whereLalu,
_sum: { totalNilai: true } _sum: { totalNilai: true },
}), }),
// Use PasarDesa
prisma.pasarDesa.findMany({ prisma.pasarDesa.findMany({
where: { deletedAt: null }, where: produkFilter,
select: { id: true, nama: true, stok: true } select: { id: true, nama: true, stok: true },
}) }),
]); ]);
const data = allProduks.map(p => { const data = allProduks.map((p) => {
const skrgRaw = produkSkrg.find(s => s.produkId === p.id)?._sum || { totalNilai: 0, jumlah: 0 }; const skrgRaw = produkSkrg.find((s) => s.produkId === p.id)?._sum || {};
const laluRaw = produkLalu.find(l => l.produkId === p.id)?._sum || { totalNilai: 0 }; const laluRaw = produkLalu.find((l) => l.produkId === p.id)?._sum || {};
const skrg = { const nilaiSkrg = (skrgRaw as any).totalNilai || 0;
totalNilai: (skrgRaw as any).totalNilai || 0, const nilaiLalu = (laluRaw as any).totalNilai || 0;
jumlah: (skrgRaw as any).jumlah || 0 const jumlah = (skrgRaw as any).jumlah || 0;
};
const lalu = {
totalNilai: (laluRaw as any).totalNilai || 0
};
let trend = "stable"; let trend = "stable";
if (skrg.totalNilai > lalu.totalNilai) trend = "up"; if (nilaiSkrg > nilaiLalu) trend = "up";
if (skrg.totalNilai < lalu.totalNilai) trend = "down"; if (nilaiSkrg < nilaiLalu) trend = "down";
let trendPersen = 0;
if (nilaiLalu > 0) {
trendPersen = Math.round(((nilaiSkrg - nilaiLalu) / nilaiLalu) * 10000) / 100;
} else if (nilaiSkrg > 0) {
trendPersen = 100;
}
let statusStok = "Aman"; let statusStok = "Aman";
if (p.stok < 5) statusStok = "Rendah"; if (p.stok < 5) statusStok = "Rendah";
@@ -51,19 +88,17 @@ async function umkmDashboardDetailPenjualan(context: Context) {
return { return {
namaProduk: p.nama, namaProduk: p.nama,
penjualanBulanIni: skrg.totalNilai, penjualanBulanIni: nilaiSkrg,
penjualanBulanLalu: lalu.totalNilai, penjualanBulanLalu: nilaiLalu,
trend, trend,
volume: skrg.jumlah, trendPersen,
volume: jumlah,
stok: p.stok, stok: p.stok,
statusStok statusStok,
}; };
}); });
return { return { success: true, data };
success: true,
data
};
} catch (e) { } catch (e) {
console.error("Error di umkmDashboardDetailPenjualan:", e); console.error("Error di umkmDashboardDetailPenjualan:", e);
return { success: false, message: "Gagal mengambil detail penjualan dashboard" }; return { success: false, message: "Gagal mengambil detail penjualan dashboard" };

View File

@@ -1,44 +1,55 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
function getWeekRange(offsetWeeks = 0) {
const now = new Date();
const day = now.getDay();
const diffToMonday = day === 0 ? -6 : 1 - day;
const monday = new Date(now);
monday.setDate(now.getDate() + diffToMonday + offsetWeeks * 7);
monday.setHours(0, 0, 0, 0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
sunday.setHours(23, 59, 59, 999);
return { start: monday, end: sunday };
}
async function umkmDashboardKpi(context: Context) { async function umkmDashboardKpi(context: Context) {
const mode = context.query.mode as string | undefined;
const isWeek = mode === "week";
const periode = const periode =
(context.query.periode as string) || (context.query.periode as string) ||
`${new Date().getFullYear()}-${String( `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`;
new Date().getMonth() + 1
).padStart(2, "0")}`; const whereClause = isWeek
? { createdAt: { gte: getWeekRange(0).start, lte: getWeekRange(0).end }, deletedAt: null }
: { periode, deletedAt: null };
try { try {
// KPI utama
const [umkmAktif, totalUmkm, omzetBulanan] = await Promise.all([ const [umkmAktif, totalUmkm, omzetBulanan] = await Promise.all([
prisma.umkm.count({ prisma.umkm.count({ where: { isActive: true, deletedAt: null } }),
where: { isActive: true, deletedAt: null }, prisma.umkm.count({ where: { deletedAt: null } }),
}),
prisma.umkm.count({
where: { deletedAt: null },
}),
prisma.penjualanProduk.aggregate({ prisma.penjualanProduk.aggregate({
where: { periode, deletedAt: null }, where: whereClause,
_sum: { totalNilai: true }, _sum: { totalNilai: true },
}), }),
]); ]);
// ========================= // Cari kategori dengan penjualan terbanyak
// 1. Cari kategori dari penjualan
// =========================
const salesByCategory = await prisma.penjualanProduk.findMany({ const salesByCategory = await prisma.penjualanProduk.findMany({
where: { periode, deletedAt: null }, where: whereClause,
select: { select: {
jumlah: true, jumlah: true,
produk: { produk: { select: { kategoriProdukId: true } },
select: {
kategoriProdukId: true,
},
},
}, },
}); });
let kategoriNama = "-"; let kategoriNama = "-";
let topCategoryId: string | null = null;
if (salesByCategory.length > 0) { if (salesByCategory.length > 0) {
const categoryCounts: Record<string, number> = {}; const categoryCounts: Record<string, number> = {};
@@ -46,15 +57,10 @@ async function umkmDashboardKpi(context: Context) {
for (const sale of salesByCategory) { for (const sale of salesByCategory) {
const catId = sale.produk.kategoriProdukId; const catId = sale.produk.kategoriProdukId;
if (!catId) continue; if (!catId) continue;
categoryCounts[catId] = (categoryCounts[catId] || 0) + sale.jumlah;
categoryCounts[catId] =
(categoryCounts[catId] || 0) + sale.jumlah;
} }
// cari kategori dengan penjualan tertinggi
let topCategoryId: string | null = null;
let maxSales = 0; let maxSales = 0;
for (const [id, count] of Object.entries(categoryCounts)) { for (const [id, count] of Object.entries(categoryCounts)) {
if (count > maxSales) { if (count > maxSales) {
maxSales = count; maxSales = count;
@@ -63,38 +69,40 @@ async function umkmDashboardKpi(context: Context) {
} }
if (topCategoryId) { if (topCategoryId) {
const kategori = await prisma.kategoriProduk.findUnique({ const kategori = await prisma.kategoriProdukUmkm.findUnique({
where: { id: topCategoryId }, where: { id: topCategoryId },
select: { nama: true }, select: { nama: true },
}); });
kategoriNama = kategori?.nama || "-"; kategoriNama = kategori?.nama || "-";
} }
} }
// ========================= // Fallback: kategori dari UMKM terbanyak
// 2. Fallback (kalau tidak ada penjualan)
// =========================
if (kategoriNama === "-") { if (kategoriNama === "-") {
const kategoriTerbanyakUmkm = await prisma.umkm.groupBy({ const kategoriTerbanyakUmkm = await prisma.umkm.groupBy({
by: ["kategoriId"], by: ["kategoriId"],
_count: { _all: true }, _count: { _all: true },
orderBy: { orderBy: { _count: { kategoriId: "desc" } },
_count: { kategoriId: "desc" },
},
take: 1, take: 1,
}); });
if (kategoriTerbanyakUmkm.length > 0) { if (kategoriTerbanyakUmkm.length > 0) {
topCategoryId = kategoriTerbanyakUmkm[0].kategoriId;
const kategori = await prisma.kategoriProdukUmkm.findUnique({ const kategori = await prisma.kategoriProdukUmkm.findUnique({
where: { id: kategoriTerbanyakUmkm[0].kategoriId }, where: { id: topCategoryId },
select: { nama: true }, select: { nama: true },
}); });
kategoriNama = kategori?.nama || "-"; kategoriNama = kategori?.nama || "-";
} }
} }
// Hitung jumlah produk dalam kategori terbanyak
const jumlahKategoriTerbanyak = topCategoryId
? await prisma.pasarDesa.count({
where: { kategoriProdukId: topCategoryId, deletedAt: null },
})
: 0;
return { return {
success: true, success: true,
data: { data: {
@@ -102,14 +110,12 @@ async function umkmDashboardKpi(context: Context) {
totalUmkm, totalUmkm,
omzetBulanan: omzetBulanan._sum.totalNilai || 0, omzetBulanan: omzetBulanan._sum.totalNilai || 0,
kategoriTerbanyak: kategoriNama, kategoriTerbanyak: kategoriNama,
jumlahKategoriTerbanyak,
}, },
}; };
} catch (e) { } catch (e) {
console.error("Error di umkmDashboardKpi:", e); console.error("Error di umkmDashboardKpi:", e);
return { return { success: false, message: "Gagal mengambil data KPI dashboard" };
success: false,
message: "Gagal mengambil data KPI dashboard",
};
} }
} }

View File

@@ -1,30 +1,58 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
function getWeekRange(offsetWeeks = 0) {
const now = new Date();
const day = now.getDay();
const diffToMonday = day === 0 ? -6 : 1 - day;
const monday = new Date(now);
monday.setDate(now.getDate() + diffToMonday + offsetWeeks * 7);
monday.setHours(0, 0, 0, 0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
sunday.setHours(23, 59, 59, 999);
return { start: monday, end: sunday };
}
async function umkmDashboardRingSummary(context: Context) { async function umkmDashboardRingSummary(context: Context) {
const periode = (context.query.periode as string) || const mode = context.query.mode as string | undefined;
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; const isWeek = mode === "week";
const periode =
(context.query.periode as string) ||
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`;
// Hitung periode bulan lalu // Hitung periode bulan lalu
const date = new Date(periode + "-01"); const date = new Date(periode + "-01");
date.setMonth(date.getMonth() - 1); date.setMonth(date.getMonth() - 1);
const periodeLalu = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; const periodeLalu = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
const whereSkrg = isWeek
? { createdAt: { gte: getWeekRange(0).start, lte: getWeekRange(0).end }, deletedAt: null }
: { periode, deletedAt: null };
const whereLalu = isWeek
? { createdAt: { gte: getWeekRange(-1).start, lte: getWeekRange(-1).end }, deletedAt: null }
: { periode: periodeLalu, deletedAt: null };
try { try {
const [penjualanSkrg, penjualanLalu, produkAktif, totalTransaksi] = await Promise.all([ const [penjualanSkrg, penjualanLalu, kategoriAktif, totalTransaksi] = await Promise.all([
prisma.penjualanProduk.aggregate({ prisma.penjualanProduk.aggregate({
where: { periode, deletedAt: null }, where: whereSkrg,
_sum: { totalNilai: true } _sum: { totalNilai: true },
}), }),
prisma.penjualanProduk.aggregate({ prisma.penjualanProduk.aggregate({
where: { periode: periodeLalu, deletedAt: null }, where: whereLalu,
_sum: { totalNilai: true } _sum: { totalNilai: true },
}), }),
// Count from PasarDesa // Hitung jumlah kategori aktif
prisma.pasarDesa.count({ prisma.kategoriProdukUmkm.count({
where: { isActive: true, deletedAt: null } where: { isActive: true, deletedAt: null },
}), }),
prisma.penjualanProduk.count({ where: { periode, deletedAt: null } }) prisma.penjualanProduk.count({ where: whereSkrg }),
]); ]);
const skrg = penjualanSkrg._sum.totalNilai || 0; const skrg = penjualanSkrg._sum.totalNilai || 0;
@@ -42,9 +70,9 @@ async function umkmDashboardRingSummary(context: Context) {
data: { data: {
totalPenjualan: skrg, totalPenjualan: skrg,
persentasePerubahan: Math.round(persentasePerubahan * 100) / 100, persentasePerubahan: Math.round(persentasePerubahan * 100) / 100,
produkAktif, kategoriAktif,
totalTransaksi totalTransaksi,
} },
}; };
} catch (e) { } catch (e) {
console.error("Error di umkmDashboardRingSummary:", e); console.error("Error di umkmDashboardRingSummary:", e);

View File

@@ -1,39 +1,91 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
function getWeekRange(offsetWeeks = 0) {
const now = new Date();
const day = now.getDay();
const diffToMonday = day === 0 ? -6 : 1 - day;
const monday = new Date(now);
monday.setDate(now.getDate() + diffToMonday + offsetWeeks * 7);
monday.setHours(0, 0, 0, 0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
sunday.setHours(23, 59, 59, 999);
return { start: monday, end: sunday };
}
async function umkmDashboardTopProduk(context: Context) { async function umkmDashboardTopProduk(context: Context) {
const periode = (context.query.periode as string) || const mode = context.query.mode as string | undefined;
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; const isWeek = mode === "week";
const periode =
(context.query.periode as string) ||
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`;
const date = new Date(periode + "-01");
date.setMonth(date.getMonth() - 1);
const periodeLalu = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
const whereSkrg = isWeek
? { createdAt: { gte: getWeekRange(0).start, lte: getWeekRange(0).end }, deletedAt: null }
: { periode, deletedAt: null };
const whereLalu = isWeek
? { createdAt: { gte: getWeekRange(-1).start, lte: getWeekRange(-1).end }, deletedAt: null }
: { periode: periodeLalu, deletedAt: null };
try { try {
const topPenjualan = await prisma.penjualanProduk.groupBy({ const topPenjualan = await prisma.penjualanProduk.groupBy({
by: ['produkId'], by: ["produkId"],
where: { periode, deletedAt: null }, where: whereSkrg,
_sum: { totalNilai: true, jumlah: true }, _sum: { totalNilai: true, jumlah: true },
orderBy: { _sum: { totalNilai: 'desc' } }, orderBy: { _sum: { totalNilai: "desc" } },
take: 3 take: 3,
}); });
const data = await Promise.all(topPenjualan.map(async (item) => { // Ambil penjualan periode lalu untuk produk yang sama
// Find from PasarDesa now const produkIds = topPenjualan.map((p) => p.produkId);
const penjualanLalu = await prisma.penjualanProduk.groupBy({
by: ["produkId"],
where: { ...whereLalu, produkId: { in: produkIds } },
_sum: { totalNilai: true },
});
const laluMap = new Map(
penjualanLalu.map((p) => [p.produkId, p._sum.totalNilai || 0])
);
const data = await Promise.all(
topPenjualan.map(async (item) => {
const produk = await prisma.pasarDesa.findUnique({ const produk = await prisma.pasarDesa.findUnique({
where: { id: item.produkId }, where: { id: item.produkId },
include: { umkm: true } include: { umkm: true },
}); });
const totalSkrg = item._sum.totalNilai || 0;
const totalLalu = laluMap.get(item.produkId) || 0;
let growth = 0;
if (totalLalu > 0) {
growth = Math.round(((totalSkrg - totalLalu) / totalLalu) * 10000) / 100;
} else if (totalSkrg > 0) {
growth = 100;
}
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: totalSkrg,
jumlahTerjual: item._sum.jumlah || 0, jumlahTerjual: item._sum.jumlah || 0,
growth: 0 growth,
}; };
})); })
);
return { return { success: true, data };
success: true,
data
};
} catch (e) { } catch (e) {
console.error("Error di umkmDashboardTopProduk:", e); console.error("Error di umkmDashboardTopProduk:", e);
return { success: false, message: "Gagal mengambil top produk" }; return { success: false, message: "Gagal mengambil top produk" };

View File

@@ -174,7 +174,10 @@ function DetailProdukPasarUser() {
{data.stok > 0 ? `Stok: ${data.stok}` : 'Stok Habis'} {data.stok > 0 ? `Stok: ${data.stok}` : 'Stok Habis'}
</Badge> </Badge>
<Text>{data.deskripsi}</Text> <Text
style={{ wordBreak: 'break-word' }}
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
/>
<Button <Button
leftSection={<IconShoppingCart />} leftSection={<IconShoppingCart />}