345 lines
20 KiB
TypeScript
345 lines
20 KiB
TypeScript
import { Head, useForm, usePage } from '@inertiajs/react';
|
|
import React, { useState, useEffect } from 'react';
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
|
import EmployeeLayout from '@/layouts/employee-layout';
|
|
import { AttendanceMapModal } from '@/components/AttendanceMapModal';
|
|
import { MapPin, AlertCircle, RefreshCw, CheckCircle2, LogOut } from 'lucide-react';
|
|
|
|
interface Attendance {
|
|
id: number;
|
|
date: string;
|
|
check_in: string | null;
|
|
check_out: string | null;
|
|
status: 'present' | 'leave' | 'dispensation';
|
|
notes: string | null;
|
|
latitude_in?: string | null;
|
|
longitude_in?: string | null;
|
|
latitude_out?: string | null;
|
|
longitude_out?: string | null;
|
|
}
|
|
|
|
interface PageProps {
|
|
attendances: Attendance[];
|
|
todayAttendance: Attendance | null;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export default function AttendanceIndex({ attendances, todayAttendance }: PageProps) {
|
|
const { flash } = usePage<any>().props;
|
|
const [locationError, setLocationError] = useState<string | null>(null);
|
|
const [coordinates, setCoordinates] = useState<{ lat: number; lng: number } | null>(null);
|
|
const [isLoadingLocation, setIsLoadingLocation] = useState<boolean>(true);
|
|
|
|
const { data: regulerData, setData: setRegulerData, post: postReguler, processing: processingReguler } = useForm({
|
|
latitude_in: '',
|
|
longitude_in: '',
|
|
latitude_out: '',
|
|
longitude_out: '',
|
|
});
|
|
|
|
const { data: leaveData, setData: setLeaveData, post: postLeave, processing: processingLeave, reset: resetLeave, errors: leaveErrors } = useForm({
|
|
date: '',
|
|
notes: '',
|
|
});
|
|
|
|
const { data: dispenData, setData: setDispenData, post: postDispen, processing: processingDispen, reset: resetDispen, errors: dispenErrors } = useForm({
|
|
latitude_in: '',
|
|
longitude_in: '',
|
|
notes: '',
|
|
});
|
|
|
|
const getLocation = () => {
|
|
setIsLoadingLocation(true);
|
|
setLocationError(null);
|
|
|
|
if (!navigator.geolocation) {
|
|
setLocationError('Geolocation tidak didukung oleh browser ini.');
|
|
setIsLoadingLocation(false);
|
|
return;
|
|
}
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
(position) => {
|
|
const lat = position.coords.latitude;
|
|
const lng = position.coords.longitude;
|
|
setCoordinates({ lat, lng });
|
|
setRegulerData({
|
|
...regulerData,
|
|
latitude_in: lat.toString(),
|
|
longitude_in: lng.toString(),
|
|
latitude_out: lat.toString(),
|
|
longitude_out: lng.toString(),
|
|
});
|
|
setDispenData({
|
|
...dispenData,
|
|
latitude_in: lat.toString(),
|
|
longitude_in: lng.toString(),
|
|
});
|
|
setIsLoadingLocation(false);
|
|
},
|
|
(error) => {
|
|
let errorMsg = 'Gagal mengambil lokasi.';
|
|
if (error.code === 1) errorMsg = 'Izin lokasi ditolak. Harap izinkan akses lokasi di pengaturan browser/perangkat Anda.';
|
|
setLocationError(errorMsg);
|
|
setIsLoadingLocation(false);
|
|
},
|
|
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
|
|
);
|
|
};
|
|
|
|
useEffect(() => {
|
|
getLocation();
|
|
}, []);
|
|
|
|
const handleClockIn = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
postReguler('/employee/attendances/clock-in');
|
|
};
|
|
|
|
const handleClockOut = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
postReguler('/employee/attendances/clock-out');
|
|
};
|
|
|
|
const handleLeaveSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
postLeave('/employee/attendances/leave', {
|
|
onSuccess: () => resetLeave()
|
|
});
|
|
};
|
|
|
|
const handleDispenSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
postDispen('/employee/attendances/dispensation', {
|
|
onSuccess: () => resetDispen()
|
|
});
|
|
};
|
|
|
|
const formatStatus = (status: string) => {
|
|
switch (status) {
|
|
case 'present': return 'Hadir';
|
|
case 'leave': return 'Izin/Cuti';
|
|
case 'dispensation': return 'Dispensasi';
|
|
default: return status;
|
|
}
|
|
};
|
|
|
|
const isClockInDisabled = processingReguler || isLoadingLocation || !coordinates || Boolean(todayAttendance?.check_in);
|
|
const isClockOutDisabled = processingReguler || isLoadingLocation || !coordinates || !todayAttendance || Boolean(todayAttendance?.check_out);
|
|
|
|
return (
|
|
<EmployeeLayout title="Absensi Karyawan">
|
|
<div className="bg-gradient-to-r from-sky-400 to-blue-500 text-white md:rounded-b-3xl shadow-sm relative mb-8 md:mb-12">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-10 pb-20 md:pb-24">
|
|
<div className="flex flex-col mb-2">
|
|
<h1 className="text-3xl md:text-4xl font-bold tracking-tight">Portal Absensi</h1>
|
|
<p className="text-sky-100 text-sm md:text-base opacity-90 mt-2">Lakukan absen masuk, pulang, atau ajukan izin.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-20 md:-mt-24 relative z-10 pb-12 space-y-6 md:space-y-8">
|
|
|
|
<div className={`p-4 md:p-5 rounded-xl md:rounded-2xl text-sm flex items-start md:items-center gap-3 shadow-sm ${
|
|
isLoadingLocation ? 'bg-sky-100 text-sky-800' :
|
|
locationError ? 'bg-red-100 text-red-800' :
|
|
'bg-green-100 text-green-800'
|
|
}`}>
|
|
{isLoadingLocation ? (
|
|
<RefreshCw className="w-5 h-5 md:w-6 md:h-6 animate-spin shrink-0 mt-0.5 md:mt-0" />
|
|
) : locationError ? (
|
|
<AlertCircle className="w-5 h-5 md:w-6 md:h-6 shrink-0 mt-0.5 md:mt-0" />
|
|
) : (
|
|
<MapPin className="w-5 h-5 md:w-6 md:h-6 shrink-0 mt-0.5 md:mt-0" />
|
|
)}
|
|
<div className="flex-1 md:flex md:items-center md:justify-between">
|
|
<div>
|
|
<p className="font-semibold mb-0.5 md:text-base">
|
|
{isLoadingLocation ? 'Mencari lokasi Anda...' :
|
|
locationError ? 'Gagal Akses Lokasi' :
|
|
'Lokasi Ditemukan'}
|
|
</p>
|
|
<p className="text-xs md:text-sm opacity-90">
|
|
{isLoadingLocation ? 'Harap tunggu, pastikan GPS aktif.' :
|
|
locationError ? locationError :
|
|
`Koordinat: ${coordinates?.lat.toFixed(6)}, ${coordinates?.lng.toFixed(6)}`}
|
|
</p>
|
|
</div>
|
|
{locationError && (
|
|
<Button onClick={getLocation} variant="outline" size="sm" className="mt-2 md:mt-0 h-8 md:h-9 bg-white">
|
|
Coba Lagi
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 md:gap-8">
|
|
<div className="lg:col-span-1">
|
|
<Card className="border-slate-100 shadow-md h-full">
|
|
<CardContent className="p-4 sm:p-6">
|
|
<Tabs defaultValue="reguler" className="w-full">
|
|
<TabsList className="w-full grid grid-cols-3 mb-6 bg-slate-100/50">
|
|
<TabsTrigger value="reguler">Harian</TabsTrigger>
|
|
<TabsTrigger value="izin">Izin</TabsTrigger>
|
|
<TabsTrigger value="dispen">Dispen</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="reguler" className="space-y-4 focus-visible:outline-none focus-visible:ring-0">
|
|
<div className="text-center mb-6">
|
|
<h3 className="text-sm md:text-base font-semibold text-slate-800">Absen Harian</h3>
|
|
<p className="text-xs md:text-sm text-slate-500 mt-1">
|
|
{todayAttendance
|
|
? 'Anda sudah memiliki catatan absensi hari ini.'
|
|
: 'Sistem membutuhkan akses lokasi.'}
|
|
</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Button
|
|
onClick={handleClockIn}
|
|
disabled={isClockInDisabled}
|
|
className="w-full bg-sky-600 hover:bg-sky-700 h-14 md:h-16 flex flex-col gap-1 items-center justify-center rounded-xl transition-all"
|
|
>
|
|
<CheckCircle2 className="w-5 h-5 md:w-6 md:h-6" />
|
|
<span className="text-xs md:text-sm font-semibold">Clock In</span>
|
|
</Button>
|
|
<Button
|
|
onClick={handleClockOut}
|
|
disabled={isClockOutDisabled}
|
|
variant="outline"
|
|
className="w-full h-14 md:h-16 flex flex-col gap-1 items-center justify-center rounded-xl border-slate-200 transition-all"
|
|
>
|
|
<LogOut className="w-5 h-5 md:w-6 md:h-6 text-slate-500" />
|
|
<span className="text-xs md:text-sm font-semibold text-slate-700">Clock Out</span>
|
|
</Button>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="izin" className="space-y-4 focus-visible:outline-none focus-visible:ring-0">
|
|
<form onSubmit={handleLeaveSubmit} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="date" className="text-xs md:text-sm">Tanggal Izin</Label>
|
|
<Input
|
|
id="date"
|
|
type="date"
|
|
required
|
|
value={leaveData.date}
|
|
onChange={e => setLeaveData('date', e.target.value)}
|
|
className="h-11"
|
|
/>
|
|
{leaveErrors.date && <p className="text-xs text-red-500">{leaveErrors.date}</p>}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="leave_notes" className="text-xs md:text-sm">Keterangan (Sakit/Cuti)</Label>
|
|
<Textarea
|
|
id="leave_notes"
|
|
placeholder="Tulis alasan izin..."
|
|
required
|
|
value={leaveData.notes}
|
|
onChange={e => setLeaveData('notes', e.target.value)}
|
|
className="resize-none"
|
|
rows={4}
|
|
/>
|
|
{leaveErrors.notes && <p className="text-xs text-red-500">{leaveErrors.notes}</p>}
|
|
</div>
|
|
<Button type="submit" disabled={processingLeave} className="w-full h-11 rounded-xl bg-sky-600 hover:bg-sky-700">
|
|
{processingLeave ? 'Menyimpan...' : 'Submit Izin'}
|
|
</Button>
|
|
</form>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="dispen" className="space-y-4 focus-visible:outline-none focus-visible:ring-0">
|
|
<form onSubmit={handleDispenSubmit} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="dispen_notes" className="text-xs md:text-sm">Tujuan / Keterangan</Label>
|
|
<Textarea
|
|
id="dispen_notes"
|
|
placeholder="Contoh: Meeting dengan klien X di lokasi Y"
|
|
required
|
|
value={dispenData.notes}
|
|
onChange={e => setDispenData('notes', e.target.value)}
|
|
className="resize-none"
|
|
rows={4}
|
|
/>
|
|
{dispenErrors.notes && <p className="text-xs text-red-500">{dispenErrors.notes}</p>}
|
|
</div>
|
|
<Button type="submit" disabled={processingDispen || !coordinates} className="w-full h-11 rounded-xl bg-sky-600 hover:bg-sky-700">
|
|
{processingDispen ? 'Menyimpan...' : 'Submit Dispensasi'}
|
|
</Button>
|
|
</form>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="lg:col-span-2">
|
|
<Card className="border-slate-100 shadow-md h-full overflow-hidden">
|
|
<CardHeader className="pb-4 border-b border-slate-100 bg-white">
|
|
<CardTitle className="text-lg">Riwayat Absensi</CardTitle>
|
|
<CardDescription>Data kehadiran Anda terbaru</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
{attendances.length > 0 ? (
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50">
|
|
<TableHead className="whitespace-nowrap">Tanggal</TableHead>
|
|
<TableHead className="whitespace-nowrap">Clock In</TableHead>
|
|
<TableHead className="whitespace-nowrap">Clock Out</TableHead>
|
|
<TableHead className="whitespace-nowrap">Status</TableHead>
|
|
<TableHead className="whitespace-nowrap min-w-[150px]">Keterangan</TableHead>
|
|
<TableHead className="whitespace-nowrap text-right">Aksi</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{attendances.map((att) => (
|
|
<TableRow key={att.id}>
|
|
<TableCell className="font-medium whitespace-nowrap">{att.date}</TableCell>
|
|
<TableCell className="font-mono text-slate-600">{att.check_in || '--:--:--'}</TableCell>
|
|
<TableCell className="font-mono text-slate-600">{att.check_out || '--:--:--'}</TableCell>
|
|
<TableCell>
|
|
<span className="inline-block px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider bg-slate-100 text-slate-600 rounded-md">
|
|
{formatStatus(att.status)}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-slate-500 text-xs">
|
|
{att.notes || '-'}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<AttendanceMapModal
|
|
latitudeIn={att.latitude_in}
|
|
longitudeIn={att.longitude_in}
|
|
latitudeOut={att.latitude_out}
|
|
longitudeOut={att.longitude_out}
|
|
employeeName="Anda"
|
|
date={att.date}
|
|
/>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
) : (
|
|
<div className="p-10 text-center">
|
|
<p className="text-sm text-slate-500">Belum ada riwayat absensi.</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
</EmployeeLayout>
|
|
);
|
|
}
|