feat: implement TodayAttendanceAlert component for improved attendance display and interaction
This commit is contained in:
parent
28f7ea0006
commit
d648d48632
136
resources/js/components/card/today-attendance-alert.tsx
Normal file
136
resources/js/components/card/today-attendance-alert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 && (
|
||||
|
||||
@ -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') && (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user