emloyee and user fixings
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import { Link2, Plus, Upload } from "lucide-react"
|
||||
import { useParams, useSearchParams } from "next/navigation"
|
||||
import { Briefcase, Link2, Plus, ShieldCheck, Upload, User, X } from "lucide-react"
|
||||
|
||||
import { CreateEmployeeLoanRequest, CreateSalaryStructureRequest, employeesApi } from "@/lib/api/employees"
|
||||
import { branchesHrmApi, departmentsApi, designationsApi, employmentTypesApi, hrDocumentTypesApi, salaryComponentsApi, workShiftsApi } from "@/lib/api/hrm-masters"
|
||||
@@ -44,6 +44,7 @@ function money(n: number): string {
|
||||
|
||||
export default function EmployeeDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const searchParams = useSearchParams()
|
||||
const employeeId = Number(params.id)
|
||||
const { isAdmin, branchId: myBranchId, branchingEnabled } = useAuth()
|
||||
|
||||
@@ -51,6 +52,7 @@ export default function EmployeeDetailPage() {
|
||||
const [employee, setEmployee] = useState<EmployeeDetail | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isEditing, setIsEditing] = useState(searchParams.get("edit") === "1")
|
||||
|
||||
const [departments, setDepartments] = useState<Department[]>([])
|
||||
const [designations, setDesignations] = useState<Designation[]>([])
|
||||
@@ -190,6 +192,7 @@ export default function EmployeeDetailPage() {
|
||||
}, etag)
|
||||
setEmployee(res.data)
|
||||
setEtag(res.etag)
|
||||
setIsEditing(false)
|
||||
toast.success("Employee updated")
|
||||
} catch (err) {
|
||||
toast.error("Could not save", errorMessage(err))
|
||||
@@ -198,6 +201,11 @@ export default function EmployeeDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancelEdit() {
|
||||
setIsEditing(false)
|
||||
load()
|
||||
}
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
if (!uploadTypeId) { toast.error("Select a document type first"); return }
|
||||
try {
|
||||
@@ -214,27 +222,30 @@ export default function EmployeeDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-3 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{employee.fullName}</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
{employee.employeeCode}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">{employee.fullName}</h1>
|
||||
<Badge variant="outline" className={cn("h-6 px-2.5 text-xs", employee.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{employee.status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-x-2 text-sm text-muted-foreground">
|
||||
<span className="font-mono">{employee.employeeCode}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
{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="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>
|
||||
<span>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">
|
||||
<div className="flex w-full gap-1 overflow-x-auto rounded-xl bg-muted p-1">
|
||||
{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")}
|
||||
className={cn("flex-1 shrink-0 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>
|
||||
@@ -242,132 +253,190 @@ export default function EmployeeDetailPage() {
|
||||
</div>
|
||||
|
||||
{tab === "Overview" && (
|
||||
<FieldGroup className="max-w-2xl">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<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>
|
||||
{branchingEnabled && (
|
||||
<Field>
|
||||
<FieldLabel>Branch</FieldLabel>
|
||||
<Select value={String(employee.branchId)} onValueChange={(v) => setEmployee({ ...employee, branchId: Number(v) })}>
|
||||
<SelectTrigger disabled={!isAdmin}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||
<SelectContent>{(isAdmin ? branches : branches.filter((b) => b.branchId === myBranchId || b.branchId === employee.branchId)).map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.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>
|
||||
<div className="flex flex-col gap-4">
|
||||
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<User className="size-4.5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Personal details</h2>
|
||||
<p className="text-sm text-muted-foreground">Contact and identity information.</p>
|
||||
</div>
|
||||
</div>
|
||||
<FieldGroup>
|
||||
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field><FieldLabel>Full name</FieldLabel><Input disabled={!isEditing} value={employee.fullName} onChange={(e) => setEmployee({ ...employee, fullName: e.target.value })} /></Field>
|
||||
<Field><FieldLabel>Email</FieldLabel><Input disabled={!isEditing} type="email" value={employee.email ?? ""} onChange={(e) => setEmployee({ ...employee, email: e.target.value })} /></Field>
|
||||
<Field><FieldLabel>NIC</FieldLabel><Input disabled={!isEditing} value={employee.nic ?? ""} onChange={(e) => setEmployee({ ...employee, nic: e.target.value })} /></Field>
|
||||
<Field><FieldLabel>Personal mobile</FieldLabel><Input disabled={!isEditing} value={employee.personalMobile ?? ""} onChange={(e) => setEmployee({ ...employee, personalMobile: e.target.value })} /></Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Briefcase className="size-4.5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Employment details</h2>
|
||||
<p className="text-sm text-muted-foreground">Where and how this employee works.</p>
|
||||
</div>
|
||||
</div>
|
||||
<FieldGroup>
|
||||
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field>
|
||||
<FieldLabel>Department</FieldLabel>
|
||||
<Select value={String(employee.departmentId)} onValueChange={(v) => setEmployee({ ...employee, departmentId: Number(v) })}>
|
||||
<SelectTrigger disabled={!isEditing}><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 disabled={!isEditing}><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 disabled={!isEditing}><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 disabled={!isEditing}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{branchingEnabled && (
|
||||
<Field>
|
||||
<FieldLabel>Branch</FieldLabel>
|
||||
<Select value={String(employee.branchId)} onValueChange={(v) => setEmployee({ ...employee, branchId: Number(v) })}>
|
||||
<SelectTrigger disabled={!isEditing || !isAdmin}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||
<SelectContent>{(isAdmin ? branches : branches.filter((b) => b.branchId === myBranchId || b.branchId === employee.branchId)).map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<ShieldCheck className="size-4.5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Statutory details</h2>
|
||||
<p className="text-sm text-muted-foreground">Government registration numbers.</p>
|
||||
</div>
|
||||
</div>
|
||||
<FieldGroup>
|
||||
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field><FieldLabel>EPF number</FieldLabel><Input disabled={!isEditing} value={employee.epfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, epfNumber: e.target.value })} /></Field>
|
||||
<Field><FieldLabel>ETF number</FieldLabel><Input disabled={!isEditing} value={employee.etfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, etfNumber: e.target.value })} /></Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</section>
|
||||
|
||||
{isEditing && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleCancelEdit} disabled={saving}>
|
||||
<X className="size-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "Bank Details" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10">
|
||||
<div className="overflow-x-auto">
|
||||
<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 className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Bank</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Branch</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Account</TableHead>
|
||||
<TableHead className="h-11 px-4 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 key={b.employeeBankDetailId ?? i} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-3.5">{b.bankName}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{b.branchName}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{b.accountNumber}</TableCell>
|
||||
<TableCell className="px-4 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>
|
||||
<TableRow><TableCell colSpan={4} className="px-4 py-10 text-center text-muted-foreground">No bank details on file.</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "Documents" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col gap-3 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:flex-row sm:items-center">
|
||||
<Select value={uploadTypeId} onValueChange={(v) => setUploadTypeId(v ?? "")}>
|
||||
<SelectTrigger className="w-56"><SelectValue placeholder="Document type" /></SelectTrigger>
|
||||
<SelectTrigger className="w-full sm: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")}>
|
||||
<label className={cn("inline-flex w-fit cursor-pointer items-center gap-2 rounded-lg border border-black 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>
|
||||
<div className="overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">File</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Uploaded</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Download</TableHead>
|
||||
</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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{documents.map((d) => (
|
||||
<TableRow key={d.employeeDocumentId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-3.5">{d.originalFileName}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{d.hrDocumentTypeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{d.status}</TableCell>
|
||||
<TableCell className="px-4 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-4 py-10 text-center text-muted-foreground">No documents uploaded yet.</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "Salary & Loans" && (
|
||||
<div className="flex flex-col gap-8">
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-6">
|
||||
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
|
||||
<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() }}>
|
||||
@@ -405,32 +474,36 @@ export default function EmployeeDetailPage() {
|
||||
</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>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Effective to</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||
</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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{salaryStructures.map((s) => (
|
||||
<TableRow key={s.employeeSalaryStructureId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{money(s.basicSalary)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{s.status}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{salaryStructures.length === 0 && (
|
||||
<TableRow><TableCell colSpan={4} className="px-4 py-10 text-center text-muted-foreground">No salary structure set yet.</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">Loans & Advances</h2>
|
||||
<Dialog open={loanOpen} onOpenChange={setLoanOpen}>
|
||||
@@ -467,31 +540,35 @@ export default function EmployeeDetailPage() {
|
||||
</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>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Principal</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Outstanding</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||
</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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loans.map((l) => (
|
||||
<TableRow key={l.employeeLoanId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-3.5 font-medium">{l.docNo}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{l.loanKind}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{money(l.principalAmount)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{money(l.outstandingBalance)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{l.status}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{loans.length === 0 && (
|
||||
<TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No loans or advances on file.</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Briefcase, KeyRound, Link2, Save, User } from "lucide-react"
|
||||
|
||||
import { employeesApi } from "@/lib/api/employees"
|
||||
import { branchesHrmApi, departmentsApi, designationsApi, employmentTypesApi, workShiftsApi } from "@/lib/api/hrm-masters"
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Branch, CreateEmployeeRequest, Department, Designation, EmploymentType, UserMatch, WorkShift } from "@/types/hrm"
|
||||
import { Role } from "@/types/rbac"
|
||||
import { UserTypeOption } from "@/types/users"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const emptyForm: CreateEmployeeRequest = {
|
||||
employeeCode: "",
|
||||
fullName: "",
|
||||
email: "",
|
||||
hireDate: new Date().toISOString().slice(0, 10),
|
||||
departmentId: 0,
|
||||
designationId: 0,
|
||||
employmentTypeId: 0,
|
||||
branchId: 0,
|
||||
workShiftId: 0,
|
||||
}
|
||||
|
||||
export default function NewEmployeePage() {
|
||||
const router = useRouter()
|
||||
const { isAdmin, branchId: myBranchId, branchName: myBranchName, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
||||
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
||||
const lockedBranchName = isAdmin ? activeBranchName : myBranchName
|
||||
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
||||
|
||||
const [departments, setDepartments] = useState<Department[]>([])
|
||||
const [designations, setDesignations] = useState<Designation[]>([])
|
||||
const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([])
|
||||
const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
|
||||
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)
|
||||
|
||||
// Optional: create a login account for this employee in the same step.
|
||||
const [grantLogin, setGrantLogin] = useState(false)
|
||||
const [loginUsername, setLoginUsername] = useState("")
|
||||
const [loginRoleId, setLoginRoleId] = useState("")
|
||||
const [loginUserTypeId, setLoginUserTypeId] = useState("")
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [userTypes, setUserTypes] = useState<UserTypeOption[]>([])
|
||||
|
||||
// Admins have access to every branch regardless of which one their staff record names —
|
||||
// don't make the creator pick one, just quietly default it (the Employee row still needs
|
||||
// a home branch on file; it just never gates what an Admin login can see or do).
|
||||
const selectedLoginRole = roles.find((r) => String(r.roleId) === loginRoleId)
|
||||
const loginIsAdmin = grantLogin && !userMatch && selectedLoginRole?.code?.toUpperCase() === "ADMIN"
|
||||
|
||||
useEffect(() => {
|
||||
if (lockedBranchId) setForm((f) => ({ ...f, branchId: lockedBranchId }))
|
||||
}, [lockedBranchId])
|
||||
|
||||
useEffect(() => {
|
||||
if (loginIsAdmin && !form.branchId) {
|
||||
const fallbackBranchId = lockedBranchId ?? branches[0]?.branchId
|
||||
if (fallbackBranchId) setForm((f) => ({ ...f, branchId: fallbackBranchId }))
|
||||
}
|
||||
}, [loginIsAdmin, lockedBranchId, branches, form.branchId])
|
||||
|
||||
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([]))
|
||||
if (branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
rolesApi.list({ pageSize: 200, status: "Active" }).then((r) => setRoles(r.items)).catch(() => setRoles([]))
|
||||
usersApi.userTypes().then(setUserTypes).catch(() => setUserTypes([]))
|
||||
}, [branchingEnabled])
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
if (branchingEnabled && !loginIsAdmin && !form.branchId) nextErrors.branchId = "Branch is required"
|
||||
if (grantLogin && !userMatch) {
|
||||
if (!form.email?.trim()) nextErrors.email = "Email is required to grant login access"
|
||||
if (!loginUsername.trim()) nextErrors.loginUsername = "Username is required"
|
||||
if (!loginRoleId) nextErrors.loginRoleId = "Role is required"
|
||||
if (!loginUserTypeId) nextErrors.loginUserTypeId = "User type is required"
|
||||
}
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const created = await employeesApi.create({
|
||||
...form,
|
||||
email: form.email || null,
|
||||
linkUserId: userMatch ? userMatch.userId : null,
|
||||
})
|
||||
|
||||
// Grant login access in the same step — no separate "Add User" flow needed.
|
||||
if (grantLogin && !userMatch) {
|
||||
try {
|
||||
await usersApi.create({
|
||||
username: loginUsername.trim(),
|
||||
fullName: form.fullName.trim(),
|
||||
email: form.email!.trim(),
|
||||
roleId: Number(loginRoleId),
|
||||
userTypeId: loginUserTypeId,
|
||||
mobileNumber: form.personalMobile || undefined,
|
||||
nic: form.nic || undefined,
|
||||
linkEmployeeId: created.data.employeeId,
|
||||
})
|
||||
toast.success("Employee created", "Login credentials have been emailed to the employee.")
|
||||
} catch (err) {
|
||||
toast.error("Employee created, but login account failed", errorMessage(err))
|
||||
}
|
||||
} else {
|
||||
toast.success("Employee created")
|
||||
}
|
||||
|
||||
router.push(`/dashboard/hrm/employees/${created.data.employeeId}`)
|
||||
} catch (err) {
|
||||
toast.error("Could not create employee", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6 pb-24">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New employee</h1>
|
||||
<p className="text-base text-muted-foreground">Add a staff record — grant login access at the end if this employee needs one.</p>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-5 rounded-2xl border bg-card p-6 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<User className="size-4.5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Personal details</h2>
|
||||
<p className="text-sm text-muted-foreground">Basic identity and contact information.</p>
|
||||
</div>
|
||||
</div>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<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 data-invalid={!!errors.email}>
|
||||
<FieldLabel htmlFor="e-email">Email {grantLogin ? "" : "(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)}
|
||||
aria-invalid={!!errors.email}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
<FieldError errors={[errors.email ? { message: errors.email } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-5 rounded-2xl border bg-card p-6 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Briefcase className="size-4.5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Employment</h2>
|
||||
<p className="text-sm text-muted-foreground">Where this employee sits in the org.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<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: v ? Number(v) : 0 }))}>
|
||||
<SelectTrigger id="e-dept" className="h-10 w-full"><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: v ? Number(v) : 0 }))}>
|
||||
<SelectTrigger id="e-desig" className="h-10 w-full"><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: v ? Number(v) : 0 }))}>
|
||||
<SelectTrigger id="e-etype" className="h-10 w-full"><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: v ? Number(v) : 0 }))}>
|
||||
<SelectTrigger id="e-shift" className="h-10 w-full"><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>
|
||||
{branchingEnabled && loginIsAdmin && (
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel>Branch</FieldLabel>
|
||||
<div className="flex h-9 items-center rounded-lg border border-primary/30 bg-primary/5 px-3 text-sm text-muted-foreground">
|
||||
Not needed — the Admin role grants access to every branch.
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
{branchingEnabled && !loginIsAdmin && (
|
||||
<Field data-invalid={!!errors.branchId}>
|
||||
<FieldLabel htmlFor="e-branch">Branch</FieldLabel>
|
||||
{showBranchPicker ? (
|
||||
<Select value={form.branchId ? String(form.branchId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, branchId: v ? Number(v) : 0 }))}>
|
||||
<SelectTrigger id="e-branch" className="h-10 w-full" aria-invalid={!!errors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div id="e-branch" className="flex h-9 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
|
||||
{lockedBranchName ?? "No branch assigned — contact an admin"}
|
||||
</div>
|
||||
)}
|
||||
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!userMatch && (
|
||||
<section className="flex flex-col gap-5 rounded-2xl border bg-card p-6 shadow-sm">
|
||||
<label className="flex cursor-pointer items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg transition-colors", grantLogin ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground")}>
|
||||
<KeyRound className="size-4.5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Grant login access</h2>
|
||||
<p className="text-sm text-muted-foreground">Optional — creates a system user account for this employee automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={grantLogin} onCheckedChange={(v) => setGrantLogin(v === true)} aria-label="Grant login access" />
|
||||
</label>
|
||||
{grantLogin && (
|
||||
<FieldGroup className="border-t pt-5">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.loginUsername}>
|
||||
<FieldLabel htmlFor="e-login-username">Username</FieldLabel>
|
||||
<Input
|
||||
id="e-login-username"
|
||||
value={loginUsername}
|
||||
onChange={(e) => setLoginUsername(e.target.value)}
|
||||
aria-invalid={!!errors.loginUsername}
|
||||
/>
|
||||
<FieldError errors={[errors.loginUsername ? { message: errors.loginUsername } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.loginRoleId}>
|
||||
<FieldLabel htmlFor="e-login-role">Role</FieldLabel>
|
||||
<Select value={loginRoleId || undefined} onValueChange={(v) => setLoginRoleId(v ?? "")}>
|
||||
<SelectTrigger id="e-login-role" className="h-10 w-full" aria-invalid={!!errors.loginRoleId}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||
<SelectContent>{roles.map((r) => <SelectItem key={r.roleId} value={String(r.roleId)}>{r.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.loginRoleId ? { message: errors.loginRoleId } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.loginUserTypeId} className="sm:col-span-2">
|
||||
<FieldLabel htmlFor="e-login-usertype">User type</FieldLabel>
|
||||
<Select value={loginUserTypeId || undefined} onValueChange={(v) => setLoginUserTypeId(v ?? "")}>
|
||||
<SelectTrigger id="e-login-usertype" className="h-10 w-full" aria-invalid={!!errors.loginUserTypeId}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||
<SelectContent>{userTypes.map((t) => <SelectItem key={t.userTypeId} value={t.userTypeId}>{t.code ?? t.description ?? t.userTypeId}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.loginUserTypeId ? { message: errors.loginUserTypeId } : undefined]} />
|
||||
</Field>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">A password will be auto-generated and emailed to the employee.</p>
|
||||
</FieldGroup>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="sticky bottom-0 -mx-4 flex justify-end gap-3 border-t bg-background/95 px-4 py-4 backdrop-blur supports-backdrop-filter:bg-background/80 sm:-mx-6 sm:px-6">
|
||||
<Link href="/dashboard/hrm/employees" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button onClick={handleCreate} disabled={submitting} className="min-w-40">
|
||||
<Save className="size-4" />
|
||||
{submitting ? "Creating…" : "Create employee"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,136 +2,85 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Link2, Pencil, Plus, Users as UsersIcon } from "lucide-react"
|
||||
import { ChevronLeft, ChevronRight, Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon, X } from "lucide-react"
|
||||
|
||||
import { employeesApi } from "@/lib/api/employees"
|
||||
import { branchesHrmApi, 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 { Branch, CreateEmployeeRequest, Department, Designation, EmployeeListItem, EmploymentType, UserMatch, WorkShift } from "@/types/hrm"
|
||||
import { EmployeeListItem } from "@/types/hrm"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
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 { StatCard } from "@/components/ui/stat-card"
|
||||
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,
|
||||
branchId: 0,
|
||||
workShiftId: 0,
|
||||
}
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
|
||||
export default function EmployeesPage() {
|
||||
const { isAdmin, branchId: myBranchId, branchName: myBranchName, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
||||
// The branch to pin the new hire to without asking: an Admin's currently-selected
|
||||
// header branch, or a non-admin's own branch. Only when an Admin is on "All Branches"
|
||||
// (no single branch implied) do they need the dropdown to pick one explicitly.
|
||||
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
||||
const lockedBranchName = isAdmin ? activeBranchName : myBranchName
|
||||
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
|
||||
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 [pageSize, setPageSize] = useState(5)
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
|
||||
const [departments, setDepartments] = useState<Department[]>([])
|
||||
const [designations, setDesignations] = useState<Designation[]>([])
|
||||
const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([])
|
||||
const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
|
||||
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)
|
||||
const [stats, setStats] = useState<{ total: number; active: number; terminated: number } | null>(null)
|
||||
|
||||
function load() {
|
||||
employeesApi
|
||||
.list({ page, pageSize: PAGE_SIZE, q: q || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
|
||||
.list({ page, pageSize, q: debouncedSearch || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
|
||||
.then((res) => { setEmployees(res.items); setPagination(res.pagination) })
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
useEffect(load, [page, q, activeBranchId, branchingEnabled])
|
||||
useEffect(load, [page, pageSize, debouncedSearch, activeBranchId, branchingEnabled])
|
||||
// Switching branch changes what "page 1" even means — stay on a page past the
|
||||
// new filtered result set otherwise.
|
||||
useEffect(() => setPage(1), [activeBranchId])
|
||||
|
||||
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([]))
|
||||
if (branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
}, [branchingEnabled])
|
||||
const t = setTimeout(() => {
|
||||
setPage(1)
|
||||
setDebouncedSearch(search.trim())
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
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 loadStats() {
|
||||
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
Promise.all([
|
||||
employeesApi.list({ page: 1, pageSize: 1, branchId }),
|
||||
employeesApi.list({ page: 1, pageSize: 1, status: "Active", branchId }),
|
||||
employeesApi.list({ page: 1, pageSize: 1, status: "Terminated", branchId }),
|
||||
])
|
||||
.then(([totalRes, activeRes, terminatedRes]) =>
|
||||
setStats({ total: totalRes.pagination.totalItems, active: activeRes.pagination.totalItems, terminated: terminatedRes.pagination.totalItems })
|
||||
)
|
||||
.catch(() => setStats(null))
|
||||
}
|
||||
useEffect(loadStats, [activeBranchId, branchingEnabled])
|
||||
|
||||
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"
|
||||
if (branchingEnabled && !form.branchId) nextErrors.branchId = "Branch is required"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
// Permanent delete — blocked by the backend (409) if the employee has payroll history.
|
||||
async function handleDelete(e: EmployeeListItem) {
|
||||
setActionPendingId(e.employeeId)
|
||||
try {
|
||||
await employeesApi.create({
|
||||
...form,
|
||||
email: form.email || null,
|
||||
linkUserId: userMatch ? userMatch.userId : null,
|
||||
})
|
||||
toast.success("Employee created")
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
await employeesApi.remove(e.employeeId)
|
||||
toast.success("Employee deleted", `${e.fullName} has been permanently removed.`)
|
||||
load()
|
||||
loadStats()
|
||||
} catch (err) {
|
||||
toast.error("Could not create employee", errorMessage(err))
|
||||
toast.error("Could not delete employee", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
setActionPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,182 +88,261 @@ export default function EmployeesPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Employees</h1>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Employees</h1>
|
||||
{pagination && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{pagination.totalItems}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<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()
|
||||
else if (lockedBranchId) setForm((f) => ({ ...f, branchId: lockedBranchId }))
|
||||
}}
|
||||
>
|
||||
<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-1 gap-3 sm:grid-cols-2">
|
||||
<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-1 gap-3 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.departmentId}>
|
||||
<FieldLabel htmlFor="e-dept">Department</FieldLabel>
|
||||
<Select value={String(form.departmentId)} 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={String(form.designationId)} 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={String(form.employmentTypeId)} 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={String(form.workShiftId)} 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>
|
||||
{branchingEnabled && (
|
||||
<Field data-invalid={!!errors.branchId}>
|
||||
<FieldLabel htmlFor="e-branch">Branch</FieldLabel>
|
||||
{showBranchPicker ? (
|
||||
<Select value={String(form.branchId)} onValueChange={(v) => setForm((f) => ({ ...f, branchId: v ? Number(v) : 0 }))}>
|
||||
<SelectTrigger id="e-branch" aria-invalid={!!errors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div id="e-branch" className="flex h-9 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
|
||||
{lockedBranchName ?? "No branch assigned — contact an admin"}
|
||||
</div>
|
||||
)}
|
||||
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Link href="/dashboard/hrm/employees/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Employee
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Input placeholder="Search by name or employee code…" value={q} onChange={(e) => { setPage(1); setQ(e.target.value) }} className="max-w-sm" />
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{stats ? (
|
||||
<>
|
||||
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} />
|
||||
<StatCard label="Active" value={stats.active} icon={UserCheck} />
|
||||
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
|
||||
<StatCard label="Terminated" value={stats.terminated} icon={UserMinus} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by name or employee code…"
|
||||
className="h-10 pl-9 pr-9"
|
||||
aria-label="Search employees"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</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 && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && employees !== null && employees.length === 0 && (
|
||||
{!error && employees === null && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="ml-4 h-7 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && employees !== null && employees.length === 0 && debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||
<Search className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No employees match "{debouncedSearch}"</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different name or code.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && employees !== null && employees.length === 0 && !debouncedSearch && (
|
||||
<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>
|
||||
<p className="text-base font-medium text-foreground">No employees yet</p>
|
||||
<p className="text-sm text-muted-foreground">Add your first employee record to get started.</p>
|
||||
</div>
|
||||
<Link href="/dashboard/hrm/employees/new" className={cn(buttonVariants())}>
|
||||
<Plus className="size-4" />
|
||||
New Employee
|
||||
</Link>
|
||||
</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>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-48 px-3 text-sm">Employee</TableHead>
|
||||
<TableHead className="h-11 w-32 px-3 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-40 px-3 text-sm">Department</TableHead>
|
||||
<TableHead className="h-11 w-40 px-3 text-sm">Designation</TableHead>
|
||||
<TableHead className="h-11 w-32 px-3 text-sm">Hire Date</TableHead>
|
||||
<TableHead className="h-11 w-32 px-3 text-sm">Login</TableHead>
|
||||
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-24 px-3 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{employees.map((e) => (
|
||||
<TableRow key={e.employeeId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-3 py-4">
|
||||
<Link
|
||||
href={`/dashboard/hrm/employees/${e.employeeId}`}
|
||||
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
>
|
||||
{e.fullName}
|
||||
</Link>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4">
|
||||
<span className="font-mono text-xs text-muted-foreground">{e.employeeCode}</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.departmentName ?? "—"}</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.designationName ?? "—"}</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
|
||||
{new Date(e.hireDate).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4">
|
||||
{e.hasUserLink ? (
|
||||
<Badge variant="outline" className="h-5 w-fit justify-center gap-1 border-transparent bg-primary/10 px-2 text-xs text-primary"><Link2 className="size-3.5" />Linked</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">No login</span>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
e.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{e.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View ${e.fullName}`} title={`View ${e.fullName}`}>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
<Link href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Edit ${e.fullName}`} title={`Edit ${e.fullName}`}>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||
aria-label={`Delete ${e.fullName}`}
|
||||
title={`Delete ${e.fullName}`}
|
||||
disabled={actionPendingId === e.employeeId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${e.fullName}?`}
|
||||
description="This permanently removes the employee and all their HR records (bank details, documents, salary history, loans). This cannot be undone. Blocked if the employee has payroll history."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(e)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> employees
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<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>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center 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
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { BranchDetailPage } from "@/components/settings/BranchDetailPage"
|
||||
|
||||
export default function BranchDetail() {
|
||||
return <BranchDetailPage />
|
||||
}
|
||||
@@ -297,18 +297,18 @@ export default function GrnDetailPage() {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-8 border-t border-border pt-4 text-base">
|
||||
<div className="flex gap-3">
|
||||
<span className="text-muted-foreground">Stock value (excl. VAT)</span>
|
||||
<span className="tabular-nums">{grn.lines.reduce((s, l) => s + l.receivedValue, 0).toFixed(2)}</span>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-1 rounded-xl border border-border bg-white p-5 shadow-sm">
|
||||
<span className="text-sm text-muted-foreground">Stock value (excl. VAT)</span>
|
||||
<span className="text-xl font-semibold tabular-nums">{grn.lines.reduce((s, l) => s + l.receivedValue, 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="text-muted-foreground">VAT</span>
|
||||
<span className="tabular-nums">{grn.lines.reduce((s, l) => s + l.vatAmount, 0).toFixed(2)}</span>
|
||||
<div className="flex flex-col gap-1 rounded-xl border border-border bg-white p-5 shadow-sm">
|
||||
<span className="text-sm text-muted-foreground">VAT</span>
|
||||
<span className="text-xl font-semibold tabular-nums">{grn.lines.reduce((s, l) => s + l.vatAmount, 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="text-muted-foreground">Document total</span>
|
||||
<span className="font-semibold tabular-nums">{grn.lines.reduce((s, l) => s + l.lineTotal, 0).toFixed(2)}</span>
|
||||
<div className="flex flex-col gap-1 rounded-xl border border-border bg-white p-5 shadow-sm">
|
||||
<span className="text-sm text-muted-foreground">Document total</span>
|
||||
<span className="text-xl font-semibold tabular-nums">{grn.lines.reduce((s, l) => s + l.lineTotal, 0).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { BranchDetailPage } from "@/components/settings/BranchDetailPage"
|
||||
|
||||
export default function BranchDetail() {
|
||||
return <BranchDetailPage />
|
||||
}
|
||||
@@ -3,10 +3,10 @@ import { Building2, LayoutTemplate, MapPin, SlidersHorizontal } from "lucide-rea
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Product Configuration",
|
||||
href: "/dashboard/products/settings",
|
||||
icon: SlidersHorizontal,
|
||||
description: "Configure product and master-data options",
|
||||
title: "Company Profile",
|
||||
href: "/dashboard/settings/company-profile",
|
||||
icon: Building2,
|
||||
description: "Invoice header, tax, and bank details",
|
||||
},
|
||||
{
|
||||
title: "Branch Configuration",
|
||||
@@ -14,18 +14,18 @@ const cards = [
|
||||
icon: MapPin,
|
||||
description: "Turn multi-branch mode on or off for this deployment",
|
||||
},
|
||||
{
|
||||
title: "Product Configuration",
|
||||
href: "/dashboard/products/settings",
|
||||
icon: SlidersHorizontal,
|
||||
description: "Configure product and master-data options",
|
||||
},
|
||||
{
|
||||
title: "Invoice Template",
|
||||
href: "/dashboard/settings/invoice-template",
|
||||
icon: LayoutTemplate,
|
||||
description: "Logo, footer, terms, and template style for printed invoices",
|
||||
},
|
||||
{
|
||||
title: "Company Profile",
|
||||
href: "/dashboard/settings/company-profile",
|
||||
icon: Building2,
|
||||
description: "Invoice header, tax, and bank details",
|
||||
},
|
||||
]
|
||||
|
||||
export default function ConfigurationPage() {
|
||||
@@ -38,7 +38,7 @@ export default function ConfigurationPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{cards.map((card) => (
|
||||
<Link
|
||||
key={card.href}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, Save } from "lucide-react"
|
||||
import { AlertTriangle, Power, Save } from "lucide-react"
|
||||
|
||||
import { navApi } from "@/lib/api/nav"
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
@@ -40,6 +40,7 @@ export default function RoleDetailPage() {
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [savingPermissions, setSavingPermissions] = useState(false)
|
||||
const [togglingStatus, setTogglingStatus] = useState(false)
|
||||
|
||||
function load() {
|
||||
setLoadError(null)
|
||||
@@ -93,6 +94,21 @@ export default function RoleDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleStatus() {
|
||||
if (!role) return
|
||||
const nextStatus = role.status === "Active" ? "Inactive" : "Active"
|
||||
setTogglingStatus(true)
|
||||
try {
|
||||
await rolesApi.updateStatus(roleId, nextStatus)
|
||||
setRole((prev) => (prev ? { ...prev, status: nextStatus } : prev))
|
||||
toast.success(nextStatus === "Active" ? "Role activated" : "Role deactivated", `${role.code} is now ${nextStatus.toLowerCase()}.`)
|
||||
} catch (err) {
|
||||
toast.error("Could not update status", errorMessage(err))
|
||||
} finally {
|
||||
setTogglingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSavePermissions() {
|
||||
setSavingPermissions(true)
|
||||
try {
|
||||
@@ -148,6 +164,17 @@ export default function RoleDetailPage() {
|
||||
<p className="text-base text-muted-foreground">{role.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className={role.status === "Active" ? "text-destructive hover:bg-destructive/10" : "text-success hover:bg-success/10"}
|
||||
onClick={handleToggleStatus}
|
||||
disabled={togglingStatus || role.isSystemRole}
|
||||
title={role.isSystemRole ? "System roles can't be deactivated" : undefined}
|
||||
>
|
||||
<Power className="size-4" />
|
||||
{togglingStatus ? "Updating…" : role.status === "Active" ? "Deactivate" : "Activate"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{conflict && (
|
||||
@@ -166,7 +193,7 @@ export default function RoleDetailPage() {
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<section className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<h2 className="text-lg font-semibold text-foreground">Details</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -187,7 +214,7 @@ export default function RoleDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<section className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Sidebar permissions</h2>
|
||||
<p className="text-sm text-muted-foreground">Choose which sections a user with this role can see.</p>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Save } from "lucide-react"
|
||||
|
||||
import { navApi } from "@/lib/api/nav"
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { NavItem } from "@/types/rbac"
|
||||
|
||||
import { RolePermissionTree } from "@/components/auth/RolePermissionTree"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
/** Code is derived from Name, never typed directly (e.g. "Store Manager" -> "STORE_MANAGER"). */
|
||||
function deriveCode(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
}
|
||||
|
||||
export default function NewRolePage() {
|
||||
const router = useRouter()
|
||||
|
||||
const [tree, setTree] = useState<NavItem[] | null>(null)
|
||||
const [name, setName] = useState("")
|
||||
const [navItemIds, setNavItemIds] = useState<Set<number>>(new Set())
|
||||
const [subNavItemIds, setSubNavItemIds] = useState<Set<number>>(new Set())
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
navApi.tree().then(setTree).catch(() => setTree([]))
|
||||
}, [])
|
||||
|
||||
const code = deriveCode(name)
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!name.trim()) nextErrors.name = "Role name is required"
|
||||
else if (!code) nextErrors.name = "Role name must contain at least one letter or number"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await rolesApi.create({ code, name: name.trim() })
|
||||
if (navItemIds.size > 0 || subNavItemIds.size > 0) {
|
||||
await rolesApi.assignPermissions(result.data.roleId, {
|
||||
navItemIds: Array.from(navItemIds),
|
||||
subNavItemIds: Array.from(subNavItemIds),
|
||||
})
|
||||
}
|
||||
toast.success("Role created", `${result.data.code} — ${result.data.name}`)
|
||||
router.push(`/dashboard/settings/roles/${result.data.roleId}`)
|
||||
} catch (err) {
|
||||
const fe = fieldErrors(err)
|
||||
if (fe?.code) setErrors((prev) => ({ ...prev, code: fe.code }))
|
||||
toast.error("Could not create role", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New role</h1>
|
||||
<p className="text-base text-muted-foreground">Created in both the auth service and here.</p>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<h2 className="text-lg font-semibold text-foreground">Details</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base" htmlFor="r-name">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="r-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Manager"
|
||||
aria-invalid={!!errors.name}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base" htmlFor="r-code">
|
||||
Code (auto-generated)
|
||||
</Label>
|
||||
<Input
|
||||
id="r-code"
|
||||
value={code}
|
||||
disabled
|
||||
readOnly
|
||||
placeholder="—"
|
||||
className="h-12 text-base text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Sidebar permissions</h2>
|
||||
<p className="text-sm text-muted-foreground">Choose which sections a user with this role can see.</p>
|
||||
</div>
|
||||
{tree === null ? (
|
||||
<Skeleton className="h-48 w-full" />
|
||||
) : (
|
||||
<RolePermissionTree
|
||||
tree={tree}
|
||||
selectedNavItemIds={navItemIds}
|
||||
selectedSubNavItemIds={subNavItemIds}
|
||||
onChange={(nav, sub) => {
|
||||
setNavItemIds(nav)
|
||||
setSubNavItemIds(sub)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button onClick={handleCreate} disabled={submitting}>
|
||||
<Save className="size-4" />
|
||||
{submitting ? "Creating…" : "Create role"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,63 +2,36 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Pencil, Plus, ShieldCheck, Trash2 } from "lucide-react"
|
||||
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react"
|
||||
|
||||
import { navApi } from "@/lib/api/nav"
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { NavItem, Role } from "@/types/rbac"
|
||||
import { Role } from "@/types/rbac"
|
||||
|
||||
import { RolePermissionTree } from "@/components/auth/RolePermissionTree"
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
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 { 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
|
||||
|
||||
/** Code is derived from Name, never typed directly (e.g. "Store Manager" -> "STORE_MANAGER"). */
|
||||
function deriveCode(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
}
|
||||
|
||||
export default function RolesPage() {
|
||||
const [roles, setRoles] = useState<Role[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [tree, setTree] = useState<NavItem[] | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState("")
|
||||
const [navItemIds, setNavItemIds] = useState<Set<number>>(new Set())
|
||||
const [subNavItemIds, setSubNavItemIds] = useState<Set<number>>(new Set())
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
|
||||
function load() {
|
||||
rolesApi
|
||||
.list({ page, pageSize: PAGE_SIZE })
|
||||
.list({ page, pageSize: PAGE_SIZE, q: debouncedSearch || undefined })
|
||||
.then((res) => {
|
||||
setRoles(res.items)
|
||||
setPagination(res.pagination)
|
||||
@@ -66,47 +39,15 @@ export default function RolesPage() {
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [page])
|
||||
useEffect(load, [page, debouncedSearch])
|
||||
|
||||
useEffect(() => {
|
||||
navApi.tree().then(setTree).catch(() => setTree([]))
|
||||
}, [])
|
||||
|
||||
function resetForm() {
|
||||
setName("")
|
||||
setNavItemIds(new Set())
|
||||
setSubNavItemIds(new Set())
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
const code = deriveCode(name)
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!name.trim()) nextErrors.name = "Role name is required"
|
||||
else if (!code) nextErrors.name = "Role name must contain at least one letter or number"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await rolesApi.create({ code, name: name.trim() })
|
||||
if (navItemIds.size > 0 || subNavItemIds.size > 0) {
|
||||
await rolesApi.assignPermissions(result.data.roleId, {
|
||||
navItemIds: Array.from(navItemIds),
|
||||
subNavItemIds: Array.from(subNavItemIds),
|
||||
})
|
||||
}
|
||||
toast.success("Role created", `${result.data.code} — ${result.data.name}`)
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not create role", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
setPage(1)
|
||||
setDebouncedSearch(search.trim())
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
async function handleDelete(role: Role) {
|
||||
setActionPendingId(role.roleId)
|
||||
@@ -125,73 +66,44 @@ export default function RolesPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Roles</h1>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Roles</h1>
|
||||
{pagination && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{pagination.totalItems}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Manage roles and which sidebar sections each one can see.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
setOpen(v)
|
||||
if (!v) resetForm()
|
||||
}}
|
||||
>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="lg">
|
||||
<Plus className="size-5" />
|
||||
New Role
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New role</DialogTitle>
|
||||
<DialogDescription>Created in both the auth service and here.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="r-name">Name</FieldLabel>
|
||||
<Input id="r-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Manager" aria-invalid={!!errors.name} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="r-code">Code (auto-generated)</FieldLabel>
|
||||
<Input id="r-code" value={code} disabled readOnly placeholder="—" className="text-muted-foreground" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<Link href="/dashboard/settings/roles/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Role
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<FieldLabel>Sidebar permissions</FieldLabel>
|
||||
{tree === null ? (
|
||||
<Skeleton className="h-32 w-full" />
|
||||
) : (
|
||||
<div className="max-h-80 overflow-auto">
|
||||
<RolePermissionTree
|
||||
tree={tree}
|
||||
selectedNavItemIds={navItemIds}
|
||||
selectedSubNavItemIds={subNavItemIds}
|
||||
onChange={(nav, sub) => {
|
||||
setNavItemIds(nav)
|
||||
setSubNavItemIds(sub)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search roles by name or code…"
|
||||
className="h-10 pl-9 pr-9"
|
||||
aria-label="Search roles"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -199,97 +111,184 @@ export default function RolesPage() {
|
||||
)}
|
||||
|
||||
{!error && roles === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="ml-4 h-7 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && roles !== null && roles.length === 0 && (
|
||||
{!error && roles !== null && roles.length === 0 && debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||
<Search className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No roles match "{debouncedSearch}"</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different name or code.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && roles !== null && roles.length === 0 && !debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<ShieldCheck className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No roles yet.</p>
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No roles yet</p>
|
||||
<p className="text-sm text-muted-foreground">Create your first role to start assigning permissions.</p>
|
||||
</div>
|
||||
<Link href="/dashboard/settings/roles/new" className={cn(buttonVariants())}>
|
||||
<Plus className="size-4" />
|
||||
New Role
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && roles !== null && roles.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>
|
||||
{roles.map((r) => (
|
||||
<TableRow key={r.roleId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{r.code}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.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",
|
||||
r.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{r.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href={`/dashboard/settings/roles/${r.roleId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Edit ${r.code}`}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
aria-label={`Delete ${r.code}`}
|
||||
disabled={r.isSystemRole || actionPendingId === r.roleId}
|
||||
/>
|
||||
}
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-56 px-3 text-sm">Role</TableHead>
|
||||
<TableHead className="h-11 w-40 px-3 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-32 px-3 text-sm">Created Date</TableHead>
|
||||
<TableHead className="h-11 w-24 px-3 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.map((r) => (
|
||||
<TableRow key={r.roleId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-3 py-4">
|
||||
<Link
|
||||
href={`/dashboard/settings/roles/${r.roleId}`}
|
||||
className="flex items-start gap-3 rounded-md -m-1 p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${r.code}?`}
|
||||
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(r)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-white text-foreground">
|
||||
<ShieldCheck className="size-4.5" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-0.5">
|
||||
<span className="font-semibold leading-tight text-foreground group-hover:underline">{r.name}</span>
|
||||
{r.isSystemRole && (
|
||||
<Badge variant="outline" className="h-5 w-fit justify-center border-input bg-white px-2 text-xs text-muted-foreground">
|
||||
System
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4">
|
||||
<span className="font-mono text-xs text-muted-foreground">{r.code}</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
r.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{r.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
|
||||
{new Date(r.createdAt).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Link
|
||||
href={`/dashboard/settings/roles/${r.roleId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ${r.code}`}
|
||||
title={`View ${r.code}`}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={`/dashboard/settings/roles/${r.roleId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Edit ${r.code}`}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||
aria-label={`Delete ${r.code}`}
|
||||
title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`}
|
||||
disabled={r.isSystemRole || actionPendingId === r.roleId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${r.code}?`}
|
||||
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(r)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm: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}
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> roles
|
||||
</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))}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
@@ -300,6 +299,7 @@ export default function RolesPage() {
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,64 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Save } from "lucide-react"
|
||||
import { Clock, IdCard, Laptop, ShieldCheck } from "lucide-react"
|
||||
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
import { auditLogsApi } from "@/lib/api/audit-logs"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Role } from "@/types/rbac"
|
||||
import { AuditLog, LoginActivity } from "@/types/audit"
|
||||
import { ManagedUser } from "@/types/users"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const ACTIVITY_PAGE_SIZE = 8
|
||||
const DEVICES_PAGE_SIZE = 8
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const userId = Number(params.id)
|
||||
|
||||
const [user, setUser] = useState<ManagedUser | null>(null)
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [roleId, setRoleId] = useState<string>("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function load() {
|
||||
setLoadError(null)
|
||||
Promise.all([usersApi.get(userId), rolesApi.list({ pageSize: 200, status: "Active" })])
|
||||
.then(([u, roleList]) => {
|
||||
setUser(u)
|
||||
setRoles(roleList.items)
|
||||
setRoleId(u.roleId ? String(u.roleId) : "")
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}
|
||||
const [activity, setActivity] = useState<AuditLog[] | null>(null)
|
||||
const [activityError, setActivityError] = useState<string | null>(null)
|
||||
|
||||
const [devices, setDevices] = useState<LoginActivity[] | null>(null)
|
||||
const [devicesError, setDevicesError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (Number.isFinite(userId)) load()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
if (!Number.isFinite(userId)) return
|
||||
setLoadError(null)
|
||||
usersApi.get(userId).then(setUser).catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [userId])
|
||||
|
||||
async function handleSave() {
|
||||
if (!roleId) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await usersApi.updateRole(userId, { roleId: Number(roleId) })
|
||||
setUser(result)
|
||||
toast.success("Role updated", `${result.username} is now assigned to ${result.roleName}.`)
|
||||
} catch (err) {
|
||||
toast.error("Could not update role", errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(userId)) return
|
||||
auditLogsApi
|
||||
.list({ userId, page: 1, pageSize: ACTIVITY_PAGE_SIZE })
|
||||
.then((res) => setActivity(res.items))
|
||||
.catch((err) => setActivityError(errorMessage(err)))
|
||||
}, [userId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(userId)) return
|
||||
usersApi
|
||||
.loginDevices(userId, { page: 1, pageSize: DEVICES_PAGE_SIZE })
|
||||
.then((res) => setDevices(res.items))
|
||||
.catch((err) => setDevicesError(errorMessage(err)))
|
||||
}, [userId])
|
||||
|
||||
if (loadError && !user) {
|
||||
return (
|
||||
@@ -100,31 +94,145 @@ export default function UserDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:max-w-sm">
|
||||
<Label className="text-base">Role</Label>
|
||||
<Select value={roleId || undefined} onValueChange={(v) => setRoleId(v ?? "")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((r) => (
|
||||
<SelectItem key={r.roleId} value={String(r.roleId)}>
|
||||
{r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<IdCard className="size-4.5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">User details</h2>
|
||||
<p className="text-sm text-muted-foreground">Account identity as recorded in this app.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm text-muted-foreground">Username</span>
|
||||
<span className="font-mono text-sm text-foreground">{user.username}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm text-muted-foreground">Display name</span>
|
||||
<span className="text-sm text-foreground">{user.displayName}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm text-muted-foreground">Email</span>
|
||||
<span className="text-sm text-foreground">{user.email ?? "—"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm text-muted-foreground">User ID</span>
|
||||
<span className="font-mono text-sm text-foreground">{user.userId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/settings/users")}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="lg" onClick={handleSave} disabled={saving || !roleId}>
|
||||
<Save className="size-5" />
|
||||
{saving ? "Saving…" : "Save role"}
|
||||
</Button>
|
||||
</div>
|
||||
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<ShieldCheck className="size-4.5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Role & access</h2>
|
||||
<p className="text-sm text-muted-foreground">Controls what this user can see and do.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-col gap-1 sm:max-w-sm">
|
||||
<span className="text-sm text-muted-foreground">Role</span>
|
||||
<span className="text-sm text-foreground">{user.roleName ?? "—"}</span>
|
||||
{user.roleCode && <p className="text-xs text-muted-foreground">Role code: {user.roleCode}</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Clock className="size-4.5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Recent activity</h2>
|
||||
<p className="text-sm text-muted-foreground">The last changes this user made, from the audit trail.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
{activityError && (
|
||||
<p className="text-sm text-destructive">{activityError}</p>
|
||||
)}
|
||||
{!activityError && activity === null && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
)}
|
||||
{!activityError && activity !== null && activity.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No recorded activity yet.</p>
|
||||
)}
|
||||
{!activityError && activity !== null && activity.length > 0 && (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{activity.map((a) => (
|
||||
<li key={a.auditId} className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center border-transparent px-2 text-xs",
|
||||
a.action === "Create" && "bg-success/10 text-success",
|
||||
a.action === "Update" && "bg-primary/10 text-primary",
|
||||
a.action === "Delete" && "bg-destructive/10 text-destructive",
|
||||
a.action === "Login" && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{a.action}
|
||||
</Badge>
|
||||
{a.action === "Login" ? (
|
||||
<span className="text-foreground">Signed in</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-foreground">{a.entityType}</span>
|
||||
<span className="text-muted-foreground">#{a.entityId}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{new Date(a.createdAt).toLocaleString()}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Laptop className="size-4.5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Login devices</h2>
|
||||
<p className="text-sm text-muted-foreground">Devices and locations this account has signed in from.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
{devicesError && <p className="text-sm text-destructive">{devicesError}</p>}
|
||||
{!devicesError && devices === null && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
)}
|
||||
{!devicesError && devices !== null && devices.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No logins recorded yet.</p>
|
||||
)}
|
||||
{!devicesError && devices !== null && devices.length > 0 && (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{devices.map((d) => (
|
||||
<li key={d.loginActivityId} className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Laptop className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-foreground">{d.deviceLabel ?? "Unknown device"}</span>
|
||||
{d.ipAddress && <span className="font-mono text-xs text-muted-foreground">{d.ipAddress}</span>}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{new Date(d.loginAt).toLocaleString()}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,104 +2,38 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Link2, Pencil, Plus, Users as UsersIcon } from "lucide-react"
|
||||
import { ChevronLeft, ChevronRight, Eye, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react"
|
||||
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { employeeCrossLinkApi } from "@/lib/api/employees"
|
||||
import { branchesHrmApi, departmentsApi, designationsApi, employmentTypesApi, workShiftsApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { Role } from "@/types/rbac"
|
||||
import { Branch, Department, Designation, EmployeeMatch, EmploymentType, WorkShift } from "@/types/hrm"
|
||||
import { ManagedUser, UserTypeOption } from "@/types/users"
|
||||
import { ManagedUser } from "@/types/users"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
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 { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 20, 25]
|
||||
|
||||
export default function UsersPage() {
|
||||
const { isAdmin, branchId: myBranchId, branchName: myBranchName, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
||||
// Same "don't ask if it's already implied" heuristic as the Employees form: an Admin's
|
||||
// currently-selected header branch, or a non-admin's own branch.
|
||||
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
||||
const lockedBranchName = isAdmin ? activeBranchName : myBranchName
|
||||
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
||||
|
||||
const [users, setUsers] = useState<ManagedUser[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [userTypes, setUserTypes] = useState<UserTypeOption[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [username, setUsername] = useState("")
|
||||
const [fullName, setFullName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [mobileNumber, setMobileNumber] = useState("")
|
||||
const [nic, setNic] = useState("")
|
||||
const [roleId, setRoleId] = useState<string>("")
|
||||
const [userTypeId, setUserTypeId] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Non-admin roles must be scoped to one branch (see UserManagementService.CreateAsync) —
|
||||
// these fields build the Employee record that carries it.
|
||||
const selectedRole = roles.find((r) => String(r.roleId) === roleId)
|
||||
const requiresBranchScope = !!roleId && selectedRole?.code?.toUpperCase() !== "ADMIN"
|
||||
|
||||
const [employeeCode, setEmployeeCode] = useState("")
|
||||
const [hireDate, setHireDate] = useState(() => new Date().toISOString().slice(0, 10))
|
||||
const [departmentId, setDepartmentId] = useState(0)
|
||||
const [designationId, setDesignationId] = useState(0)
|
||||
const [employmentTypeId, setEmploymentTypeId] = useState(0)
|
||||
const [workShiftId, setWorkShiftId] = useState(0)
|
||||
const [branchId, setBranchId] = useState(0)
|
||||
|
||||
const [departments, setDepartments] = useState<Department[]>([])
|
||||
const [designations, setDesignations] = useState<Designation[]>([])
|
||||
const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([])
|
||||
const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
const [pageSize, setPageSize] = useState(5)
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null)
|
||||
|
||||
function load() {
|
||||
usersApi
|
||||
.list({ page, pageSize: PAGE_SIZE })
|
||||
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||
.then((res) => {
|
||||
setUsers(res.items)
|
||||
setPagination(res.pagination)
|
||||
@@ -107,113 +41,44 @@ export default function UsersPage() {
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [page])
|
||||
useEffect(load, [page, pageSize, debouncedSearch])
|
||||
|
||||
useEffect(() => {
|
||||
rolesApi.list({ pageSize: 200, status: "Active" }).then((res) => setRoles(res.items)).catch(() => setRoles([]))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
usersApi
|
||||
.userTypes()
|
||||
.then((types) => {
|
||||
setUserTypes(types)
|
||||
// Only one user type exists today (AuthHex's "Admin"/"dev" seed) — default to it
|
||||
// so the admin never has to pick a raw GUID; the select still lets them switch
|
||||
// if more types are added later.
|
||||
if (types.length > 0) setUserTypeId((current) => current || types[0].userTypeId)
|
||||
})
|
||||
.catch(() => setUserTypes([]))
|
||||
}, [])
|
||||
|
||||
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([]))
|
||||
if (branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
}, [branchingEnabled])
|
||||
|
||||
// Once a non-admin role is picked, default the branch to whatever's already implied
|
||||
// (an Admin's selected header branch, or a non-admin creator's own branch) instead of
|
||||
// making them pick it again.
|
||||
useEffect(() => {
|
||||
if (requiresBranchScope && lockedBranchId && !branchId) setBranchId(lockedBranchId)
|
||||
}, [requiresBranchScope, lockedBranchId, branchId])
|
||||
|
||||
function resetForm() {
|
||||
setUsername("")
|
||||
setFullName("")
|
||||
setEmail("")
|
||||
setMobileNumber("")
|
||||
setNic("")
|
||||
setRoleId("")
|
||||
setUserTypeId("")
|
||||
setEmployeeCode("")
|
||||
setHireDate(new Date().toISOString().slice(0, 10))
|
||||
setDepartmentId(0)
|
||||
setDesignationId(0)
|
||||
setEmploymentTypeId(0)
|
||||
setWorkShiftId(0)
|
||||
setBranchId(0)
|
||||
setErrors({})
|
||||
setStaffMatch(null)
|
||||
function loadStats() {
|
||||
Promise.all([
|
||||
usersApi.list({ page: 1, pageSize: 1 }),
|
||||
usersApi.list({ page: 1, pageSize: 1, status: "Active" }),
|
||||
usersApi.list({ page: 1, pageSize: 1, roleCode: "Admin" }),
|
||||
])
|
||||
.then(([totalRes, activeRes, adminsRes]) =>
|
||||
setStats({ total: totalRes.pagination.totalItems, active: activeRes.pagination.totalItems, admins: adminsRes.pagination.totalItems })
|
||||
)
|
||||
.catch(() => setStats(null))
|
||||
}
|
||||
useEffect(loadStats, [])
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!username.trim()) nextErrors.username = "Username is required"
|
||||
if (!fullName.trim()) nextErrors.fullName = "Full name is required"
|
||||
if (!email.trim()) nextErrors.email = "Email is required"
|
||||
if (!roleId) nextErrors.roleId = "Role is required"
|
||||
if (!userTypeId.trim()) nextErrors.userTypeId = "User type is required"
|
||||
// A matched existing Staff record already carries a branch — only ask for a fresh
|
||||
// one to be created when there's no such link.
|
||||
if (requiresBranchScope && !staffMatch) {
|
||||
if (!employeeCode.trim()) nextErrors.employeeCode = "Employee code is required"
|
||||
if (!hireDate) nextErrors.hireDate = "Hire date is required"
|
||||
if (!departmentId) nextErrors.departmentId = "Department is required"
|
||||
if (!designationId) nextErrors.designationId = "Designation is required"
|
||||
if (!employmentTypeId) nextErrors.employmentTypeId = "Employment type is required"
|
||||
if (!workShiftId) nextErrors.workShiftId = "Work shift is required"
|
||||
if (branchingEnabled && !branchId) nextErrors.branchId = "Branch is required"
|
||||
}
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setPage(1)
|
||||
setDebouncedSearch(search.trim())
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
setSubmitting(true)
|
||||
// Toggles the account's standing inside ERPCore (BranchAccessService and the rest of the
|
||||
// app gate on this) — the auth-service credential is untouched either way, and the row is
|
||||
// never removed from the table, just flagged so the user can't log in while Inactive.
|
||||
async function handleToggleStatus(u: ManagedUser) {
|
||||
const nextStatus = u.status === "Active" ? "Inactive" : "Active"
|
||||
setActionPendingId(u.userId)
|
||||
try {
|
||||
const result = await usersApi.create({
|
||||
username: username.trim(),
|
||||
fullName: fullName.trim(),
|
||||
email: email.trim(),
|
||||
mobileNumber: mobileNumber || null,
|
||||
nic: nic || null,
|
||||
roleId: Number(roleId),
|
||||
userTypeId: userTypeId.trim(),
|
||||
linkEmployeeId: staffMatch ? staffMatch.employeeId : null,
|
||||
...(requiresBranchScope && !staffMatch
|
||||
? {
|
||||
employeeCode: employeeCode.trim(),
|
||||
hireDate,
|
||||
departmentId,
|
||||
designationId,
|
||||
employmentTypeId,
|
||||
workShiftId,
|
||||
branchId: branchId || null,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
toast.success("User created", `Credentials have been emailed to ${result.username}.`)
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
await usersApi.updateStatus(u.userId, { status: nextStatus })
|
||||
toast.success(nextStatus === "Active" ? "User activated" : "User deactivated", `${u.username} is now ${nextStatus}.`)
|
||||
load()
|
||||
loadStats()
|
||||
} catch (err) {
|
||||
const fe = fieldErrors(err)
|
||||
if (fe) setErrors(fe)
|
||||
toast.error("Could not create user", errorMessage(err))
|
||||
toast.error("Could not update user status", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
setActionPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,176 +86,50 @@ export default function UsersPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Users</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Create accounts and assign roles. New users receive their credentials by email.
|
||||
</p>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Users</h1>
|
||||
{pagination && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{pagination.totalItems}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">Manage user accounts and their roles.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="lg">
|
||||
<Plus className="size-5" />
|
||||
New User
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New user</DialogTitle>
|
||||
<DialogDescription>Created in both the auth service and here; password is emailed.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.username}>
|
||||
<FieldLabel htmlFor="u-username">Username</FieldLabel>
|
||||
<Input id="u-username" value={username} onChange={(e) => setUsername(e.target.value)} aria-invalid={!!errors.username} />
|
||||
<FieldError errors={[errors.username ? { message: errors.username } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.fullName}>
|
||||
<FieldLabel htmlFor="u-fullname">Full name</FieldLabel>
|
||||
<Input id="u-fullname" value={fullName} onChange={(e) => setFullName(e.target.value)} aria-invalid={!!errors.fullName} />
|
||||
<FieldError errors={[errors.fullName ? { message: errors.fullName } : undefined]} />
|
||||
</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)}
|
||||
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>
|
||||
<Input id="u-mobile" value={mobileNumber} onChange={(e) => setMobileNumber(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="u-nic">NIC (optional)</FieldLabel>
|
||||
<Input id="u-nic" value={nic} onChange={(e) => setNic(e.target.value)} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.roleId}>
|
||||
<FieldLabel htmlFor="u-role">Role</FieldLabel>
|
||||
<Select value={roleId || undefined} onValueChange={(v) => setRoleId(v ?? "")}>
|
||||
<SelectTrigger id="u-role">
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((r) => (
|
||||
<SelectItem key={r.roleId} value={String(r.roleId)}>
|
||||
{r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.roleId ? { message: errors.roleId } : undefined]} />
|
||||
</Field>
|
||||
{requiresBranchScope && !staffMatch && (
|
||||
<>
|
||||
<div className="rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm text-muted-foreground">
|
||||
This role isn't Admin — it must be scoped to one branch. These details create the linked staff record.
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.employeeCode}>
|
||||
<FieldLabel htmlFor="u-empcode">Employee code</FieldLabel>
|
||||
<Input id="u-empcode" value={employeeCode} onChange={(e) => setEmployeeCode(e.target.value)} aria-invalid={!!errors.employeeCode} />
|
||||
<FieldError errors={[errors.employeeCode ? { message: errors.employeeCode } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.hireDate}>
|
||||
<FieldLabel htmlFor="u-hire">Hire date</FieldLabel>
|
||||
<Input id="u-hire" type="date" value={hireDate} onChange={(e) => setHireDate(e.target.value)} aria-invalid={!!errors.hireDate} />
|
||||
<FieldError errors={[errors.hireDate ? { message: errors.hireDate } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.departmentId}>
|
||||
<FieldLabel htmlFor="u-dept">Department</FieldLabel>
|
||||
<Select value={departmentId ? String(departmentId) : undefined} onValueChange={(v) => setDepartmentId(v ? Number(v) : 0)}>
|
||||
<SelectTrigger id="u-dept" aria-invalid={!!errors.departmentId}><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="u-desig">Designation</FieldLabel>
|
||||
<Select value={designationId ? String(designationId) : undefined} onValueChange={(v) => setDesignationId(v ? Number(v) : 0)}>
|
||||
<SelectTrigger id="u-desig" aria-invalid={!!errors.designationId}><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="u-etype">Employment type</FieldLabel>
|
||||
<Select value={employmentTypeId ? String(employmentTypeId) : undefined} onValueChange={(v) => setEmploymentTypeId(v ? Number(v) : 0)}>
|
||||
<SelectTrigger id="u-etype" aria-invalid={!!errors.employmentTypeId}><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="u-shift">Work shift</FieldLabel>
|
||||
<Select value={workShiftId ? String(workShiftId) : undefined} onValueChange={(v) => setWorkShiftId(v ? Number(v) : 0)}>
|
||||
<SelectTrigger id="u-shift" aria-invalid={!!errors.workShiftId}><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>
|
||||
{branchingEnabled && (
|
||||
<Field data-invalid={!!errors.branchId}>
|
||||
<FieldLabel htmlFor="u-branch">Branch</FieldLabel>
|
||||
{showBranchPicker ? (
|
||||
<Select value={branchId ? String(branchId) : undefined} onValueChange={(v) => setBranchId(v ? Number(v) : 0)}>
|
||||
<SelectTrigger id="u-branch" aria-invalid={!!errors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div id="u-branch" className="flex h-9 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
|
||||
{lockedBranchName ?? "No branch assigned — contact an admin"}
|
||||
</div>
|
||||
)}
|
||||
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Field data-invalid={!!errors.userTypeId}>
|
||||
<FieldLabel htmlFor="u-usertype">User type</FieldLabel>
|
||||
<Select value={userTypeId || undefined} onValueChange={(v) => setUserTypeId(v ?? "")}>
|
||||
<SelectTrigger id="u-usertype">
|
||||
<SelectValue placeholder="Select a user type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userTypes.map((t) => (
|
||||
<SelectItem key={t.userTypeId} value={t.userTypeId}>
|
||||
{t.code ?? t.userTypeId}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.userTypeId ? { message: errors.userTypeId } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{stats ? (
|
||||
<>
|
||||
<StatCard label="Total users" value={stats.total} icon={UsersIcon} />
|
||||
<StatCard label="Active" value={stats.active} icon={UserCheck} />
|
||||
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
|
||||
<StatCard label="Admins" value={stats.admins} icon={ShieldCheck} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search users by username or name…"
|
||||
className="h-10 pl-9 pr-9"
|
||||
aria-label="Search users"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -398,14 +137,39 @@ export default function UsersPage() {
|
||||
)}
|
||||
|
||||
{!error && users === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="ml-4 h-7 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && users !== null && users.length === 0 && (
|
||||
{!error && users !== null && users.length === 0 && debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||
<Search className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No users match "{debouncedSearch}"</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different username or name.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && users !== null && users.length === 0 && !debouncedSearch && (
|
||||
<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 users yet.</p>
|
||||
@@ -413,59 +177,122 @@ export default function UsersPage() {
|
||||
)}
|
||||
|
||||
{!error && users !== null && users.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Username</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Display name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Role</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
<TableRow key={u.userId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{u.username}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{u.displayName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{u.roleName ?? "—"}</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",
|
||||
u.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{u.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link
|
||||
href={`/dashboard/settings/users/${u.userId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Edit ${u.username}`}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-48 px-3 text-sm">Username</TableHead>
|
||||
<TableHead className="h-11 w-48 px-3 text-sm">Display name</TableHead>
|
||||
<TableHead className="h-11 w-40 px-3 text-sm">Role</TableHead>
|
||||
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-32 px-3 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
<TableRow key={u.userId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-3 py-4">
|
||||
<Link
|
||||
href={`/dashboard/settings/users/${u.userId}`}
|
||||
className="font-semibold leading-tight text-foreground group-hover:underline"
|
||||
>
|
||||
{u.username}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{u.displayName}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{u.roleName ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
u.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{u.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Link
|
||||
href={`/dashboard/settings/users/${u.userId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ${u.username}`}
|
||||
title={`View ${u.username}`}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/settings/users/${u.userId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Edit ${u.username}`}
|
||||
title={`Edit ${u.username}`}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
u.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={u.status === "Active" ? `Deactivate ${u.username}` : `Activate ${u.username}`}
|
||||
title={u.status === "Active" ? `Deactivate ${u.username}` : `Activate ${u.username}`}
|
||||
disabled={actionPendingId === u.userId}
|
||||
onClick={() => handleToggleStatus(u)}
|
||||
>
|
||||
{u.status === "Active" ? <UserX className="size-4" /> : <UserCheck className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> users
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<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))}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
@@ -476,6 +303,7 @@ export default function UsersPage() {
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user