This commit is contained in:
2026-09-13 08:30:18 +05:30
parent cb41d503c4
commit 99aa395fb1
41 changed files with 3454 additions and 506 deletions
@@ -268,7 +268,7 @@ function SidebarContent({
return (
<nav
className={cn(
"sidebar-surface flex h-full flex-col rounded-3xl bg-card p-3 shadow-sm ring-1 ring-foreground/10 transition-[width] duration-300 ease-in-out",
"sidebar-surface flex h-full flex-col rounded-3xl bg-card p-3 shadow-sm ring-1 ring-foreground/10 transition-[width] duration-300 ease-in-out print:hidden",
!isMobile && (collapsed ? "w-20" : "w-64")
)}
>
@@ -64,7 +64,7 @@ export function Breadcrumbs() {
}))
return (
<Breadcrumb className="mb-4">
<Breadcrumb className="mb-4 print:hidden">
<BreadcrumbList>
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1
@@ -219,7 +219,7 @@ export function Header() {
}
return (
<header className="mx-3 mt-3 mb-3 flex items-center justify-between gap-2 rounded-3xl bg-card py-4 pr-3 pl-12 shadow-sm ring-1 ring-foreground/10 sm:mx-6 sm:pr-4 sm:pl-14 lg:mx-8 lg:mt-4 lg:mb-4 lg:gap-4 lg:p-4">
<header className="print:hidden mx-3 mt-3 mb-3 flex items-center justify-between gap-2 rounded-3xl bg-card py-4 pr-3 pl-12 shadow-sm ring-1 ring-foreground/10 sm:mx-6 sm:pr-4 sm:pl-14 lg:mx-8 lg:mt-4 lg:mb-4 lg:gap-4 lg:p-4">
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
{showBackButton && (
<button
@@ -4,12 +4,13 @@
// (Branch, Designation, EmploymentType) — identical shape to each other, so one
// component parameterized by the resource's api/labels replaces 3 near-duplicate pages.
import { useEffect, useState } from "react"
import { Plus } from "lucide-react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ApiResult, EntityStatus, PaginationMeta } from "@/types/common"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
@@ -19,16 +20,22 @@ 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, 25, 50]
interface CodeNamed {
code: string
name: string
status: EntityStatus
createdAt: string
}
interface Api<T extends CodeNamed> {
list(params: { page: number; pageSize: number }): Promise<{ items: T[]; pagination: PaginationMeta }>
list(params: { page: number; pageSize: number; q?: string }): Promise<{ items: T[]; pagination: PaginationMeta }>
get(id: number): Promise<ApiResult<T>>
create(request: { code: string; name: string }): Promise<ApiResult<T>>
update(id: number, request: { name: string }, ifMatch: string): Promise<ApiResult<T>>
updateStatus(id: number, status: EntityStatus): Promise<void>
remove(id: number): Promise<void>
}
export function CodeNameMasterPage<T extends CodeNamed>({
@@ -42,11 +49,15 @@ export function CodeNameMasterPage<T extends CodeNamed>({
idOf: (item: T) => number
api: Api<T>
}) {
const PAGE_SIZE = 10
const singular = title.replace(/s$/, "")
const [items, setItems] = useState<T[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [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 [code, setCode] = useState("")
@@ -54,9 +65,16 @@ export function CodeNameMasterPage<T extends CodeNamed>({
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [editItem, setEditItem] = useState<T | null>(null)
const [editEtag, setEditEtag] = useState<string | null>(null)
const [editName, setEditName] = useState("")
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
const [editSubmitting, setEditSubmitting] = useState(false)
const [editLoading, setEditLoading] = useState(false)
function load() {
api
.list({ page, pageSize: PAGE_SIZE })
.list({ page, pageSize, q: debouncedSearch || undefined })
.then((res) => {
setItems(res.items)
setPagination(res.pagination)
@@ -64,7 +82,15 @@ export function CodeNameMasterPage<T extends CodeNamed>({
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const t = setTimeout(() => {
setPage(1)
setDebouncedSearch(search.trim())
}, 300)
return () => clearTimeout(t)
}, [search])
async function handleCreate() {
const nextErrors: Record<string, string> = {}
@@ -76,7 +102,7 @@ export function CodeNameMasterPage<T extends CodeNamed>({
setSubmitting(true)
try {
await api.create({ code: code.trim(), name: name.trim() })
toast.success(`${title.replace(/s$/, "")} created`)
toast.success(`${singular} created`)
setOpen(false)
setCode("")
setName("")
@@ -89,29 +115,90 @@ export function CodeNameMasterPage<T extends CodeNamed>({
}
}
async function openEdit(item: T) {
setEditItem(item)
setEditName(item.name)
setEditErrors({})
setEditEtag(null)
setEditLoading(true)
try {
const res = await api.get(idOf(item))
setEditEtag(res.etag)
} catch (err) {
toast.error("Could not load record", errorMessage(err))
setEditItem(null)
} finally {
setEditLoading(false)
}
}
async function handleEditSave() {
if (!editItem || !editEtag) return
const nextErrors: Record<string, string> = {}
if (!editName.trim()) nextErrors.name = "Name is required"
setEditErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setEditSubmitting(true)
try {
await api.update(idOf(editItem), { name: editName.trim() }, editEtag)
toast.success(`${singular} updated`)
setEditItem(null)
load()
} catch (err) {
toast.error("Could not save", errorMessage(err))
} finally {
setEditSubmitting(false)
}
}
async function toggleStatus(item: T) {
const next: EntityStatus = item.status === "Active" ? "Inactive" : "Active"
setActionPendingId(idOf(item))
try {
await api.updateStatus(idOf(item), next)
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
} finally {
setActionPendingId(null)
}
}
async function handleDelete(item: T) {
setActionPendingId(idOf(item))
try {
await api.remove(idOf(item))
toast.success(`${singular} deleted`)
load()
} catch (err) {
toast.error("Could not delete", 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">{title}</h1>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">{title}</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">{description}</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New</Button>} />
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New {singular}</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New {title.replace(/s$/, "")}</DialogTitle>
<DialogDescription>Deactivate later — masters are never deleted.</DialogDescription>
<DialogTitle>New {singular}</DialogTitle>
<DialogDescription>You can deactivate or delete it later.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.code}>
@@ -133,58 +220,239 @@ export function CodeNameMasterPage<T extends CodeNamed>({
</Dialog>
</div>
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>Edit {singular}</DialogTitle>
<DialogDescription>Code can&apos;t be changed after creation.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="m-edit-code">Code</FieldLabel>
<Input id="m-edit-code" value={editItem?.code ?? ""} disabled />
</Field>
<Field data-invalid={!!editErrors.name}>
<FieldLabel htmlFor="m-edit-name">Name</FieldLabel>
<Input
id="m-edit-name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
aria-invalid={!!editErrors.name}
disabled={editLoading}
/>
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"}
</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 by code or name…`}
className="h-10 pl-9 pr-9"
aria-label={`Search ${title.toLowerCase()}`}
/>
{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="h-4 w-24" />
<Skeleton className="h-4 w-40" />
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-4 w-20" />
<Skeleton className="ml-auto h-7 w-20" />
</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 {title.toLowerCase()} match &quot;{debouncedSearch}&quot;</p>
<p className="text-sm text-muted-foreground">Try a different code or 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 records yet.</p>
</div>
)}
{!error && items !== null && items.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={idOf(item)}>
<TableCell className="px-3 py-3.5 font-medium">{item.code}</TableCell>
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>
{item.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<Button variant="ghost" size="sm" onClick={() => toggleStatus(item)}>
{item.status === "Active" ? "Deactivate" : "Activate"}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-1/5 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-1/5 px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-1/5 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-1/5 px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-1/5 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={idOf(item)} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{item.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{item.name}</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 text-sm text-muted-foreground">
{new Date(item.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${item.name}`}
title={`Edit ${item.name}`}
onClick={() => openEdit(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 === idOf(item)}
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 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${item.name}`}
title={`Delete ${item.name}`}
disabled={actionPendingId === idOf(item)}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${item.name}?`}
description="This permanently removes this record. This cannot be undone. Blocked if it's referenced by other records — deactivate it instead in that case."
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> {title.toLowerCase()}
</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>
)}