JEISA-WEBAPP/src/components/purchase-orders/PurchaseOrderForm.tsx
davidels1 315a9c7759 feat(purchase-orders): add edit functionality and improve form validation
- Implement EditPurchaseOrderDialog for modifying existing orders
- Enhance form validation with better number handling and error messages
- Add subtotal calculation to purchase order forms
- Improve PDF generation with logo support and better layout
- Update column definitions to include edit action
- Add className prop to StockItemSearch for better styling control
2026-01-14 09:01:49 +02:00

516 lines
18 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.coerce.number().int().positive(),
sku: z.string(),
costPrice: z.coerce.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.coerce.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,
// 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) {
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, update } = useFieldArray({
control: form.control,
name: "items",
});
const itemsValues = form.watch("items") as ItemSchemaType[];
const subtotalGBP = React.useMemo(
() =>
(itemsValues || []).reduce(
(sum, item) =>
sum + (Number(item?.quantity || 0) * Number(item?.costPrice || 0)),
0
),
[itemsValues]
);
// 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 currentItem = form.getValues(`items.${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;
}
// Use useFieldArray.update to keep internal ids in sync
update(index, currentItem);
}
// 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
form.setValue(`items.${index}.stockItemId`, selectedStockItem.id, { shouldDirty: true });
form.setValue(`items.${index}.itemName`, selectedStockItem.name, { shouldDirty: true });
form.setValue(`items.${index}.sku`, selectedStockItem.sku, { shouldDirty: true });
// Use GBP price for purchase orders
form.setValue(`items.${index}.costPrice`, selectedStockItem.costPriceGBP, { shouldDirty: true, shouldValidate: true });
// Ensure quantity has a sensible default
const currentQty = form.getValues(`items.${index}.quantity`);
if (!currentQty || Number.isNaN(currentQty)) {
form.setValue(`items.${index}.quantity`, 1, { shouldDirty: true, shouldValidate: true });
}
}
}
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);
// Update stock item GBP prices if missing or changed
try {
await Promise.all(
values.items.map(async (item) => {
const current = stockItems.find(si => si.id === item.stockItemId);
const newPrice = Number(item.costPrice || 0);
if (!current) return;
const existing = Number(current.costPriceGBP ?? 0);
if (Number.isFinite(newPrice) && newPrice > 0 && newPrice !== existing) {
await pb.collection('stock_items').update(item.stockItemId, { cost_price_gbp: newPrice });
}
})
);
} catch (updateErr) {
console.error("Error updating stock item GBP prices:", updateErr);
}
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 md:grid-cols-[minmax(220px,1fr),160px,110px,140px,auto] gap-4 mb-4 items-start"
>
<FormField
control={form.control}
name={`items.${index}.itemName`}
render={({ field }) => (
<FormItem>
<FormLabel>Item Name</FormLabel>
<StockItemSearch
stockItems={stockItems}
selectedItemName={field.value}
onSelectItem={(selectedItemId) => handleStockItemSelect(index, selectedItemId)}
placeholder="Search by SKU or name..."
className="min-w-0"
/>
<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" className="md:w-40" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`items.${index}.quantity`}
render={({ field }) => (
<FormItem>
<FormLabel>Quantity</FormLabel>
<FormControl>
<Input
type="text"
inputMode="numeric"
pattern="[0-9]*"
className="text-right md:w-28"
value={field.value ?? ""}
onChange={(e) => field.onChange(e.target.value.replace(/[^0-9]/g, ""))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`items.${index}.costPrice`}
render={({ field }) => (
<FormItem>
<FormLabel>Unit Price (£ GBP)</FormLabel>
<FormControl>
<Input
type="text"
inputMode="decimal"
pattern="^[0-9]*[.,]?[0-9]*$"
placeholder="£0.00"
className="text-right md:w-36"
value={field.value ?? ""}
onChange={(e) => field.onChange(e.target.value.replace(",", "."))}
/>
</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 className="mt-4 border-t pt-4">
<div className="flex items-center justify-end gap-6">
<div className="text-sm text-gray-600">Items total (GBP)</div>
<div className="text-lg font-semibold">£{subtotalGBP.toFixed(2)}</div>
</div>
</div>
</div>
<Button type="submit" className="w-full">Create Purchase Order</Button>
</form>
</Form>
)
}