product and sales fixers
This commit is contained in:
@@ -43,14 +43,12 @@ import {
|
||||
PieChart,
|
||||
Receipt,
|
||||
ReceiptText,
|
||||
Ruler,
|
||||
Scale,
|
||||
ScrollText,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
SlidersHorizontal,
|
||||
Tag,
|
||||
Truck,
|
||||
Undo2,
|
||||
Users,
|
||||
@@ -73,11 +71,7 @@ const navItems: {
|
||||
/** Where clicking the row actually navigates, if different from `href`. `href` itself
|
||||
* stays the section prefix used to decide whether this row is "active". */
|
||||
landingHref?: string
|
||||
/** This item's own href has no page of its own (no landingHref either) — clicking the
|
||||
* row should only expand/collapse its children, never navigate. */
|
||||
expandOnly?: boolean
|
||||
icon: LucideIcon
|
||||
chevron?: boolean
|
||||
children?: {
|
||||
title: string
|
||||
code: string
|
||||
@@ -94,12 +88,16 @@ const navItems: {
|
||||
code: "products",
|
||||
href: "/dashboard/products",
|
||||
icon: Package,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
|
||||
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
|
||||
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
|
||||
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
|
||||
// Category, Brand and UOM share one tabbed page — any of the three permissions shows it.
|
||||
{
|
||||
title: "Category/Brand/UOM",
|
||||
code: "products.category",
|
||||
href: "/dashboard/products/categories",
|
||||
icon: ListTree,
|
||||
altCodes: ["products.brand", "products.uom"],
|
||||
},
|
||||
{ title: "Tax Rates", code: "products.tax-rates", href: "/dashboard/products/tax-rates", icon: Percent },
|
||||
],
|
||||
},
|
||||
@@ -111,7 +109,6 @@ const navItems: {
|
||||
// has nothing on it (its card grid was removed once the sidebar grew these sub-items).
|
||||
landingHref: "/dashboard/procurement/purchase-orders",
|
||||
icon: ClipboardList,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart },
|
||||
{ title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX },
|
||||
@@ -124,9 +121,11 @@ const navItems: {
|
||||
href: "/dashboard/sales",
|
||||
landingHref: "/dashboard/sales/invoices",
|
||||
icon: ReceiptText,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Sales", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
|
||||
// Shortcut straight to the New Sale form — reuses the sales.invoices permission code (same
|
||||
// access as the Sales list), so it needs no seeded SubNavItem of its own.
|
||||
{ title: "Create Sale", code: "sales.invoices", href: "/dashboard/sales/invoices/new", icon: ShoppingCart },
|
||||
// Reuses the sales.invoices permission code — customers aren't yet their own
|
||||
// seeded NavItem/Permission on the backend, so this rides along with Sales access.
|
||||
{ title: "Customers", code: "sales.invoices", href: "/dashboard/customers", icon: Users },
|
||||
@@ -137,7 +136,7 @@ const navItems: {
|
||||
],
|
||||
},
|
||||
{ title: "Sales Returns", code: "sales-returns", href: "/dashboard/sales/sales-returns", icon: Undo2 },
|
||||
{ title: "GRN", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||
{ title: "GRN", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck },
|
||||
{
|
||||
title: "Stock",
|
||||
code: "stock",
|
||||
@@ -146,7 +145,6 @@ const navItems: {
|
||||
// it (its card grid was removed once the sidebar grew these sub-items), same as Procurement.
|
||||
landingHref: "/dashboard/stock/ledger",
|
||||
icon: Warehouse,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
|
||||
{ title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch },
|
||||
@@ -158,14 +156,13 @@ const navItems: {
|
||||
{ title: "Wastage", code: "stock.wastage", href: "/dashboard/stock/wastage", icon: AlertOctagon },
|
||||
],
|
||||
},
|
||||
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
||||
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2 },
|
||||
{
|
||||
title: "Production",
|
||||
code: "production",
|
||||
href: "/dashboard/production",
|
||||
landingHref: "/dashboard/production/runs",
|
||||
icon: Factory,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Templates", code: "production.templates", href: "/dashboard/production/templates", icon: LayoutTemplate },
|
||||
{ title: "Runs", code: "production.runs", href: "/dashboard/production/runs", icon: PlayCircle },
|
||||
@@ -178,7 +175,6 @@ const navItems: {
|
||||
href: "/dashboard/hrm",
|
||||
landingHref: "/dashboard/hrm/employees",
|
||||
icon: IdCard,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Employees", code: "hrm.employees", href: "/dashboard/hrm/employees", icon: Users },
|
||||
{ title: "Attendance", code: "hrm.attendance", href: "/dashboard/hrm/attendance", icon: CalendarCheck },
|
||||
@@ -193,7 +189,6 @@ const navItems: {
|
||||
code: "ledgers",
|
||||
href: "/dashboard/ledgers",
|
||||
icon: Landmark,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Trial Balance", code: "ledgers.trial-balance", href: "/dashboard/ledgers/trial-balance", icon: Scale },
|
||||
{ title: "Balance Sheet", code: "ledgers.balance-sheet", href: "/dashboard/ledgers/balance-sheet", icon: Landmark },
|
||||
@@ -209,7 +204,6 @@ const navItems: {
|
||||
code: "accounts",
|
||||
href: "/dashboard/accounts",
|
||||
icon: CreditCard,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Cash / Bank Accounts", code: "accounts.bank-accounts", href: "/dashboard/accounts/bank-accounts", icon: Wallet },
|
||||
{ title: "Cheque Books", code: "accounts.cheque-books", href: "/dashboard/accounts/cheque-books", icon: BookText },
|
||||
@@ -220,9 +214,7 @@ const navItems: {
|
||||
title: "Settings",
|
||||
code: "settings",
|
||||
href: "/dashboard/settings",
|
||||
expandOnly: true,
|
||||
icon: Settings,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Branches", code: "settings.branches", href: "/dashboard/settings/branches", icon: MapPin },
|
||||
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
|
||||
@@ -329,49 +321,46 @@ function SidebarContent({
|
||||
isActive ? "bg-primary/10" : "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={item.landingHref ?? item.href}
|
||||
title={iconOnly ? item.title : undefined}
|
||||
onClick={() => {
|
||||
onClose?.()
|
||||
if (hasChildren) setExpanded((prev) => (prev[item.code] ? prev : { ...prev, [item.code]: true }))
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-1.5 text-base font-normal",
|
||||
iconOnly && "justify-center px-0",
|
||||
isActive ? "text-primary" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
|
||||
/>
|
||||
{!iconOnly && (
|
||||
<>
|
||||
<span className="flex-1">{item.title}</span>
|
||||
{item.chevron && !hasChildren && (
|
||||
<ChevronRight
|
||||
className={cn("size-4 shrink-0", isActive ? "text-primary/70" : "text-muted-foreground")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{hasChildren && (
|
||||
{hasChildren ? (
|
||||
// Parents with sub-items only expand/collapse on click — navigation
|
||||
// happens through the sub-items themselves.
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(item.code)}
|
||||
aria-label={isOpen ? `Collapse ${item.title}` : `Expand ${item.title}`}
|
||||
aria-expanded={isOpen}
|
||||
className={cn(
|
||||
"mr-2 flex size-7 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-foreground/10",
|
||||
isActive ? "text-primary" : "text-muted-foreground"
|
||||
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-1.5 text-left text-base font-normal",
|
||||
isActive ? "text-primary" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
|
||||
/>
|
||||
<span className="flex-1">{item.title}</span>
|
||||
<ChevronRight
|
||||
className={cn("size-4 transition-transform duration-300 ease-in-out", isOpen && "rotate-90")}
|
||||
className={cn(
|
||||
"size-4 shrink-0 transition-transform duration-300 ease-in-out",
|
||||
isActive ? "text-primary" : "text-muted-foreground",
|
||||
isOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href={item.landingHref ?? item.href}
|
||||
title={iconOnly ? item.title : undefined}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-1.5 text-base font-normal",
|
||||
iconOnly && "justify-center px-0",
|
||||
isActive ? "text-primary" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
|
||||
/>
|
||||
{!iconOnly && <span className="flex-1">{item.title}</span>}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -132,10 +132,10 @@ function titleFromPath(pathname: string) {
|
||||
|
||||
if (pathname === "/dashboard/products") return "Items"
|
||||
if (pathname === "/dashboard/products/new") return "New Item"
|
||||
if (pathname === "/dashboard/products/categories") return "Categories"
|
||||
if (pathname === "/dashboard/products/categories") return "Category/Brand/UOM"
|
||||
if (pathname.startsWith("/dashboard/products/categories/")) return "Subcategories"
|
||||
if (pathname === "/dashboard/products/brands") return "Brand/UOM"
|
||||
if (pathname === "/dashboard/products/item-types") return "Item Types"
|
||||
if (pathname === "/dashboard/products/item-types") return "Item Variants"
|
||||
if (pathname === "/dashboard/products/warranty-periods") return "Warranty Periods"
|
||||
if (pathname === "/dashboard/products/settings") return "Product Configuration"
|
||||
if (pathname === "/dashboard/settings/notification-config") return "Notification Config"
|
||||
if (pathname === "/dashboard/settings/company-profile") return "Company Profile"
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
|
||||
/**
|
||||
* Thin top-of-page progress bar for client-side navigations.
|
||||
*
|
||||
* The App Router has no "navigation started" event, so the start is detected from clicks on
|
||||
* internal links (capture phase, before next/link handles them) and back/forward. The route
|
||||
* committing — pathname or query changing — finishes it. Programmatic router.push() isn't
|
||||
* seen at start; app/dashboard/loading.tsx covers those with an in-content spinner.
|
||||
*/
|
||||
export function RouteProgress() {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [visible, setVisible] = useState(false)
|
||||
// Set by the mount effect; lets the route-change effect end the bar.
|
||||
const finishRef = useRef<() => void>(() => {})
|
||||
|
||||
useEffect(() => {
|
||||
let active = false
|
||||
let trickle: ReturnType<typeof setInterval> | null = null
|
||||
let safety: ReturnType<typeof setTimeout> | null = null
|
||||
let hide: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearTimers() {
|
||||
if (trickle) clearInterval(trickle)
|
||||
if (safety) clearTimeout(safety)
|
||||
if (hide) clearTimeout(hide)
|
||||
trickle = safety = hide = null
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (active) return
|
||||
active = true
|
||||
clearTimers()
|
||||
setVisible(true)
|
||||
setProgress(8)
|
||||
// Ease toward 90% — never reaches the end until the route actually commits.
|
||||
trickle = setInterval(() => setProgress((p) => (p < 90 ? p + (90 - p) * 0.1 : p)), 200)
|
||||
// Never leave a bar hanging if a navigation is cancelled or fails silently.
|
||||
safety = setTimeout(finish, 15000)
|
||||
}
|
||||
|
||||
function finish() {
|
||||
if (!active) return
|
||||
active = false
|
||||
clearTimers()
|
||||
setProgress(100)
|
||||
hide = setTimeout(() => {
|
||||
setVisible(false)
|
||||
setProgress(0)
|
||||
}, 250)
|
||||
}
|
||||
finishRef.current = finish
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
||||
const anchor = (e.target as Element | null)?.closest?.("a")
|
||||
if (!anchor || !anchor.href) return
|
||||
if (anchor.target && anchor.target !== "_self") return
|
||||
if (anchor.hasAttribute("download")) return
|
||||
|
||||
const next = new URL(anchor.href, window.location.href)
|
||||
if (next.origin !== window.location.origin) return
|
||||
// API downloads (e.g. document links) aren't page navigations.
|
||||
if (next.pathname.startsWith("/api/")) return
|
||||
// Same page (or hash-only jump) — nothing will commit, so don't start.
|
||||
if (next.pathname === window.location.pathname && next.search === window.location.search) return
|
||||
start()
|
||||
}
|
||||
|
||||
document.addEventListener("click", onClick, true)
|
||||
window.addEventListener("popstate", start)
|
||||
return () => {
|
||||
document.removeEventListener("click", onClick, true)
|
||||
window.removeEventListener("popstate", start)
|
||||
clearTimers()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Route committed ⇒ done. Keyed on the full URL so query-only navigations count too.
|
||||
const url = `${pathname}?${searchParams.toString()}`
|
||||
useEffect(() => {
|
||||
finishRef.current()
|
||||
}, [url])
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-x-0 top-0 z-100 h-0.5 print:hidden"
|
||||
style={{ opacity: visible ? 1 : 0, transition: "opacity 250ms ease" }}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-primary shadow-[0_0_8px_var(--color-primary)]"
|
||||
style={{ width: `${progress}%`, transition: "width 200ms ease-out" }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Plus, Tag } from "lucide-react"
|
||||
|
||||
import { brandsApi } from "@/lib/api/brands"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateBrandName } from "@/lib/validations/master-data"
|
||||
import { Brand } from "@/types/master-data"
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
// Large enough to show "all brands" in one grid — the chip layout replaces the
|
||||
// old table's pagination, so we just ask for everything in one page.
|
||||
const ALL_ITEMS_PAGE_SIZE = 500
|
||||
|
||||
/** Brand master — the Brand tab of the Category/Brand/UOM page. */
|
||||
export function BrandPanel() {
|
||||
const [brands, setBrands] = useState<Brand[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [name, setName] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Brand | null>(null)
|
||||
const [editName, setEditName] = useState("")
|
||||
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
|
||||
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
brandsApi
|
||||
.list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
|
||||
.then((res) => setBrands(res.items))
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
async function handleAdd() {
|
||||
const nextErrors = validateBrandName(name)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await brandsApi.create({ name })
|
||||
toast.success("Brand created", result.data.name)
|
||||
setName("")
|
||||
setErrors({})
|
||||
load()
|
||||
} catch (err) {
|
||||
setErrors({ name: errorMessage(err) })
|
||||
toast.error("Could not create brand", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
|
||||
async function handleToggleActive(brand: Brand) {
|
||||
const next = brand.status === "Active" ? "Inactive" : "Active"
|
||||
setTogglingId(brand.brandId)
|
||||
try {
|
||||
await brandsApi.updateStatus(brand.brandId, next)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update brand status", errorMessage(err))
|
||||
} finally {
|
||||
setTogglingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(brand: Brand) {
|
||||
setEditing(brand)
|
||||
setEditName(brand.name)
|
||||
setEditErrors({})
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
async function handleEditSubmit() {
|
||||
if (!editing) return
|
||||
const nextErrors = validateBrandName(editName)
|
||||
setEditErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setEditSubmitting(true)
|
||||
try {
|
||||
// The list response carries no ETag, so re-read to get a fresh If-Match token
|
||||
// rather than guessing one. A concurrent edit surfaces as 412 from the server.
|
||||
const current = await brandsApi.get(editing.brandId)
|
||||
const result = await brandsApi.update(editing.brandId, { name: editName }, current.etag ?? "")
|
||||
toast.success("Brand updated", result.data.name)
|
||||
setEditOpen(false)
|
||||
setEditing(null)
|
||||
load()
|
||||
} catch (err) {
|
||||
setEditErrors({ name: errorMessage(err) })
|
||||
toast.error("Could not update brand", errorMessage(err))
|
||||
} finally {
|
||||
setEditSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="New brand name, e.g. Bosch"
|
||||
className="h-12 flex-1 text-base"
|
||||
aria-label="New brand name"
|
||||
aria-invalid={!!errors.name}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
/>
|
||||
<Button size="lg" onClick={handleAdd} disabled={submitting}>
|
||||
<Plus className="size-5" />
|
||||
Add Brand
|
||||
</Button>
|
||||
</div>
|
||||
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && brands === null && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && brands !== null && brands.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Tag className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No brands yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && brands !== null && brands.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{brands.map((b) => (
|
||||
<label
|
||||
key={b.brandId}
|
||||
onDoubleClick={() => openEdit(b)}
|
||||
title="Tick to activate/deactivate — double-click to rename"
|
||||
className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={b.status === "Active"}
|
||||
onCheckedChange={() => handleToggleActive(b)}
|
||||
disabled={togglingId === b.brandId}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="truncate font-medium">{b.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>Edit brand</DialogTitle>
|
||||
<DialogDescription>Give the brand a name.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!editErrors.name}>
|
||||
<FieldLabel htmlFor="brand-edit-name">Name</FieldLabel>
|
||||
<Input
|
||||
id="brand-edit-name"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
aria-invalid={!!editErrors.name}
|
||||
/>
|
||||
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<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" onClick={() => setEditOpen(false)} disabled={editSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleEditSubmit} disabled={editSubmitting}>
|
||||
{editSubmitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
|
||||
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { productConfig } from "@/lib/api/product-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateCategoryName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Category, ProductConfig } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
type SortOrder = "asc" | "desc"
|
||||
type SortKey = "categoryId" | "name" | "status" | "createdAt"
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
|
||||
/** Item category master (FR-MD-04) — the Category tab of the Category/Brand/UOM page. */
|
||||
export function CategoryPanel() {
|
||||
const [categories, setCategories] = useState<Category[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [search, setSearch] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
// Sorts only the currently loaded page client-side — the backend ignores `sort` and
|
||||
// always returns Name ascending, so this doesn't hold across page turns or other columns.
|
||||
const [sortKey, setSortKey] = useState<SortKey>("name")
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Category | null>(null)
|
||||
const [name, setName] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||
const [subCategoryCounts, setSubCategoryCounts] = useState<Record<number, number>>({})
|
||||
|
||||
useEffect(() => {
|
||||
productConfig().then(setConfig).catch(() => setConfig(null))
|
||||
}, [])
|
||||
|
||||
// Subcategories are their own resource — there's no count on the Category DTO itself,
|
||||
// so fetch each visible row's total separately (pageSize 1: only `pagination.totalItems`
|
||||
// is needed) once the feature is on and the page's categories are known.
|
||||
useEffect(() => {
|
||||
if (!config?.subcategoriesEnabled || !categories || categories.length === 0) {
|
||||
setSubCategoryCounts({})
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
Promise.all(
|
||||
categories.map((c) =>
|
||||
categoriesApi
|
||||
.listSubCategories(c.categoryId, { pageSize: 1 })
|
||||
.then((res) => [c.categoryId, res.pagination.totalItems] as const)
|
||||
.catch(() => [c.categoryId, 0] as const)
|
||||
)
|
||||
).then((entries) => {
|
||||
if (!cancelled) setSubCategoryCounts(Object.fromEntries(entries))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [config?.subcategoriesEnabled, categories])
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [search, status])
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
categoriesApi
|
||||
.list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize })
|
||||
.then((res) => {
|
||||
setCategories(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [search, status, page, pageSize])
|
||||
|
||||
const hasFilters = search.trim().length > 0 || status !== "All"
|
||||
|
||||
const sortedCategories = categories
|
||||
? [...categories].sort((a, b) => {
|
||||
const cmp =
|
||||
sortKey === "categoryId"
|
||||
? a.categoryId - b.categoryId
|
||||
: sortKey === "createdAt"
|
||||
? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
: a[sortKey].localeCompare(b[sortKey])
|
||||
return sortOrder === "asc" ? cmp : -cmp
|
||||
})
|
||||
: null
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (key === sortKey) {
|
||||
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortOrder("asc")
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditing(null)
|
||||
setName("")
|
||||
setErrors({})
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
function openEditDialog(category: Category) {
|
||||
setEditing(category)
|
||||
setName(category.name)
|
||||
setErrors({})
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const nextErrors = validateCategoryName(name)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
let result
|
||||
if (editing) {
|
||||
// The list carries no ETag, so re-read for a fresh If-Match rather than guessing.
|
||||
const current = await categoriesApi.get(editing.categoryId)
|
||||
result = await categoriesApi.update(editing.categoryId, { name }, current.etag ?? "")
|
||||
} else {
|
||||
result = await categoriesApi.create({ name })
|
||||
}
|
||||
toast.success(editing ? "Category updated" : "Category created", result.data.name)
|
||||
setOpen(false)
|
||||
setName("")
|
||||
setEditing(null)
|
||||
setErrors({})
|
||||
load()
|
||||
} catch (err) {
|
||||
setErrors({ name: errorMessage(err) })
|
||||
toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
|
||||
async function handleToggleStatus(category: Category) {
|
||||
const next = category.status === "Active" ? "Inactive" : "Active"
|
||||
setDeletingId(category.categoryId)
|
||||
try {
|
||||
await categoriesApi.updateStatus(category.categoryId, next)
|
||||
toast.success(next === "Inactive" ? "Category deactivated" : "Category activated", category.name)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update category status", errorMessage(err))
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 basis-0">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search categories…"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search categories"
|
||||
/>
|
||||
</div>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Active" className="text-base">Active</SelectItem>
|
||||
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Category</Button>} />
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>{editing ? "Edit category" : "New category"}</DialogTitle>
|
||||
<DialogDescription>Give the category a name.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="cat-name">Name</FieldLabel>
|
||||
<Input id="cat-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<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" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && categories === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && categories !== null && categories.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<ListTree className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{hasFilters ? "No categories match your search/filter." : "No categories yet."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && categories !== null && categories.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{sortedCategories!.map((c) => (
|
||||
<div key={c.categoryId} 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-medium text-foreground">{c.name}</p>
|
||||
<p className="text-sm text-muted-foreground">#{c.categoryId}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
|
||||
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Created</p>
|
||||
<p className="font-medium text-foreground">{new Date(c.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
{config?.subcategoriesEnabled && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Subcategories</p>
|
||||
<p className="font-medium text-foreground">{subCategoryCounts[c.categoryId] ?? "—"}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
||||
{config?.subcategoriesEnabled && (
|
||||
<Link
|
||||
href={`/dashboard/products/categories/${c.categoryId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Manage subcategories of ${c.name}`}
|
||||
>
|
||||
<Network className="size-4" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${c.name}`} onClick={() => openEditDialog(c)}>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
|
||||
disabled={deletingId === c.categoryId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant={c.status === "Active" ? "destructive" : "success"}
|
||||
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
|
||||
description={
|
||||
c.status === "Active"
|
||||
? "The category stays on existing items but cannot take new subcategories or items."
|
||||
: "The category becomes selectable again."
|
||||
}
|
||||
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
|
||||
onConfirm={() => handleToggleStatus(c)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</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>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
||||
</TableHead>
|
||||
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategories</TableHead>}
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedCategories!.map((c) => (
|
||||
<TableRow key={c.categoryId}>
|
||||
<TableCell className="px-3 py-3.5">{c.categoryId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-transparent",
|
||||
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
||||
{config?.subcategoriesEnabled && (
|
||||
<TableCell className="px-3 py-3.5">{subCategoryCounts[c.categoryId] ?? "—"}</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Subcategories are their own resource now, not a nested tree.
|
||||
Hidden while the feature is switched off in Product Configuration. */}
|
||||
{config?.subcategoriesEnabled && (
|
||||
<Link
|
||||
href={`/dashboard/products/categories/${c.categoryId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Manage subcategories of ${c.name}`}
|
||||
>
|
||||
<Network className="size-4" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${c.name}`}
|
||||
onClick={() => openEditDialog(c)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
|
||||
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
|
||||
disabled={deletingId === c.categoryId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant={c.status === "Active" ? "destructive" : "success"}
|
||||
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
|
||||
description={
|
||||
c.status === "Active"
|
||||
? "The category stays on existing items but cannot take new subcategories or items."
|
||||
: "The category becomes selectable again."
|
||||
}
|
||||
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
|
||||
onConfirm={() => handleToggleStatus(c)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableHeader({
|
||||
label,
|
||||
active,
|
||||
order,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
order: SortOrder
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
onClick={onClick}
|
||||
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
|
||||
>
|
||||
{label}
|
||||
{active ? (
|
||||
order === "asc" ? (
|
||||
<ArrowUp className="size-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="size-3.5" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { ImageIcon, ImagePlus, Trash2 } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { ItemImage } from "@/types/master-data"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
/** Mirrors the server's rules (ItemImageService) so a bad pick fails fast, before any upload. */
|
||||
export const ITEM_IMAGE_ACCEPT = "image/jpeg,image/png,image/webp"
|
||||
export const ITEM_IMAGE_MAX_BYTES = 5 * 1024 * 1024
|
||||
export const ITEM_IMAGE_MAX_COUNT = 10
|
||||
|
||||
/** Client-side pre-check; returns a reason the file can't be used, or null. */
|
||||
export function itemImageProblem(file: File): string | null {
|
||||
if (!ITEM_IMAGE_ACCEPT.split(",").includes(file.type)) return `${file.name}: use a JPG, PNG or WebP image.`
|
||||
if (file.size > ITEM_IMAGE_MAX_BYTES) return `${file.name}: larger than 5 MB.`
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Photos for one existing item. Read-only unless `editable` (the item's Edit dialog) — there
|
||||
* images can be removed, and added too when `canUpload` (ProductConfig.itemImagesEnabled).
|
||||
* Changes apply immediately, not on the dialog's Save. Hides itself when there is nothing
|
||||
* to show and nothing the user could do.
|
||||
*/
|
||||
export function ItemImagesPanel({
|
||||
itemId,
|
||||
editable = false,
|
||||
canUpload = false,
|
||||
}: {
|
||||
itemId: number
|
||||
editable?: boolean
|
||||
canUpload?: boolean
|
||||
}) {
|
||||
const allowUpload = editable && canUpload
|
||||
const [images, setImages] = useState<ItemImage[] | null>(null)
|
||||
const [uploading, setUploading] = useState(0)
|
||||
const [viewing, setViewing] = useState<ItemImage | null>(null)
|
||||
const [confirmDelete, setConfirmDelete] = useState<ItemImage | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
itemsApi
|
||||
.listImages(itemId)
|
||||
.then((res) => {
|
||||
if (!cancelled) setImages(res)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setImages([])
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [itemId])
|
||||
|
||||
async function handleFiles(files: FileList | null) {
|
||||
if (!files || files.length === 0) return
|
||||
const room = ITEM_IMAGE_MAX_COUNT - (images?.length ?? 0)
|
||||
const picked = Array.from(files)
|
||||
if (picked.length > room) toast.warning("Too many images", `An item can have ${ITEM_IMAGE_MAX_COUNT}; only the first ${room} will be added.`)
|
||||
|
||||
const toUpload: File[] = []
|
||||
for (const file of picked.slice(0, Math.max(room, 0))) {
|
||||
const problem = itemImageProblem(file)
|
||||
if (problem) toast.error("Image skipped", problem)
|
||||
else toUpload.push(file)
|
||||
}
|
||||
|
||||
setUploading(toUpload.length)
|
||||
for (const file of toUpload) {
|
||||
try {
|
||||
const image = await itemsApi.uploadImage(itemId, file)
|
||||
setImages((prev) => [...(prev ?? []), image])
|
||||
} catch (err) {
|
||||
toast.error(`Could not upload ${file.name}`, errorMessage(err))
|
||||
} finally {
|
||||
setUploading((n) => n - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirmDelete) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await itemsApi.deleteImage(itemId, confirmDelete.itemImageId)
|
||||
setImages((prev) => (prev ?? []).filter((i) => i.itemImageId !== confirmDelete.itemImageId))
|
||||
if (viewing?.itemImageId === confirmDelete.itemImageId) setViewing(null)
|
||||
setConfirmDelete(null)
|
||||
} catch (err) {
|
||||
toast.error("Could not remove image", errorMessage(err))
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (images !== null && images.length === 0 && !allowUpload) return null
|
||||
|
||||
const full = (images?.length ?? 0) >= ITEM_IMAGE_MAX_COUNT
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="size-4.5 text-primary" />
|
||||
<h2 className="text-base font-semibold text-foreground">Images</h2>
|
||||
{images && images.length > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{images.length} / {ITEM_IMAGE_MAX_COUNT}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{images === null ? (
|
||||
<Skeleton className="h-28 w-full" />
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 lg:grid-cols-6">
|
||||
{images.map((img, index) => (
|
||||
<div key={img.itemImageId} className="group relative aspect-square overflow-hidden rounded-lg border bg-muted">
|
||||
<button type="button" className="size-full" onClick={() => setViewing(img)} aria-label={`View ${img.fileName}`}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- authenticated API image, not a static asset */}
|
||||
<img src={itemsApi.imageUrl(itemId, img.itemImageId)} alt={img.fileName} className="size-full object-cover" />
|
||||
</button>
|
||||
{index === 0 && (
|
||||
<span className="absolute top-1.5 left-1.5 rounded bg-background/90 px-1.5 py-0.5 text-xs font-medium">Main</span>
|
||||
)}
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(img)}
|
||||
aria-label={`Remove ${img.fileName}`}
|
||||
className="absolute top-1.5 right-1.5 flex size-7 items-center justify-center rounded-md bg-background/90 text-destructive opacity-100 shadow-sm transition-opacity hover:bg-background sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Array.from({ length: uploading }).map((_, i) => (
|
||||
<Skeleton key={`up-${i}`} className="aspect-square w-full rounded-lg" />
|
||||
))}
|
||||
|
||||
{allowUpload && !full && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading > 0}
|
||||
className="flex aspect-square flex-col items-center justify-center gap-1.5 rounded-lg border-2 border-dashed text-muted-foreground transition-colors hover:border-primary hover:text-primary disabled:opacity-50"
|
||||
>
|
||||
<ImagePlus className="size-6" />
|
||||
<span className="text-xs font-medium">Add images</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editable && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{allowUpload && "JPG, PNG or WebP, up to 5 MB each. The first image is the main one. "}
|
||||
Image changes are saved immediately.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{allowUpload && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ITEM_IMAGE_ACCEPT}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleFiles(e.target.files)
|
||||
e.target.value = "" // allow re-picking the same file
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={viewing !== null} onOpenChange={(open) => !open && setViewing(null)}>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="truncate">{viewing?.fileName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{viewing && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- authenticated API image, not a static asset
|
||||
<img
|
||||
src={itemsApi.imageUrl(itemId, viewing.itemImageId)}
|
||||
alt={viewing.fileName}
|
||||
className="max-h-[70vh] w-full rounded-lg object-contain"
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={confirmDelete !== null} onOpenChange={(open) => !open && setConfirmDelete(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove image?</DialogTitle>
|
||||
<DialogDescription>{confirmDelete?.fileName} will be permanently deleted from this item.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmDelete(null)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
||||
{deleting ? "Removing…" : "Remove"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Plus, Ruler } from "lucide-react"
|
||||
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateUomName } from "@/lib/validations/master-data"
|
||||
import { Uom } from "@/types/master-data"
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
// Large enough to show every UOM in one grid — the chip layout has no pagination.
|
||||
const ALL_ITEMS_PAGE_SIZE = 500
|
||||
|
||||
/** Unit-of-measure master — the UOM tab of the Category/Brand/UOM page. */
|
||||
export function UomPanel() {
|
||||
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [name, setName] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||
|
||||
function load() {
|
||||
uomsApi
|
||||
.list({ page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
|
||||
.then((res) => setUoms(res.items))
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
|
||||
async function handleToggleActive(uom: Uom) {
|
||||
const next = uom.status === "Active" ? "Inactive" : "Active"
|
||||
setTogglingId(uom.uomId)
|
||||
try {
|
||||
await uomsApi.updateStatus(uom.uomId, next)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update UOM status", errorMessage(err))
|
||||
} finally {
|
||||
setTogglingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
const nextErrors = validateUomName(name)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const uom = await uomsApi.create({ name })
|
||||
toast.success("UOM created", uom.name)
|
||||
setName("")
|
||||
setErrors({})
|
||||
load()
|
||||
} catch (err) {
|
||||
const fe = fieldErrors(err)
|
||||
if (fe?.name) setErrors({ name: fe.name })
|
||||
toast.error("Could not create UOM", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="New UOM name, e.g. Box-12"
|
||||
className="h-12 flex-1 text-base"
|
||||
aria-label="New UOM name"
|
||||
aria-invalid={!!errors.name}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
/>
|
||||
<Button size="lg" onClick={handleAdd} disabled={submitting}>
|
||||
<Plus className="size-5" />
|
||||
Add UOM
|
||||
</Button>
|
||||
</div>
|
||||
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && uoms === null && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && uoms !== null && uoms.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Ruler className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No UOMs yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && uoms !== null && uoms.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{uoms.map((u) => (
|
||||
<label
|
||||
key={u.uomId}
|
||||
title="Tick to activate/deactivate"
|
||||
className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={u.status === "Active"}
|
||||
onCheckedChange={() => handleToggleActive(u)}
|
||||
disabled={togglingId === u.uomId}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="truncate font-medium">{u.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export interface ScanInputProps {
|
||||
hint?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
/** Grab focus on mount/re-enable (default true). Turn off when another field on the page
|
||||
* should get the cursor first (e.g. a type-ahead "Add item" box). */
|
||||
autoFocus?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -31,6 +34,7 @@ export function ScanInput({
|
||||
hint = "Scan item or lot barcode",
|
||||
placeholder = "Scan or type a barcode, then press Enter",
|
||||
disabled = false,
|
||||
autoFocus = true,
|
||||
className,
|
||||
}: ScanInputProps) {
|
||||
const [value, setValue] = useState("")
|
||||
@@ -39,8 +43,8 @@ export function ScanInput({
|
||||
// Refocus whenever the field becomes usable again (e.g. after being briefly disabled while a
|
||||
// lookup for the previous scan was in flight) so a scanner's next read always lands here.
|
||||
useEffect(() => {
|
||||
if (!disabled) inputRef.current?.focus()
|
||||
}, [disabled])
|
||||
if (!disabled && autoFocus) inputRef.current?.focus()
|
||||
}, [disabled, autoFocus])
|
||||
|
||||
function commit() {
|
||||
const code = value.trim()
|
||||
|
||||
@@ -22,6 +22,8 @@ interface FilterBarProps {
|
||||
advancedActive?: boolean
|
||||
/** Shown as a "Clear advanced filters" action inside the popover when provided. */
|
||||
onClearAdvanced?: () => void
|
||||
/** Puts the search box on the left and groups `children` + "Advanced" together on the right. */
|
||||
searchFirst?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -33,59 +35,76 @@ interface FilterBarProps {
|
||||
*/
|
||||
export function FilterBar({
|
||||
search, onSearchChange, searchPlaceholder = "Search…", searchAriaLabel,
|
||||
children, advanced, advancedActive, onClearAdvanced, className,
|
||||
children, advanced, advancedActive, onClearAdvanced, searchFirst, className,
|
||||
}: FilterBarProps) {
|
||||
const searchBox = (
|
||||
<div className="relative w-full lg:max-w-sm">
|
||||
<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) => onSearchChange(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-10 pl-9 pr-9"
|
||||
aria-label={searchAriaLabel ?? searchPlaceholder}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchChange("")}
|
||||
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>
|
||||
)
|
||||
|
||||
const advancedPopover = advanced && (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm" className="relative shrink-0">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
Advanced
|
||||
{advancedActive && (
|
||||
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-primary" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-72 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{advanced}
|
||||
{onClearAdvanced && (
|
||||
<Button variant="ghost" size="sm" onClick={onClearAdvanced} className="self-start">
|
||||
Clear advanced filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
if (searchFirst) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between", className)}>
|
||||
{searchBox}
|
||||
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||
{children}
|
||||
{advancedPopover}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3 lg:flex-row lg:items-center", className)}>
|
||||
{children}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
|
||||
<div className="relative w-full lg:max-w-sm">
|
||||
<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) => onSearchChange(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-10 pl-9 pr-9"
|
||||
aria-label={searchAriaLabel ?? searchPlaceholder}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchChange("")}
|
||||
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>
|
||||
|
||||
{advanced && (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm" className="relative shrink-0">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
Advanced
|
||||
{advancedActive && (
|
||||
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-primary" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-72 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{advanced}
|
||||
{onClearAdvanced && (
|
||||
<Button variant="ghost" size="sm" onClick={onClearAdvanced} className="self-start">
|
||||
Clear advanced filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
{searchBox}
|
||||
{advancedPopover}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { ChevronDown, Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
export interface SuggestOption {
|
||||
value: string
|
||||
label: string
|
||||
/** Optional secondary text shown on the right of the suggestion (e.g. a price). */
|
||||
hint?: string
|
||||
}
|
||||
|
||||
interface SuggestInputProps {
|
||||
options: SuggestOption[]
|
||||
/** Called with the picked option's value; the box then clears for the next entry. */
|
||||
onSelect: (value: string) => void
|
||||
placeholder?: string
|
||||
emptyText?: string
|
||||
maxResults?: number
|
||||
/** Enter with text but no suggestion to pick (e.g. a barcode from a USB scanner, which types
|
||||
* the code then presses Enter) — receives the typed text; the box then clears. */
|
||||
onEnterWithoutMatch?: (text: string) => void
|
||||
autoFocus?: boolean
|
||||
/** Also works as a dropdown: clicking the box (or its arrow, or pressing ↓) lists every option
|
||||
* before anything is typed. Off by default, so plain type-ahead boxes stay quiet on focus. */
|
||||
browsable?: boolean
|
||||
/** With `browsable`: after a pick the box stays focused, keeps its (selected) search text and
|
||||
* the list stays open, for adding several options in a row. Click away or press Esc to close. */
|
||||
keepOpenOnSelect?: boolean
|
||||
className?: string
|
||||
"aria-label"?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-ahead "add" box: shows no list until the user types, then suggests matching options
|
||||
* below the input. Every typed word must appear in the label (so "bolt 10" finds
|
||||
* "BLT-10 — Hex bolt 10mm"); labels starting with the query rank first. Picking one fires
|
||||
* `onSelect` and clears the box, so it works as a quick "add another" field rather than a
|
||||
* value holder.
|
||||
*/
|
||||
export function SuggestInput({
|
||||
options,
|
||||
onSelect,
|
||||
placeholder = "Type to search…",
|
||||
emptyText = "No matches.",
|
||||
maxResults = 8,
|
||||
onEnterWithoutMatch,
|
||||
autoFocus,
|
||||
browsable = false,
|
||||
keepOpenOnSelect = false,
|
||||
className,
|
||||
...rest
|
||||
}: SuggestInputProps) {
|
||||
const [query, setQuery] = React.useState("")
|
||||
const [open, setOpen] = React.useState(false)
|
||||
// Set only by an explicit click/arrow/↓ (never by plain focus), so an autoFocused box doesn't
|
||||
// pop its full list open on page load.
|
||||
const [browsing, setBrowsing] = React.useState(false)
|
||||
const [highlight, setHighlight] = React.useState(0)
|
||||
const listId = React.useId()
|
||||
const listRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const matches = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return browsing ? options : []
|
||||
const words = q.split(/\s+/)
|
||||
return options
|
||||
.filter((o) => {
|
||||
const label = o.label.toLowerCase()
|
||||
return words.every((w) => label.includes(w))
|
||||
})
|
||||
.sort((a, b) => Number(b.label.toLowerCase().startsWith(q)) - Number(a.label.toLowerCase().startsWith(q)))
|
||||
.slice(0, maxResults)
|
||||
}, [options, query, maxResults, browsing])
|
||||
|
||||
const showList = open && (query.trim().length > 0 || browsing)
|
||||
|
||||
function openBrowse() {
|
||||
if (!browsable) return
|
||||
setBrowsing(true)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
function close() {
|
||||
setOpen(false)
|
||||
setBrowsing(false)
|
||||
}
|
||||
|
||||
// Keep the keyboard-highlighted option visible when arrowing through a long browse list.
|
||||
React.useEffect(() => {
|
||||
listRef.current?.querySelector(`[data-idx="${highlight}"]`)?.scrollIntoView({ block: "nearest" })
|
||||
}, [highlight])
|
||||
|
||||
// The list is portalled to <body> with fixed positioning so it isn't clipped when the box sits
|
||||
// inside a scroll container (e.g. a table wrapped in overflow-x-auto) — so track the box's
|
||||
// on-screen position while it's open.
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null)
|
||||
const [rect, setRect] = React.useState<DOMRect | null>(null)
|
||||
React.useLayoutEffect(() => {
|
||||
if (!showList) return
|
||||
const update = () => setRect(wrapperRef.current?.getBoundingClientRect() ?? null)
|
||||
update()
|
||||
window.addEventListener("scroll", update, true)
|
||||
window.addEventListener("resize", update)
|
||||
return () => {
|
||||
window.removeEventListener("scroll", update, true)
|
||||
window.removeEventListener("resize", update)
|
||||
}
|
||||
}, [showList])
|
||||
|
||||
// The box can also move without any scroll/resize — e.g. a pick adds a table row above it and
|
||||
// pushes it down — so re-measure after every render too (only storing a real change, so this
|
||||
// can't loop).
|
||||
React.useLayoutEffect(() => {
|
||||
if (!showList) return
|
||||
const next = wrapperRef.current?.getBoundingClientRect()
|
||||
if (next && (next.bottom !== rect?.bottom || next.left !== rect?.left || next.width !== rect?.width)) setRect(next)
|
||||
})
|
||||
|
||||
function choose(option: SuggestOption) {
|
||||
onSelect(option.value)
|
||||
if (browsable && keepOpenOnSelect) {
|
||||
// Keep the search text (and its filtered list) so similar items can be added one after
|
||||
// another; the text is selected, so simply typing replaces it with a new search. The
|
||||
// just-picked option stays highlighted for a quick repeat.
|
||||
setBrowsing(true)
|
||||
setOpen(true)
|
||||
setHighlight(Math.max(matches.findIndex((o) => o.value === option.value), 0))
|
||||
wrapperRef.current?.querySelector("input")?.select()
|
||||
} else {
|
||||
setQuery("")
|
||||
close()
|
||||
setHighlight(0)
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setOpen(true)
|
||||
if (!showList) {
|
||||
openBrowse()
|
||||
setHighlight(0)
|
||||
return
|
||||
}
|
||||
setHighlight((h) => Math.min(h + 1, Math.max(matches.length - 1, 0)))
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setHighlight((h) => Math.max(h - 1, 0))
|
||||
} else if (e.key === "Enter") {
|
||||
// Never submit a surrounding form from here — Enter only picks a suggestion.
|
||||
e.preventDefault()
|
||||
const option = matches[highlight]
|
||||
if (option) {
|
||||
choose(option)
|
||||
} else if (onEnterWithoutMatch && query.trim()) {
|
||||
onEnterWithoutMatch(query.trim())
|
||||
setQuery("")
|
||||
close()
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={cn("relative", className)}>
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setOpen(true)
|
||||
setHighlight(0)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onClick={openBrowse}
|
||||
onBlur={close}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
// Stop the browser's own autofill history from dropping down "previous entries".
|
||||
autoComplete="off"
|
||||
autoFocus={autoFocus}
|
||||
className={cn("h-11 pl-9 text-sm", browsable && "pr-9")}
|
||||
role="combobox"
|
||||
aria-expanded={showList}
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-label={rest["aria-label"]}
|
||||
/>
|
||||
{browsable && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Show all options"
|
||||
// mousedown + preventDefault keeps focus in the input, so its blur doesn't close the list.
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
wrapperRef.current?.querySelector("input")?.focus()
|
||||
if (showList) close()
|
||||
else {
|
||||
openBrowse()
|
||||
setHighlight(0)
|
||||
}
|
||||
}}
|
||||
className="absolute top-1/2 right-2 flex size-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<ChevronDown className={cn("size-4 transition-transform", showList && "rotate-180")} />
|
||||
</button>
|
||||
)}
|
||||
{showList && rect && createPortal(
|
||||
<div
|
||||
ref={listRef}
|
||||
id={listId}
|
||||
role="listbox"
|
||||
style={{ top: rect.bottom + 4, left: rect.left, width: rect.width }}
|
||||
className="fixed z-50 max-h-72 min-w-72 overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10"
|
||||
>
|
||||
{matches.length === 0 ? (
|
||||
<p className="px-2.5 py-4 text-center text-sm text-muted-foreground">{emptyText}</p>
|
||||
) : (
|
||||
matches.map((option, idx) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
data-idx={idx}
|
||||
aria-selected={idx === highlight}
|
||||
// mousedown (not click) so the input's blur doesn't close the list first.
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
choose(option)
|
||||
}}
|
||||
onMouseEnter={() => setHighlight(idx)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-3 rounded-md px-2.5 py-2 text-left text-sm",
|
||||
idx === highlight ? "bg-secondary text-secondary-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.hint && <span className="shrink-0 text-xs text-muted-foreground tabular-nums">{option.hint}</span>}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user