feat: enhance attendance management with employee-specific data and location tracking
This commit is contained in:
parent
9bbb00c69f
commit
bc02af2a66
@ -22,19 +22,38 @@ public function index(Request $request): Response
|
|||||||
{
|
{
|
||||||
$year = $request->integer('year', now()->year);
|
$year = $request->integer('year', now()->year);
|
||||||
$month = $request->integer('month', now()->month);
|
$month = $request->integer('month', now()->month);
|
||||||
|
|
||||||
$hrSettings = app(HRSettings::class);
|
$hrSettings = app(HRSettings::class);
|
||||||
|
$user = auth()->user();
|
||||||
|
$isAdmin = $user->hasAnyRole(['developer', 'owner', 'direktur']);
|
||||||
|
|
||||||
|
if ($isAdmin) {
|
||||||
|
return Inertia::render('admin/hr/attendance/index', [
|
||||||
|
'attendances' => $this->service->getByMonth($year, $month),
|
||||||
|
'todayAttendance' => null,
|
||||||
|
'currentYear' => $year,
|
||||||
|
'currentMonth' => $month,
|
||||||
|
'monthStats' => $this->service->getMonthStats($year, $month),
|
||||||
|
'hrSettings' => [
|
||||||
|
'scheduled_check_in_time' => $hrSettings->scheduled_check_in_time,
|
||||||
|
'scheduled_check_out_time' => $hrSettings->scheduled_check_out_time,
|
||||||
|
],
|
||||||
|
'isAdmin' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$employeeId = $user->employee?->id;
|
||||||
|
|
||||||
return Inertia::render('admin/hr/attendance/index', [
|
return Inertia::render('admin/hr/attendance/index', [
|
||||||
'attendances' => $this->service->getByMonth($year, $month),
|
'attendances' => $this->service->getByMonth($year, $month, $employeeId),
|
||||||
'todayAttendance' => $this->service->getToday(),
|
'todayAttendance' => $this->service->getToday(),
|
||||||
'currentYear' => $year,
|
'currentYear' => $year,
|
||||||
'currentMonth' => $month,
|
'currentMonth' => $month,
|
||||||
'monthStats' => $this->service->getMonthStats($year, $month),
|
'monthStats' => $this->service->getMonthStats($year, $month, $employeeId),
|
||||||
'hrSettings' => [
|
'hrSettings' => [
|
||||||
'scheduled_check_in_time' => $hrSettings->scheduled_check_in_time,
|
'scheduled_check_in_time' => $hrSettings->scheduled_check_in_time,
|
||||||
'scheduled_check_out_time' => $hrSettings->scheduled_check_out_time,
|
'scheduled_check_out_time' => $hrSettings->scheduled_check_out_time,
|
||||||
],
|
],
|
||||||
|
'isAdmin' => false,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Services\Admin\HR;
|
namespace App\Services\Admin\HR;
|
||||||
|
|
||||||
use App\Models\Attendance;
|
use App\Models\Attendance;
|
||||||
|
use App\Models\Employee;
|
||||||
use App\Models\LeaveRequest;
|
use App\Models\LeaveRequest;
|
||||||
use App\Services\Concerns\RegistersMedia;
|
use App\Services\Concerns\RegistersMedia;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
@ -27,11 +28,12 @@ public function getAll(): Collection
|
|||||||
->map(fn (Attendance $attendance) => $this->formatAttendance($attendance));
|
->map(fn (Attendance $attendance) => $this->formatAttendance($attendance));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getByMonth(int $year, int $month): Collection
|
public function getByMonth(int $year, int $month, ?int $employeeId = null): Collection
|
||||||
{
|
{
|
||||||
return Attendance::with(['employee.user.userProfile', 'media'])
|
return Attendance::with(['employee.user.userProfile', 'media'])
|
||||||
->whereYear('attendance_date', $year)
|
->whereYear('attendance_date', $year)
|
||||||
->whereMonth('attendance_date', $month)
|
->whereMonth('attendance_date', $month)
|
||||||
|
->when($employeeId, fn ($q) => $q->where('employee_id', $employeeId))
|
||||||
->get()
|
->get()
|
||||||
->map(fn (Attendance $attendance) => $this->formatAttendance($attendance));
|
->map(fn (Attendance $attendance) => $this->formatAttendance($attendance));
|
||||||
}
|
}
|
||||||
@ -51,7 +53,14 @@ public function getToday(): ?array
|
|||||||
return $this->getByDate(now()->toDateString());
|
return $this->getByDate(now()->toDateString());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getMonthStats(int $year, int $month): array
|
public function getAllEmployees(): Collection
|
||||||
|
{
|
||||||
|
return Employee::with(['user.userProfile', 'user.roles'])
|
||||||
|
->whereHas('user', fn ($q) => $q->where('is_active', true))
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMonthStats(int $year, int $month, ?int $employeeId = null): array
|
||||||
{
|
{
|
||||||
$startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth();
|
$startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth();
|
||||||
$endOfMonth = $startOfMonth->copy()->endOfMonth();
|
$endOfMonth = $startOfMonth->copy()->endOfMonth();
|
||||||
@ -65,14 +74,20 @@ public function getMonthStats(int $year, int $month): array
|
|||||||
$current->addDay();
|
$current->addDay();
|
||||||
}
|
}
|
||||||
|
|
||||||
$attendanceCount = Attendance::whereYear('attendance_date', $year)
|
$attendanceQuery = Attendance::whereYear('attendance_date', $year)
|
||||||
->whereMonth('attendance_date', $month)
|
->whereMonth('attendance_date', $month);
|
||||||
->count();
|
if ($employeeId) {
|
||||||
|
$attendanceQuery->where('employee_id', $employeeId);
|
||||||
|
}
|
||||||
|
$attendanceCount = $attendanceQuery->count();
|
||||||
|
|
||||||
$leaveDays = LeaveRequest::approved()
|
$leaveQuery = LeaveRequest::approved()
|
||||||
->where('start_date', '<=', $endOfMonth)
|
->where('start_date', '<=', $endOfMonth)
|
||||||
->where('end_date', '>=', $startOfMonth)
|
->where('end_date', '>=', $startOfMonth);
|
||||||
->get()
|
if ($employeeId) {
|
||||||
|
$leaveQuery->where('employee_id', $employeeId);
|
||||||
|
}
|
||||||
|
$leaveDays = $leaveQuery->get()
|
||||||
->reduce(function ($carry, $leave) use ($startOfMonth, $endOfMonth) {
|
->reduce(function ($carry, $leave) use ($startOfMonth, $endOfMonth) {
|
||||||
$leaveStart = max($leave->start_date->timestamp, $startOfMonth->timestamp);
|
$leaveStart = max($leave->start_date->timestamp, $startOfMonth->timestamp);
|
||||||
$leaveEnd = min($leave->end_date->timestamp, $endOfMonth->timestamp);
|
$leaveEnd = min($leave->end_date->timestamp, $endOfMonth->timestamp);
|
||||||
@ -154,6 +169,7 @@ public function checkOut(Attendance $attendance, array $data): Attendance
|
|||||||
private function formatAttendance(Attendance $attendance): array
|
private function formatAttendance(Attendance $attendance): array
|
||||||
{
|
{
|
||||||
$toArray = $attendance->toArray();
|
$toArray = $attendance->toArray();
|
||||||
|
$toArray['employee_name'] = $attendance->employee?->user?->userProfile?->full_name ?? '-';
|
||||||
|
|
||||||
if (is_null($toArray['work_duration_minutes']) && $attendance->check_in_at) {
|
if (is_null($toArray['work_duration_minutes']) && $attendance->check_in_at) {
|
||||||
$checkIn = Carbon::parse($attendance->check_in_at);
|
$checkIn = Carbon::parse($attendance->check_in_at);
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
import L from 'leaflet';
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import 'leaflet/dist/leaflet.css';
|
|
||||||
|
|
||||||
interface LocationMapProps {
|
interface LocationMapProps {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
@ -13,52 +9,18 @@ export function LocationMap({
|
|||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
height = '250px',
|
height = '250px',
|
||||||
zoom = 15,
|
zoom = 17,
|
||||||
}: LocationMapProps) {
|
}: LocationMapProps) {
|
||||||
const mapRef = useRef<HTMLDivElement>(null);
|
const src = `https://maps.google.com/maps?q=${latitude},${longitude}&z=${zoom}&t=k&output=embed`;
|
||||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!mapRef.current || mapInstanceRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const map = L.map(mapRef.current, {
|
|
||||||
center: [latitude, longitude],
|
|
||||||
zoom,
|
|
||||||
zoomControl: false,
|
|
||||||
attributionControl: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
L.control.zoom({ position: 'topright' }).addTo(map);
|
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
||||||
attribution:
|
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
const icon = L.divIcon({
|
|
||||||
html: `<div style="background: #ef4444; width: 24px; height: 24px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 6px rgba(0,0,0,0.3);"></div>`,
|
|
||||||
className: '',
|
|
||||||
iconSize: [24, 24],
|
|
||||||
iconAnchor: [12, 12],
|
|
||||||
});
|
|
||||||
|
|
||||||
L.marker([latitude, longitude], { icon }).addTo(map);
|
|
||||||
|
|
||||||
mapInstanceRef.current = map;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
map.remove();
|
|
||||||
mapInstanceRef.current = null;
|
|
||||||
};
|
|
||||||
}, [latitude, longitude, zoom]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<iframe
|
||||||
ref={mapRef}
|
src={src}
|
||||||
style={{ height, width: '100%' }}
|
style={{ height, width: '100%', border: 0 }}
|
||||||
className="rounded-lg"
|
className="rounded-lg"
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
|
title="Lokasi Presensi"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { toast } from 'sonner';
|
|||||||
import { CameraCapture } from '@/components/camera-capture';
|
import { CameraCapture } from '@/components/camera-capture';
|
||||||
import { LocationMap } from '@/components/location-map';
|
import { LocationMap } from '@/components/location-map';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
@ -44,6 +45,7 @@ type Attendance = {
|
|||||||
check_out_latitude: number | null;
|
check_out_latitude: number | null;
|
||||||
check_out_longitude: number | null;
|
check_out_longitude: number | null;
|
||||||
work_duration_minutes: number | null;
|
work_duration_minutes: number | null;
|
||||||
|
employee_name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MonthStats = {
|
type MonthStats = {
|
||||||
@ -58,11 +60,12 @@ type Props = {
|
|||||||
todayAttendance: Attendance | null;
|
todayAttendance: Attendance | null;
|
||||||
currentYear: number;
|
currentYear: number;
|
||||||
currentMonth: number;
|
currentMonth: number;
|
||||||
monthStats: MonthStats;
|
monthStats: MonthStats | null;
|
||||||
hrSettings: {
|
hrSettings: {
|
||||||
scheduled_check_in_time: string;
|
scheduled_check_in_time: string;
|
||||||
scheduled_check_out_time: string;
|
scheduled_check_out_time: string;
|
||||||
};
|
};
|
||||||
|
isAdmin: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const WEEKDAYS = ['Min', 'Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab'];
|
const WEEKDAYS = ['Min', 'Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab'];
|
||||||
@ -83,16 +86,6 @@ function isSameDay(d1: Date, d2: Date): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkIsToday(date: Date): boolean {
|
|
||||||
const t = new Date();
|
|
||||||
|
|
||||||
return (
|
|
||||||
date.getDate() === t.getDate() &&
|
|
||||||
date.getMonth() === t.getMonth() &&
|
|
||||||
date.getFullYear() === t.getFullYear()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWeekend(date: Date): boolean {
|
function isWeekend(date: Date): boolean {
|
||||||
const day = date.getDay();
|
const day = date.getDay();
|
||||||
|
|
||||||
@ -105,8 +98,8 @@ function isLate(
|
|||||||
officeMinute: number,
|
officeMinute: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!checkInAt) {
|
if (!checkInAt) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const d = new Date(checkInAt);
|
const d = new Date(checkInAt);
|
||||||
const h = d.getHours();
|
const h = d.getHours();
|
||||||
@ -121,8 +114,8 @@ function getLateMinutes(
|
|||||||
officeMinute: number,
|
officeMinute: number,
|
||||||
): number {
|
): number {
|
||||||
if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) {
|
if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const d = new Date(checkInAt);
|
const d = new Date(checkInAt);
|
||||||
const officeStart = new Date(d);
|
const officeStart = new Date(d);
|
||||||
@ -133,15 +126,15 @@ return 0;
|
|||||||
|
|
||||||
function formatMinutes(minutes: number | null): string {
|
function formatMinutes(minutes: number | null): string {
|
||||||
if (!minutes) {
|
if (!minutes) {
|
||||||
return '-';
|
return '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
const mins = Math.floor(minutes % 60);
|
const mins = Math.floor(minutes % 60);
|
||||||
|
|
||||||
if (mins === 0) {
|
if (mins === 0) {
|
||||||
return `${hours} jam`;
|
return `${hours} jam`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${hours} jam ${mins} menit`;
|
return `${hours} jam ${mins} menit`;
|
||||||
}
|
}
|
||||||
@ -153,6 +146,7 @@ export default function AttendanceIndex({
|
|||||||
currentMonth,
|
currentMonth,
|
||||||
monthStats,
|
monthStats,
|
||||||
hrSettings,
|
hrSettings,
|
||||||
|
isAdmin,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [officeHour, officeMinute] = hrSettings.scheduled_check_in_time
|
const [officeHour, officeMinute] = hrSettings.scheduled_check_in_time
|
||||||
.split(':')
|
.split(':')
|
||||||
@ -173,21 +167,17 @@ export default function AttendanceIndex({
|
|||||||
const viewYear = viewDate.getFullYear();
|
const viewYear = viewDate.getFullYear();
|
||||||
const viewMonth = viewDate.getMonth() + 1;
|
const viewMonth = viewDate.getMonth() + 1;
|
||||||
|
|
||||||
const attendanceDates = useMemo(() => {
|
const attendanceByDate = useMemo(() => {
|
||||||
const dates = new Map<string, Attendance>();
|
const map = new Map<string, Attendance[]>();
|
||||||
attendances.forEach((att) => {
|
attendances.forEach((att) => {
|
||||||
dates.set(att.attendance_date, att);
|
const existing = map.get(att.attendance_date) ?? [];
|
||||||
|
existing.push(att);
|
||||||
|
map.set(att.attendance_date, existing);
|
||||||
});
|
});
|
||||||
|
|
||||||
return dates;
|
return map;
|
||||||
}, [attendances]);
|
}, [attendances]);
|
||||||
|
|
||||||
const selectedAttendance = useMemo(() => {
|
|
||||||
const dateStr = format(selectedDate, 'yyyy-MM-dd');
|
|
||||||
|
|
||||||
return attendanceDates.get(dateStr) ?? null;
|
|
||||||
}, [selectedDate, attendanceDates]);
|
|
||||||
|
|
||||||
const calendarDays = useMemo(() => {
|
const calendarDays = useMemo(() => {
|
||||||
const daysInMonth = getDaysInMonth(viewYear, viewMonth);
|
const daysInMonth = getDaysInMonth(viewYear, viewMonth);
|
||||||
const firstDay = getFirstDayOfMonth(viewYear, viewMonth);
|
const firstDay = getFirstDayOfMonth(viewYear, viewMonth);
|
||||||
@ -232,8 +222,51 @@ export default function AttendanceIndex({
|
|||||||
return days;
|
return days;
|
||||||
}, [viewYear, viewMonth]);
|
}, [viewYear, viewMonth]);
|
||||||
|
|
||||||
const handlePrevMonth = () => setViewDate((d) => subMonths(d, 1));
|
const handlePrevMonth = () => {
|
||||||
const handleNextMonth = () => setViewDate((d) => addMonths(d, 1));
|
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) => {
|
const handleCameraCapture = (dataUrl: string) => {
|
||||||
setShowCamera(false);
|
setShowCamera(false);
|
||||||
@ -290,8 +323,8 @@ export default function AttendanceIndex({
|
|||||||
|
|
||||||
function formatTime(dateStr: string | null): string {
|
function formatTime(dateStr: string | null): string {
|
||||||
if (!dateStr) {
|
if (!dateStr) {
|
||||||
return '-';
|
return '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
return format(new Date(dateStr), 'HH:mm');
|
return format(new Date(dateStr), 'HH:mm');
|
||||||
}
|
}
|
||||||
@ -305,167 +338,168 @@ return '-';
|
|||||||
<>
|
<>
|
||||||
<Head title="Presensi" />
|
<Head title="Presensi" />
|
||||||
|
|
||||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
<div className="flex min-h-full flex-1 flex-col gap-6 overflow-auto p-4 md:p-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
Presensi
|
Presensi
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Alert Status Presensi */}
|
{!isAdmin && (
|
||||||
<Alert>
|
<Alert>
|
||||||
<CalendarCheck className="h-4 w-4" />
|
<CalendarCheck className="h-4 w-4" />
|
||||||
<AlertTitle>Presensi Hari Ini</AlertTitle>
|
<AlertTitle>Presensi Hari Ini</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{hasCheckedIn ? (
|
{hasCheckedIn ? (
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
Masuk:
|
Masuk:
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{formatTime(
|
{formatTime(
|
||||||
todayAttendance?.check_in_at,
|
todayAttendance?.check_in_at,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{hasCheckedOut ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Pulang:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatTime(
|
||||||
|
todayAttendance?.check_out_at,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Clock className="h-3.5 w-3.5 text-orange-500" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Pulang:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-orange-600">
|
||||||
|
Belum
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
) : (
|
||||||
{hasCheckedOut ? (
|
<p className="text-sm text-muted-foreground">
|
||||||
<>
|
Anda belum melakukan presensi hari ini
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
|
</p>
|
||||||
<span className="text-muted-foreground">
|
)}
|
||||||
Pulang:
|
</div>
|
||||||
</span>
|
|
||||||
<span className="font-medium">
|
<div className="flex items-center gap-2">
|
||||||
{formatTime(
|
{locationLoading && (
|
||||||
todayAttendance?.check_out_at,
|
<span className="text-xs text-muted-foreground">
|
||||||
)}
|
Mendapatkan lokasi...
|
||||||
</span>
|
</span>
|
||||||
</>
|
)}
|
||||||
) : (
|
<Button
|
||||||
<>
|
size="sm"
|
||||||
<Clock className="h-3.5 w-3.5 text-orange-500" />
|
onClick={handleCheckIn}
|
||||||
<span className="text-muted-foreground">
|
disabled={hasCheckedIn || locationLoading}
|
||||||
Pulang:
|
>
|
||||||
</span>
|
<LogIn className="mr-1.5 h-3.5 w-3.5" />
|
||||||
<span className="font-medium text-orange-600">
|
Presensi Masuk
|
||||||
Belum
|
</Button>
|
||||||
</span>
|
<Button
|
||||||
</>
|
size="sm"
|
||||||
)}
|
variant="outline"
|
||||||
</div>
|
onClick={handleCheckOut}
|
||||||
</div>
|
disabled={
|
||||||
) : (
|
!hasCheckedIn ||
|
||||||
<p className="text-sm text-muted-foreground">
|
hasCheckedOut ||
|
||||||
Anda belum melakukan presensi hari ini
|
locationLoading
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LogOut className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Presensi Pulang
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
)}
|
<p className="text-lg font-bold">
|
||||||
</div>
|
{monthStats.working_days}
|
||||||
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
{locationLoading && (
|
</CardContent>
|
||||||
<span className="text-xs text-muted-foreground">
|
</Card>
|
||||||
Mendapatkan lokasi...
|
<Card>
|
||||||
</span>
|
<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">
|
||||||
<Button
|
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||||
size="sm"
|
</div>
|
||||||
onClick={handleCheckIn}
|
<div>
|
||||||
disabled={hasCheckedIn || locationLoading}
|
<p className="text-xs text-muted-foreground">
|
||||||
>
|
Hadir
|
||||||
<LogIn className="mr-1.5 h-3.5 w-3.5" />
|
</p>
|
||||||
Presensi Masuk
|
<p className="text-lg font-bold">
|
||||||
</Button>
|
{monthStats.present}
|
||||||
<Button
|
</p>
|
||||||
size="sm"
|
</div>
|
||||||
variant="outline"
|
</CardContent>
|
||||||
onClick={handleCheckOut}
|
</Card>
|
||||||
disabled={
|
<Card>
|
||||||
!hasCheckedIn ||
|
<CardContent className="flex items-center gap-3 py-3">
|
||||||
hasCheckedOut ||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-red-100">
|
||||||
locationLoading
|
<UserX className="h-5 w-5 text-red-600" />
|
||||||
}
|
</div>
|
||||||
>
|
<div>
|
||||||
<LogOut className="mr-1.5 h-3.5 w-3.5" />
|
<p className="text-xs text-muted-foreground">
|
||||||
Presensi Pulang
|
Tidak Hadir
|
||||||
</Button>
|
</p>
|
||||||
</div>
|
<p className="text-lg font-bold">
|
||||||
</div>
|
{monthStats.absent}
|
||||||
</AlertDescription>
|
</p>
|
||||||
</Alert>
|
</div>
|
||||||
|
</CardContent>
|
||||||
{/* Summary Stats */}
|
</Card>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<Card>
|
||||||
<Card>
|
<CardContent className="flex items-center gap-3 py-3">
|
||||||
<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">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-100">
|
<Wallet className="h-5 w-5 text-amber-600" />
|
||||||
<CalendarDays className="h-5 w-5 text-blue-600" />
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<p className="text-xs text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground">
|
Cuti
|
||||||
Hari Kerja
|
</p>
|
||||||
</p>
|
<p className="text-lg font-bold">
|
||||||
<p className="text-lg font-bold">
|
{monthStats.leave}
|
||||||
{monthStats.working_days}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
</div>
|
||||||
<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
|
<Card
|
||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
style={{ '--card-spacing': '0px' } as React.CSSProperties}
|
style={{ '--card-spacing': '0px' } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
{/* Calendar Header */}
|
|
||||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex flex-col items-center overflow-hidden rounded-lg ring-1 ring-border">
|
<div className="flex flex-col items-center overflow-hidden rounded-lg ring-1 ring-border">
|
||||||
@ -503,10 +537,7 @@ return '-';
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 px-3 text-xs font-medium"
|
className="h-8 px-3 text-xs font-medium"
|
||||||
onClick={() => {
|
onClick={handleGoToToday}
|
||||||
setViewDate(new Date());
|
|
||||||
setSelectedDate(new Date());
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Hari ini
|
Hari ini
|
||||||
</Button>
|
</Button>
|
||||||
@ -521,7 +552,6 @@ return '-';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Weekday Headers */}
|
|
||||||
<div className="grid grid-cols-7 border-b">
|
<div className="grid grid-cols-7 border-b">
|
||||||
{WEEKDAYS.map((day) => (
|
{WEEKDAYS.map((day) => (
|
||||||
<div
|
<div
|
||||||
@ -533,11 +563,10 @@ return '-';
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Calendar Grid */}
|
|
||||||
<div className="grid grid-cols-7">
|
<div className="grid grid-cols-7">
|
||||||
{calendarDays.map((cell, idx) => {
|
{calendarDays.map((cell, idx) => {
|
||||||
const dateStr = format(cell.date, 'yyyy-MM-dd');
|
const dateStr = format(cell.date, 'yyyy-MM-dd');
|
||||||
const attendance = attendanceDates.get(dateStr);
|
const dayAttendances = attendanceByDate.get(dateStr) ?? [];
|
||||||
const isSelected = isSameDay(
|
const isSelected = isSameDay(
|
||||||
cell.date,
|
cell.date,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
@ -547,47 +576,33 @@ return '-';
|
|||||||
new Date(),
|
new Date(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const today = new Date();
|
const todayMidnight = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
todayMidnight.setHours(0, 0, 0, 0);
|
||||||
const cellDate = new Date(cell.date);
|
const cellDate = new Date(cell.date);
|
||||||
cellDate.setHours(0, 0, 0, 0);
|
cellDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const isPastDate = cellDate < today;
|
const isPastDate = cellDate < todayMidnight;
|
||||||
const showAbsent =
|
const showAbsent =
|
||||||
cell.isCurrentMonth &&
|
cell.isCurrentMonth &&
|
||||||
isPastDate &&
|
isPastDate &&
|
||||||
!isWeekend(cell.date) &&
|
!isWeekend(cell.date) &&
|
||||||
!attendance;
|
dayAttendances.length === 0;
|
||||||
|
|
||||||
const late = attendance
|
|
||||||
? isLate(
|
|
||||||
attendance.check_in_at,
|
|
||||||
officeHour,
|
|
||||||
officeMinute,
|
|
||||||
)
|
|
||||||
: false;
|
|
||||||
const lateMins = attendance
|
|
||||||
? getLateMinutes(
|
|
||||||
attendance.check_in_at,
|
|
||||||
officeHour,
|
|
||||||
officeMinute,
|
|
||||||
)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedDate(cell.date);
|
setSelectedDate(cell.date);
|
||||||
|
if (!isAdmin && dayAttendances.length > 0) {
|
||||||
if (attendance) {
|
setDetailAttendance(dayAttendances[0]);
|
||||||
setDetailAttendance(attendance);
|
}
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className={`group relative flex min-h-[100px] flex-col p-2 text-left transition-colors hover:bg-muted/50 ${
|
className={`group relative flex min-h-[100px] flex-col p-2 text-left transition-colors hover:bg-muted/50 ${
|
||||||
!cell.isCurrentMonth
|
!cell.isCurrentMonth
|
||||||
? 'bg-muted/30 text-muted-foreground/50'
|
? 'bg-muted/30 text-muted-foreground/50'
|
||||||
: ''
|
: ''
|
||||||
|
} ${isSelected ? 'bg-muted/50' : ''} ${
|
||||||
|
!isAdmin && dayAttendances.length > 0 ? 'cursor-pointer' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
borderRight: '1px solid var(--border)',
|
borderRight: '1px solid var(--border)',
|
||||||
@ -607,43 +622,89 @@ setDetailAttendance(attendance);
|
|||||||
{cell.day}
|
{cell.day}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex flex-col gap-0.5">
|
<div className="mt-1 flex flex-col gap-0.5 overflow-hidden">
|
||||||
{attendance && (
|
{isAdmin ? (
|
||||||
<>
|
<>
|
||||||
<span
|
{dayAttendances.map((att) => {
|
||||||
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'}`}
|
const late = isLate(
|
||||||
>
|
att.check_in_at,
|
||||||
{late
|
officeHour,
|
||||||
? 'Terlambat'
|
officeMinute,
|
||||||
: 'Hadir'}
|
);
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground">
|
return (
|
||||||
Masuk :{' '}
|
<button
|
||||||
{formatTime(
|
key={att.id}
|
||||||
attendance.check_in_at,
|
onClick={(e) => {
|
||||||
)}
|
e.stopPropagation();
|
||||||
</span>
|
setDetailAttendance(att);
|
||||||
<span className="text-[10px] text-muted-foreground">
|
}}
|
||||||
Pulang :{' '}
|
className="w-full"
|
||||||
{formatTime(
|
>
|
||||||
attendance.check_out_at,
|
<Badge
|
||||||
)}
|
variant={late ? 'destructive' : 'default'}
|
||||||
</span>
|
className="w-full justify-center cursor-pointer truncate"
|
||||||
{late && (
|
>
|
||||||
<span className="text-[10px] text-yellow-600">
|
{att.employee_name}
|
||||||
Telat :{' '}
|
</Badge>
|
||||||
{formatMinutes(
|
</button>
|
||||||
lateMins,
|
);
|
||||||
)}{' '}
|
})}
|
||||||
menit
|
</>
|
||||||
</span>
|
) : (
|
||||||
)}
|
<>
|
||||||
<span className="text-[10px] text-muted-foreground">
|
{dayAttendances.length > 0 && (() => {
|
||||||
Jam Kerja :{' '}
|
const att = dayAttendances[0];
|
||||||
{formatMinutes(
|
const late = isLate(
|
||||||
attendance.work_duration_minutes,
|
att.check_in_at,
|
||||||
)}
|
officeHour,
|
||||||
</span>
|
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 && (
|
{showAbsent && (
|
||||||
@ -652,7 +713,7 @@ setDetailAttendance(attendance);
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@ -673,6 +734,9 @@ setDetailAttendance(attendance);
|
|||||||
<DialogContent className="sm:max-w-2xl">
|
<DialogContent className="sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
|
{isAdmin && detailAttendance?.employee_name
|
||||||
|
? `${detailAttendance.employee_name} - `
|
||||||
|
: ''}
|
||||||
Detail Presensi -{' '}
|
Detail Presensi -{' '}
|
||||||
{detailAttendance &&
|
{detailAttendance &&
|
||||||
format(
|
format(
|
||||||
@ -749,19 +813,37 @@ setDetailAttendance(attendance);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-4">
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
<div className="flex flex-col gap-2">
|
||||||
Lokasi Presensi
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
</span>
|
Lokasi Masuk
|
||||||
<LocationMap
|
</span>
|
||||||
latitude={
|
<LocationMap
|
||||||
detailAttendance.check_in_latitude
|
latitude={
|
||||||
}
|
detailAttendance.check_in_latitude
|
||||||
longitude={
|
}
|
||||||
detailAttendance.check_in_longitude
|
longitude={
|
||||||
}
|
detailAttendance.check_in_longitude
|
||||||
height="200px"
|
}
|
||||||
/>
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user