feat: implement analysis dashboard with new charts, controller, and reporting components
This commit is contained in:
parent
34190253e5
commit
b1eb893cfe
187
app/Http/Controllers/AnalysisController.php
Normal file
187
app/Http/Controllers/AnalysisController.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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[] = [
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
84
resources/js/components/charts/orders-by-hour-chart.tsx
Normal file
84
resources/js/components/charts/orders-by-hour-chart.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
104
resources/js/pages/analysis.tsx
Normal file
104
resources/js/pages/analysis.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user