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.
This commit is contained in:
davidels1 2025-11-05 14:55:42 +02:00
parent 1030dad876
commit f7b46986c5
10 changed files with 605 additions and 168 deletions

View file

@ -0,0 +1,27 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_2382354120");
// Add cost_price_gbp field
collection.fields.addAt(6, new Field({
"hidden": false,
"id": "",
"max": null,
"min": 0,
"name": "cost_price_gbp",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}));
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2382354120");
// Remove cost_price_gbp field
collection.fields.removeById("number_cost_price_gbp");
return app.save(collection);
});

View file

@ -87,126 +87,259 @@ export function PurchaseOrderDataTable<TData, TValue>({
} }
const generatePDF = (order: PurchaseOrder) => { const generatePDF = (order: PurchaseOrder) => {
try {
console.log("Generating PDF for order:", order);
const doc = new jsPDF(); const doc = new jsPDF();
// Set font and colors // Modern color scheme - Changed to black
doc.setFont("helvetica"); const brandBlack = "#000000"; // Changed from purple to black
const primaryColor = "#ffc905"; // Your primary color const accentYellow = "#ffc905"; // Accent color
const textColor = "#333333"; const darkText = "#1a1a1a";
const lightGray = "#f5f5f5";
const mediumGray = "#666666";
// --- Company Details --- // Create branded logo box
const companyName = "JEI SA"; doc.setFillColor(brandBlack);
const companyAddress = "47 Station St, Oberholzer, Carletonville, 2499"; doc.roundedRect(14, 10, 20, 20, 3, 3, 'F');
const companyPhone = "018 786 2897"; doc.setFont("helvetica", "bold");
doc.setFontSize(16);
doc.setTextColor(255, 255, 255);
doc.text("JEI", 24, 23, { align: "center" }); // --- Modern Header with black background ---
doc.setFillColor(brandBlack);
doc.rect(0, 0, 210, 45, 'F');
// --- Header Section --- doc.setFillColor(accentYellow);
doc.rect(0, 45, 210, 2, 'F'); // Accent line
// Company name and details
doc.setFont("helvetica", "bold");
doc.setFontSize(20);
doc.setTextColor(255, 255, 255);
doc.text("JEI SA", 40, 20);
doc.setFont("helvetica", "normal");
doc.setFontSize(9);
doc.setTextColor(220, 220, 220);
doc.text("47 Station St, Oberholzer", 40, 26);
doc.text("Carletonville, 2499", 40, 31);
doc.text("Tel: 018 786 2897", 40, 36);
// Purchase Order title - positioned to the right
doc.setFont("helvetica", "bold");
doc.setFontSize(24); doc.setFontSize(24);
doc.setTextColor(primaryColor); doc.setTextColor(255, 255, 255);
doc.text("PURCHASE ORDER", 105, 20, { align: "center" }); doc.text("PURCHASE ORDER", 120, 25);
// --- PO Details Box (Top Right) - Extended for more fields ---
doc.setFillColor(250, 250, 250);
doc.setDrawColor(200, 200, 200);
doc.roundedRect(125, 55, 71, 44, 2, 2, 'FD'); // Increased height for more fields
doc.setFont("helvetica", "bold");
doc.setFontSize(9);
doc.setTextColor(mediumGray);
doc.text("PO Number:", 128, 61);
doc.text("Date:", 128, 69);
doc.text("Account Ref:", 128, 77);
doc.text("Sales Order No:", 128, 85);
doc.text("Status:", 128, 93);
doc.setFont("helvetica", "normal");
doc.setTextColor(darkText);
doc.text(order.orderNumber || order.id, 193, 61, { align: "right" });
doc.text(new Date(order.orderDate).toLocaleDateString(), 193, 69, { align: "right" });
doc.text(order.accountRef || 'N/A', 193, 77, { align: "right" });
doc.text(order.salesOrderNo || 'N/A', 193, 85, { align: "right" });
// Status badge
const statusColors: Record<string, string> = {
'Pending': '#FFA500',
'Ordered': '#4CAF50',
'Received': '#2196F3'
};
const statusColor = statusColors[order.status] || '#999999';
doc.setFillColor(statusColor);
doc.setTextColor(255, 255, 255);
doc.setFont("helvetica", "bold");
const statusWidth = doc.getTextWidth(order.status) + 4;
doc.roundedRect(196 - statusWidth, 89, statusWidth, 6, 1, 1, 'F');
doc.text(order.status, 194, 93, { align: "right" });
// --- Supplier Details Box ---
let y = 95;
doc.setFont("helvetica", "bold");
doc.setFontSize(11);
doc.setTextColor(brandBlack);
doc.text("SUPPLIER INFORMATION", 14, y);
doc.setDrawColor(accentYellow);
doc.setLineWidth(1.5);
doc.line(14, y + 2, 75, y + 2);
y += 10;
doc.setFillColor(lightGray);
doc.roundedRect(14, y, 182, 38, 2, 2, 'F'); // Increased height for supplier info
y += 6;
doc.setFont("helvetica", "bold");
doc.setFontSize(10); doc.setFontSize(10);
doc.setTextColor(textColor); doc.setTextColor(darkText);
doc.text(companyName, 14, 30); // Company Name
doc.text(companyAddress, 14, 35);
doc.text(companyPhone, 14, 40);
doc.setFontSize(10); // Use actual supplier data from expanded relation
doc.text(`PO Number: ${order.id}`, 150, 30); const supplierName = order.supplier?.name || "N/A";
doc.text(`Date: ${new Date(order.orderDate).toLocaleDateString()}`, 150, 35); doc.text(supplierName, 18, y);
doc.text(`Status: ${order.status}`, 150, 40);
// --- Supplier Details --- y += 6;
doc.setFontSize(12); doc.setFont("helvetica", "normal");
doc.setTextColor(primaryColor); doc.setFontSize(8);
doc.text("SUPPLIER DETAILS", 14, 65); doc.setTextColor(mediumGray);
doc.setDrawColor(primaryColor);
doc.line(14, 66, 50, 66); // Underline
doc.setFontSize(10); if (order.supplier?.address) {
doc.setTextColor(textColor); // Wrap long address text
doc.text(`Supplier Name: ${order.supplierName}`, 14, 75); const addressLines = doc.splitTextToSize(order.supplier.address, 160);
// Add more supplier details if available in the future doc.text(addressLines, 18, y);
doc.text("Address: Supplier Address Line 1", 14, 80); y += (addressLines.length * 4) + 2;
doc.text("City, Postal Code", 14, 85); }
doc.text("Contact: Supplier Contact Person", 14, 90); if (order.supplier?.email) {
doc.text("Email: supplier@example.com", 14, 95); doc.text(`Email: ${order.supplier.email}`, 18, y);
y += 5;
}
if (order.supplier?.phone) {
doc.text(`Tel: ${order.supplier.phone}`, 18, y);
}
// --- Items Table --- // --- Items Table ---
let y = 110; y += 12;
doc.setFontSize(12); doc.setFont("helvetica", "bold");
doc.setTextColor(primaryColor); doc.setFontSize(11);
doc.setTextColor(brandBlack);
doc.text("ORDER ITEMS", 14, y); doc.text("ORDER ITEMS", 14, y);
doc.setDrawColor(primaryColor);
doc.line(14, y + 1, 45, y + 1); // Underline
y += 10; // Space after title doc.setDrawColor(accentYellow);
doc.setLineWidth(1.5);
doc.line(14, y + 2, 50, y + 2);
// Table Headers y += 8;
doc.setFillColor(primaryColor);
doc.rect(14, y, 182, 8, "F"); // Background for header
doc.setTextColor("#FFFFFF"); // White text for header
doc.setFontSize(10);
doc.text("Item #", 16, y + 5);
doc.text("Description (SKU)", 40, y + 5);
doc.text("Qty", 130, y + 5);
doc.text("Unit Price", 150, y + 5);
doc.text("Total", 180, y + 5);
y += 8; // Move past header // Modern table header
doc.setFillColor(brandBlack);
doc.roundedRect(14, y, 182, 10, 1, 1, 'F');
doc.setTextColor(textColor); doc.setFont("helvetica", "bold");
doc.setFontSize(9); doc.setFontSize(9);
doc.setTextColor(255, 255, 255);
doc.text("#", 17, y + 6);
doc.text("Description", 28, y + 6);
doc.text("SKU", 100, y + 6);
doc.text("Qty", 130, y + 6, { align: "center" });
doc.text("Unit Price", 155, y + 6, { align: "right" });
doc.text("Total", 188, y + 6, { align: "right" });
y += 10;
// Table rows with alternating colors and proper text wrapping
doc.setFont("helvetica", "normal");
doc.setFontSize(8);
let subtotal = 0; let subtotal = 0;
let rowColor = true;
order.items.forEach((item, index) => { order.items.forEach((item, index) => {
const orderedQuantity = order.quantities[index] || 0; const orderedQuantity = order.quantities[index] || 0;
const unitPrice = item.costPrice || 0; // Use costPrice from the item const unitPriceGBP = item.costPriceGBP || 0; // Use GBP price
const itemTotal = orderedQuantity * unitPrice; const itemTotal = orderedQuantity * unitPriceGBP;
subtotal += itemTotal; subtotal += itemTotal;
doc.text(String(index + 1), 16, y + 5); // Calculate row height based on description length
doc.text(`${item.itemName} (${item.sku})`, 40, y + 5); const maxDescWidth = 65;
doc.text(String(orderedQuantity), 130, y + 5); const wrappedDesc = doc.splitTextToSize(item.itemName, maxDescWidth);
doc.text(unitPrice.toLocaleString("en-ZA", { style: "currency", currency: "ZAR" }), 150, y + 5); const rowHeight = Math.max(10, wrappedDesc.length * 5 + 5); // Dynamic height
doc.text(itemTotal.toLocaleString("en-ZA", { style: "currency", currency: "ZAR" }), 180, y + 5);
y += 8; // Alternating row background
if (rowColor) {
doc.setFillColor(250, 250, 250);
doc.rect(14, y, 182, rowHeight, 'F');
}
rowColor = !rowColor;
doc.setTextColor(darkText);
doc.text(String(index + 1), 17, y + 5);
// Wrap long descriptions - allow multiple lines
doc.text(wrappedDesc, 28, y + 5);
doc.setTextColor(mediumGray);
doc.setFontSize(7);
doc.text(item.sku, 100, y + 5);
doc.setFontSize(8);
doc.setTextColor(darkText);
doc.text(String(orderedQuantity), 130, y + 5, { align: "center" });
doc.text(`£${unitPriceGBP.toFixed(2)}`, 155, y + 5, { align: "right" });
doc.text(`£${itemTotal.toFixed(2)}`, 188, y + 5, { align: "right" });
y += rowHeight;
}); });
// --- Totals Section --- // --- Modern Totals Section ---
y += 10; // Space before totals
doc.setDrawColor(primaryColor);
doc.line(140, y, 196, y); // Line above subtotal
y += 5; y += 5;
doc.setFontSize(10);
doc.text("Subtotal:", 140, y); const carriageCharge = order.carriageCharge || 0;
doc.text(subtotal.toLocaleString("en-ZA", { style: "currency", currency: "ZAR" }), 180, y); const grandTotal = subtotal + carriageCharge;
// Totals box - increased height to accommodate carriage charge
doc.setFillColor(lightGray);
doc.roundedRect(130, y, 66, 38, 2, 2, 'F');
y += 7; y += 7;
doc.text("Shipping:", 140, y); doc.setFont("helvetica", "normal");
doc.text("R0.00", 180, y); // Placeholder for shipping doc.setFontSize(9);
doc.setTextColor(mediumGray);
doc.text("Subtotal:", 135, y);
doc.setTextColor(darkText);
doc.text(`£${subtotal.toFixed(2)}`, 188, y, { align: "right" });
y += 7; y += 6;
doc.text("Tax:", 140, y); doc.setTextColor(mediumGray);
doc.text("R0.00", 180, y); // Placeholder for tax doc.text("Carriage Charge:", 135, y);
doc.setTextColor(darkText);
doc.text(`£${carriageCharge.toFixed(2)}`, 188, y, { align: "right" });
y += 7; y += 6;
doc.text("Other:", 140, y); doc.setTextColor(mediumGray);
doc.text("R0.00", 180, y); // Placeholder for other charges doc.text("Tax (VAT):", 135, y);
doc.setTextColor(darkText);
doc.text("£0.00", 188, y, { align: "right" });
y += 10; y += 8;
doc.setDrawColor(primaryColor);
doc.setLineWidth(0.5);
doc.line(140, y, 196, y); // Thicker line above total
y += 5; // Grand total highlight
doc.setFontSize(14); doc.setFillColor(brandBlack);
doc.setTextColor(primaryColor); doc.roundedRect(130, y - 3, 66, 10, 2, 2, 'F');
doc.text("TOTAL:", 140, y);
doc.text(subtotal.toLocaleString("en-ZA", { style: "currency", currency: "ZAR" }), 180, y); // Grand Total
doc.save(`purchase_order_${order.id}.pdf`); doc.setFont("helvetica", "bold");
doc.setFontSize(11);
doc.setTextColor(255, 255, 255);
doc.text("GRAND TOTAL:", 135, y + 4);
doc.text(`£${grandTotal.toFixed(2)}`, 188, y + 4, { align: "right" });
// --- Footer (with more spacing from grand total) ---
y += 25; // Add more space between grand total and footer
doc.setFont("helvetica", "italic");
doc.setFontSize(8);
doc.setTextColor(mediumGray);
doc.text("Thank you for your business!", 105, y, { align: "center" });
doc.text("This is a computer-generated document and does not require a signature.", 105, y + 5, { align: "center" });
console.log("Saving PDF...");
doc.save(`PO_${order.id}_${new Date().toISOString().split('T')[0]}.pdf`);
console.log("PDF saved successfully!");
} catch (error: any) {
console.error("Error generating PDF:", error);
console.error("Error message:", error?.message);
console.error("Error stack:", error?.stack);
console.error("Order data:", order);
alert(`Failed to generate PDF: ${error?.message || 'Unknown error'}. Please check the console for details.`);
}
}; };
const approveOrder = async (order: PurchaseOrder) => { const approveOrder = async (order: PurchaseOrder) => {

View file

@ -25,6 +25,7 @@ import {
} from "@/components/ui/select" // Import Select components } from "@/components/ui/select" // Import Select components
import { showSuccess, showError } from "@/utils/toast" import { showSuccess, showError } from "@/utils/toast"
import pb from "@/lib/pocketbase" import pb from "@/lib/pocketbase"
import { StockItemSearch } from "./StockItemSearch"
// Define the schema for a single item in the purchase order // Define the schema for a single item in the purchase order
const itemSchema = z.object({ const itemSchema = z.object({
@ -40,7 +41,11 @@ type ItemSchemaType = z.infer<typeof itemSchema>;
// Define the schema for the purchase order form // Define the schema for the purchase order form
const purchaseOrderFormSchema = z.object({ const purchaseOrderFormSchema = z.object({
orderNumber: z.string().min(1, { message: "Order number is required." }),
supplierName: z.string().min(1, { message: "Supplier name 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." }), orderDate: z.date({ required_error: "Order date is required." }),
items: z.array(itemSchema).min(1, { message: "At least one item is required." }), items: z.array(itemSchema).min(1, { message: "At least one item is required." }),
}) })
@ -62,11 +67,43 @@ type StockItem = {
name: string name: string
sku: string sku: string
costPrice: number costPrice: number
costPriceGBP: number
} }
export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormProps) { export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormProps) {
const [suppliers, setSuppliers] = useState<Supplier[]>([]) // State to hold suppliers const [suppliers, setSuppliers] = useState<Supplier[]>([]) // State to hold suppliers
const [stockItems, setStockItems] = useState<StockItem[]>([]) // State to hold stock items 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 // Fetch suppliers when the component mounts
const fetchSuppliers = useCallback(async () => { const fetchSuppliers = useCallback(async () => {
@ -95,7 +132,8 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
id: item.id, id: item.id,
name: item.name, name: item.name,
sku: item.sku, sku: item.sku,
costPrice: item.cost_price, // Assuming cost_price is the field in stock_items costPrice: item.cost_price, // ZAR cost price
costPriceGBP: item.cost_price_gbp || 0, // GBP cost price
})); }));
setStockItems(formattedStockItems); setStockItems(formattedStockItems);
} catch (error) { } catch (error) {
@ -107,12 +145,17 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
useEffect(() => { useEffect(() => {
fetchSuppliers(); fetchSuppliers();
fetchStockItems(); fetchStockItems();
}, [fetchSuppliers, fetchStockItems]); generateOrderNumber();
}, [fetchSuppliers, fetchStockItems, generateOrderNumber]);
const form = useForm<z.infer<typeof purchaseOrderFormSchema>>({ const form = useForm<z.infer<typeof purchaseOrderFormSchema>>({
resolver: zodResolver(purchaseOrderFormSchema), resolver: zodResolver(purchaseOrderFormSchema),
defaultValues: { defaultValues: {
orderNumber: orderNumber,
supplierName: "", supplierName: "",
accountRef: "",
salesOrderNo: "",
carriageCharge: 0,
orderDate: new Date(), orderDate: new Date(),
items: [{ items: [{
stockItemId: "", stockItemId: "",
@ -124,6 +167,13 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
}, },
}) })
// Update order number when it's generated
React.useEffect(() => {
if (orderNumber) {
form.setValue('orderNumber', orderNumber);
}
}, [orderNumber, form]);
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
control: form.control, control: form.control,
name: "items", name: "items",
@ -171,7 +221,8 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
handleItemChange(index, "stockItemId", selectedStockItem.id); handleItemChange(index, "stockItemId", selectedStockItem.id);
handleItemChange(index, "itemName", selectedStockItem.name); handleItemChange(index, "itemName", selectedStockItem.name);
handleItemChange(index, "sku", selectedStockItem.sku); handleItemChange(index, "sku", selectedStockItem.sku);
handleItemChange(index, "costPrice", selectedStockItem.costPrice); // Use GBP price for purchase orders
handleItemChange(index, "costPrice", selectedStockItem.costPriceGBP);
// Quantity remains as is or defaults to 1 if not set // Quantity remains as is or defaults to 1 if not set
if (form.getValues("items")[index].quantity === 0) { if (form.getValues("items")[index].quantity === 0) {
handleItemChange(index, "quantity", 1); handleItemChange(index, "quantity", 1);
@ -190,28 +241,68 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
// Prepare data for PocketBase // Prepare data for PocketBase
const purchaseOrderData = { const purchaseOrderData = {
supplierName: values.supplierName, // This will be the supplier's name for display order_number: values.orderNumber,
supplier: selectedSupplier.id, // This will be the relation to the suppliers collection 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(), orderDate: values.orderDate.toISOString(),
status: "Pending Approval", status: "Pending", // Must match one of: Pending, Ordered, Received
items: values.items.map(item => item.stockItemId), items: values.items.map(item => item.stockItemId),
quantities: values.items.map(item => item.quantity), // Keep quantities array as well if needed elsewhere 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); await pb.collection('purchase_orders').create(purchaseOrderData);
showSuccess("Purchase order created successfully!"); showSuccess(`Purchase order ${values.orderNumber} created successfully!`);
form.reset(); form.reset();
// Generate new order number for next use
await generateOrderNumber();
setOpen(false); setOpen(false);
refreshData(); refreshData();
} catch (error) { } catch (error: any) {
console.error("Error creating purchase order:", error); console.error("Error creating purchase order:", error);
showError("Failed to create purchase order. Please check the form and try again."); 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 ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <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 <FormField
control={form.control} control={form.control}
name="supplierName" name="supplierName"
@ -258,6 +349,36 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
)} )}
/> />
<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 */} {/* Items Section */}
<div className="border p-4 rounded-md"> <div className="border p-4 rounded-md">
<h3 className="text-lg font-semibold mb-4">Order Items</h3> <h3 className="text-lg font-semibold mb-4">Order Items</h3>
@ -269,23 +390,12 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Item Name</FormLabel> <FormLabel>Item Name</FormLabel>
<Select <StockItemSearch
onValueChange={(selectedValue) => handleStockItemSelect(index, selectedValue)} stockItems={stockItems}
value={field.value} // Set the current value of the item name selectedItemName={field.value}
> onSelectItem={(selectedItemName) => handleStockItemSelect(index, selectedItemName)}
<FormControl> placeholder="Search by SKU or name..."
<SelectTrigger> />
<SelectValue placeholder="Select item" />
</SelectTrigger>
</FormControl>
<SelectContent>
{stockItems.map((stockItem) => (
<SelectItem key={stockItem.id} value={stockItem.name}>
{stockItem.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -322,9 +432,9 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
name={`items.${index}.costPrice`} name={`items.${index}.costPrice`}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Unit Price (ZAR)</FormLabel> <FormLabel>Unit Price (£ GBP)</FormLabel>
<FormControl> <FormControl>
<Input type="number" step="0.01" {...field} onChange={(e) => handleItemChange(index, "costPrice", e.target.value)} /> <Input type="number" step="0.01" placeholder="£0.00" {...field} onChange={(e) => handleItemChange(index, "costPrice", e.target.value)} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View file

@ -0,0 +1,108 @@
"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
costPriceGBP: number
}
interface StockItemSearchProps {
stockItems: StockItem[];
selectedItemName: string;
onSelectItem: (itemName: string) => void;
placeholder: string;
}
export function StockItemSearch({ stockItems, selectedItemName, onSelectItem, placeholder }: StockItemSearchProps) {
const [open, setOpen] = React.useState(false)
const [search, setSearch] = React.useState("")
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between text-left font-normal"
>
{selectedItemName ? (
<div className="flex flex-col">
<span>{stockItems.find(item => item.name === selectedItemName)?.name}</span>
<span className="text-xs text-muted-foreground">
{stockItems.find(item => item.name === selectedItemName)?.sku}
</span>
</div>
) : (
placeholder
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search by SKU or name..."
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>No items found.</CommandEmpty>
<CommandGroup>
{stockItems
.filter(item =>
item.name.toLowerCase().includes(search.toLowerCase()) ||
item.sku.toLowerCase().includes(search.toLowerCase())
)
.map((item) => (
<CommandItem
key={item.id}
value={item.name}
onSelect={(value) => {
onSelectItem(value);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedItemName === item.name ? "opacity-100" : "opacity-0"
)}
/>
<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)}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View file

@ -27,8 +27,18 @@ export type PurchaseOrder = {
orderDate: string orderDate: string
status: string status: string
itemCount: number itemCount: number
items: Array<{ stockItemId: string; itemName: string; quantity: number; sku: string; costPrice: number }> // Added costPrice orderNumber?: string
accountRef?: string
salesOrderNo?: string
carriageCharge?: number
items: Array<{ stockItemId: string; itemName: string; quantity: number; sku: string; costPrice: number; costPriceGBP: number }> // Added costPrice and costPriceGBP
quantities: number[] quantities: number[]
supplier?: {
name: string
address: string
email: string
phone: string
} | null
} }
// Define the props for the actions column // Define the props for the actions column

View file

@ -24,8 +24,9 @@ const formSchema = z.object({
name: z.string().min(2, { message: "Name must be at least 2 characters." }), name: z.string().min(2, { message: "Name must be at least 2 characters." }),
sku: z.string().min(2, { message: "SKU must be at least 2 characters." }), sku: z.string().min(2, { message: "SKU must be at least 2 characters." }),
category: z.string({ required_error: "Please select or create a category." }), category: z.string({ required_error: "Please select or create a category." }),
quantity: z.coerce.number().int().positive({ message: "Quantity must be a positive number." }), quantity: z.coerce.number().int().min(0, { message: "Quantity must be 0 or greater." }),
costPrice: z.coerce.number().positive({ message: "Cost price must be a positive number." }), costPrice: z.coerce.number().min(0, { message: "Cost price (ZAR) must be 0 or greater." }),
costPriceGBP: z.coerce.number().min(0, { message: "Cost price (GBP) must be 0 or greater." }),
sellingPrice: z.coerce.number().positive({ message: "Selling price must be a positive number." }), sellingPrice: z.coerce.number().positive({ message: "Selling price must be a positive number." }),
}) })
@ -64,8 +65,9 @@ export function AddItemForm({ setOpen, refreshData }: AddItemFormProps) {
defaultValues: { defaultValues: {
name: "", name: "",
sku: "", sku: "",
quantity: 1, quantity: 0,
costPrice: 0.00, costPrice: 0.00,
costPriceGBP: 0.00,
sellingPrice: 0.00, sellingPrice: 0.00,
}, },
}) })
@ -78,6 +80,7 @@ export function AddItemForm({ setOpen, refreshData }: AddItemFormProps) {
category: values.category, category: values.category,
quantity: values.quantity, quantity: values.quantity,
cost_price: values.costPrice, cost_price: values.costPrice,
cost_price_gbp: values.costPriceGBP,
selling_price: values.sellingPrice, selling_price: values.sellingPrice,
}); });
@ -166,26 +169,41 @@ export function AddItemForm({ setOpen, refreshData }: AddItemFormProps) {
</FormItem> </FormItem>
)} )}
/> />
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<FormField <FormField
control={form.control} control={form.control}
name="costPrice" name="costPriceGBP"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Cost Price</FormLabel> <FormLabel>Cost Price (£ GBP)</FormLabel>
<FormControl> <FormControl>
<Input type="number" step="0.01" placeholder="R0.00" {...field} /> <Input type="number" step="0.01" placeholder="£0.00" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="costPrice"
render={({ field }) => (
<FormItem>
<FormLabel>Cost Price (R ZAR)</FormLabel>
<FormControl>
<Input type="number" step="0.01" placeholder="R0.00" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField <FormField
control={form.control} control={form.control}
name="sellingPrice" name="sellingPrice"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Selling Price</FormLabel> <FormLabel>Selling Price (R ZAR)</FormLabel>
<FormControl> <FormControl>
<Input type="number" step="0.01" placeholder="R0.00" {...field} /> <Input type="number" step="0.01" placeholder="R0.00" {...field} />
</FormControl> </FormControl>

View file

@ -25,8 +25,9 @@ const formSchema = z.object({
name: z.string().min(2, { message: "Name must be at least 2 characters." }), name: z.string().min(2, { message: "Name must be at least 2 characters." }),
sku: z.string().min(2, { message: "SKU must be at least 2 characters." }), sku: z.string().min(2, { message: "SKU must be at least 2 characters." }),
category: z.string({ required_error: "Please select or create a category." }), category: z.string({ required_error: "Please select or create a category." }),
quantity: z.coerce.number().int().positive({ message: "Quantity must be a positive number." }), quantity: z.coerce.number().int().min(0, { message: "Quantity must be 0 or greater." }),
costPrice: z.coerce.number().positive({ message: "Cost price must be a positive number." }), costPrice: z.coerce.number().min(0, { message: "Cost price (ZAR) must be 0 or greater." }),
costPriceGBP: z.coerce.number().min(0, { message: "Cost price (GBP) must be 0 or greater." }),
sellingPrice: z.coerce.number().positive({ message: "Selling price must be a positive number." }), sellingPrice: z.coerce.number().positive({ message: "Selling price must be a positive number." }),
}) })
@ -69,6 +70,7 @@ export function EditItemForm({ setOpen, refreshData, item }: EditItemFormProps)
category: item.category.toLowerCase(), // Ensure category is lowercase for combobox value category: item.category.toLowerCase(), // Ensure category is lowercase for combobox value
quantity: item.quantity, quantity: item.quantity,
costPrice: item.costPrice, costPrice: item.costPrice,
costPriceGBP: item.costPriceGBP || 0,
sellingPrice: item.sellingPrice, sellingPrice: item.sellingPrice,
}, },
}) })
@ -81,6 +83,7 @@ export function EditItemForm({ setOpen, refreshData, item }: EditItemFormProps)
category: values.category, category: values.category,
quantity: values.quantity, quantity: values.quantity,
cost_price: values.costPrice, cost_price: values.costPrice,
cost_price_gbp: values.costPriceGBP,
selling_price: values.sellingPrice, selling_price: values.sellingPrice,
}); });
showSuccess("Item updated successfully!"); showSuccess("Item updated successfully!");
@ -160,26 +163,41 @@ export function EditItemForm({ setOpen, refreshData, item }: EditItemFormProps)
</FormItem> </FormItem>
)} )}
/> />
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<FormField <FormField
control={form.control} control={form.control}
name="costPrice" name="costPriceGBP"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Cost Price</FormLabel> <FormLabel>Cost Price (£ GBP)</FormLabel>
<FormControl> <FormControl>
<Input type="number" step="0.01" placeholder="R0.00" {...field} /> <Input type="number" step="0.01" placeholder="£0.00" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="costPrice"
render={({ field }) => (
<FormItem>
<FormLabel>Cost Price (R ZAR)</FormLabel>
<FormControl>
<Input type="number" step="0.01" placeholder="R0.00" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField <FormField
control={form.control} control={form.control}
name="sellingPrice" name="sellingPrice"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Selling Price</FormLabel> <FormLabel>Selling Price (R ZAR)</FormLabel>
<FormControl> <FormControl>
<Input type="number" step="0.01" placeholder="R0.00" {...field} /> <Input type="number" step="0.01" placeholder="R0.00" {...field} />
</FormControl> </FormControl>

View file

@ -29,6 +29,7 @@ export type StockItem = {
category: string category: string
quantity: number quantity: number
costPrice: number costPrice: number
costPriceGBP: number
sellingPrice: number sellingPrice: number
} }

View file

@ -26,7 +26,7 @@ const PurchaseOrders = () => {
try { try {
const records = await pb.collection('purchase_orders').getList(1, 50, { const records = await pb.collection('purchase_orders').getList(1, 50, {
sort: '-orderDate', sort: '-orderDate',
expand: 'items', // Ensure items are expanded for PDF generation expand: 'items,supplierName', // Expand both items and supplier for PDF generation
}); });
const formattedData = records.items.map((record) => ({ const formattedData = records.items.map((record) => ({
@ -35,14 +35,25 @@ const PurchaseOrders = () => {
orderDate: record.orderDate, orderDate: record.orderDate,
status: record.status, status: record.status,
itemCount: record.items?.length || 0, itemCount: record.items?.length || 0,
orderNumber: record.order_number || record.id, // Use order_number field
accountRef: record.account_ref || '',
salesOrderNo: record.sales_order_no || '',
carriageCharge: record.carriage_charge || 0,
items: (record.expand?.items || []).map((item: any) => ({ items: (record.expand?.items || []).map((item: any) => ({
stockItemId: item.id, stockItemId: item.id,
itemName: item.name, itemName: item.name,
quantity: item.quantity, // This is the quantity in stock_items, not the ordered quantity quantity: item.quantity, // This is the quantity in stock_items, not the ordered quantity
sku: item.sku, sku: item.sku,
costPrice: item.cost_price, // Include costPrice from expanded item costPrice: item.cost_price, // ZAR price
costPriceGBP: item.cost_price_gbp || 0, // GBP price
})), })),
quantities: record.quantities || [], // This array holds the ordered quantities quantities: record.quantities || [], // This array holds the ordered quantities
supplier: record.expand?.supplierName ? {
name: record.expand.supplierName.supplier_name || '',
address: record.expand.supplierName.suppier_address || '', // Note: typo in DB field name "suppier_address"
email: record.expand.supplierName.supplier_email || '',
phone: record.expand.supplierName.supplier_phone_number || '',
} : null,
})); }));
setPurchaseOrders(formattedData); setPurchaseOrders(formattedData);

View file

@ -32,6 +32,7 @@ const Stock = () => {
category: item.category, category: item.category,
quantity: item.quantity, quantity: item.quantity,
costPrice: item.cost_price, costPrice: item.cost_price,
costPriceGBP: item.cost_price_gbp || 0,
sellingPrice: item.selling_price, sellingPrice: item.selling_price,
})); }));
setStockItems(formattedData); setStockItems(formattedData);