feat: implement TodayAttendanceAlert component for improved attendance display and interaction

This commit is contained in:
Yoga Pangestu 2026-08-07 13:25:41 +07:00
parent 28f7ea0006
commit d648d48632
3 changed files with 213 additions and 91 deletions

View File

@ -0,0 +1,136 @@
import { CalendarCheck, CheckCircle2, Clock, LogIn, LogOut } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
type TodayAttendance = {
id: number;
check_in_at: string | null;
check_out_at: string | null;
} | null;
type TodayAttendanceAlertProps = {
todayAttendance: TodayAttendance;
canCheckIn?: boolean;
isOnLeave?: boolean;
locationLoading?: boolean;
onCheckIn?: () => void;
onCheckOut?: () => void;
showButtons?: boolean;
};
function formatTime(dateStr: string | null): string {
if (!dateStr) {
return '-';
}
return new Date(dateStr).toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
export function TodayAttendanceAlert({
todayAttendance,
canCheckIn = true,
isOnLeave = false,
locationLoading = false,
onCheckIn,
onCheckOut,
showButtons = true,
}: TodayAttendanceAlertProps) {
const hasCheckedIn = !!todayAttendance?.check_in_at;
const hasCheckedOut = !!todayAttendance?.check_out_at;
return (
<Alert>
<CalendarCheck className="h-4 w-4" />
<AlertTitle>Presensi Hari Ini</AlertTitle>
<AlertDescription>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
{hasCheckedIn ? (
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
<span className="text-muted-foreground">
Masuk:
</span>
<span className="font-medium">
{formatTime(todayAttendance?.check_in_at)}
</span>
</div>
<div className="flex items-center gap-1.5">
{hasCheckedOut ? (
<>
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
<span className="text-muted-foreground">
Pulang:
</span>
<span className="font-medium">
{formatTime(todayAttendance?.check_out_at)}
</span>
</>
) : (
<>
<Clock className="h-3.5 w-3.5 text-orange-500" />
<span className="text-muted-foreground">
Pulang:
</span>
<span className="font-medium text-orange-600">
Belum
</span>
</>
)}
</div>
</div>
) : isOnLeave ? (
<p className="text-sm text-muted-foreground">
Anda sedang cuti hari ini.
</p>
) : !canCheckIn ? (
<p className="text-sm text-muted-foreground">
Anda tidak memiliki akses presensi.
</p>
) : (
<p className="text-sm text-muted-foreground">
Anda belum melakukan presensi hari ini
</p>
)}
</div>
{showButtons && canCheckIn && !isOnLeave && (
<div className="flex items-center gap-2">
{locationLoading && (
<span className="text-xs text-muted-foreground">
Mendapatkan lokasi...
</span>
)}
<Button
size="sm"
onClick={onCheckIn}
disabled={hasCheckedIn || locationLoading}
>
<LogIn className="mr-1.5 h-3.5 w-3.5" />
Presensi Masuk
</Button>
<Button
size="sm"
variant="outline"
onClick={onCheckOut}
disabled={
!hasCheckedIn ||
hasCheckedOut ||
locationLoading
}
>
<LogOut className="mr-1.5 h-3.5 w-3.5" />
Presensi Pulang
</Button>
</div>
)}
</div>
</AlertDescription>
</Alert>
);
}

View File

@ -2,7 +2,6 @@ import { Head, router } from '@inertiajs/react';
import { addMonths, format, subMonths } from 'date-fns';
import { id } from 'date-fns/locale';
import {
CalendarCheck,
CalendarDays,
CheckCircle2,
ChevronLeft,
@ -17,7 +16,7 @@ import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { CameraCapture } from '@/components/camera-capture';
import { LocationMap } from '@/components/location-map';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { TodayAttendanceAlert } from '@/components/card/today-attendance-alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@ -329,9 +328,6 @@ export default function AttendanceIndex({
return format(new Date(dateStr), 'HH:mm');
}
const hasCheckedIn = !!todayAttendance;
const hasCheckedOut = !!todayAttendance?.check_out_at;
const today = new Date();
return (
@ -346,89 +342,12 @@ export default function AttendanceIndex({
</div>
{!isAdmin && (
<Alert>
<CalendarCheck className="h-4 w-4" />
<AlertTitle>Presensi Hari Ini</AlertTitle>
<AlertDescription>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
{hasCheckedIn ? (
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
<span className="text-muted-foreground">
Masuk:
</span>
<span className="font-medium">
{formatTime(
todayAttendance?.check_in_at,
)}
</span>
</div>
<div className="flex items-center gap-1.5">
{hasCheckedOut ? (
<>
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
<span className="text-muted-foreground">
Pulang:
</span>
<span className="font-medium">
{formatTime(
todayAttendance?.check_out_at,
)}
</span>
</>
) : (
<>
<Clock className="h-3.5 w-3.5 text-orange-500" />
<span className="text-muted-foreground">
Pulang:
</span>
<span className="font-medium text-orange-600">
Belum
</span>
</>
)}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
Anda belum melakukan presensi hari ini
</p>
)}
</div>
<div className="flex items-center gap-2">
{locationLoading && (
<span className="text-xs text-muted-foreground">
Mendapatkan lokasi...
</span>
)}
<Button
size="sm"
onClick={handleCheckIn}
disabled={hasCheckedIn || locationLoading}
>
<LogIn className="mr-1.5 h-3.5 w-3.5" />
Presensi Masuk
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCheckOut}
disabled={
!hasCheckedIn ||
hasCheckedOut ||
locationLoading
}
>
<LogOut className="mr-1.5 h-3.5 w-3.5" />
Presensi Pulang
</Button>
</div>
</div>
</AlertDescription>
</Alert>
<TodayAttendanceAlert
todayAttendance={todayAttendance}
locationLoading={locationLoading}
onCheckIn={handleCheckIn}
onCheckOut={handleCheckOut}
/>
)}
{monthStats && (

View File

@ -1,4 +1,4 @@
import { Head, usePage } from '@inertiajs/react';
import { Head, router, usePage } from '@inertiajs/react';
import {
Clock,
Moon,
@ -9,12 +9,19 @@ import {
UserCheck,
} from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Pie, PieChart, Cell, ResponsiveContainer, Tooltip } from 'recharts';
import { AttendanceCard } from '@/components/card/attendance-card';
import { CameraCapture } from '@/components/camera-capture';
import { TodayAttendanceAlert } from '@/components/card/today-attendance-alert';
import { StatCard } from '@/components/card/stat-card';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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';
type TodayAttendance = {
id: number;
@ -125,6 +132,9 @@ export default function Dashboard({
const { can } = 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);
@ -238,6 +248,51 @@ export default function Dashboard({
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" />
@ -272,12 +327,24 @@ export default function Dashboard({
</CardContent>
</Card>
<AttendanceCard
<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') && (