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:
parent
1030dad876
commit
f7b46986c5
10 changed files with 605 additions and 168 deletions
27
pocketbase/pb_migrations/1760602672_add_cost_price_gbp.js
Normal file
27
pocketbase/pb_migrations/1760602672_add_cost_price_gbp.js
Normal 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);
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
||||
// --- Header Section ---
|
||||
doc.setFontSize(24);
|
||||
doc.setTextColor(primaryColor);
|
||||
doc.text("PURCHASE ORDER", 105, 20, { align: "center" });
|
||||
doc.setFillColor(accentYellow);
|
||||
doc.rect(0, 45, 210, 2, 'F'); // Accent line
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(textColor);
|
||||
doc.text(companyName, 14, 30); // Company Name
|
||||
doc.text(companyAddress, 14, 35);
|
||||
doc.text(companyPhone, 14, 40);
|
||||
// Company name and details
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(20);
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text("JEI SA", 40, 20);
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
// --- Supplier Details ---
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(primaryColor);
|
||||
doc.text("SUPPLIER DETAILS", 14, 65);
|
||||
doc.setDrawColor(primaryColor);
|
||||
doc.line(14, 66, 50, 66); // Underline
|
||||
// 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.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);
|
||||
// --- 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
|
||||
|
||||
// --- 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
|
||||
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);
|
||||
|
||||
y += 10; // Space after title
|
||||
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" });
|
||||
|
||||
// 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);
|
||||
// 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" });
|
||||
|
||||
y += 8; // Move past header
|
||||
// --- Supplier Details Box ---
|
||||
let y = 95;
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(11);
|
||||
doc.setTextColor(brandBlack);
|
||||
doc.text("SUPPLIER INFORMATION", 14, y);
|
||||
|
||||
doc.setTextColor(textColor);
|
||||
doc.setFontSize(9);
|
||||
let subtotal = 0;
|
||||
doc.setDrawColor(accentYellow);
|
||||
doc.setLineWidth(1.5);
|
||||
doc.line(14, y + 2, 75, y + 2);
|
||||
|
||||
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;
|
||||
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 ---
|
||||
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);
|
||||
|
||||
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.setDrawColor(primaryColor);
|
||||
doc.line(140, y, 196, y); // Line above subtotal
|
||||
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" });
|
||||
|
||||
y += 5;
|
||||
doc.setFontSize(10);
|
||||
doc.text("Subtotal:", 140, y);
|
||||
doc.text(subtotal.toLocaleString("en-ZA", { style: "currency", currency: "ZAR" }), 180, y);
|
||||
y += 10;
|
||||
|
||||
y += 7;
|
||||
doc.text("Shipping:", 140, y);
|
||||
doc.text("R0.00", 180, y); // Placeholder for shipping
|
||||
// Table rows with alternating colors and proper text wrapping
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(8);
|
||||
let subtotal = 0;
|
||||
let rowColor = true;
|
||||
|
||||
y += 7;
|
||||
doc.text("Tax:", 140, y);
|
||||
doc.text("R0.00", 180, y); // Placeholder for tax
|
||||
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("Other:", 140, y);
|
||||
doc.text("R0.00", 180, y); // Placeholder for other charges
|
||||
// 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 += 10;
|
||||
doc.setDrawColor(primaryColor);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(140, y, 196, y); // Thicker line above total
|
||||
// Alternating row background
|
||||
if (rowColor) {
|
||||
doc.setFillColor(250, 250, 250);
|
||||
doc.rect(14, y, 182, rowHeight, 'F');
|
||||
}
|
||||
rowColor = !rowColor;
|
||||
|
||||
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
|
||||
doc.setTextColor(darkText);
|
||||
doc.text(String(index + 1), 17, y + 5);
|
||||
|
||||
doc.save(`purchase_order_${order.id}.pdf`);
|
||||
// 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;
|
||||
});
|
||||
|
||||
// --- 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 += 6;
|
||||
doc.setTextColor(mediumGray);
|
||||
doc.text("Carriage Charge:", 135, y);
|
||||
doc.setTextColor(darkText);
|
||||
doc.text(`£${carriageCharge.toFixed(2)}`, 188, y, { align: "right" });
|
||||
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
108
src/components/purchase-orders/StockItemSearch.tsx
Normal file
108
src/components/purchase-orders/StockItemSearch.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export type StockItem = {
|
|||
category: string
|
||||
quantity: number
|
||||
costPrice: number
|
||||
costPriceGBP: number
|
||||
sellingPrice: number
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue