379 lines
18 KiB
TypeScript
379 lines
18 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import AppLayout from '@/layouts/app-layout';
|
|
|
|
// Define expected prop types
|
|
interface Attendance {
|
|
id: number;
|
|
date: string;
|
|
check_in: string | null;
|
|
check_out: string | null;
|
|
status: 'present' | 'leave' | 'dispensation';
|
|
notes: string | null;
|
|
}
|
|
|
|
interface PageProps {
|
|
attendances: Attendance[];
|
|
todayAttendance: Attendance | null;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface SharedData {
|
|
flash: {
|
|
success?: string;
|
|
error?: string;
|
|
};
|
|
}
|
|
|
|
export default function Index({ attendances, todayAttendance }: PageProps) {
|
|
const { flash } = usePage<any>().props as SharedData;
|
|
const [locationError, setLocationError] = useState<string | null>(null);
|
|
const [coordinates, setCoordinates] = useState<{ lat: number; lng: number } | null>(null);
|
|
const [isLoadingLocation, setIsLoadingLocation] = useState<boolean>(false);
|
|
|
|
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
|
|
} = useForm({
|
|
date: '',
|
|
notes: '',
|
|
});
|
|
|
|
const {
|
|
data: dispenData,
|
|
setData: setDispenData,
|
|
post: postDispen,
|
|
processing: processingDispen,
|
|
reset: resetDispen
|
|
} = useForm({
|
|
latitude_in: '',
|
|
longitude_in: '',
|
|
notes: '',
|
|
});
|
|
|
|
// Mendapatkan lokasi saat komponen dimuat atau tombol ditekan
|
|
const getLocation = (callback?: (lat: number, lng: number) => void) => {
|
|
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);
|
|
if (callback) callback(lat, lng);
|
|
},
|
|
(error) => {
|
|
let errorMsg = 'Gagal mengambil lokasi.';
|
|
if (error.code === 1) errorMsg = 'Izin lokasi ditolak. Harap izinkan akses lokasi.';
|
|
setLocationError(errorMsg);
|
|
setIsLoadingLocation(false);
|
|
},
|
|
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
|
|
);
|
|
};
|
|
|
|
useEffect(() => {
|
|
getLocation();
|
|
}, []);
|
|
|
|
const handleClockIn = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!coordinates) {
|
|
getLocation((lat, lng) => {
|
|
postReguler('/employee/attendances/clock-in');
|
|
});
|
|
} else {
|
|
postReguler('/employee/attendances/clock-in');
|
|
}
|
|
};
|
|
|
|
const handleClockOut = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!coordinates) {
|
|
getLocation((lat, lng) => {
|
|
postReguler('/employee/attendances/clock-out');
|
|
});
|
|
} else {
|
|
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();
|
|
if (!coordinates) {
|
|
getLocation((lat, lng) => {
|
|
postDispen('/employee/attendances/dispensation', {
|
|
onSuccess: () => resetDispen()
|
|
});
|
|
});
|
|
} else {
|
|
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;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<AppLayout>
|
|
<Head title="Absensi Karyawan" />
|
|
|
|
<div className="p-4 md:p-8 max-w-5xl mx-auto space-y-6">
|
|
|
|
{/* Header Section */}
|
|
<div className="space-y-1">
|
|
<h2 className="text-2xl font-bold tracking-tight">Portal Absensi</h2>
|
|
<p className="text-muted-foreground">Lakukan absen masuk/pulang, atau ajukan izin dan dispensasi.</p>
|
|
</div>
|
|
|
|
{/* Flash Messages */}
|
|
{flash?.success && (
|
|
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
|
|
{flash.success}
|
|
</div>
|
|
)}
|
|
{flash?.error && (
|
|
<div className="p-4 bg-red-50 text-red-700 border border-red-200 rounded-md text-sm">
|
|
{flash.error}
|
|
</div>
|
|
)}
|
|
{locationError && (
|
|
<div className="p-4 bg-yellow-50 text-yellow-700 border border-yellow-200 rounded-md text-sm">
|
|
{locationError} <Button variant="link" className="p-0 h-auto font-bold text-yellow-800" onClick={() => getLocation()}>Coba Lagi</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
{/* Left Column: Forms */}
|
|
<div className="md:col-span-1 space-y-6">
|
|
<Tabs defaultValue="reguler" className="w-full">
|
|
<TabsList className="w-full grid grid-cols-3">
|
|
<TabsTrigger value="reguler">Harian</TabsTrigger>
|
|
<TabsTrigger value="izin">Izin</TabsTrigger>
|
|
<TabsTrigger value="dispen">Dispen</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Tab Absen Reguler */}
|
|
<TabsContent value="reguler">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Absen Reguler</CardTitle>
|
|
<CardDescription>
|
|
{todayAttendance
|
|
? 'Anda sudah memiliki catatan absensi hari ini.'
|
|
: 'Sistem membutuhkan akses lokasi untuk mencatat absensi.'}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="p-4 bg-zinc-50 border rounded-lg text-center space-y-2">
|
|
<p className="text-sm text-muted-foreground">Lokasi Saat Ini:</p>
|
|
<p className="font-mono text-xs font-semibold">
|
|
{isLoadingLocation ? 'Mengambil lokasi...' : (coordinates ? `${coordinates.lat.toFixed(6)}, ${coordinates.lng.toFixed(6)}` : 'Lokasi tidak tersedia')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Button
|
|
onClick={handleClockIn}
|
|
disabled={processingReguler || (todayAttendance && todayAttendance.check_in !== null)}
|
|
className="w-full bg-blue-600 hover:bg-blue-700"
|
|
>
|
|
Clock In
|
|
</Button>
|
|
<Button
|
|
onClick={handleClockOut}
|
|
disabled={processingReguler || !todayAttendance || (todayAttendance && todayAttendance.check_out !== null)}
|
|
variant="outline"
|
|
className="w-full"
|
|
>
|
|
Clock Out
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* Tab Izin */}
|
|
<TabsContent value="izin">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Form Izin / Cuti</CardTitle>
|
|
<CardDescription>Ajukan izin tidak masuk kerja.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleLeaveSubmit} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="date">Tanggal Izin</Label>
|
|
<Input
|
|
id="date"
|
|
type="date"
|
|
required
|
|
value={leaveData.date}
|
|
onChange={e => setLeaveData('date', e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="leave_notes">Keterangan (Sakit/Cuti/Dll)</Label>
|
|
<Textarea
|
|
id="leave_notes"
|
|
placeholder="Tulis alasan izin..."
|
|
required
|
|
value={leaveData.notes}
|
|
onChange={e => setLeaveData('notes', e.target.value)}
|
|
/>
|
|
</div>
|
|
<Button type="submit" disabled={processingLeave} className="w-full">
|
|
Submit Izin
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* Tab Dispensasi */}
|
|
<TabsContent value="dispen">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Form Dispensasi</CardTitle>
|
|
<CardDescription>Dispensasi tugas luar. Memerlukan lokasi.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleDispenSubmit} className="space-y-4">
|
|
<div className="p-3 bg-zinc-50 border rounded-lg text-center mb-4">
|
|
<p className="text-xs text-muted-foreground">Lokasi Tercatat:</p>
|
|
<p className="font-mono text-xs font-semibold">
|
|
{isLoadingLocation ? 'Mengambil lokasi...' : (coordinates ? `${coordinates.lat.toFixed(6)}, ${coordinates.lng.toFixed(6)}` : 'Lokasi tidak tersedia')}
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="dispen_notes">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)}
|
|
/>
|
|
</div>
|
|
<Button type="submit" disabled={processingDispen} className="w-full">
|
|
Submit Dispensasi
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
|
|
{/* Right Column: History */}
|
|
<div className="md:col-span-2">
|
|
<Card className="h-full">
|
|
<CardHeader>
|
|
<CardTitle>Riwayat Absensi</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="relative w-full overflow-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Tanggal</TableHead>
|
|
<TableHead>Jam Masuk</TableHead>
|
|
<TableHead>Jam Keluar</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Keterangan</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{attendances.length > 0 ? (
|
|
attendances.map((att) => (
|
|
<TableRow key={att.id}>
|
|
<TableCell>{att.date}</TableCell>
|
|
<TableCell>{att.check_in || '-'}</TableCell>
|
|
<TableCell>{att.check_out || '-'}</TableCell>
|
|
<TableCell>
|
|
<span className="inline-block px-2 py-1 text-xs font-medium bg-gray-100 rounded">
|
|
{formatStatus(att.status)}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate">
|
|
{att.notes || '-'}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
|
|
Belum ada riwayat absensi.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</AppLayout>
|
|
);
|
|
}
|