emloyee and user fixings
This commit is contained in:
@@ -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 "{debouncedSearch}"</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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user