Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev
This commit is contained in:
@@ -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'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 & 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's OnLeave status and Payroll'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'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's OnLeave classification and Payroll'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's paid affects payroll'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 "and above".</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>
|
||||
|
||||
@@ -4,12 +4,17 @@ import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import {
|
||||
Banknote,
|
||||
Boxes,
|
||||
Building2,
|
||||
CalendarCheck,
|
||||
CalendarClock,
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
FileBarChart,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
IdCard,
|
||||
LayoutGrid,
|
||||
ListTree,
|
||||
Menu,
|
||||
@@ -81,6 +86,22 @@ const navItems: {
|
||||
{ title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
|
||||
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
||||
{ title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
|
||||
{
|
||||
title: "HRM",
|
||||
code: "hrm",
|
||||
href: "/dashboard/hrm",
|
||||
landingHref: "/dashboard/hrm/employees",
|
||||
icon: IdCard,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Employees", code: "hrm.employees", href: "/dashboard/hrm/employees", icon: Users },
|
||||
{ title: "Attendance", code: "hrm.attendance", href: "/dashboard/hrm/attendance", icon: CalendarCheck },
|
||||
{ title: "Leave", code: "hrm.leave", href: "/dashboard/hrm/leave", icon: CalendarClock },
|
||||
{ title: "Payroll", code: "hrm.payroll", href: "/dashboard/hrm/payroll", icon: Banknote },
|
||||
{ title: "Reports", code: "hrm.reports", href: "/dashboard/hrm/reports", icon: FileBarChart },
|
||||
{ title: "Settings", code: "hrm.settings", href: "/dashboard/hrm/settings", icon: SlidersHorizontal },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
code: "settings",
|
||||
@@ -294,17 +315,21 @@ export function AppSidebar() {
|
||||
// flashing the full menu to a restricted role. Once resolved, a nav item
|
||||
// is visible if its own code is granted, or (for parents) if any child is.
|
||||
//
|
||||
// "procurement" is exempted from that check (frontend-only): no role is currently
|
||||
// seeded with NAV:procurement or its children server-side, which would hide the whole
|
||||
// section for everyone. Remove this bypass once roles are granted the permission
|
||||
// properly (Settings → Roles → Sidebar permissions) or a backend seed grants it.
|
||||
// "procurement" and "hrm" are exempted from that check (frontend-only): no role is
|
||||
// currently seeded with NAV:procurement/NAV:hrm or their children server-side, which
|
||||
// would hide the whole section for everyone. Remove each bypass once roles are granted
|
||||
// the permission properly (Settings → Roles → Sidebar permissions) or a backend seed
|
||||
// grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
||||
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
||||
// anything server-side.
|
||||
const bypassCodes = new Set(["procurement", "hrm"])
|
||||
const visibleItems = loading
|
||||
? []
|
||||
: navItems
|
||||
.filter((item) => item.code === "procurement" || navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code)))
|
||||
.filter((item) => bypassCodes.has(item.code) || navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code)))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: item.code === "procurement" ? item.children : item.children?.filter((c) => navCodes.includes(c.code)),
|
||||
children: bypassCodes.has(item.code) ? item.children : item.children?.filter((c) => navCodes.includes(c.code)),
|
||||
}))
|
||||
|
||||
// Close mobile menu on route change
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client"
|
||||
|
||||
// Shared list/create/deactivate screen for the plain "code + name" HRM masters
|
||||
// (Branch, Designation, EmploymentType) — identical shape to each other, so one
|
||||
// component parameterized by the resource's api/labels replaces 3 near-duplicate pages.
|
||||
import { useEffect, useState } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
|
||||
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 { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface CodeNamed {
|
||||
code: string
|
||||
name: string
|
||||
status: EntityStatus
|
||||
}
|
||||
|
||||
interface Api<T extends CodeNamed> {
|
||||
list(params: { page: number; pageSize: number }): Promise<{ items: T[]; pagination: PaginationMeta }>
|
||||
create(request: { code: string; name: string }): Promise<{ value: T }>
|
||||
updateStatus(id: number, status: EntityStatus): Promise<void>
|
||||
}
|
||||
|
||||
export function CodeNameMasterPage<T extends CodeNamed>({
|
||||
title,
|
||||
description,
|
||||
idOf,
|
||||
api,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
idOf: (item: T) => number
|
||||
api: Api<T>
|
||||
}) {
|
||||
const PAGE_SIZE = 10
|
||||
const [items, setItems] = useState<T[] | 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 [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
function load() {
|
||||
api
|
||||
.list({ page, pageSize: PAGE_SIZE })
|
||||
.then((res) => {
|
||||
setItems(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [page]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
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 api.create({ code: code.trim(), name: name.trim() })
|
||||
toast.success(`${title.replace(/s$/, "")} created`)
|
||||
setOpen(false)
|
||||
setCode("")
|
||||
setName("")
|
||||
setErrors({})
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not create", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(item: T) {
|
||||
const next: EntityStatus = item.status === "Active" ? "Inactive" : "Active"
|
||||
try {
|
||||
await api.updateStatus(idOf(item), next)
|
||||
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">{title}</h1>
|
||||
<p className="text-base text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New</Button>} />
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New {title.replace(/s$/, "")}</DialogTitle>
|
||||
<DialogDescription>Deactivate later — masters are never deleted.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.code}>
|
||||
<FieldLabel htmlFor="m-code">Code</FieldLabel>
|
||||
<Input id="m-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="m-name">Name</FieldLabel>
|
||||
<Input id="m-name" value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</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 records 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">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={idOf(item)}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{item.code}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{item.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", item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Button variant="ghost" size="sm" onClick={() => toggleStatus(item)}>
|
||||
{item.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>
|
||||
)
|
||||
}
|
||||
@@ -50,10 +50,13 @@ export function readCsrfToken(): string | null {
|
||||
async function rawRequest(path: string, options: RequestOptions = {}): Promise<Response> {
|
||||
const { body, ifMatch, idempotencyKey, csrf, headers, ...rest } = options
|
||||
const csrfToken = csrf ? readCsrfToken() : null
|
||||
// Multipart uploads (attendance files, staff documents) pass a FormData body — the
|
||||
// browser sets its own Content-Type (with boundary), and it must never be JSON-encoded.
|
||||
const isFormData = typeof FormData !== "undefined" && body instanceof FormData
|
||||
|
||||
const finalHeaders: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
...(body !== undefined && !isFormData ? { "Content-Type": "application/json" } : {}),
|
||||
...(ifMatch ? { "If-Match": ifMatch } : {}),
|
||||
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
|
||||
...(csrfToken ? { "X-XSRF-TOKEN": csrfToken } : {}),
|
||||
@@ -64,7 +67,7 @@ async function rawRequest(path: string, options: RequestOptions = {}): Promise<R
|
||||
...rest,
|
||||
credentials: "include", // sends erp_at; the whole auth story depends on this
|
||||
headers: finalHeaders,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
body: isFormData ? (body as FormData) : body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Attendance upload/validate/confirm pipeline (docs/13-BACKEND-HRM-API.md §4).
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { AttendanceBatchStatus, AttendanceRecord, AttendanceStatusValue, AttendanceUploadBatch, RowValidationStatus } from "@/types/hrm"
|
||||
|
||||
export interface ListAttendanceBatchesParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: AttendanceBatchStatus
|
||||
periodYear?: number
|
||||
periodMonth?: number
|
||||
}
|
||||
|
||||
/** Direct-link download — the browser navigates/streams the binary response itself. */
|
||||
export function attendanceTemplateUrl(format?: "csv"): string {
|
||||
return `/api/v1/attendance-batches/template.xlsx${format ? "?format=csv" : ""}`
|
||||
}
|
||||
|
||||
export const attendanceApi = {
|
||||
list(params: ListAttendanceBatchesParams = {}): Promise<PagedResponse<AttendanceUploadBatch>> {
|
||||
return apiRequest<PagedResponse<AttendanceUploadBatch>>(`/attendance-batches${buildQuery(params)}`)
|
||||
},
|
||||
get(batchId: number): Promise<AttendanceUploadBatch> {
|
||||
return apiRequest<AttendanceUploadBatch>(`/attendance-batches/${batchId}`)
|
||||
},
|
||||
upload(file: File, periodStart: string, periodEnd: string): Promise<AttendanceUploadBatch> {
|
||||
const form = new FormData()
|
||||
form.append("file", file)
|
||||
form.append("PeriodStart", periodStart)
|
||||
form.append("PeriodEnd", periodEnd)
|
||||
return apiRequest<AttendanceUploadBatch>("/attendance-batches", { method: "POST", body: form })
|
||||
},
|
||||
|
||||
listRecords(batchId: number, status?: RowValidationStatus): Promise<AttendanceRecord[]> {
|
||||
return apiRequest<AttendanceRecord[]>(`/attendance-batches/${batchId}/records${buildQuery({ status })}`)
|
||||
},
|
||||
updateRecord(
|
||||
batchId: number,
|
||||
recordId: number,
|
||||
request: { checkIn?: string | null; checkOut?: string | null; attendanceStatus?: AttendanceStatusValue | null; notes?: string | null }
|
||||
): Promise<AttendanceRecord> {
|
||||
return apiRequest<AttendanceRecord>(`/attendance-batches/${batchId}/records/${recordId}`, { method: "PUT", body: request })
|
||||
},
|
||||
resolveDuplicate(batchId: number, recordId: number, action: "keep" | "discard" | "supersede"): Promise<void> {
|
||||
return apiRequest<void>(`/attendance-batches/${batchId}/resolve-duplicate`, { method: "POST", body: { recordId, action } })
|
||||
},
|
||||
|
||||
validate(batchId: number): Promise<AttendanceUploadBatch> {
|
||||
return apiRequest<AttendanceUploadBatch>(`/attendance-batches/${batchId}/validate`, { method: "POST" })
|
||||
},
|
||||
confirm(batchId: number): Promise<AttendanceUploadBatch> {
|
||||
return apiRequest<AttendanceUploadBatch>(`/attendance-batches/${batchId}/confirm`, { method: "POST" })
|
||||
},
|
||||
unlock(batchId: number, reason: string): Promise<AttendanceUploadBatch> {
|
||||
return apiRequest<AttendanceUploadBatch>(`/attendance-batches/${batchId}/unlock`, { method: "POST", body: { reason } })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Employee (staff) endpoints, incl. the Employee<->User cross-link, bank details,
|
||||
// documents, salary structure, loans, and leave balances (docs/13-BACKEND-HRM-API.md §3).
|
||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
import { ApiResult, PagedResponse } from "@/types/common"
|
||||
import {
|
||||
CreateEmployeeRequest,
|
||||
EmployeeBankDetail,
|
||||
EmployeeDetail,
|
||||
EmployeeDocument,
|
||||
EmployeeListItem,
|
||||
EmployeeLoan,
|
||||
EmployeeMatch,
|
||||
EmployeeSalaryStructure,
|
||||
EmployeeStatus,
|
||||
LeaveBalance,
|
||||
UpdateEmployeeRequest,
|
||||
UserMatch,
|
||||
} from "@/types/hrm"
|
||||
|
||||
export interface ListEmployeesParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
q?: string
|
||||
status?: EmployeeStatus
|
||||
departmentId?: number
|
||||
designationId?: number
|
||||
branchId?: number
|
||||
}
|
||||
|
||||
export interface CreateSalaryStructureRequest {
|
||||
effectiveFrom: string
|
||||
basicSalary: number
|
||||
lines: { salaryComponentId: number; amount: number }[]
|
||||
}
|
||||
|
||||
export interface CreateEmployeeLoanRequest {
|
||||
loanKind: "Loan" | "Advance"
|
||||
principalAmount: number
|
||||
interestRate: number
|
||||
installmentAmount: number
|
||||
numberOfInstallments: number
|
||||
startYear: number
|
||||
startMonth: number
|
||||
}
|
||||
|
||||
export const employeesApi = {
|
||||
list(params: ListEmployeesParams = {}): Promise<PagedResponse<EmployeeListItem>> {
|
||||
return apiRequest<PagedResponse<EmployeeListItem>>(`/employees${buildQuery(params)}`)
|
||||
},
|
||||
get(employeeId: number): Promise<ApiResult<EmployeeDetail>> {
|
||||
return apiRequestWithETag<EmployeeDetail>(`/employees/${employeeId}`)
|
||||
},
|
||||
create(request: CreateEmployeeRequest): Promise<ApiResult<EmployeeDetail>> {
|
||||
return apiRequestWithETag<EmployeeDetail>("/employees", { method: "POST", body: request })
|
||||
},
|
||||
update(employeeId: number, request: UpdateEmployeeRequest, ifMatch: string): Promise<ApiResult<EmployeeDetail>> {
|
||||
return apiRequestWithETag<EmployeeDetail>(`/employees/${employeeId}`, { method: "PUT", body: request, ifMatch })
|
||||
},
|
||||
updateStatus(employeeId: number, status: EmployeeStatus): Promise<void> {
|
||||
return apiRequest<void>(`/employees/${employeeId}/status`, { method: "PATCH", body: { status } })
|
||||
},
|
||||
|
||||
/** Advisory: does a System User already exist with this email? */
|
||||
emailLookup(email: string): Promise<{ match: UserMatch | null }> {
|
||||
return apiRequest<{ match: UserMatch | null }>(`/employees/email-lookup${buildQuery({ email })}`)
|
||||
},
|
||||
linkUser(employeeId: number, userId: number): Promise<void> {
|
||||
return apiRequest<void>(`/employees/${employeeId}/link-user`, { method: "POST", body: { userId } })
|
||||
},
|
||||
unlinkUser(employeeId: number): Promise<void> {
|
||||
return apiRequest<void>(`/employees/${employeeId}/link-user`, { method: "DELETE" })
|
||||
},
|
||||
|
||||
listBankDetails(employeeId: number): Promise<EmployeeBankDetail[]> {
|
||||
return apiRequest<EmployeeBankDetail[]>(`/employees/${employeeId}/bank-details`)
|
||||
},
|
||||
replaceBankDetails(employeeId: number, items: EmployeeBankDetail[]): Promise<EmployeeBankDetail[]> {
|
||||
return apiRequest<EmployeeBankDetail[]>(`/employees/${employeeId}/bank-details`, { method: "PUT", body: { items } })
|
||||
},
|
||||
|
||||
listDocuments(employeeId: number): Promise<EmployeeDocument[]> {
|
||||
return apiRequest<EmployeeDocument[]>(`/employees/${employeeId}/documents`)
|
||||
},
|
||||
async uploadDocument(
|
||||
employeeId: number,
|
||||
file: File,
|
||||
meta: { hrDocumentTypeId: number; issueDate?: string | null; expiryDate?: string | null; notes?: string | null }
|
||||
): Promise<EmployeeDocument> {
|
||||
const form = new FormData()
|
||||
form.append("file", file)
|
||||
form.append("HrDocumentTypeId", String(meta.hrDocumentTypeId))
|
||||
if (meta.issueDate) form.append("IssueDate", meta.issueDate)
|
||||
if (meta.expiryDate) form.append("ExpiryDate", meta.expiryDate)
|
||||
if (meta.notes) form.append("Notes", meta.notes)
|
||||
return apiRequest<EmployeeDocument>(`/employees/${employeeId}/documents`, { method: "POST", body: form })
|
||||
},
|
||||
documentDownloadUrl(employeeId: number, documentId: number): string {
|
||||
return `/api/v1/employees/${employeeId}/documents/${documentId}/download`
|
||||
},
|
||||
setDocumentStatus(employeeId: number, documentId: number, status: "Active" | "Archived"): Promise<void> {
|
||||
return apiRequest<void>(`/employees/${employeeId}/documents/${documentId}/status`, { method: "PATCH", body: { status } })
|
||||
},
|
||||
|
||||
salaryStructureHistory(employeeId: number): Promise<EmployeeSalaryStructure[]> {
|
||||
return apiRequest<EmployeeSalaryStructure[]>(`/employees/${employeeId}/salary-structure`)
|
||||
},
|
||||
createSalaryStructure(employeeId: number, request: CreateSalaryStructureRequest): Promise<EmployeeSalaryStructure> {
|
||||
return apiRequest<EmployeeSalaryStructure>(`/employees/${employeeId}/salary-structure`, { method: "POST", body: request })
|
||||
},
|
||||
|
||||
listLoans(employeeId: number): Promise<EmployeeLoan[]> {
|
||||
return apiRequest<EmployeeLoan[]>(`/employees/${employeeId}/loans`)
|
||||
},
|
||||
createLoan(employeeId: number, request: CreateEmployeeLoanRequest): Promise<EmployeeLoan> {
|
||||
return apiRequest<EmployeeLoan>(`/employees/${employeeId}/loans`, { method: "POST", body: request })
|
||||
},
|
||||
|
||||
listLeaveBalances(employeeId: number, year?: number): Promise<LeaveBalance[]> {
|
||||
return apiRequest<LeaveBalance[]>(`/employees/${employeeId}/leave-balances${buildQuery({ year })}`)
|
||||
},
|
||||
}
|
||||
|
||||
// Re-exported for the Employee create form's cross-link chip (mirrors employeesApi.emailLookup in reverse).
|
||||
export const employeeCrossLinkApi = {
|
||||
findStaffByEmail(email: string): Promise<{ match: EmployeeMatch | null }> {
|
||||
return apiRequest<{ match: EmployeeMatch | null }>(`/users/email-lookup${buildQuery({ email })}`)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Read-only HRM reports (docs/13-BACKEND-HRM-API.md §6, FR-HR-RPT).
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
import {
|
||||
AttendanceSummaryRow,
|
||||
DocumentExpiryReportRow,
|
||||
LateArrivalReportRow,
|
||||
LeaveBalanceReportRow,
|
||||
OvertimeReportRow,
|
||||
PayrollRegisterRow,
|
||||
SalaryHistoryRow,
|
||||
} from "@/types/hrm"
|
||||
|
||||
export const hrReportsApi = {
|
||||
attendanceSummary(periodYear: number, periodMonth: number, departmentId?: number): Promise<AttendanceSummaryRow[]> {
|
||||
return apiRequest<AttendanceSummaryRow[]>(`/reports/hrm/attendance-summary${buildQuery({ periodYear, periodMonth, departmentId })}`)
|
||||
},
|
||||
overtime(periodYear: number, periodMonth: number): Promise<OvertimeReportRow[]> {
|
||||
return apiRequest<OvertimeReportRow[]>(`/reports/hrm/overtime${buildQuery({ periodYear, periodMonth })}`)
|
||||
},
|
||||
lateArrivals(periodYear: number, periodMonth: number): Promise<LateArrivalReportRow[]> {
|
||||
return apiRequest<LateArrivalReportRow[]>(`/reports/hrm/late-arrivals${buildQuery({ periodYear, periodMonth })}`)
|
||||
},
|
||||
payrollRegister(payrollRunId: number): Promise<PayrollRegisterRow[]> {
|
||||
return apiRequest<PayrollRegisterRow[]>(`/reports/hrm/payroll-register${buildQuery({ payrollRunId })}`)
|
||||
},
|
||||
salaryHistory(employeeId: number): Promise<SalaryHistoryRow[]> {
|
||||
return apiRequest<SalaryHistoryRow[]>(`/reports/hrm/salary-history${buildQuery({ employeeId })}`)
|
||||
},
|
||||
leaveBalances(year: number): Promise<LeaveBalanceReportRow[]> {
|
||||
return apiRequest<LeaveBalanceReportRow[]>(`/reports/hrm/leave-balances${buildQuery({ year })}`)
|
||||
},
|
||||
documentExpiry(withinDays: number): Promise<DocumentExpiryReportRow[]> {
|
||||
return apiRequest<DocumentExpiryReportRow[]>(`/reports/hrm/document-expiry${buildQuery({ withinDays })}`)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Shared CRUD shape for the ~8 near-identical HRM masters (Branch, Department,
|
||||
// Designation, EmploymentType, WorkShift, HrDocumentType, LeaveType, SalaryComponent) —
|
||||
// same ETag/status/list pattern as brandsApi, factored out once instead of copy-pasted 8x.
|
||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
|
||||
|
||||
export interface ListMasterParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
q?: string
|
||||
status?: EntityStatus
|
||||
}
|
||||
|
||||
export function createMasterApi<T, TCreate, TUpdate>(resource: string) {
|
||||
return {
|
||||
list(params: ListMasterParams = {}): Promise<PagedResponse<T>> {
|
||||
return apiRequest<PagedResponse<T>>(`/${resource}${buildQuery(params)}`)
|
||||
},
|
||||
get(id: number): Promise<ApiResult<T>> {
|
||||
return apiRequestWithETag<T>(`/${resource}/${id}`)
|
||||
},
|
||||
create(request: TCreate): Promise<ApiResult<T>> {
|
||||
return apiRequestWithETag<T>(`/${resource}`, { method: "POST", body: request })
|
||||
},
|
||||
update(id: number, request: TUpdate, ifMatch: string): Promise<ApiResult<T>> {
|
||||
return apiRequestWithETag<T>(`/${resource}/${id}`, { method: "PUT", body: request, ifMatch })
|
||||
},
|
||||
updateStatus(id: number, status: EntityStatus): Promise<void> {
|
||||
return apiRequest<void>(`/${resource}/${id}/status`, { method: "PATCH", body: { status } })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// HRM org/reference masters (docs/13-BACKEND-HRM-API.md §2, §5, §6). Each is the
|
||||
// same list/get/create/update/status-toggle shape as brandsApi (see hrm-master-factory).
|
||||
import { createMasterApi } from "@/lib/api/hrm-master-factory"
|
||||
import {
|
||||
Branch,
|
||||
Department,
|
||||
Designation,
|
||||
EmploymentType,
|
||||
HrDocumentType,
|
||||
LeaveType,
|
||||
SalaryComponent,
|
||||
WorkShift,
|
||||
} from "@/types/hrm"
|
||||
|
||||
export interface CreateBranchRequest { code: string; name: string; address?: string | null }
|
||||
export type UpdateBranchRequest = Omit<CreateBranchRequest, "code">
|
||||
export const branchesHrmApi = createMasterApi<Branch, CreateBranchRequest, UpdateBranchRequest>("branches")
|
||||
|
||||
export interface CreateDepartmentRequest {
|
||||
code: string
|
||||
name: string
|
||||
parentDepartmentId?: number | null
|
||||
headEmployeeId?: number | null
|
||||
branchId?: number | null
|
||||
}
|
||||
export type UpdateDepartmentRequest = Omit<CreateDepartmentRequest, "code">
|
||||
export const departmentsApi = createMasterApi<Department, CreateDepartmentRequest, UpdateDepartmentRequest>("departments")
|
||||
|
||||
export interface CreateDesignationRequest { code: string; name: string }
|
||||
export type UpdateDesignationRequest = Omit<CreateDesignationRequest, "code">
|
||||
export const designationsApi = createMasterApi<Designation, CreateDesignationRequest, UpdateDesignationRequest>("designations")
|
||||
|
||||
export interface CreateEmploymentTypeRequest { code: string; name: string }
|
||||
export type UpdateEmploymentTypeRequest = Omit<CreateEmploymentTypeRequest, "code">
|
||||
export const employmentTypesApi = createMasterApi<EmploymentType, CreateEmploymentTypeRequest, UpdateEmploymentTypeRequest>("employment-types")
|
||||
|
||||
export interface CreateWorkShiftRequest {
|
||||
code: string
|
||||
name: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
isOvernight: boolean
|
||||
graceMinutes: number
|
||||
breakMinutes: number
|
||||
standardWorkingMinutes: number
|
||||
otMultiplier: number
|
||||
workingDaysMask: number
|
||||
}
|
||||
export type UpdateWorkShiftRequest = Omit<CreateWorkShiftRequest, "code">
|
||||
export const workShiftsApi = createMasterApi<WorkShift, CreateWorkShiftRequest, UpdateWorkShiftRequest>("work-shifts")
|
||||
|
||||
export interface CreateHrDocumentTypeRequest {
|
||||
code: string
|
||||
name: string
|
||||
category: HrDocumentType["category"]
|
||||
requiredAtOnboarding: boolean
|
||||
expiryTracked: boolean
|
||||
}
|
||||
export type UpdateHrDocumentTypeRequest = Omit<CreateHrDocumentTypeRequest, "code">
|
||||
export const hrDocumentTypesApi = createMasterApi<HrDocumentType, CreateHrDocumentTypeRequest, UpdateHrDocumentTypeRequest>("hr-document-types")
|
||||
|
||||
export interface CreateLeaveTypeRequest {
|
||||
code: string
|
||||
name: string
|
||||
isPaid: boolean
|
||||
countsAsNoPay: boolean
|
||||
accrualPerYear: number
|
||||
carryForwardAllowed: boolean
|
||||
maxCarryForwardDays?: number | null
|
||||
requiresApproval: boolean
|
||||
}
|
||||
export type UpdateLeaveTypeRequest = Omit<CreateLeaveTypeRequest, "code">
|
||||
export const leaveTypesApi = createMasterApi<LeaveType, CreateLeaveTypeRequest, UpdateLeaveTypeRequest>("leave-types")
|
||||
|
||||
export interface CreateSalaryComponentRequest {
|
||||
code: string
|
||||
name: string
|
||||
componentType: SalaryComponent["componentType"]
|
||||
isTaxable: boolean
|
||||
isEpfEtfApplicable: boolean
|
||||
}
|
||||
export type UpdateSalaryComponentRequest = Omit<CreateSalaryComponentRequest, "code">
|
||||
export const salaryComponentsApi = createMasterApi<SalaryComponent, CreateSalaryComponentRequest, UpdateSalaryComponentRequest>("salary-components")
|
||||
@@ -0,0 +1,43 @@
|
||||
// Leave requests (docs/13-BACKEND-HRM-API.md §5).
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { LeaveRequest, LeaveRequestStatus } from "@/types/hrm"
|
||||
|
||||
export interface ListLeaveRequestsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
employeeId?: number
|
||||
status?: LeaveRequestStatus
|
||||
}
|
||||
|
||||
export interface CreateLeaveRequestRequest {
|
||||
employeeId: number
|
||||
leaveTypeId: number
|
||||
startDate: string
|
||||
endDate: string
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export const leaveRequestsApi = {
|
||||
list(params: ListLeaveRequestsParams = {}): Promise<PagedResponse<LeaveRequest>> {
|
||||
return apiRequest<PagedResponse<LeaveRequest>>(`/leave-requests${buildQuery(params)}`)
|
||||
},
|
||||
get(leaveRequestId: number): Promise<LeaveRequest> {
|
||||
return apiRequest<LeaveRequest>(`/leave-requests/${leaveRequestId}`)
|
||||
},
|
||||
create(request: CreateLeaveRequestRequest): Promise<LeaveRequest> {
|
||||
return apiRequest<LeaveRequest>("/leave-requests", { method: "POST", body: request })
|
||||
},
|
||||
submit(leaveRequestId: number): Promise<LeaveRequest> {
|
||||
return apiRequest<LeaveRequest>(`/leave-requests/${leaveRequestId}/submit`, { method: "POST" })
|
||||
},
|
||||
approve(leaveRequestId: number): Promise<LeaveRequest> {
|
||||
return apiRequest<LeaveRequest>(`/leave-requests/${leaveRequestId}/approve`, { method: "POST" })
|
||||
},
|
||||
reject(leaveRequestId: number, reason: string): Promise<LeaveRequest> {
|
||||
return apiRequest<LeaveRequest>(`/leave-requests/${leaveRequestId}/reject`, { method: "POST", body: { reason } })
|
||||
},
|
||||
cancel(leaveRequestId: number): Promise<LeaveRequest> {
|
||||
return apiRequest<LeaveRequest>(`/leave-requests/${leaveRequestId}/cancel`, { method: "POST" })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Payroll runs, statutory settings, tax slabs, payslips (docs/13-BACKEND-HRM-API.md §6).
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { Payslip, PayrollLine, PayrollLineDetail, PayrollRun, PayrollRunStatus, PayrollStatutorySetting, TaxSlab } from "@/types/hrm"
|
||||
|
||||
export interface ListPayrollRunsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
periodYear?: number
|
||||
periodMonth?: number
|
||||
status?: PayrollRunStatus
|
||||
}
|
||||
|
||||
export interface GeneratePayrollRunRequest {
|
||||
periodYear: number
|
||||
periodMonth: number
|
||||
branchId?: number | null
|
||||
}
|
||||
|
||||
export const payrollRunsApi = {
|
||||
list(params: ListPayrollRunsParams = {}): Promise<PagedResponse<PayrollRun>> {
|
||||
return apiRequest<PagedResponse<PayrollRun>>(`/payroll-runs${buildQuery(params)}`)
|
||||
},
|
||||
get(payrollRunId: number): Promise<PayrollRun> {
|
||||
return apiRequest<PayrollRun>(`/payroll-runs/${payrollRunId}`)
|
||||
},
|
||||
listLines(payrollRunId: number): Promise<PayrollLine[]> {
|
||||
return apiRequest<PayrollLine[]>(`/payroll-runs/${payrollRunId}/lines`)
|
||||
},
|
||||
getLine(payrollRunId: number, lineId: number): Promise<PayrollLineDetail> {
|
||||
return apiRequest<PayrollLineDetail>(`/payroll-runs/${payrollRunId}/lines/${lineId}`)
|
||||
},
|
||||
generate(request: GeneratePayrollRunRequest): Promise<PayrollRun> {
|
||||
return apiRequest<PayrollRun>("/payroll-runs", { method: "POST", body: request })
|
||||
},
|
||||
approve(payrollRunId: number): Promise<PayrollRun> {
|
||||
return apiRequest<PayrollRun>(`/payroll-runs/${payrollRunId}/approve`, { method: "POST" })
|
||||
},
|
||||
lock(payrollRunId: number): Promise<PayrollRun> {
|
||||
return apiRequest<PayrollRun>(`/payroll-runs/${payrollRunId}/lock`, { method: "POST" })
|
||||
},
|
||||
unlock(payrollRunId: number, reason: string): Promise<PayrollRun> {
|
||||
return apiRequest<PayrollRun>(`/payroll-runs/${payrollRunId}/unlock`, { method: "POST", body: { reason } })
|
||||
},
|
||||
generatePayslips(payrollRunId: number): Promise<Payslip[]> {
|
||||
return apiRequest<Payslip[]>(`/payroll-runs/${payrollRunId}/generate-payslips`, { method: "POST" })
|
||||
},
|
||||
}
|
||||
|
||||
export const payrollStatutorySettingsApi = {
|
||||
list(): Promise<PayrollStatutorySetting[]> {
|
||||
return apiRequest<PayrollStatutorySetting[]>("/payroll-statutory-settings")
|
||||
},
|
||||
create(request: Omit<PayrollStatutorySetting, "payrollStatutorySettingId" | "effectiveTo">): Promise<PayrollStatutorySetting> {
|
||||
return apiRequest<PayrollStatutorySetting>("/payroll-statutory-settings", { method: "POST", body: request })
|
||||
},
|
||||
}
|
||||
|
||||
export const taxSlabsApi = {
|
||||
list(): Promise<TaxSlab[]> {
|
||||
return apiRequest<TaxSlab[]>("/tax-slabs")
|
||||
},
|
||||
create(request: Omit<TaxSlab, "taxSlabId" | "effectiveTo">): Promise<TaxSlab> {
|
||||
return apiRequest<TaxSlab>("/tax-slabs", { method: "POST", body: request })
|
||||
},
|
||||
}
|
||||
|
||||
export function payslipViewUrl(payslipId: number): string {
|
||||
return `/api/v1/payslips/${payslipId}/view`
|
||||
}
|
||||
@@ -28,6 +28,19 @@ const CODE_MESSAGES: Record<string, string> = {
|
||||
CONCURRENCY_CONFLICT: "This record was changed by someone else. Reload and try again.",
|
||||
PRECONDITION_REQUIRED: "This record needs to be reloaded before it can be updated.",
|
||||
IDEMPOTENCY_REPLAY: "This request was already processed; showing the original result.",
|
||||
EMPLOYEE_CODE_DUPLICATE: "An employee with that code already exists.",
|
||||
EMPLOYEE_ALREADY_LINKED: "This staff record already has a linked system user.",
|
||||
USER_ALREADY_LINKED: "This user account is already linked to a different staff record.",
|
||||
DEPARTMENT_CYCLE_DETECTED: "Setting this parent would create a department cycle.",
|
||||
DOCUMENT_TYPE_IN_USE: "This document type is referenced by existing documents and cannot be removed.",
|
||||
FILE_TYPE_NOT_ALLOWED: "That file type isn't allowed. Use PDF, JPG, PNG, or DOCX.",
|
||||
FILE_TOO_LARGE: "That file is too large.",
|
||||
ATTENDANCE_BATCH_LOCKED: "This attendance batch is locked and cannot be edited.",
|
||||
ATTENDANCE_DUPLICATE_UNRESOLVED: "Some records have unresolved errors or duplicates.",
|
||||
ATTENDANCE_NOT_CONFIRMED: "Attendance for this period must be Confirmed before payroll can be generated.",
|
||||
SALARY_STRUCTURE_OVERLAP: "The new effective date must be after the current salary structure's effective date.",
|
||||
TAX_SLAB_GAP_INVALID: "This tax slab overlaps another slab for the same effective date.",
|
||||
PAYROLL_PERIOD_LOCKED: "This payroll run is locked.",
|
||||
validation_error: "Please check the highlighted fields.",
|
||||
not_found: "The requested record was not found.",
|
||||
conflict: "This action conflicts with the record's current state.",
|
||||
|
||||
Generated
+34
-20
@@ -18,6 +18,7 @@
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"next": "16.2.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-day-picker": "^10.0.1",
|
||||
@@ -81,7 +82,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -532,8 +532,7 @@
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
|
||||
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dotenvx/dotenvx": {
|
||||
"version": "1.75.1",
|
||||
@@ -726,7 +725,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -770,6 +768,28 @@
|
||||
"integrity": "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
|
||||
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
@@ -2309,7 +2329,6 @@
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2375,7 +2394,6 @@
|
||||
"integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.63.0",
|
||||
"@typescript-eslint/types": "8.63.0",
|
||||
@@ -2992,7 +3010,6 @@
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3448,7 +3465,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.42",
|
||||
"caniuse-lite": "^1.0.30001800",
|
||||
@@ -3586,7 +3602,6 @@
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
@@ -3959,7 +3974,6 @@
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
|
||||
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
@@ -4488,7 +4502,6 @@
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -4674,7 +4687,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -4964,7 +4976,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -5594,7 +5605,6 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz",
|
||||
"integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -7109,6 +7119,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-themes": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
@@ -7934,7 +7954,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -7980,7 +7999,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -7993,7 +8011,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz",
|
||||
"integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -9125,7 +9142,6 @@
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -9346,7 +9362,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -9742,7 +9757,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
// HRM DTOs mirroring ERPCore's Dtos/Hrm/*.cs (docs/13-BACKEND-HRM-API.md).
|
||||
import { EntityStatus } from "@/types/common"
|
||||
|
||||
// --- Org masters ---
|
||||
|
||||
export interface Branch {
|
||||
branchId: number
|
||||
code: string
|
||||
name: string
|
||||
address: string | null
|
||||
status: EntityStatus
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
departmentId: number
|
||||
code: string
|
||||
name: string
|
||||
parentDepartmentId: number | null
|
||||
headEmployeeId: number | null
|
||||
branchId: number | null
|
||||
status: EntityStatus
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface Designation {
|
||||
designationId: number
|
||||
code: string
|
||||
name: string
|
||||
status: EntityStatus
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface EmploymentType {
|
||||
employmentTypeId: number
|
||||
code: string
|
||||
name: string
|
||||
status: EntityStatus
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface WorkShift {
|
||||
workShiftId: number
|
||||
code: string
|
||||
name: string
|
||||
startTime: string // "HH:mm:ss"
|
||||
endTime: string
|
||||
isOvernight: boolean
|
||||
graceMinutes: number
|
||||
breakMinutes: number
|
||||
standardWorkingMinutes: number
|
||||
otMultiplier: number
|
||||
workingDaysMask: number
|
||||
status: EntityStatus
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export type HrDocumentCategory = "Identity" | "Educational" | "Contract" | "Certification" | "Statutory" | "Other"
|
||||
|
||||
export interface HrDocumentType {
|
||||
hrDocumentTypeId: number
|
||||
code: string
|
||||
name: string
|
||||
category: HrDocumentCategory
|
||||
requiredAtOnboarding: boolean
|
||||
expiryTracked: boolean
|
||||
status: EntityStatus
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
// --- Employee ---
|
||||
|
||||
export type EmployeeStatus = "Active" | "Suspended" | "Resigned" | "Terminated" | "Retired"
|
||||
export type Gender = "Male" | "Female" | "Other"
|
||||
|
||||
export interface EmployeeListItem {
|
||||
employeeId: number
|
||||
employeeCode: string
|
||||
fullName: string
|
||||
email: string | null
|
||||
departmentId: number
|
||||
departmentName: string | null
|
||||
designationId: number
|
||||
designationName: string | null
|
||||
employmentTypeId: number
|
||||
employmentTypeName: string | null
|
||||
branchId: number | null
|
||||
branchName: string | null
|
||||
status: EmployeeStatus
|
||||
hasUserLink: boolean
|
||||
hireDate: string
|
||||
}
|
||||
|
||||
export interface EmployeeDetail {
|
||||
employeeId: number
|
||||
employeeCode: string
|
||||
fullName: string
|
||||
nic: string | null
|
||||
dateOfBirth: string | null
|
||||
gender: Gender | null
|
||||
nationality: string | null
|
||||
profilePhotoPath: string | null
|
||||
email: string | null
|
||||
personalMobile: string | null
|
||||
addressLine1: string | null
|
||||
addressLine2: string | null
|
||||
city: string | null
|
||||
postalCode: string | null
|
||||
country: string | null
|
||||
emergencyContactName: string | null
|
||||
emergencyContactRelationship: string | null
|
||||
emergencyContactPhone: string | null
|
||||
hireDate: string
|
||||
confirmationDate: string | null
|
||||
lastWorkingDate: string | null
|
||||
departmentId: number
|
||||
designationId: number
|
||||
employmentTypeId: number
|
||||
branchId: number | null
|
||||
workShiftId: number
|
||||
reportingManagerId: number | null
|
||||
epfNumber: string | null
|
||||
etfNumber: string | null
|
||||
taxIdentificationNumber: string | null
|
||||
userId: number | null
|
||||
status: EmployeeStatus
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface CreateEmployeeRequest {
|
||||
employeeCode: string
|
||||
fullName: string
|
||||
nic?: string | null
|
||||
dateOfBirth?: string | null
|
||||
gender?: Gender | null
|
||||
nationality?: string | null
|
||||
email?: string | null
|
||||
personalMobile?: string | null
|
||||
addressLine1?: string | null
|
||||
addressLine2?: string | null
|
||||
city?: string | null
|
||||
postalCode?: string | null
|
||||
country?: string | null
|
||||
emergencyContactName?: string | null
|
||||
emergencyContactRelationship?: string | null
|
||||
emergencyContactPhone?: string | null
|
||||
hireDate: string
|
||||
departmentId: number
|
||||
designationId: number
|
||||
employmentTypeId: number
|
||||
branchId?: number | null
|
||||
workShiftId: number
|
||||
reportingManagerId?: number | null
|
||||
epfNumber?: string | null
|
||||
etfNumber?: string | null
|
||||
taxIdentificationNumber?: string | null
|
||||
linkUserId?: number | null
|
||||
}
|
||||
|
||||
export type UpdateEmployeeRequest = Omit<CreateEmployeeRequest, "employeeCode" | "linkUserId"> & {
|
||||
confirmationDate?: string | null
|
||||
lastWorkingDate?: string | null
|
||||
}
|
||||
|
||||
export interface EmployeeBankDetail {
|
||||
employeeBankDetailId: number | null
|
||||
bankName: string
|
||||
branchName: string
|
||||
accountNumber: string
|
||||
accountHolderName: string
|
||||
swiftCode: string | null
|
||||
isPrimary: boolean
|
||||
status: EntityStatus
|
||||
}
|
||||
|
||||
export interface EmployeeMatch {
|
||||
employeeId: number
|
||||
employeeCode: string
|
||||
fullName: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface UserMatch {
|
||||
userId: number
|
||||
username: string
|
||||
displayName: string
|
||||
email: string
|
||||
}
|
||||
|
||||
// --- Documents ---
|
||||
|
||||
export type EmployeeDocumentStatus = "Active" | "Archived"
|
||||
|
||||
export interface EmployeeDocument {
|
||||
employeeDocumentId: number
|
||||
employeeId: number
|
||||
hrDocumentTypeId: number
|
||||
hrDocumentTypeName: string | null
|
||||
originalFileName: string
|
||||
contentType: string
|
||||
sizeBytes: number
|
||||
issueDate: string | null
|
||||
expiryDate: string | null
|
||||
notes: string | null
|
||||
uploadedBy: number
|
||||
uploadedAt: string
|
||||
status: EmployeeDocumentStatus
|
||||
}
|
||||
|
||||
// --- Attendance ---
|
||||
|
||||
export type AttendanceSourceType = "Excel" | "Csv" | "Manual" | "BiometricDevice"
|
||||
export type AttendanceBatchStatus = "Draft" | "Validated" | "Confirmed" | "UsedInPayroll"
|
||||
export type AttendanceStatusValue = "Present" | "Absent" | "HalfDay" | "OnLeave" | "Holiday" | "WeekOff"
|
||||
export type RowValidationStatus = "Valid" | "DuplicateWithinBatch" | "DuplicateConfirmed" | "EmployeeNotFound" | "InvalidDateTime" | "Error"
|
||||
|
||||
export interface AttendanceUploadBatch {
|
||||
attendanceUploadBatchId: number
|
||||
docNo: string
|
||||
periodStart: string
|
||||
periodEnd: string
|
||||
sourceType: AttendanceSourceType
|
||||
originalFileName: string | null
|
||||
uploadedBy: number
|
||||
uploadedAt: string
|
||||
status: AttendanceBatchStatus
|
||||
confirmedBy: number | null
|
||||
confirmedAt: string | null
|
||||
rowCountTotal: number
|
||||
rowCountDuplicate: number
|
||||
rowCountError: number
|
||||
}
|
||||
|
||||
export interface AttendanceRecord {
|
||||
attendanceRecordId: number
|
||||
attendanceUploadBatchId: number | null
|
||||
employeeId: number
|
||||
employeeCode: string | null
|
||||
employeeName: string | null
|
||||
attendanceDate: string
|
||||
checkIn: string | null
|
||||
checkOut: string | null
|
||||
workingMinutes: number
|
||||
lateMinutes: number
|
||||
earlyLeaveMinutes: number
|
||||
overtimeMinutes: number
|
||||
attendanceStatus: AttendanceStatusValue
|
||||
rowValidationStatus: RowValidationStatus
|
||||
duplicateOfAttendanceRecordId: number | null
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
// --- Leave ---
|
||||
|
||||
export interface LeaveType {
|
||||
leaveTypeId: number
|
||||
code: string
|
||||
name: string
|
||||
isPaid: boolean
|
||||
countsAsNoPay: boolean
|
||||
accrualPerYear: number
|
||||
carryForwardAllowed: boolean
|
||||
maxCarryForwardDays: number | null
|
||||
requiresApproval: boolean
|
||||
status: EntityStatus
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export type LeaveRequestStatus = "Draft" | "Submitted" | "Approved" | "Rejected" | "Cancelled"
|
||||
|
||||
export interface LeaveRequest {
|
||||
leaveRequestId: number
|
||||
docNo: string
|
||||
employeeId: number
|
||||
employeeName: string | null
|
||||
leaveTypeId: number
|
||||
leaveTypeName: string | null
|
||||
startDate: string
|
||||
endDate: string
|
||||
daysCount: number
|
||||
reason: string | null
|
||||
status: LeaveRequestStatus
|
||||
approvedBy: number | null
|
||||
approvedAt: string | null
|
||||
rejectionReason: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface LeaveBalance {
|
||||
leaveBalanceId: number
|
||||
employeeId: number
|
||||
leaveTypeId: number
|
||||
leaveTypeName: string | null
|
||||
year: number
|
||||
entitledDays: number
|
||||
takenDays: number
|
||||
carriedForwardDays: number
|
||||
adjustmentDays: number
|
||||
remainingDays: number
|
||||
}
|
||||
|
||||
// --- Payroll ---
|
||||
|
||||
export type SalaryComponentType = "Earning" | "Deduction"
|
||||
|
||||
export interface SalaryComponent {
|
||||
salaryComponentId: number
|
||||
code: string
|
||||
name: string
|
||||
componentType: SalaryComponentType
|
||||
isTaxable: boolean
|
||||
isEpfEtfApplicable: boolean
|
||||
status: EntityStatus
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface EmployeeSalaryStructureLine {
|
||||
salaryComponentId: number
|
||||
salaryComponentName: string | null
|
||||
amount: number
|
||||
}
|
||||
|
||||
export type SalaryStructureStatus = "Active" | "Superseded"
|
||||
|
||||
export interface EmployeeSalaryStructure {
|
||||
employeeSalaryStructureId: number
|
||||
employeeId: number
|
||||
effectiveFrom: string
|
||||
effectiveTo: string | null
|
||||
basicSalary: number
|
||||
currency: string
|
||||
status: SalaryStructureStatus
|
||||
lines: EmployeeSalaryStructureLine[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type LoanKind = "Loan" | "Advance"
|
||||
export type LoanStatus = "Active" | "Closed" | "Cancelled"
|
||||
export type LoanInstallmentStatus = "Pending" | "Deducted" | "Skipped"
|
||||
|
||||
export interface LoanInstallment {
|
||||
loanInstallmentId: number
|
||||
installmentNumber: number
|
||||
dueYear: number
|
||||
dueMonth: number
|
||||
scheduledAmount: number
|
||||
paidAmount: number | null
|
||||
payrollRunId: number | null
|
||||
status: LoanInstallmentStatus
|
||||
}
|
||||
|
||||
export interface EmployeeLoan {
|
||||
employeeLoanId: number
|
||||
docNo: string
|
||||
employeeId: number
|
||||
loanKind: LoanKind
|
||||
principalAmount: number
|
||||
interestRate: number
|
||||
installmentAmount: number
|
||||
numberOfInstallments: number
|
||||
startYear: number
|
||||
startMonth: number
|
||||
outstandingBalance: number
|
||||
status: LoanStatus
|
||||
installments: LoanInstallment[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PayrollStatutorySetting {
|
||||
payrollStatutorySettingId: number
|
||||
epfEmployeeRate: number
|
||||
epfEmployerRate: number
|
||||
etfEmployerRate: number
|
||||
otMultiplierDefault: number
|
||||
effectiveFrom: string
|
||||
effectiveTo: string | null
|
||||
}
|
||||
|
||||
export interface TaxSlab {
|
||||
taxSlabId: number
|
||||
effectiveFrom: string
|
||||
effectiveTo: string | null
|
||||
lowerBound: number
|
||||
upperBound: number | null
|
||||
rate: number
|
||||
}
|
||||
|
||||
export type PayrollRunStatus = "Draft" | "Approved" | "Locked"
|
||||
export type PayrollLineComponentCategory = "Earning" | "Deduction" | "EmployerContribution"
|
||||
|
||||
export interface PayrollLineComponent {
|
||||
componentCategory: PayrollLineComponentCategory
|
||||
salaryComponentId: number | null
|
||||
label: string
|
||||
amount: number
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export interface PayrollLine {
|
||||
payrollLineId: number
|
||||
payrollRunId: number
|
||||
employeeId: number
|
||||
employeeCode: string | null
|
||||
employeeName: string | null
|
||||
basicSalary: number
|
||||
totalAllowances: number
|
||||
overtimeAmount: number
|
||||
grossSalary: number
|
||||
lateDeductionAmount: number
|
||||
noPayAmount: number
|
||||
loanDeductionAmount: number
|
||||
epfEmployeeAmount: number
|
||||
epfEmployerAmount: number
|
||||
etfEmployerAmount: number
|
||||
taxAmount: number
|
||||
otherDeductionsAmount: number
|
||||
netSalary: number
|
||||
workingDays: number
|
||||
presentDays: number
|
||||
absentDays: number
|
||||
leaveDays: number
|
||||
otMinutesTotal: number
|
||||
lateMinutesTotal: number
|
||||
}
|
||||
|
||||
export interface PayrollLineDetail {
|
||||
line: PayrollLine
|
||||
components: PayrollLineComponent[]
|
||||
}
|
||||
|
||||
export interface PayrollRun {
|
||||
payrollRunId: number
|
||||
docNo: string
|
||||
periodYear: number
|
||||
periodMonth: number
|
||||
branchId: number | null
|
||||
status: PayrollRunStatus
|
||||
generatedBy: number
|
||||
generatedAt: string
|
||||
approvedBy: number | null
|
||||
approvedAt: string | null
|
||||
lockedBy: number | null
|
||||
lockedAt: string | null
|
||||
unlockedBy: number | null
|
||||
unlockedAt: string | null
|
||||
unlockReason: string | null
|
||||
totalGross: number
|
||||
totalNet: number
|
||||
employeeCount: number
|
||||
}
|
||||
|
||||
export interface Payslip {
|
||||
payslipId: number
|
||||
payrollLineId: number
|
||||
generatedAt: string
|
||||
releasedAt: string | null
|
||||
releasedBy: number | null
|
||||
}
|
||||
|
||||
// --- Reports ---
|
||||
|
||||
export interface AttendanceSummaryRow {
|
||||
employeeId: number
|
||||
employeeCode: string
|
||||
employeeName: string
|
||||
departmentName: string | null
|
||||
presentDays: number
|
||||
absentDays: number
|
||||
leaveDays: number
|
||||
halfDays: number
|
||||
otMinutesTotal: number
|
||||
lateMinutesTotal: number
|
||||
}
|
||||
|
||||
export interface OvertimeReportRow {
|
||||
employeeId: number
|
||||
employeeCode: string
|
||||
employeeName: string
|
||||
attendanceDate: string
|
||||
overtimeMinutes: number
|
||||
}
|
||||
|
||||
export interface LateArrivalReportRow {
|
||||
employeeId: number
|
||||
employeeCode: string
|
||||
employeeName: string
|
||||
attendanceDate: string
|
||||
lateMinutes: number
|
||||
}
|
||||
|
||||
export interface PayrollRegisterRow {
|
||||
payrollLineId: number
|
||||
employeeId: number
|
||||
employeeCode: string
|
||||
employeeName: string
|
||||
grossSalary: number
|
||||
totalDeductions: number
|
||||
netSalary: number
|
||||
}
|
||||
|
||||
export interface SalaryHistoryRow {
|
||||
employeeSalaryStructureId: number
|
||||
effectiveFrom: string
|
||||
effectiveTo: string | null
|
||||
basicSalary: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface LeaveBalanceReportRow {
|
||||
employeeId: number
|
||||
employeeCode: string
|
||||
employeeName: string
|
||||
leaveTypeName: string
|
||||
entitledDays: number
|
||||
takenDays: number
|
||||
remainingDays: number
|
||||
}
|
||||
|
||||
export interface DocumentExpiryReportRow {
|
||||
employeeDocumentId: number
|
||||
employeeId: number
|
||||
employeeCode: string
|
||||
employeeName: string
|
||||
documentTypeName: string
|
||||
expiryDate: string
|
||||
daysUntilExpiry: number
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export interface ManagedUser {
|
||||
userId: number
|
||||
username: string
|
||||
displayName: string
|
||||
email: string | null
|
||||
status: EntityStatus
|
||||
roleId: number | null
|
||||
roleCode: string | null
|
||||
@@ -21,6 +22,8 @@ export interface CreateUserRequest {
|
||||
mobileNumber?: string | null
|
||||
/** Left empty to auto-generate — AuthHex emails it to `email`. */
|
||||
password?: string | null
|
||||
/** Explicit, human-confirmed link to an existing unlinked Employee found via email-lookup. */
|
||||
linkEmployeeId?: number | null
|
||||
}
|
||||
|
||||
export interface UpdateUserRoleRequest {
|
||||
|
||||
Reference in New Issue
Block a user