Refactor item selection to use stock item IDs

Updated BookOutForm, StockItemCombobox, PurchaseOrderForm, and StockItemSearch to use stock item IDs instead of names for selection and form state management. Improved search robustness and matching logic, normalized cost price values, and enhanced field array updates for better React Hook Form integration. Added a dialog description to the purchase order modal for improved user guidance.
This commit is contained in:
davidels1 2025-11-24 11:33:12 +02:00
parent f7b46986c5
commit 6f12859c71
6 changed files with 85 additions and 59 deletions

0
src/SCRATCH Normal file
View file

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { useForm, useFieldArray } from "react-hook-form"
import * as z from "zod"
import { useState, useEffect, useCallback } from "react"
@ -114,45 +114,47 @@ export function BookOutForm({ setOpen, refreshData }: BookOutFormProps) {
},
})
const { control } = form
const { fields, append, remove, update } = useFieldArray({
control,
name: "items",
})
const addItem = () => {
const currentItems = form.getValues("items");
form.setValue("items", [...currentItems, {
append({
stockItemId: "",
itemName: "",
quantity: 1,
sku: "",
}]);
})
}
const removeItem = (index: number) => {
const currentItems = form.getValues("items");
const newItems = currentItems.filter((_, i) => i !== index);
form.setValue("items", newItems);
remove(index)
}
const handleItemChange = (index: number, field: keyof BookOutItemSchemaType, value: any) => {
const currentItems = form.getValues("items");
const newItems = [...currentItems];
const currentItem = newItems[index] as BookOutItemSchemaType;
const currentItem = form.getValues("items")[index] as BookOutItemSchemaType
const nextItem = { ...currentItem }
if (field === "quantity") {
currentItem[field] = Number(value) || 0;
nextItem[field] = Number(value) || 0
} else {
currentItem[field] = value;
;(nextItem as any)[field] = value
}
newItems[index] = currentItem;
form.setValue("items", newItems);
update(index, nextItem)
}
const handleStockItemSelect = (index: number, selectedItemName: string) => {
const selectedStockItem = stockItems.find(item => item.name === selectedItemName);
const handleStockItemSelect = (index: number, selectedItemId: string) => {
const selectedStockItem = stockItems.find(item => item.id === selectedItemId)
if (selectedStockItem) {
handleItemChange(index, "stockItemId", selectedStockItem.id);
handleItemChange(index, "itemName", selectedStockItem.name);
handleItemChange(index, "sku", selectedStockItem.sku);
if (form.getValues("items")[index].quantity === 0) {
handleItemChange(index, "quantity", 1);
const currentItem = form.getValues("items")[index] as BookOutItemSchemaType
const nextItem: BookOutItemSchemaType = {
...currentItem,
itemName: selectedStockItem.name,
sku: selectedStockItem.sku,
quantity: currentItem.quantity && currentItem.quantity > 0 ? currentItem.quantity : 1,
}
update(index, nextItem)
}
}
@ -229,19 +231,22 @@ export function BookOutForm({ setOpen, refreshData }: BookOutFormProps) {
<div className="border p-4 rounded-md">
<h3 className="text-lg font-semibold mb-4">Items to Book Out</h3>
{form.getValues("items").map((item, index) => (
<div key={index} className="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4 items-center">
{fields.map((item, index) => (
<div key={item.id} className="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4 items-center">
<FormField
control={form.control}
name={`items.${index}.itemName`}
name={`items.${index}.stockItemId`}
render={({ field }) => (
<FormItem>
<FormLabel>Item Name</FormLabel>
<FormLabel>Item</FormLabel>
<StockItemCombobox
stockItems={stockItems}
selectedItemName={field.value}
onSelectItem={(selectedItemName) => handleStockItemSelect(index, selectedItemName)}
placeholder="Search items..."
selectedItemId={field.value}
onSelectItem={(selectedItemId) => {
field.onChange(selectedItemId);
handleStockItemSelect(index, selectedItemId);
}}
placeholder="Search by name or SKU..."
/>
<FormMessage />
</FormItem>

View file

@ -29,15 +29,38 @@ type StockItem = {
interface StockItemComboboxProps {
stockItems: StockItem[];
selectedItemName: string;
onSelectItem: (itemName: string) => void;
selectedItemId: string;
onSelectItem: (itemId: string) => void;
placeholder: string;
}
export function StockItemCombobox({ stockItems, selectedItemName, onSelectItem, placeholder }: StockItemComboboxProps) {
export function StockItemCombobox({ stockItems, selectedItemId, onSelectItem, placeholder }: StockItemComboboxProps) {
const [open, setOpen] = React.useState(false)
const [search, setSearch] = React.useState("")
// Normalize strings for more robust matching (ignore case and non-alphanumerics)
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "")
const query = normalize(search)
const filteredItems = React.useMemo(() => {
if (!query) return stockItems
return stockItems
.map((item) => {
const nameNorm = normalize(item.name)
const skuNorm = normalize(item.sku)
return { item, nameNorm, skuNorm }
})
.filter(({ nameNorm, skuNorm }) => nameNorm.includes(query) || skuNorm.includes(query))
.sort((a, b) => {
const aSkuMatch = a.skuNorm.includes(query) ? 1 : 0
const bSkuMatch = b.skuNorm.includes(query) ? 1 : 0
if (aSkuMatch !== bSkuMatch) return bSkuMatch - aSkuMatch // prioritize SKU matches
const aPos = a.nameNorm.indexOf(query)
const bPos = b.nameNorm.indexOf(query)
return aPos - bPos // earlier match in name comes first
})
.map(({ item }) => item)
}, [stockItems, query])
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@ -47,7 +70,7 @@ export function StockItemCombobox({ stockItems, selectedItemName, onSelectItem,
aria-expanded={open}
className="w-full justify-between"
>
{selectedItemName ? stockItems.find(item => item.name === selectedItemName)?.name : placeholder}
{selectedItemId ? stockItems.find(item => item.id === selectedItemId)?.name : placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@ -61,24 +84,19 @@ export function StockItemCombobox({ stockItems, selectedItemName, onSelectItem,
<CommandList> {/* Use CommandList for scrolling */}
<CommandEmpty>No items found.</CommandEmpty>
<CommandGroup>
{stockItems
.filter(item =>
item.name.toLowerCase().includes(search.toLowerCase()) ||
item.sku.toLowerCase().includes(search.toLowerCase())
)
.map((item) => (
{filteredItems.map((item) => (
<CommandItem
key={item.id}
value={item.name} // Use item name for value
onSelect={(value) => {
onSelectItem(value);
value={`${item.name} ${item.sku}`}
onSelect={() => {
onSelectItem(item.id);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedItemName === item.name ? "opacity-100" : "opacity-0"
selectedItemId === item.id ? "opacity-100" : "opacity-0"
)}
/>
<div>

View file

@ -132,8 +132,9 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
id: item.id,
name: item.name,
sku: item.sku,
costPrice: item.cost_price, // ZAR cost price
costPriceGBP: item.cost_price_gbp || 0, // GBP cost price
// Normalize costs to numbers to avoid UI rendering errors
costPrice: Number(item.cost_price ?? 0), // ZAR cost price
costPriceGBP: Number(item.cost_price_gbp ?? 0), // GBP cost price
}));
setStockItems(formattedStockItems);
} catch (error) {
@ -174,7 +175,7 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
}
}, [orderNumber, form]);
const { fields, append, remove } = useFieldArray({
const { fields, append, remove, update } = useFieldArray({
control: form.control,
name: "items",
});
@ -198,9 +199,7 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
// Function to handle changes in item fields
const handleItemChange = (index: number, field: keyof ItemSchemaType, value: any) => {
const currentItems = form.getValues("items");
const newItems = [...currentItems];
const currentItem = newItems[index] as ItemSchemaType;
const currentItem = form.getValues(`items.${index}`) as ItemSchemaType;
if (field === "quantity") {
currentItem[field] = parseInt(value, 10) || 0;
@ -209,13 +208,14 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
} else {
currentItem[field] = value;
}
newItems[index] = currentItem;
form.setValue("items", newItems);
// Use useFieldArray.update to keep internal ids in sync
update(index, currentItem);
}
// Function to handle stock item selection from dropdown
const handleStockItemSelect = (index: number, selectedItemName: string) => {
const selectedStockItem = stockItems.find(item => item.name === selectedItemName);
// Function to handle stock item selection by ID (robust against duplicate/special names)
const handleStockItemSelect = (index: number, selectedItemId: string) => {
const selectedStockItem = stockItems.find(item => item.id === selectedItemId);
if (selectedStockItem) {
// Update the form fields with the selected stock item's details
handleItemChange(index, "stockItemId", selectedStockItem.id);
@ -393,7 +393,7 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
<StockItemSearch
stockItems={stockItems}
selectedItemName={field.value}
onSelectItem={(selectedItemName) => handleStockItemSelect(index, selectedItemName)}
onSelectItem={(selectedItemId) => handleStockItemSelect(index, selectedItemId)}
placeholder="Search by SKU or name..."
/>
<FormMessage />

View file

@ -31,7 +31,7 @@ type StockItem = {
interface StockItemSearchProps {
stockItems: StockItem[];
selectedItemName: string;
onSelectItem: (itemName: string) => void;
onSelectItem: (itemId: string) => void;
placeholder: string;
}
@ -80,8 +80,8 @@ export function StockItemSearch({ stockItems, selectedItemName, onSelectItem, pl
<CommandItem
key={item.id}
value={item.name}
onSelect={(value) => {
onSelectItem(value);
onSelect={() => {
onSelectItem(item.id);
setOpen(false);
}}
>
@ -94,7 +94,7 @@ export function StockItemSearch({ stockItems, selectedItemName, onSelectItem, pl
<div className="flex-1">
<div className="font-medium">{item.name}</div>
<div className="text-xs text-muted-foreground">
SKU: {item.sku} | GBP: £{item.costPriceGBP.toFixed(2)} | ZAR: R{item.costPrice.toFixed(2)}
SKU: {item.sku} | GBP: £{Number(item.costPriceGBP ?? 0).toFixed(2)} | ZAR: R{Number(item.costPrice ?? 0).toFixed(2)}
</div>
</div>
</CommandItem>

View file

@ -103,6 +103,9 @@ const PurchaseOrders = () => {
<DialogContent className="mx-4 sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>New Purchase Order</DialogTitle>
<DialogDescription>
Select a supplier and add one or more items to create a purchase order.
</DialogDescription>
</DialogHeader>
<PurchaseOrderForm setOpen={setPurchaseOrderOpen} refreshData={handleRefreshData} />
</DialogContent>