- Introduced ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, and ChartLegendContent components to streamline chart rendering and tooltip management. - Replaced existing donut chart implementation with a new pie chart component utilizing the new charting system. - Updated dashboard to utilize the new chart components, improving maintainability and visual consistency. - Refactored chart data handling and configuration for better integration with the new charting components.
518 lines
18 KiB
TypeScript
518 lines
18 KiB
TypeScript
import { StatCard } from '@/components/card/stat-card';
|
|
import { TodayAttendanceAlert } from '@/components/card/today-attendance-alert';
|
|
import { CameraCapture } from '@/components/inputs';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
} from '@/components/ui/dialog';
|
|
import { useCan } from '@/hooks/use-can';
|
|
import { formatRupiah } from '@/lib/rupiah';
|
|
import { store, update } from '@/routes/admin/hr/attendances';
|
|
import { Head, router, usePage } from '@inertiajs/react';
|
|
import {
|
|
Clock,
|
|
Moon,
|
|
Sun,
|
|
Sunrise,
|
|
TrendingDown,
|
|
TrendingUp,
|
|
UserCheck,
|
|
} from 'lucide-react';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { Pie, PieChart } from 'recharts';
|
|
import { toast } from 'sonner';
|
|
|
|
type TodayAttendance = {
|
|
id: number;
|
|
attendance_date: string;
|
|
check_in_at: string | null;
|
|
check_out_at: string | null;
|
|
check_in_photo: string | null;
|
|
check_out_photo: string | null;
|
|
check_in_latitude: number;
|
|
check_in_longitude: number;
|
|
check_out_latitude: number | null;
|
|
check_out_longitude: number | null;
|
|
work_duration_minutes: number | null;
|
|
} | null;
|
|
|
|
type DashboardProps = {
|
|
attendance: {
|
|
total_employees: number;
|
|
percentage: number;
|
|
present: number;
|
|
absent: number;
|
|
on_leave: number;
|
|
};
|
|
revenueSummary: {
|
|
total_revenue: number;
|
|
total_discount: number;
|
|
total_cogs: number;
|
|
total_deduction: number;
|
|
total_orders: number;
|
|
};
|
|
expenseSummary: {
|
|
total: number;
|
|
expense_total: number;
|
|
advance_total: number;
|
|
};
|
|
orderStats: {
|
|
by_channel: Array<{
|
|
channel: string;
|
|
label: string;
|
|
count: number;
|
|
total: number;
|
|
}>;
|
|
by_payment_type: Array<{
|
|
payment_type: string;
|
|
label: string;
|
|
count: number;
|
|
total: number;
|
|
}>;
|
|
by_marketing: Array<{
|
|
name: string;
|
|
count: number;
|
|
total: number;
|
|
}>;
|
|
by_status: Array<{
|
|
status: string;
|
|
label: string;
|
|
count: number;
|
|
}>;
|
|
};
|
|
todayAttendance: TodayAttendance;
|
|
isOnLeave: boolean;
|
|
canCheckIn: boolean;
|
|
};
|
|
|
|
export default function Dashboard({
|
|
attendance: attendanceStats,
|
|
revenueSummary,
|
|
expenseSummary,
|
|
orderStats,
|
|
todayAttendance,
|
|
isOnLeave,
|
|
canCheckIn,
|
|
}: DashboardProps) {
|
|
const { can, hasRole } = useCan();
|
|
const { auth } = usePage().props as { auth: { user?: { username?: string } } };
|
|
const [currentTime, setCurrentTime] = useState(new Date());
|
|
const [showCamera, setShowCamera] = useState(false);
|
|
const [actionType, setActionType] = useState<'check-in' | 'check-out'>('check-in');
|
|
const [locationLoading, setLocationLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
|
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
|
|
const greeting = useMemo(() => {
|
|
const hour = currentTime.getHours();
|
|
|
|
if (hour >= 4 && hour < 11) {
|
|
return { text: 'Selamat Pagi', icon: Sunrise };
|
|
}
|
|
|
|
if (hour >= 11 && hour < 15) {
|
|
return { text: 'Selamat Siang', icon: Sun };
|
|
}
|
|
|
|
if (hour >= 15 && hour < 18) {
|
|
return { text: 'Selamat Sore', icon: Sun };
|
|
}
|
|
|
|
return { text: 'Selamat Malam', icon: Moon };
|
|
}, [currentTime]);
|
|
|
|
const timeStr = currentTime.toLocaleTimeString('id-ID', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false,
|
|
});
|
|
|
|
const dateStr = currentTime.toLocaleDateString('id-ID', {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
});
|
|
|
|
const visibleStatsCount = useMemo(() => {
|
|
let count = 0;
|
|
|
|
if (can('dashboard.attendance')) {
|
|
count++;
|
|
}
|
|
|
|
if (can('dashboard.revenue')) {
|
|
count++;
|
|
}
|
|
|
|
if (can('dashboard.expense')) {
|
|
count++;
|
|
}
|
|
|
|
return count;
|
|
}, [can]);
|
|
|
|
const statsGridClass = useMemo(() => {
|
|
if (visibleStatsCount === 1) {
|
|
return 'grid gap-4 grid-cols-1 md:max-w-md';
|
|
}
|
|
|
|
if (visibleStatsCount === 2) {
|
|
return 'grid gap-4 grid-cols-1 md:grid-cols-2';
|
|
}
|
|
|
|
if (visibleStatsCount === 3) {
|
|
return 'grid gap-4 grid-cols-1 md:grid-cols-3';
|
|
}
|
|
|
|
return 'grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4';
|
|
}, [visibleStatsCount]);
|
|
|
|
const visibleChartsCount = useMemo(() => {
|
|
let count = 0;
|
|
|
|
if (can('dashboard.orders_channel')) {
|
|
count++;
|
|
}
|
|
|
|
if (can('dashboard.orders_payment')) {
|
|
count++;
|
|
}
|
|
|
|
if (can('dashboard.orders_marketing')) {
|
|
count++;
|
|
}
|
|
|
|
if (can('dashboard.orders_status')) {
|
|
count++;
|
|
}
|
|
|
|
return count;
|
|
}, [can]);
|
|
|
|
const chartsGridClass = useMemo(() => {
|
|
if (visibleChartsCount === 1) {
|
|
return 'grid gap-4 grid-cols-1 md:max-w-xl';
|
|
}
|
|
|
|
if (visibleChartsCount === 2) {
|
|
return 'grid gap-4 grid-cols-1 md:grid-cols-2';
|
|
}
|
|
|
|
if (visibleChartsCount === 3) {
|
|
return 'grid gap-4 grid-cols-1 md:grid-cols-3';
|
|
}
|
|
|
|
return 'grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4';
|
|
}, [visibleChartsCount]);
|
|
|
|
const GreetingIcon = greeting.icon;
|
|
|
|
const handleCheckIn = () => {
|
|
setActionType('check-in');
|
|
setShowCamera(true);
|
|
};
|
|
|
|
const handleCheckOut = () => {
|
|
setActionType('check-out');
|
|
setShowCamera(true);
|
|
};
|
|
|
|
const handleCameraCapture = (dataUrl: string) => {
|
|
setShowCamera(false);
|
|
setLocationLoading(true);
|
|
|
|
if (!navigator.geolocation) {
|
|
setLocationLoading(false);
|
|
toast.error('Geolocation tidak didukung di browser ini.');
|
|
|
|
return;
|
|
}
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
(position) => {
|
|
setLocationLoading(false);
|
|
const formData = new FormData();
|
|
formData.append('photo', dataUrl);
|
|
formData.append('latitude', position.coords.latitude.toString());
|
|
formData.append('longitude', position.coords.longitude.toString());
|
|
|
|
if (actionType === 'check-in') {
|
|
router.post(store(), formData, { preserveScroll: true });
|
|
} else if (actionType === 'check-out' && todayAttendance) {
|
|
router.put(update(todayAttendance.id), formData, {
|
|
preserveScroll: true,
|
|
});
|
|
}
|
|
},
|
|
() => {
|
|
setLocationLoading(false);
|
|
toast.error('Gagal mendapatkan lokasi. Pastikan izin lokasi diberikan.');
|
|
},
|
|
{ enableHighAccuracy: true, timeout: 10000 },
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Head title="Dasbor" />
|
|
|
|
<div className="flex h-full flex-1 flex-col gap-6 p-4 md:p-6">
|
|
<Card className="relative overflow-visible">
|
|
<div className="absolute inset-0 bg-linear-to-br from-primary/5 to-background" />
|
|
<CardHeader className="relative">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex size-12 items-center justify-center rounded-full bg-primary/10">
|
|
<GreetingIcon className="size-6 text-primary" />
|
|
</div>
|
|
<div>
|
|
<CardTitle className="text-2xl font-bold">
|
|
{greeting.text}, {auth.user?.username}!
|
|
</CardTitle>
|
|
<CardDescription className="mt-1">
|
|
Selamat datang di dasbor aplikasi.
|
|
</CardDescription>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="relative">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<span>{dateStr}</span>
|
|
<span>-</span>
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="size-3.5" />
|
|
{timeStr}
|
|
</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{can('attendances.create') && !hasRole('developer') && !hasRole('owner') && (
|
|
<>
|
|
<TodayAttendanceAlert
|
|
todayAttendance={todayAttendance}
|
|
isOnLeave={isOnLeave}
|
|
canCheckIn={canCheckIn}
|
|
locationLoading={locationLoading}
|
|
onCheckIn={handleCheckIn}
|
|
onCheckOut={handleCheckOut}
|
|
/>
|
|
|
|
<Dialog open={showCamera} onOpenChange={setShowCamera}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<CameraCapture
|
|
onCapture={handleCameraCapture}
|
|
onClose={() => setShowCamera(false)}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)}
|
|
|
|
{visibleStatsCount > 0 && (
|
|
<div className={statsGridClass}>
|
|
{can('dashboard.attendance') && (
|
|
<StatCard
|
|
title="Kehadiran Hari Ini"
|
|
icon={UserCheck}
|
|
mainLabel="Total Karyawan"
|
|
mainValue={attendanceStats.total_employees}
|
|
subLabel={`${attendanceStats.percentage}% hadir`}
|
|
items={[
|
|
{ label: 'Hadir', value: attendanceStats.present },
|
|
{ label: 'Tidak Hadir', value: attendanceStats.absent },
|
|
{ label: 'Cuti', value: attendanceStats.on_leave },
|
|
]}
|
|
/>
|
|
)}
|
|
|
|
{can('dashboard.revenue') && (
|
|
<StatCard
|
|
title="Pendapatan Hari Ini"
|
|
icon={TrendingUp}
|
|
mainLabel="Total"
|
|
mainValue={`Rp${formatRupiah(revenueSummary.total_revenue)}`}
|
|
subLabel={`${revenueSummary.total_orders} transaksi selesai`}
|
|
items={[
|
|
{
|
|
label: 'Bersih',
|
|
value: `Rp${formatRupiah(revenueSummary.total_revenue - revenueSummary.total_cogs - revenueSummary.total_deduction)}`,
|
|
},
|
|
{
|
|
label: 'Potongan',
|
|
value: `Rp${formatRupiah(revenueSummary.total_deduction)}`,
|
|
},
|
|
{
|
|
label: 'Diskon',
|
|
value: `Rp${formatRupiah(revenueSummary.total_discount)}`,
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
|
|
{can('dashboard.expense') && (
|
|
<StatCard
|
|
title="Pengeluaran Hari Ini"
|
|
icon={TrendingDown}
|
|
mainLabel="Total"
|
|
mainValue={`Rp${formatRupiah(expenseSummary.total)}`}
|
|
items={[
|
|
{
|
|
label: 'Pengeluaran',
|
|
value: `Rp${formatRupiah(expenseSummary.expense_total)}`,
|
|
},
|
|
{
|
|
label: 'Kasbon',
|
|
value: `Rp${formatRupiah(expenseSummary.advance_total)}`,
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{visibleChartsCount > 0 && (
|
|
<div className={chartsGridClass}>
|
|
{can('dashboard.orders_channel') && (
|
|
<DashboardPieChart
|
|
title="Pesanan per Channel Hari Ini"
|
|
data={orderStats.by_channel.map((item) => ({
|
|
name: item.label,
|
|
count: item.count,
|
|
}))}
|
|
dataKey="count"
|
|
nameKey="name"
|
|
/>
|
|
)}
|
|
|
|
{can('dashboard.orders_payment') && (
|
|
<DashboardPieChart
|
|
title="Pesanan per Pembayaran Hari Ini"
|
|
data={orderStats.by_payment_type.map((item) => ({
|
|
name: item.label,
|
|
count: item.count,
|
|
}))}
|
|
dataKey="count"
|
|
nameKey="name"
|
|
/>
|
|
)}
|
|
|
|
{can('dashboard.orders_marketing') && (
|
|
<DashboardPieChart
|
|
title="Pesanan per Marketing Hari Ini"
|
|
data={orderStats.by_marketing.map((item) => ({
|
|
name: item.name,
|
|
count: item.count,
|
|
}))}
|
|
dataKey="count"
|
|
nameKey="name"
|
|
/>
|
|
)}
|
|
|
|
{can('dashboard.orders_status') && (
|
|
<DashboardPieChart
|
|
title="Pesanan per Status Hari Ini"
|
|
data={orderStats.by_status.map((item) => ({
|
|
name: item.label,
|
|
count: item.count,
|
|
}))}
|
|
dataKey="count"
|
|
nameKey="name"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
type PieChartItem = {
|
|
name: string;
|
|
count: number;
|
|
};
|
|
|
|
type DashboardPieChartProps = {
|
|
title: string;
|
|
data: PieChartItem[];
|
|
dataKey: string;
|
|
nameKey: string;
|
|
};
|
|
|
|
const PIE_COLORS = [
|
|
'var(--chart-1)',
|
|
'var(--chart-2)',
|
|
'var(--chart-3)',
|
|
'var(--chart-4)',
|
|
'var(--chart-5)',
|
|
];
|
|
|
|
function DashboardPieChart({ title, data, dataKey, nameKey }: DashboardPieChartProps) {
|
|
const hasData = data.length > 0 && data.some((d) => d.count > 0);
|
|
|
|
const chartConfig = useMemo(() => {
|
|
const config: ChartConfig = {};
|
|
data.forEach((item, index) => {
|
|
config[item.name] = {
|
|
label: item.name,
|
|
color: PIE_COLORS[index % PIE_COLORS.length],
|
|
};
|
|
});
|
|
return config;
|
|
}, [data]);
|
|
|
|
const chartData = useMemo(() => {
|
|
return data.map((item) => ({
|
|
...item,
|
|
fill: PIE_COLORS[data.indexOf(item) % PIE_COLORS.length],
|
|
}));
|
|
}, [data]);
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="items-center pb-0">
|
|
<CardTitle className="text-base">{title}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex-1 pb-0">
|
|
{hasData ? (
|
|
<ChartContainer
|
|
config={chartConfig}
|
|
className="mx-auto aspect-square max-h-[250px]"
|
|
>
|
|
<PieChart>
|
|
<ChartTooltip
|
|
content={
|
|
<ChartTooltipContent
|
|
nameKey={nameKey}
|
|
hideLabel
|
|
/>
|
|
}
|
|
/>
|
|
<Pie
|
|
data={chartData}
|
|
dataKey={dataKey}
|
|
nameKey={nameKey}
|
|
/>
|
|
<ChartLegend
|
|
content={<ChartLegendContent nameKey={nameKey} />}
|
|
className="-translate-y-2 flex-wrap gap-2 *:basis-1/4 *:justify-center"
|
|
/>
|
|
</PieChart>
|
|
</ChartContainer>
|
|
) : (
|
|
<div className="flex h-[200px] items-center justify-center text-muted-foreground">
|
|
Belum ada data
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|