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 doc = new jsPDF();
try {
console.log("Generating PDF for order:", order);
const doc = new jsPDF();
// Set font and colors
doc.setFont("helvetica");
const primaryColor = "#ffc905"; // Your primary color
const textColor = "#333333";
// Modern color scheme - Changed to black
const brandBlack = "#000000"; // Changed from purple to black
const accentYellow = "#ffc905"; // Accent color
const darkText = "#1a1a1a";
const lightGray = "#f5f5f5";
const mediumGray = "#666666";
// --- Company Details ---
const companyName = "JEI SA";
const companyAddress = "47 Station St, Oberholzer, Carletonville, 2499";
const companyPhone = "018 786 2897";
// Create branded logo box
doc.setFillColor(brandBlack);
doc.roundedRect(14, 10, 20, 20, 3, 3, 'F');
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');
doc.setFillColor(accentYellow);
doc.rect(0, 45, 210, 2, 'F'); // Accent line
// --- Header Section ---
doc.setFontSize(24);
doc.setTextColor(primaryColor);
doc.text("PURCHASE ORDER", 105, 20, { align: "center" });
// 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);
doc.setFontSize(10);
doc.setTextColor(textColor);
doc.text(companyName, 14, 30); // Company Name
doc.text(companyAddress, 14, 35);
doc.text(companyPhone, 14, 40);
// Purchase Order title - positioned to the right
doc.setFont("helvetica", "bold");
doc.setFontSize(24);
doc.setTextColor(255, 255, 255);
doc.text("PURCHASE ORDER", 120, 25);
doc.setFontSize(10);
doc.text(`PO Number: ${order.id}`, 150, 30);
doc.text(`Date: ${new Date(order.orderDate).toLocaleDateString()}`, 150, 35);
doc.text(`Status: ${order.status}`, 150, 40);
// --- 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 ---
doc.setFontSize(12);
doc.setTextColor(primaryColor);
doc.text("SUPPLIER DETAILS", 14, 65);
doc.setDrawColor(primaryColor);
doc.line(14, 66, 50, 66); // Underline
// --- 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);
doc.setFontSize(10);
doc.setTextColor(textColor);
doc.text(`Supplier Name: ${order.supplierName}`, 14, 75);
// Add more supplier details if available in the future
doc.text("Address: Supplier Address Line 1", 14, 80);
doc.text("City, Postal Code", 14, 85);
doc.text("Contact: Supplier Contact Person", 14, 90);
doc.text("Email: supplier@example.com", 14, 95);
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.setTextColor(darkText);
// Use actual supplier data from expanded relation
const supplierName = order.supplier?.name || "N/A";
doc.text(supplierName, 18, y);
y += 6;
doc.setFont("helvetica", "normal");
doc.setFontSize(8);
doc.setTextColor(mediumGray);
if (order.supplier?.address) {
// Wrap long address text
const addressLines = doc.splitTextToSize(order.supplier.address, 160);
doc.text(addressLines, 18, y);
y += (addressLines.length * 4) + 2;
}
if (order.supplier?.email) {
doc.text(`Email: ${order.supplier.email}`, 18, y);
y += 5;
}
if (order.supplier?.phone) {
doc.text(`Tel: ${order.supplier.phone}`, 18, y);
}
// --- Items Table ---
let y = 110;
doc.setFontSize(12);
doc.setTextColor(primaryColor);
doc.text("ORDER ITEMS", 14, y);
doc.setDrawColor(primaryColor);
doc.line(14, y + 1, 45, y + 1); // Underline
// --- Items Table ---
y += 12;
doc.setFont("helvetica", "bold");
doc.setFontSize(11);
doc.setTextColor(brandBlack);
doc.text("ORDER ITEMS", 14, y);
doc.setDrawColor(accentYellow);
doc.setLineWidth(1.5);
doc.line(14, y + 2, 50, y + 2);
y += 10; // Space after title
// Table Headers
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
doc.setTextColor(textColor);
doc.setFontSize(9);
let subtotal = 0;
order.items.forEach((item, index) => {
const orderedQuantity = order.quantities[index] || 0;
const unitPrice = item.costPrice || 0; // Use costPrice from the item
const itemTotal = orderedQuantity * unitPrice;
subtotal += itemTotal;
doc.text(String(index + 1), 16, y + 5);
doc.text(`${item.itemName} (${item.sku})`, 40, y + 5);
doc.text(String(orderedQuantity), 130, y + 5);
doc.text(unitPrice.toLocaleString("en-ZA", { style: "currency", currency: "ZAR" }), 150, y + 5);
doc.text(itemTotal.toLocaleString("en-ZA", { style: "currency", currency: "ZAR" }), 180, y + 5);
y += 8;
});
// --- Totals Section ---
y += 10; // Space before totals
// Modern table header
doc.setFillColor(brandBlack);
doc.roundedRect(14, y, 182, 10, 1, 1, 'F');
doc.setFont("helvetica", "bold");
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" });
doc.setDrawColor(primaryColor);
doc.line(140, y, 196, y); // Line above subtotal
y += 10;
// Table rows with alternating colors and proper text wrapping
doc.setFont("helvetica", "normal");
doc.setFontSize(8);
let subtotal = 0;
let rowColor = true;
y += 5;
doc.setFontSize(10);
doc.text("Subtotal:", 140, y);
doc.text(subtotal.toLocaleString("en-ZA", { style: "currency", currency: "ZAR" }), 180, y);
order.items.forEach((item, index) => {
const orderedQuantity = order.quantities[index] || 0;
const unitPriceGBP = item.costPriceGBP || 0; // Use GBP price
const itemTotal = orderedQuantity * unitPriceGBP;
subtotal += itemTotal;
y += 7;
doc.text("Shipping:", 140, y);
doc.text("R0.00", 180, y); // Placeholder for shipping
// Calculate row height based on description length
const maxDescWidth = 65;
const wrappedDesc = doc.splitTextToSize(item.itemName, maxDescWidth);
const rowHeight = Math.max(10, wrappedDesc.length * 5 + 5); // Dynamic height
y += 7;
doc.text("Tax:", 140, y);
doc.text("R0.00", 180, y); // Placeholder for tax
// Alternating row background
if (rowColor) {
doc.setFillColor(250, 250, 250);
doc.rect(14, y, 182, rowHeight, 'F');
}
rowColor = !rowColor;
y += 7;
doc.text("Other:", 140, y);
doc.text("R0.00", 180, y); // Placeholder for other charges
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;
});
y += 10;
doc.setDrawColor(primaryColor);
doc.setLineWidth(0.5);
doc.line(140, y, 196, y); // Thicker line above total
// --- Modern Totals Section ---
y += 5;
const carriageCharge = order.carriageCharge || 0;
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;
doc.setFont("helvetica", "normal");
doc.setFontSize(9);
doc.setTextColor(mediumGray);
doc.text("Subtotal:", 135, y);
doc.setTextColor(darkText);
doc.text(`£${subtotal.toFixed(2)}`, 188, y, { align: "right" });
y += 5;
doc.setFontSize(14);
doc.setTextColor(primaryColor);
doc.text("TOTAL:", 140, y);
doc.text(subtotal.toLocaleString("en-ZA", { style: "currency", currency: "ZAR" }), 180, y); // Grand Total
y += 6;
doc.setTextColor(mediumGray);
doc.text("Carriage Charge:", 135, y);
doc.setTextColor(darkText);
doc.text(`£${carriageCharge.toFixed(2)}`, 188, y, { align: "right" });
doc.save(`purchase_order_${order.id}.pdf`);
y += 6;
doc.setTextColor(mediumGray);
doc.text("Tax (VAT):", 135, y);
doc.setTextColor(darkText);
doc.text("£0.00", 188, y, { align: "right" });
y += 8;
// Grand total highlight
doc.setFillColor(brandBlack);
doc.roundedRect(130, y - 3, 66, 10, 2, 2, 'F');
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) => {

View file

@ -25,6 +25,7 @@ import {
} 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({
@ -40,7 +41,11 @@ 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." }),
})
@ -62,11 +67,43 @@ type StockItem = {
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 () => {
@ -95,7 +132,8 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
id: item.id,
name: item.name,
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);
} catch (error) {
@ -107,12 +145,17 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
useEffect(() => {
fetchSuppliers();
fetchStockItems();
}, [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: "",
@ -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({
control: form.control,
name: "items",
@ -171,7 +221,8 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
handleItemChange(index, "stockItemId", selectedStockItem.id);
handleItemChange(index, "itemName", selectedStockItem.name);
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
if (form.getValues("items")[index].quantity === 0) {
handleItemChange(index, "quantity", 1);
@ -190,28 +241,68 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
// Prepare data for PocketBase
const purchaseOrderData = {
supplierName: values.supplierName, // This will be the supplier's name for display
supplier: selectedSupplier.id, // This will be the relation to the suppliers collection
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 Approval",
status: "Pending", // Must match one of: Pending, Ordered, Received
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);
showSuccess("Purchase order created successfully!");
showSuccess(`Purchase order ${values.orderNumber} created successfully!`);
form.reset();
// Generate new order number for next use
await generateOrderNumber();
setOpen(false);
refreshData();
} catch (error) {
} catch (error: any) {
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 (
<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"
@ -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 */}
<div className="border p-4 rounded-md">
<h3 className="text-lg font-semibold mb-4">Order Items</h3>
@ -269,23 +390,12 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
render={({ field }) => (
<FormItem>
<FormLabel>Item Name</FormLabel>
<Select
onValueChange={(selectedValue) => handleStockItemSelect(index, selectedValue)}
value={field.value} // Set the current value of the item name
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select item" />
</SelectTrigger>
</FormControl>
<SelectContent>
{stockItems.map((stockItem) => (
<SelectItem key={stockItem.id} value={stockItem.name}>
{stockItem.name}
</SelectItem>
))}
</SelectContent>
</Select>
<StockItemSearch
stockItems={stockItems}
selectedItemName={field.value}
onSelectItem={(selectedItemName) => handleStockItemSelect(index, selectedItemName)}
placeholder="Search by SKU or name..."
/>
<FormMessage />
</FormItem>
)}
@ -322,9 +432,9 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
name={`items.${index}.costPrice`}
render={({ field }) => (
<FormItem>
<FormLabel>Unit Price (ZAR)</FormLabel>
<FormLabel>Unit Price (£ GBP)</FormLabel>
<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>
<FormMessage />
</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
status: string
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[]
supplier?: {
name: string
address: string
email: string
phone: string
} | null
}
// 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." }),
sku: z.string().min(2, { message: "SKU must be at least 2 characters." }),
category: z.string({ required_error: "Please select or create a category." }),
quantity: z.coerce.number().int().positive({ message: "Quantity must be a positive number." }),
costPrice: z.coerce.number().positive({ message: "Cost price must be a positive number." }),
quantity: z.coerce.number().int().min(0, { message: "Quantity must be 0 or greater." }),
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." }),
})
@ -64,8 +65,9 @@ export function AddItemForm({ setOpen, refreshData }: AddItemFormProps) {
defaultValues: {
name: "",
sku: "",
quantity: 1,
quantity: 0,
costPrice: 0.00,
costPriceGBP: 0.00,
sellingPrice: 0.00,
},
})
@ -78,6 +80,7 @@ export function AddItemForm({ setOpen, refreshData }: AddItemFormProps) {
category: values.category,
quantity: values.quantity,
cost_price: values.costPrice,
cost_price_gbp: values.costPriceGBP,
selling_price: values.sellingPrice,
});
@ -166,26 +169,41 @@ export function AddItemForm({ setOpen, refreshData }: AddItemFormProps) {
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="costPrice"
render={({ field }) => (
<FormItem>
<FormLabel>Cost Price</FormLabel>
<FormControl>
<Input type="number" step="0.01" placeholder="R0.00" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="costPriceGBP"
render={({ field }) => (
<FormItem>
<FormLabel>Cost Price (£ GBP)</FormLabel>
<FormControl>
<Input type="number" step="0.01" placeholder="£0.00" {...field} />
</FormControl>
<FormMessage />
</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
control={form.control}
name="sellingPrice"
render={({ field }) => (
<FormItem>
<FormLabel>Selling Price</FormLabel>
<FormLabel>Selling Price (R ZAR)</FormLabel>
<FormControl>
<Input type="number" step="0.01" placeholder="R0.00" {...field} />
</FormControl>

View file

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

View file

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

View file

@ -26,7 +26,7 @@ const PurchaseOrders = () => {
try {
const records = await pb.collection('purchase_orders').getList(1, 50, {
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) => ({
@ -35,14 +35,25 @@ const PurchaseOrders = () => {
orderDate: record.orderDate,
status: record.status,
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) => ({
stockItemId: item.id,
itemName: item.name,
quantity: item.quantity, // This is the quantity in stock_items, not the ordered quantity
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
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);

View file

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