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.
461 lines
No EOL
15 KiB
TypeScript
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>
|
|
)
|
|
} |