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:
+179
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,10 @@ import { useMemo, 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 { ImportDataButton } from "@/shared/components/import-data-button"
|
||||
import { ExportDataButton } from "@/shared/components/export-data-button"
|
||||
import { EmployeeForm } from "@/modules/employees/employee-form"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { EMPLOYEE_ROUTES } from "@garage/api"
|
||||
import type { EmployeesClient } from "@garage/api"
|
||||
import { Avatar, AvatarFallback } from "@/shared/components/ui/avatar"
|
||||
@@ -33,6 +36,7 @@ function initialsOf(first?: string | null, last?: string | null) {
|
||||
|
||||
export default function EmployeesPage() {
|
||||
const router = useRouter()
|
||||
const api = useAuthApi()
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all")
|
||||
|
||||
const extraParams = useMemo(() => {
|
||||
@@ -68,15 +72,28 @@ export default function EmployeesPage() {
|
||||
)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Employee">
|
||||
{(resourceId) => (
|
||||
<EmployeeForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem}
|
||||
onSuccess={invalidateQuery}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
<div className="flex items-center gap-2">
|
||||
<ImportDataButton
|
||||
onImport={(file) => api.employees.importData(file)}
|
||||
onSuccess={invalidateQuery}
|
||||
entityLabel="Employees"
|
||||
onDownloadSample={() => api.employees.downloadImportSample() as Promise<Blob>}
|
||||
sampleFileName="employees-import-sample"
|
||||
/>
|
||||
<ExportDataButton
|
||||
onExport={(filters) => api.employees.exportData(filters)}
|
||||
fileName="employees"
|
||||
/>
|
||||
<FormDialog title="Employee">
|
||||
{(resourceId) => (
|
||||
<EmployeeForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem}
|
||||
onSuccess={invalidateQuery}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
</div>
|
||||
),
|
||||
})}
|
||||
columns={({ actionsColumn }) => [
|
||||
|
||||
Reference in New Issue
Block a user