39898-vm/frontend/src/pages/clock-in.tsx
2026-05-05 06:02:39 +00:00

1006 lines
40 KiB
TypeScript

import * as icon from '@mdi/js';
import axios from 'axios';
import dayjs from 'dayjs';
import Head from 'next/head';
import React, {
ChangeEvent,
ReactElement,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import BaseButton from '../components/BaseButton';
import BaseButtons from '../components/BaseButtons';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import FileUploader from '../components/Uploaders/UploadService';
import { getPageTitle } from '../config';
import { hasPermission } from '../helpers/userPermissions';
import LayoutAuthenticated from '../layouts/Authenticated';
import { useAppSelector } from '../stores/hooks';
type ClockInEmployee = {
id: string;
employee_code: string | null;
full_name: string | null;
phone: string | null;
userId: string | null;
outletId: string | null;
job_positionId: string | null;
};
type ClockInOutlet = {
id: string;
code: string | null;
name: string | null;
address: string | null;
gps_lat: number | null;
gps_lng: number | null;
gps_radius_m: number | null;
is_active: boolean;
};
type ClockInJobPosition = {
id: string;
code: string | null;
name: string | null;
payroll_weight: number | null;
is_active: boolean;
shift_schedule: string | null;
shift_times: string[];
};
type ClockInFile = {
id: string;
name: string;
sizeInBytes: number;
privateUrl: string;
publicUrl: string;
new?: boolean;
};
type ClockInLog = {
id: string;
work_date: string | null;
check_in_at: string | null;
check_out_at: string | null;
check_in_lat: number | null;
check_in_lng: number | null;
check_out_lat: number | null;
check_out_lng: number | null;
status: string | null;
late_minutes: number | null;
gps_valid: boolean;
remarks: string | null;
employee: ClockInEmployee | null;
outlet: ClockInOutlet | null;
check_in_photo: ClockInFile[];
check_out_photo: ClockInFile[];
};
type RosterShift = {
label: string;
time: string;
startsAt: string;
windowStartAt: string;
windowEndAt: string;
windowLabel: string;
lateMinutes: number;
latePenaltyAmount: number;
status: string;
withinWindow: boolean;
};
type ClockInRoster = {
latePenaltyPerMinute: number;
windowBeforeMinutes: number;
windowAfterMinutes: number;
shiftScheduleRaw: string | null;
shiftTimes: string[];
activeShift: RosterShift | null;
nextShift: RosterShift | null;
assignedShift: RosterShift | null;
blockedReason: string | null;
};
type ClockInContext = {
employee: ClockInEmployee | null;
outlet: ClockInOutlet | null;
jobPosition: ClockInJobPosition | null;
todayLog: ClockInLog | null;
canClockIn: boolean;
setupError: string | null;
clockInBlockedReason: string | null;
roster: ClockInRoster;
timezoneOffsetMinutes: number;
};
type ClockInResponse = {
success: boolean;
action: 'created' | 'updated';
attendanceLog: ClockInLog;
jobPosition: ClockInJobPosition | null;
roster: ClockInRoster;
timezoneOffsetMinutes: number;
};
type LocationState = {
latitude: number;
longitude: number;
accuracy: number | null;
capturedAt: string;
};
const pageIcon =
'mdiCellphoneMarker' in icon
? icon['mdiCellphoneMarker' as keyof typeof icon]
: icon.mdiMapMarkerCheck ?? icon.mdiTable;
const gpsIcon =
'mdiCrosshairsGps' in icon
? icon['mdiCrosshairsGps' as keyof typeof icon]
: icon.mdiMapMarkerCheck ?? icon.mdiTable;
const selfieIcon =
'mdiCameraOutline' in icon
? icon['mdiCameraOutline' as keyof typeof icon]
: icon.mdiUpload ?? icon.mdiTable;
const successIcon =
'mdiCheckCircleOutline' in icon
? icon['mdiCheckCircleOutline' as keyof typeof icon]
: icon.mdiCheckCircle ?? icon.mdiTable;
const warningIcon =
'mdiAlertCircleOutline' in icon
? icon['mdiAlertCircleOutline' as keyof typeof icon]
: icon.mdiAlert ?? icon.mdiTable;
const refreshIcon = icon.mdiReload ?? icon.mdiTable;
const detailIcon =
'mdiOpenInNew' in icon
? icon['mdiOpenInNew' as keyof typeof icon]
: icon.mdiArrowRightBoldCircleOutline ?? icon.mdiTable;
const calendarIcon =
'mdiCalendarClock' in icon
? icon['mdiCalendarClock' as keyof typeof icon]
: icon.mdiCalendar ?? icon.mdiTable;
const shiftIcon =
'mdiClockOutline' in icon
? icon['mdiClockOutline' as keyof typeof icon]
: icon.mdiClock ?? icon.mdiTable;
const officeIcon =
'mdiStoreMarker' in icon
? icon['mdiStoreMarker' as keyof typeof icon]
: icon.mdiStore ?? icon.mdiTable;
const teamIcon =
'mdiAccountTieOutline' in icon
? icon['mdiAccountTieOutline' as keyof typeof icon]
: icon.mdiAccount ?? icon.mdiTable;
const inputClassName =
'w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm outline-none transition focus:border-teal-500 focus:ring focus:ring-teal-200';
const formatDateTime = (value?: string | null) => {
if (!value) {
return '—';
}
return dayjs(value).format('DD MMM YYYY • HH:mm');
};
const formatTime = (value?: string | null) => {
if (!value) {
return '—';
}
return dayjs(value).format('HH:mm');
};
const formatCurrency = (value?: number | null) =>
new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(Number.isFinite(value) ? Number(value) : 0);
const getErrorMessage = (error: unknown) => {
if (axios.isAxiosError(error)) {
if (typeof error.response?.data === 'string' && error.response.data) {
return error.response.data;
}
if (
error.response?.data &&
typeof error.response.data === 'object' &&
error.response.data !== null &&
'message' in error.response.data &&
typeof error.response.data.message === 'string'
) {
return error.response.data.message;
}
return error.message;
}
if (error instanceof Error) {
return error.message;
}
return 'Terjadi kesalahan saat memproses clock in.';
};
const getStatusBadgeClasses = (status?: string | null) => {
if (status === 'telat') {
return 'border border-amber-200 bg-amber-50 text-amber-700';
}
if (status === 'hadir') {
return 'border border-emerald-200 bg-emerald-50 text-emerald-700';
}
return 'border border-slate-200 bg-slate-50 text-slate-700';
};
const getChecklistBadgeClasses = (ok: boolean) =>
ok ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-600';
const ClockInPage = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const canReadAttendanceLogs = hasPermission(currentUser, 'READ_ATTENDANCE_LOGS');
const timezoneOffsetMinutes = useMemo(() => new Date().getTimezoneOffset(), []);
const [context, setContext] = useState<ClockInContext | null>(null);
const [result, setResult] = useState<ClockInResponse | null>(null);
const [remarks, setRemarks] = useState('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState('');
const [location, setLocation] = useState<LocationState | null>(null);
const [manualLatitude, setManualLatitude] = useState('');
const [manualLongitude, setManualLongitude] = useState('');
const [pageError, setPageError] = useState('');
const [submitError, setSubmitError] = useState('');
const [locationError, setLocationError] = useState('');
const [isLoadingContext, setIsLoadingContext] = useState(true);
const [isLocating, setIsLocating] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const todayLog = context?.todayLog || result?.attendanceLog || null;
const roster = context?.roster || result?.roster || null;
const jobPosition = context?.jobPosition || result?.jobPosition || null;
const hasClockedIn = Boolean(todayLog?.check_in_at);
const lateMinutes = typeof todayLog?.late_minutes === 'number' ? todayLog.late_minutes : 0;
const latePenaltyAmount = lateMinutes * (roster?.latePenaltyPerMinute || 0);
const employeeLabel =
context?.employee?.full_name ||
currentUser?.employees_user?.[0]?.full_name ||
currentUser?.firstName ||
currentUser?.email ||
'Belum terhubung';
const outletLabel = context?.outlet?.name || 'Belum ada outlet';
const availableShiftLabel = roster?.shiftTimes.length ? roster.shiftTimes.join(', ') : 'Belum diatur';
useEffect(() => {
if (!selectedFile) {
setPreviewUrl('');
return undefined;
}
const nextPreviewUrl = URL.createObjectURL(selectedFile);
setPreviewUrl(nextPreviewUrl);
return () => {
URL.revokeObjectURL(nextPreviewUrl);
};
}, [selectedFile]);
const loadClockInContext = useCallback(async () => {
setIsLoadingContext(true);
setPageError('');
try {
const response = await axios.get<ClockInContext>('/attendance_logs/clock-in', {
params: {
timezoneOffsetMinutes,
},
});
setContext(response.data);
} catch (error) {
console.error('Failed to load clock-in context:', error);
setPageError(getErrorMessage(error));
} finally {
setIsLoadingContext(false);
}
}, [timezoneOffsetMinutes]);
const requestLocation = useCallback(async () => {
if (typeof window === 'undefined' || !navigator.geolocation) {
setLocation(null);
setLocationError('Browser ini belum mendukung GPS otomatis. Isi koordinat manual untuk testing.');
return;
}
setIsLocating(true);
setLocationError('');
await new Promise<void>((resolve) => {
navigator.geolocation.getCurrentPosition(
(position) => {
setLocation({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
capturedAt: new Date().toISOString(),
});
setManualLatitude('');
setManualLongitude('');
setIsLocating(false);
resolve();
},
(error) => {
console.error('Failed to get geolocation:', error);
setLocation(null);
setLocationError(
'GPS tidak bisa diambil otomatis. Izinkan location di browser, atau isi koordinat manual untuk testing dev.',
);
setIsLocating(false);
resolve();
},
{
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 0,
},
);
});
}, []);
useEffect(() => {
void loadClockInContext();
void requestLocation();
}, [loadClockInContext, requestLocation]);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const nextFile = event.target.files?.[0] ?? null;
setSelectedFile(nextFile);
};
const handleClockIn = async () => {
setSubmitError('');
if (!context) {
setSubmitError('Context clock in belum siap. Coba refresh halaman.');
return;
}
if (!context.canClockIn) {
setSubmitError(
context.clockInBlockedReason || context.setupError || 'Clock in belum bisa dilakukan sekarang.',
);
return;
}
if (!selectedFile) {
setSubmitError('Ambil atau unggah foto selfie terlebih dulu.');
return;
}
const latitude = location?.latitude ?? Number(manualLatitude);
const longitude = location?.longitude ?? Number(manualLongitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
setSubmitError('GPS belum tersedia. Klik “Ambil GPS lagi”, atau isi koordinat manual untuk testing dev.');
return;
}
setIsSubmitting(true);
try {
const uploadedPhoto = (await FileUploader.upload(
'attendance_logs/check_in_photo',
selectedFile,
{ image: true },
)) as ClockInFile;
const response = await axios.post<ClockInResponse>('/attendance_logs/clock-in', {
latitude,
longitude,
remarks,
check_in_photo: [uploadedPhoto],
clientTimestamp: new Date().toISOString(),
timezoneOffsetMinutes,
});
setResult(response.data);
setSelectedFile(null);
setRemarks('');
await loadClockInContext();
} catch (error) {
console.error('Clock-in request failed:', error);
setSubmitError(getErrorMessage(error));
} finally {
setIsSubmitting(false);
}
};
const checklistItems = [
{
label: 'User terhubung ke employee',
ok: Boolean(context?.employee),
},
{
label: 'Employee terhubung ke outlet',
ok: Boolean(context?.outlet),
},
{
label: 'Employee terhubung ke divisi/job position',
ok: Boolean(jobPosition),
},
{
label: 'Jadwal shift sudah dikonfigurasi',
ok: Boolean(roster?.shiftTimes.length),
},
{
label: 'Sedang berada di window shift yang valid',
ok: Boolean(roster?.activeShift?.withinWindow),
},
{
label: 'GPS tersedia',
ok:
typeof location?.latitude === 'number' ||
(manualLatitude.trim() !== '' && manualLongitude.trim() !== ''),
},
{
label: 'Foto selfie dipilih',
ok: Boolean(selectedFile),
},
{
label: 'Belum ada clock in hari ini',
ok: !hasClockedIn,
},
];
return (
<>
<Head>
<title>{getPageTitle('Clock in')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={pageIcon} title="Clock in" main>
{''}
</SectionTitleLineWithButton>
<div className="space-y-6">
<CardBox className="overflow-hidden bg-gradient-to-br from-slate-950 via-slate-900 to-teal-900 text-white shadow-xl">
<div className="grid gap-6 lg:grid-cols-[1.2fr,0.8fr]">
<div className="space-y-4">
<div className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-3 py-1 text-sm font-medium text-slate-100 backdrop-blur">
<BaseIcon path={pageIcon} size={18} />
Flow Clock In Otomatis v2
</div>
<div>
<h2 className="text-2xl font-semibold md:text-3xl">
Clock in otomatis dengan roster shift, GPS, selfie, dan denda telat per menit.
</h2>
<p className="mt-3 max-w-2xl text-sm text-slate-200 md:text-base">
Sistem akan otomatis mencocokkan absensi ke shift yang tersedia pada divisi user.
Clock in hanya boleh dilakukan 60 menit sebelum sampai 60 menit sesudah shift
dimulai.
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
<div className="rounded-2xl border border-white/15 bg-white/10 p-4 backdrop-blur">
<div className="text-xs uppercase tracking-[0.2em] text-slate-300">Employee</div>
<div className="mt-2 text-lg font-semibold">{employeeLabel}</div>
<div className="mt-1 text-sm text-slate-300">
{context?.employee?.employee_code || 'Employee code belum ada'}
</div>
</div>
<div className="rounded-2xl border border-white/15 bg-white/10 p-4 backdrop-blur">
<div className="text-xs uppercase tracking-[0.2em] text-slate-300">Outlet</div>
<div className="mt-2 text-lg font-semibold">{outletLabel}</div>
<div className="mt-1 text-sm text-slate-300">
{context?.outlet?.address || 'Hubungkan employee ke outlet untuk auto clock in'}
</div>
</div>
<div className="rounded-2xl border border-white/15 bg-white/10 p-4 backdrop-blur sm:col-span-2 lg:col-span-1">
<div className="text-xs uppercase tracking-[0.2em] text-slate-300">Divisi & shift</div>
<div className="mt-2 text-lg font-semibold">{jobPosition?.name || 'Belum ada job position'}</div>
<div className="mt-1 text-sm text-slate-300">{availableShiftLabel}</div>
</div>
</div>
</div>
</CardBox>
{pageError ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{pageError}
</div>
) : null}
{context?.setupError ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{context.setupError} Minta admin memastikan employee, outlet, dan job position sudah terhubung.
</div>
) : null}
{!context?.setupError && context?.clockInBlockedReason && !hasClockedIn ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{context.clockInBlockedReason}
</div>
) : null}
{result?.success ? (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-4 text-emerald-800">
<div className="flex items-start gap-3">
<div className="mt-0.5 inline-flex h-9 w-9 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
<BaseIcon path={successIcon} size={20} />
</div>
<div className="flex-1">
<div className="font-semibold">Clock in berhasil disimpan.</div>
<div className="mt-1 text-sm leading-6">
{result.action === 'updated' ? 'Record hari ini diperbarui.' : 'Record baru dibuat.'}{' '}
Jam masuk: {formatDateTime(result.attendanceLog.check_in_at)}.
{result.roster.assignedShift
? ` Shift terpilih otomatis: ${result.roster.assignedShift.label} (${result.roster.assignedShift.windowLabel}).`
: ''}
{typeof result.attendanceLog.late_minutes === 'number'
? ` Keterlambatan: ${result.attendanceLog.late_minutes} menit.`
: ''}
</div>
{canReadAttendanceLogs ? (
<BaseButtons type="justify-start" mb="mt-3 -mb-1" classAddon="mr-2 mb-1 last:mr-0">
<BaseButton
color="success"
icon={detailIcon}
label="Lihat detail log"
href={`/attendance_logs/${result.attendanceLog.id}`}
/>
<BaseButton
color="white"
icon={detailIcon}
label="Buka attendance logs"
href="/attendance_logs/attendance_logs-list"
/>
</BaseButtons>
) : null}
</div>
</div>
</div>
) : null}
<div className="grid gap-6 xl:grid-cols-[1.25fr,0.75fr]">
<div className="space-y-6">
<CardBox>
<div className="space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-sm font-medium text-slate-500">Status hari ini</div>
<h3 className="mt-1 text-xl font-semibold text-slate-900">
{dayjs().format('dddd, DD MMMM YYYY')}
</h3>
<p className="mt-2 text-sm text-slate-500">
Refresh halaman ini jika admin baru saja mengubah job position atau jadwal shift user.
</p>
</div>
<span
className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-sm font-medium ${getStatusBadgeClasses(
hasClockedIn ? todayLog?.status : roster?.activeShift?.status,
)}`}
>
<BaseIcon path={hasClockedIn ? successIcon : warningIcon} size={18} />
{hasClockedIn
? todayLog?.status === 'telat'
? 'Sudah clock in • telat'
: 'Sudah clock in'
: roster?.activeShift
? `Shift aktif • ${roster.activeShift.label}`
: 'Belum clock in'}
</span>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-slate-500">
<BaseIcon path={calendarIcon} size={18} />
Jam masuk tercatat
</div>
<div className="mt-2 text-2xl font-semibold text-slate-900">
{todayLog?.check_in_at ? formatTime(todayLog.check_in_at) : '—'}
</div>
<div className="mt-2 text-sm text-slate-500">
{todayLog?.check_in_at
? formatDateTime(todayLog.check_in_at)
: 'Belum ada record check in untuk hari ini.'}
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-slate-500">
<BaseIcon path={shiftIcon} size={18} />
Shift yang dipakai
</div>
<div className="mt-2 text-2xl font-semibold text-slate-900">
{roster?.assignedShift?.label || roster?.activeShift?.label || '—'}
</div>
<div className="mt-2 text-sm text-slate-500">
{roster?.assignedShift
? `Window shift: ${roster.assignedShift.windowLabel}`
: roster?.nextShift
? `Window berikutnya: ${roster.nextShift.windowLabel}`
: 'Belum ada shift yang cocok untuk waktu sekarang.'}
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-slate-500">
<BaseIcon path={warningIcon} size={18} />
Keterlambatan & denda
</div>
<div className="mt-2 text-2xl font-semibold text-slate-900">{lateMinutes} menit</div>
<div className="mt-2 text-sm text-slate-500">
Denda estimasi: {formatCurrency(latePenaltyAmount)}
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="flex items-center gap-2 text-slate-900">
<BaseIcon path={teamIcon} size={20} className="text-teal-600" />
<h4 className="text-lg font-semibold">Konfigurasi roster divisi</h4>
</div>
<div className="mt-4 space-y-3 text-sm text-slate-600">
<div className="flex items-start justify-between gap-4">
<span className="text-slate-500">Job position</span>
<span className="text-right font-semibold text-slate-950">
{jobPosition?.name || 'Belum diatur'}
</span>
</div>
<div className="flex items-start justify-between gap-4">
<span className="text-slate-500">Jam shift</span>
<span className="text-right font-semibold text-slate-950">
{availableShiftLabel}
</span>
</div>
<div className="flex items-start justify-between gap-4">
<span className="text-slate-500">Rule absensi</span>
<span className="text-right font-semibold text-slate-950">
{roster
? `${roster.windowBeforeMinutes} menit sebelum s/d ${roster.windowAfterMinutes} menit sesudah shift`
: '—'}
</span>
</div>
<div className="flex items-start justify-between gap-4">
<span className="text-slate-500">Denda telat</span>
<span className="text-right font-semibold text-slate-950">
{formatCurrency(roster?.latePenaltyPerMinute)} / menit
</span>
</div>
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="flex items-center gap-2 text-slate-900">
<BaseIcon path={officeIcon} size={20} className="text-teal-600" />
<h4 className="text-lg font-semibold">Shift aktif / shift berikutnya</h4>
</div>
<div className="mt-4 space-y-3 text-sm text-slate-600">
{roster?.activeShift ? (
<>
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
<div className="font-semibold text-emerald-700">
Shift aktif sekarang: {roster.activeShift.label}
</div>
<div className="mt-2 text-emerald-700">
Window: {roster.activeShift.windowLabel}
</div>
<div className="mt-1 text-emerald-700">
Status saat submit: {roster.activeShift.status} estimasi telat{' '}
{roster.activeShift.lateMinutes} menit
</div>
</div>
</>
) : roster?.nextShift ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-amber-800">
<div className="font-semibold">Belum masuk window shift.</div>
<div className="mt-2">
Shift terdekat: {roster.nextShift.label} window {roster.nextShift.windowLabel}
</div>
</div>
) : (
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-slate-600">
Tidak ada shift aktif saat ini.
</div>
)}
</div>
</div>
</div>
</div>
</CardBox>
<CardBox>
<div className="space-y-6">
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-slate-900">
<BaseIcon path={gpsIcon} size={20} className="text-teal-600" />
<h4 className="text-lg font-semibold">GPS browser</h4>
</div>
<p className="mt-2 text-sm text-slate-500">
Halaman ini akan mencoba mengambil GPS otomatis. Kalau browser HP memblokir,
Anda masih bisa isi koordinat manual untuk testing dev.
</p>
</div>
<BaseButton
color="info"
icon={refreshIcon}
label={isLocating ? 'Mengambil GPS…' : 'Ambil GPS lagi'}
onClick={() => {
void requestLocation();
}}
disabled={isLocating}
/>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="text-sm font-medium text-slate-500">Latitude</div>
<div className="mt-2 text-lg font-semibold text-slate-900">
{location?.latitude ?? '—'}
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="text-sm font-medium text-slate-500">Longitude</div>
<div className="mt-2 text-lg font-semibold text-slate-900">
{location?.longitude ?? '—'}
</div>
</div>
</div>
<div className="mt-3 text-sm text-slate-500">
{location
? `Diambil ${formatDateTime(location.capturedAt)}${
location.accuracy ? ` • akurasi ±${Math.round(location.accuracy)} m` : ''
}`
: 'Belum ada GPS yang berhasil diambil.'}
</div>
{locationError ? (
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{locationError}
</div>
) : null}
{locationError ? (
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-slate-700" htmlFor="manualLatitude">
Latitude manual
</label>
<input
id="manualLatitude"
type="number"
step="any"
className={inputClassName}
value={manualLatitude}
onChange={(event) => setManualLatitude(event.target.value)}
placeholder="-6.200000"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700" htmlFor="manualLongitude">
Longitude manual
</label>
<input
id="manualLongitude"
type="number"
step="any"
className={inputClassName}
value={manualLongitude}
onChange={(event) => setManualLongitude(event.target.value)}
placeholder="106.816666"
/>
</div>
</div>
) : null}
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="flex items-center gap-2 text-slate-900">
<BaseIcon path={selfieIcon} size={20} className="text-teal-600" />
<h4 className="text-lg font-semibold">Foto selfie</h4>
</div>
<p className="mt-2 text-sm text-slate-500">
Di HP, tombol ini akan membuka kamera depan kalau browser mendukung capture &quot;user&quot;.
</p>
<div className="mt-4 space-y-4">
<input
id="clockInSelfie"
type="file"
accept="image/*"
capture="user"
className={inputClassName}
onChange={handleFileChange}
/>
{selectedFile ? (
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="text-sm font-medium text-slate-700">File siap diunggah</div>
<div className="mt-1 text-sm text-slate-500">{selectedFile.name}</div>
{previewUrl ? (
<img
src={previewUrl}
alt="Preview selfie check in"
className="mt-4 h-56 w-full rounded-2xl object-cover"
/>
) : null}
</div>
) : (
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500">
Belum ada foto selfie yang dipilih.
</div>
)}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700" htmlFor="remarks">
Catatan opsional
</label>
<textarea
id="remarks"
rows={4}
className={inputClassName}
value={remarks}
onChange={(event) => setRemarks(event.target.value)}
placeholder="Contoh: buka outlet pagi, bantu stock opname, dsb."
/>
</div>
{submitError ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{submitError}
</div>
) : null}
<BaseButtons type="justify-start" mb="mb-0" classAddon="mr-3 mb-3 last:mr-0">
<BaseButton
color="info"
icon={pageIcon}
label={
isSubmitting
? 'Menyimpan clock in…'
: hasClockedIn
? 'Sudah clock in hari ini'
: 'Clock in sekarang'
}
onClick={() => {
void handleClockIn();
}}
disabled={
isSubmitting ||
isLoadingContext ||
hasClockedIn ||
Boolean(context?.setupError) ||
Boolean(context?.clockInBlockedReason && !context?.canClockIn)
}
/>
<BaseButton
color="white"
icon={refreshIcon}
label="Refresh context"
onClick={() => {
void loadClockInContext();
}}
disabled={isLoadingContext}
/>
</BaseButtons>
</div>
</CardBox>
</div>
<div className="space-y-6">
<CardBox>
<div className="space-y-5">
<div>
<div className="text-sm font-medium text-slate-500">Checklist sebelum submit</div>
<h3 className="mt-1 text-xl font-semibold text-slate-900">Validasi cepat</h3>
</div>
<div className="space-y-3">
{checklistItems.map((item) => (
<div
key={item.label}
className="flex items-center justify-between rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3"
>
<span className="text-sm text-slate-700">{item.label}</span>
<span
className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-semibold ${getChecklistBadgeClasses(
item.ok,
)}`}
>
<BaseIcon path={item.ok ? successIcon : warningIcon} size={16} />
{item.ok ? 'OK' : 'Perlu aksi'}
</span>
</div>
))}
</div>
</div>
</CardBox>
<CardBox>
<div className="space-y-4">
<div>
<div className="text-sm font-medium text-slate-500">Ringkasan absensi hari ini</div>
<h3 className="mt-1 text-xl font-semibold text-slate-900">Record attendance</h3>
</div>
{isLoadingContext ? (
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-500">
Memuat context clock in
</div>
) : todayLog ? (
<div className="space-y-3 rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
<div>
<span className="font-medium">ID:</span> {todayLog.id}
</div>
<div>
<span className="font-medium">Check in:</span> {formatDateTime(todayLog.check_in_at)}
</div>
<div>
<span className="font-medium">Status:</span> {todayLog.status || '—'}
</div>
<div>
<span className="font-medium">Late minutes:</span> {lateMinutes}
</div>
<div>
<span className="font-medium">Shift:</span> {roster?.assignedShift?.label || '—'}
</div>
<div>
<span className="font-medium">GPS:</span>{' '}
{todayLog.check_in_lat !== null && todayLog.check_in_lng !== null
? `${todayLog.check_in_lat}, ${todayLog.check_in_lng}`
: 'Belum ada'}
</div>
</div>
) : (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-5 text-sm text-slate-500">
Belum ada record absensi untuk hari ini.
</div>
)}
</div>
</CardBox>
<CardBox>
<div className="space-y-4 rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
<div className="font-medium text-slate-700">Catatan implementasi roster otomatis</div>
<ul className="list-disc space-y-2 pl-5">
<li>Shift diambil otomatis dari job position/divisi user login.</li>
<li>Format jadwal shift per divisi: contoh <span className="font-medium">07:00, 12:00, 15:00</span>.</li>
<li>Clock in hanya valid dalam window 60 menit sebelum sampai 60 menit sesudah shift.</li>
<li>Keterlambatan otomatis dihitung dari selisih waktu check in terhadap jam shift.</li>
<li>Denda telat mengikuti rule <span className="font-medium">Rp1.000 per menit</span>.</li>
<li>Validasi radius outlet dan flow clock-out otomatis bisa ditambah di step berikutnya.</li>
</ul>
</div>
</CardBox>
</div>
</div>
</div>
</SectionMain>
</>
);
};
ClockInPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission="CREATE_ATTENDANCE_LOGS">{page}</LayoutAuthenticated>;
};
export default ClockInPage;