dstpabuaran.com/resources/js/pages/dashboard.tsx

565 lines
21 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 { useCan } from '@/hooks/use-can';
import { generateRandomColors } from '@/lib/utils';
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 { Cell, 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;
gross: number;
net: number;
payroll_total: number;
expense_total: number;
total_orders: number;
total_products_sold: 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, hasAnyRole } = useCan();
const isMarketing = hasAnyRole(['marketing-offline', 'marketing-online']);
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 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 statsGridClass = useMemo(() => {
if (visibleStatsCount === 1) {
return 'grid gap-4 grid-cols-1 md:max-w-xl';
}
if (visibleStatsCount === 2) {
return 'grid gap-4 grid-cols-1 md:grid-cols-2';
}
return 'grid gap-4 grid-cols-1 md:grid-cols-3';
}, [visibleStatsCount]);
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}
/>
{showCamera && (
<CameraCapture
onCapture={handleCameraCapture}
onClose={() => setShowCamera(false)}
/>
)}
</>
)}
{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 },
]}
cols={3}
/>
)}
{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={
isMarketing
? [
{
label: 'Produk Terjual',
value: revenueSummary.total_products_sold.toLocaleString('id-ID'),
},
]
: [
{
label: 'Kotor',
value: `Rp${formatRupiah(revenueSummary.gross)}`,
},
{
label: 'Bersih',
value: `Rp${formatRupiah(revenueSummary.net)}`,
},
{
label: 'Gaji',
value: `Rp${formatRupiah(revenueSummary.payroll_total)}`,
},
{
label: 'Pengeluaran',
value: `Rp${formatRupiah(revenueSummary.expense_total)}`,
},
{
label: 'Potongan',
value: `Rp${formatRupiah(revenueSummary.total_deduction)}`,
},
{
label: 'Diskon',
value: `Rp${formatRupiah(revenueSummary.total_discount)}`,
},
{
label: 'HPP',
value: `Rp${formatRupiah(revenueSummary.total_cogs)}`,
},
]
}
cols={isMarketing ? 2 : visibleStatsCount === 1 ? 4 : visibleStatsCount === 2 ? 3 : 2}
/>
)}
{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)}`,
},
]}
cols={visibleStatsCount <= 2 ? 2 : 2}
/>
)}
</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;
};
function DashboardPieChart({ title, data, dataKey, nameKey }: DashboardPieChartProps) {
const hasData = data.length > 0 && data.some((d) => d.count > 0);
const [activeName, setActiveName] = useState<string | undefined>(undefined);
const pieColors = useMemo(() => generateRandomColors(5), []);
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
data.forEach((item, index) => {
config[item.name] = {
label: item.name,
color: pieColors[index % pieColors.length],
};
});
return config;
}, [data, pieColors]);
const chartData = useMemo(() => {
return data.map((item) => ({
...item,
fill: pieColors[data.indexOf(item) % pieColors.length],
}));
}, [data, pieColors]);
const handlePieClick = (_: unknown, index: number) => {
const name = chartData[index]?.name;
if (name) {
setActiveName((prev) => (prev === name ? undefined : name));
}
};
const handleLegendClick = (name: string) => {
setActiveName((prev) => (prev === name ? undefined : name));
};
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}
onClick={handlePieClick}
>
{chartData.map((item, index) => (
<Cell
key={`cell-${index}`}
opacity={activeName === undefined || activeName === item.name ? 1 : 0.3}
style={{ cursor: 'pointer' }}
/>
))}
</Pie>
<ChartLegend
content={
<ChartLegendContent
nameKey={nameKey}
onItemClick={handleLegendClick}
activeName={activeName}
/>
}
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>
);
}