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 }) => [
|
||||
|
||||
@@ -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(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 }),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { LogInIcon, LogOutIcon, TimerIcon, SearchIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { Avatar, AvatarFallback } from "@/shared/components/ui/avatar"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
import { EMPLOYEE_ROUTES } from "@garage/api"
|
||||
|
||||
type Employee = {
|
||||
id: number
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
email?: string
|
||||
designation?: string
|
||||
department?: { id?: number; name?: string }
|
||||
track_attendance?: boolean
|
||||
has_active_time_sheet?: boolean
|
||||
active_time_sheet?: { id?: number; clock_in?: string; date?: string } | null
|
||||
status?: string
|
||||
}
|
||||
|
||||
function initialsOf(first?: string | null, last?: string | null) {
|
||||
const f = (first ?? "").trim()[0] ?? ""
|
||||
const l = (last ?? "").trim()[0] ?? ""
|
||||
return (f + l).toUpperCase() || "?"
|
||||
}
|
||||
|
||||
function formatTime(value: unknown) {
|
||||
if (!value || typeof value !== "string") return "—"
|
||||
return value.length >= 5 ? value.slice(0, 5) : value
|
||||
}
|
||||
|
||||
export default function TimeClocksPage() {
|
||||
const api = useAuthApi()
|
||||
const queryClient = useQueryClient()
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const employeesQuery = useQuery({
|
||||
queryKey: [EMPLOYEE_ROUTES.INDEX, "time-clocks", { per_page: 100, status: "active" }],
|
||||
queryFn: () => api.employees.list({ per_page: 100, status: "active" } as any) as Promise<{ data: Employee[] }>,
|
||||
})
|
||||
|
||||
const employees: Employee[] = useMemo(() => {
|
||||
const rows: Employee[] = (employeesQuery.data as any)?.data ?? []
|
||||
if (!search) return rows
|
||||
const q = search.toLowerCase()
|
||||
return rows.filter((e) => {
|
||||
const name = `${e.first_name ?? ""} ${e.last_name ?? ""}`.toLowerCase()
|
||||
return name.includes(q) || (e.email ?? "").toLowerCase().includes(q)
|
||||
})
|
||||
}, [employeesQuery.data, search])
|
||||
|
||||
const refetch = () => queryClient.invalidateQueries({ queryKey: [EMPLOYEE_ROUTES.INDEX, "time-clocks", { per_page: 100, status: "active" }] })
|
||||
|
||||
const clockInMutation = useMutation({
|
||||
mutationFn: (employeeId: number) => api.timeSheets.clockIn({ employee_id: employeeId } as any),
|
||||
onSuccess: () => {
|
||||
toast.success("Clocked in")
|
||||
refetch()
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.payload?.message ?? err?.message ?? "Failed to clock in"),
|
||||
})
|
||||
|
||||
const clockOutMutation = useMutation({
|
||||
mutationFn: (timeSheetId: number) => api.timeSheets.clockOut({ time_sheet_id: timeSheetId } as any),
|
||||
onSuccess: () => {
|
||||
toast.success("Clocked out")
|
||||
refetch()
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.payload?.message ?? err?.message ?? "Failed to clock out"),
|
||||
})
|
||||
|
||||
return (
|
||||
<DashboardPage
|
||||
headerProps={{
|
||||
title: "Time Clocks",
|
||||
actions: (
|
||||
<div className="relative w-64">
|
||||
<SearchIcon className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search employees..."
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{employeesQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : employees.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No employees found.</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{employees.map((emp) => {
|
||||
const fullName = `${emp.first_name ?? ""} ${emp.last_name ?? ""}`.trim() || emp.email
|
||||
const isClockedIn = !!emp.has_active_time_sheet
|
||||
const activeId = emp.active_time_sheet?.id
|
||||
const clockedInAt = emp.active_time_sheet?.clock_in
|
||||
|
||||
return (
|
||||
<Card key={emp.id}>
|
||||
<CardContent className="flex items-center justify-between gap-3 p-4">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Avatar size="lg">
|
||||
<AvatarFallback>{initialsOf(emp.first_name, emp.last_name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{fullName}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{emp.designation ?? emp.department?.name ?? emp.email}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{isClockedIn ? (
|
||||
<Badge variant="default" className="gap-1">
|
||||
<TimerIcon className="size-3" />
|
||||
Since {formatTime(clockedInAt)}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Idle</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isClockedIn && activeId ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={clockOutMutation.isPending}
|
||||
onClick={() => clockOutMutation.mutate(activeId)}
|
||||
>
|
||||
<LogOutIcon className="size-4" />
|
||||
Clock Out
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={clockInMutation.isPending}
|
||||
onClick={() => clockInMutation.mutate(emp.id)}
|
||||
>
|
||||
<LogInIcon className="size-4" />
|
||||
Clock In
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client"
|
||||
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { TIME_SHEET_ROUTES } from "@garage/api"
|
||||
import type { TimeSheetsClient } from "@garage/api"
|
||||
import { ClockIcon } from "lucide-react"
|
||||
import { formatEnum } from "@/shared/utils/formatters"
|
||||
|
||||
function formatDate(value: unknown) {
|
||||
if (!value || typeof value !== "string") return "—"
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString()
|
||||
}
|
||||
|
||||
function formatTime(value: unknown) {
|
||||
if (!value || typeof value !== "string") return "—"
|
||||
return value.length >= 5 ? value.slice(0, 5) : value
|
||||
}
|
||||
|
||||
const ACTIVITY_VARIANT: Record<string, "default" | "secondary" | "outline"> = {
|
||||
general: "secondary",
|
||||
order: "default",
|
||||
task: "outline",
|
||||
}
|
||||
|
||||
export default function TimeSheetsPage() {
|
||||
return (
|
||||
<ResourcePage<TimeSheetsClient>
|
||||
pageTitle="Time Sheets"
|
||||
routeKey={TIME_SHEET_ROUTES.INDEX}
|
||||
getClient={(api) => api.timeSheets}
|
||||
statusFilter={{
|
||||
statuses: ["general", "order", "task"],
|
||||
paramKey: "activity_type",
|
||||
allLabel: "All",
|
||||
}}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<ClockIcon className="size-4 text-muted-foreground" />
|
||||
<span>{formatDate((row.original as any).date)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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: "clock_in",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Clock In" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{formatTime((row.original as any).clock_in)}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "clock_out",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Clock Out" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{formatTime((row.original as any).clock_out)}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "duration",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Duration" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{formatTime((row.original as any).duration)}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "activity_type",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Activity" />,
|
||||
cell: ({ row }) => {
|
||||
const t = (row.original as any).activity_type ?? "general"
|
||||
return <Badge variant={ACTIVITY_VARIANT[t] ?? "secondary"}>{formatEnum(t)}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "calculated_total_cost",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Cost" />,
|
||||
cell: ({ row }) => {
|
||||
const v = (row.original as any).calculated_total_cost
|
||||
if (v == null) return "—"
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n.toFixed(2) : "—"
|
||||
},
|
||||
},
|
||||
actionsColumn({ onEdit: undefined }),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user