From b1eb893cfeec8324272db52ed6d4b789d4a68089 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 23 Apr 2026 19:43:55 +0700 Subject: [PATCH] feat: implement analysis dashboard with new charts, controller, and reporting components --- app/Http/Controllers/AnalysisController.php | 187 ++++++++++++++++++ resources/js/components/app-sidebar.tsx | 11 +- resources/js/components/cards/stat-card.tsx | 33 ++-- .../charts/orders-by-hour-chart.tsx | 84 ++++++++ .../charts/revenue-vs-purchases-chart.tsx | 73 +++++++ resources/js/lib/formatters.ts | 15 +- resources/js/pages/analysis.tsx | 104 ++++++++++ resources/js/types/dashboard.ts | 13 ++ routes/web.php | 2 + 9 files changed, 501 insertions(+), 21 deletions(-) create mode 100644 app/Http/Controllers/AnalysisController.php create mode 100644 resources/js/components/charts/orders-by-hour-chart.tsx create mode 100644 resources/js/components/charts/revenue-vs-purchases-chart.tsx create mode 100644 resources/js/pages/analysis.tsx diff --git a/app/Http/Controllers/AnalysisController.php b/app/Http/Controllers/AnalysisController.php new file mode 100644 index 0000000..5c0f78d --- /dev/null +++ b/app/Http/Controllers/AnalysisController.php @@ -0,0 +1,187 @@ + $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, + ]; + } +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 07ae19c..7f84a1d 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -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 { NavMain } from '@/components/nav-main'; import { @@ -10,8 +8,10 @@ import { SidebarMenuButton, SidebarMenuItem, } from '@/components/ui/sidebar'; -import { dashboard } from '@/routes'; +import { analysis, dashboard } from '@/routes'; 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 order from '@/routes/order'; @@ -28,6 +28,11 @@ const mainNavItems: NavItem[] = [ href: dashboard().url, icon: LayoutGrid, }, + { + title: 'Analisa', + href: analysis().url, + icon: BarChart3, + }, ]; const masterNavItems: NavItem[] = [ diff --git a/resources/js/components/cards/stat-card.tsx b/resources/js/components/cards/stat-card.tsx index f3a9fd1..7a0ca95 100644 --- a/resources/js/components/cards/stat-card.tsx +++ b/resources/js/components/cards/stat-card.tsx @@ -20,7 +20,8 @@ export function StatCard({ isPercentage = false, className }: StatCardProps) { - const isPositive = stat.change >= 0; + const hasComparison = stat.yesterday !== null; + const isPositive = stat.change !== null && stat.change >= 0; return ( @@ -31,20 +32,24 @@ export function StatCard({ {isPercentage ? `${stat.value}%` : (isCurrency ? formatCurrency(stat.value) : formatNumber(stat.value))} - - - {isPositive ? : } - {stat.change === null - ? '∞' - : `${Math.abs(stat.change)}%`} - - + {hasComparison && ( + + + {isPositive ? : } + {stat.change === null + ? '∞' + : `${Math.abs(stat.change)}%`} + + + )} - -
- Kemarin: {isPercentage ? `${stat.yesterday}%` : (isCurrency ? formatCurrency(stat.yesterday) : formatNumber(stat.yesterday))} -
-
+ {hasComparison && ( + +
+ Kemarin: {isPercentage ? `${stat.yesterday}%` : (isCurrency ? formatCurrency(stat.yesterday) : formatNumber(stat.yesterday))} +
+
+ )}
); } diff --git a/resources/js/components/charts/orders-by-hour-chart.tsx b/resources/js/components/charts/orders-by-hour-chart.tsx new file mode 100644 index 0000000..1508dd4 --- /dev/null +++ b/resources/js/components/charts/orders-by-hour-chart.tsx @@ -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 ( + + +
+ Jam Sibuk + + Menampilkan aktivitas toko berdasarkan pesanan yang masuk per jam. + +
+
+ + + + + + + + + + + + } + /> + + } /> + + + +
+ ) +} diff --git a/resources/js/components/charts/revenue-vs-purchases-chart.tsx b/resources/js/components/charts/revenue-vs-purchases-chart.tsx new file mode 100644 index 0000000..561411c --- /dev/null +++ b/resources/js/components/charts/revenue-vs-purchases-chart.tsx @@ -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 ( + + + Penjualan vs Belanja + Perbandingan total penjualan dan belanja per bulan + + + + + + value.slice(0, 3)} + /> + [ + formatCurrency(Number(value)), + ` - ${chartConfig[name as keyof typeof chartConfig]?.label ?? name}`, + ]} + /> + } + /> + + + } /> + + + + + ) +} diff --git a/resources/js/lib/formatters.ts b/resources/js/lib/formatters.ts index a99fe9f..26c2b5f 100644 --- a/resources/js/lib/formatters.ts +++ b/resources/js/lib/formatters.ts @@ -8,14 +8,14 @@ export const formatTime = (date: Date) => { export const formatDate = (date: Date | string | undefined) => { if (!date) { -return '-'; -} + return '-'; + } const parsedDate = date instanceof Date ? date : new Date(date); if (isNaN(parsedDate.getTime())) { -return '-'; -} + return '-'; + } return parsedDate.toLocaleDateString('id-ID', { weekday: 'long', @@ -32,6 +32,13 @@ export const formatCurrency = (value: number) => { }).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) => { return new Intl.NumberFormat('id-ID').format(value); }; diff --git a/resources/js/pages/analysis.tsx b/resources/js/pages/analysis.tsx new file mode 100644 index 0000000..3c7fb18 --- /dev/null +++ b/resources/js/pages/analysis.tsx @@ -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().props; + + return ( + <> + + +
+ +
+

Analisa

+
+ + {/* Stats Grid */} +
+ {[ + { 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) => ( +
+ +
+ ))} +
+ + + + +
+ + + +
+ +
+ + +
+
+ + ); +} + +Analisa.layout = { + breadcrumbs: [ + { + title: 'Analisa', + href: analysis().url, + }, + ], +}; diff --git a/resources/js/types/dashboard.ts b/resources/js/types/dashboard.ts index a2cf5cd..2d5bcd3 100644 --- a/resources/js/types/dashboard.ts +++ b/resources/js/types/dashboard.ts @@ -26,3 +26,16 @@ export interface DashboardPageProps { stats: DashboardStats; [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; +} diff --git a/routes/web.php b/routes/web.php index 85fbc49..b0b3c99 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,10 +7,12 @@ 'canRegister' => Features::enabled(Features::registration()), ])->name('home'); +use App\Http\Controllers\AnalysisController; use App\Http\Controllers\DashboardController; Route::middleware(['auth', 'verified'])->group(function () { Route::get('dashboard', DashboardController::class)->name('dashboard'); + Route::get('analysis', AnalysisController::class)->name('analysis'); }); require __DIR__.'/settings.php';