feat: implement analysis dashboard with new charts, controller, and reporting components

This commit is contained in:
Yoga Pangestu 2026-04-23 19:43:55 +07:00
parent 34190253e5
commit b1eb893cfe
9 changed files with 501 additions and 21 deletions

View File

@ -0,0 +1,187 @@
<?php
namespace App\Http\Controllers;
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use App\Enums\PaymentMethod;
use App\Models\Expense;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Purchase;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
class AnalysisController extends Controller
{
public function __invoke()
{
$stats = [
'total_sales' => $this->getStats(fn() => Order::count()),
'total_revenue' => $this->getStats(fn() => Order::sum('total')),
'total_expenses' => $this->getStats(fn() => Expense::sum('amount')),
'cogs' => $this->getStats(fn() => Order::sum('hpp')),
'total_discount' => $this->getStats(fn() => Order::sum('discount')),
'total_purchases' => $this->getStats(fn() => Purchase::sum('total')),
'products_sold' => $this->getStats(fn() => OrderItem::sum('qty')),
'total_customers' => $this->getStats(fn() => Order::distinct('customer_name')->count('customer_name')),
];
$stats['aov'] = $this->calculateAov($stats['total_revenue'], $stats['total_sales']);
$stats['gross_profit'] = $this->calculateDiff($stats['total_revenue'], $stats['cogs']);
$stats['net_profit'] = $this->calculateDiff($stats['gross_profit'], $stats['total_expenses']);
$stats['profit_margin'] = $this->calculateMargin($stats['net_profit'], $stats['total_revenue']);
// Revenue vs Purchases per month (this year)
$revenueByMonth = DB::table('orders')
->select(
DB::raw('MONTH(created_at) as month'),
DB::raw('SUM(total) as total')
)
->groupBy(DB::raw('MONTH(created_at)'))
->get();
$purchasesByMonth = DB::table('purchases')
->select(
DB::raw('MONTH(created_at) as month'),
DB::raw('SUM(total) as total')
)
->groupBy(DB::raw('MONTH(created_at)'))
->get();
$monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];
$salesByMonth = collect(range(1, 12))->map(function ($month) use ($revenueByMonth, $purchasesByMonth, $monthNames) {
$revenueFound = $revenueByMonth->firstWhere('month', $month);
$purchasesFound = $purchasesByMonth->firstWhere('month', $month);
return [
'month' => $monthNames[$month - 1],
'sales' => $revenueFound ? (float) $revenueFound->total : 0,
'purchase' => $purchasesFound ? (float) $purchasesFound->total : 0,
];
});
$paymentMethods = DB::table('orders')
->select('payment_method as name', DB::raw('COUNT(*) as total'))
->groupBy('payment_method')
->get()
->map(function ($item) {
if ($item->name) {
$item->name = PaymentMethod::tryFrom($item->name)?->label() ?? $item->name;
}
return $item;
});
$orderStatuses = DB::table('orders')
->select('order_status as name', DB::raw('COUNT(*) as total'))
->groupBy('order_status')
->get()
->map(function ($item) {
if ($item->name) {
$item->name = OrderStatus::tryFrom($item->name)?->label() ?? $item->name;
}
return $item;
});
$orderChannels = DB::table('orders')
->select('order_channel as name', DB::raw('COUNT(*) as total'))
->groupBy('order_channel')
->get()
->map(function ($item) {
if ($item->name) {
$item->name = OrderChannel::tryFrom($item->name)?->label() ?? $item->name;
}
return $item;
});
$topProducts = DB::table('order_items')
->join('products', 'order_items.product_id', '=', 'products.id')
->select('products.name as name', DB::raw('SUM(order_items.qty) as total'))
->groupBy('products.name')
->orderByDesc('total')
->limit(5)
->get();
$topCustomers = DB::table('orders')
->select('customer_name as name', DB::raw('SUM(total) as total'))
->whereNotNull('customer_name')
->groupBy('customer_name')
->orderByDesc('total')
->limit(5)
->get();
$ordersByHourRaw = DB::table('orders')
->select(
DB::raw('HOUR(created_at) as hour'),
DB::raw('COUNT(*) as total')
)
->groupBy(DB::raw('HOUR(created_at)'))
->get();
$ordersByHour = collect(range(0, 23))->map(function ($hour) use ($ordersByHourRaw) {
$found = $ordersByHourRaw->firstWhere('hour', $hour);
return [
'hour' => str_pad($hour, 2, '0', STR_PAD_LEFT) . ':00',
'total' => $found ? (int) $found->total : 0,
];
});
return Inertia::render('analysis', [
'stats' => $stats,
'salesByMonth' => $salesByMonth,
'ordersByHour' => $ordersByHour,
'paymentMethods' => $paymentMethods,
'orderStatuses' => $orderStatuses,
'orderChannels' => $orderChannels,
'topProducts' => $topProducts,
'topCustomers' => $topCustomers,
]);
}
private function getStats(callable $callback): array
{
$value = $callback();
return [
'value' => $value,
'yesterday' => null,
'change' => null,
];
}
private function calculateAov($revenue, $sales): array
{
$value = $sales['value'] > 0 ? $revenue['value'] / $sales['value'] : 0;
return [
'value' => $value,
'yesterday' => null,
'change' => null,
];
}
private function calculateDiff($a, $b): array
{
return [
'value' => $a['value'] - $b['value'],
'yesterday' => null,
'change' => null,
];
}
private function calculateMargin($profit, $revenue): array
{
$value = $revenue['value'] > 0 ? ($profit['value'] / $revenue['value']) * 100 : 0;
return [
'value' => round($value, 2),
'yesterday' => null,
'change' => null,
];
}
}

View File

@ -1,5 +1,3 @@
import { Link } from '@inertiajs/react';
import { Boxes, DollarSign, History, LayoutGrid, List, ScrollText, Settings, ShoppingBag, ShoppingCart, User, Wallet } from 'lucide-react';
import AppLogo from '@/components/app-logo'; import AppLogo from '@/components/app-logo';
import { NavMain } from '@/components/nav-main'; import { NavMain } from '@/components/nav-main';
import { import {
@ -10,8 +8,10 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { dashboard } from '@/routes'; import { analysis, dashboard } from '@/routes';
import category from '@/routes/category'; import category from '@/routes/category';
import { Link } from '@inertiajs/react';
import { BarChart3, Boxes, DollarSign, History, LayoutGrid, List, ScrollText, Settings, ShoppingBag, ShoppingCart, User, Wallet } from 'lucide-react';
import expense from '@/routes/expense'; import expense from '@/routes/expense';
import order from '@/routes/order'; import order from '@/routes/order';
@ -28,6 +28,11 @@ const mainNavItems: NavItem[] = [
href: dashboard().url, href: dashboard().url,
icon: LayoutGrid, icon: LayoutGrid,
}, },
{
title: 'Analisa',
href: analysis().url,
icon: BarChart3,
},
]; ];
const masterNavItems: NavItem[] = [ const masterNavItems: NavItem[] = [

View File

@ -20,7 +20,8 @@ export function StatCard({
isPercentage = false, isPercentage = false,
className className
}: StatCardProps) { }: StatCardProps) {
const isPositive = stat.change >= 0; const hasComparison = stat.yesterday !== null;
const isPositive = stat.change !== null && stat.change >= 0;
return ( return (
<Card className={cn("@container/card", className)}> <Card className={cn("@container/card", className)}>
@ -31,20 +32,24 @@ export function StatCard({
<CardTitle className="text-2xl font-bold tabular-nums @[250px]/card:text-3xl"> <CardTitle className="text-2xl font-bold tabular-nums @[250px]/card:text-3xl">
{isPercentage ? `${stat.value}%` : (isCurrency ? formatCurrency(stat.value) : formatNumber(stat.value))} {isPercentage ? `${stat.value}%` : (isCurrency ? formatCurrency(stat.value) : formatNumber(stat.value))}
</CardTitle> </CardTitle>
<CardAction> {hasComparison && (
<Badge variant="default" className={`flex gap-1 px-1.5 py-0.5 text-xs font-bold ${isPositive ? 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300' : 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300'}`}> <CardAction>
{isPositive ? <TrendingUp className="size-3" /> : <TrendingDown className="size-3" />} <Badge variant="default" className={`flex gap-1 px-1.5 py-0.5 text-xs font-bold ${isPositive ? 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300' : 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300'}`}>
{stat.change === null {isPositive ? <TrendingUp className="size-3" /> : <TrendingDown className="size-3" />}
? '∞' {stat.change === null
: `${Math.abs(stat.change)}%`} ? '∞'
</Badge> : `${Math.abs(stat.change)}%`}
</CardAction> </Badge>
</CardAction>
)}
</CardHeader> </CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-xs"> {hasComparison && (
<div className="text-muted-foreground"> <CardFooter className="flex-col items-start gap-1.5 text-xs">
Kemarin: <span className="font-semibold">{isPercentage ? `${stat.yesterday}%` : (isCurrency ? formatCurrency(stat.yesterday) : formatNumber(stat.yesterday))}</span> <div className="text-muted-foreground">
</div> Kemarin: <span className="font-semibold">{isPercentage ? `${stat.yesterday}%` : (isCurrency ? formatCurrency(stat.yesterday) : formatNumber(stat.yesterday))}</span>
</CardFooter> </div>
</CardFooter>
)}
</Card> </Card>
); );
} }

View File

@ -0,0 +1,84 @@
"use client"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { ChartConfig } from "@/components/ui/chart";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent
} from "@/components/ui/chart";
export const description = "Menampilkan aktivitas toko berdasarkan pesanan yang masuk per jam."
const chartConfig = {
total: {
label: "Total Pesanan",
color: "var(--chart-1)",
},
} satisfies ChartConfig
export function OrdersByHourChart({ data }: { data: any[] }) {
return (
<Card className="pt-0">
<CardHeader className="flex items-center gap-2 space-y-0 border-b py-5 sm:flex-row">
<div className="grid flex-1 gap-1">
<CardTitle>Jam Sibuk</CardTitle>
<CardDescription>
Menampilkan aktivitas toko berdasarkan pesanan yang masuk per jam.
</CardDescription>
</div>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={data}>
<defs>
<linearGradient id="fillTotal" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-total)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-total)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Area
dataKey="total"
type="natural"
fill="url(#fillTotal)"
stroke="var(--color-total)"
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,73 @@
"use client"
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import { formatCurrency } from "@/lib/formatters"
export const description = "Perbandingan total penjualan dan belanja per bulan."
const chartConfig = {
sales: {
label: "Penjualan",
color: "var(--chart-1)",
},
purchase: {
label: "Belanja",
color: "var(--chart-2)",
},
} satisfies ChartConfig
export function RevenueVsPurchasesChart({ data }: { data: any[] }) {
return (
<Card>
<CardHeader>
<CardTitle>Penjualan vs Belanja</CardTitle>
<CardDescription>Perbandingan total penjualan dan belanja per bulan</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="aspect-auto h-[300px] w-full">
<BarChart accessibilityLayer data={data}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) => value.slice(0, 3)}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
indicator="dashed"
formatter={(value, name) => [
formatCurrency(Number(value)),
` - ${chartConfig[name as keyof typeof chartConfig]?.label ?? name}`,
]}
/>
}
/>
<Bar dataKey="sales" fill="var(--color-sales)" radius={4} />
<Bar dataKey="purchase" fill="var(--color-purchase)" radius={4} />
<ChartLegend content={<ChartLegendContent />} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@ -8,14 +8,14 @@ export const formatTime = (date: Date) => {
export const formatDate = (date: Date | string | undefined) => { export const formatDate = (date: Date | string | undefined) => {
if (!date) { if (!date) {
return '-'; return '-';
} }
const parsedDate = date instanceof Date ? date : new Date(date); const parsedDate = date instanceof Date ? date : new Date(date);
if (isNaN(parsedDate.getTime())) { if (isNaN(parsedDate.getTime())) {
return '-'; return '-';
} }
return parsedDate.toLocaleDateString('id-ID', { return parsedDate.toLocaleDateString('id-ID', {
weekday: 'long', weekday: 'long',
@ -32,6 +32,13 @@ export const formatCurrency = (value: number) => {
}).format(value); }).format(value);
}; };
export const formatShortCurrency = (value: number) => {
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}M`;
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}jt`;
if (value >= 1_000) return `${(value / 1_000).toFixed(0)}rb`;
return String(value);
}
export const formatNumber = (value: number) => { export const formatNumber = (value: number) => {
return new Intl.NumberFormat('id-ID').format(value); return new Intl.NumberFormat('id-ID').format(value);
}; };

View File

@ -0,0 +1,104 @@
import { StatCard } from '@/components/cards/stat-card';
import { analysis } from '@/routes';
import type { AnalysisPageProps } from '@/types';
import { Head, usePage } from '@inertiajs/react';
import { CustomBarChart } from '../components/charts/bar-chart';
import { CustomPieChart } from '../components/charts/pie-chart';
import { OrdersByHourChart } from '../components/charts/orders-by-hour-chart';
import { RevenueVsPurchasesChart } from '../components/charts/revenue-vs-purchases-chart';
export default function Analisa() {
const { stats, salesByMonth, ordersByHour, paymentMethods, orderStatuses, orderChannels, topProducts, topCustomers } =
usePage<AnalysisPageProps>().props;
return (
<>
<Head title="Analisa" />
<div className="flex h-full min-h-screen flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-6 @container/main">
<div>
<h1 className="text-3xl font-bold tracking-tight">Analisa</h1>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 gap-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card *:data-[slot=card]:shadow-xs @xl/main:grid-cols-2 @4xl/main:grid-cols-4 dark:*:data-[slot=card]:bg-card">
{[
{ title: "Total Penjualan", stat: stats.total_sales, isCurrency: false },
{ title: "Total Pendapatan", stat: stats.total_revenue },
{ title: "Total Pengeluaran", stat: stats.total_expenses },
{ title: "HPP (Modal)", stat: stats.cogs },
{ title: "AOV (Avg Order Value)", stat: stats.aov },
{ title: "Profit Margin", stat: stats.profit_margin, isPercentage: true },
{ title: "Total Diskon", stat: stats.total_discount },
{ title: "Laba Kotor", stat: stats.gross_profit },
{ title: "Laba Bersih", stat: stats.net_profit },
{ title: "Total Pembelian", stat: stats.total_purchases },
{ title: "Produk Terjual", stat: stats.products_sold, isCurrency: false },
{ title: "Total Pelanggan", stat: stats.total_customers, isCurrency: false },
].map((item, index) => (
<div
key={index}
className="animate-in fade-in slide-in-from-bottom-4 fill-mode-both"
style={{ animationDelay: `${index * 100}ms` }}
>
<StatCard
{...item}
className="transition-all duration-300 hover:scale-[1.03] hover:shadow-xl hover:-translate-y-1"
/>
</div>
))}
</div>
<RevenueVsPurchasesChart data={salesByMonth} />
<OrdersByHourChart data={ordersByHour} />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<CustomPieChart
title="Metode Pembayaran"
description="Distribusi pesanan berdasarkan metode pembayaran."
data={paymentMethods}
colorOffset={0}
/>
<CustomPieChart
title="Status Pesanan"
description="Distribusi pesanan berdasarkan status."
data={orderStatuses}
colorOffset={120}
/>
<CustomPieChart
title="Channel Pesanan"
description="Distribusi pesanan berdasarkan channel/platform."
data={orderChannels}
colorOffset={240}
/>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<CustomBarChart
title="Top 5 Produk Terlaris"
description="Berdasarkan jumlah produk yang terjual."
data={topProducts}
colorOffset={45}
/>
<CustomBarChart
title="Top 5 Pelanggan Setia"
description="Berdasarkan total nominal belanja."
data={topCustomers}
colorOffset={180}
isCurrency={true}
/>
</div>
</div>
</>
);
}
Analisa.layout = {
breadcrumbs: [
{
title: 'Analisa',
href: analysis().url,
},
],
};

View File

@ -26,3 +26,16 @@ export interface DashboardPageProps {
stats: DashboardStats; stats: DashboardStats;
[key: string]: any; [key: string]: any;
} }
export interface AnalysisPageProps {
auth: Auth;
stats: DashboardStats;
salesByMonth: any[];
ordersByHour: any[];
paymentMethods: any[];
orderStatuses: any[];
orderChannels: any[];
topProducts: any[];
topCustomers: any[];
[key: string]: any;
}

View File

@ -7,10 +7,12 @@
'canRegister' => Features::enabled(Features::registration()), 'canRegister' => Features::enabled(Features::registration()),
])->name('home'); ])->name('home');
use App\Http\Controllers\AnalysisController;
use App\Http\Controllers\DashboardController; use App\Http\Controllers\DashboardController;
Route::middleware(['auth', 'verified'])->group(function () { Route::middleware(['auth', 'verified'])->group(function () {
Route::get('dashboard', DashboardController::class)->name('dashboard'); Route::get('dashboard', DashboardController::class)->name('dashboard');
Route::get('analysis', AnalysisController::class)->name('analysis');
}); });
require __DIR__.'/settings.php'; require __DIR__.'/settings.php';