humam kerdiah 3c55be5052 feat: add payroll, time clocks, and leave request modules
- 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.
2026-05-21 13:30:06 +04:00

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>
)
}