- Implemented PayrollPage for managing payroll runs with CRUD operations. - Created TimeClocksPage for tracking employee clock-ins and clock-outs. - Developed TimeSheetsPage for displaying employee timesheets. - Added EmployeeCertificationForm and EmployeeDocumentForm for managing employee certifications and documents. - Introduced LeaveRequestForm for submitting and managing leave requests. - Added usePermissions hook for UI-side permission checks. - Created LeaveRequestsClient and PayrollClient for API interactions.
164 lines
7.5 KiB
TypeScript
164 lines
7.5 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo, useState } from "react"
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
import { LogInIcon, LogOutIcon, TimerIcon, SearchIcon } from "lucide-react"
|
|
import { toast } from "sonner"
|
|
|
|
import { useAuthApi } from "@/shared/useApi"
|
|
import { Card, CardContent } from "@/shared/components/ui/card"
|
|
import { Avatar, AvatarFallback } from "@/shared/components/ui/avatar"
|
|
import { Badge } from "@/shared/components/ui/badge"
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { Input } from "@/shared/components/ui/input"
|
|
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
|
import { EMPLOYEE_ROUTES } from "@garage/api"
|
|
|
|
type Employee = {
|
|
id: number
|
|
first_name?: string
|
|
last_name?: string
|
|
email?: string
|
|
designation?: string
|
|
department?: { id?: number; name?: string }
|
|
track_attendance?: boolean
|
|
has_active_time_sheet?: boolean
|
|
active_time_sheet?: { id?: number; clock_in?: string; date?: string } | null
|
|
status?: string
|
|
}
|
|
|
|
function initialsOf(first?: string | null, last?: string | null) {
|
|
const f = (first ?? "").trim()[0] ?? ""
|
|
const l = (last ?? "").trim()[0] ?? ""
|
|
return (f + l).toUpperCase() || "?"
|
|
}
|
|
|
|
function formatTime(value: unknown) {
|
|
if (!value || typeof value !== "string") return "—"
|
|
return value.length >= 5 ? value.slice(0, 5) : value
|
|
}
|
|
|
|
export default function TimeClocksPage() {
|
|
const api = useAuthApi()
|
|
const queryClient = useQueryClient()
|
|
const [search, setSearch] = useState("")
|
|
|
|
const employeesQuery = useQuery({
|
|
queryKey: [EMPLOYEE_ROUTES.INDEX, "time-clocks", { per_page: 100, status: "active" }],
|
|
queryFn: () => api.employees.list({ per_page: 100, status: "active" } as any) as Promise<{ data: Employee[] }>,
|
|
})
|
|
|
|
const employees: Employee[] = useMemo(() => {
|
|
const rows: Employee[] = (employeesQuery.data as any)?.data ?? []
|
|
if (!search) return rows
|
|
const q = search.toLowerCase()
|
|
return rows.filter((e) => {
|
|
const name = `${e.first_name ?? ""} ${e.last_name ?? ""}`.toLowerCase()
|
|
return name.includes(q) || (e.email ?? "").toLowerCase().includes(q)
|
|
})
|
|
}, [employeesQuery.data, search])
|
|
|
|
const refetch = () => queryClient.invalidateQueries({ queryKey: [EMPLOYEE_ROUTES.INDEX, "time-clocks", { per_page: 100, status: "active" }] })
|
|
|
|
const clockInMutation = useMutation({
|
|
mutationFn: (employeeId: number) => api.timeSheets.clockIn({ employee_id: employeeId } as any),
|
|
onSuccess: () => {
|
|
toast.success("Clocked in")
|
|
refetch()
|
|
},
|
|
onError: (err: any) => toast.error(err?.payload?.message ?? err?.message ?? "Failed to clock in"),
|
|
})
|
|
|
|
const clockOutMutation = useMutation({
|
|
mutationFn: (timeSheetId: number) => api.timeSheets.clockOut({ time_sheet_id: timeSheetId } as any),
|
|
onSuccess: () => {
|
|
toast.success("Clocked out")
|
|
refetch()
|
|
},
|
|
onError: (err: any) => toast.error(err?.payload?.message ?? err?.message ?? "Failed to clock out"),
|
|
})
|
|
|
|
return (
|
|
<DashboardPage
|
|
headerProps={{
|
|
title: "Time Clocks",
|
|
actions: (
|
|
<div className="relative w-64">
|
|
<SearchIcon className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
|
<Input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Search employees..."
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
),
|
|
}}
|
|
>
|
|
{employeesQuery.isLoading ? (
|
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
|
) : employees.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No employees found.</p>
|
|
) : (
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{employees.map((emp) => {
|
|
const fullName = `${emp.first_name ?? ""} ${emp.last_name ?? ""}`.trim() || emp.email
|
|
const isClockedIn = !!emp.has_active_time_sheet
|
|
const activeId = emp.active_time_sheet?.id
|
|
const clockedInAt = emp.active_time_sheet?.clock_in
|
|
|
|
return (
|
|
<Card key={emp.id}>
|
|
<CardContent className="flex items-center justify-between gap-3 p-4">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<Avatar size="lg">
|
|
<AvatarFallback>{initialsOf(emp.first_name, emp.last_name)}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0">
|
|
<div className="font-medium truncate">{fullName}</div>
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{emp.designation ?? emp.department?.name ?? emp.email}
|
|
</div>
|
|
<div className="mt-1 flex items-center gap-2">
|
|
{isClockedIn ? (
|
|
<Badge variant="default" className="gap-1">
|
|
<TimerIcon className="size-3" />
|
|
Since {formatTime(clockedInAt)}
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="secondary">Idle</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{isClockedIn && activeId ? (
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
disabled={clockOutMutation.isPending}
|
|
onClick={() => clockOutMutation.mutate(activeId)}
|
|
>
|
|
<LogOutIcon className="size-4" />
|
|
Clock Out
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
disabled={clockInMutation.isPending}
|
|
onClick={() => clockInMutation.mutate(emp.id)}
|
|
>
|
|
<LogInIcon className="size-4" />
|
|
Clock In
|
|
</Button>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</DashboardPage>
|
|
)
|
|
}
|