emloyee and user fixings

This commit is contained in:
2026-09-11 10:32:47 +05:30
parent 629f3d1f44
commit cb41d503c4
61 changed files with 35831 additions and 1228 deletions
@@ -5,8 +5,11 @@
// "select all children" convenience toggle — the parent's own visibility is
// derived from its children on the frontend (see AppSidebar.tsx's filter), so
// only the children need to carry the actual grant for those groups.
import { Check } from "lucide-react"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
import { NavItem } from "@/types/rbac"
interface Props {
@@ -41,42 +44,60 @@ export function RolePermissionTree({ tree, selectedNavItemIds, selectedSubNavIte
}
return (
<div className="flex flex-col gap-1 rounded-lg border p-4">
{tree.map((item) => {
if (item.children.length === 0) {
return (
<label key={item.navItemId} className="flex items-center gap-3 rounded-md px-2 py-2 hover:bg-muted/50">
<Checkbox
checked={selectedNavItemIds.has(item.navItemId)}
onCheckedChange={() => toggleLeaf(item.navItemId)}
/>
<Label className="cursor-pointer text-base font-medium">{item.label}</Label>
</label>
)
}
<div className="overflow-hidden rounded-lg border">
{tree.map((item, i) => {
const hasChildren = item.children.length > 0
const checkedCount = item.children.filter((c) => selectedSubNavItemIds.has(c.subNavItemId)).length
const allChecked = checkedCount === item.children.length
const allChecked = hasChildren && checkedCount === item.children.length
const someChecked = hasChildren && checkedCount > 0 && !allChecked
return (
<div key={item.navItemId} className="flex flex-col gap-1 border-t pt-2 first:border-t-0 first:pt-0">
<label className="flex items-center gap-3 rounded-md px-2 py-2 hover:bg-muted/50">
<Checkbox checked={allChecked} onCheckedChange={(checked) => toggleAllChildren(item, !!checked)} />
<Label className="cursor-pointer text-base font-semibold">
{item.label} {checkedCount > 0 && !allChecked && <span className="text-sm font-normal text-muted-foreground">({checkedCount} of {item.children.length})</span>}
<div
key={item.navItemId}
className="grid grid-cols-[minmax(11rem,14rem)_1fr] items-start gap-x-4 gap-y-2 border-b bg-white px-4 py-3 last:border-b-0"
>
<label className="flex items-center gap-2.5 pt-0.5">
<Checkbox
checked={hasChildren ? allChecked : selectedNavItemIds.has(item.navItemId)}
indeterminate={someChecked}
onCheckedChange={
hasChildren
? (checked) => toggleAllChildren(item, !!checked)
: () => toggleLeaf(item.navItemId)
}
className="size-5 border-2 border-muted-foreground/50 data-checked:border-blue-600! data-checked:bg-blue-600! data-checked:text-white! [&_svg]:size-4"
/>
<Label className={cn("cursor-pointer text-base", hasChildren ? "font-semibold" : "font-medium")}>
{item.label}
</Label>
</label>
<div className="flex flex-col gap-1 pl-9">
{item.children.map((child) => (
<label key={child.subNavItemId} className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50">
<Checkbox
checked={selectedSubNavItemIds.has(child.subNavItemId)}
onCheckedChange={() => toggleChild(child.subNavItemId)}
/>
<Label className="cursor-pointer text-sm">{child.label}</Label>
</label>
))}
</div>
{hasChildren ? (
<div className="flex flex-wrap items-center gap-1.5">
{item.children.map((child) => {
const checked = selectedSubNavItemIds.has(child.subNavItemId)
return (
<button
key={child.subNavItemId}
type="button"
aria-pressed={checked}
onClick={() => toggleChild(child.subNavItemId)}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-sm font-medium transition-colors",
checked
? "border-transparent bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-500/20 dark:text-blue-300 dark:hover:bg-blue-500/30"
: "border-input bg-white text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground"
)}
>
{checked && <Check className="size-3.5" />}
{child.label}
</button>
)
})}
</div>
) : (
<span className="pt-1 text-sm text-muted-foreground">No sub-items — grants the whole section.</span>
)}
</div>
)
})}
@@ -0,0 +1,236 @@
"use client"
// Read-only Branch detail screen, linked from the "View" action on BranchMasterPage.
// Shared by both entry points (/dashboard/settings/branches/[id] and
// /dashboard/hrm/settings/branches/[id]) the same way BranchMasterPage is.
import { useEffect, useState } from "react"
import { useParams, usePathname } from "next/navigation"
import Link from "next/link"
import { BarChart3, Building2, MapPin, Phone, ReceiptText } from "lucide-react"
import { employeesApi } from "@/lib/api/employees"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Branch } from "@/types/hrm"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import PieChart from "@/components/ui/pie-chart"
import { Skeleton } from "@/components/ui/skeleton"
// Status colors, not a categorical series — same green/gray the Active/Inactive
// badges already use elsewhere on this page, so the chart reads as an extension
// of that badge rather than an unrelated palette.
const STATUS_COLORS = { active: "#22c55e", inactive: "#9ca3af" }
// Sales invoice status — same three colors as the invoice status Badge elsewhere in the app.
const SALES_STATUS_COLORS = { posted: "#22c55e", draft: "#9ca3af", cancelled: "#ef4444" }
const CURRENCY_FORMAT = new Intl.NumberFormat("en-LK", { style: "currency", currency: "LKR" })
export function BranchDetailPage() {
const params = useParams<{ id: string }>()
const branchId = Number(params.id)
const pathname = usePathname()
const listHref = pathname.replace(/\/\d+$/, "")
const [branch, setBranch] = useState<Branch | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [insights, setInsights] = useState<{ employees: number; activeEmployees: number } | null>(null)
const [salesInsights, setSalesInsights] = useState<{ posted: number; draft: number; cancelled: number; revenue: number } | null>(null)
useEffect(() => {
if (!Number.isFinite(branchId)) return
setLoadError(null)
branchesHrmApi
.get(branchId)
.then((res) => setBranch(res.data))
.catch((err) => setLoadError(errorMessage(err)))
}, [branchId])
useEffect(() => {
if (!Number.isFinite(branchId)) return
setInsights(null)
Promise.all([
employeesApi.list({ branchId, page: 1, pageSize: 1 }),
employeesApi.list({ branchId, status: "Active", page: 1, pageSize: 1 }),
])
.then(([all, active]) =>
setInsights({
employees: all.pagination.totalItems,
activeEmployees: active.pagination.totalItems,
})
)
.catch(() => setInsights(null))
}, [branchId])
useEffect(() => {
if (!Number.isFinite(branchId)) return
setSalesInsights(null)
Promise.all([
// Posted revenue is summed from the invoices actually returned, so it's exact up to the
// most recent 200 — fine for an at-a-glance figure, not a statutory total.
salesApi.listInvoices({ branchId, status: "Posted", page: 1, pageSize: 200 }),
salesApi.listInvoices({ branchId, status: "Draft", page: 1, pageSize: 1 }),
salesApi.listInvoices({ branchId, status: "Cancelled", page: 1, pageSize: 1 }),
])
.then(([posted, draft, cancelled]) =>
setSalesInsights({
posted: posted.pagination.totalItems,
draft: draft.pagination.totalItems,
cancelled: cancelled.pagination.totalItems,
revenue: posted.items.reduce((sum, inv) => sum + inv.totals.grandTotal, 0),
})
)
.catch(() => setSalesInsights(null))
}, [branchId])
if (loadError && !branch) {
return (
<div className="flex flex-col gap-4">
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
<Link href={listHref} className={cn(buttonVariants({ variant: "outline" }))}>
Back to branches
</Link>
</div>
)
}
if (!branch) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-72" />
<Skeleton className="h-48 w-full" />
</div>
)
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{branch.name}</h1>
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
branch.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{branch.status}
</Badge>
</div>
<p className="font-mono text-sm text-muted-foreground">{branch.code}</p>
</div>
</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">
<Building2 className="size-4.5 text-primary" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">Branch details</h2>
<p className="text-sm text-muted-foreground">Company branch/location used for multi-branch scoping.</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">Code</span>
<span className="font-mono text-sm text-foreground">{branch.code}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-sm text-muted-foreground">Name</span>
<span className="text-sm text-foreground">{branch.name}</span>
</div>
<div className="flex flex-col gap-1">
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPin className="size-3.5" /> Address
</span>
<span className="text-sm text-foreground">{branch.address ?? "—"}</span>
</div>
<div className="flex flex-col gap-1">
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Phone className="size-3.5" /> Phone Number
</span>
<span className="text-sm text-foreground">{branch.phoneNumber ?? "—"}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm text-foreground">{new Date(branch.createdAt).toLocaleString()}</span>
</div>
{branch.updatedAt && (
<div className="flex flex-col gap-1">
<span className="text-sm text-muted-foreground">Last updated</span>
<span className="text-sm text-foreground">{new Date(branch.updatedAt).toLocaleString()}</span>
</div>
)}
</div>
</section>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<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">
<BarChart3 className="size-4.5 text-primary" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">Workforce status</h2>
<p className="text-sm text-muted-foreground">Active vs. inactive employees at this branch.</p>
</div>
</div>
<div className="mt-5">
{!insights ? (
<Skeleton className="h-64 w-full rounded-xl" />
) : insights.employees === 0 ? (
<p className="text-sm text-muted-foreground">No employees assigned to this branch yet.</p>
) : (
<PieChart
labels={["Active", "Inactive"]}
data={[insights.activeEmployees, insights.employees - insights.activeEmployees]}
backgroundColor={[STATUS_COLORS.active, STATUS_COLORS.inactive]}
/>
)}
</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">
<ReceiptText className="size-4.5 text-primary" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">Sales</h2>
<p className="text-sm text-muted-foreground">Invoices raised at this branch, by status.</p>
</div>
</div>
<div className="mt-5">
{!salesInsights ? (
<Skeleton className="h-64 w-full rounded-xl" />
) : salesInsights.posted + salesInsights.draft + salesInsights.cancelled === 0 ? (
<p className="text-sm text-muted-foreground">No sales invoices for this branch yet.</p>
) : (
<>
<p className="mb-4 text-sm text-muted-foreground">
Posted revenue <span className="font-semibold text-foreground">{CURRENCY_FORMAT.format(salesInsights.revenue)}</span>
</p>
<PieChart
labels={["Posted", "Draft", "Cancelled"]}
data={[salesInsights.posted, salesInsights.draft, salesInsights.cancelled]}
backgroundColor={[SALES_STATUS_COLORS.posted, SALES_STATUS_COLORS.draft, SALES_STATUS_COLORS.cancelled]}
/>
</>
)}
</div>
</section>
</div>
<Link href={listHref} className={cn(buttonVariants({ variant: "outline" }), "ml-auto w-fit")}>
Back to branches
</Link>
</div>
)
}
@@ -6,7 +6,9 @@
// entry points (Settings → Branches and HRM → Settings → Branches) since Branch
// is a cross-cutting concept now, not HRM-specific.
import { useEffect, useState } from "react"
import { Plus } from "lucide-react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Building2, ChevronLeft, ChevronRight, CheckCircle2, Eye, Pencil, Plus, Search, Trash2, X, XCircle } from "lucide-react"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
@@ -14,7 +16,8 @@ import { cn } from "@/lib/utils"
import { Branch } from "@/types/hrm"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Button } from "@/components/ui/button"
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"
@@ -23,23 +26,31 @@ 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_OPTIONS = [5, 10, 20, 25]
export function BranchMasterPage() {
const PAGE_SIZE = 10
const pathname = usePathname()
const [items, setItems] = useState<Branch[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(5)
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<Branch | null>(null)
const [name, setName] = useState("")
const [address, setAddress] = useState("")
const [phoneNumber, setPhoneNumber] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
function load() {
branchesHrmApi
.list({ page, pageSize: PAGE_SIZE })
.list({ page, pageSize, q: debouncedSearch || undefined })
.then((res) => {
setItems(res.items)
setPagination(res.pagination)
@@ -47,16 +58,39 @@ export function BranchMasterPage() {
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page])
useEffect(load, [page, pageSize, debouncedSearch])
useEffect(() => {
const t = setTimeout(() => {
setPage(1)
setDebouncedSearch(search.trim())
}, 300)
return () => clearTimeout(t)
}, [search])
function resetForm() {
setEditing(null)
setName("")
setAddress("")
setPhoneNumber("")
setErrors({})
}
async function handleCreate() {
function openCreateDialog() {
resetForm()
setOpen(true)
}
function openEditDialog(item: Branch) {
setEditing(item)
setName(item.name)
setAddress(item.address ?? "")
setPhoneNumber(item.phoneNumber ?? "")
setErrors({})
setOpen(true)
}
async function handleSubmit() {
const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Name is required"
setErrors(nextErrors)
@@ -64,45 +98,72 @@ export function BranchMasterPage() {
setSubmitting(true)
try {
await branchesHrmApi.create({
name: name.trim(),
address: address.trim() || null,
phoneNumber: phoneNumber.trim() || null,
})
toast.success("Branch created")
const payload = { name: name.trim(), address: address.trim() || null, phoneNumber: phoneNumber.trim() || null }
if (editing) {
// The list response carries no ETag, so re-read to get a fresh If-Match token.
const current = await branchesHrmApi.get(editing.branchId)
await branchesHrmApi.update(editing.branchId, payload, current.etag ?? "")
toast.success("Branch updated")
} else {
await branchesHrmApi.create(payload)
toast.success("Branch created")
}
setOpen(false)
resetForm()
load()
} catch (err) {
toast.error("Could not create branch", errorMessage(err))
toast.error(editing ? "Could not update branch" : "Could not create branch", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function handleDelete(item: Branch) {
setDeletingId(item.branchId)
try {
await branchesHrmApi.remove(item.branchId)
toast.success("Branch deleted", item.name)
load()
} catch (err) {
toast.error("Could not delete branch", errorMessage(err))
} finally {
setDeletingId(null)
}
}
async function toggleStatus(item: Branch) {
const next: EntityStatus = item.status === "Active" ? "Inactive" : "Active"
setActionPendingId(item.branchId)
try {
await branchesHrmApi.updateStatus(item.branchId, next)
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
} finally {
setActionPendingId(null)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<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">Branches</h1>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Branches</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">Company branches/locations — used for multi-branch scoping.</p>
</div>
<Dialog open={open} onOpenChange={(next) => { setOpen(next); if (!next) resetForm() }}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New</Button>} />
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Branch</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New Branch</DialogTitle>
<DialogDescription>Code is generated automatically. Deactivate later — branches are never deleted.</DialogDescription>
<DialogTitle>{editing ? "Edit Branch" : "New Branch"}</DialogTitle>
<DialogDescription>{editing ? "Code cannot be changed." : "Code is generated automatically."}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
@@ -121,68 +182,235 @@ export function BranchMasterPage() {
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
<Button className="min-w-32" onClick={handleSubmit} disabled={submitting}>
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</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 branches by name…"
className="h-10 pl-9 pr-9"
aria-label="Search branches"
/>
{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 && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && items === null && (
<div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>
<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 && items !== null && items.length === 0 && (
{!error && items !== null && items.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 branches match &quot;{debouncedSearch}&quot;</p>
<p className="text-sm text-muted-foreground">Try a different name.</p>
</div>
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
Clear search
</Button>
</div>
)}
{!error && items !== null && items.length === 0 && !debouncedSearch && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<p className="text-base text-muted-foreground">No branches yet.</p>
<Building2 className="size-12 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">No branches yet</p>
<p className="text-sm text-muted-foreground">Add your first branch to get started.</p>
</div>
<Button onClick={openCreateDialog}>
<Plus className="size-4" />
New Branch
</Button>
</div>
)}
{!error && items !== null && items.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Address</TableHead>
<TableHead className="h-12 px-3 text-sm">Phone Number</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.branchId}>
<TableCell className="px-3 py-3.5 font-medium">{item.code}</TableCell>
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{item.address ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{item.phoneNumber ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>
{item.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<Button variant="ghost" size="sm" onClick={() => toggleStatus(item)}>
{item.status === "Active" ? "Deactivate" : "Activate"}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<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-4 text-sm">Branch</TableHead>
<TableHead className="h-11 w-32 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 px-4 text-sm">Address</TableHead>
<TableHead className="h-11 w-40 px-4 text-sm">Phone Number</TableHead>
<TableHead className="h-11 w-28 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-40 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.branchId} className="align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-semibold text-foreground">{item.name}</TableCell>
<TableCell className="px-4 py-4">
<span className="font-mono text-xs text-muted-foreground">{item.code}</span>
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{item.address ?? "—"}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{item.phoneNumber ?? "—"}</TableCell>
<TableCell className="px-4 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
item.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{item.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center gap-1">
<Link
href={`${pathname}/${item.branchId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${item.name}`}
title={`View ${item.name}`}
>
<Eye className="size-4" />
</Link>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${item.name}`}
title={`Edit ${item.name}`}
onClick={() => openEditDialog(item)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
item.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
title={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
disabled={actionPendingId === item.branchId}
onClick={() => toggleStatus(item)}
>
{item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${item.name}`}
title={`Delete ${item.name}`}
disabled={deletingId === item.branchId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${item.name}?`}
description="This permanently removes the branch. Branches referenced by employees, warehouses, or other records cannot be deleted."
confirmLabel="Delete"
onConfirm={() => handleDelete(item)}
/>
</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> branches
</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>
)}
@@ -71,21 +71,21 @@ export function StatCard({
return (
<div
className={cn(
"flex flex-col gap-3 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
"flex flex-col gap-1.5 rounded-xl bg-card p-3.5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
className
)}
>
<div className="flex items-start justify-between gap-2">
<p className="text-base font-medium text-muted-foreground">{label}</p>
<p className="text-sm font-medium text-muted-foreground">{label}</p>
{Icon && (
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-5 text-primary" />
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-4 text-primary" />
</div>
)}
</div>
<div className="flex items-end justify-between gap-2">
<p className="text-xl font-bold tracking-tight text-foreground">{formatValue(value)}</p>
<p className="text-lg font-bold tracking-tight text-foreground">{formatValue(value)}</p>
{trend && trend.length > 1 && <Sparkline points={trend} />}
</div>