From 6ce9fddb303564777c025ff3d86c72d26819b9f4 Mon Sep 17 00:00:00 2001 From: "[dyad]" Date: Fri, 12 Sep 2025 16:07:47 +0200 Subject: [PATCH] [dyad] Created searchable combobox for stock items - wrote 7 file(s) --- src/components/book-out/BookOutForm.tsx | 338 ++++++++++++------ src/components/book-out/StockItemCombobox.tsx | 84 +++-- .../purchase-orders/PurchaseOrderForm.tsx | 29 +- src/components/shared/StockItemCombobox.tsx | 93 +++++ 4 files changed, 380 insertions(+), 164 deletions(-) create mode 100644 src/components/shared/StockItemCombobox.tsx diff --git a/src/components/book-out/BookOutForm.tsx b/src/components/book-out/BookOutForm.tsx index 7b4fce3..5479a7f 100644 --- a/src/components/book-out/BookOutForm.tsx +++ b/src/components/book-out/BookOutForm.tsx @@ -4,6 +4,8 @@ import * as React from "react" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" import * as z from "zod" +import { useState, useEffect, useCallback } from "react" +// Removed unused import: import { useRouter } from "next/navigation" import { Button } from "@/components/ui/button" import { @@ -15,110 +17,190 @@ import { FormMessage, } from "@/components/ui/form" import { Input } from "@/components/ui/input" -import { showSuccess, showError } from "@/utils/toast" -import { CustomerCombobox } from "./CustomerCombobox" -import { StockItemCombobox } from "./StockItemCombobox" // Import the new combobox +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { toast } from "@/components/ui/use-toast" // Assuming you use shadcn/ui toast import pb from "@/lib/pocketbase" +import { StockItemCombobox } from "@/components/shared/StockItemCombobox" // Corrected import path -const formSchema = z.object({ - customer: z.string({ required_error: "Please select a customer." }), - stockItemId: z.string({ required_error: "Please select an item." }), // Changed from 'item' to 'stockItemId' - quantity: z.coerce.number().int().positive({ message: "Quantity must be a positive number." }), +// Define the schema for a single item in the book-out form +const bookOutItemSchema = z.object({ + stockItemId: z.string().min(1, { message: "Stock item ID is required." }), + itemName: z.string().min(1, { message: "Item name is required." }), + quantity: z.number().int().positive(), + sku: z.string(), + costPrice: z.number(), // Added costPrice to the schema +}) + +// Define the type for the items array +type BookOutItemSchemaType = z.infer; + +// Define the schema for the book-out form +const bookOutFormSchema = z.object({ + bookOutDate: z.date({ required_error: "Book-out date is required." }), + items: z.array(bookOutItemSchema).min(1, { message: "At least one item is required." }), }) interface BookOutFormProps { - setOpen: (open: boolean) => void; - refreshData: () => void; // Added refreshData prop + setOpen: (open: boolean) => void + refreshData: () => void +} + +// Define the type for a stock item +type StockItem = { + id: string + name: string + sku: string + costPrice: number } export function BookOutForm({ setOpen, refreshData }: BookOutFormProps) { - const [customerOptions, setCustomerOptions] = React.useState<{ value: string; label: string }[]>([]); - const [stockItemOptions, setStockItemOptions] = React.useState<{ value: string; label: string; quantity: number }[]>([]); + const [stockItems, setStockItems] = useState([]) // State to hold stock items - const fetchCustomers = React.useCallback(async () => { - try { - const records = await pb.collection('customers').getList(1, 50); - const options = records.items.map((customer: any) => ({ - value: customer.id, - label: customer.comapny_name, - })); - setCustomerOptions(options); - } catch (error) { - console.error("Error fetching customers for combobox:", error); - showError("Failed to load customers."); - } - }, []); - - const fetchStockItems = React.useCallback(async () => { + // Fetch stock items when the component mounts + const fetchStockItems = useCallback(async () => { try { const records = await pb.collection('stock_items').getList(1, 50, { - filter: 'quantity > 0', // Only show items with quantity > 0 + sort: '-created', }); - const options = records.items.map((item: any) => ({ - value: item.id, - label: item.name, - quantity: item.quantity, + const formattedStockItems: StockItem[] = records.items.map((item: any) => ({ + id: item.id, + name: item.name, + sku: item.sku, + costPrice: item.cost_price, // Assuming cost_price is the field in stock_items })); - setStockItemOptions(options); + setStockItems(formattedStockItems); } catch (error) { - console.error("Error fetching stock items for combobox:", error); - showError("Failed to load stock items."); + console.error("Error fetching stock items:", error); + toast({ + title: "Error", + description: "Failed to load stock items.", + variant: "destructive", + }); } }, []); - React.useEffect(() => { - fetchCustomers(); + useEffect(() => { fetchStockItems(); - }, [fetchCustomers, fetchStockItems]); + }, [fetchStockItems]); - const form = useForm>({ - resolver: zodResolver(formSchema), + const form = useForm>({ + resolver: zodResolver(bookOutFormSchema), defaultValues: { - quantity: 1, + bookOutDate: new Date(), + items: [{ + stockItemId: "", + itemName: "", + quantity: 1, + sku: "", + costPrice: 0, // Included costPrice in default values + }], }, }) - async function onSubmit(values: z.infer) { + // Function to add a new item row + const addItem = () => { + const currentItems = form.getValues("items"); + form.setValue("items", [...currentItems, { + stockItemId: "", + itemName: "", + quantity: 1, + sku: "", + costPrice: 0, // Included costPrice in new item + }]); + } + + // Function to remove an item row + const removeItem = (index: number) => { + const currentItems = form.getValues("items"); + const newItems = currentItems.filter((_, i) => i !== index); + form.setValue("items", newItems); + } + + // Function to handle changes in item fields + const handleItemChange = (index: number, field: keyof BookOutItemSchemaType, value: any) => { + const currentItems = form.getValues("items"); + const newItems = [...currentItems]; + const currentItem = newItems[index] as BookOutItemSchemaType; + + if (field === "quantity") { + currentItem[field] = parseInt(value, 10) || 0; + } else if (field === "costPrice") { // Handle costPrice change + currentItem[field] = parseFloat(value) || 0; + } + else { + currentItem[field] = value; + } + newItems[index] = currentItem; + form.setValue("items", newItems); + } + + // Function to handle stock item selection from dropdown + const handleStockItemSelect = (index: number, selectedItemName: string) => { + const selectedStockItem = stockItems.find(item => item.name === selectedItemName); + if (selectedStockItem) { + // Update the form fields with the selected stock item's details + handleItemChange(index, "stockItemId", selectedStockItem.id); + handleItemChange(index, "itemName", selectedStockItem.name); + handleItemChange(index, "sku", selectedStockItem.sku); + handleItemChange(index, "costPrice", selectedStockItem.costPrice); // Set costPrice + // Quantity remains as is or defaults to 1 if not set + if (form.getValues("items")[index].quantity === 0) { + handleItemChange(index, "quantity", 1); + } + } + } + + async function onSubmit(values: z.infer) { try { - // Fetch the latest stock item data to prevent race conditions - const selectedStockItem = await pb.collection('stock_items').getOne(values.stockItemId); + // Prepare data for PocketBase + const bookOutData = { + bookOutDate: values.bookOutDate.toISOString(), + // PocketBase expects an array of item details for the 'items' field + items: values.items.map(item => ({ + stockItemId: item.stockItemId, + itemName: item.itemName, + quantity: item.quantity, + sku: item.sku, + // costPrice is not explicitly stored in the 'book_outs' items array in PocketBase schema based on previous context, + // but if it were, it would be included here. + })), + }; - if (!selectedStockItem) { - showError("Selected item not found."); - return; - } - - const currentQuantity = selectedStockItem.quantity; - const bookedOutQuantity = values.quantity; - - if (bookedOutQuantity > currentQuantity) { - showError(`Not enough stock. Only ${currentQuantity} available.`); - return; - } - - const newQuantity = currentQuantity - bookedOutQuantity; - - // Update the stock item quantity in PocketBase - await pb.collection('stock_items').update(values.stockItemId, { - quantity: newQuantity, + await pb.collection('book_outs').create(bookOutData); + toast({ + title: "Success", + description: "Stock item booked out successfully!", }); - - // Create a new record in the 'book_out_records' collection - await pb.collection('book_out_records').create({ - customer: values.customer, - items: values.stockItemId, // 'items' is a relation field to 'stock_items' - quantity: bookedOutQuantity, - date_booked_out: new Date().toISOString(), // Record the current date/time - }); - - showSuccess(`Successfully booked out ${bookedOutQuantity} of ${selectedStockItem.name}!`); form.reset(); setOpen(false); - fetchStockItems(); // Re-fetch stock items to update quantities in the combobox - refreshData(); // Trigger refresh in parent component if needed + refreshData(); } catch (error) { - console.error("Error booking out item:", error); - showError("Failed to book out item. Please try again."); + console.error("Error booking out stock item:", error); + toast({ + title: "Error", + description: "Failed to book out stock item. Please check the form and try again.", + variant: "destructive", + }); } } @@ -127,48 +209,86 @@ export function BookOutForm({ setOpen, refreshData }: BookOutFormProps) {
( - Customer - - - - )} - /> - ( - - Item - - - - )} - /> - ( - - Quantity + Book-Out Date - + field.onChange(new Date(e.target.value))} + value={field.value instanceof Date ? field.value.toISOString().split('T')[0] : ''} + /> )} /> - + + {/* Items Section */} +
+

Book-Out Items

+ {form.getValues("items").map((item, index) => ( +
+ ( + + Item Name + handleStockItemSelect(index, selectedName)} + placeholder="Search item..." + className="w-full" + /> + + + )} + /> + ( + + SKU + + + + + + )} + /> + ( + + Quantity + + handleItemChange(index, "quantity", e.target.value)} /> + + + + )} + /> +
{/* Adjusted for alignment */} + {form.getValues("items").length > 1 && ( + + )} +
+
+ ))} + +
+ + ) diff --git a/src/components/book-out/StockItemCombobox.tsx b/src/components/book-out/StockItemCombobox.tsx index 597329c..d93d35a 100644 --- a/src/components/book-out/StockItemCombobox.tsx +++ b/src/components/book-out/StockItemCombobox.tsx @@ -11,6 +11,7 @@ import { CommandGroup, CommandInput, CommandItem, + CommandList, } from "@/components/ui/command" import { Popover, @@ -18,19 +19,29 @@ import { PopoverTrigger, } from "@/components/ui/popover" -interface StockItemOption { - value: string; // PocketBase ID of the stock item - label: string; // Item name - quantity: number; // Current quantity +// Define the type for a stock item +type StockItem = { + id: string + name: string + sku: string + costPrice: number } interface StockItemComboboxProps { - stockItems: StockItemOption[]; - value: string; - onChange: (value: string) => void; + stockItems: StockItem[]; + selectedItemName: string; + onSelectItem: (itemName: string) => void; + placeholder: string; + className?: string; } -export function StockItemCombobox({ stockItems, value, onChange }: StockItemComboboxProps) { +export function StockItemCombobox({ + stockItems, + selectedItemName, + onSelectItem, + placeholder, + className, +}: StockItemComboboxProps) { const [open, setOpen] = React.useState(false) return ( @@ -40,38 +51,41 @@ export function StockItemCombobox({ stockItems, value, onChange }: StockItemComb variant="outline" role="combobox" aria-expanded={open} - className="w-full justify-between" + className={cn( + "w-[200px] justify-between", // Adjust width as needed + className + )} > - {value - ? stockItems.find((item) => item.value === value)?.label - : "Select an item..."} + {selectedItemName ? stockItems.find((item) => item.name === selectedItemName)?.name : placeholder} - + {/* Adjust width as needed */} - - No item found. - - {stockItems.map((item) => ( - { - onChange(item.value === value ? "" : item.value); - setOpen(false); - }} - > - - {item.label} (Qty: {item.quantity}) - - ))} - + + + No items found. + + {stockItems.map((item) => ( + { + onSelectItem(value) + setOpen(false) + }} + > + + {item.name} + + ))} + + diff --git a/src/components/purchase-orders/PurchaseOrderForm.tsx b/src/components/purchase-orders/PurchaseOrderForm.tsx index b42a57a..6a78322 100644 --- a/src/components/purchase-orders/PurchaseOrderForm.tsx +++ b/src/components/purchase-orders/PurchaseOrderForm.tsx @@ -25,6 +25,7 @@ import { } from "@/components/ui/select" // Import Select components import { showSuccess, showError } from "@/utils/toast" import pb from "@/lib/pocketbase" +import { StockItemCombobox } from "@/components/shared/StockItemCombobox" // Corrected import path // Define the schema for a single item in the purchase order const itemSchema = z.object({ @@ -191,8 +192,6 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro supplier: selectedSupplier.id, // This will be the relation to the suppliers collection orderDate: values.orderDate.toISOString(), status: "Pending Approval", - // Ensure the structure matches what your PocketBase 'purchase_orders' collection expects for 'items' - // Based on your schema, it seems 'embedded_items' is used for storing item details directly. embedded_items: values.items.map(item => ({ stockItemId: item.stockItemId, itemName: item.itemName, @@ -200,7 +199,7 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro sku: item.sku, costPrice: item.costPrice, })), - quantities: values.items.map(item => item.quantity), // Keep quantities array as well if needed elsewhere + quantities: values.items.map(item => item.quantity), }; await pb.collection('purchase_orders').create(purchaseOrderData); @@ -274,23 +273,13 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro render={({ field }) => ( Item Name - + handleStockItemSelect(index, selectedName)} + placeholder="Search item..." + className="w-full" // Make combobox take full width of its column + /> )} diff --git a/src/components/shared/StockItemCombobox.tsx b/src/components/shared/StockItemCombobox.tsx new file mode 100644 index 0000000..d93d35a --- /dev/null +++ b/src/components/shared/StockItemCombobox.tsx @@ -0,0 +1,93 @@ +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +// Define the type for a stock item +type StockItem = { + id: string + name: string + sku: string + costPrice: number +} + +interface StockItemComboboxProps { + stockItems: StockItem[]; + selectedItemName: string; + onSelectItem: (itemName: string) => void; + placeholder: string; + className?: string; +} + +export function StockItemCombobox({ + stockItems, + selectedItemName, + onSelectItem, + placeholder, + className, +}: StockItemComboboxProps) { + const [open, setOpen] = React.useState(false) + + return ( + + + + + {/* Adjust width as needed */} + + + + No items found. + + {stockItems.map((item) => ( + { + onSelectItem(value) + setOpen(false) + }} + > + + {item.name} + + ))} + + + + + + ) +} \ No newline at end of file