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:
2026-05-21 13:30:06 +04:00
parent 0d591b9807
commit 3c55be5052
29 changed files with 3808 additions and 113 deletions
@@ -0,0 +1,259 @@
"use client"
import { use } from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { type ColumnDef } from "@tanstack/react-table"
import { toast } from "sonner"
import { CheckCircle2Icon, RefreshCwIcon, BanknoteIcon, Trash2, DownloadIcon } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { confirm } from "@/shared/components/confirm-dialog"
import { useRouter } from "next/navigation"
import { formatEnum } from "@/shared/utils/formatters"
type Entry = {
id: number
employee_id: number
employee?: { id?: number; first_name?: string; last_name?: string; email?: string }
hours_worked: number
overtime_hours: number
base_salary: number
commission_amount: number
allowances: number
deductions: number
net_pay: number
note?: string | null
}
type Run = {
id: number
reference?: string
period_start: string
period_end: string
status: "draft" | "finalized" | "paid"
entries: Entry[]
note?: string | null
finalized_at?: string | null
paid_at?: string | null
}
function formatDate(value: unknown) {
if (!value || typeof value !== "string") return "—"
const d = new Date(value)
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString()
}
function fmtCurrency(value: unknown) {
const n = Number(value)
if (!Number.isFinite(n)) return "—"
return n.toFixed(2)
}
function statusVariant(status: string): "default" | "secondary" | "outline" {
if (status === "paid") return "default"
if (status === "finalized") return "outline"
return "secondary"
}
export default function PayrollRunPage({ params }: { params: Promise<{ id: string }> }) {
const { id: runId } = use(params)
const api = useAuthApi()
const queryClient = useQueryClient()
const router = useRouter()
const queryKey = ["payroll-run", runId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.payroll.show(runId) as Promise<{ data: Run; totals: Record<string, number> }>,
})
const run = (data as any)?.data as Run | undefined
const totals = (data as any)?.totals as Record<string, number> | undefined
const refetch = () => queryClient.invalidateQueries({ queryKey })
const finalizeMutation = useMutation({
mutationFn: () => api.payroll.finalize(runId),
onSuccess: () => {
toast.success("Run finalized")
refetch()
},
onError: () => toast.error("Failed to finalize"),
})
const regenerateMutation = useMutation({
mutationFn: () => api.payroll.regenerate(runId),
onSuccess: () => {
toast.success("Entries regenerated")
refetch()
},
onError: () => toast.error("Failed to regenerate"),
})
const markPaidMutation = useMutation({
mutationFn: () => api.payroll.markPaid(runId),
onSuccess: () => {
toast.success("Marked as paid")
refetch()
},
onError: () => toast.error("Failed to mark paid"),
})
const deleteMutation = useMutation({
mutationFn: () => api.payroll.destroy(runId),
onSuccess: () => {
toast.success("Run deleted")
router.push("/productivity/payroll")
},
onError: () => toast.error("Failed to delete"),
})
async function handleDelete() {
const ok = await confirm({
title: "Delete payroll run?",
description: "This permanently removes the run and all its entries.",
confirmLabel: "Delete",
variant: "destructive",
})
if (ok) deleteMutation.mutate()
}
const columns: ColumnDef<Entry>[] = [
{
accessorKey: "employee",
header: ({ column }) => <ColumnHeader column={column} title="Employee" />,
cell: ({ row }) => {
const e = row.original.employee
if (!e) return `#${row.original.employee_id}`
return `${e.first_name ?? ""} ${e.last_name ?? ""}`.trim() || e.email || "—"
},
},
{
accessorKey: "hours_worked",
header: ({ column }) => <ColumnHeader column={column} title="Hours" />,
cell: ({ row }) => fmtCurrency(row.original.hours_worked),
},
{
accessorKey: "base_salary",
header: ({ column }) => <ColumnHeader column={column} title="Base" />,
cell: ({ row }) => fmtCurrency(row.original.base_salary),
},
{
accessorKey: "commission_amount",
header: ({ column }) => <ColumnHeader column={column} title="Commission" />,
cell: ({ row }) => fmtCurrency(row.original.commission_amount),
},
{
accessorKey: "allowances",
header: ({ column }) => <ColumnHeader column={column} title="Allowances" />,
cell: ({ row }) => fmtCurrency(row.original.allowances),
},
{
accessorKey: "deductions",
header: ({ column }) => <ColumnHeader column={column} title="Deductions" />,
cell: ({ row }) => fmtCurrency(row.original.deductions),
},
{
accessorKey: "net_pay",
header: ({ column }) => <ColumnHeader column={column} title="Net Pay" />,
cell: ({ row }) => <span className="font-semibold">{fmtCurrency(row.original.net_pay)}</span>,
},
{
id: "slip",
header: () => <span className="sr-only">Slip</span>,
cell: ({ row }) => (
<Button variant="ghost" size="icon" asChild>
<a
href={`/api/payroll/runs/${runId}/entries/${row.original.id}/slip`}
target="_blank"
rel="noopener noreferrer"
title="Download slip PDF"
>
<DownloadIcon className="size-4" />
</a>
</Button>
),
},
]
if (isLoading && !run) {
return <DashboardPage>Loading...</DashboardPage>
}
if (!run) {
return <DashboardPage>Run not found.</DashboardPage>
}
const entries = run.entries ?? []
return (
<DashboardPage
headerProps={{
title: `${run.reference ?? `Payroll #${run.id}`} · ${formatDate(run.period_start)} → ${formatDate(run.period_end)}`,
actions: (
<div className="flex items-center gap-2">
<Badge variant={statusVariant(run.status)}>{formatEnum(run.status)}</Badge>
{run.status === "draft" && (
<>
<Button size="sm" variant="outline" disabled={regenerateMutation.isPending} onClick={() => regenerateMutation.mutate()}>
<RefreshCwIcon className="size-4" />
Regenerate
</Button>
<Button size="sm" disabled={finalizeMutation.isPending} onClick={() => finalizeMutation.mutate()}>
<CheckCircle2Icon className="size-4" />
Finalize
</Button>
<Button size="sm" variant="destructive" disabled={deleteMutation.isPending} onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</Button>
</>
)}
{run.status === "finalized" && (
<Button size="sm" disabled={markPaidMutation.isPending} onClick={() => markPaidMutation.mutate()}>
<BanknoteIcon className="size-4" />
Mark Paid
</Button>
)}
</div>
),
}}
>
{totals && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5 mb-4">
{[
{ label: "Gross Base", value: totals.gross_base },
{ label: "Commission", value: totals.commission },
{ label: "Allowances", value: totals.allowances },
{ label: "Deductions", value: totals.deductions },
{ label: "Net Total", value: totals.net_pay },
].map((t) => (
<Card key={t.label}>
<CardHeader className="pb-1">
<CardTitle className="text-xs font-medium text-muted-foreground">{t.label}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-lg font-semibold">{fmtCurrency(t.value)}</div>
</CardContent>
</Card>
))}
</div>
)}
<DataTable
columns={columns}
data={entries}
pagination={{ page: 1, pageSize: entries.length || 10, pageCount: 1, total: entries.length }}
sorting={[]}
onChange={() => {}}
isLoading={false}
/>
</DashboardPage>
)
}
@@ -0,0 +1,102 @@
"use client"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { PayrollRunForm } from "@/modules/payroll/payroll-run-form"
import { PAYROLL_ROUTES } from "@garage/api"
import type { PayrollClient } from "@garage/api"
import { Badge } from "@/shared/components/ui/badge"
import { useRouter } from "next/navigation"
import { formatEnum } from "@/shared/utils/formatters"
type RunRow = {
id: number
reference?: string | null
period_start?: string
period_end?: string
status: string
entries_count?: number
generated_at?: string | null
finalized_at?: string | null
paid_at?: string | null
}
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" {
if (status === "paid") return "default"
if (status === "finalized") return "outline"
return "secondary"
}
export default function PayrollPage() {
const router = useRouter()
return (
<ResourcePage<PayrollClient>
pageTitle="Payroll"
routeKey={PAYROLL_ROUTES.RUNS_INDEX}
getClient={(api) => api.payroll}
statusFilter={{
statuses: ["draft", "finalized", "paid"],
paramKey: "status",
}}
onRowClick={(row) => router.push(`/productivity/payroll/${(row as any).id}`)}
headerProps={({ invalidateQuery }) => ({
actions: (
<FormDialog title="Payroll Run">
{() => (
<PayrollRunForm
onSuccess={(runId) => {
invalidateQuery()
if (runId) router.push(`/productivity/payroll/${runId}`)
}}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "reference",
header: ({ column }) => <ColumnHeader column={column} title="Reference" />,
cell: ({ row }) => <span className="font-mono text-xs">{(row.original as any).reference ?? `#${(row.original as any).id}`}</span>,
},
{
accessorKey: "period_start",
header: ({ column }) => <ColumnHeader column={column} title="Period Start" />,
cell: ({ row }) => formatDate((row.original as any).period_start),
},
{
accessorKey: "period_end",
header: ({ column }) => <ColumnHeader column={column} title="Period End" />,
cell: ({ row }) => formatDate((row.original as any).period_end),
},
{
accessorKey: "entries_count",
header: ({ column }) => <ColumnHeader column={column} title="Entries" />,
cell: ({ row }) => (row.original as any).entries_count ?? "—",
},
{
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>
},
},
{
accessorKey: "finalized_at",
header: ({ column }) => <ColumnHeader column={column} title="Finalized" />,
cell: ({ row }) => formatDate((row.original as any).finalized_at),
},
actionsColumn({ onEdit: undefined }),
]}
/>
)
}