1406 lines
75 KiB
TypeScript
1406 lines
75 KiB
TypeScript
import { StatCard } from '@/components/card/stat-card';
|
|
import { DatePicker } from '@/components/inputs';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { useCan } from '@/hooks/use-can';
|
|
import { generateRandomColors } from '@/lib/utils';
|
|
import { formatRupiah } from '@/lib/rupiah';
|
|
import { format } from 'date-fns';
|
|
import { Head, router } from '@inertiajs/react';
|
|
import {
|
|
Banknote,
|
|
Package,
|
|
ShoppingCart,
|
|
} from 'lucide-react';
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
Bar,
|
|
BarChart,
|
|
CartesianGrid,
|
|
Cell,
|
|
Line,
|
|
LineChart,
|
|
Pie,
|
|
PieChart,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
XAxis,
|
|
} from 'recharts';
|
|
|
|
type Filters = {
|
|
start_date?: string;
|
|
end_date?: string;
|
|
};
|
|
|
|
type AnalysisProps = {
|
|
filters: Filters;
|
|
attendance: {
|
|
total_employees: number;
|
|
percentage: number;
|
|
present: number;
|
|
absent: number;
|
|
on_leave: number;
|
|
};
|
|
isManager: boolean;
|
|
cashOverview: {
|
|
total_balance: number;
|
|
total_transactions: number;
|
|
total_deposit: number;
|
|
total_withdrawal: number;
|
|
};
|
|
rawMaterialStock: {
|
|
total_stock: number;
|
|
total_price: number;
|
|
by_unit: {
|
|
yard: number;
|
|
meter: number;
|
|
kilogram: number;
|
|
};
|
|
};
|
|
productStock: {
|
|
total_stock: number;
|
|
total_price: number;
|
|
by_type: {
|
|
stock?: number;
|
|
reject_stock?: number;
|
|
retail_stock?: number;
|
|
};
|
|
};
|
|
revenueByStockType: {
|
|
monthly: Array<{
|
|
month: string;
|
|
good: number;
|
|
reject: number;
|
|
retail: number;
|
|
}>;
|
|
totals: {
|
|
good: number;
|
|
reject: number;
|
|
retail: number;
|
|
};
|
|
};
|
|
revenueSummary: {
|
|
total_revenue: number;
|
|
total_discount: number;
|
|
total_deduction: number;
|
|
cogs: number;
|
|
gross: number;
|
|
net: number;
|
|
payroll_total: number;
|
|
expense_total: number;
|
|
total_orders: number;
|
|
avg_order: number;
|
|
};
|
|
monthlyRevenue: Array<{
|
|
month: string;
|
|
total: number;
|
|
gross: number;
|
|
net: number;
|
|
discount: number;
|
|
deduction: number;
|
|
cogs: number;
|
|
payroll: number;
|
|
expense: number;
|
|
}>;
|
|
monthlyRevenueByChannel: Array<{
|
|
month: string;
|
|
store: number;
|
|
shopee: number;
|
|
tiktok: number;
|
|
}>;
|
|
revenueByPaymentType: Array<{
|
|
payment_type: string;
|
|
label: string;
|
|
total: number;
|
|
}>;
|
|
expenseSummary: {
|
|
total: number;
|
|
purchase_total: number;
|
|
expense_total: number;
|
|
advance_total: number;
|
|
};
|
|
monthlyExpense: Array<{
|
|
month: string;
|
|
total: number;
|
|
purchase: number;
|
|
expense: number;
|
|
advance: number;
|
|
}>;
|
|
busyHours: Array<{
|
|
hour: string;
|
|
orders: number;
|
|
}>;
|
|
profitMetrics: {
|
|
total_orders: number;
|
|
total_products_sold: number;
|
|
hpp: number;
|
|
gross_profit: number;
|
|
net_profit: number;
|
|
profit_margin: number;
|
|
aov: number;
|
|
items_per_transaction: number;
|
|
payroll_total: number;
|
|
expense_total: number;
|
|
};
|
|
topSuppliers: Array<{
|
|
name: string;
|
|
total_amount: number;
|
|
purchase_count: number;
|
|
}>;
|
|
topCustomers: Array<{
|
|
name: string;
|
|
total_amount: number;
|
|
order_count: number;
|
|
}>;
|
|
topProducts: Array<{
|
|
name: string;
|
|
total_qty: number;
|
|
total_revenue: number;
|
|
}>;
|
|
marketingSales: Array<{
|
|
marketing_name: string;
|
|
total_orders: number;
|
|
total_products_sold: number;
|
|
total_revenue: number;
|
|
total_subtotal: number;
|
|
total_discount: number;
|
|
avg_order: number;
|
|
}>;
|
|
orderStats: {
|
|
by_channel: Array<{
|
|
channel: string;
|
|
label: string;
|
|
count: number;
|
|
total: number;
|
|
}>;
|
|
by_payment_type: Array<{
|
|
payment_type: string;
|
|
label: string;
|
|
count: number;
|
|
total: number;
|
|
}>;
|
|
by_marketing: Array<{
|
|
name: string;
|
|
count: number;
|
|
total: number;
|
|
}>;
|
|
by_status: Array<{
|
|
status: string;
|
|
label: string;
|
|
count: number;
|
|
}>;
|
|
};
|
|
revenueTrend: Array<{
|
|
date: string;
|
|
qty: number;
|
|
}>;
|
|
monthlyRetailRevenue: {
|
|
monthly: Array<{
|
|
month: string;
|
|
total: number;
|
|
gross: number;
|
|
discount: number;
|
|
deduction: number;
|
|
cogs: number;
|
|
}>;
|
|
summary: {
|
|
total: number;
|
|
discount: number;
|
|
deduction: number;
|
|
cogs: number;
|
|
gross: number;
|
|
};
|
|
};
|
|
};
|
|
|
|
const revenueChartConfig = (() => {
|
|
const colors = generateRandomColors(6);
|
|
return {
|
|
total: { label: 'Total', color: colors[0] },
|
|
gross: { label: 'Keuntungan Kotor', color: colors[1] },
|
|
net: { label: 'Keuntungan Bersih', color: colors[2] },
|
|
deduction: { label: 'Potongan Nego', color: colors[3] },
|
|
discount: { label: 'Diskon', color: colors[4] },
|
|
cogs: { label: 'HPP', color: colors[5] },
|
|
} satisfies ChartConfig;
|
|
})();
|
|
|
|
const revenueKeys = ['total', 'deduction', 'discount', 'cogs', 'gross', 'net'] as const;
|
|
|
|
const retailRevenueChartConfig = (() => {
|
|
const colors = generateRandomColors(5);
|
|
return {
|
|
total: { label: 'Total', color: colors[0] },
|
|
gross: { label: 'Keuntungan Kotor', color: colors[1] },
|
|
deduction: { label: 'Potongan Nego', color: colors[2] },
|
|
discount: { label: 'Diskon', color: colors[3] },
|
|
cogs: { label: 'HPP', color: colors[4] },
|
|
} satisfies ChartConfig;
|
|
})();
|
|
|
|
const retailRevenueKeys = ['total', 'deduction', 'discount', 'cogs', 'gross'] as const;
|
|
|
|
const expenseChartConfig = (() => {
|
|
const colors = generateRandomColors(4);
|
|
return {
|
|
total: { label: 'Total', color: colors[0] },
|
|
purchase: { label: 'Belanja', color: colors[1] },
|
|
expense: { label: 'Pengeluaran Toko', color: colors[2] },
|
|
advance: { label: 'Kasbon', color: colors[3] },
|
|
} satisfies ChartConfig;
|
|
})();
|
|
|
|
const expenseKeys = ['total', 'purchase', 'expense', 'advance'] as const;
|
|
|
|
const revenueTrendChartConfig = (() => {
|
|
const colors = generateRandomColors(1);
|
|
return {
|
|
qty: { label: 'Qty', color: colors[0] },
|
|
} satisfies ChartConfig;
|
|
})();
|
|
|
|
const revenueTrendKeys = ['qty'] as const;
|
|
|
|
const stockComparisonConfig = (() => {
|
|
const colors = generateRandomColors(3);
|
|
return {
|
|
good: { label: 'Bagus', color: colors[0] },
|
|
reject: { label: 'Reject', color: colors[1] },
|
|
retail: { label: 'Ecer', color: colors[2] },
|
|
} satisfies ChartConfig;
|
|
})();
|
|
|
|
const stockComparisonKeys = ['good', 'reject', 'retail'] as const;
|
|
|
|
const CHANNEL_COLORS: Record<string, string> = (() => {
|
|
const colors = generateRandomColors(3);
|
|
return {
|
|
store: colors[0],
|
|
shopee: colors[1],
|
|
tiktok: colors[2],
|
|
};
|
|
})();
|
|
|
|
const PAYMENT_COLORS: Record<string, string> = (() => {
|
|
const colors = generateRandomColors(4);
|
|
return {
|
|
cash: colors[0],
|
|
transfer: colors[1],
|
|
qris: colors[2],
|
|
marketplace: colors[3],
|
|
};
|
|
})();
|
|
|
|
type PieChartItem = {
|
|
name: string;
|
|
count: number;
|
|
};
|
|
|
|
type DashboardPieChartProps = {
|
|
title: string;
|
|
data: PieChartItem[];
|
|
dataKey: string;
|
|
nameKey: string;
|
|
};
|
|
|
|
function DashboardPieChart({ title, data, dataKey, nameKey }: DashboardPieChartProps) {
|
|
const hasData = data.length > 0 && data.some((d) => d.count > 0);
|
|
|
|
const [activeName, setActiveName] = useState<string | undefined>(undefined);
|
|
|
|
const pieColors = useMemo(() => generateRandomColors(5), []);
|
|
|
|
const chartConfig = useMemo(() => {
|
|
const config: ChartConfig = {};
|
|
data.forEach((item, index) => {
|
|
config[item.name] = {
|
|
label: item.name,
|
|
color: pieColors[index % pieColors.length],
|
|
};
|
|
});
|
|
return config;
|
|
}, [data, pieColors]);
|
|
|
|
const chartData = useMemo(() => {
|
|
return data.map((item) => ({
|
|
...item,
|
|
fill: pieColors[data.indexOf(item) % pieColors.length],
|
|
}));
|
|
}, [data, pieColors]);
|
|
|
|
const handlePieClick = (_: unknown, index: number) => {
|
|
const name = chartData[index]?.name;
|
|
if (name) {
|
|
setActiveName((prev) => (prev === name ? undefined : name));
|
|
}
|
|
};
|
|
|
|
const handleLegendClick = (name: string) => {
|
|
setActiveName((prev) => (prev === name ? undefined : name));
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="items-center pb-0">
|
|
<CardTitle className="text-base">{title}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex-1 pb-0">
|
|
{hasData ? (
|
|
<ChartContainer
|
|
config={chartConfig}
|
|
className="mx-auto aspect-square max-h-[250px]"
|
|
>
|
|
<PieChart>
|
|
<ChartTooltip
|
|
content={
|
|
<ChartTooltipContent
|
|
nameKey={nameKey}
|
|
hideLabel
|
|
/>
|
|
}
|
|
/>
|
|
<Pie
|
|
data={chartData}
|
|
dataKey={dataKey}
|
|
nameKey={nameKey}
|
|
onClick={handlePieClick}
|
|
>
|
|
{chartData.map((item, index) => (
|
|
<Cell
|
|
key={`cell-${index}`}
|
|
opacity={activeName === undefined || activeName === item.name ? 1 : 0.3}
|
|
style={{ cursor: 'pointer' }}
|
|
/>
|
|
))}
|
|
</Pie>
|
|
<ChartLegend
|
|
content={
|
|
<ChartLegendContent
|
|
nameKey={nameKey}
|
|
onItemClick={handleLegendClick}
|
|
activeName={activeName}
|
|
/>
|
|
}
|
|
className="-translate-y-2 flex-wrap gap-2 *:basis-1/4 *:justify-center"
|
|
/>
|
|
</PieChart>
|
|
</ChartContainer>
|
|
) : (
|
|
<div className="flex h-[200px] items-center justify-center text-muted-foreground">
|
|
Belum ada data
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
type TooltipProps = {
|
|
active?: boolean;
|
|
payload?: Array<{
|
|
name: string;
|
|
value: number;
|
|
color?: string;
|
|
payload: Record<string, unknown>;
|
|
}>;
|
|
label?: string;
|
|
};
|
|
|
|
function BarTooltip({ active, payload, label }: TooltipProps) {
|
|
if (!active || !payload?.length) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-lg border bg-background px-3 py-1.5 shadow-xl">
|
|
<p className="font-medium">{label}</p>
|
|
{payload.map((item, i) => (
|
|
<p key={i} className="flex items-center gap-1 text-sm">
|
|
<span className="size-2 rounded-full" style={{ backgroundColor: item.color }} />
|
|
<span className="text-muted-foreground">{item.name}</span>
|
|
<span className="ml-auto font-medium tabular-nums">
|
|
{typeof item.value === 'number' && item.value > 1000
|
|
? `Rp${formatRupiah(item.value)}`
|
|
: item.value}
|
|
</span>
|
|
</p>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DonutTooltip({ active, payload }: TooltipProps) {
|
|
if (!active || !payload?.length) {
|
|
return null;
|
|
}
|
|
|
|
const data = payload[0].payload;
|
|
|
|
return (
|
|
<div className="rounded-lg border bg-background px-3 py-1.5 shadow-xl">
|
|
<p className="flex items-center gap-1 text-sm">
|
|
<span className="size-2 rounded-full" style={{ backgroundColor: data.color as string }} />
|
|
<span className="text-muted-foreground">{(data.label as string) ?? ''}</span>
|
|
<span className="ml-auto font-medium tabular-nums">
|
|
{data.total !== undefined ? `Rp${formatRupiah(data.total as number)}` : (data.value as number)?.toLocaleString('id-ID')}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function Analysis({
|
|
filters: initialFilters,
|
|
attendance,
|
|
isManager,
|
|
cashOverview,
|
|
rawMaterialStock,
|
|
productStock,
|
|
revenueByStockType,
|
|
revenueSummary,
|
|
monthlyRevenue,
|
|
monthlyRevenueByChannel,
|
|
revenueByPaymentType,
|
|
expenseSummary,
|
|
monthlyExpense,
|
|
busyHours,
|
|
profitMetrics,
|
|
topSuppliers,
|
|
topCustomers,
|
|
topProducts,
|
|
marketingSales,
|
|
orderStats,
|
|
revenueTrend,
|
|
monthlyRetailRevenue,
|
|
}: AnalysisProps) {
|
|
const { can, hasAnyRole, hasRole } = useCan();
|
|
const [startDate, setStartDate] = useState(initialFilters.start_date ?? '');
|
|
const [endDate, setEndDate] = useState(initialFilters.end_date ?? '');
|
|
const [selectedPreset, setSelectedPreset] = useState('');
|
|
const [activeRevenueKey, setActiveRevenueKey] = useState<keyof typeof revenueChartConfig>('total');
|
|
const [activeRetailRevenueKey, setActiveRetailRevenueKey] = useState<keyof typeof retailRevenueChartConfig>('total');
|
|
const [activeExpenseKey, setActiveExpenseKey] = useState<keyof typeof expenseChartConfig>('total');
|
|
const [activeStockKey, setActiveStockKey] = useState<'good' | 'reject' | 'retail'>('good');
|
|
|
|
const hasActiveFilters = !!startDate || !!endDate;
|
|
|
|
const formatDate = useCallback((date: Date): string => format(date, 'yyyy-MM-dd'), []);
|
|
|
|
const applyFilters = useCallback(() => {
|
|
router.get(
|
|
'/admin/analysis',
|
|
{ start_date: startDate, end_date: endDate },
|
|
{ preserveState: true, preserveScroll: true },
|
|
);
|
|
}, [startDate, endDate]);
|
|
|
|
const clearFilters = useCallback(() => {
|
|
setStartDate('');
|
|
setEndDate('');
|
|
setSelectedPreset('');
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(applyFilters, 500);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [startDate, endDate, applyFilters]);
|
|
|
|
const onPresetChange = useCallback((value: string) => {
|
|
setSelectedPreset(value);
|
|
|
|
const now = new Date();
|
|
let start: Date;
|
|
|
|
switch (value) {
|
|
case 'today':
|
|
start = new Date(now);
|
|
break;
|
|
case 'week':
|
|
start = new Date(now);
|
|
start.setDate(now.getDate() - now.getDay() + 1);
|
|
break;
|
|
case 'month':
|
|
start = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
break;
|
|
case 'year':
|
|
start = new Date(now.getFullYear(), 0, 1);
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
setStartDate(formatDate(start));
|
|
setEndDate(formatDate(now));
|
|
}, [formatDate]);
|
|
|
|
const revenueByChannelData = useMemo(() => {
|
|
const totals = monthlyRevenueByChannel.reduce(
|
|
(acc, item) => ({
|
|
store: acc.store + (item.store ?? 0),
|
|
shopee: acc.shopee + (item.shopee ?? 0),
|
|
tiktok: acc.tiktok + (item.tiktok ?? 0),
|
|
}),
|
|
{ store: 0, shopee: 0, tiktok: 0 },
|
|
);
|
|
|
|
return [
|
|
{ channel: 'store', label: 'Toko', total: totals.store, color: CHANNEL_COLORS.store },
|
|
{ channel: 'shopee', label: 'Shopee', total: totals.shopee, color: CHANNEL_COLORS.shopee },
|
|
{ channel: 'tiktok', label: 'TikTok', total: totals.tiktok, color: CHANNEL_COLORS.tiktok },
|
|
];
|
|
}, [monthlyRevenueByChannel]);
|
|
|
|
const stockComparisonData = useMemo(() => revenueByStockType.monthly, [revenueByStockType]);
|
|
|
|
const peakHour = useMemo(() => {
|
|
if (busyHours.length === 0) {
|
|
return { hour: '-', orders: 0 };
|
|
}
|
|
|
|
return busyHours.reduce((max, item) => (item.orders > max.orders ? item : max), busyHours[0]);
|
|
}, [busyHours]);
|
|
|
|
const visibleExpenseCharts = useMemo(() => {
|
|
if (hasAnyRole(['owner', 'developer'])) {
|
|
return expenseKeys;
|
|
}
|
|
|
|
return expenseKeys.filter((c) => c !== 'purchase');
|
|
}, [hasAnyRole]);
|
|
|
|
const sectionOrder = useMemo(() => {
|
|
if (hasAnyRole(['owner', 'developer'])) {
|
|
return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, revenueTrend: 9, marketingSales: 10, topSuppliers: 11, topProducts: 12, topCustomers: 13, busyHours: 14 };
|
|
}
|
|
|
|
if (hasAnyRole(['admin_toko', 'direktur'])) {
|
|
return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, revenueTrend: 9, marketingSales: 10, topSuppliers: 11, topProducts: 12, topCustomers: 13, busyHours: 14 };
|
|
}
|
|
|
|
if (hasRole('cashier')) {
|
|
return { statCards: 1, revenue: 2, revenueByChannel: 3, orderPie: 4, totalOrder: 5, topProducts: 6, topCustomers: 7 };
|
|
}
|
|
|
|
if (hasRole('marketing')) {
|
|
return { statCards: 1, revenue: 2, revenueByChannel: 3, orderPie: 4, totalOrder: 5, revenueTrend: 6, topProducts: 7, topCustomers: 8 };
|
|
}
|
|
|
|
return {};
|
|
}, [hasAnyRole, hasRole]);
|
|
|
|
return (
|
|
<>
|
|
<Head title="Analisa" />
|
|
|
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="space-y-1">
|
|
<h2 className="text-2xl font-bold tracking-tight">Analisa</h2>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Select value={selectedPreset} onValueChange={onPresetChange}>
|
|
<SelectTrigger className="h-9 w-[140px]">
|
|
<SelectValue placeholder="Filter Cepat" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="today">Hari Ini</SelectItem>
|
|
<SelectItem value="week">Minggu Ini</SelectItem>
|
|
<SelectItem value="month">Bulan Ini</SelectItem>
|
|
<SelectItem value="year">Tahun Ini</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Mulai:</span>
|
|
<DatePicker value={startDate} onChange={(d) => setStartDate(d ? formatDate(d) : '')} className="w-[170px]" placeholder="Pilih tanggal" />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Sampai:</span>
|
|
<DatePicker value={endDate} onChange={(d) => setEndDate(d ? formatDate(d) : '')} className="w-[170px]" placeholder="Pilih tanggal" />
|
|
</div>
|
|
{hasActiveFilters && (
|
|
<Button variant="ghost" size="sm" onClick={clearFilters} className="h-9 px-3">
|
|
Reset Filter
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3" style={{ order: sectionOrder.statCards ?? 99 }}>
|
|
{can('analysis.cash') && (
|
|
<StatCard
|
|
title="Kas Toko"
|
|
icon={Banknote}
|
|
mainLabel="Total Saldo"
|
|
mainValue={`Rp${formatRupiah(cashOverview.total_balance)}`}
|
|
subLabel={`${cashOverview.total_transactions} transaksi`}
|
|
description="Tidak terpengaruh filter tanggal"
|
|
items={[
|
|
{ label: 'Deposit', value: `Rp${formatRupiah(cashOverview.total_deposit)}` },
|
|
{ label: 'Withdrawal', value: `Rp${formatRupiah(cashOverview.total_withdrawal)}` },
|
|
]}
|
|
cols={2}
|
|
/>
|
|
)}
|
|
|
|
{can('analysis.raw_materials') && (
|
|
<StatCard
|
|
title="Bahan Baku"
|
|
icon={Package}
|
|
mainLabel="Total Stok"
|
|
mainValue={rawMaterialStock.total_stock.toLocaleString('id-ID')}
|
|
description="Tidak terpengaruh filter tanggal"
|
|
items={[
|
|
{ label: 'Yard', value: rawMaterialStock.by_unit?.yard?.toLocaleString('id-ID') ?? '0' },
|
|
{ label: 'Meter', value: rawMaterialStock.by_unit?.meter?.toLocaleString('id-ID') ?? '0' },
|
|
{ label: 'Kg', value: rawMaterialStock.by_unit?.kilogram?.toLocaleString('id-ID') ?? '0' },
|
|
{ label: 'Total Harga', value: `Rp${formatRupiah(rawMaterialStock.total_price)}` },
|
|
]}
|
|
cols={4}
|
|
/>
|
|
)}
|
|
|
|
{can('analysis.product_stock') && (
|
|
<StatCard
|
|
title="Stok Produk"
|
|
icon={ShoppingCart}
|
|
mainLabel="Total Stok"
|
|
mainValue={productStock.total_stock.toLocaleString('id-ID')}
|
|
description="Tidak terpengaruh filter tanggal"
|
|
items={[
|
|
{ label: 'Bagus', value: (productStock.by_type?.stock ?? 0).toLocaleString('id-ID') },
|
|
{ label: 'Reject', value: (productStock.by_type?.reject_stock ?? 0).toLocaleString('id-ID') },
|
|
{ label: 'Ecer', value: (productStock.by_type?.retail_stock ?? 0).toLocaleString('id-ID') },
|
|
{ label: 'Total Harga', value: `Rp${formatRupiah(productStock.total_price)}` },
|
|
]}
|
|
cols={4}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{can('analysis.product_stock') && (
|
|
<Card className="py-0" style={{ order: 2 }}>
|
|
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
|
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:py-0!">
|
|
<CardTitle>Pendapatan per Jenis Stok</CardTitle>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row">
|
|
{(['good', 'reject', 'retail'] as const).map((key) => {
|
|
const value = revenueByStockType.totals[key];
|
|
return (
|
|
<button
|
|
key={key}
|
|
data-active={activeStockKey === key}
|
|
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-4 py-2 text-left even:border-l data-[active=true]:bg-muted/30 sm:border-t-0 sm:border-l sm:px-5 sm:py-3"
|
|
onClick={() => setActiveStockKey(key)}
|
|
>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{stockComparisonConfig[key].label}
|
|
</span>
|
|
<span className="text-xs leading-none font-semibold sm:text-sm">
|
|
Rp{formatRupiah(value)}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="px-2 sm:p-6">
|
|
{stockComparisonData.length > 0 ? (
|
|
<ChartContainer config={stockComparisonConfig} className="aspect-auto h-[250px] w-full">
|
|
<BarChart data={stockComparisonData} margin={{ left: 12, right: 12 }}>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
|
|
<ChartTooltip
|
|
cursor={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
className="w-[150px]"
|
|
nameKey="views"
|
|
formatter={(value, name, item, index) => (
|
|
<>
|
|
<div
|
|
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
|
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
|
|
/>
|
|
<span className="text-muted-foreground">{stockComparisonConfig[activeStockKey]?.label ?? name}</span>
|
|
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
|
|
Rp{Number(value).toLocaleString('id-ID')}
|
|
</span>
|
|
</>
|
|
)}
|
|
/>
|
|
}
|
|
/>
|
|
<Bar dataKey={activeStockKey} fill={`var(--color-${activeStockKey})`} radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ChartContainer>
|
|
) : (
|
|
<div className="flex h-[300px] items-center justify-center text-muted-foreground">Belum ada data pendapatan</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{can('analysis.revenue') && (
|
|
<Card className="py-0" style={{ order: sectionOrder.revenue ?? 99 }}>
|
|
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
|
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:py-0!">
|
|
<CardTitle>Pendapatan Global</CardTitle>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row">
|
|
{revenueKeys.filter((key) => {
|
|
if (hasAnyRole(['owner', 'developer'])) return true;
|
|
if (hasRole('cashier')) return key === 'total' || key === 'discount' || key === 'deduction' || key === 'cogs';
|
|
return key === 'total' || key === 'discount' || key === 'deduction' || key === 'cogs' || key === 'gross' || key === 'net';
|
|
}).map((key) => {
|
|
const value = key === 'total' ? revenueSummary.total_revenue : key === 'discount' ? revenueSummary.total_discount : key === 'net' ? revenueSummary.net : key === 'gross' ? revenueSummary.gross : key === 'cogs' ? revenueSummary.cogs : revenueSummary.total_deduction;
|
|
return (
|
|
<button
|
|
key={key}
|
|
data-active={activeRevenueKey === key}
|
|
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-4 py-2 text-left even:border-l data-[active=true]:bg-muted/30 sm:border-t-0 sm:border-l sm:px-5 sm:py-3"
|
|
onClick={() => setActiveRevenueKey(key)}
|
|
>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{revenueChartConfig[key].label}
|
|
</span>
|
|
<span className="text-xs leading-none font-semibold sm:text-sm">
|
|
Rp{formatRupiah(value)}
|
|
{key === 'net' && revenueSummary.total_revenue > 0 && (
|
|
<span className="ml-1 text-[10px] font-normal text-muted-foreground">
|
|
({((revenueSummary.net / revenueSummary.total_revenue) * 100).toFixed(1)}%)
|
|
</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="px-2 sm:p-6">
|
|
{monthlyRevenue.length > 0 ? (
|
|
<ChartContainer config={revenueChartConfig} className="aspect-auto h-[250px] w-full">
|
|
<BarChart data={monthlyRevenue} margin={{ left: 12, right: 12 }}>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
|
|
<ChartTooltip
|
|
cursor={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
className="w-[150px]"
|
|
nameKey="views"
|
|
formatter={(value, name, item, index) => (
|
|
<>
|
|
<div
|
|
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
|
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
|
|
/>
|
|
<span className="text-muted-foreground">{revenueChartConfig[activeRevenueKey]?.label ?? name}</span>
|
|
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
|
|
Rp{Number(value).toLocaleString('id-ID')}
|
|
</span>
|
|
</>
|
|
)}
|
|
/>
|
|
}
|
|
/>
|
|
<Bar dataKey={activeRevenueKey} fill={`var(--color-${activeRevenueKey})`} radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ChartContainer>
|
|
) : (
|
|
<div className="flex h-[300px] items-center justify-center text-muted-foreground">Belum ada data pendapatan</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{can('analysis.revenue') && (
|
|
<div className="grid gap-4 md:grid-cols-2" style={{ order: sectionOrder.revenueByChannel ?? 99 }}>
|
|
<Card className="py-4 sm:py-0">
|
|
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
|
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
|
|
<CardTitle>Pendapatan per Channel</CardTitle>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row">
|
|
{revenueByChannelData.map((item) => (
|
|
<div key={item.channel} className="flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l sm:border-t-0 sm:border-l sm:px-8 sm:py-4">
|
|
<span className="text-xs text-muted-foreground">{item.label}</span>
|
|
<span className="text-sm">Rp{formatRupiah(item.total)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="px-2 sm:p-6">
|
|
{revenueByChannelData.length > 0 ? (
|
|
<div className="mx-auto aspect-square max-h-[300px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie data={revenueByChannelData} dataKey="total" nameKey="label" cx="50%" cy="50%" outerRadius={100} innerRadius={60} strokeWidth={2} stroke="#374151">
|
|
{revenueByChannelData.map((entry, index) => (
|
|
<Cell key={index} fill={entry.color} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip content={<DonutTooltip />} />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-[300px] items-center justify-center text-muted-foreground">Belum ada data pendapatan</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="py-4 sm:py-0">
|
|
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
|
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
|
|
<CardTitle>Pendapatan per Pembayaran</CardTitle>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row">
|
|
{revenueByPaymentType.map((item) => (
|
|
<div key={item.payment_type} className="flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l sm:border-t-0 sm:border-l sm:px-8 sm:py-4">
|
|
<span className="text-xs text-muted-foreground">{item.label}</span>
|
|
<span className="text-sm">Rp{formatRupiah(item.total)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="px-2 sm:p-6">
|
|
{revenueByPaymentType.length > 0 ? (
|
|
<div className="mx-auto aspect-square max-h-[300px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
data={revenueByPaymentType.map((item) => ({
|
|
...item,
|
|
color: PAYMENT_COLORS[item.payment_type] ?? '#94a3b8',
|
|
}))}
|
|
dataKey="total"
|
|
nameKey="label"
|
|
cx="50%"
|
|
cy="50%"
|
|
outerRadius={100}
|
|
innerRadius={60}
|
|
strokeWidth={2}
|
|
stroke="#374151"
|
|
>
|
|
{revenueByPaymentType.map((entry, index) => (
|
|
<Cell key={index} fill={PAYMENT_COLORS[entry.payment_type] ?? '#94a3b8'} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip content={<DonutTooltip />} />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-[300px] items-center justify-center text-muted-foreground">Belum ada data pendapatan</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{can('analysis.revenue') && (
|
|
<Card className="py-0" style={{ order: 3 }}>
|
|
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
|
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:py-0!">
|
|
<CardTitle>Pendapatan Penjualan Ecer</CardTitle>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row">
|
|
{retailRevenueKeys.map((key) => {
|
|
const value = key === 'total' ? monthlyRetailRevenue.summary.total : key === 'discount' ? monthlyRetailRevenue.summary.discount : key === 'gross' ? monthlyRetailRevenue.summary.gross : key === 'cogs' ? monthlyRetailRevenue.summary.cogs : monthlyRetailRevenue.summary.deduction;
|
|
return (
|
|
<button
|
|
key={key}
|
|
data-active={activeRetailRevenueKey === key}
|
|
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-4 py-2 text-left even:border-l data-[active=true]:bg-muted/30 sm:border-t-0 sm:border-l sm:px-5 sm:py-3"
|
|
onClick={() => setActiveRetailRevenueKey(key)}
|
|
>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{retailRevenueChartConfig[key].label}
|
|
</span>
|
|
<span className="text-xs leading-none font-semibold sm:text-sm">
|
|
Rp{formatRupiah(value)}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="px-2 sm:p-6">
|
|
{monthlyRetailRevenue.monthly.length > 0 ? (
|
|
<ChartContainer config={retailRevenueChartConfig} className="aspect-auto h-[250px] w-full">
|
|
<BarChart data={monthlyRetailRevenue.monthly} margin={{ left: 12, right: 12 }}>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
|
|
<ChartTooltip
|
|
cursor={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
className="w-[150px]"
|
|
nameKey="views"
|
|
formatter={(value, name, item, index) => (
|
|
<>
|
|
<div
|
|
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
|
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
|
|
/>
|
|
<span className="text-muted-foreground">{retailRevenueChartConfig[activeRetailRevenueKey]?.label ?? name}</span>
|
|
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
|
|
Rp{Number(value).toLocaleString('id-ID')}
|
|
</span>
|
|
</>
|
|
)}
|
|
/>
|
|
}
|
|
/>
|
|
<Bar dataKey={activeRetailRevenueKey} fill={`var(--color-${activeRetailRevenueKey})`} radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ChartContainer>
|
|
) : (
|
|
<div className="flex h-[300px] items-center justify-center text-muted-foreground">Belum ada data pendapatan ecer</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{can('analysis.revenue') && (
|
|
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4" style={{ order: sectionOrder.orderPie ?? 99 }}>
|
|
<DashboardPieChart
|
|
title="Pesanan per Channel"
|
|
data={orderStats.by_channel.map((item) => ({
|
|
name: item.label,
|
|
count: item.count,
|
|
}))}
|
|
dataKey="count"
|
|
nameKey="name"
|
|
/>
|
|
|
|
<DashboardPieChart
|
|
title="Pesanan per Pembayaran"
|
|
data={orderStats.by_payment_type.map((item) => ({
|
|
name: item.label,
|
|
count: item.count,
|
|
}))}
|
|
dataKey="count"
|
|
nameKey="name"
|
|
/>
|
|
|
|
<DashboardPieChart
|
|
title="Pesanan per Marketing"
|
|
data={orderStats.by_marketing.map((item) => ({
|
|
name: item.name,
|
|
count: item.count,
|
|
}))}
|
|
dataKey="count"
|
|
nameKey="name"
|
|
/>
|
|
|
|
<DashboardPieChart
|
|
title="Pesanan per Status"
|
|
data={orderStats.by_status.map((item) => ({
|
|
name: item.label,
|
|
count: item.count,
|
|
}))}
|
|
dataKey="count"
|
|
nameKey="name"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{can('analysis.expense') && (
|
|
<Card className="py-0" style={{ order: sectionOrder.expense ?? 99 }}>
|
|
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
|
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:py-0!">
|
|
<CardTitle>Pengeluaran</CardTitle>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row">
|
|
{visibleExpenseCharts.map((key) => {
|
|
const value = key === 'total' ? expenseSummary.total : key === 'purchase' ? expenseSummary.purchase_total : key === 'expense' ? expenseSummary.expense_total : expenseSummary.advance_total;
|
|
return (
|
|
<button
|
|
key={key}
|
|
data-active={activeExpenseKey === key}
|
|
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-4 py-2 text-left even:border-l data-[active=true]:bg-muted/30 sm:border-t-0 sm:border-l sm:px-5 sm:py-3"
|
|
onClick={() => setActiveExpenseKey(key)}
|
|
>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{expenseChartConfig[key].label}
|
|
</span>
|
|
<span className="text-xs leading-none font-semibold sm:text-sm">
|
|
Rp{formatRupiah(value)}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="px-2 sm:p-6">
|
|
{monthlyExpense.length > 0 ? (
|
|
<ChartContainer config={expenseChartConfig} className="aspect-auto h-[250px] w-full">
|
|
<BarChart data={monthlyExpense} margin={{ left: 12, right: 12 }}>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
|
|
<ChartTooltip
|
|
cursor={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
className="w-[150px]"
|
|
nameKey="views"
|
|
formatter={(value, name, item, index) => (
|
|
<>
|
|
<div
|
|
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
|
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
|
|
/>
|
|
<span className="text-muted-foreground">{expenseChartConfig[activeExpenseKey]?.label ?? name}</span>
|
|
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
|
|
Rp{Number(value).toLocaleString('id-ID')}
|
|
</span>
|
|
</>
|
|
)}
|
|
/>
|
|
}
|
|
/>
|
|
<Bar dataKey={activeExpenseKey} fill={`var(--color-${activeExpenseKey})`} radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ChartContainer>
|
|
) : (
|
|
<div className="flex h-[300px] items-center justify-center text-muted-foreground">Belum ada data pengeluaran</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{can('analysis.profit_orders') && (
|
|
<div style={{ order: sectionOrder.totalOrder ?? 99 }}>
|
|
<StatCard
|
|
title="Total Order"
|
|
icon={ShoppingCart}
|
|
mainLabel="Pesanan Selesai"
|
|
mainValue={profitMetrics.total_orders.toLocaleString('id-ID')}
|
|
items={[
|
|
{ label: 'Produk Terjual', value: profitMetrics.total_products_sold.toLocaleString('id-ID') },
|
|
{ label: 'Item/Transaksi', value: profitMetrics.items_per_transaction },
|
|
{ label: 'Rata-rata/Transaksi', value: `Rp${formatRupiah(profitMetrics.aov)}` },
|
|
]}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{can('analysis.revenue_trend') && (
|
|
<Card className="py-0" style={{ order: sectionOrder.revenueTrend ?? 99 }}>
|
|
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
|
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:py-0!">
|
|
<CardTitle>Trend Penjualan</CardTitle>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row">
|
|
{revenueTrendKeys.map((key) => {
|
|
const total = revenueTrend.reduce((acc, item) => acc + (item[key] ?? 0), 0);
|
|
return (
|
|
<button
|
|
key={key}
|
|
data-active={false}
|
|
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-4 py-2 text-left even:border-l sm:border-t-0 sm:border-l sm:px-5 sm:py-3"
|
|
>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{revenueTrendChartConfig[key].label}
|
|
</span>
|
|
<span className="text-xs leading-none font-semibold sm:text-sm">
|
|
{total.toLocaleString('id-ID')}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="px-2 sm:p-6">
|
|
{revenueTrend.length > 0 ? (
|
|
<ChartContainer config={revenueTrendChartConfig} className="aspect-auto h-[250px] w-full">
|
|
<LineChart data={revenueTrend} margin={{ left: 12, right: 12 }}>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis
|
|
dataKey="date"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickMargin={8}
|
|
minTickGap={32}
|
|
tickFormatter={(value) => {
|
|
const [year, month, day] = String(value).split('-').map(Number);
|
|
const date = new Date(year, month - 1, day);
|
|
return date.toLocaleDateString('id-ID', { day: 'numeric', month: 'short' });
|
|
}}
|
|
/>
|
|
<ChartTooltip
|
|
cursor={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
className="w-[150px]"
|
|
nameKey="views"
|
|
labelFormatter={(value) => {
|
|
const [year, month, day] = String(value).split('-').map(Number);
|
|
const date = new Date(year, month - 1, day);
|
|
return date.toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
}}
|
|
formatter={(value, name, item, index) => (
|
|
<>
|
|
<div
|
|
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
|
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
|
|
/>
|
|
<span className="text-muted-foreground">{revenueTrendChartConfig[item.dataKey as keyof typeof revenueTrendChartConfig]?.label ?? name}</span>
|
|
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
|
|
{Number(value).toLocaleString('id-ID')}
|
|
</span>
|
|
</>
|
|
)}
|
|
/>
|
|
}
|
|
/>
|
|
<Line dataKey="qty" type="monotone" stroke="var(--color-qty)" strokeWidth={2} dot={false} />
|
|
</LineChart>
|
|
</ChartContainer>
|
|
) : (
|
|
<div className="flex h-[300px] items-center justify-center text-muted-foreground">Belum ada data trend pendapatan</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{can('analysis.marketing_sales') && (
|
|
<Card style={{ order: sectionOrder.marketingSales ?? 99 }}>
|
|
<CardHeader>
|
|
<CardTitle>Penjualan Marketing</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{marketingSales.length > 0 ? (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b">
|
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Marketing</th>
|
|
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Total Order</th>
|
|
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Produk Terjual</th>
|
|
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Total Pendapatan</th>
|
|
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Total Subtotal</th>
|
|
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Total Diskon & Potongan Nego</th>
|
|
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Rata-rata Order</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{marketingSales.map((item, index) => (
|
|
<tr key={index} className="border-b last:border-0">
|
|
<td className="px-4 py-3 font-medium">{item.marketing_name}</td>
|
|
<td className="px-4 py-3 text-right tabular-nums">{item.total_orders.toLocaleString('id-ID')}</td>
|
|
<td className="px-4 py-3 text-right tabular-nums">{item.total_products_sold.toLocaleString('id-ID')} pcs</td>
|
|
<td className="px-4 py-3 text-right tabular-nums">Rp{formatRupiah(item.total_revenue)}</td>
|
|
<td className="px-4 py-3 text-right tabular-nums">Rp{formatRupiah(item.total_subtotal)}</td>
|
|
<td className="px-4 py-3 text-right tabular-nums">Rp{formatRupiah(item.total_discount)}</td>
|
|
<td className="px-4 py-3 text-right tabular-nums">Rp{formatRupiah(item.avg_order)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-[150px] items-center justify-center text-muted-foreground">Belum ada data penjualan marketing</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{can('analysis.top_suppliers') && (
|
|
<Card style={{ order: sectionOrder.topSuppliers ?? 99 }}>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Top 5 Supplier</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="px-2 sm:p-6">
|
|
{topSuppliers.length > 0 ? (
|
|
<ChartContainer config={{ amount: { label: 'Total Pembelian', color: generateRandomColors(1)[0] } }} className="aspect-auto h-[250px] w-full">
|
|
<BarChart data={topSuppliers.map((s) => ({ name: s.name.length > 15 ? s.name.substring(0, 15) + '...' : s.name, amount: s.total_amount }))} margin={{ left: 12, right: 12 }}>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
|
|
<ChartTooltip
|
|
cursor={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
className="w-[150px]"
|
|
nameKey="views"
|
|
formatter={(value, name, item, index) => (
|
|
<>
|
|
<div
|
|
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
|
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
|
|
/>
|
|
<span className="text-muted-foreground">Total Pembelian</span>
|
|
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
|
|
Rp{Number(value).toLocaleString('id-ID')}
|
|
</span>
|
|
</>
|
|
)}
|
|
/>
|
|
}
|
|
/>
|
|
<Bar dataKey="amount" fill="var(--color-amount)" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ChartContainer>
|
|
) : (
|
|
<div className="flex h-[250px] items-center justify-center text-muted-foreground">Belum ada data supplier</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{can('analysis.top_products') && (
|
|
<Card style={{ order: sectionOrder.topProducts ?? 99 }}>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Top 5 Produk</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="px-2 sm:p-6">
|
|
{topProducts.length > 0 ? (
|
|
<ChartContainer config={{ qty: { label: 'Jumlah Terjual', color: generateRandomColors(1)[0] } }} className="aspect-auto h-[250px] w-full">
|
|
<BarChart data={topProducts.map((p) => ({ name: p.name.length > 20 ? p.name.substring(0, 20) + '...' : p.name, qty: p.total_qty }))} margin={{ left: 12, right: 12 }}>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
|
|
<ChartTooltip
|
|
cursor={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
className="w-[150px]"
|
|
nameKey="views"
|
|
formatter={(value, name, item, index) => (
|
|
<>
|
|
<div
|
|
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
|
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
|
|
/>
|
|
<span className="text-muted-foreground">Jumlah Terjual</span>
|
|
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
|
|
{Number(value).toLocaleString('id-ID')}
|
|
</span>
|
|
</>
|
|
)}
|
|
/>
|
|
}
|
|
/>
|
|
<Bar dataKey="qty" fill="var(--color-qty)" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ChartContainer>
|
|
) : (
|
|
<div className="flex h-[250px] items-center justify-center text-muted-foreground">Belum ada data produk</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{can('analysis.top_customers') && (
|
|
<Card style={{ order: sectionOrder.topCustomers ?? 99 }}>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Top 5 Pelanggan</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="px-2 sm:p-6">
|
|
{topCustomers.length > 0 ? (
|
|
<ChartContainer config={{ amount: { label: 'Total Pesanan', color: generateRandomColors(1)[0] } }} className="aspect-auto h-[250px] w-full">
|
|
<BarChart data={topCustomers.map((c) => ({ name: c.name.length > 15 ? c.name.substring(0, 15) + '...' : c.name, amount: c.total_amount }))} margin={{ left: 12, right: 12 }}>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
|
|
<ChartTooltip
|
|
cursor={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
className="w-[150px]"
|
|
nameKey="views"
|
|
formatter={(value, name, item, index) => (
|
|
<>
|
|
<div
|
|
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
|
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
|
|
/>
|
|
<span className="text-muted-foreground">Total Pesanan</span>
|
|
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
|
|
Rp{Number(value).toLocaleString('id-ID')}
|
|
</span>
|
|
</>
|
|
)}
|
|
/>
|
|
}
|
|
/>
|
|
<Bar dataKey="amount" fill="var(--color-amount)" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ChartContainer>
|
|
) : (
|
|
<div className="flex h-[250px] items-center justify-center text-muted-foreground">Belum ada data pelanggan</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{can('analysis.busy_hours') && (
|
|
<Card className="py-0" style={{ order: sectionOrder.busyHours ?? 99 }}>
|
|
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
|
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:py-0!">
|
|
<CardTitle>Jam Sibuk Toko</CardTitle>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row">
|
|
<div className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-4 py-2 text-left even:border-l sm:border-t-0 sm:border-l sm:px-5 sm:py-3">
|
|
<span className="text-[10px] text-muted-foreground">Jam Tersibuk</span>
|
|
<span className="text-xs leading-none font-semibold sm:text-sm text-primary">{peakHour.hour}</span>
|
|
</div>
|
|
<div className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-4 py-2 text-left even:border-l data-[active=true]:bg-muted/30 sm:border-t-0 sm:border-l sm:px-5 sm:py-3">
|
|
<span className="text-[10px] text-muted-foreground">Pesanan</span>
|
|
<span className="text-xs leading-none font-semibold sm:text-sm">{peakHour.orders.toLocaleString('id-ID')}</span>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="px-2 sm:p-6">
|
|
{busyHours.length > 0 ? (
|
|
<ChartContainer config={{ orders: { label: 'Pesanan', color: generateRandomColors(1)[0] } }} className="aspect-auto h-[250px] w-full">
|
|
<BarChart data={busyHours} margin={{ left: 12, right: 12 }}>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis dataKey="hour" tickLine={false} axisLine={false} tickMargin={8} />
|
|
<ChartTooltip
|
|
cursor={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
className="w-[150px]"
|
|
nameKey="views"
|
|
formatter={(value, name, item, index) => (
|
|
<>
|
|
<div
|
|
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
|
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
|
|
/>
|
|
<span className="text-muted-foreground">Pesanan</span>
|
|
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
|
|
{Number(value).toLocaleString('id-ID')}
|
|
</span>
|
|
</>
|
|
)}
|
|
/>
|
|
}
|
|
/>
|
|
<Bar dataKey="orders" fill="var(--color-orders)" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ChartContainer>
|
|
) : (
|
|
<div className="flex h-[250px] items-center justify-center text-muted-foreground">Belum ada data pesanan</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|