feat: extract dashboard components and formatters to dedicated files for improved maintainability
This commit is contained in:
parent
5f8718af69
commit
803eebd026
50
resources/js/components/cards/stat-card.tsx
Normal file
50
resources/js/components/cards/stat-card.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { formatCurrency, formatNumber } from '@/lib/formatters';
|
||||||
|
import { StatItem } from '@/types';
|
||||||
|
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription, CardAction, CardFooter } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
export interface StatCardProps {
|
||||||
|
title: string;
|
||||||
|
stat: StatItem;
|
||||||
|
isCurrency?: boolean;
|
||||||
|
isPercentage?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({
|
||||||
|
title,
|
||||||
|
stat,
|
||||||
|
isCurrency = true,
|
||||||
|
isPercentage = false,
|
||||||
|
className
|
||||||
|
}: StatCardProps) {
|
||||||
|
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="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'}`}>
|
||||||
|
{isPositive ? <TrendingUp className="size-3" /> : <TrendingDown className="size-3" />}
|
||||||
|
{stat.change === null
|
||||||
|
? '∞'
|
||||||
|
: `${Math.abs(stat.change)}%`}
|
||||||
|
</Badge>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter className="flex-col items-start gap-1.5 text-xs">
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Kemarin: <span className="font-semibold">{isPercentage ? `${stat.yesterday}%` : (isCurrency ? formatCurrency(stat.yesterday) : formatNumber(stat.yesterday))}</span>
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
resources/js/components/cards/welcome-card.tsx
Normal file
85
resources/js/components/cards/welcome-card.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { formatTime, formatDate } from '@/lib/formatters';
|
||||||
|
import { Sun, Moon, Cloud, Trees, Stars, Bird } from 'lucide-react';
|
||||||
|
|
||||||
|
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 interface WelcomeCardProps {
|
||||||
|
user: { name: string };
|
||||||
|
time: Date;
|
||||||
|
theme: {
|
||||||
|
class: string;
|
||||||
|
decoration: string;
|
||||||
|
text: string;
|
||||||
|
subtext: string;
|
||||||
|
greeting: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WelcomeCard({ user, time, theme }: WelcomeCardProps) {
|
||||||
|
const hour = time.getHours();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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}, {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}`}>
|
||||||
|
{formatTime(time)}
|
||||||
|
</div>
|
||||||
|
<div className={`text-sm font-bold uppercase tracking-wider ${theme.subtext}`}>
|
||||||
|
{formatDate(time)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
resources/js/lib/formatters.ts
Normal file
28
resources/js/lib/formatters.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
export const formatTime = (date: Date) => {
|
||||||
|
return date.toLocaleTimeString('id-ID', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDate = (date: Date) => {
|
||||||
|
return date.toLocaleDateString('id-ID', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatCurrency = (value: number) => {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IDR',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatNumber = (value: number) => {
|
||||||
|
return new Intl.NumberFormat('id-ID').format(value);
|
||||||
|
};
|
||||||
@ -1,54 +1,10 @@
|
|||||||
import { Head, usePage } from '@inertiajs/react';
|
import { Head, usePage } from '@inertiajs/react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
import { StatItem, DashboardPageProps } from '@/types';
|
import { DashboardPageProps } from '@/types';
|
||||||
import {
|
import { StatCard } from '@/components/cards/stat-card';
|
||||||
Sun, Moon, Cloud, Trees, Stars, Bird,
|
import { WelcomeCard } from '@/components/cards/welcome-card';
|
||||||
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';
|
|
||||||
|
|
||||||
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() {
|
export default function Dashboard() {
|
||||||
const { auth, stats } = usePage<DashboardPageProps>().props;
|
const { auth, stats } = usePage<DashboardPageProps>().props;
|
||||||
@ -107,124 +63,29 @@ export default function Dashboard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const theme = getTimeTheme();
|
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="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'}`}>
|
|
||||||
{isPositive ? <TrendingUp className="size-3" /> : <TrendingDown className="size-3" />}
|
|
||||||
{stat.change === null
|
|
||||||
? '∞'
|
|
||||||
: `${Math.abs(stat.change)}%`}
|
|
||||||
</Badge>
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
<CardFooter className="flex-col items-start gap-1.5 text-xs">
|
|
||||||
<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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Dashboard" />
|
<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`}>
|
<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 */}
|
{/* 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}`}>
|
<WelcomeCard user={auth.user} time={time} theme={theme} />
|
||||||
{/* 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 */}
|
{/* 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">
|
<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, icon: ShoppingBag, isCurrency: false },
|
{ title: "Total Penjualan", stat: stats.total_sales, isCurrency: false },
|
||||||
{ title: "Total Pendapatan", stat: stats.total_revenue, icon: DollarSign },
|
{ title: "Total Pendapatan", stat: stats.total_revenue },
|
||||||
{ title: "Total Pengeluaran", stat: stats.total_expenses, icon: Wallet },
|
{ title: "Total Pengeluaran", stat: stats.total_expenses },
|
||||||
{ title: "HPP (Modal)", stat: stats.cogs, icon: Receipt },
|
{ title: "HPP (Modal)", stat: stats.cogs },
|
||||||
{ title: "AOV (Avg Order Value)", stat: stats.aov, icon: CreditCard },
|
{ title: "AOV (Avg Order Value)", stat: stats.aov },
|
||||||
{ title: "Profit Margin", stat: stats.profit_margin, icon: Percent, isPercentage: true },
|
{ title: "Profit Margin", stat: stats.profit_margin, isPercentage: true },
|
||||||
{ title: "Total Diskon", stat: stats.total_discount, icon: Percent },
|
{ title: "Total Diskon", stat: stats.total_discount },
|
||||||
{ title: "Laba Kotor", stat: stats.gross_profit, icon: BarChart3 },
|
{ title: "Laba Kotor", stat: stats.gross_profit },
|
||||||
{ title: "Laba Bersih", stat: stats.net_profit, icon: TrendingUp },
|
{ title: "Laba Bersih", stat: stats.net_profit },
|
||||||
{ title: "Total Pembelian", stat: stats.total_purchases, icon: ShoppingCart },
|
{ title: "Total Pembelian", stat: stats.total_purchases },
|
||||||
{ title: "Produk Terjual", stat: stats.products_sold, icon: Package, isCurrency: false },
|
{ title: "Produk Terjual", stat: stats.products_sold, isCurrency: false },
|
||||||
{ title: "Total Pelanggan", stat: stats.total_customers, icon: Users, isCurrency: false },
|
{ title: "Total Pelanggan", stat: stats.total_customers, isCurrency: false },
|
||||||
].map((item, index) => (
|
].map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user