develop full initial module

This commit is contained in:
Dhananjaya99
2026-07-23 19:54:56 +05:30
parent eacc21afad
commit 755df494fe
188 changed files with 13894 additions and 49 deletions
@@ -0,0 +1,194 @@
"use client"
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import { attendanceApi } from "@/lib/api/attendance"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { AttendanceRecord, AttendanceUploadBatch } from "@/types/hrm"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const ROW_STATUS_STYLE: Record<AttendanceRecord["rowValidationStatus"], string> = {
Valid: "bg-success/10 text-success",
DuplicateWithinBatch: "bg-warning/10 text-warning",
DuplicateConfirmed: "bg-warning/10 text-warning",
EmployeeNotFound: "bg-destructive/10 text-destructive",
InvalidDateTime: "bg-destructive/10 text-destructive",
Error: "bg-destructive/10 text-destructive",
}
function minutesLabel(m: number): string {
if (m <= 0) return "0"
const h = Math.floor(m / 60)
const mm = m % 60
return h > 0 ? `${h}h ${mm}m` : `${mm}m`
}
export default function AttendanceBatchDetailPage() {
const params = useParams<{ id: string }>()
const batchId = Number(params.id)
const [batch, setBatch] = useState<AttendanceUploadBatch | null>(null)
const [records, setRecords] = useState<AttendanceRecord[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [unlockOpen, setUnlockOpen] = useState(false)
const [unlockReason, setUnlockReason] = useState("")
function load() {
Promise.all([attendanceApi.get(batchId), attendanceApi.listRecords(batchId)])
.then(([b, r]) => { setBatch(b); setRecords(r) })
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [batchId])
const locked = batch?.status === "Confirmed" || batch?.status === "UsedInPayroll"
async function resolve(recordId: number, action: "keep" | "discard" | "supersede") {
try {
await attendanceApi.resolveDuplicate(batchId, recordId, action)
toast.success("Resolved")
load()
} catch (err) {
toast.error("Could not resolve", errorMessage(err))
}
}
async function validate() {
setBusy(true)
try {
const b = await attendanceApi.validate(batchId)
setBatch(b)
toast.success("Batch validated")
} catch (err) {
toast.error("Could not validate", errorMessage(err))
} finally {
setBusy(false)
}
}
async function confirm() {
setBusy(true)
try {
const b = await attendanceApi.confirm(batchId)
setBatch(b)
toast.success("Batch confirmed", "This is now the source of truth for payroll.")
} catch (err) {
toast.error("Could not confirm", errorMessage(err))
} finally {
setBusy(false)
}
}
async function unlock() {
if (!unlockReason.trim()) { toast.error("A reason is required"); return }
setBusy(true)
try {
const b = await attendanceApi.unlock(batchId, unlockReason.trim())
setBatch(b)
setUnlockOpen(false)
setUnlockReason("")
toast.success("Batch unlocked")
} catch (err) {
toast.error("Could not unlock", errorMessage(err))
} finally {
setBusy(false)
}
}
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
if (!batch || !records) return <div className="flex flex-col gap-3">{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>
const unresolved = records.filter((r) => r.rowValidationStatus !== "Valid")
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">{batch.docNo}</h1>
<p className="text-base text-muted-foreground">{new Date(batch.periodStart).toLocaleDateString()} {new Date(batch.periodEnd).toLocaleDateString()} · {batch.rowCountTotal} rows</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className="h-7 px-3 text-sm">{batch.status}</Badge>
{batch.status === "Draft" && <Button onClick={validate} disabled={busy || unresolved.length > 0}>Validate</Button>}
{batch.status === "Validated" && <Button onClick={confirm} disabled={busy}>Confirm</Button>}
{batch.status === "Confirmed" && (
<Dialog open={unlockOpen} onOpenChange={setUnlockOpen}>
<DialogTrigger render={<Button variant="outline">Unlock</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>Unlock batch</DialogTitle>
<DialogDescription>Requires a reason and reverts to Validated for editing.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field><FieldLabel htmlFor="u-reason">Reason</FieldLabel><Input id="u-reason" value={unlockReason} onChange={(e) => setUnlockReason(e.target.value)} /></Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setUnlockOpen(false)}>Cancel</Button>
<Button className="min-w-32" onClick={unlock} disabled={busy}>Unlock</Button>
</div>
</DialogContent>
</Dialog>
)}
</div>
</div>
{unresolved.length > 0 && batch.status === "Draft" && (
<div className="rounded-lg border border-warning/30 bg-warning/5 p-4 text-sm text-warning">
{unresolved.length} record(s) have unresolved errors or duplicates resolve them before validating.
</div>
)}
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Employee</TableHead>
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
<TableHead className="h-12 px-3 text-sm">In / Out</TableHead>
<TableHead className="h-12 px-3 text-sm">Working Hours</TableHead>
<TableHead className="h-12 px-3 text-sm">Late</TableHead>
<TableHead className="h-12 px-3 text-sm">OT</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Row</TableHead>
{!locked && <TableHead className="h-12 px-3 text-sm">Resolve</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.attendanceRecordId}>
<TableCell className="px-3 py-3.5">{r.employeeName ?? r.employeeCode ?? "Unknown"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.attendanceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{minutesLabel(r.workingMinutes)}</TableCell>
<TableCell className="px-3 py-3.5">{r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"}</TableCell>
<TableCell className="px-3 py-3.5">{r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"}</TableCell>
<TableCell className="px-3 py-3.5">{r.attendanceStatus}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", ROW_STATUS_STYLE[r.rowValidationStatus])}>{r.rowValidationStatus}</Badge>
</TableCell>
{!locked && (
<TableCell className="px-3 py-3.5">
{r.rowValidationStatus !== "Valid" && r.rowValidationStatus !== "EmployeeNotFound" && (
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "keep")}>Keep</Button>
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "discard")}>Discard</Button>
</div>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
@@ -0,0 +1,152 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { Download, Eye, Plus, Upload } from "lucide-react"
import { attendanceApi, attendanceTemplateUrl } from "@/lib/api/attendance"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { AttendanceUploadBatch } from "@/types/hrm"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10
const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
Draft: "bg-muted text-muted-foreground",
Validated: "bg-warning/10 text-warning",
Confirmed: "bg-success/10 text-success",
UsedInPayroll: "bg-primary/10 text-primary",
}
export default function AttendanceBatchesPage() {
const [batches, setBatches] = useState<AttendanceUploadBatch[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [open, setOpen] = useState(false)
const [periodStart, setPeriodStart] = useState("")
const [periodEnd, setPeriodEnd] = useState("")
const [file, setFile] = useState<File | null>(null)
const [submitting, setSubmitting] = useState(false)
function load() {
attendanceApi
.list({ page, pageSize: PAGE_SIZE })
.then((res) => { setBatches(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page])
async function handleUpload() {
if (!file) { toast.error("Choose an Excel or CSV file first"); return }
if (!periodStart || !periodEnd) { toast.error("Period start/end are required"); return }
setSubmitting(true)
try {
await attendanceApi.upload(file, periodStart, periodEnd)
toast.success("Attendance uploaded", "Review the preview and confirm when ready.")
setOpen(false)
setFile(null)
setPeriodStart("")
setPeriodEnd("")
load()
} catch (err) {
toast.error("Could not upload attendance", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Attendance</h1>
<p className="text-base text-muted-foreground">Upload Preview Confirm. Once Confirmed, a batch becomes payroll&apos;s source of truth.</p>
</div>
<div className="flex gap-2">
<a href={attendanceTemplateUrl()} className={cn(buttonVariants({ variant: "outline" }))}>
<Download className="size-4" />Download template
</a>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />Upload</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>Upload Attendance</DialogTitle>
<DialogDescription>Columns: Employee Code, Date, Check In, Check Out.</DialogDescription>
</DialogHeader>
<FieldGroup>
<div className="grid grid-cols-2 gap-3">
<Field><FieldLabel htmlFor="a-start">Period start</FieldLabel><Input id="a-start" type="date" value={periodStart} onChange={(e) => setPeriodStart(e.target.value)} /></Field>
<Field><FieldLabel htmlFor="a-end">Period end</FieldLabel><Input id="a-end" type="date" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} /></Field>
</div>
<Field>
<FieldLabel htmlFor="a-file">File (.xlsx or .csv)</FieldLabel>
<Input id="a-file" type="file" accept=".xlsx,.csv" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleUpload} disabled={submitting}><Upload className="size-4" />{submitting ? "Uploading…" : "Upload"}</Button>
</div>
</DialogContent>
</Dialog>
</div>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && batches === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
{!error && batches !== null && batches.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No attendance batches yet.</p></div>
)}
{!error && batches !== null && batches.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Period</TableHead>
<TableHead className="h-12 px-3 text-sm">Rows</TableHead>
<TableHead className="h-12 px-3 text-sm">Duplicates/Errors</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{batches.map((b) => (
<TableRow key={b.attendanceUploadBatchId}>
<TableCell className="px-3 py-3.5 font-medium">{b.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(b.periodStart).toLocaleDateString()} {new Date(b.periodEnd).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{b.rowCountTotal}</TableCell>
<TableCell className="px-3 py-3.5">{b.rowCountDuplicate} / {b.rowCountError}</TableCell>
<TableCell className="px-3 py-3.5"><Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[b.status])}>{b.status}</Badge></TableCell>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label="View"><Eye className="size-4" /></Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,485 @@
"use client"
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import { Link2, Plus, Upload } from "lucide-react"
import { CreateEmployeeLoanRequest, CreateSalaryStructureRequest, employeesApi } from "@/lib/api/employees"
import { departmentsApi, designationsApi, employmentTypesApi, hrDocumentTypesApi, salaryComponentsApi, workShiftsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import {
Department,
Designation,
EmployeeBankDetail,
EmployeeDetail,
EmployeeDocument,
EmployeeLoan,
EmployeeSalaryStructure,
EmploymentType,
HrDocumentType,
LoanKind,
SalaryComponent,
WorkShift,
} from "@/types/hrm"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const TABS = ["Overview", "Bank Details", "Documents", "Salary & Loans"] as const
type Tab = (typeof TABS)[number]
function money(n: number): string {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export default function EmployeeDetailPage() {
const params = useParams<{ id: string }>()
const employeeId = Number(params.id)
const [tab, setTab] = useState<Tab>("Overview")
const [employee, setEmployee] = useState<EmployeeDetail | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [departments, setDepartments] = useState<Department[]>([])
const [designations, setDesignations] = useState<Designation[]>([])
const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([])
const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
const [saving, setSaving] = useState(false)
const [bankDetails, setBankDetails] = useState<EmployeeBankDetail[]>([])
const [documents, setDocuments] = useState<EmployeeDocument[]>([])
const [documentTypes, setDocumentTypes] = useState<HrDocumentType[]>([])
const [uploadTypeId, setUploadTypeId] = useState<string>("")
const [salaryStructures, setSalaryStructures] = useState<EmployeeSalaryStructure[]>([])
const [salaryComponents, setSalaryComponents] = useState<SalaryComponent[]>([])
const [loans, setLoans] = useState<EmployeeLoan[]>([])
const [structureOpen, setStructureOpen] = useState(false)
const [structureEffectiveFrom, setStructureEffectiveFrom] = useState("")
const [structureBasic, setStructureBasic] = useState(0)
const [structureLines, setStructureLines] = useState<{ salaryComponentId: string; amount: number }[]>([])
const [loanOpen, setLoanOpen] = useState(false)
const [loanKind, setLoanKind] = useState<LoanKind>("Loan")
const [loanPrincipal, setLoanPrincipal] = useState(0)
const [loanInstallmentAmount, setLoanInstallmentAmount] = useState(0)
const [loanCount, setLoanCount] = useState(1)
const now = new Date()
const [loanStartYear, setLoanStartYear] = useState(now.getFullYear())
const [loanStartMonth, setLoanStartMonth] = useState(now.getMonth() + 1)
const [busy, setBusy] = useState(false)
function load() {
employeesApi.get(employeeId)
.then((res) => { setEmployee(res.data); setEtag(res.etag) })
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [employeeId])
useEffect(() => {
departmentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDepartments(r.items)).catch(() => setDepartments([]))
designationsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDesignations(r.items)).catch(() => setDesignations([]))
employmentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmploymentTypes(r.items)).catch(() => setEmploymentTypes([]))
workShiftsApi.list({ pageSize: 200, status: "Active" }).then((r) => setWorkShifts(r.items)).catch(() => setWorkShifts([]))
hrDocumentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setDocumentTypes(r.items)).catch(() => setDocumentTypes([]))
}, [])
useEffect(() => {
if (tab === "Bank Details") employeesApi.listBankDetails(employeeId).then(setBankDetails).catch((err) => toast.error("Could not load bank details", errorMessage(err)))
if (tab === "Documents") employeesApi.listDocuments(employeeId).then(setDocuments).catch((err) => toast.error("Could not load documents", errorMessage(err)))
if (tab === "Salary & Loans") {
employeesApi.salaryStructureHistory(employeeId).then(setSalaryStructures).catch((err) => toast.error("Could not load salary history", errorMessage(err)))
employeesApi.listLoans(employeeId).then(setLoans).catch((err) => toast.error("Could not load loans", errorMessage(err)))
salaryComponentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setSalaryComponents(r.items)).catch(() => setSalaryComponents([]))
}
}, [tab, employeeId])
function addStructureLine() {
setStructureLines((prev) => [...prev, { salaryComponentId: "", amount: 0 }])
}
async function createStructure() {
if (!structureEffectiveFrom) { toast.error("Effective date is required"); return }
const lines = structureLines.filter((l) => l.salaryComponentId).map((l) => ({ salaryComponentId: Number(l.salaryComponentId), amount: l.amount }))
const request: CreateSalaryStructureRequest = { effectiveFrom: structureEffectiveFrom, basicSalary: structureBasic, lines }
setBusy(true)
try {
await employeesApi.createSalaryStructure(employeeId, request)
toast.success("Salary structure saved")
setStructureOpen(false)
setStructureEffectiveFrom("")
setStructureBasic(0)
setStructureLines([])
employeesApi.salaryStructureHistory(employeeId).then(setSalaryStructures)
} catch (err) {
toast.error("Could not save salary structure", errorMessage(err))
} finally {
setBusy(false)
}
}
async function createLoan() {
if (loanPrincipal <= 0 || loanInstallmentAmount <= 0 || loanCount <= 0) { toast.error("Principal, installment amount, and count must be positive"); return }
const request: CreateEmployeeLoanRequest = {
loanKind, principalAmount: loanPrincipal, interestRate: 0, installmentAmount: loanInstallmentAmount,
numberOfInstallments: loanCount, startYear: loanStartYear, startMonth: loanStartMonth,
}
setBusy(true)
try {
await employeesApi.createLoan(employeeId, request)
toast.success("Loan created")
setLoanOpen(false)
setLoanPrincipal(0)
setLoanInstallmentAmount(0)
setLoanCount(1)
employeesApi.listLoans(employeeId).then(setLoans)
} catch (err) {
toast.error("Could not create loan", errorMessage(err))
} finally {
setBusy(false)
}
}
async function handleSave() {
if (!employee || !etag) return
setSaving(true)
try {
const res = await employeesApi.update(employeeId, {
fullName: employee.fullName,
nic: employee.nic,
dateOfBirth: employee.dateOfBirth,
gender: employee.gender,
nationality: employee.nationality,
email: employee.email,
personalMobile: employee.personalMobile,
addressLine1: employee.addressLine1,
addressLine2: employee.addressLine2,
city: employee.city,
postalCode: employee.postalCode,
country: employee.country,
emergencyContactName: employee.emergencyContactName,
emergencyContactRelationship: employee.emergencyContactRelationship,
emergencyContactPhone: employee.emergencyContactPhone,
confirmationDate: employee.confirmationDate,
lastWorkingDate: employee.lastWorkingDate,
departmentId: employee.departmentId,
designationId: employee.designationId,
employmentTypeId: employee.employmentTypeId,
branchId: employee.branchId,
workShiftId: employee.workShiftId,
reportingManagerId: employee.reportingManagerId,
epfNumber: employee.epfNumber,
etfNumber: employee.etfNumber,
taxIdentificationNumber: employee.taxIdentificationNumber,
}, etag)
setEmployee(res.data)
setEtag(res.etag)
toast.success("Employee updated")
} catch (err) {
toast.error("Could not save", errorMessage(err))
} finally {
setSaving(false)
}
}
async function handleUpload(file: File) {
if (!uploadTypeId) { toast.error("Select a document type first"); return }
try {
const doc = await employeesApi.uploadDocument(employeeId, file, { hrDocumentTypeId: Number(uploadTypeId) })
setDocuments((prev) => [doc, ...prev])
toast.success("Document uploaded")
} catch (err) {
toast.error("Could not upload document", errorMessage(err))
}
}
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
if (!employee) return <div className="flex flex-col gap-3">{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">{employee.fullName}</h1>
<p className="text-base text-muted-foreground">
{employee.employeeCode}
{employee.userId ? (
<span className="ml-2 inline-flex items-center gap-1 text-primary"><Link2 className="size-3.5" />Linked to a system user</span>
) : (
<span className="ml-2 text-muted-foreground">No system login</span>
)}
</p>
</div>
<Badge variant="outline" className={cn("h-7 px-3 text-sm", employee.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{employee.status}</Badge>
</div>
<div className="flex gap-1 rounded-xl bg-muted p-1 w-fit">
{TABS.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn("rounded-lg px-4 py-2 text-sm font-medium transition-colors", tab === t ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground")}
>
{t}
</button>
))}
</div>
{tab === "Overview" && (
<FieldGroup className="max-w-2xl">
<div className="grid grid-cols-2 gap-3">
<Field><FieldLabel>Full name</FieldLabel><Input value={employee.fullName} onChange={(e) => setEmployee({ ...employee, fullName: e.target.value })} /></Field>
<Field><FieldLabel>Email</FieldLabel><Input type="email" value={employee.email ?? ""} onChange={(e) => setEmployee({ ...employee, email: e.target.value })} /></Field>
<Field><FieldLabel>NIC</FieldLabel><Input value={employee.nic ?? ""} onChange={(e) => setEmployee({ ...employee, nic: e.target.value })} /></Field>
<Field><FieldLabel>Personal mobile</FieldLabel><Input value={employee.personalMobile ?? ""} onChange={(e) => setEmployee({ ...employee, personalMobile: e.target.value })} /></Field>
<Field>
<FieldLabel>Department</FieldLabel>
<Select value={String(employee.departmentId)} onValueChange={(v) => setEmployee({ ...employee, departmentId: Number(v) })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Designation</FieldLabel>
<Select value={String(employee.designationId)} onValueChange={(v) => setEmployee({ ...employee, designationId: Number(v) })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Employment type</FieldLabel>
<Select value={String(employee.employmentTypeId)} onValueChange={(v) => setEmployee({ ...employee, employmentTypeId: Number(v) })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Work shift</FieldLabel>
<Select value={String(employee.workShiftId)} onValueChange={(v) => setEmployee({ ...employee, workShiftId: Number(v) })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
<Field><FieldLabel>EPF number</FieldLabel><Input value={employee.epfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, epfNumber: e.target.value })} /></Field>
<Field><FieldLabel>ETF number</FieldLabel><Input value={employee.etfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, etfNumber: e.target.value })} /></Field>
</div>
<div className="flex justify-end pt-2">
<Button onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button>
</div>
</FieldGroup>
)}
{tab === "Bank Details" && (
<div className="flex flex-col gap-4">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Bank</TableHead>
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
<TableHead className="h-12 px-3 text-sm">Account</TableHead>
<TableHead className="h-12 px-3 text-sm">Primary</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bankDetails.map((b, i) => (
<TableRow key={b.employeeBankDetailId ?? i}>
<TableCell className="px-3 py-3.5">{b.bankName}</TableCell>
<TableCell className="px-3 py-3.5">{b.branchName}</TableCell>
<TableCell className="px-3 py-3.5">{b.accountNumber}</TableCell>
<TableCell className="px-3 py-3.5">{b.isPrimary ? "Yes" : "—"}</TableCell>
</TableRow>
))}
{bankDetails.length === 0 && (
<TableRow><TableCell colSpan={4} className="px-3 py-8 text-center text-muted-foreground">No bank details on file.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
)}
{tab === "Documents" && (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Select value={uploadTypeId} onValueChange={(v) => setUploadTypeId(v ?? "")}>
<SelectTrigger className="w-56"><SelectValue placeholder="Document type" /></SelectTrigger>
<SelectContent>{documentTypes.map((t) => <SelectItem key={t.hrDocumentTypeId} value={String(t.hrDocumentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
</Select>
<label className={cn("inline-flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted")}>
<Upload className="size-4" />
Upload
<input type="file" className="hidden" accept=".pdf,.jpg,.jpeg,.png,.docx" onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f); e.target.value = "" }} />
</label>
</div>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">File</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Uploaded</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Download</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{documents.map((d) => (
<TableRow key={d.employeeDocumentId}>
<TableCell className="px-3 py-3.5">{d.originalFileName}</TableCell>
<TableCell className="px-3 py-3.5">{d.hrDocumentTypeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{d.status}</TableCell>
<TableCell className="px-3 py-3.5">
<a href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)} className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer">Download</a>
</TableCell>
</TableRow>
))}
{documents.length === 0 && (
<TableRow><TableCell colSpan={5} className="px-3 py-8 text-center text-muted-foreground">No documents uploaded yet.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
)}
{tab === "Salary & Loans" && (
<div className="flex flex-col gap-8">
<section className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">Salary Structure</h2>
<Dialog open={structureOpen} onOpenChange={(v) => { setStructureOpen(v); if (v && structureLines.length === 0) addStructureLine() }}>
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Structure</Button>} />
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New Salary Structure</DialogTitle>
<DialogDescription>Supersedes the current open-ended structure from this date.</DialogDescription>
</DialogHeader>
<FieldGroup>
<div className="grid grid-cols-2 gap-3">
<Field><FieldLabel>Effective from</FieldLabel><Input type="date" value={structureEffectiveFrom} onChange={(e) => setStructureEffectiveFrom(e.target.value)} /></Field>
<Field><FieldLabel>Basic salary</FieldLabel><Input type="number" min={0} value={structureBasic} onChange={(e) => setStructureBasic(Number(e.target.value))} /></Field>
</div>
<Field>
<FieldLabel>Allowances / other deductions</FieldLabel>
<div className="flex flex-col gap-2">
{structureLines.map((line, i) => (
<div key={i} className="flex gap-2">
<Select value={line.salaryComponentId} onValueChange={(v) => setStructureLines((prev) => prev.map((l, idx) => (idx === i ? { ...l, salaryComponentId: v ?? "" } : l)))}>
<SelectTrigger className="flex-1"><SelectValue placeholder="Component" /></SelectTrigger>
<SelectContent>{salaryComponents.map((c) => <SelectItem key={c.salaryComponentId} value={String(c.salaryComponentId)}>{c.name} ({c.componentType})</SelectItem>)}</SelectContent>
</Select>
<Input type="number" className="w-28" value={line.amount} onChange={(e) => setStructureLines((prev) => prev.map((l, idx) => (idx === i ? { ...l, amount: Number(e.target.value) } : l)))} />
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={addStructureLine}>Add line</Button>
</div>
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setStructureOpen(false)} disabled={busy}>Cancel</Button>
<Button className="min-w-32" onClick={createStructure} disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Effective from</TableHead>
<TableHead className="h-12 px-3 text-sm">Effective to</TableHead>
<TableHead className="h-12 px-3 text-sm">Basic</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{salaryStructures.map((s) => (
<TableRow key={s.employeeSalaryStructureId}>
<TableCell className="px-3 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</TableCell>
<TableCell className="px-3 py-3.5">{money(s.basicSalary)}</TableCell>
<TableCell className="px-3 py-3.5">{s.status}</TableCell>
</TableRow>
))}
{salaryStructures.length === 0 && (
<TableRow><TableCell colSpan={4} className="px-3 py-8 text-center text-muted-foreground">No salary structure set yet.</TableCell></TableRow>
)}
</TableBody>
</Table>
</section>
<section className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">Loans &amp; Advances</h2>
<Dialog open={loanOpen} onOpenChange={setLoanOpen}>
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Loan</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New Loan / Advance</DialogTitle>
<DialogDescription>Generates the full installment schedule up front.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel>Type</FieldLabel>
<Select value={loanKind} onValueChange={(v) => setLoanKind((v as LoanKind) ?? "Loan")}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="Loan">Loan</SelectItem><SelectItem value="Advance">Advance</SelectItem></SelectContent>
</Select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field><FieldLabel>Principal</FieldLabel><Input type="number" min={0} value={loanPrincipal} onChange={(e) => setLoanPrincipal(Number(e.target.value))} /></Field>
<Field><FieldLabel>Installment amount</FieldLabel><Input type="number" min={0} value={loanInstallmentAmount} onChange={(e) => setLoanInstallmentAmount(Number(e.target.value))} /></Field>
<Field><FieldLabel># installments</FieldLabel><Input type="number" min={1} value={loanCount} onChange={(e) => setLoanCount(Number(e.target.value))} /></Field>
<Field><FieldLabel>Start year/month</FieldLabel>
<div className="flex gap-2">
<Input type="number" value={loanStartYear} onChange={(e) => setLoanStartYear(Number(e.target.value))} />
<Input type="number" min={1} max={12} value={loanStartMonth} onChange={(e) => setLoanStartMonth(Number(e.target.value))} />
</div>
</Field>
</div>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setLoanOpen(false)} disabled={busy}>Cancel</Button>
<Button className="min-w-32" onClick={createLoan} disabled={busy}>{busy ? "Creating…" : "Create"}</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Principal</TableHead>
<TableHead className="h-12 px-3 text-sm">Outstanding</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loans.map((l) => (
<TableRow key={l.employeeLoanId}>
<TableCell className="px-3 py-3.5 font-medium">{l.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{l.loanKind}</TableCell>
<TableCell className="px-3 py-3.5">{money(l.principalAmount)}</TableCell>
<TableCell className="px-3 py-3.5">{money(l.outstandingBalance)}</TableCell>
<TableCell className="px-3 py-3.5">{l.status}</TableCell>
</TableRow>
))}
{loans.length === 0 && (
<TableRow><TableCell colSpan={5} className="px-3 py-8 text-center text-muted-foreground">No loans or advances on file.</TableCell></TableRow>
)}
</TableBody>
</Table>
</section>
</div>
)}
</div>
)
}
@@ -0,0 +1,284 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { Link2, Pencil, Plus, Users as UsersIcon } from "lucide-react"
import { employeesApi } from "@/lib/api/employees"
import { departmentsApi, designationsApi, employmentTypesApi, workShiftsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { CreateEmployeeRequest, Department, Designation, EmployeeListItem, EmploymentType, UserMatch, WorkShift } from "@/types/hrm"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10
const emptyForm: CreateEmployeeRequest = {
employeeCode: "",
fullName: "",
email: "",
hireDate: new Date().toISOString().slice(0, 10),
departmentId: 0,
designationId: 0,
employmentTypeId: 0,
workShiftId: 0,
}
export default function EmployeesPage() {
const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [q, setQ] = useState("")
const [departments, setDepartments] = useState<Department[]>([])
const [designations, setDesignations] = useState<Designation[]>([])
const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([])
const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
const [open, setOpen] = useState(false)
const [form, setForm] = useState<CreateEmployeeRequest>(emptyForm)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Advisory cross-link suggestion: does a System User already exist with this email?
const [userMatch, setUserMatch] = useState<UserMatch | null>(null)
const [checkingEmail, setCheckingEmail] = useState(false)
function load() {
employeesApi
.list({ page, pageSize: PAGE_SIZE, q: q || undefined })
.then((res) => { setEmployees(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page, q])
useEffect(() => {
departmentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDepartments(r.items)).catch(() => setDepartments([]))
designationsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDesignations(r.items)).catch(() => setDesignations([]))
employmentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmploymentTypes(r.items)).catch(() => setEmploymentTypes([]))
workShiftsApi.list({ pageSize: 200, status: "Active" }).then((r) => setWorkShifts(r.items)).catch(() => setWorkShifts([]))
}, [])
async function checkEmail(email: string) {
if (!email.trim()) { setUserMatch(null); return }
setCheckingEmail(true)
try {
const { match } = await employeesApi.emailLookup(email.trim())
setUserMatch(match)
} catch {
setUserMatch(null)
} finally {
setCheckingEmail(false)
}
}
function resetForm() {
setForm(emptyForm)
setUserMatch(null)
setErrors({})
}
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!form.employeeCode.trim()) nextErrors.employeeCode = "Employee code is required"
if (!form.fullName.trim()) nextErrors.fullName = "Full name is required"
if (!form.hireDate) nextErrors.hireDate = "Hire date is required"
if (!form.departmentId) nextErrors.departmentId = "Department is required"
if (!form.designationId) nextErrors.designationId = "Designation is required"
if (!form.employmentTypeId) nextErrors.employmentTypeId = "Employment type is required"
if (!form.workShiftId) nextErrors.workShiftId = "Work shift is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
await employeesApi.create({
...form,
email: form.email || null,
linkUserId: userMatch ? userMatch.userId : null,
})
toast.success("Employee created")
setOpen(false)
resetForm()
load()
} catch (err) {
toast.error("Could not create employee", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Employees</h1>
<p className="text-base text-muted-foreground">Staff records separate from system login accounts (see the link chip below).</p>
</div>
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) resetForm() }}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Employee</Button>} />
<DialogContent className="sm:max-w-lg">
<DialogHeader className="items-center text-center">
<DialogTitle>New Employee</DialogTitle>
<DialogDescription>Not every employee needs a login a system user account is optional and separate.</DialogDescription>
</DialogHeader>
<FieldGroup>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={!!errors.employeeCode}>
<FieldLabel htmlFor="e-code">Employee code</FieldLabel>
<Input id="e-code" value={form.employeeCode} onChange={(e) => setForm((f) => ({ ...f, employeeCode: e.target.value }))} aria-invalid={!!errors.employeeCode} />
<FieldError errors={[errors.employeeCode ? { message: errors.employeeCode } : undefined]} />
</Field>
<Field data-invalid={!!errors.hireDate}>
<FieldLabel htmlFor="e-hire">Hire date</FieldLabel>
<Input id="e-hire" type="date" value={form.hireDate} onChange={(e) => setForm((f) => ({ ...f, hireDate: e.target.value }))} aria-invalid={!!errors.hireDate} />
<FieldError errors={[errors.hireDate ? { message: errors.hireDate } : undefined]} />
</Field>
</div>
<Field data-invalid={!!errors.fullName}>
<FieldLabel htmlFor="e-name">Full name</FieldLabel>
<Input id="e-name" value={form.fullName} onChange={(e) => setForm((f) => ({ ...f, fullName: e.target.value }))} aria-invalid={!!errors.fullName} />
<FieldError errors={[errors.fullName ? { message: errors.fullName } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="e-email">Email (optional)</FieldLabel>
<Input
id="e-email"
type="email"
value={form.email ?? ""}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
onBlur={(e) => checkEmail(e.target.value)}
/>
{checkingEmail && <p className="text-sm text-muted-foreground">Checking for an existing system user</p>}
{userMatch && (
<div className="flex items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm">
<Link2 className="size-4 shrink-0 text-primary" />
<span>
System user <strong>{userMatch.username}</strong> ({userMatch.displayName}) matches this email it will be linked to this employee.
</span>
</div>
)}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={!!errors.departmentId}>
<FieldLabel htmlFor="e-dept">Department</FieldLabel>
<Select value={form.departmentId ? String(form.departmentId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, departmentId: Number(v) }))}>
<SelectTrigger id="e-dept"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.departmentId ? { message: errors.departmentId } : undefined]} />
</Field>
<Field data-invalid={!!errors.designationId}>
<FieldLabel htmlFor="e-desig">Designation</FieldLabel>
<Select value={form.designationId ? String(form.designationId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, designationId: Number(v) }))}>
<SelectTrigger id="e-desig"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.designationId ? { message: errors.designationId } : undefined]} />
</Field>
<Field data-invalid={!!errors.employmentTypeId}>
<FieldLabel htmlFor="e-etype">Employment type</FieldLabel>
<Select value={form.employmentTypeId ? String(form.employmentTypeId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, employmentTypeId: Number(v) }))}>
<SelectTrigger id="e-etype"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.employmentTypeId ? { message: errors.employmentTypeId } : undefined]} />
</Field>
<Field data-invalid={!!errors.workShiftId}>
<FieldLabel htmlFor="e-shift">Work shift</FieldLabel>
<Select value={form.workShiftId ? String(form.workShiftId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, workShiftId: Number(v) }))}>
<SelectTrigger id="e-shift"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.workShiftId ? { message: errors.workShiftId } : undefined]} />
</Field>
</div>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Input placeholder="Search by name or employee code…" value={q} onChange={(e) => { setPage(1); setQ(e.target.value) }} className="max-w-sm" />
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && employees === null && (
<div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>
)}
{!error && employees !== null && employees.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<UsersIcon className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No employees yet.</p>
</div>
)}
{!error && employees !== null && employees.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Department</TableHead>
<TableHead className="h-12 px-3 text-sm">Designation</TableHead>
<TableHead className="h-12 px-3 text-sm">Login</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{employees.map((e) => (
<TableRow key={e.employeeId}>
<TableCell className="px-3 py-3.5 font-medium">{e.employeeCode}</TableCell>
<TableCell className="px-3 py-3.5">{e.fullName}</TableCell>
<TableCell className="px-3 py-3.5">{e.departmentName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{e.designationName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">
{e.hasUserLink ? (
<Badge variant="outline" className="h-6 w-fit justify-center gap-1 border-transparent bg-primary/10 px-2.5 text-sm text-primary"><Link2 className="size-3.5" />Linked</Badge>
) : (
<span className="text-sm text-muted-foreground">No login</span>
)}
</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", e.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{e.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Edit ${e.fullName}`}>
<Pencil className="size-4" />
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,198 @@
"use client"
import { useEffect, useState } from "react"
import { Plus } from "lucide-react"
import { employeesApi } from "@/lib/api/employees"
import { leaveTypesApi } from "@/lib/api/hrm-masters"
import { leaveRequestsApi } from "@/lib/api/leave"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { EmployeeListItem, LeaveRequest, LeaveRequestStatus, LeaveType } from "@/types/hrm"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10
const STATUS_STYLE: Record<LeaveRequestStatus, string> = {
Draft: "bg-muted text-muted-foreground",
Submitted: "bg-warning/10 text-warning",
Approved: "bg-success/10 text-success",
Rejected: "bg-destructive/10 text-destructive",
Cancelled: "bg-muted text-muted-foreground",
}
export default function LeaveRequestsPage() {
const [requests, setRequests] = useState<LeaveRequest[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [employees, setEmployees] = useState<EmployeeListItem[]>([])
const [leaveTypes, setLeaveTypes] = useState<LeaveType[]>([])
const [open, setOpen] = useState(false)
const [employeeId, setEmployeeId] = useState("")
const [leaveTypeId, setLeaveTypeId] = useState("")
const [startDate, setStartDate] = useState("")
const [endDate, setEndDate] = useState("")
const [reason, setReason] = useState("")
const [submitting, setSubmitting] = useState(false)
function load() {
leaveRequestsApi
.list({ page, pageSize: PAGE_SIZE })
.then((res) => { setRequests(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page])
useEffect(() => {
employeesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmployees(r.items)).catch(() => setEmployees([]))
leaveTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setLeaveTypes(r.items)).catch(() => setLeaveTypes([]))
}, [])
async function handleCreate() {
if (!employeeId || !leaveTypeId || !startDate || !endDate) { toast.error("All fields except reason are required"); return }
setSubmitting(true)
try {
const created = await leaveRequestsApi.create({ employeeId: Number(employeeId), leaveTypeId: Number(leaveTypeId), startDate, endDate, reason: reason || null })
await leaveRequestsApi.submit(created.leaveRequestId)
toast.success("Leave request submitted")
setOpen(false)
setEmployeeId(""); setLeaveTypeId(""); setStartDate(""); setEndDate(""); setReason("")
load()
} catch (err) {
toast.error("Could not submit leave request", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function approve(id: number) {
try {
await leaveRequestsApi.approve(id)
toast.success("Leave approved")
load()
} catch (err) {
toast.error("Could not approve", errorMessage(err))
}
}
async function reject(id: number) {
const reasonText = window.prompt("Reason for rejection:")
if (!reasonText) return
try {
await leaveRequestsApi.reject(id, reasonText)
toast.success("Leave rejected")
load()
} catch (err) {
toast.error("Could not reject", errorMessage(err))
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Leave</h1>
<p className="text-base text-muted-foreground">Approved leave feeds Attendance&apos;s OnLeave status and Payroll&apos;s No-Pay calculation.</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Request</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New Leave Request</DialogTitle>
<DialogDescription>Submitted immediately for approval.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel>Employee</FieldLabel>
<Select value={employeeId} onValueChange={(v) => setEmployeeId(v ?? "")}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{employees.map((e) => <SelectItem key={e.employeeId} value={String(e.employeeId)}>{e.fullName} ({e.employeeCode})</SelectItem>)}</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Leave type</FieldLabel>
<Select value={leaveTypeId} onValueChange={(v) => setLeaveTypeId(v ?? "")}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{leaveTypes.map((t) => <SelectItem key={t.leaveTypeId} value={String(t.leaveTypeId)}>{t.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field><FieldLabel>Start date</FieldLabel><Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} /></Field>
<Field><FieldLabel>End date</FieldLabel><Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} /></Field>
</div>
<Field><FieldLabel>Reason (optional)</FieldLabel><Input value={reason} onChange={(e) => setReason(e.target.value)} /></Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Submitting…" : "Submit"}</Button>
</div>
</DialogContent>
</Dialog>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && requests === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
{!error && requests !== null && requests.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No leave requests yet.</p></div>
)}
{!error && requests !== null && requests.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Employee</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Dates</TableHead>
<TableHead className="h-12 px-3 text-sm">Days</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requests.map((r) => (
<TableRow key={r.leaveRequestId}>
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{r.employeeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{r.leaveTypeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.startDate).toLocaleDateString()} {new Date(r.endDate).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{r.daysCount}</TableCell>
<TableCell className="px-3 py-3.5"><Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>{r.status}</Badge></TableCell>
<TableCell className="px-3 py-3.5">
{r.status === "Submitted" && (
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => approve(r.leaveRequestId)}>Approve</Button>
<Button variant="ghost" size="sm" onClick={() => reject(r.leaveRequestId)}>Reject</Button>
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,76 @@
"use client"
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import { payrollRunsApi } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map"
import { PayrollLineDetail } from "@/types/hrm"
import { Skeleton } from "@/components/ui/skeleton"
function money(n: number): string {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export default function PayrollLineDetailPage() {
const params = useParams<{ id: string; lineId: string }>()
const runId = Number(params.id)
const lineId = Number(params.lineId)
const [detail, setDetail] = useState<PayrollLineDetail | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
payrollRunsApi.getLine(runId, lineId).then(setDetail).catch((err) => setError(errorMessage(err)))
}, [runId, lineId])
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
if (!detail) return <div className="flex flex-col gap-3">{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}</div>
const { line, components } = detail
const earnings = components.filter((c) => c.componentCategory === "Earning")
const deductions = components.filter((c) => c.componentCategory === "Deduction")
const employerContributions = components.filter((c) => c.componentCategory === "EmployerContribution")
return (
<div className="mx-auto flex max-w-lg flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Salary Breakdown</h1>
<p className="text-base text-muted-foreground">{line.employeeName} ({line.employeeCode})</p>
</div>
<div className="rounded-2xl border bg-card p-6 shadow-sm">
<table className="w-full text-base">
<tbody>
{earnings.map((c, i) => (
<tr key={i}><td className="py-1.5">{c.label}</td><td className="py-1.5 text-right">{money(c.amount)}</td></tr>
))}
<tr className="border-t font-semibold"><td className="py-2">Gross Salary</td><td className="py-2 text-right">{money(line.grossSalary)}</td></tr>
<tr><td className="pt-4 text-sm font-semibold text-muted-foreground" colSpan={2}>Deductions</td></tr>
{deductions.map((c, i) => (
<tr key={i}><td className="py-1.5">{c.label}</td><td className="py-1.5 text-right">{money(c.amount)}</td></tr>
))}
<tr className="border-t font-bold"><td className="py-2">Net Salary</td><td className="py-2 text-right">{money(line.netSalary)}</td></tr>
{employerContributions.length > 0 && (
<>
<tr><td className="pt-4 text-sm font-semibold text-muted-foreground" colSpan={2}>Employer Contributions (informational not deducted)</td></tr>
{employerContributions.map((c, i) => (
<tr key={i} className="text-muted-foreground"><td className="py-1.5">{c.label}</td><td className="py-1.5 text-right">{money(c.amount)}</td></tr>
))}
</>
)}
</tbody>
</table>
</div>
<div className="grid grid-cols-3 gap-3 text-sm text-muted-foreground">
<div>Present days: {line.presentDays}</div>
<div>Absent days: {line.absentDays}</div>
<div>Leave days: {line.leaveDays}</div>
<div>OT minutes: {line.otMinutesTotal}</div>
<div>Late minutes: {line.lateMinutesTotal}</div>
</div>
</div>
)
}
@@ -0,0 +1,170 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useParams } from "next/navigation"
import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PayrollLine, PayrollRun, Payslip } from "@/types/hrm"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
function money(n: number): string {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export default function PayrollRunDetailPage() {
const params = useParams<{ id: string }>()
const runId = Number(params.id)
const [run, setRun] = useState<PayrollRun | null>(null)
const [lines, setLines] = useState<PayrollLine[] | null>(null)
const [payslips, setPayslips] = useState<Payslip[]>([])
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [unlockOpen, setUnlockOpen] = useState(false)
const [unlockReason, setUnlockReason] = useState("")
function load() {
Promise.all([payrollRunsApi.get(runId), payrollRunsApi.listLines(runId)])
.then(([r, l]) => { setRun(r); setLines(l) })
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [runId])
async function approve() {
setBusy(true)
try { setRun(await payrollRunsApi.approve(runId)); toast.success("Payroll approved") }
catch (err) { toast.error("Could not approve", errorMessage(err)) }
finally { setBusy(false) }
}
async function lock() {
setBusy(true)
try { setRun(await payrollRunsApi.lock(runId)); toast.success("Payroll locked") }
catch (err) { toast.error("Could not lock", errorMessage(err)) }
finally { setBusy(false) }
}
async function unlock() {
if (!unlockReason.trim()) { toast.error("A reason is required"); return }
setBusy(true)
try {
setRun(await payrollRunsApi.unlock(runId, unlockReason.trim()))
setUnlockOpen(false)
setUnlockReason("")
toast.success("Payroll unlocked")
} catch (err) {
toast.error("Could not unlock", errorMessage(err))
} finally {
setBusy(false)
}
}
async function generatePayslips() {
setBusy(true)
try {
const result = await payrollRunsApi.generatePayslips(runId)
setPayslips(result)
toast.success("Payslips generated")
} catch (err) {
toast.error("Could not generate payslips", errorMessage(err))
} finally {
setBusy(false)
}
}
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
if (!run || !lines) return <div className="flex flex-col gap-3">{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">{run.docNo}</h1>
<p className="text-base text-muted-foreground">{run.periodMonth.toString().padStart(2, "0")}/{run.periodYear} · {run.employeeCount} employees</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className="h-7 px-3 text-sm">{run.status}</Badge>
{run.status === "Draft" && <Button onClick={approve} disabled={busy}>Approve</Button>}
{run.status === "Approved" && <Button onClick={lock} disabled={busy}>Lock Payroll</Button>}
{run.status === "Locked" && (
<>
<Button onClick={generatePayslips} disabled={busy}>Generate Payslips</Button>
<Dialog open={unlockOpen} onOpenChange={setUnlockOpen}>
<DialogTrigger render={<Button variant="outline">Unlock</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>Unlock payroll</DialogTitle>
<DialogDescription>The highest-risk action in this module requires a reason and is fully audited.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field><FieldLabel htmlFor="p-reason">Reason</FieldLabel><Input id="p-reason" value={unlockReason} onChange={(e) => setUnlockReason(e.target.value)} /></Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setUnlockOpen(false)}>Cancel</Button>
<Button className="min-w-32" onClick={unlock} disabled={busy}>Unlock</Button>
</div>
</DialogContent>
</Dialog>
</>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Gross</p><p className="text-xl font-bold">{money(run.totalGross)}</p></div>
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Net</p><p className="text-xl font-bold">{money(run.totalNet)}</p></div>
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Employees</p><p className="text-xl font-bold">{run.employeeCount}</p></div>
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Deductions</p><p className="text-xl font-bold">{money(run.totalGross - run.totalNet)}</p></div>
</div>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Employee</TableHead>
<TableHead className="h-12 px-3 text-sm">Basic</TableHead>
<TableHead className="h-12 px-3 text-sm">OT</TableHead>
<TableHead className="h-12 px-3 text-sm">Allowances</TableHead>
<TableHead className="h-12 px-3 text-sm">Deductions</TableHead>
<TableHead className="h-12 px-3 text-sm">Net Salary</TableHead>
<TableHead className="h-12 px-3 text-sm">Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{lines.map((l) => {
const totalDeductions = l.grossSalary - l.netSalary
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
return (
<TableRow key={l.payrollLineId}>
<TableCell className="px-3 py-3.5 font-medium">{l.employeeName} <span className="text-muted-foreground">({l.employeeCode})</span></TableCell>
<TableCell className="px-3 py-3.5">{money(l.basicSalary)}</TableCell>
<TableCell className="px-3 py-3.5">{money(l.overtimeAmount)}</TableCell>
<TableCell className="px-3 py-3.5">{money(l.totalAllowances)}</TableCell>
<TableCell className="px-3 py-3.5">{money(totalDeductions)}</TableCell>
<TableCell className="px-3 py-3.5 font-semibold">{money(l.netSalary)}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-3">
<Link href={`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`} className="text-primary underline underline-offset-2">Breakdown</Link>
{payslip && (
<a href={payslipViewUrl(payslip.payslipId)} target="_blank" rel="noreferrer" className={cn("text-primary underline underline-offset-2")}>Payslip</a>
)}
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}
@@ -0,0 +1,139 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { Eye, Plus } from "lucide-react"
import { payrollRunsApi } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { PayrollRun, PayrollRunStatus } from "@/types/hrm"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10
const STATUS_STYLE: Record<PayrollRunStatus, string> = {
Draft: "bg-muted text-muted-foreground",
Approved: "bg-warning/10 text-warning",
Locked: "bg-success/10 text-success",
}
export default function PayrollRunsPage() {
const [runs, setRuns] = useState<PayrollRun[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [open, setOpen] = useState(false)
const now = new Date()
const [periodYear, setPeriodYear] = useState(now.getFullYear())
const [periodMonth, setPeriodMonth] = useState(now.getMonth() + 1)
const [submitting, setSubmitting] = useState(false)
function load() {
payrollRunsApi
.list({ page, pageSize: PAGE_SIZE })
.then((res) => { setRuns(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page])
async function handleGenerate() {
setSubmitting(true)
try {
await payrollRunsApi.generate({ periodYear, periodMonth })
toast.success("Payroll run generated")
setOpen(false)
load()
} catch (err) {
toast.error("Could not generate payroll", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Payroll</h1>
<p className="text-base text-muted-foreground">Generate Review Approve Lock Payslips.</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />Generate Payroll</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>Generate Payroll Run</DialogTitle>
<DialogDescription>Blocked if attendance for this period isn&apos;t fully Confirmed yet.</DialogDescription>
</DialogHeader>
<FieldGroup>
<div className="grid grid-cols-2 gap-3">
<Field><FieldLabel>Year</FieldLabel><Input type="number" value={periodYear} onChange={(e) => setPeriodYear(Number(e.target.value))} /></Field>
<Field><FieldLabel>Month</FieldLabel><Input type="number" min={1} max={12} value={periodMonth} onChange={(e) => setPeriodMonth(Number(e.target.value))} /></Field>
</div>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleGenerate} disabled={submitting}>{submitting ? "Generating…" : "Generate"}</Button>
</div>
</DialogContent>
</Dialog>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && runs === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
{!error && runs !== null && runs.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No payroll runs yet.</p></div>
)}
{!error && runs !== null && runs.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Period</TableHead>
<TableHead className="h-12 px-3 text-sm">Employees</TableHead>
<TableHead className="h-12 px-3 text-sm">Gross</TableHead>
<TableHead className="h-12 px-3 text-sm">Net</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((r) => (
<TableRow key={r.payrollRunId}>
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</TableCell>
<TableCell className="px-3 py-3.5">{r.employeeCount}</TableCell>
<TableCell className="px-3 py-3.5">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
<TableCell className="px-3 py-3.5">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
<TableCell className="px-3 py-3.5"><Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>{r.status}</Badge></TableCell>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/hrm/payroll/${r.payrollRunId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label="View"><Eye className="size-4" /></Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,175 @@
"use client"
import { useState } from "react"
import { hrReportsApi } from "@/lib/api/hr-reports"
import { errorMessage } from "@/lib/error-map"
import {
AttendanceSummaryRow,
DocumentExpiryReportRow,
LateArrivalReportRow,
LeaveBalanceReportRow,
OvertimeReportRow,
PayrollRegisterRow,
SalaryHistoryRow,
} from "@/types/hrm"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const REPORTS = [
"Attendance Summary",
"Overtime",
"Late Arrivals",
"Payroll Register",
"Salary History",
"Leave Balances",
"Document Expiry",
] as const
type ReportName = (typeof REPORTS)[number]
const now = new Date()
export default function HrReportsPage() {
const [report, setReport] = useState<ReportName>("Attendance Summary")
const [periodYear, setPeriodYear] = useState(now.getFullYear())
const [periodMonth, setPeriodMonth] = useState(now.getMonth() + 1)
const [payrollRunId, setPayrollRunId] = useState("")
const [employeeId, setEmployeeId] = useState("")
const [year, setYear] = useState(now.getFullYear())
const [withinDays, setWithinDays] = useState(30)
const [loading, setLoading] = useState(false)
const [attendanceRows, setAttendanceRows] = useState<AttendanceSummaryRow[]>([])
const [otRows, setOtRows] = useState<OvertimeReportRow[]>([])
const [lateRows, setLateRows] = useState<LateArrivalReportRow[]>([])
const [payrollRows, setPayrollRows] = useState<PayrollRegisterRow[]>([])
const [salaryRows, setSalaryRows] = useState<SalaryHistoryRow[]>([])
const [leaveRows, setLeaveRows] = useState<LeaveBalanceReportRow[]>([])
const [expiryRows, setExpiryRows] = useState<DocumentExpiryReportRow[]>([])
async function run() {
setLoading(true)
try {
switch (report) {
case "Attendance Summary": setAttendanceRows(await hrReportsApi.attendanceSummary(periodYear, periodMonth)); break
case "Overtime": setOtRows(await hrReportsApi.overtime(periodYear, periodMonth)); break
case "Late Arrivals": setLateRows(await hrReportsApi.lateArrivals(periodYear, periodMonth)); break
case "Payroll Register": setPayrollRows(await hrReportsApi.payrollRegister(Number(payrollRunId))); break
case "Salary History": setSalaryRows(await hrReportsApi.salaryHistory(Number(employeeId))); break
case "Leave Balances": setLeaveRows(await hrReportsApi.leaveBalances(year)); break
case "Document Expiry": setExpiryRows(await hrReportsApi.documentExpiry(withinDays)); break
}
} catch (err) {
toast.error("Could not load report", errorMessage(err))
} finally {
setLoading(false)
}
}
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Reports</h1>
<p className="text-base text-muted-foreground">Read-only views over Attendance, Payroll, Leave, and Documents.</p>
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Report</label>
<Select value={report} onValueChange={(v) => setReport((v as ReportName) ?? "Attendance Summary")}>
<SelectTrigger className="w-56"><SelectValue /></SelectTrigger>
<SelectContent>{REPORTS.map((r) => <SelectItem key={r} value={r}>{r}</SelectItem>)}</SelectContent>
</Select>
</div>
{(report === "Attendance Summary" || report === "Overtime" || report === "Late Arrivals") && (
<>
<div className="flex flex-col gap-1.5"><label className="text-sm font-medium">Year</label><Input type="number" className="w-28" value={periodYear} onChange={(e) => setPeriodYear(Number(e.target.value))} /></div>
<div className="flex flex-col gap-1.5"><label className="text-sm font-medium">Month</label><Input type="number" min={1} max={12} className="w-24" value={periodMonth} onChange={(e) => setPeriodMonth(Number(e.target.value))} /></div>
</>
)}
{report === "Payroll Register" && (
<div className="flex flex-col gap-1.5"><label className="text-sm font-medium">Payroll run ID</label><Input className="w-32" value={payrollRunId} onChange={(e) => setPayrollRunId(e.target.value)} /></div>
)}
{report === "Salary History" && (
<div className="flex flex-col gap-1.5"><label className="text-sm font-medium">Employee ID</label><Input className="w-32" value={employeeId} onChange={(e) => setEmployeeId(e.target.value)} /></div>
)}
{report === "Leave Balances" && (
<div className="flex flex-col gap-1.5"><label className="text-sm font-medium">Year</label><Input type="number" className="w-28" value={year} onChange={(e) => setYear(Number(e.target.value))} /></div>
)}
{report === "Document Expiry" && (
<div className="flex flex-col gap-1.5"><label className="text-sm font-medium">Within days</label><Input type="number" className="w-28" value={withinDays} onChange={(e) => setWithinDays(Number(e.target.value))} /></div>
)}
<Button onClick={run} disabled={loading}>{loading ? "Loading…" : "Run"}</Button>
</div>
{report === "Attendance Summary" && (
<Table className="text-base">
<TableHeader><TableRow><TableHead className="h-12 px-3 text-sm">Employee</TableHead><TableHead className="h-12 px-3 text-sm">Dept</TableHead><TableHead className="h-12 px-3 text-sm">Present</TableHead><TableHead className="h-12 px-3 text-sm">Absent</TableHead><TableHead className="h-12 px-3 text-sm">Leave</TableHead><TableHead className="h-12 px-3 text-sm">OT (min)</TableHead><TableHead className="h-12 px-3 text-sm">Late (min)</TableHead></TableRow></TableHeader>
<TableBody>{attendanceRows.map((r) => (
<TableRow key={r.employeeId}><TableCell className="px-3 py-3">{r.employeeName} ({r.employeeCode})</TableCell><TableCell className="px-3 py-3">{r.departmentName ?? "—"}</TableCell><TableCell className="px-3 py-3">{r.presentDays}</TableCell><TableCell className="px-3 py-3">{r.absentDays}</TableCell><TableCell className="px-3 py-3">{r.leaveDays}</TableCell><TableCell className="px-3 py-3">{r.otMinutesTotal}</TableCell><TableCell className="px-3 py-3">{r.lateMinutesTotal}</TableCell></TableRow>
))}</TableBody>
</Table>
)}
{report === "Overtime" && (
<Table className="text-base">
<TableHeader><TableRow><TableHead className="h-12 px-3 text-sm">Employee</TableHead><TableHead className="h-12 px-3 text-sm">Date</TableHead><TableHead className="h-12 px-3 text-sm">OT (min)</TableHead></TableRow></TableHeader>
<TableBody>{otRows.map((r, i) => (
<TableRow key={i}><TableCell className="px-3 py-3">{r.employeeName} ({r.employeeCode})</TableCell><TableCell className="px-3 py-3">{new Date(r.attendanceDate).toLocaleDateString()}</TableCell><TableCell className="px-3 py-3">{r.overtimeMinutes}</TableCell></TableRow>
))}</TableBody>
</Table>
)}
{report === "Late Arrivals" && (
<Table className="text-base">
<TableHeader><TableRow><TableHead className="h-12 px-3 text-sm">Employee</TableHead><TableHead className="h-12 px-3 text-sm">Date</TableHead><TableHead className="h-12 px-3 text-sm">Late (min)</TableHead></TableRow></TableHeader>
<TableBody>{lateRows.map((r, i) => (
<TableRow key={i}><TableCell className="px-3 py-3">{r.employeeName} ({r.employeeCode})</TableCell><TableCell className="px-3 py-3">{new Date(r.attendanceDate).toLocaleDateString()}</TableCell><TableCell className="px-3 py-3">{r.lateMinutes}</TableCell></TableRow>
))}</TableBody>
</Table>
)}
{report === "Payroll Register" && (
<Table className="text-base">
<TableHeader><TableRow><TableHead className="h-12 px-3 text-sm">Employee</TableHead><TableHead className="h-12 px-3 text-sm">Gross</TableHead><TableHead className="h-12 px-3 text-sm">Deductions</TableHead><TableHead className="h-12 px-3 text-sm">Net</TableHead></TableRow></TableHeader>
<TableBody>{payrollRows.map((r) => (
<TableRow key={r.payrollLineId}><TableCell className="px-3 py-3">{r.employeeName} ({r.employeeCode})</TableCell><TableCell className="px-3 py-3">{r.grossSalary.toFixed(2)}</TableCell><TableCell className="px-3 py-3">{r.totalDeductions.toFixed(2)}</TableCell><TableCell className="px-3 py-3">{r.netSalary.toFixed(2)}</TableCell></TableRow>
))}</TableBody>
</Table>
)}
{report === "Salary History" && (
<Table className="text-base">
<TableHeader><TableRow><TableHead className="h-12 px-3 text-sm">Effective from</TableHead><TableHead className="h-12 px-3 text-sm">Effective to</TableHead><TableHead className="h-12 px-3 text-sm">Basic</TableHead><TableHead className="h-12 px-3 text-sm">Status</TableHead></TableRow></TableHeader>
<TableBody>{salaryRows.map((r) => (
<TableRow key={r.employeeSalaryStructureId}><TableCell className="px-3 py-3">{new Date(r.effectiveFrom).toLocaleDateString()}</TableCell><TableCell className="px-3 py-3">{r.effectiveTo ? new Date(r.effectiveTo).toLocaleDateString() : "Current"}</TableCell><TableCell className="px-3 py-3">{r.basicSalary.toFixed(2)}</TableCell><TableCell className="px-3 py-3">{r.status}</TableCell></TableRow>
))}</TableBody>
</Table>
)}
{report === "Leave Balances" && (
<Table className="text-base">
<TableHeader><TableRow><TableHead className="h-12 px-3 text-sm">Employee</TableHead><TableHead className="h-12 px-3 text-sm">Leave type</TableHead><TableHead className="h-12 px-3 text-sm">Entitled</TableHead><TableHead className="h-12 px-3 text-sm">Taken</TableHead><TableHead className="h-12 px-3 text-sm">Remaining</TableHead></TableRow></TableHeader>
<TableBody>{leaveRows.map((r, i) => (
<TableRow key={i}><TableCell className="px-3 py-3">{r.employeeName} ({r.employeeCode})</TableCell><TableCell className="px-3 py-3">{r.leaveTypeName}</TableCell><TableCell className="px-3 py-3">{r.entitledDays}</TableCell><TableCell className="px-3 py-3">{r.takenDays}</TableCell><TableCell className="px-3 py-3">{r.remainingDays}</TableCell></TableRow>
))}</TableBody>
</Table>
)}
{report === "Document Expiry" && (
<Table className="text-base">
<TableHeader><TableRow><TableHead className="h-12 px-3 text-sm">Employee</TableHead><TableHead className="h-12 px-3 text-sm">Document type</TableHead><TableHead className="h-12 px-3 text-sm">Expiry date</TableHead><TableHead className="h-12 px-3 text-sm">Days left</TableHead></TableRow></TableHeader>
<TableBody>{expiryRows.map((r) => (
<TableRow key={r.employeeDocumentId}><TableCell className="px-3 py-3">{r.employeeName} ({r.employeeCode})</TableCell><TableCell className="px-3 py-3">{r.documentTypeName}</TableCell><TableCell className="px-3 py-3">{new Date(r.expiryDate).toLocaleDateString()}</TableCell><TableCell className="px-3 py-3">{r.daysUntilExpiry}</TableCell></TableRow>
))}</TableBody>
</Table>
)}
</div>
)
}
@@ -0,0 +1,16 @@
"use client"
import { CodeNameMasterPage } from "@/components/hrm/CodeNameMasterPage"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { Branch } from "@/types/hrm"
export default function BranchesPage() {
return (
<CodeNameMasterPage<Branch>
title="Branches"
description="Company locations/branches — used for multi-branch employee and payroll scoping."
idOf={(b) => b.branchId}
api={branchesHrmApi}
/>
)
}
@@ -0,0 +1,198 @@
"use client"
import { useEffect, useState } from "react"
import { Plus } from "lucide-react"
import { departmentsApi, branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { Branch, Department } from "@/types/hrm"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10
const NONE = "__none__"
export default function DepartmentsPage() {
const [items, setItems] = useState<Department[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
const [name, setName] = useState("")
const [parentDepartmentId, setParentDepartmentId] = useState(NONE)
const [branchId, setBranchId] = useState(NONE)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
function load() {
departmentsApi
.list({ page, pageSize: PAGE_SIZE })
.then((res) => {
setItems(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page])
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((res) => setBranches(res.items)).catch(() => setBranches([]))
}, [])
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!code.trim()) nextErrors.code = "Code is required"
if (!name.trim()) nextErrors.name = "Name is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
await departmentsApi.create({
code: code.trim(),
name: name.trim(),
parentDepartmentId: parentDepartmentId === NONE ? null : Number(parentDepartmentId),
branchId: branchId === NONE ? null : Number(branchId),
})
toast.success("Department created")
setOpen(false)
setCode("")
setName("")
setParentDepartmentId(NONE)
setBranchId(NONE)
load()
} catch (err) {
toast.error("Could not create department", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function toggleStatus(item: Department) {
try {
await departmentsApi.updateStatus(item.departmentId, item.status === "Active" ? "Inactive" : "Active")
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Departments</h1>
<p className="text-base text-muted-foreground">Org structure unlimited nesting, optionally scoped to a branch.</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Department</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New Department</DialogTitle>
<DialogDescription>Set a parent department for a sub-department, or leave it top-level.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="d-code">Code</FieldLabel>
<Input id="d-code" value={code} onChange={(e) => setCode(e.target.value)} aria-invalid={!!errors.code} />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="d-name">Name</FieldLabel>
<Input id="d-name" value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="d-parent">Parent department (optional)</FieldLabel>
<Select value={parentDepartmentId} onValueChange={(v) => setParentDepartmentId(v ?? NONE)}>
<SelectTrigger id="d-parent"><SelectValue placeholder="None" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>None</SelectItem>
{(items ?? []).map((d) => (
<SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="d-branch">Branch (optional)</FieldLabel>
<Select value={branchId} onValueChange={(v) => setBranchId(v ?? NONE)}>
<SelectTrigger id="d-branch"><SelectValue placeholder="None" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>None</SelectItem>
{branches.map((b) => (
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
</div>
</DialogContent>
</Dialog>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && items === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
{!error && items !== null && items.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<p className="text-base text-muted-foreground">No departments yet.</p>
</div>
)}
{!error && items !== null && items.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Parent</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((d) => (
<TableRow key={d.departmentId}>
<TableCell className="px-3 py-3.5 font-medium">{d.code}</TableCell>
<TableCell className="px-3 py-3.5">{d.name}</TableCell>
<TableCell className="px-3 py-3.5">{items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", d.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{d.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<Button variant="ghost" size="sm" onClick={() => toggleStatus(d)}>{d.status === "Active" ? "Deactivate" : "Activate"}</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,16 @@
"use client"
import { CodeNameMasterPage } from "@/components/hrm/CodeNameMasterPage"
import { designationsApi } from "@/lib/api/hrm-masters"
import { Designation } from "@/types/hrm"
export default function DesignationsPage() {
return (
<CodeNameMasterPage<Designation>
title="Designations"
description="Job titles — standalone, reusable across departments."
idOf={(d) => d.designationId}
api={designationsApi}
/>
)
}
@@ -0,0 +1,176 @@
"use client"
import { useEffect, useState } from "react"
import { Plus } from "lucide-react"
import { hrDocumentTypesApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { HrDocumentCategory, HrDocumentType } from "@/types/hrm"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10
const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"]
export default function DocumentTypesPage() {
const [items, setItems] = useState<HrDocumentType[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
const [name, setName] = useState("")
const [category, setCategory] = useState<HrDocumentCategory>("Identity")
const [requiredAtOnboarding, setRequiredAtOnboarding] = useState(false)
const [expiryTracked, setExpiryTracked] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
function load() {
hrDocumentTypesApi
.list({ page, pageSize: PAGE_SIZE })
.then((res) => { setItems(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page])
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!code.trim()) nextErrors.code = "Code is required"
if (!name.trim()) nextErrors.name = "Name is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
await hrDocumentTypesApi.create({ code: code.trim(), name: name.trim(), category, requiredAtOnboarding, expiryTracked })
toast.success("Document type created")
setOpen(false)
setCode("")
setName("")
load()
} catch (err) {
toast.error("Could not create document type", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function toggleStatus(item: HrDocumentType) {
try {
await hrDocumentTypesApi.updateStatus(item.hrDocumentTypeId, item.status === "Active" ? "Inactive" : "Active")
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Document Types</h1>
<p className="text-base text-muted-foreground">The staff document catalog NIC, contracts, certificates, etc.</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Type</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New Document Type</DialogTitle>
<DialogDescription>Categorize how this document is used, not the file itself.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="dt-code">Code</FieldLabel>
<Input id="dt-code" value={code} onChange={(e) => setCode(e.target.value)} aria-invalid={!!errors.code} />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="dt-name">Name</FieldLabel>
<Input id="dt-name" value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="dt-category">Category</FieldLabel>
<Select value={category} onValueChange={(v) => setCategory((v as HrDocumentCategory) ?? "Other")}>
<SelectTrigger id="dt-category"><SelectValue /></SelectTrigger>
<SelectContent>
{CATEGORIES.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field orientation="horizontal">
<Checkbox id="dt-required" checked={requiredAtOnboarding} onCheckedChange={(v) => setRequiredAtOnboarding(v === true)} />
<FieldLabel htmlFor="dt-required">Required at onboarding</FieldLabel>
</Field>
<Field orientation="horizontal">
<Checkbox id="dt-expiry" checked={expiryTracked} onCheckedChange={(v) => setExpiryTracked(v === true)} />
<FieldLabel htmlFor="dt-expiry">Track expiry date (e.g. passport, visa)</FieldLabel>
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
</div>
</DialogContent>
</Dialog>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && items === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
{!error && items !== null && items.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No document types yet.</p></div>
)}
{!error && items !== null && items.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Category</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((t) => (
<TableRow key={t.hrDocumentTypeId}>
<TableCell className="px-3 py-3.5 font-medium">{t.code}</TableCell>
<TableCell className="px-3 py-3.5">{t.name}</TableCell>
<TableCell className="px-3 py-3.5">{t.category}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{t.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<Button variant="ghost" size="sm" onClick={() => toggleStatus(t)}>{t.status === "Active" ? "Deactivate" : "Activate"}</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,16 @@
"use client"
import { CodeNameMasterPage } from "@/components/hrm/CodeNameMasterPage"
import { employmentTypesApi } from "@/lib/api/hrm-masters"
import { EmploymentType } from "@/types/hrm"
export default function EmploymentTypesPage() {
return (
<CodeNameMasterPage<EmploymentType>
title="Employment Types"
description="Labor categories — Permanent, Probation, Contract, etc."
idOf={(e) => e.employmentTypeId}
api={employmentTypesApi}
/>
)
}
@@ -0,0 +1,179 @@
"use client"
import { useEffect, useState } from "react"
import { Plus } from "lucide-react"
import { leaveTypesApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { LeaveType } from "@/types/hrm"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10
export default function LeaveTypesPage() {
const [items, setItems] = useState<LeaveType[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
const [name, setName] = useState("")
const [isPaid, setIsPaid] = useState(true)
const [countsAsNoPay, setCountsAsNoPay] = useState(false)
const [accrualPerYear, setAccrualPerYear] = useState(14)
const [carryForwardAllowed, setCarryForwardAllowed] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
function load() {
leaveTypesApi
.list({ page, pageSize: PAGE_SIZE })
.then((res) => { setItems(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page])
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!code.trim()) nextErrors.code = "Code is required"
if (!name.trim()) nextErrors.name = "Name is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
await leaveTypesApi.create({
code: code.trim(), name: name.trim(), isPaid, countsAsNoPay, accrualPerYear,
carryForwardAllowed, requiresApproval: true,
})
toast.success("Leave type created")
setOpen(false)
setCode("")
setName("")
load()
} catch (err) {
toast.error("Could not create leave type", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function toggleStatus(item: LeaveType) {
try {
await leaveTypesApi.updateStatus(item.leaveTypeId, item.status === "Active" ? "Inactive" : "Active")
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Leave Types</h1>
<p className="text-base text-muted-foreground">Annual, Casual, Medical, Unpaid, etc. drives Attendance&apos;s OnLeave classification and Payroll&apos;s No-Pay calc.</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Leave Type</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New Leave Type</DialogTitle>
<DialogDescription>Whether it&apos;s paid affects payroll&apos;s No-Pay deduction.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="lt-code">Code</FieldLabel>
<Input id="lt-code" value={code} onChange={(e) => setCode(e.target.value)} aria-invalid={!!errors.code} />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="lt-name">Name</FieldLabel>
<Input id="lt-name" value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="lt-accrual">Days per year</FieldLabel>
<Input id="lt-accrual" type="number" min={0} value={accrualPerYear} onChange={(e) => setAccrualPerYear(Number(e.target.value))} />
</Field>
<Field orientation="horizontal">
<Checkbox id="lt-paid" checked={isPaid} onCheckedChange={(v) => setIsPaid(v === true)} />
<FieldLabel htmlFor="lt-paid">Paid leave</FieldLabel>
</Field>
<Field orientation="horizontal">
<Checkbox id="lt-nopay" checked={countsAsNoPay} onCheckedChange={(v) => setCountsAsNoPay(v === true)} />
<FieldLabel htmlFor="lt-nopay">Counts as No-Pay in payroll</FieldLabel>
</Field>
<Field orientation="horizontal">
<Checkbox id="lt-carry" checked={carryForwardAllowed} onCheckedChange={(v) => setCarryForwardAllowed(v === true)} />
<FieldLabel htmlFor="lt-carry">Carry-forward allowed</FieldLabel>
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
</div>
</DialogContent>
</Dialog>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && items === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
{!error && items !== null && items.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No leave types yet.</p></div>
)}
{!error && items !== null && items.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Days/yr</TableHead>
<TableHead className="h-12 px-3 text-sm">Paid</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((t) => (
<TableRow key={t.leaveTypeId}>
<TableCell className="px-3 py-3.5 font-medium">{t.code}</TableCell>
<TableCell className="px-3 py-3.5">{t.name}</TableCell>
<TableCell className="px-3 py-3.5">{t.accrualPerYear}</TableCell>
<TableCell className="px-3 py-3.5">{t.isPaid ? "Yes" : "No"}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{t.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<Button variant="ghost" size="sm" onClick={() => toggleStatus(t)}>{t.status === "Active" ? "Deactivate" : "Activate"}</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,44 @@
"use client"
import Link from "next/link"
import { Building2, CalendarClock, FileText, ListTree, Percent, Sigma, Users } from "lucide-react"
const cards = [
{ title: "Branches", href: "/dashboard/hrm/settings/branches", icon: Building2, desc: "Company locations" },
{ title: "Departments", href: "/dashboard/hrm/settings/departments", icon: ListTree, desc: "Org structure" },
{ title: "Designations", href: "/dashboard/hrm/settings/designations", icon: Users, desc: "Job titles" },
{ title: "Employment Types", href: "/dashboard/hrm/settings/employment-types", icon: Users, desc: "Permanent, Contract, etc." },
{ title: "Work Shifts", href: "/dashboard/hrm/settings/work-shifts", icon: CalendarClock, desc: "Attendance baseline" },
{ title: "Document Types", href: "/dashboard/hrm/settings/document-types", icon: FileText, desc: "Staff document catalog" },
{ title: "Leave Types", href: "/dashboard/hrm/settings/leave-types", icon: CalendarClock, desc: "Annual, Casual, Medical…" },
{ title: "Salary Components", href: "/dashboard/hrm/settings/salary-components", icon: Sigma, desc: "Allowances & deductions" },
{ title: "Statutory Settings", href: "/dashboard/hrm/settings/statutory", icon: Percent, desc: "EPF/ETF rates & tax slabs" },
]
export default function HrmSettingsPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">HRM Settings</h1>
<p className="text-base text-muted-foreground">Masters and configuration used across Employees, Attendance, Leave, and Payroll.</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{cards.map((c) => (
<Link
key={c.href}
href={c.href}
className="flex items-center gap-4 rounded-2xl border bg-card p-5 shadow-sm ring-1 ring-foreground/5 transition-colors hover:bg-muted"
>
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<c.icon className="size-5 text-primary" />
</div>
<div>
<p className="font-semibold text-foreground">{c.title}</p>
<p className="text-sm text-muted-foreground">{c.desc}</p>
</div>
</Link>
))}
</div>
</div>
)
}
@@ -0,0 +1,176 @@
"use client"
import { useEffect, useState } from "react"
import { Plus } from "lucide-react"
import { salaryComponentsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { SalaryComponent, SalaryComponentType } from "@/types/hrm"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10
export default function SalaryComponentsPage() {
const [items, setItems] = useState<SalaryComponent[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
const [name, setName] = useState("")
const [componentType, setComponentType] = useState<SalaryComponentType>("Earning")
const [isTaxable, setIsTaxable] = useState(true)
const [isEpfEtfApplicable, setIsEpfEtfApplicable] = useState(true)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
function load() {
salaryComponentsApi
.list({ page, pageSize: PAGE_SIZE })
.then((res) => { setItems(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page])
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!code.trim()) nextErrors.code = "Code is required"
if (!name.trim()) nextErrors.name = "Name is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
await salaryComponentsApi.create({ code: code.trim(), name: name.trim(), componentType, isTaxable, isEpfEtfApplicable })
toast.success("Salary component created")
setOpen(false)
setCode("")
setName("")
load()
} catch (err) {
toast.error("Could not create salary component", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function toggleStatus(item: SalaryComponent) {
try {
await salaryComponentsApi.updateStatus(item.salaryComponentId, item.status === "Active" ? "Inactive" : "Active")
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Salary Components</h1>
<p className="text-base text-muted-foreground">Allowances and ad hoc other deductions OT/Late/No-Pay/Loan/EPF/ETF/Tax are computed automatically, not components.</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Component</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New Salary Component</DialogTitle>
<DialogDescription>e.g. Transport Allowance, Meal Allowance, or an ad hoc deduction.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="sc-code">Code</FieldLabel>
<Input id="sc-code" value={code} onChange={(e) => setCode(e.target.value)} aria-invalid={!!errors.code} />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="sc-name">Name</FieldLabel>
<Input id="sc-name" value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="sc-type">Type</FieldLabel>
<Select value={componentType} onValueChange={(v) => setComponentType((v as SalaryComponentType) ?? "Earning")}>
<SelectTrigger id="sc-type"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Earning">Earning</SelectItem>
<SelectItem value="Deduction">Deduction</SelectItem>
</SelectContent>
</Select>
</Field>
<Field orientation="horizontal">
<Checkbox id="sc-taxable" checked={isTaxable} onCheckedChange={(v) => setIsTaxable(v === true)} />
<FieldLabel htmlFor="sc-taxable">Taxable</FieldLabel>
</Field>
<Field orientation="horizontal">
<Checkbox id="sc-epfetf" checked={isEpfEtfApplicable} onCheckedChange={(v) => setIsEpfEtfApplicable(v === true)} />
<FieldLabel htmlFor="sc-epfetf">EPF/ETF applicable</FieldLabel>
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
</div>
</DialogContent>
</Dialog>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && items === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
{!error && items !== null && items.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No salary components yet.</p></div>
)}
{!error && items !== null && items.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((c) => (
<TableRow key={c.salaryComponentId}>
<TableCell className="px-3 py-3.5 font-medium">{c.code}</TableCell>
<TableCell className="px-3 py-3.5">{c.name}</TableCell>
<TableCell className="px-3 py-3.5">{c.componentType}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", c.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{c.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<Button variant="ghost" size="sm" onClick={() => toggleStatus(c)}>{c.status === "Active" ? "Deactivate" : "Activate"}</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,179 @@
"use client"
import { useEffect, useState } from "react"
import { Plus } from "lucide-react"
import { payrollStatutorySettingsApi, taxSlabsApi } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map"
import { PayrollStatutorySetting, TaxSlab } from "@/types/hrm"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
export default function StatutorySettingsPage() {
const [settings, setSettings] = useState<PayrollStatutorySetting[]>([])
const [slabs, setSlabs] = useState<TaxSlab[]>([])
const [error, setError] = useState<string | null>(null)
const [settingOpen, setSettingOpen] = useState(false)
const [epfEmployeeRate, setEpfEmployeeRate] = useState(0.08)
const [epfEmployerRate, setEpfEmployerRate] = useState(0.12)
const [etfEmployerRate, setEtfEmployerRate] = useState(0.03)
const [otMultiplierDefault, setOtMultiplierDefault] = useState(1.5)
const [effectiveFrom, setEffectiveFrom] = useState("")
const [slabOpen, setSlabOpen] = useState(false)
const [slabEffectiveFrom, setSlabEffectiveFrom] = useState("")
const [lowerBound, setLowerBound] = useState(0)
const [upperBound, setUpperBound] = useState<string>("")
const [rate, setRate] = useState(0.06)
const [submitting, setSubmitting] = useState(false)
function load() {
Promise.all([payrollStatutorySettingsApi.list(), taxSlabsApi.list()])
.then(([s, t]) => { setSettings(s); setSlabs(t) })
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
async function createSetting() {
if (!effectiveFrom) { toast.error("Effective date is required"); return }
setSubmitting(true)
try {
await payrollStatutorySettingsApi.create({ epfEmployeeRate, epfEmployerRate, etfEmployerRate, otMultiplierDefault, effectiveFrom })
toast.success("Statutory setting saved")
setSettingOpen(false)
load()
} catch (err) {
toast.error("Could not save", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function createSlab() {
if (!slabEffectiveFrom) { toast.error("Effective date is required"); return }
setSubmitting(true)
try {
await taxSlabsApi.create({
effectiveFrom: slabEffectiveFrom,
lowerBound,
upperBound: upperBound.trim() === "" ? null : Number(upperBound),
rate,
})
toast.success("Tax slab created")
setSlabOpen(false)
load()
} catch (err) {
toast.error("Could not create tax slab", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-8">
<div>
<h1 className="text-2xl font-bold text-foreground">Statutory Settings</h1>
<p className="text-base text-muted-foreground">EPF/ETF rates and tax slabs effective-dated since these change with government policy.</p>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
<section className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">EPF / ETF Rates</h2>
<Dialog open={settingOpen} onOpenChange={setSettingOpen}>
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Setting</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New Statutory Setting</DialogTitle>
<DialogDescription>Supersedes the current open-ended setting from this date.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field><FieldLabel>Effective from</FieldLabel><Input type="date" value={effectiveFrom} onChange={(e) => setEffectiveFrom(e.target.value)} /></Field>
<Field><FieldLabel>EPF employee rate (0-1)</FieldLabel><Input type="number" step="0.001" value={epfEmployeeRate} onChange={(e) => setEpfEmployeeRate(Number(e.target.value))} /></Field>
<Field><FieldLabel>EPF employer rate (0-1)</FieldLabel><Input type="number" step="0.001" value={epfEmployerRate} onChange={(e) => setEpfEmployerRate(Number(e.target.value))} /></Field>
<Field><FieldLabel>ETF employer rate (0-1)</FieldLabel><Input type="number" step="0.001" value={etfEmployerRate} onChange={(e) => setEtfEmployerRate(Number(e.target.value))} /></Field>
<Field><FieldLabel>Default OT multiplier</FieldLabel><Input type="number" step="0.1" value={otMultiplierDefault} onChange={(e) => setOtMultiplierDefault(Number(e.target.value))} /></Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setSettingOpen(false)} disabled={submitting}>Cancel</Button>
<Button className="min-w-32" onClick={createSetting} disabled={submitting}>{submitting ? "Saving…" : "Save"}</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Effective from</TableHead>
<TableHead className="h-12 px-3 text-sm">EPF (Employee)</TableHead>
<TableHead className="h-12 px-3 text-sm">EPF (Employer)</TableHead>
<TableHead className="h-12 px-3 text-sm">ETF (Employer)</TableHead>
<TableHead className="h-12 px-3 text-sm">OT multiplier</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{settings.map((s) => (
<TableRow key={s.payrollStatutorySettingId}>
<TableCell className="px-3 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}</TableCell>
<TableCell className="px-3 py-3.5">{(s.epfEmployeeRate * 100).toFixed(1)}%</TableCell>
<TableCell className="px-3 py-3.5">{(s.epfEmployerRate * 100).toFixed(1)}%</TableCell>
<TableCell className="px-3 py-3.5">{(s.etfEmployerRate * 100).toFixed(1)}%</TableCell>
<TableCell className="px-3 py-3.5">{s.otMultiplierDefault}x</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
<section className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">Tax Slabs</h2>
<Dialog open={slabOpen} onOpenChange={setSlabOpen}>
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Slab</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New Tax Slab</DialogTitle>
<DialogDescription>Leave upper bound empty for &quot;and above&quot;.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field><FieldLabel>Effective from</FieldLabel><Input type="date" value={slabEffectiveFrom} onChange={(e) => setSlabEffectiveFrom(e.target.value)} /></Field>
<Field><FieldLabel>Lower bound</FieldLabel><Input type="number" min={0} value={lowerBound} onChange={(e) => setLowerBound(Number(e.target.value))} /></Field>
<Field><FieldLabel>Upper bound (optional)</FieldLabel><Input type="number" value={upperBound} onChange={(e) => setUpperBound(e.target.value)} placeholder="And above" /></Field>
<Field><FieldLabel>Rate (0-1)</FieldLabel><Input type="number" step="0.01" value={rate} onChange={(e) => setRate(Number(e.target.value))} /></Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setSlabOpen(false)} disabled={submitting}>Cancel</Button>
<Button className="min-w-32" onClick={createSlab} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Effective from</TableHead>
<TableHead className="h-12 px-3 text-sm">Range</TableHead>
<TableHead className="h-12 px-3 text-sm">Rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{slabs.map((s) => (
<TableRow key={s.taxSlabId}>
<TableCell className="px-3 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{s.lowerBound.toLocaleString()} {s.upperBound ? s.upperBound.toLocaleString() : "and above"}</TableCell>
<TableCell className="px-3 py-3.5">{(s.rate * 100).toFixed(1)}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
</div>
)
}
@@ -0,0 +1,222 @@
"use client"
import { useEffect, useState } from "react"
import { Plus } from "lucide-react"
import { workShiftsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { WorkShift } from "@/types/hrm"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
export default function WorkShiftsPage() {
const [items, setItems] = useState<WorkShift[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
const [name, setName] = useState("")
const [startTime, setStartTime] = useState("08:00")
const [endTime, setEndTime] = useState("17:00")
const [isOvernight, setIsOvernight] = useState(false)
const [graceMinutes, setGraceMinutes] = useState(15)
const [breakMinutes, setBreakMinutes] = useState(60)
const [standardWorkingMinutes, setStandardWorkingMinutes] = useState(480)
const [otMultiplier, setOtMultiplier] = useState(1.5)
const [workingDays, setWorkingDays] = useState<boolean[]>([true, true, true, true, true, false, false])
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
function load() {
workShiftsApi
.list({ page, pageSize: PAGE_SIZE })
.then((res) => {
setItems(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page])
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!code.trim()) nextErrors.code = "Code is required"
if (!name.trim()) nextErrors.name = "Name is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
const mask = workingDays.reduce((m, on, i) => (on ? m | (1 << i) : m), 0)
setSubmitting(true)
try {
await workShiftsApi.create({
code: code.trim(),
name: name.trim(),
startTime: `${startTime}:00`,
endTime: `${endTime}:00`,
isOvernight,
graceMinutes,
breakMinutes,
standardWorkingMinutes,
otMultiplier,
workingDaysMask: mask,
})
toast.success("Work shift created")
setOpen(false)
setCode("")
setName("")
load()
} catch (err) {
toast.error("Could not create work shift", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function toggleStatus(item: WorkShift) {
try {
await workShiftsApi.updateStatus(item.workShiftId, item.status === "Active" ? "Inactive" : "Active")
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Work Shifts</h1>
<p className="text-base text-muted-foreground">The baseline Attendance computes Late/Early/OT against.</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Shift</Button>} />
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New Work Shift</DialogTitle>
<DialogDescription>Standard hours, grace period, and working days for this shift.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="w-code">Code</FieldLabel>
<Input id="w-code" value={code} onChange={(e) => setCode(e.target.value)} aria-invalid={!!errors.code} />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="w-name">Name</FieldLabel>
<Input id="w-name" value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="w-start">Start time</FieldLabel>
<Input id="w-start" type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="w-end">End time</FieldLabel>
<Input id="w-end" type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
</Field>
</div>
<Field orientation="horizontal">
<Checkbox id="w-overnight" checked={isOvernight} onCheckedChange={(v) => setIsOvernight(v === true)} />
<FieldLabel htmlFor="w-overnight">Overnight shift (end time rolls past midnight)</FieldLabel>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="w-grace">Grace (minutes)</FieldLabel>
<Input id="w-grace" type="number" min={0} value={graceMinutes} onChange={(e) => setGraceMinutes(Number(e.target.value))} />
</Field>
<Field>
<FieldLabel htmlFor="w-break">Break (minutes)</FieldLabel>
<Input id="w-break" type="number" min={0} value={breakMinutes} onChange={(e) => setBreakMinutes(Number(e.target.value))} />
</Field>
<Field>
<FieldLabel htmlFor="w-standard">Standard working minutes</FieldLabel>
<Input id="w-standard" type="number" min={1} value={standardWorkingMinutes} onChange={(e) => setStandardWorkingMinutes(Number(e.target.value))} />
</Field>
<Field>
<FieldLabel htmlFor="w-ot">OT multiplier</FieldLabel>
<Input id="w-ot" type="number" step="0.1" min={1} value={otMultiplier} onChange={(e) => setOtMultiplier(Number(e.target.value))} />
</Field>
</div>
<Field>
<FieldLabel>Working days</FieldLabel>
<div className="flex flex-wrap gap-3 pt-1">
{DAYS.map((d, i) => (
<label key={d} className="flex items-center gap-1.5 text-sm">
<Checkbox checked={workingDays[i]} onCheckedChange={(v) => setWorkingDays((prev) => prev.map((p, idx) => (idx === i ? v === true : p)))} />
{d}
</label>
))}
</div>
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
</div>
</DialogContent>
</Dialog>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && items === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
{!error && items !== null && items.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No work shifts yet.</p></div>
)}
{!error && items !== null && items.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Hours</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((w) => (
<TableRow key={w.workShiftId}>
<TableCell className="px-3 py-3.5 font-medium">{w.code}</TableCell>
<TableCell className="px-3 py-3.5">{w.name}</TableCell>
<TableCell className="px-3 py-3.5">{w.startTime.slice(0, 5)}{w.endTime.slice(0, 5)}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", w.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{w.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<Button variant="ghost" size="sm" onClick={() => toggleStatus(w)}>{w.status === "Active" ? "Deactivate" : "Activate"}</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
</div>
</div>
)}
</div>
)
}
@@ -2,14 +2,16 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { Pencil, Plus, Users as UsersIcon } from "lucide-react"
import { Link2, Pencil, Plus, Users as UsersIcon } from "lucide-react"
import { rolesApi } from "@/lib/api/roles"
import { usersApi } from "@/lib/api/users"
import { employeeCrossLinkApi } from "@/lib/api/employees"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { Role } from "@/types/rbac"
import { EmployeeMatch } from "@/types/hrm"
import { ManagedUser, UserTypeOption } from "@/types/users"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -50,6 +52,23 @@ export default function UsersPage() {
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Advisory cross-link suggestion: does a Staff record already exist with this email?
const [staffMatch, setStaffMatch] = useState<EmployeeMatch | null>(null)
const [checkingEmail, setCheckingEmail] = useState(false)
async function checkEmail(value: string) {
if (!value.trim()) { setStaffMatch(null); return }
setCheckingEmail(true)
try {
const { match } = await employeeCrossLinkApi.findStaffByEmail(value.trim())
setStaffMatch(match)
} catch {
setStaffMatch(null)
} finally {
setCheckingEmail(false)
}
}
function load() {
usersApi
.list({ page, pageSize: PAGE_SIZE })
@@ -88,6 +107,7 @@ export default function UsersPage() {
setRoleId("")
setUserTypeId("")
setErrors({})
setStaffMatch(null)
}
async function handleCreate() {
@@ -110,6 +130,7 @@ export default function UsersPage() {
nic: nic || null,
roleId: Number(roleId),
userTypeId: userTypeId.trim(),
linkEmployeeId: staffMatch ? staffMatch.employeeId : null,
})
toast.success("User created", `Credentials have been emailed to ${result.username}.`)
setOpen(false)
@@ -161,8 +182,24 @@ export default function UsersPage() {
</Field>
<Field data-invalid={!!errors.email}>
<FieldLabel htmlFor="u-email">Email</FieldLabel>
<Input id="u-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={!!errors.email} />
<Input
id="u-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={(e) => checkEmail(e.target.value)}
aria-invalid={!!errors.email}
/>
<FieldError errors={[errors.email ? { message: errors.email } : undefined]} />
{checkingEmail && <p className="text-sm text-muted-foreground">Checking for an existing staff record</p>}
{staffMatch && (
<div className="flex items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm">
<Link2 className="size-4 shrink-0 text-primary" />
<span>
Staff record <strong>{staffMatch.employeeCode}</strong> ({staffMatch.fullName}) matches this email it will be linked to this user.
</span>
</div>
)}
</Field>
<Field>
<FieldLabel htmlFor="u-mobile">Mobile number (optional)</FieldLabel>