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,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user