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:
parent
f7b46986c5
commit
6f12859c71
6 changed files with 85 additions and 59 deletions
0
src/SCRATCH
Normal file
0
src/SCRATCH
Normal 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue