responsiveness and ui fixers
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { ArrowLeft, Bell, Boxes, Building2, FileText, LogOut, Receipt, Search, Settings, User } from "lucide-react"
|
||||
import { ArrowLeft, Bell, Boxes, Building2, FileText, LogOut, Receipt, Search, Settings, User, X } from "lucide-react"
|
||||
|
||||
import { authApi } from "@/lib/api/auth"
|
||||
import { searchApi } from "@/lib/api/search"
|
||||
@@ -201,7 +201,9 @@ export function Header() {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [searchResults, setSearchResults] = useState<GlobalSearchResult[]>([])
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [mobileSearchOpen, setMobileSearchOpen] = useState(false)
|
||||
const searchBoxRef = useRef<HTMLDivElement>(null)
|
||||
const mobileSearchBoxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const term = searchQuery.trim()
|
||||
@@ -221,6 +223,9 @@ export function Header() {
|
||||
if (searchBoxRef.current && !searchBoxRef.current.contains(e.target as Node)) {
|
||||
setSearchOpen(false)
|
||||
}
|
||||
if (mobileSearchBoxRef.current && !mobileSearchBoxRef.current.contains(e.target as Node)) {
|
||||
setMobileSearchOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onClickOutside)
|
||||
return () => document.removeEventListener("mousedown", onClickOutside)
|
||||
@@ -228,6 +233,7 @@ export function Header() {
|
||||
|
||||
function goToSearchResult(result: GlobalSearchResult) {
|
||||
setSearchOpen(false)
|
||||
setMobileSearchOpen(false)
|
||||
setSearchQuery("")
|
||||
setSearchResults([])
|
||||
router.push(SEARCH_TYPE_META[result.type].href(result.id))
|
||||
@@ -263,7 +269,8 @@ export function Header() {
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<header className="print:hidden mx-3 mt-3 mb-3 flex flex-col 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:p-4">
|
||||
<div className="flex items-center justify-between gap-2 lg:gap-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
|
||||
{showBackButton && (
|
||||
<button
|
||||
@@ -279,6 +286,18 @@ export function Header() {
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5 sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileSearchOpen((v) => !v)}
|
||||
aria-label="Search"
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground sm:hidden",
|
||||
mobileSearchOpen && "bg-muted text-foreground"
|
||||
)}
|
||||
>
|
||||
<Search className="size-5" />
|
||||
</button>
|
||||
|
||||
{branchingEnabled && (isAdmin ? (
|
||||
<Select value={activeBranchId ? String(activeBranchId) : ALL_BRANCHES} onValueChange={handleBranchChange}>
|
||||
<SelectTrigger
|
||||
@@ -375,10 +394,7 @@ export function Header() {
|
||||
<Badge className="absolute top-1.5 right-1.5 size-2 rounded-full bg-primary p-0" />
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-[calc(100vw-2rem)] max-w-sm sm:w-96"
|
||||
>
|
||||
<PopoverContent align="end" sideOffset={10} className="w-[calc(100vw-1.5rem)] max-w-[22rem] sm:w-96">
|
||||
<div className="flex items-center justify-between border-b border-foreground/10 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-foreground">Notifications</p>
|
||||
{unreadCount > 0 && (
|
||||
@@ -477,6 +493,68 @@ export function Header() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mobileSearchOpen && (
|
||||
<div ref={mobileSearchBoxRef} className="relative sm:hidden">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search item, PO, invoice, employee…"
|
||||
className="h-10 w-full rounded-full border border-transparent bg-muted pr-9 pl-9 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMobileSearchOpen(false)
|
||||
setSearchQuery("")
|
||||
setSearchResults([])
|
||||
}}
|
||||
aria-label="Close search"
|
||||
className="absolute top-1/2 right-2 flex size-6 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground hover:bg-background hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
|
||||
{searchQuery.trim().length >= 2 && (
|
||||
<div className="absolute top-full right-0 left-0 z-50 mt-2 max-h-96 overflow-y-auto rounded-xl bg-popover py-1.5 text-sm shadow-md ring-1 ring-foreground/10">
|
||||
{searchResults.length === 0 ? (
|
||||
<p className="p-4 text-center text-sm text-muted-foreground">No matches found.</p>
|
||||
) : (
|
||||
searchGrouped.map((group) => {
|
||||
const meta = SEARCH_TYPE_META[group.type]
|
||||
const Icon = meta.icon
|
||||
return (
|
||||
<div key={group.type}>
|
||||
<p className="px-4 pt-1 pb-1 text-xs font-medium text-muted-foreground">{meta.label}</p>
|
||||
{group.items.map((item) => (
|
||||
<button
|
||||
key={`${item.type}-${item.id}`}
|
||||
type="button"
|
||||
onClick={() => goToSearchResult(item)}
|
||||
className="flex w-full items-center gap-3 px-4 py-2 text-left hover:bg-primary/10"
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">{item.title}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{item.number}
|
||||
{item.subtitle ? ` · ${item.subtitle}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ export function RolePermissionActions({
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<div className="grid grid-cols-[minmax(9rem,14rem)_1fr] items-center gap-x-4 border-b bg-muted/40 px-4 py-2 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
<div className="hidden items-center gap-x-4 border-b bg-muted/40 px-4 py-2 text-xs font-semibold tracking-wide text-muted-foreground uppercase sm:grid sm:grid-cols-[minmax(9rem,14rem)_1fr]">
|
||||
<span>Section</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
@@ -172,7 +172,7 @@ export function RolePermissionActions({
|
||||
{groups.map((group) => (
|
||||
<div key={group.navItemId} className="border-b last:border-b-0">
|
||||
{group.own ? (
|
||||
<div className="grid grid-cols-[minmax(9rem,14rem)_1fr] items-center gap-x-4 bg-white px-4 py-3">
|
||||
<div className="grid grid-cols-1 gap-2 bg-white px-4 py-3 sm:grid-cols-[minmax(9rem,14rem)_1fr] sm:items-center sm:gap-x-4">
|
||||
<label className="flex items-center gap-2.5">
|
||||
<SelectAllCheckbox
|
||||
actions={actionsFor(group.own.id, navItemActions)}
|
||||
@@ -189,7 +189,7 @@ export function RolePermissionActions({
|
||||
)}
|
||||
|
||||
{group.children.map((child) => (
|
||||
<div key={child.id} className="grid grid-cols-[minmax(9rem,14rem)_1fr] items-center gap-x-4 bg-white px-4 py-3 pl-8">
|
||||
<div key={child.id} className="grid grid-cols-1 gap-2 bg-white px-4 py-3 pl-8 sm:grid-cols-[minmax(9rem,14rem)_1fr] sm:items-center sm:gap-x-4">
|
||||
<label className="flex items-center gap-2.5">
|
||||
<span className="text-muted-foreground/40">└</span>
|
||||
<SelectAllCheckbox
|
||||
|
||||
@@ -54,7 +54,7 @@ export function RolePermissionTree({ tree, selectedNavItemIds, selectedSubNavIte
|
||||
return (
|
||||
<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"
|
||||
className="grid grid-cols-1 gap-2 border-b bg-white px-4 py-3 last:border-b-0 sm:grid-cols-[minmax(11rem,14rem)_1fr] sm:items-start sm:gap-x-4 sm:gap-y-2"
|
||||
>
|
||||
<label className="flex items-center gap-2.5 pt-0.5">
|
||||
<Checkbox
|
||||
|
||||
@@ -21,6 +21,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
interface CodeNamed {
|
||||
code: string
|
||||
@@ -58,6 +61,7 @@ export function CodeNameMasterPage<T extends CodeNamed>({
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
@@ -80,6 +84,12 @@ export function CodeNameMasterPage<T extends CodeNamed>({
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -212,9 +222,9 @@ export function CodeNameMasterPage<T extends CodeNamed>({
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
</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>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -243,9 +253,9 @@ export function CodeNameMasterPage<T extends CodeNamed>({
|
||||
<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}>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||
{editSubmitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -313,100 +323,191 @@ export function CodeNameMasterPage<T extends CodeNamed>({
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length > 0 && (
|
||||
<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">
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{items.map((item) => (
|
||||
<div key={idOf(item)} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{item.name}</p>
|
||||
<span className="font-mono text-xs text-muted-foreground">{item.code}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit shrink-0 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>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-3 text-sm">
|
||||
<p className="text-muted-foreground">Created</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{new Date(item.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
||||
<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
|
||||
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}`}
|
||||
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)}
|
||||
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>
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pagination && items.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<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.totalItems > 0 && (
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="hidden items-center gap-3 md:flex md: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>–
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { salesReturnsApi } from "@/lib/api/sales-returns"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateSalesReturnLine } from "@/lib/validations/sales"
|
||||
import { CreateSalesReturnLineInput, SalesInvoice, SalesInvoiceLine } from "@/types/sales"
|
||||
import { ItemListItem } from "@/types/master-data"
|
||||
import { ReasonCode } from "@/types/stock"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface LineState {
|
||||
selected: boolean
|
||||
qty: string
|
||||
}
|
||||
|
||||
export interface SalesReturnFormProps {
|
||||
presetInvoiceId?: number | null
|
||||
presetLineId?: number | null
|
||||
onCancel: () => void
|
||||
onSuccess: (docNo: string) => void
|
||||
}
|
||||
|
||||
/** Shared form body behind both the standalone /sales-returns/new page (deep-linked from an
|
||||
* invoice's "Return this line" action, which needs presetInvoiceId/presetLineId) and the
|
||||
* "New Return" popup on the sales-returns list. */
|
||||
export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, onCancel, onSuccess }: SalesReturnFormProps) {
|
||||
const [invoices, setInvoices] = useState<SalesInvoice[] | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [remainingByLine, setRemainingByLine] = useState<Record<number, number>>({})
|
||||
const [remainingLoading, setRemainingLoading] = useState(false)
|
||||
|
||||
const [invoiceId, setInvoiceId] = useState<number | null>(presetInvoiceId)
|
||||
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
|
||||
const [lineState, setLineState] = useState<Record<number, LineState>>({})
|
||||
const [lineErrors, setLineErrors] = useState<Record<number, Record<string, string>>>({})
|
||||
|
||||
const [headerError, setHeaderError] = useState<string | null>(null)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([salesApi.listInvoices({ status: "Posted", pageSize: 200 }), itemsApi.list({ pageSize: 200 }), reasonCodesApi.list("Return")])
|
||||
.then(([invoiceList, it, rc]) => {
|
||||
// Only Posted invoices have stock movements to return against.
|
||||
Promise.all(invoiceList.items.map((i) => salesApi.getInvoice(i.salesInvoiceId))).then((results) => setInvoices(results.map((r) => r.data)))
|
||||
setItems(it.items)
|
||||
setReasonCodes(rc.items)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
const selectedInvoice = invoices?.find((i) => i.salesInvoiceId === invoiceId) ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedInvoice) {
|
||||
setLineState({})
|
||||
setRemainingByLine({})
|
||||
return
|
||||
}
|
||||
const next: Record<number, LineState> = {}
|
||||
for (const line of selectedInvoice.lines) {
|
||||
next[line.salesInvoiceLineId] = { selected: false, qty: "" }
|
||||
}
|
||||
setLineState(next)
|
||||
setRemainingLoading(true)
|
||||
salesReturnsApi
|
||||
.getRemaining(selectedInvoice.salesInvoiceId)
|
||||
.then((rows) => {
|
||||
const map: Record<number, number> = {}
|
||||
for (const row of rows) map[row.salesInvoiceLineId] = row.remainingQty
|
||||
setRemainingByLine(map)
|
||||
if (presetLineId) {
|
||||
const remaining = map[presetLineId] ?? 0
|
||||
setLineState((prev) => ({ ...prev, [presetLineId]: { selected: remaining > 0, qty: remaining > 0 ? String(remaining) : "" } }))
|
||||
}
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
.finally(() => setRemainingLoading(false))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedInvoice?.salesInvoiceId])
|
||||
|
||||
function itemFor(itemId: number) {
|
||||
return items.find((i) => i.itemId === itemId)
|
||||
}
|
||||
|
||||
function remainingFor(lineId: number, fallback: number) {
|
||||
return remainingByLine[lineId] ?? fallback
|
||||
}
|
||||
|
||||
function toggleLine(line: SalesInvoiceLine) {
|
||||
const remaining = remainingFor(line.salesInvoiceLineId, line.qty)
|
||||
setLineState((prev) => ({
|
||||
...prev,
|
||||
[line.salesInvoiceLineId]: { selected: !prev[line.salesInvoiceLineId]?.selected, qty: prev[line.salesInvoiceLineId]?.qty || String(remaining) },
|
||||
}))
|
||||
}
|
||||
|
||||
function setQty(lineId: number, qty: string) {
|
||||
setLineState((prev) => ({ ...prev, [lineId]: { ...prev[lineId], qty } }))
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setHeaderError(null)
|
||||
setSubmitError(null)
|
||||
|
||||
if (!selectedInvoice) {
|
||||
setHeaderError("Select an invoice to return against.")
|
||||
return
|
||||
}
|
||||
if (!reasonCodeId) {
|
||||
setHeaderError("Select a reason code.")
|
||||
return
|
||||
}
|
||||
|
||||
const selectedLines = selectedInvoice.lines.filter((l) => lineState[l.salesInvoiceLineId]?.selected)
|
||||
if (selectedLines.length === 0) {
|
||||
setSubmitError("Select at least one line to return.")
|
||||
return
|
||||
}
|
||||
|
||||
const nextErrors: Record<number, Record<string, string>> = {}
|
||||
for (const line of selectedLines) {
|
||||
const errors = validateSalesReturnLine({
|
||||
salesInvoiceLineId: line.salesInvoiceLineId,
|
||||
qty: lineState[line.salesInvoiceLineId].qty,
|
||||
maxQty: remainingFor(line.salesInvoiceLineId, line.qty),
|
||||
})
|
||||
if (Object.keys(errors).length > 0) nextErrors[line.salesInvoiceLineId] = errors
|
||||
}
|
||||
setLineErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
setSubmitError("Fix the highlighted lines before submitting.")
|
||||
return
|
||||
}
|
||||
|
||||
const payloadLines: CreateSalesReturnLineInput[] = selectedLines.map((l) => ({
|
||||
salesInvoiceLineId: l.salesInvoiceLineId,
|
||||
itemId: l.itemId,
|
||||
qty: Number(lineState[l.salesInvoiceLineId].qty),
|
||||
}))
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const salesReturn = await salesReturnsApi.create({
|
||||
customerId: selectedInvoice.customerId,
|
||||
warehouseId: selectedInvoice.warehouseId,
|
||||
reasonCodeId,
|
||||
lines: payloadLines,
|
||||
})
|
||||
toast.success("Sales return posted", `${salesReturn.docNo} — ${salesReturn.ledgerRefs.length} ledger entr${salesReturn.ledgerRefs.length === 1 ? "y" : "ies"} posted.`)
|
||||
onSuccess(salesReturn.docNo)
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
toast.error("Could not post sales return", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loading = !invoices
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{loadError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
)}
|
||||
|
||||
{loading && !loadError && <Skeleton className="h-24 w-full" />}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Sales Invoice</Label>
|
||||
<Select<number | null> value={invoiceId} onValueChange={setInvoiceId} disabled={!!presetInvoiceId}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select a posted invoice" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(invoices ?? []).map((inv) => (
|
||||
<SelectItem key={inv.salesInvoiceId} value={inv.salesInvoiceId} className="text-base">
|
||||
{inv.invoiceNo} — {inv.customerSnapshotName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Reason</Label>
|
||||
<Select<number | null> value={reasonCodeId} onValueChange={setReasonCodeId}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select reason" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{reasonCodes.map((rc) => (
|
||||
<SelectItem key={rc.reasonCodeId} value={rc.reasonCodeId} className="text-base">
|
||||
{rc.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{headerError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
|
||||
)}
|
||||
|
||||
{selectedInvoice && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines invoiced on {selectedInvoice.invoiceNo}</h2>
|
||||
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{selectedInvoice.lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const state = lineState[line.salesInvoiceLineId] ?? { selected: false, qty: "" }
|
||||
const errors = lineErrors[line.salesInvoiceLineId] ?? {}
|
||||
const remaining = remainingFor(line.salesInvoiceLineId, line.qty)
|
||||
const fullyReturned = !remainingLoading && remaining <= 0
|
||||
return (
|
||||
<div key={line.salesInvoiceLineId} className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<label className="flex items-start gap-3">
|
||||
<Checkbox checked={state.selected} disabled={fullyReturned} onCheckedChange={() => toggleLine(line)} className="mt-0.5 shrink-0" />
|
||||
<span className="min-w-0 truncate text-base font-medium text-foreground">
|
||||
{item ? `${item.sku} — ${item.name}` : line.description}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Invoiced</p>
|
||||
<p className="font-medium text-foreground">{line.qty}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Remaining</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{remainingLoading ? "…" : fullyReturned ? "Returned" : remaining}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Unit price</p>
|
||||
<p className="font-medium text-foreground">{line.unitPrice.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm">Return qty</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={state.qty}
|
||||
disabled={!state.selected || fullyReturned}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => setQty(line.salesInvoiceLineId, e.target.value)}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-x-auto rounded-xl border md:block">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>Invoiced qty</TableHead>
|
||||
<TableHead>Remaining qty</TableHead>
|
||||
<TableHead>Unit price</TableHead>
|
||||
<TableHead className="w-36">Return qty</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedInvoice.lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const state = lineState[line.salesInvoiceLineId] ?? { selected: false, qty: "" }
|
||||
const errors = lineErrors[line.salesInvoiceLineId] ?? {}
|
||||
const remaining = remainingFor(line.salesInvoiceLineId, line.qty)
|
||||
const fullyReturned = !remainingLoading && remaining <= 0
|
||||
return (
|
||||
<TableRow key={line.salesInvoiceLineId} className="align-top hover:bg-muted/20">
|
||||
<TableCell>
|
||||
<Checkbox checked={state.selected} disabled={fullyReturned} onCheckedChange={() => toggleLine(line)} />
|
||||
</TableCell>
|
||||
<TableCell>{item ? `${item.sku} — ${item.name}` : line.description}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{line.qty}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{remainingLoading ? "…" : fullyReturned ? <span>Fully returned</span> : remaining}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{line.unitPrice.toFixed(2)}</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={state.qty}
|
||||
disabled={!state.selected || fullyReturned}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => setQty(line.salesInvoiceLineId, e.target.value)}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" type="button" onClick={onCancel} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Posting…" : "Post Return"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
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 { ArrowLeft, BarChart3, Building2, MapPin, Phone, ReceiptText } from "lucide-react"
|
||||
|
||||
import { employeesApi } from "@/lib/api/employees"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
@@ -20,13 +20,12 @@ 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" }
|
||||
// Status colors — same green/red the item detail page's charts use (Purchased/Sold),
|
||||
// so chart colors read as one consistent palette across the app.
|
||||
const STATUS_COLORS = { active: "rgba(34,197,94,1)", inactive: "rgba(239,68,68,1)" }
|
||||
|
||||
// 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" }
|
||||
// Sales invoice status — same green/red as the item detail page's charts, plus gray for Draft.
|
||||
const SALES_STATUS_COLORS = { posted: "rgba(34,197,94,1)", draft: "#9ca3af", cancelled: "rgba(239,68,68,1)" }
|
||||
|
||||
const CURRENCY_FORMAT = new Intl.NumberFormat("en-LK", { style: "currency", currency: "LKR" })
|
||||
|
||||
@@ -109,9 +108,17 @@ export function BranchDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={listHref}
|
||||
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to Branches
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{branch.name}</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -227,10 +234,6 @@ export function BranchDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Link href={listHref} className={cn(buttonVariants({ variant: "outline" }), "ml-auto w-fit")}>
|
||||
Back to branches
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 20, 25]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
export function BranchMasterPage() {
|
||||
const pathname = usePathname()
|
||||
@@ -47,6 +50,7 @@ export function BranchMasterPage() {
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
function load() {
|
||||
branchesHrmApi
|
||||
@@ -56,6 +60,12 @@ export function BranchMasterPage() {
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [page, pageSize, debouncedSearch])
|
||||
@@ -180,9 +190,9 @@ export function BranchMasterPage() {
|
||||
<Input id="branch-phone" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} />
|
||||
</Field>
|
||||
</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={handleSubmit} disabled={submitting}>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -261,110 +271,213 @@ export function BranchMasterPage() {
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length > 0 && (
|
||||
<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>
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{items.map((item) => (
|
||||
<div key={item.branchId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{item.name}</p>
|
||||
<span className="font-mono text-xs text-muted-foreground">{item.code}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit shrink-0 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>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Address</p>
|
||||
<p className="font-medium text-foreground">{item.address ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Phone Number</p>
|
||||
<p className="font-medium text-foreground">{item.phoneNumber ?? "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
||||
<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
|
||||
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>
|
||||
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>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pagination && items.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<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.totalItems > 0 && (
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="hidden items-center gap-3 md:flex md: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>–
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client"
|
||||
import React from "react"
|
||||
import React, { useEffect, useState } from "react"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
@@ -13,6 +13,24 @@ import { Bar } from "react-chartjs-2"
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
||||
|
||||
// Matches Tailwind's `sm` breakpoint — below it, axis labels get a smaller font
|
||||
// so warehouse/category codes don't crowd or overlap on narrow chart widths.
|
||||
const MOBILE_QUERY = "(max-width: 639px)"
|
||||
|
||||
function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(MOBILE_QUERY)
|
||||
setIsMobile(mql.matches)
|
||||
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||
mql.addEventListener("change", onChange)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return isMobile
|
||||
}
|
||||
|
||||
type Props = {
|
||||
labels: string[]
|
||||
datasets: Array<{
|
||||
@@ -29,16 +47,32 @@ type Props = {
|
||||
}
|
||||
|
||||
export default function BarChart({ labels, datasets, height = 260, barPercentage, categoryPercentage }: Props) {
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const data = {
|
||||
labels,
|
||||
datasets: datasets.map((d) => ({ ...d, barPercentage, categoryPercentage })),
|
||||
}
|
||||
|
||||
const tickFont = { size: isMobile ? 10 : 12 }
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: "top" as const } },
|
||||
scales: { y: { beginAtZero: true } },
|
||||
plugins: { legend: { position: "top" as const, labels: { font: tickFont } } },
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
font: tickFont,
|
||||
// Let Chart.js rotate labels to fit rather than silently dropping them via
|
||||
// autoSkip — on a narrow phone width, angled text beats missing categories.
|
||||
autoSkip: false,
|
||||
maxRotation: 60,
|
||||
minRotation: isMobile ? 45 : 0,
|
||||
},
|
||||
},
|
||||
y: { beginAtZero: true, ticks: { font: tickFont } },
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface KpiTileSpec {
|
||||
*/
|
||||
export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) {
|
||||
return (
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-4">
|
||||
{tiles
|
||||
? tiles.map((tile) => (
|
||||
<StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} />
|
||||
|
||||
@@ -14,6 +14,9 @@ import { PaginationMeta } from "@/types/common"
|
||||
interface PaginationFooterProps {
|
||||
pagination: PaginationMeta
|
||||
onPageChange: (page: number) => void
|
||||
/** Rows-per-page choices — pass together with `onPageSizeChange` to show the selector. */
|
||||
pageSizeOptions?: number[]
|
||||
onPageSizeChange?: (size: number) => void
|
||||
}
|
||||
|
||||
/** First, last, current ±1, and ellipses for the gaps — keeps the row from overflowing
|
||||
@@ -30,9 +33,9 @@ function pageItems(current: number, total: number): (number | "ellipsis")[] {
|
||||
/** Server-paginated table footer: "X – Y of Z" plus numbered page links, built on the
|
||||
* shadcn-style pagination primitives. Use for any list page driven by a `PaginationMeta`
|
||||
* from the API (as opposed to `DataTable`'s own client-side pagination). */
|
||||
export function PaginationFooter({ pagination, onPageChange }: PaginationFooterProps) {
|
||||
export function PaginationFooter({ pagination, onPageChange, pageSizeOptions, onPageSizeChange }: PaginationFooterProps) {
|
||||
const { page, pageSize, totalItems, totalPages } = pagination
|
||||
if (totalPages <= 1) return null
|
||||
if (totalPages <= 1 && !pageSizeOptions) return null
|
||||
|
||||
const from = (page - 1) * pageSize + 1
|
||||
const to = Math.min(page * pageSize, totalItems)
|
||||
@@ -44,46 +47,63 @@ export function PaginationFooter({ pagination, onPageChange }: PaginationFooterP
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{from} – {to} of {totalItems}
|
||||
</p>
|
||||
<Pagination className="mx-0 w-auto justify-end">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
text=""
|
||||
onClick={(e) => go(e, page - 1)}
|
||||
aria-disabled={page === 1}
|
||||
className={page === 1 ? "pointer-events-none opacity-50" : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{from} – {to} of {totalItems}
|
||||
</p>
|
||||
{pageSizeOptions && onPageSizeChange && (
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{pageSizeOptions.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<Pagination className="mx-0 w-auto justify-end">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => go(e, page - 1)}
|
||||
aria-disabled={page === 1}
|
||||
className={page === 1 ? "pointer-events-none opacity-50" : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{pageItems(page, totalPages).map((item, i) =>
|
||||
item === "ellipsis" ? (
|
||||
<PaginationItem key={`ellipsis-${i}`}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={item}>
|
||||
<PaginationLink href="#" isActive={item === page} onClick={(e) => go(e, item)}>
|
||||
{item}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
)}
|
||||
{pageItems(page, totalPages).map((item, i) =>
|
||||
item === "ellipsis" ? (
|
||||
<PaginationItem key={`ellipsis-${i}`}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={item}>
|
||||
<PaginationLink href="#" isActive={item === page} onClick={(e) => go(e, item)}>
|
||||
{item}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
text=""
|
||||
onClick={(e) => go(e, page + 1)}
|
||||
aria-disabled={page === totalPages}
|
||||
className={page === totalPages ? "pointer-events-none opacity-50" : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => go(e, page + 1)}
|
||||
aria-disabled={page === totalPages}
|
||||
className={page === totalPages ? "pointer-events-none opacity-50" : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,7 +76,6 @@ export function StatCard({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||||
{Icon && (
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
|
||||
@@ -23,7 +23,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("bg-muted/50 [&_tr]:border-b [&_tr]:hover:bg-muted/50", className)}
|
||||
className={cn("bg-muted/50 [&_tr]:border-b [&_tr]:hover:bg-transparent", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -57,7 +57,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
"border-b transition-colors hover:bg-muted/20 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-semibold whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
"h-11 px-3 text-left align-middle text-sm font-semibold whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -83,7 +83,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
"px-3 py-4 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user