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