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,179 @@
"use client"
import { useState } from "react"
import { useParams } from "next/navigation"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { type ColumnDef } from "@tanstack/react-table"
import { Plus, Trash2, ExternalLinkIcon, AwardIcon } from "lucide-react"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
import { confirm } from "@/shared/components/confirm-dialog"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { EmployeeCertificationForm } from "@/modules/employees/employee-certification-form"
type CertRow = {
id: number
name: string
issuer?: string | null
certificate_number?: string | null
issued_date?: string | null
expiry_date?: string | null
file_url?: string | null
is_expired?: boolean
notes?: 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()
}
export default function EmployeeCertificationsPage() {
const { id: employeeId } = useParams<{ id: string }>()
const api = useAuthApi()
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const queryKey = ["employee-certifications", employeeId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.employees.listCertifications(employeeId) as Promise<{ data: CertRow[] | { data: CertRow[] } }>,
})
const rows: CertRow[] = Array.isArray((data as any)?.data)
? (data as any).data
: ((data as any)?.data?.data ?? [])
const deleteMutation = useMutation({
mutationFn: (id: number) => api.employees.destroyCertification(employeeId, String(id)),
onSuccess: () => {
toast.success("Certification deleted")
queryClient.invalidateQueries({ queryKey })
},
onError: () => toast.error("Failed to delete"),
})
async function handleDelete(row: CertRow) {
const ok = await confirm({
title: "Delete certification?",
description: `Permanently delete "${row.name}"?`,
confirmLabel: "Delete",
variant: "destructive",
})
if (ok) deleteMutation.mutate(row.id)
}
const columns: ColumnDef<CertRow>[] = [
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<AwardIcon className="size-4 text-muted-foreground" />
<span className="font-medium">{row.original.name}</span>
</div>
),
},
{
accessorKey: "issuer",
header: ({ column }) => <ColumnHeader column={column} title="Issuer" />,
cell: ({ row }) => row.original.issuer ?? "—",
},
{
accessorKey: "certificate_number",
header: ({ column }) => <ColumnHeader column={column} title="Cert #" />,
cell: ({ row }) => {
const v = row.original.certificate_number
return v ? <span className="font-mono text-xs">{v}</span> : "—"
},
},
{
accessorKey: "issued_date",
header: ({ column }) => <ColumnHeader column={column} title="Issued" />,
cell: ({ row }) => formatDate(row.original.issued_date),
},
{
accessorKey: "expiry_date",
header: ({ column }) => <ColumnHeader column={column} title="Expiry" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span>{formatDate(row.original.expiry_date)}</span>
{row.original.is_expired && <Badge variant="destructive">Expired</Badge>}
</div>
),
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<div className="flex justify-end gap-1">
{row.original.file_url && (
<Button variant="ghost" size="icon" asChild>
<a href={row.original.file_url} target="_blank" rel="noopener noreferrer">
<ExternalLinkIcon className="size-4" />
</a>
</Button>
)}
<Button
variant="ghost"
size="icon"
disabled={deleteMutation.isPending}
onClick={() => handleDelete(row.original)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
),
},
]
return (
<DashboardPage
headerProps={{
title: "Certifications",
actions: (
<Button size="sm" onClick={() => setOpen(true)}>
<Plus className="size-4" />
Add Certification
</Button>
),
}}
>
<DataTable
columns={columns}
data={rows}
pagination={{ page: 1, pageSize: rows.length || 10, pageCount: 1, total: rows.length }}
sorting={[]}
onChange={() => {}}
isLoading={isLoading}
/>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Certification</DialogTitle>
</DialogHeader>
<EmployeeCertificationForm
employeeId={employeeId}
onSuccess={() => {
setOpen(false)
queryClient.invalidateQueries({ queryKey })
}}
onCancel={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
</DashboardPage>
)
}
@@ -0,0 +1,182 @@
"use client"
import { useState } from "react"
import { useParams } from "next/navigation"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { type ColumnDef } from "@tanstack/react-table"
import { Plus, Trash2, ExternalLinkIcon, FileTextIcon } from "lucide-react"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
import { confirm } from "@/shared/components/confirm-dialog"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { EmployeeDocumentForm } from "@/modules/employees/employee-document-form"
import { formatEnum } from "@/shared/utils/formatters"
type DocRow = {
id: number
name: string
type: string
file_url?: string
issued_date?: string | null
expiry_date?: string | null
notes?: string | null
created_at?: 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 isExpired(value: unknown) {
if (!value || typeof value !== "string") return false
const d = new Date(value)
return !Number.isNaN(d.getTime()) && d.getTime() < Date.now()
}
export default function EmployeeDocumentsPage() {
const { id: employeeId } = useParams<{ id: string }>()
const api = useAuthApi()
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const queryKey = ["employee-documents", employeeId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.employees.listDocuments(employeeId) as Promise<{ data: DocRow[] | { data: DocRow[] } }>,
})
const rows: DocRow[] = Array.isArray((data as any)?.data)
? (data as any).data
: ((data as any)?.data?.data ?? [])
const deleteMutation = useMutation({
mutationFn: (id: number) => api.employees.destroyDocument(employeeId, String(id)),
onSuccess: () => {
toast.success("Document deleted")
queryClient.invalidateQueries({ queryKey })
},
onError: () => toast.error("Failed to delete document"),
})
async function handleDelete(row: DocRow) {
const ok = await confirm({
title: "Delete document?",
description: `Permanently delete "${row.name}"?`,
confirmLabel: "Delete",
variant: "destructive",
})
if (ok) deleteMutation.mutate(row.id)
}
const columns: ColumnDef<DocRow>[] = [
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<FileTextIcon className="size-4 text-muted-foreground" />
<span className="font-medium">{row.original.name}</span>
</div>
),
},
{
accessorKey: "type",
header: ({ column }) => <ColumnHeader column={column} title="Type" />,
cell: ({ row }) => <Badge variant="outline">{formatEnum(row.original.type)}</Badge>,
},
{
accessorKey: "issued_date",
header: ({ column }) => <ColumnHeader column={column} title="Issued" />,
cell: ({ row }) => formatDate(row.original.issued_date),
},
{
accessorKey: "expiry_date",
header: ({ column }) => <ColumnHeader column={column} title="Expiry" />,
cell: ({ row }) => {
const v = row.original.expiry_date
if (!v) return "—"
return (
<div className="flex items-center gap-2">
<span>{formatDate(v)}</span>
{isExpired(v) && <Badge variant="destructive">Expired</Badge>}
</div>
)
},
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<div className="flex justify-end gap-1">
{row.original.file_url && (
<Button variant="ghost" size="icon" asChild>
<a href={row.original.file_url} target="_blank" rel="noopener noreferrer">
<ExternalLinkIcon className="size-4" />
</a>
</Button>
)}
<Button
variant="ghost"
size="icon"
disabled={deleteMutation.isPending}
onClick={() => handleDelete(row.original)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
),
},
]
return (
<DashboardPage
headerProps={{
title: "Documents",
actions: (
<Button size="sm" onClick={() => setOpen(true)}>
<Plus className="size-4" />
Upload Document
</Button>
),
}}
>
<DataTable
columns={columns}
data={rows}
pagination={{ page: 1, pageSize: rows.length || 10, pageCount: 1, total: rows.length }}
sorting={[]}
onChange={() => {}}
isLoading={isLoading}
/>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Document</DialogTitle>
</DialogHeader>
<EmployeeDocumentForm
employeeId={employeeId}
onSuccess={() => {
setOpen(false)
queryClient.invalidateQueries({ queryKey })
}}
onCancel={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
</DashboardPage>
)
}
@@ -30,6 +30,11 @@ export default async function layout(props: {
{ href: `/productivity/employees/${id}`, label: 'Details' },
{ href: `/productivity/employees/${id}/attendance`, label: 'Attendance' },
{ href: `/productivity/employees/${id}/work-history`, label: 'Work History' },
{ href: `/productivity/employees/${id}/documents`, label: 'Documents' },
{ href: `/productivity/employees/${id}/certifications`, label: 'Certifications' },
{ href: `/productivity/employees/${id}/leave`, label: 'Leave' },
{ href: `/productivity/employees/${id}/payroll`, label: 'Payroll' },
{ href: `/productivity/employees/${id}/performance`, label: 'Performance' },
{ href: `/productivity/employees/${id}/permissions`, label: 'Permissions' },
]}
>
@@ -0,0 +1,164 @@
"use client"
import { use, useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { useAuthApi } from "@/shared/useApi"
import { useEmployee } from "@/modules/employees/employee-context"
import { LeaveRequestForm } from "@/modules/leave-requests/leave-request-form"
import { LEAVE_REQUEST_ROUTES } from "@garage/api"
import type { LeaveRequestsClient } from "@garage/api"
import { CalendarIcon, PlaneIcon, ActivityIcon } from "lucide-react"
import { formatEnum } from "@/shared/utils/formatters"
type Balance = {
annual_total: number
annual_used: number
annual_remaining: number
sick_total: number
sick_used: number
sick_remaining: number
}
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"
}
function BalanceCard({ icon: Icon, label, used, total, remaining }: {
icon: React.ComponentType<{ className?: string }>
label: string
used: number
total: number
remaining: number
}) {
const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Icon className="size-4" />
{label}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">{remaining}<span className="ml-1 text-sm font-normal text-muted-foreground">/ {total}</span></div>
<p className="mt-1 text-xs text-muted-foreground">{used} used</p>
<div className="mt-2 h-2 w-full overflow-hidden rounded bg-muted">
<div className="h-full bg-primary" style={{ width: `${pct}%` }} />
</div>
</CardContent>
</Card>
)
}
export default function EmployeeLeavePage({ params }: { params: Promise<{ id: string }> }) {
const { id: employeeId } = use(params)
const api = useAuthApi()
const employee = useEmployee()
const balanceQuery = useQuery({
queryKey: ["employee-leave-balance", employeeId],
queryFn: () => api.employees.getLeaveBalance(employeeId) as Promise<{ data: Balance }>,
})
const balance = (balanceQuery.data as any)?.data as Balance | undefined
return (
<div className="space-y-4">
{balance && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-2 px-4 pt-4">
<BalanceCard
icon={PlaneIcon}
label="Annual Leave"
used={Number(balance.annual_used)}
total={Number(balance.annual_total)}
remaining={Number(balance.annual_remaining)}
/>
<BalanceCard
icon={ActivityIcon}
label="Sick Leave"
used={Number(balance.sick_used)}
total={Number(balance.sick_total)}
remaining={Number(balance.sick_remaining)}
/>
</div>
)}
<ResourcePage<LeaveRequestsClient>
pageTitle="Leave History"
routeKey={LEAVE_REQUEST_ROUTES.INDEX}
getClient={(api) => api.leaveRequests}
extraParams={{ employee_id: employeeId }}
header={null}
headerProps={({ invalidateQuery }) => ({
actions: (
<FormDialog title="New Leave Request">
{() => (
<LeaveRequestForm
presetEmployee={{ id: employeeId, label: employee?.label ?? "" }}
onSuccess={() => {
invalidateQuery()
balanceQuery.refetch()
}}
/>
)}
</FormDialog>
),
})}
columns={() => [
{
accessorKey: "leave_type",
header: ({ column }) => <ColumnHeader column={column} title="Type" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<CalendarIcon className="size-4 text-muted-foreground" />
<Badge variant="outline">{formatEnum((row.original as any).leave_type)}</Badge>
</div>
),
},
{
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>
},
},
{
accessorKey: "reason",
header: ({ column }) => <ColumnHeader column={column} title="Reason" />,
cell: ({ row }) => (row.original as any).reason ?? "—",
},
]}
/>
</div>
)
}
@@ -0,0 +1,121 @@
"use client"
import { use } from "react"
import { useQuery } from "@tanstack/react-query"
import { type ColumnDef } from "@tanstack/react-table"
import { useAuthApi } from "@/shared/useApi"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
import { Badge } from "@/shared/components/ui/badge"
import { formatEnum } from "@/shared/utils/formatters"
type Slip = {
id: number
payroll_run?: { id?: number; reference?: string; period_start?: string; period_end?: string; status?: string }
hours_worked: number
base_salary: number
commission_amount: number
allowances: number
deductions: number
net_pay: number
}
function formatDate(value: unknown) {
if (!value || typeof value !== "string") return "—"
const d = new Date(value)
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString()
}
function fmt(v: unknown) {
const n = Number(v)
if (!Number.isFinite(n)) return "—"
return n.toFixed(2)
}
function statusVariant(status: string | undefined): "default" | "secondary" | "outline" {
if (status === "paid") return "default"
if (status === "finalized") return "outline"
return "secondary"
}
export default function EmployeePayrollPage({ params }: { params: Promise<{ id: string }> }) {
const { id: employeeId } = use(params)
const api = useAuthApi()
const { data, isLoading } = useQuery({
queryKey: ["employee-payroll-slips", employeeId],
queryFn: () => api.employees.listPayrollSlips(employeeId) as Promise<{ data: Slip[] | { data: Slip[] } }>,
})
const rows: Slip[] = Array.isArray((data as any)?.data)
? (data as any).data
: ((data as any)?.data?.data ?? [])
const columns: ColumnDef<Slip>[] = [
{
accessorKey: "payroll_run",
header: ({ column }) => <ColumnHeader column={column} title="Run" />,
cell: ({ row }) => {
const r = row.original.payroll_run
return r?.reference ?? (r?.id ? `#${r.id}` : "—")
},
},
{
accessorKey: "period",
header: () => <span>Period</span>,
cell: ({ row }) => {
const r = row.original.payroll_run
if (!r) return "—"
return `${formatDate(r.period_start)} → ${formatDate(r.period_end)}`
},
},
{
accessorKey: "hours_worked",
header: ({ column }) => <ColumnHeader column={column} title="Hours" />,
cell: ({ row }) => fmt(row.original.hours_worked),
},
{
accessorKey: "base_salary",
header: ({ column }) => <ColumnHeader column={column} title="Base" />,
cell: ({ row }) => fmt(row.original.base_salary),
},
{
accessorKey: "commission_amount",
header: ({ column }) => <ColumnHeader column={column} title="Commission" />,
cell: ({ row }) => fmt(row.original.commission_amount),
},
{
accessorKey: "deductions",
header: ({ column }) => <ColumnHeader column={column} title="Deductions" />,
cell: ({ row }) => fmt(row.original.deductions),
},
{
accessorKey: "net_pay",
header: ({ column }) => <ColumnHeader column={column} title="Net Pay" />,
cell: ({ row }) => <span className="font-semibold">{fmt(row.original.net_pay)}</span>,
},
{
id: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const s = row.original.payroll_run?.status
if (!s) return "—"
return <Badge variant={statusVariant(s)}>{formatEnum(s)}</Badge>
},
},
]
return (
<DashboardPage headerProps={{ title: "Payroll Slips" }}>
<DataTable
columns={columns}
data={rows}
pagination={{ page: 1, pageSize: rows.length || 10, pageCount: 1, total: rows.length }}
sorting={[]}
onChange={() => {}}
isLoading={isLoading}
/>
</DashboardPage>
)
}
@@ -0,0 +1,121 @@
"use client"
import { useMemo, useState } from "react"
import { useParams } from "next/navigation"
import { useQuery } from "@tanstack/react-query"
import { ClockIcon, WrenchIcon, CheckCircle2Icon, TimerIcon, PercentIcon } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import { Button } from "@/shared/components/ui/button"
import { Field, FieldLabel } from "@/shared/components/ui/field"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
type PerfData = {
period: { from: string; to: string }
hours_worked: number
jobs_total: number
jobs_completed: number
avg_job_duration_hours: number
completion_rate: number
}
function StatCard({
icon: Icon,
label,
value,
suffix,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value: string | number
suffix?: string
}) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Icon className="size-4" />
{label}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">
{value}
{suffix && <span className="ml-1 text-base font-normal text-muted-foreground">{suffix}</span>}
</div>
</CardContent>
</Card>
)
}
function firstOfMonth() {
const d = new Date()
return new Date(d.getFullYear(), d.getMonth(), 1).toISOString().slice(0, 10)
}
function lastOfMonth() {
const d = new Date()
return new Date(d.getFullYear(), d.getMonth() + 1, 0).toISOString().slice(0, 10)
}
export default function EmployeePerformancePage() {
const { id: employeeId } = useParams<{ id: string }>()
const api = useAuthApi()
const [fromInput, setFromInput] = useState(firstOfMonth())
const [toInput, setToInput] = useState(lastOfMonth())
const [from, setFrom] = useState(fromInput)
const [to, setTo] = useState(toInput)
const { data, isLoading, isFetching } = useQuery({
queryKey: ["employee-performance", employeeId, from, to],
queryFn: () => api.employees.performance(employeeId, { from, to }) as Promise<{ data: PerfData }>,
})
const perf = useMemo(() => (data as any)?.data as PerfData | undefined, [data])
return (
<DashboardPage
headerProps={{
title: perf ? `Performance · ${perf.period.from} → ${perf.period.to}` : "Performance",
actions: (
<div className="flex items-end gap-2">
<Field>
<FieldLabel>From</FieldLabel>
<Input type="date" value={fromInput} onChange={(e) => setFromInput(e.target.value)} />
</Field>
<Field>
<FieldLabel>To</FieldLabel>
<Input type="date" value={toInput} onChange={(e) => setToInput(e.target.value)} />
</Field>
<Button
size="sm"
disabled={isFetching}
onClick={() => {
setFrom(fromInput)
setTo(toInput)
}}
>
{isFetching ? "Loading..." : "Apply"}
</Button>
</div>
),
}}
>
{isLoading && !perf ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : !perf ? (
<p className="text-sm text-muted-foreground">No data.</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<StatCard icon={ClockIcon} label="Hours Worked" value={perf.hours_worked.toFixed(2)} suffix="h" />
<StatCard icon={WrenchIcon} label="Jobs Assigned" value={perf.jobs_total} />
<StatCard icon={CheckCircle2Icon} label="Jobs Completed" value={perf.jobs_completed} />
<StatCard icon={TimerIcon} label="Avg Job Duration" value={perf.avg_job_duration_hours.toFixed(2)} suffix="h" />
<StatCard icon={PercentIcon} label="Completion Rate" value={perf.completion_rate} suffix="%" />
</div>
)}
</DashboardPage>
)
}