1006 lines
40 KiB
TypeScript
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 "user".
|
|
</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;
|