feat: implement dashboard statistics controller and update UI with interactive metrics cards
This commit is contained in:
parent
9f312091e7
commit
5b3b12e6c2
97
app/Http/Controllers/DashboardController.php
Normal file
97
app/Http/Controllers/DashboardController.php
Normal file
@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Expense;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Purchase;
|
||||
use Carbon\Carbon;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __invoke()
|
||||
{
|
||||
$today = Carbon::today();
|
||||
$yesterday = Carbon::yesterday();
|
||||
|
||||
$stats = [
|
||||
'total_penjualan' => $this->getStats($today, $yesterday, fn ($date) => Order::whereDate('created_at', $date)->count()),
|
||||
'total_pendapatan' => $this->getStats($today, $yesterday, fn ($date) => Order::whereDate('created_at', $date)->sum('total')),
|
||||
'total_pengeluaran' => $this->getStats($today, $yesterday, fn ($date) => Expense::whereDate('created_at', $date)->sum('amount')),
|
||||
'hpp' => $this->getStats($today, $yesterday, fn ($date) => Order::whereDate('created_at', $date)->sum('hpp')),
|
||||
'total_diskon' => $this->getStats($today, $yesterday, fn ($date) => Order::whereDate('created_at', $date)->sum('discount')),
|
||||
'total_pembelian' => $this->getStats($today, $yesterday, fn ($date) => Purchase::whereDate('created_at', $date)->sum('total')),
|
||||
'produk_terjual' => $this->getStats($today, $yesterday, fn ($date) => OrderItem::whereHas('order', fn ($q) => $q->whereDate('created_at', $date))->sum('qty')),
|
||||
'total_pelanggan' => $this->getStats($today, $yesterday, fn ($date) => Order::whereDate('created_at', $date)->distinct('customer_name')->count('customer_name')),
|
||||
];
|
||||
|
||||
// Complex stats
|
||||
$stats['aov'] = $this->calculateAov($stats['total_pendapatan'], $stats['total_penjualan']);
|
||||
$stats['laba_kotor'] = $this->calculateDiff($stats['total_pendapatan'], $stats['hpp']);
|
||||
$stats['laba_bersih'] = $this->calculateDiff($stats['laba_kotor'], $stats['total_pengeluaran']);
|
||||
$stats['profit_margin'] = $this->calculateMargin($stats['laba_bersih'], $stats['total_pendapatan']);
|
||||
|
||||
return Inertia::render('dashboard', [
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
private function getStats($today, $yesterday, $callback)
|
||||
{
|
||||
$todayVal = $callback($today);
|
||||
$yesterdayVal = $callback($yesterday);
|
||||
|
||||
return [
|
||||
'value' => $todayVal,
|
||||
'yesterday' => $yesterdayVal,
|
||||
'change' => $this->calculatePercentageChange($todayVal, $yesterdayVal),
|
||||
];
|
||||
}
|
||||
|
||||
private function calculatePercentageChange($current, $previous)
|
||||
{
|
||||
if ($previous == 0) {
|
||||
return $current > 0 ? 100 : 0;
|
||||
}
|
||||
|
||||
return round((($current - $previous) / $previous) * 100, 2);
|
||||
}
|
||||
|
||||
private function calculateAov($revenue, $sales)
|
||||
{
|
||||
$todayVal = $sales['value'] > 0 ? $revenue['value'] / $sales['value'] : 0;
|
||||
$yesterdayVal = $sales['yesterday'] > 0 ? $revenue['yesterday'] / $sales['yesterday'] : 0;
|
||||
|
||||
return [
|
||||
'value' => $todayVal,
|
||||
'yesterday' => $yesterdayVal,
|
||||
'change' => $this->calculatePercentageChange($todayVal, $yesterdayVal),
|
||||
];
|
||||
}
|
||||
|
||||
private function calculateDiff($a, $b)
|
||||
{
|
||||
$todayVal = $a['value'] - $b['value'];
|
||||
$yesterdayVal = $a['yesterday'] - $b['yesterday'];
|
||||
|
||||
return [
|
||||
'value' => $todayVal,
|
||||
'yesterday' => $yesterdayVal,
|
||||
'change' => $this->calculatePercentageChange($todayVal, $yesterdayVal),
|
||||
];
|
||||
}
|
||||
|
||||
private function calculateMargin($profit, $revenue)
|
||||
{
|
||||
$todayVal = $revenue['value'] > 0 ? ($profit['value'] / $revenue['value']) * 100 : 0;
|
||||
$yesterdayVal = $revenue['yesterday'] > 0 ? ($profit['yesterday'] / $revenue['yesterday']) * 100 : 0;
|
||||
|
||||
return [
|
||||
'value' => round($todayVal, 2),
|
||||
'yesterday' => round($yesterdayVal, 2),
|
||||
'change' => $this->calculatePercentageChange($todayVal, $yesterdayVal),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,42 @@
|
||||
import { Head, usePage } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
|
||||
import { dashboard } from '@/routes';
|
||||
import { Auth } from '@/types';
|
||||
import { Sun, Moon, Cloud, Trees, CloudRain, Stars, Bird } from 'lucide-react';
|
||||
import {
|
||||
Sun, Moon, Cloud, Trees, CloudRain, Stars, Bird,
|
||||
TrendingUp, TrendingDown, ShoppingBag, DollarSign,
|
||||
Wallet, Receipt, Percent, BarChart3, CreditCard,
|
||||
ArrowUpRight, ArrowDownRight, Package, Users, ShoppingCart
|
||||
} from 'lucide-react';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardAction, CardFooter } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface StatItem {
|
||||
value: number;
|
||||
yesterday: number;
|
||||
change: number;
|
||||
}
|
||||
|
||||
interface DashboardStats {
|
||||
total_penjualan: StatItem;
|
||||
total_pendapatan: StatItem;
|
||||
total_pengeluaran: StatItem;
|
||||
hpp: StatItem;
|
||||
aov: StatItem;
|
||||
profit_margin: StatItem;
|
||||
total_diskon: StatItem;
|
||||
laba_kotor: StatItem;
|
||||
laba_bersih: StatItem;
|
||||
total_pembelian: StatItem;
|
||||
produk_terjual: StatItem;
|
||||
total_pelanggan: StatItem;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
auth: Auth;
|
||||
stats: DashboardStats;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@ -49,7 +79,7 @@ const DynamicScene = ({ hour }: { hour: number }) => {
|
||||
};
|
||||
|
||||
export default function Dashboard() {
|
||||
const { auth } = usePage<PageProps>().props;
|
||||
const { auth, stats } = usePage<PageProps>().props;
|
||||
const [time, setTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
@ -120,10 +150,76 @@ export default function Dashboard() {
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatNumber = (value: number) => {
|
||||
return new Intl.NumberFormat('id-ID').format(value);
|
||||
};
|
||||
|
||||
const StatCard = ({
|
||||
title,
|
||||
stat,
|
||||
icon: Icon,
|
||||
isCurrency = true,
|
||||
isPercentage = false,
|
||||
className
|
||||
}: {
|
||||
title: string;
|
||||
stat: StatItem;
|
||||
icon: any;
|
||||
isCurrency?: boolean;
|
||||
isPercentage?: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const isPositive = stat.change >= 0;
|
||||
|
||||
return (
|
||||
<Card className={cn("@container/card", className)}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardDescription>{title}</CardDescription>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold tabular-nums @[250px]/card:text-3xl">
|
||||
{isPercentage ? `${stat.value}%` : (isCurrency ? formatCurrency(stat.value) : formatNumber(stat.value))}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant={isPositive ? "default" : "destructive"} className="flex gap-1 px-1.5 py-0.5 text-xs font-bold">
|
||||
{isPositive ? <TrendingUp className="size-3" /> : <TrendingDown className="size-3" />}
|
||||
{Math.abs(stat.change)}%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-xs">
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
{isPositive ? (
|
||||
<span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
|
||||
<ArrowUpRight className="size-3" /> Meningkat
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-rose-600 dark:text-rose-400">
|
||||
<ArrowDownRight className="size-3" /> Menurun
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground">vs kemarin</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Kemarin: <span className="font-semibold">{isPercentage ? `${stat.yesterday}%` : (isCurrency ? formatCurrency(stat.yesterday) : formatNumber(stat.yesterday))}</span>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Dashboard" />
|
||||
<div className={`flex h-full min-h-screen flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4 transition-colors duration-1000`}>
|
||||
<div className={`flex h-full min-h-screen flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4 transition-colors duration-1000 @container/main`}>
|
||||
{/* Welcome Section */}
|
||||
<div className={`relative flex flex-col justify-between gap-4 overflow-hidden rounded-xl border p-6 shadow-2xl transition-all duration-1000 md:flex-row md:items-center ${theme.class}`}>
|
||||
{/* Decorative Elements */}
|
||||
@ -152,19 +248,33 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className={`relative aspect-video overflow-hidden rounded-xl border transition-all duration-1000`}>
|
||||
<PlaceholderPattern className={`absolute inset-0 size-full transition-colors duration-1000`} />
|
||||
</div>
|
||||
<div className={`relative aspect-video overflow-hidden rounded-xl border transition-all duration-1000`}>
|
||||
<PlaceholderPattern className={`absolute inset-0 size-full transition-colors duration-1000`} />
|
||||
</div>
|
||||
<div className={`relative aspect-video overflow-hidden rounded-xl border transition-all duration-1000`}>
|
||||
<PlaceholderPattern className={`absolute inset-0 size-full transition-colors duration-1000`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`relative min-h-[100vh] flex-1 overflow-hidden rounded-xl border transition-all duration-1000 md:min-h-min`}>
|
||||
<PlaceholderPattern className={`absolute inset-0 size-full transition-colors duration-1000`} />
|
||||
{/* 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_penjualan, icon: ShoppingBag, isCurrency: false },
|
||||
{ title: "Total Pendapatan", stat: stats.total_pendapatan, icon: DollarSign },
|
||||
{ title: "Total Pengeluaran", stat: stats.total_pengeluaran, icon: Wallet },
|
||||
{ title: "HPP (Modal)", stat: stats.hpp, icon: Receipt },
|
||||
{ title: "AOV (Avg Order Value)", stat: stats.aov, icon: CreditCard },
|
||||
{ title: "Profit Margin", stat: stats.profit_margin, icon: Percent, isPercentage: true },
|
||||
{ title: "Total Diskon", stat: stats.total_diskon, icon: Percent },
|
||||
{ title: "Laba Kotor", stat: stats.laba_kotor, icon: BarChart3 },
|
||||
{ title: "Laba Bersih", stat: stats.laba_bersih, icon: TrendingUp },
|
||||
{ title: "Total Pembelian", stat: stats.total_pembelian, icon: ShoppingCart },
|
||||
{ title: "Produk Terjual", stat: stats.produk_terjual, icon: Package, isCurrency: false },
|
||||
{ title: "Total Pelanggan", stat: stats.total_pelanggan, icon: Users, 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>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -7,8 +7,10 @@
|
||||
'canRegister' => Features::enabled(Features::registration()),
|
||||
])->name('home');
|
||||
|
||||
use App\Http\Controllers\DashboardController;
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::inertia('dashboard', 'dashboard')->name('dashboard');
|
||||
Route::get('dashboard', DashboardController::class)->name('dashboard');
|
||||
});
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user