JEISA-WEBAPP/src/components/purchase-orders/PurchaseOrderForm.tsx
davidels1 f7b46986c5 Add GBP cost price to stock and purchase orders
Introduces a new 'cost_price_gbp' field to the stock items schema and database migration. Updates stock item forms to support both ZAR and GBP cost prices. Purchase order forms and PDF generation now use GBP pricing, with enhanced supplier and order metadata. Adds a searchable stock item selector for purchase orders and improves type definitions and UI labels for clarity.
2025-11-05 14:55:42 +02:00

461 lines
No EOL
15 KiB
TypeScript

"use client"
import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm, useFieldArray } from "react-hook-form"
import * as z from "zod"
import { useState, useEffect, useCallback } from "react" // Import useState and useEffect
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select" // Import Select components
import { showSuccess, showError } from "@/utils/toast"
import pb from "@/lib/pocketbase"
import { StockItemSearch } from "./StockItemSearch"
// Define the schema for a single item in the purchase order
const itemSchema = 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(),
})
// Define the type for the items array
type ItemSchemaType = z.infer<typeof itemSchema>;
// Define the schema for the purchase order form
const purchaseOrderFormSchema = z.object({
orderNumber: z.string().min(1, { message: "Order number is required." }),
supplierName: z.string().min(1, { message: "Supplier name is required." }),
accountRef: z.string().optional(),
salesOrderNo: z.string().optional(),
carriageCharge: z.number().min(0).optional(),
orderDate: z.date({ required_error: "Order date is required." }),
items: z.array(itemSchema).min(1, { message: "At least one item is required." }),
})
interface PurchaseOrderFormProps {
setOpen: (open: boolean) => void
refreshData: () => void
}
// Define the type for a supplier
type Supplier = {
id: string
name: string
}
// Define the type for a stock item
type StockItem = {
id: string
name: string
sku: string
costPrice: number
costPriceGBP: number
}
export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormProps) {
const [suppliers, setSuppliers] = useState<Supplier[]>([]) // State to hold suppliers
const [stockItems, setStockItems] = useState<StockItem[]>([]) // State to hold stock items
const [orderNumber, setOrderNumber] = useState<string>("") // State for unique order number
// Generate unique order number
const generateOrderNumber = useCallback(async () => {
try {
// Get the latest purchase order to determine next number
const records = await pb.collection('purchase_orders').getList(1, 1, {
sort: '-created',
});
// Generate order number in format: PO-YYYYMMDD-XXX
const today = new Date();
const dateStr = today.toISOString().split('T')[0].replace(/-/g, '');
// Get today's orders to increment counter
const todayOrders = await pb.collection('purchase_orders').getList(1, 100, {
filter: `created >= "${today.toISOString().split('T')[0]} 00:00:00"`,
});
const counter = todayOrders.items.length + 1;
const orderNum = `PO-${dateStr}-${String(counter).padStart(3, '0')}`;
setOrderNumber(orderNum);
return orderNum;
} catch (error) {
console.error("Error generating order number:", error);
// Fallback to timestamp-based number
const fallbackNum = `PO-${Date.now()}`;
setOrderNumber(fallbackNum);
return fallbackNum;
}
}, []);
// Fetch suppliers when the component mounts
const fetchSuppliers = useCallback(async () => {
try {
const records = await pb.collection('suppliers').getFullList({
sort: '-created',
});
const formattedSuppliers: Supplier[] = records.map((item: any) => ({
id: item.id,
name: item.supplier_name, // Use the correct field name from PocketBase
}));
setSuppliers(formattedSuppliers);
} catch (error) {
console.error("Error fetching suppliers:", error);
showError("Failed to load suppliers.");
}
}, []);
// Fetch stock items when the component mounts
const fetchStockItems = useCallback(async () => {
try {
const records = await pb.collection('stock_items').getFullList({
sort: '-created',
});
const formattedStockItems: StockItem[] = records.map((item: any) => ({
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
}));
setStockItems(formattedStockItems);
} catch (error) {
console.error("Error fetching stock items:", error);
showError("Failed to load stock items.");
}
}, []);
useEffect(() => {
fetchSuppliers();
fetchStockItems();
generateOrderNumber();
}, [fetchSuppliers, fetchStockItems, generateOrderNumber]);
const form = useForm<z.infer<typeof purchaseOrderFormSchema>>({
resolver: zodResolver(purchaseOrderFormSchema),
defaultValues: {
orderNumber: orderNumber,
supplierName: "",
accountRef: "",
salesOrderNo: "",
carriageCharge: 0,
orderDate: new Date(),
items: [{
stockItemId: "",
itemName: "",
quantity: 1,
sku: "",
costPrice: 0,
}],
},
})
// Update order number when it's generated
React.useEffect(() => {
if (orderNumber) {
form.setValue('orderNumber', orderNumber);
}
}, [orderNumber, form]);
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "items",
});
// Function to add a new item row
const addItem = () => {
append({
stockItemId: "",
itemName: "",
quantity: 1,
sku: "",
costPrice: 0,
});
}
// Function to remove an item row
const removeItem = (index: number) => {
remove(index);
}
// 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;
if (field === "quantity") {
currentItem[field] = parseInt(value, 10) || 0;
} else if (field === "costPrice") {
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);
// Use GBP price for purchase orders
handleItemChange(index, "costPrice", selectedStockItem.costPriceGBP);
// 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<typeof purchaseOrderFormSchema>) {
try {
// Find the selected supplier's ID
const selectedSupplier = suppliers.find(s => s.name === values.supplierName);
if (!selectedSupplier) {
showError("Invalid supplier selected.");
return;
}
// Prepare data for PocketBase
const purchaseOrderData = {
order_number: values.orderNumber,
supplierName: selectedSupplier.id, // This is the relation field to suppliers collection
account_ref: values.accountRef || "",
sales_order_no: values.salesOrderNo || "",
carriage_charge: values.carriageCharge || 0,
orderDate: values.orderDate.toISOString(),
status: "Pending", // Must match one of: Pending, Ordered, Received
items: values.items.map(item => item.stockItemId),
quantities: values.items.map(item => item.quantity),
};
console.log("Attempting to create purchase order with data:", purchaseOrderData);
console.log("Selected supplier:", selectedSupplier);
console.log("Form values:", values);
await pb.collection('purchase_orders').create(purchaseOrderData);
showSuccess(`Purchase order ${values.orderNumber} created successfully!`);
form.reset();
// Generate new order number for next use
await generateOrderNumber();
setOpen(false);
refreshData();
} catch (error: any) {
console.error("Error creating purchase order:", error);
console.error("Error data:", error.data);
console.error("Error response:", error.response);
// Log detailed field errors
if (error.data?.data) {
console.error("Field validation errors:", JSON.stringify(error.data.data, null, 2));
}
// Create a more detailed error message
let errorMessage = "Failed to create purchase order";
if (error.data?.data) {
const fieldErrors = Object.entries(error.data.data).map(([field, details]: [string, any]) => {
return `${field}: ${details.message || JSON.stringify(details)}`;
});
errorMessage += ":\n" + fieldErrors.join("\n");
}
showError(errorMessage);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="orderNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Order Number</FormLabel>
<FormControl>
<Input {...field} readOnly className="bg-gray-100" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="supplierName"
render={({ field }) => (
<FormItem>
<FormLabel>Supplier</FormLabel>
<Select onValueChange={(value) => {
field.onChange(value);
}} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a supplier" />
</SelectTrigger>
</FormControl>
<SelectContent>
{suppliers.map((supplier) => (
<SelectItem key={supplier.id} value={supplier.name}>
{supplier.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="orderDate"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Order Date</FormLabel>
<FormControl>
<Input
type="date"
{...field}
onChange={(e) => field.onChange(new Date(e.target.value))}
value={field.value instanceof Date ? field.value.toISOString().split('T')[0] : ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="accountRef"
render={({ field }) => (
<FormItem>
<FormLabel>Account Ref</FormLabel>
<FormControl>
<Input placeholder="Optional" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="salesOrderNo"
render={({ field }) => (
<FormItem>
<FormLabel>Sales Order No</FormLabel>
<FormControl>
<Input placeholder="Optional" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Items Section */}
<div className="border p-4 rounded-md">
<h3 className="text-lg font-semibold mb-4">Order Items</h3>
{fields.map((item, index) => (
<div key={item.id} className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-[2fr,1fr,1fr,1fr,auto] gap-4 mb-4 items-center">
<FormField
control={form.control}
name={`items.${index}.itemName`}
render={({ field }) => (
<FormItem>
<FormLabel>Item Name</FormLabel>
<StockItemSearch
stockItems={stockItems}
selectedItemName={field.value}
onSelectItem={(selectedItemName) => handleStockItemSelect(index, selectedItemName)}
placeholder="Search by SKU or name..."
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`items.${index}.sku`}
render={({ field }) => (
<FormItem>
<FormLabel>SKU</FormLabel>
<FormControl>
{/* Display SKU, but it's managed by handleStockItemSelect */}
<Input {...field} readOnly placeholder="SKU" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`items.${index}.quantity`}
render={({ field }) => (
<FormItem>
<FormLabel>Quantity</FormLabel>
<FormControl>
<Input type="number" {...field} onChange={(e) => handleItemChange(index, "quantity", e.target.value)} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`items.${index}.costPrice`}
render={({ field }) => (
<FormItem>
<FormLabel>Unit Price (£ GBP)</FormLabel>
<FormControl>
<Input type="number" step="0.01" placeholder="£0.00" {...field} onChange={(e) => handleItemChange(index, "costPrice", e.target.value)} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-center md:justify-start">
{fields.length > 1 && (
<Button type="button" variant="outline" size="icon" onClick={() => removeItem(index)} className="w-full sm:w-auto">
-
</Button>
)}
</div>
</div>
))}
<Button type="button" variant="outline" onClick={addItem} className="mt-2">
Add Item
</Button>
</div>
<Button type="submit" className="w-full">Create Purchase Order</Button>
</form>
</Form>
)
}