770 lines
33 KiB
TypeScript
770 lines
33 KiB
TypeScript
import { TodayAttendanceAlert } from '@/components/card/today-attendance-alert';
|
|
import { CameraCapture, LocationMap } from '@/components/inputs';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
index as attendanceIndex,
|
|
store,
|
|
update,
|
|
} from '@/routes/admin/hr/attendances';
|
|
import { Head, router } from '@inertiajs/react';
|
|
import { addMonths, format, subMonths } from 'date-fns';
|
|
import { id } from 'date-fns/locale';
|
|
import {
|
|
CalendarDays,
|
|
CheckCircle2,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Clock,
|
|
LogIn,
|
|
LogOut,
|
|
UserX,
|
|
Wallet,
|
|
} from 'lucide-react';
|
|
import { useMemo, useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
|
|
type Attendance = {
|
|
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;
|
|
employee_name: string;
|
|
};
|
|
|
|
type MonthStats = {
|
|
working_days: number;
|
|
present: number;
|
|
absent: number;
|
|
leave: number;
|
|
};
|
|
|
|
type Props = {
|
|
attendances: Attendance[];
|
|
todayAttendance: Attendance | null;
|
|
currentYear: number;
|
|
currentMonth: number;
|
|
monthStats: MonthStats | null;
|
|
hrSettings: {
|
|
scheduled_check_in_time: string;
|
|
scheduled_check_out_time: string;
|
|
};
|
|
isAdmin: boolean;
|
|
};
|
|
|
|
const WEEKDAYS = ['Min', 'Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab'];
|
|
|
|
function getDaysInMonth(year: number, month: number): number {
|
|
return new Date(year, month, 0).getDate();
|
|
}
|
|
|
|
function getFirstDayOfMonth(year: number, month: number): number {
|
|
return new Date(year, month - 1, 1).getDay();
|
|
}
|
|
|
|
function isSameDay(d1: Date, d2: Date): boolean {
|
|
return (
|
|
d1.getDate() === d2.getDate() &&
|
|
d1.getMonth() === d2.getMonth() &&
|
|
d1.getFullYear() === d2.getFullYear()
|
|
);
|
|
}
|
|
|
|
function isWeekend(date: Date): boolean {
|
|
const day = date.getDay();
|
|
|
|
return day === 0 || day === 6;
|
|
}
|
|
|
|
function isLate(
|
|
checkInAt: string | null,
|
|
officeHour: number,
|
|
officeMinute: number,
|
|
): boolean {
|
|
if (!checkInAt) {
|
|
return false;
|
|
}
|
|
|
|
const d = new Date(checkInAt);
|
|
const h = d.getHours();
|
|
const m = d.getMinutes();
|
|
|
|
return h > officeHour || (h === officeHour && m > officeMinute);
|
|
}
|
|
|
|
function getLateMinutes(
|
|
checkInAt: string | null,
|
|
officeHour: number,
|
|
officeMinute: number,
|
|
): number {
|
|
if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) {
|
|
return 0;
|
|
}
|
|
|
|
const d = new Date(checkInAt);
|
|
const officeStart = new Date(d);
|
|
officeStart.setHours(officeHour, officeMinute, 0, 0);
|
|
|
|
return Math.ceil((d.getTime() - officeStart.getTime()) / 60000);
|
|
}
|
|
|
|
function formatMinutes(minutes: number | null): string {
|
|
if (!minutes) {
|
|
return '-';
|
|
}
|
|
|
|
const hours = Math.floor(minutes / 60);
|
|
const mins = Math.floor(minutes % 60);
|
|
|
|
if (mins === 0) {
|
|
return `${hours} jam`;
|
|
}
|
|
|
|
return `${hours} jam ${mins} menit`;
|
|
}
|
|
|
|
export default function AttendanceIndex({
|
|
attendances,
|
|
todayAttendance,
|
|
currentYear,
|
|
currentMonth,
|
|
monthStats,
|
|
hrSettings,
|
|
isAdmin,
|
|
}: Props) {
|
|
const [officeHour, officeMinute] = hrSettings.scheduled_check_in_time
|
|
.split(':')
|
|
.map(Number);
|
|
const [viewDate, setViewDate] = useState(
|
|
new Date(currentYear, currentMonth - 1),
|
|
);
|
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
|
const [showCamera, setShowCamera] = useState(false);
|
|
const [actionType, setActionType] = useState<'check-in' | 'check-out'>(
|
|
'check-in',
|
|
);
|
|
const [locationLoading, setLocationLoading] = useState(false);
|
|
const [detailAttendance, setDetailAttendance] = useState<Attendance | null>(
|
|
null,
|
|
);
|
|
|
|
const viewYear = viewDate.getFullYear();
|
|
const viewMonth = viewDate.getMonth() + 1;
|
|
|
|
const attendanceByDate = useMemo(() => {
|
|
const map = new Map<string, Attendance[]>();
|
|
attendances.forEach((att) => {
|
|
const existing = map.get(att.attendance_date) ?? [];
|
|
existing.push(att);
|
|
map.set(att.attendance_date, existing);
|
|
});
|
|
|
|
return map;
|
|
}, [attendances]);
|
|
|
|
const calendarDays = useMemo(() => {
|
|
const daysInMonth = getDaysInMonth(viewYear, viewMonth);
|
|
const firstDay = getFirstDayOfMonth(viewYear, viewMonth);
|
|
const prevMonthDays = getDaysInMonth(
|
|
viewYear,
|
|
viewMonth === 1 ? 12 : viewMonth - 1,
|
|
);
|
|
|
|
const days: { day: number; isCurrentMonth: boolean; date: Date }[] = [];
|
|
|
|
for (let i = firstDay - 1; i >= 0; i--) {
|
|
const d = prevMonthDays - i;
|
|
const m = viewMonth === 1 ? 12 : viewMonth - 1;
|
|
const y = viewMonth === 1 ? viewYear - 1 : viewYear;
|
|
days.push({
|
|
day: d,
|
|
isCurrentMonth: false,
|
|
date: new Date(y, m - 1, d),
|
|
});
|
|
}
|
|
|
|
for (let i = 1; i <= daysInMonth; i++) {
|
|
days.push({
|
|
day: i,
|
|
isCurrentMonth: true,
|
|
date: new Date(viewYear, viewMonth - 1, i),
|
|
});
|
|
}
|
|
|
|
const remaining = 42 - days.length;
|
|
|
|
for (let i = 1; i <= remaining; i++) {
|
|
const m = viewMonth === 12 ? 1 : viewMonth + 1;
|
|
const y = viewMonth === 12 ? viewYear + 1 : viewYear;
|
|
days.push({
|
|
day: i,
|
|
isCurrentMonth: false,
|
|
date: new Date(y, m - 1, i),
|
|
});
|
|
}
|
|
|
|
return days;
|
|
}, [viewYear, viewMonth]);
|
|
|
|
const handlePrevMonth = () => {
|
|
const newDate = subMonths(viewDate, 1);
|
|
setViewDate(newDate);
|
|
|
|
if (!isAdmin) {
|
|
router.get(
|
|
attendanceIndex.url(),
|
|
{
|
|
year: newDate.getFullYear(),
|
|
month: newDate.getMonth() + 1,
|
|
},
|
|
{ preserveState: true, preserveScroll: true },
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleNextMonth = () => {
|
|
const newDate = addMonths(viewDate, 1);
|
|
setViewDate(newDate);
|
|
|
|
if (!isAdmin) {
|
|
router.get(
|
|
attendanceIndex.url(),
|
|
{
|
|
year: newDate.getFullYear(),
|
|
month: newDate.getMonth() + 1,
|
|
},
|
|
{ preserveState: true, preserveScroll: true },
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleGoToToday = () => {
|
|
const now = new Date();
|
|
setViewDate(now);
|
|
setSelectedDate(now);
|
|
|
|
if (!isAdmin) {
|
|
router.get(
|
|
attendanceIndex.url(),
|
|
{ year: now.getFullYear(), month: now.getMonth() + 1 },
|
|
{ preserveState: true, preserveScroll: 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 },
|
|
);
|
|
};
|
|
|
|
const handleCheckIn = () => {
|
|
setActionType('check-in');
|
|
setShowCamera(true);
|
|
};
|
|
|
|
const handleCheckOut = () => {
|
|
setActionType('check-out');
|
|
setShowCamera(true);
|
|
};
|
|
|
|
function formatTime(dateStr: string | null): string {
|
|
if (!dateStr) {
|
|
return '-';
|
|
}
|
|
|
|
return format(new Date(dateStr), 'HH:mm');
|
|
}
|
|
|
|
const today = new Date();
|
|
|
|
return (
|
|
<>
|
|
<Head title="Presensi" />
|
|
|
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
|
<div>
|
|
<h2 className="text-2xl font-semibold tracking-tight">
|
|
Presensi
|
|
</h2>
|
|
</div>
|
|
|
|
{!isAdmin && (
|
|
<TodayAttendanceAlert
|
|
todayAttendance={todayAttendance}
|
|
locationLoading={locationLoading}
|
|
onCheckIn={handleCheckIn}
|
|
onCheckOut={handleCheckOut}
|
|
/>
|
|
)}
|
|
|
|
{monthStats && (
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
<Card>
|
|
<CardContent className="flex items-center gap-3 py-3">
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-100">
|
|
<CalendarDays className="h-5 w-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Hari Kerja
|
|
</p>
|
|
<p className="text-lg font-bold">
|
|
{monthStats.working_days}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="flex items-center gap-3 py-3">
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-green-100">
|
|
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Hadir
|
|
</p>
|
|
<p className="text-lg font-bold">
|
|
{monthStats.present}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="flex items-center gap-3 py-3">
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-red-100">
|
|
<UserX className="h-5 w-5 text-red-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Tidak Hadir
|
|
</p>
|
|
<p className="text-lg font-bold">
|
|
{monthStats.absent}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="flex items-center gap-3 py-3">
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-amber-100">
|
|
<Wallet className="h-5 w-5 text-amber-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Cuti
|
|
</p>
|
|
<p className="text-lg font-bold">
|
|
{monthStats.leave}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
<Card className="py-0!">
|
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex flex-col items-center overflow-hidden rounded-lg ring-1 ring-border">
|
|
<div className="bg-muted px-3 py-0.5">
|
|
<span className="text-xs font-semibold text-muted-foreground uppercase">
|
|
{format(today, 'MMM', {
|
|
locale: id,
|
|
})}
|
|
</span>
|
|
</div>
|
|
<div className="px-3 py-1">
|
|
<span className="text-lg font-bold text-primary">
|
|
{format(today, 'dd')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-foreground">
|
|
{format(viewDate, 'MMMM yyyy', {
|
|
locale: id,
|
|
})}
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={handlePrevMonth}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-3 text-xs font-medium"
|
|
onClick={handleGoToToday}
|
|
>
|
|
Hari ini
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={handleNextMonth}
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<div className="grid min-w-[700px] grid-cols-7 border-b">
|
|
{WEEKDAYS.map((day) => (
|
|
<div
|
|
key={day}
|
|
className="flex items-center justify-center py-3 text-xs font-medium text-muted-foreground"
|
|
>
|
|
{day}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid min-w-[700px] grid-cols-7">
|
|
{calendarDays.map((cell, idx) => {
|
|
const dateStr = format(cell.date, 'yyyy-MM-dd');
|
|
const dayAttendances = attendanceByDate.get(dateStr) ?? [];
|
|
const isSelected = isSameDay(
|
|
cell.date,
|
|
selectedDate,
|
|
);
|
|
const isTodayDate = isSameDay(
|
|
cell.date,
|
|
new Date(),
|
|
);
|
|
|
|
const todayMidnight = new Date();
|
|
todayMidnight.setHours(0, 0, 0, 0);
|
|
const cellDate = new Date(cell.date);
|
|
cellDate.setHours(0, 0, 0, 0);
|
|
|
|
const isPastDate = cellDate < todayMidnight;
|
|
const showAbsent =
|
|
cell.isCurrentMonth &&
|
|
isPastDate &&
|
|
!isWeekend(cell.date) &&
|
|
dayAttendances.length === 0;
|
|
|
|
return (
|
|
<div
|
|
key={idx}
|
|
onClick={() => {
|
|
setSelectedDate(cell.date);
|
|
|
|
if (!isAdmin && dayAttendances.length > 0) {
|
|
setDetailAttendance(dayAttendances[0]);
|
|
}
|
|
}}
|
|
className={`group relative flex min-h-[100px] flex-col p-2 text-left transition-colors hover:bg-muted/50 ${!cell.isCurrentMonth
|
|
? 'bg-muted/30 text-muted-foreground/50'
|
|
: ''
|
|
} ${isSelected ? 'bg-muted/50' : ''} ${!isAdmin && dayAttendances.length > 0 ? 'cursor-pointer' : ''
|
|
}`}
|
|
style={{
|
|
borderRight: '1px solid var(--border)',
|
|
borderBottom: '1px solid var(--border)',
|
|
}}
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<span
|
|
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-colors ${isSelected
|
|
? 'bg-primary text-primary-foreground'
|
|
: isTodayDate
|
|
? 'bg-muted text-foreground'
|
|
: 'text-foreground'
|
|
}`}
|
|
>
|
|
{cell.day}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 flex flex-col gap-0.5 overflow-hidden">
|
|
{isAdmin ? (
|
|
<>
|
|
{dayAttendances.map((att) => {
|
|
const late = isLate(
|
|
att.check_in_at,
|
|
officeHour,
|
|
officeMinute,
|
|
);
|
|
|
|
return (
|
|
<button
|
|
key={att.id}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setDetailAttendance(att);
|
|
}}
|
|
className="w-full"
|
|
>
|
|
<Badge
|
|
variant={late ? 'destructive' : 'default'}
|
|
className="w-full justify-center cursor-pointer truncate"
|
|
>
|
|
{att.employee_name}
|
|
</Badge>
|
|
</button>
|
|
);
|
|
})}
|
|
</>
|
|
) : (
|
|
<>
|
|
{dayAttendances.length > 0 && (() => {
|
|
const att = dayAttendances[0];
|
|
const late = isLate(
|
|
att.check_in_at,
|
|
officeHour,
|
|
officeMinute,
|
|
);
|
|
const lateMins = getLateMinutes(
|
|
att.check_in_at,
|
|
officeHour,
|
|
officeMinute,
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<span
|
|
className={`inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${late ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'}`}
|
|
>
|
|
{late
|
|
? 'Terlambat'
|
|
: 'Hadir'}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
Masuk :{' '}
|
|
{formatTime(
|
|
att.check_in_at,
|
|
)}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
Pulang :{' '}
|
|
{formatTime(
|
|
att.check_out_at,
|
|
)}
|
|
</span>
|
|
{late && (
|
|
<span className="text-[10px] text-yellow-600">
|
|
Telat :{' '}
|
|
{formatMinutes(
|
|
lateMins,
|
|
)}{' '}
|
|
menit
|
|
</span>
|
|
)}
|
|
<span className="text-[10px] text-muted-foreground">
|
|
Jam Kerja :{' '}
|
|
{formatMinutes(
|
|
att.work_duration_minutes,
|
|
)}
|
|
</span>
|
|
</>
|
|
);
|
|
})()}
|
|
</>
|
|
)}
|
|
{showAbsent && (
|
|
<span className="inline-flex items-center justify-center rounded-full bg-red-100 px-1.5 py-0.5 text-[10px] font-semibold text-red-700">
|
|
Tidak Hadir
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{showCamera && (
|
|
<CameraCapture
|
|
onCapture={handleCameraCapture}
|
|
onClose={() => setShowCamera(false)}
|
|
/>
|
|
)}
|
|
|
|
<Dialog
|
|
open={!!detailAttendance}
|
|
onOpenChange={(open) => !open && setDetailAttendance(null)}
|
|
>
|
|
<DialogContent className="sm:max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{isAdmin && detailAttendance?.employee_name
|
|
? `${detailAttendance.employee_name} - `
|
|
: ''}
|
|
Detail Presensi -{' '}
|
|
{detailAttendance &&
|
|
format(
|
|
new Date(detailAttendance.attendance_date),
|
|
'dd MMMM yyyy',
|
|
{ locale: id },
|
|
)}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
{detailAttendance && (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="flex flex-col gap-2">
|
|
<span className="text-sm font-medium text-muted-foreground">
|
|
Foto Masuk
|
|
</span>
|
|
{detailAttendance.check_in_photo ? (
|
|
<img
|
|
src={
|
|
detailAttendance.check_in_photo
|
|
}
|
|
alt="Foto Masuk"
|
|
className="w-full rounded-lg border object-cover"
|
|
/>
|
|
) : (
|
|
<div className="flex h-40 items-center justify-center rounded-lg border bg-muted text-sm text-muted-foreground">
|
|
Tidak ada foto
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<LogIn className="h-4 w-4 text-green-600" />
|
|
<span className="font-medium">
|
|
{formatTime(
|
|
detailAttendance.check_in_at,
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<span className="text-sm font-medium text-muted-foreground">
|
|
Foto Pulang
|
|
</span>
|
|
{detailAttendance.check_out_photo ? (
|
|
<img
|
|
src={
|
|
detailAttendance.check_out_photo
|
|
}
|
|
alt="Foto Pulang"
|
|
className="w-full rounded-lg border object-cover"
|
|
/>
|
|
) : (
|
|
<div className="flex h-40 items-center justify-center rounded-lg border bg-muted text-sm text-muted-foreground">
|
|
Tidak ada foto
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2 text-sm">
|
|
{detailAttendance.check_out_at ? (
|
|
<>
|
|
<LogOut className="h-4 w-4 text-blue-600" />
|
|
<span className="font-medium">
|
|
{formatTime(
|
|
detailAttendance.check_out_at,
|
|
)}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Clock className="h-4 w-4 text-orange-500" />
|
|
<span className="font-medium text-orange-600">
|
|
Belum pulang
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex flex-col gap-2">
|
|
<span className="text-sm font-medium text-muted-foreground">
|
|
Lokasi Masuk
|
|
</span>
|
|
<LocationMap
|
|
latitude={
|
|
detailAttendance.check_in_latitude
|
|
}
|
|
longitude={
|
|
detailAttendance.check_in_longitude
|
|
}
|
|
height="200px"
|
|
/>
|
|
</div>
|
|
{detailAttendance.check_out_latitude && detailAttendance.check_out_longitude && (
|
|
<div className="flex flex-col gap-2">
|
|
<span className="text-sm font-medium text-muted-foreground">
|
|
Lokasi Pulang
|
|
</span>
|
|
<LocationMap
|
|
latitude={
|
|
detailAttendance.check_out_latitude
|
|
}
|
|
longitude={
|
|
detailAttendance.check_out_longitude
|
|
}
|
|
height="200px"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|