feat: implement dashboard analytics charts for sales activity, payment methods, and top products
This commit is contained in:
parent
3d9fcedcba
commit
0f3a4e9912
@ -33,11 +33,78 @@ public function __invoke()
|
|||||||
$stats['laba_bersih'] = $this->calculateDiff($stats['laba_kotor'], $stats['total_pengeluaran']);
|
$stats['laba_bersih'] = $this->calculateDiff($stats['laba_kotor'], $stats['total_pengeluaran']);
|
||||||
$stats['profit_margin'] = $this->calculateMargin($stats['laba_bersih'], $stats['total_pendapatan']);
|
$stats['profit_margin'] = $this->calculateMargin($stats['laba_bersih'], $stats['total_pendapatan']);
|
||||||
|
|
||||||
|
// Chart Data
|
||||||
|
$stats['charts'] = [
|
||||||
|
'jam_sibuk' => $this->getJamSibuk($today, $yesterday),
|
||||||
|
'order_by_payment' => $this->getOrderByEnum($today, 'payment_method', \App\Enums\PaymentMethod::class),
|
||||||
|
'order_by_status' => $this->getOrderByEnum($today, 'order_status', \App\Enums\OrderStatus::class),
|
||||||
|
'order_by_channel' => $this->getOrderByEnum($today, 'order_channel', \App\Enums\OrderChannel::class),
|
||||||
|
'top_products' => $this->getTopProducts($today),
|
||||||
|
];
|
||||||
|
|
||||||
return Inertia::render('dashboard', [
|
return Inertia::render('dashboard', [
|
||||||
'stats' => $stats,
|
'stats' => $stats,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getJamSibuk($today, $yesterday)
|
||||||
|
{
|
||||||
|
$todayData = Order::whereDate('created_at', $today)
|
||||||
|
->selectRaw('HOUR(created_at) as hour, count(*) as count')
|
||||||
|
->groupBy('hour')
|
||||||
|
->pluck('count', 'hour')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$yesterdayData = Order::whereDate('created_at', $yesterday)
|
||||||
|
->selectRaw('HOUR(created_at) as hour, count(*) as count')
|
||||||
|
->groupBy('hour')
|
||||||
|
->pluck('count', 'hour')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$chartData = [];
|
||||||
|
for ($i = 0; $i < 24; $i++) {
|
||||||
|
$chartData[] = [
|
||||||
|
'hour' => sprintf('%02d:00', $i),
|
||||||
|
'today' => $todayData[$i] ?? 0,
|
||||||
|
'yesterday' => $yesterdayData[$i] ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $chartData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getOrderByEnum($date, $column, $enumClass)
|
||||||
|
{
|
||||||
|
$data = Order::whereDate('created_at', $date)
|
||||||
|
->selectRaw("$column, count(*) as count")
|
||||||
|
->groupBy($column)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $data->map(function($item) use ($column, $enumClass) {
|
||||||
|
$enumValue = $item->$column;
|
||||||
|
$label = $enumValue instanceof $enumClass ? $enumValue->label() : $enumValue;
|
||||||
|
return [
|
||||||
|
'name' => $label,
|
||||||
|
'value' => $item->count,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTopProducts($date)
|
||||||
|
{
|
||||||
|
return OrderItem::whereHas('order', fn($q) => $q->whereDate('created_at', $date))
|
||||||
|
->with('product:id,name')
|
||||||
|
->selectRaw('product_id, sum(qty) as total_qty')
|
||||||
|
->groupBy('product_id')
|
||||||
|
->orderByDesc('total_qty')
|
||||||
|
->limit(5)
|
||||||
|
->get()
|
||||||
|
->map(fn($item) => [
|
||||||
|
'name' => $item->product->name ?? 'Unknown',
|
||||||
|
'qty' => (int) $item->total_qty,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
private function getStats($today, $yesterday, $callback)
|
private function getStats($today, $yesterday, $callback)
|
||||||
{
|
{
|
||||||
$todayVal = $callback($today);
|
$todayVal = $callback($today);
|
||||||
|
|||||||
@ -10,8 +10,17 @@ import {
|
|||||||
Wallet, Receipt, Percent, BarChart3, CreditCard,
|
Wallet, Receipt, Percent, BarChart3, CreditCard,
|
||||||
ArrowUpRight, ArrowDownRight, Package, Users, ShoppingCart
|
ArrowUpRight, ArrowDownRight, Package, Users, ShoppingCart
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardAction, CardFooter } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardDescription, CardAction, CardFooter, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||||
|
ResponsiveContainer, AreaChart, Area,
|
||||||
|
PieChart, Pie, Cell, LabelList, LineChart, Line, Sector, Label
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
ChartContainer, ChartTooltip, ChartTooltipContent,
|
||||||
|
ChartLegend, ChartLegendContent, type ChartConfig
|
||||||
|
} from '@/components/ui/chart';
|
||||||
|
|
||||||
interface StatItem {
|
interface StatItem {
|
||||||
value: number;
|
value: number;
|
||||||
@ -32,6 +41,13 @@ interface DashboardStats {
|
|||||||
total_pembelian: StatItem;
|
total_pembelian: StatItem;
|
||||||
produk_terjual: StatItem;
|
produk_terjual: StatItem;
|
||||||
total_pelanggan: StatItem;
|
total_pelanggan: StatItem;
|
||||||
|
charts: {
|
||||||
|
jam_sibuk: Array<{ hour: string; today: number; yesterday: number }>;
|
||||||
|
order_by_payment: Array<{ name: string; value: number }>;
|
||||||
|
order_by_status: Array<{ name: string; value: number }>;
|
||||||
|
order_by_channel: Array<{ name: string; value: number }>;
|
||||||
|
top_products: Array<{ name: string; qty: number }>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@ -276,6 +292,178 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Section */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
{/* Jam Sibuk - Multiple Line Chart Pattern */}
|
||||||
|
<Card className="col-span-full transition-all duration-500 hover:shadow-xl animate-in fade-in slide-in-from-bottom-8 fill-mode-both" style={{ animationDelay: '1200ms' }}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Jam Sibuk (Pesanan per Jam)</CardTitle>
|
||||||
|
<CardDescription>Perbandingan aktivitas hari ini vs kemarin</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer
|
||||||
|
config={{
|
||||||
|
today: { label: "Hari Ini", color: "var(--chart-1)" },
|
||||||
|
yesterday: { label: "Kemarin", color: "var(--chart-2)" },
|
||||||
|
}}
|
||||||
|
className="h-[300px] w-full"
|
||||||
|
>
|
||||||
|
<LineChart
|
||||||
|
accessibilityLayer
|
||||||
|
data={stats.charts.jam_sibuk}
|
||||||
|
margin={{ left: 12, right: 12 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="hour"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
/>
|
||||||
|
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||||
|
<Line
|
||||||
|
dataKey="today"
|
||||||
|
type="monotone"
|
||||||
|
stroke="var(--color-today)"
|
||||||
|
strokeWidth={3}
|
||||||
|
dot={{ r: 4, fill: "var(--color-today)" }}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="yesterday"
|
||||||
|
type="monotone"
|
||||||
|
stroke="var(--color-yesterday)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<div className="flex w-full items-start gap-2 text-sm text-muted-foreground">
|
||||||
|
<TrendingUp className="h-4 w-4 text-emerald-500" />
|
||||||
|
<span>Menampilkan data pesanan per jam untuk mengidentifikasi waktu operasional tersibuk.</span>
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Order Distribution - Pie Chart Label List Pattern */}
|
||||||
|
<Card className="flex flex-col transition-all duration-500 hover:shadow-xl animate-in fade-in slide-in-from-bottom-8 fill-mode-both" style={{ animationDelay: '1300ms' }}>
|
||||||
|
<CardHeader className="items-center pb-0">
|
||||||
|
<CardTitle>Metode Pembayaran</CardTitle>
|
||||||
|
<CardDescription>Distribusi transaksi hari ini</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 pb-0">
|
||||||
|
<ChartContainer
|
||||||
|
config={Object.fromEntries(stats.charts.order_by_payment.map((item, i) => [
|
||||||
|
item.name.toLowerCase().replace(/[^a-z0-h]/g, ''),
|
||||||
|
{ label: item.name, color: `var(--chart-${(i % 5) + 1})` }
|
||||||
|
]))}
|
||||||
|
className="mx-auto aspect-square max-h-[300px] [&_.recharts-text]:fill-foreground"
|
||||||
|
>
|
||||||
|
<PieChart>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
|
||||||
|
<Pie
|
||||||
|
data={stats.charts.order_by_payment.map((item, i) => ({
|
||||||
|
...item,
|
||||||
|
fill: `var(--color-${item.name.toLowerCase().replace(/[^a-z0-h]/g, '')})`
|
||||||
|
}))}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
>
|
||||||
|
<LabelList
|
||||||
|
dataKey="name"
|
||||||
|
className="fill-foreground font-bold"
|
||||||
|
stroke="none"
|
||||||
|
fontSize={12}
|
||||||
|
/>
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex-col gap-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2 font-medium">
|
||||||
|
<TrendingUp className="h-4 w-4" /> Distribusi pembayaran real-time
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Order Channel - Donut Active Pattern */}
|
||||||
|
<Card className="flex flex-col transition-all duration-500 hover:shadow-xl animate-in fade-in slide-in-from-bottom-8 fill-mode-both" style={{ animationDelay: '1400ms' }}>
|
||||||
|
<CardHeader className="items-center pb-0">
|
||||||
|
<CardTitle>Saluran Pesanan</CardTitle>
|
||||||
|
<CardDescription>Sumber pesanan masuk</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 pb-0">
|
||||||
|
<ChartContainer
|
||||||
|
config={Object.fromEntries(stats.charts.order_by_channel.map((item, i) => [
|
||||||
|
item.name.toLowerCase().replace(/[^a-z0-h]/g, ''),
|
||||||
|
{ label: item.name, color: `var(--chart-${((i + 2) % 5) + 1})` }
|
||||||
|
]))}
|
||||||
|
className="mx-auto aspect-square max-h-[300px]"
|
||||||
|
>
|
||||||
|
<PieChart>
|
||||||
|
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
|
||||||
|
<Pie
|
||||||
|
data={stats.charts.order_by_channel.map((item, i) => ({
|
||||||
|
...item,
|
||||||
|
fill: `var(--color-${item.name.toLowerCase().replace(/[^a-z0-h]/g, '')})`
|
||||||
|
}))}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
innerRadius={60}
|
||||||
|
strokeWidth={5}
|
||||||
|
activeShape={({ outerRadius = 0, ...props }: any) => (
|
||||||
|
<Sector {...props} outerRadius={outerRadius + 10} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex-col gap-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2 font-medium">
|
||||||
|
<TrendingUp className="h-4 w-4" /> Performa saluran penjualan
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Top Products - Multiple Bar Pattern (Adapted) */}
|
||||||
|
<Card className="col-span-full transition-all duration-500 hover:shadow-xl animate-in fade-in slide-in-from-bottom-8 fill-mode-both" style={{ animationDelay: '1500ms' }}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top 5 Produk Terlaris</CardTitle>
|
||||||
|
<CardDescription>Berdasarkan kuantitas yang terjual hari ini</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer
|
||||||
|
config={{
|
||||||
|
qty: { label: "Terjual", color: "var(--chart-1)" },
|
||||||
|
}}
|
||||||
|
className="h-[350px] w-full"
|
||||||
|
>
|
||||||
|
<BarChart accessibilityLayer data={stats.charts.top_products}>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tickLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(value) => value.length > 15 ? value.slice(0, 15) + '...' : value}
|
||||||
|
/>
|
||||||
|
<ChartTooltip cursor={false} content={<ChartTooltipContent indicator="dashed" />} />
|
||||||
|
<Bar
|
||||||
|
dataKey="qty"
|
||||||
|
fill="var(--color-qty)"
|
||||||
|
radius={8}
|
||||||
|
label={{ position: 'top', fill: 'var(--foreground)', fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user