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.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { LeaveRequestForm } from "@/modules/leave-requests/leave-request-form"
|
||||
import { LEAVE_REQUEST_ROUTES } from "@garage/api"
|
||||
import type { LeaveRequestsClient } from "@garage/api"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { CheckIcon, XIcon } from "lucide-react"
|
||||
import { confirm } from "@/shared/components/confirm-dialog"
|
||||
import { formatEnum } from "@/shared/utils/formatters"
|
||||
import { usePermissions } from "@/shared/hooks/use-permissions"
|
||||
|
||||
type LeaveRow = {
|
||||
id: number
|
||||
leave_type: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
days?: number
|
||||
status: string
|
||||
reason?: string | null
|
||||
employee?: { id?: number; first_name?: string; last_name?: string; email?: string }
|
||||
}
|
||||
|
||||
function formatDate(value: unknown) {
|
||||
if (!value || typeof value !== "string") return "—"
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString()
|
||||
}
|
||||
|
||||
function statusVariant(status: string): "default" | "secondary" | "outline" | "destructive" {
|
||||
if (status === "approved") return "default"
|
||||
if (status === "rejected") return "destructive"
|
||||
if (status === "cancelled") return "outline"
|
||||
return "secondary"
|
||||
}
|
||||
|
||||
export default function LeaveRequestsPage() {
|
||||
const api = useAuthApi()
|
||||
const queryClient = useQueryClient()
|
||||
const perms = usePermissions()
|
||||
|
||||
async function handleDecision(id: number, action: "approve" | "reject") {
|
||||
const ok = await confirm({
|
||||
title: action === "approve" ? "Approve request?" : "Reject request?",
|
||||
description: `This will mark the leave request as ${action}d.`,
|
||||
confirmLabel: action === "approve" ? "Approve" : "Reject",
|
||||
variant: action === "approve" ? "default" : "destructive",
|
||||
})
|
||||
if (!ok) return
|
||||
const promise = action === "approve"
|
||||
? api.leaveRequests.approve(String(id))
|
||||
: api.leaveRequests.reject(String(id))
|
||||
toast.promise(promise, {
|
||||
loading: action === "approve" ? "Approving..." : "Rejecting...",
|
||||
success: action === "approve" ? "Approved" : "Rejected",
|
||||
error: "Failed",
|
||||
})
|
||||
await promise
|
||||
queryClient.invalidateQueries({ queryKey: [LEAVE_REQUEST_ROUTES.INDEX] })
|
||||
}
|
||||
|
||||
return (
|
||||
<ResourcePage<LeaveRequestsClient>
|
||||
pageTitle="Leave Requests"
|
||||
routeKey={LEAVE_REQUEST_ROUTES.INDEX}
|
||||
getClient={(api) => api.leaveRequests}
|
||||
statusFilter={{
|
||||
statuses: ["pending", "approved", "rejected", "cancelled"],
|
||||
paramKey: "status",
|
||||
allLabel: "All",
|
||||
}}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: perms.canCreate("leave_requests") ? (
|
||||
<FormDialog title="Leave Request">
|
||||
{(resourceId) => (
|
||||
<LeaveRequestForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem}
|
||||
onSuccess={invalidateQuery}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
) : null,
|
||||
})}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "employee",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Employee" />,
|
||||
cell: ({ row }) => {
|
||||
const e = (row.original as any).employee
|
||||
if (!e) return "—"
|
||||
return `${e.first_name ?? ""} ${e.last_name ?? ""}`.trim() || e.email || "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "leave_type",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Type" />,
|
||||
cell: ({ row }) => <Badge variant="outline">{formatEnum((row.original as any).leave_type)}</Badge>,
|
||||
},
|
||||
{
|
||||
accessorKey: "start_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Start" />,
|
||||
cell: ({ row }) => formatDate((row.original as any).start_date),
|
||||
},
|
||||
{
|
||||
accessorKey: "end_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="End" />,
|
||||
cell: ({ row }) => formatDate((row.original as any).end_date),
|
||||
},
|
||||
{
|
||||
accessorKey: "days",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Days" />,
|
||||
cell: ({ row }) => (row.original as any).days ?? "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => {
|
||||
const s = (row.original as any).status as string
|
||||
return <Badge variant={statusVariant(s)}>{formatEnum(s)}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "decide",
|
||||
header: () => <span className="sr-only">Decide</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original as LeaveRow
|
||||
if (r.status !== "pending" || !perms.canUpdate("leave_requests")) return null
|
||||
return (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="icon" variant="ghost" onClick={() => handleDecision(r.id, "approve")}>
|
||||
<CheckIcon className="size-4 text-green-600" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" onClick={() => handleDecision(r.id, "reject")}>
|
||||
<XIcon className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user