dress/resources/js/pages/dashboard.tsx

480 lines
25 KiB
TypeScript

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,
TrendingUp, TrendingDown, ShoppingBag, DollarSign,
Wallet, Receipt, Percent, BarChart3, CreditCard,
ArrowUpRight, ArrowDownRight, Package, Users, ShoppingCart
} from 'lucide-react';
import { Card, CardHeader, CardTitle, CardDescription, CardAction, CardFooter, CardContent } from '@/components/ui/card';
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 {
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;
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 {
auth: Auth;
stats: DashboardStats;
[key: string]: any;
}
const DynamicScene = ({ hour }: { hour: number }) => {
if (hour >= 5 && hour < 11) {
return (
<div className="relative h-24 w-32 overflow-hidden">
<Sun className="absolute bottom-0 left-4 h-16 w-16 animate-pulse text-amber-400" fill="currentColor" />
<Cloud className="absolute top-2 right-2 h-10 w-10 animate-bounce text-white/80" fill="currentColor" />
<Cloud className="absolute top-8 left-0 h-8 w-8 animate-bounce text-white/60 [animation-delay:1s]" fill="currentColor" />
</div>
);
}
if (hour >= 11 && hour < 15) {
return (
<div className="relative h-24 w-32 overflow-hidden">
<Sun className="absolute top-0 right-4 h-14 w-14 animate-spin-slow text-yellow-400" fill="currentColor" />
<Trees className="absolute bottom-0 left-2 h-14 w-14 text-green-600 dark:text-green-500" />
<Cloud className="absolute top-4 left-4 h-8 w-8 animate-pulse text-white/80" fill="currentColor" />
</div>
);
}
if (hour >= 15 && hour < 19) {
return (
<div className="relative h-24 w-32 overflow-hidden">
<Sun className="absolute bottom-[-10px] left-8 h-16 w-16 text-orange-500" fill="currentColor" />
<Bird className="absolute top-4 left-4 h-6 w-6 animate-bounce text-orange-800 dark:text-orange-200" />
<Bird className="absolute top-8 right-8 h-5 w-5 animate-bounce text-orange-800 dark:text-orange-200 [animation-delay:0.5s]" />
</div>
);
}
return (
<div className="relative h-24 w-32 overflow-hidden">
<Moon className="absolute top-2 left-6 h-14 w-14 rotate-12 text-indigo-200" fill="currentColor" />
<Stars className="absolute top-4 right-4 h-6 w-6 animate-pulse text-yellow-200" />
<Stars className="absolute bottom-4 left-2 h-4 w-4 animate-pulse text-yellow-100 [animation-delay:1s]" />
<div className="absolute bottom-2 right-6 h-1 w-1 animate-ping rounded-full bg-white"></div>
</div>
);
};
export default function Dashboard() {
const { auth, stats } = usePage<PageProps>().props;
const [time, setTime] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(timer);
}, []);
const getTimeTheme = () => {
const hour = time.getHours();
if (hour >= 5 && hour < 11) return {
greeting: 'Selamat Pagi',
class: 'bg-gradient-to-br from-amber-50 to-orange-100 dark:from-amber-950/40 dark:to-orange-900/40 border-amber-200 dark:border-amber-800/30 shadow-sm',
text: 'text-amber-950 dark:text-amber-50',
subtext: 'text-amber-900/70 dark:text-amber-200/70',
decoration: 'bg-amber-500/10 dark:bg-amber-500/20',
pageBackground: 'bg-amber-50/50 dark:bg-amber-950/10',
card: 'bg-white/80 dark:bg-amber-900/10 backdrop-blur-sm border-amber-200/50 dark:border-amber-800/20',
placeholder: 'stroke-amber-500/20 dark:stroke-amber-400/20'
};
if (hour >= 11 && hour < 15) return {
greeting: 'Selamat Siang',
class: 'bg-gradient-to-br from-sky-50 to-blue-100 dark:from-sky-950/40 dark:to-blue-900/40 border-sky-200 dark:border-sky-800/30 shadow-sm',
text: 'text-sky-950 dark:text-sky-50',
subtext: 'text-sky-900/70 dark:text-sky-200/70',
decoration: 'bg-sky-500/10 dark:bg-sky-500/20',
pageBackground: 'bg-sky-50/50 dark:bg-sky-950/10',
card: 'bg-white/80 dark:bg-sky-900/10 backdrop-blur-sm border-sky-200/50 dark:border-sky-800/20',
placeholder: 'stroke-sky-500/20 dark:stroke-sky-400/20'
};
if (hour >= 15 && hour < 19) return {
greeting: 'Selamat Sore',
class: 'bg-gradient-to-br from-orange-50 to-purple-100 dark:from-orange-950/40 dark:to-purple-900/40 border-orange-200 dark:border-orange-800/30 shadow-sm',
text: 'text-orange-950 dark:text-orange-50',
subtext: 'text-orange-900/70 dark:text-orange-200/70',
decoration: 'bg-orange-500/10 dark:bg-orange-500/20',
pageBackground: 'bg-orange-50/50 dark:bg-orange-950/10',
card: 'bg-white/80 dark:bg-orange-900/10 backdrop-blur-sm border-orange-200/50 dark:border-orange-800/20',
placeholder: 'stroke-orange-500/20 dark:stroke-orange-400/20'
};
return {
greeting: 'Selamat Malam',
class: 'bg-gradient-to-br from-slate-900 via-indigo-950 to-black text-white border-indigo-500/20 shadow-indigo-950',
text: 'text-indigo-50 drop-shadow-md',
subtext: 'text-indigo-200/70 drop-shadow-sm',
decoration: 'bg-indigo-500/20',
pageBackground: 'bg-slate-100 dark:bg-slate-950',
card: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800',
placeholder: 'stroke-slate-300 dark:stroke-slate-700'
};
};
const theme = getTimeTheme();
const hour = time.getHours();
const formattedTime = time.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const formattedDate = time.toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
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 @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 */}
<div className={`absolute -right-10 -top-10 h-40 w-40 rounded-full blur-3xl ${theme.decoration} opacity-60`} />
<div className={`absolute -bottom-10 left-1/3 h-32 w-32 rounded-full blur-3xl ${theme.decoration} opacity-40`} />
<div className="z-10">
<h1 className={`text-3xl font-extrabold tracking-tight ${theme.text}`}>
{theme.greeting}, {auth.user.name}!
</h1>
<p className={`mt-1 text-lg font-medium ${theme.subtext}`}>
Senang melihat Anda kembali. Berikut adalah ringkasan bisnis Anda hari ini.
</p>
</div>
<div className="z-10 flex flex-col items-start gap-4 md:flex-row md:items-center md:gap-8">
<DynamicScene hour={hour} />
<div className="flex flex-col items-start gap-1 md:items-end">
<div className={`text-4xl font-mono font-black tracking-tighter ${theme.text}`}>
{formattedTime}
</div>
<div className={`text-sm font-bold uppercase tracking-wider ${theme.subtext}`}>
{formattedDate}
</div>
</div>
</div>
</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_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>
{/* 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>
</>
);
}
Dashboard.layout = {
breadcrumbs: [
{
title: 'Dasbor',
href: dashboard(),
},
],
};