feat(grn): add update functionality for draft GRNs and support document-level discounts

- Implemented Update method in GrnsController to allow editing of draft GRNs.
- Enhanced CreateGrnRequest to include an optional totalDiscount property.
- Updated GrnService to handle GRN updates, including validation and line item processing.
- Modified NewGrnPage to support editing existing GRNs and applying document-level discounts.
- Improved UI in NewItemPage and GrnDetailPage for better user experience.
- Added search functionality to Select component for improved item selection.
This commit is contained in:
2026-08-11 11:00:31 +05:30
parent 8e9974b735
commit bb6d939059
13 changed files with 450 additions and 76 deletions
+80 -3
View File
@@ -4,7 +4,7 @@ import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon, SearchIcon } from "lucide-react"
// Base UI's `Select.Value` renders the raw selected value (e.g. an id) unless the Root is
// given an `items` map to resolve the label from — the popup items are unmounted when closed,
@@ -61,6 +61,50 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
)
}
/** Flattens a node's rendered text so a SelectItem can be matched against a search query. */
function nodeToText(node: React.ReactNode): string {
if (node === null || node === undefined || typeof node === "boolean") return ""
if (typeof node === "string" || typeof node === "number") return String(node)
if (Array.isArray(node)) return node.map(nodeToText).join(" ")
if (React.isValidElement(node)) {
return nodeToText((node.props as { children?: React.ReactNode }).children)
}
return ""
}
/** Walks the popup's children, dropping any SelectItem whose text doesn't match the query. */
function filterSelectChildren(children: React.ReactNode, query: string): React.ReactNode {
const q = query.trim().toLowerCase()
if (!q) return children
return React.Children.map(children, (child) => {
if (!React.isValidElement(child)) return child
if (child.type === SelectItem) {
const text = nodeToText((child.props as { children?: React.ReactNode }).children).toLowerCase()
return text.includes(q) ? child : null
}
const nested = (child.props as { children?: React.ReactNode }).children
if (nested !== undefined) {
return React.cloneElement(child, undefined, filterSelectChildren(nested, query))
}
return child
})
}
function countSelectItems(node: React.ReactNode): number {
let count = 0
React.Children.forEach(node, (child) => {
if (!React.isValidElement(child)) return
if (child.type === SelectItem) {
count += 1
return
}
const nested = (child.props as { children?: React.ReactNode }).children
if (nested !== undefined) count += countSelectItems(nested)
})
return count
}
function SelectTrigger({
className,
size = "default",
@@ -96,13 +140,27 @@ function SelectContent({
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
// Aligning the popup to the selected item lets it float above the trigger (and above the
// search box). A plain dropdown that always opens fully below the trigger is what the
// search box needs to stay pinned to the top, so this defaults to off now.
alignItemWithTrigger = false,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
const [query, setQuery] = React.useState("")
const searchRef = React.useRef<HTMLInputElement>(null)
// The popup remounts each time it opens, so this only ever fires once per open.
React.useEffect(() => {
searchRef.current?.focus()
}, [])
const filteredChildren = React.useMemo(() => filterSelectChildren(children, query), [children, query])
const noResults = query.trim().length > 0 && countSelectItems(filteredChildren) === 0
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
@@ -119,8 +177,27 @@ function SelectContent({
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<div data-slot="select-search" className="sticky top-0 z-10 bg-popover p-1.5 pb-1">
<div className="relative">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input
ref={searchRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation()
}}
placeholder="Search…"
className="h-8 w-full rounded-md border border-input bg-transparent pr-2 pl-7 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
</div>
</div>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectPrimitive.List>{filteredChildren}</SelectPrimitive.List>
{noResults && (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">No results found.</div>
)}
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>