@@ -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 >
) }