product and sales fixers

This commit is contained in:
2026-09-30 17:06:28 +05:30
parent 9fd290e256
commit cea433e320
98 changed files with 85367 additions and 1516 deletions
@@ -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>
)
}