diff --git a/pocketbase/pb_migrations/1760602673_add_snapshot_items_to_purchase_orders.js b/pocketbase/pb_migrations/1760602673_add_snapshot_items_to_purchase_orders.js new file mode 100644 index 0000000..c4a00f6 --- /dev/null +++ b/pocketbase/pb_migrations/1760602673_add_snapshot_items_to_purchase_orders.js @@ -0,0 +1,24 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1342968361"); + + // Add snapshot_items JSON field to store item details at time of PO creation + collection.fields.addAt(4, new Field({ + "hidden": false, + "id": "", + "maxSize": 0, + "name": "snapshot_items", + "presentable": false, + "required": false, + "system": false, + "type": "json" + })); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1342968361"); + + collection.fields.removeByName("snapshot_items"); + + return app.save(collection); +}); diff --git a/src/components/book-out/BookOutForm.tsx b/src/components/book-out/BookOutForm.tsx index 1364a1f..afe933f 100644 --- a/src/components/book-out/BookOutForm.tsx +++ b/src/components/book-out/BookOutForm.tsx @@ -165,10 +165,18 @@ export function BookOutForm({ setOpen, refreshData }: BookOutFormProps) { // Create one book out record per item with its quantity for (const item of values.items) { + // Deduct stock first + const stockItem = await pb.collection('stock_items').getOne(item.stockItemId); + const newQty = (stockItem.quantity || 0) - item.quantity; + if (newQty < 0) { + throw new Error(`Insufficient stock for "${item.itemName}". Available: ${stockItem.quantity}, Requested: ${item.quantity}`); + } + await pb.collection('stock_items').update(item.stockItemId, { quantity: newQty }); + const bookOutData = { bookOutDate: values.bookOutDate.toISOString(), customer: values.customer, - items: [item.stockItemId], // single item ID in array + items: [item.stockItemId], quantity: item.quantity, date_booked_out: values.bookOutDate.toISOString(), }; @@ -188,7 +196,7 @@ export function BookOutForm({ setOpen, refreshData }: BookOutFormProps) { // Log the error await logger.logError('Failed to book out stock', error, 'admin', 'book-out'); - showError("Failed to book out stock. Please check the form and try again."); + showError(error instanceof Error ? error.message : "Failed to book out stock. Please check the form and try again."); } } diff --git a/src/components/purchase-orders/EditPurchaseOrderDialog.tsx b/src/components/purchase-orders/EditPurchaseOrderDialog.tsx index 3a0b7d1..41a10d3 100644 --- a/src/components/purchase-orders/EditPurchaseOrderDialog.tsx +++ b/src/components/purchase-orders/EditPurchaseOrderDialog.tsx @@ -22,8 +22,10 @@ import { AttachmentUpload, AttachmentList } from "./AttachmentUpload" const rowSchema = z.object({ stockItemId: z.string(), itemName: z.string(), + sku: z.string(), quantity: z.coerce.number().int().min(1), unitPriceGBP: z.coerce.number().min(0), + entryMode: z.enum(['stock', 'manual']).default('stock'), }) const formSchema = z.object({ @@ -39,19 +41,30 @@ interface EditPurchaseOrderDialogProps { export function EditPurchaseOrderDialog({ open, onOpenChange, order, refreshData }: EditPurchaseOrderDialogProps) { const [files, setFiles] = React.useState([]) + + // Use snapshot_items if available, fall back to live items + const sourceItems = order.snapshotItems && order.snapshotItems.length > 0 + ? order.snapshotItems + : order.items.map((item, idx) => ({ + ...item, + entryMode: 'stock' as const, + })) + const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { - rows: order.items.map((item, idx) => ({ + rows: sourceItems.map((item) => ({ stockItemId: item.stockItemId, itemName: item.itemName, - quantity: order.quantities[idx] ?? 1, + sku: item.sku, + quantity: item.quantity, unitPriceGBP: Number(item.costPriceGBP ?? 0), + entryMode: item.entryMode || 'stock', })), }, }) - const { fields } = useFieldArray({ + const { fields, append, remove } = useFieldArray({ control: form.control, name: "rows", }) @@ -66,22 +79,43 @@ export function EditPurchaseOrderDialog({ open, onOpenChange, order, refreshData [rowsValues] ) + const addItem = () => { + append({ + stockItemId: '', + itemName: '', + sku: '', + quantity: 1, + unitPriceGBP: 0, + entryMode: 'manual', + }) + } + const onSubmit = async (values: z.infer) => { try { + // Build updated snapshot + const updatedSnapshot = values.rows.map(r => ({ + stockItemId: r.stockItemId, + itemName: r.itemName, + sku: r.sku, + quantity: r.quantity, + costPrice: 0, + costPriceGBP: r.unitPriceGBP, + entryMode: r.entryMode, + })) + const formData = new FormData() formData.append('quantities', JSON.stringify(values.rows.map(r => r.quantity))) + formData.append('snapshot_items', JSON.stringify(updatedSnapshot)) files.forEach(f => formData.append('attachments', f)) - // If there are new attachments, set status to Ordered - if (files.length > 0) { - formData.append('status', 'Ordered') - } + await pb.collection("purchase_orders").update(order.id, formData) + // Update stock item GBP prices for stock-mode items if changed try { - await Promise.all(values.rows.map(async (r, idx) => { - const existingGBP = Number(order.items[idx].costPriceGBP ?? 0) + await Promise.all(values.rows.map(async (r) => { + if (r.entryMode !== 'stock' || !r.stockItemId) return const newGBP = Number(r.unitPriceGBP || 0) - if (Number.isFinite(newGBP) && newGBP >= 0 && newGBP !== existingGBP) { + if (Number.isFinite(newGBP) && newGBP >= 0) { await pb.collection("stock_items").update(r.stockItemId, { cost_price_gbp: newGBP }) } })) @@ -103,7 +137,7 @@ export function EditPurchaseOrderDialog({ open, onOpenChange, order, refreshData Edit Purchase Order - Order ID: {order.id} + {order.orderNumber || order.id}
@@ -112,7 +146,7 @@ export function EditPurchaseOrderDialog({ open, onOpenChange, order, refreshData {fields.map((field, index) => (
)} /> + ( + + SKU + + + + + + )} + /> )} /> +
+ +
))} +
-
Items total (GBP)
+
Items total (GBP)
£{subtotalGBP.toFixed(2)}
diff --git a/src/components/purchase-orders/PurchaseOrderForm.tsx b/src/components/purchase-orders/PurchaseOrderForm.tsx index 7df2e3e..a96b02f 100644 --- a/src/components/purchase-orders/PurchaseOrderForm.tsx +++ b/src/components/purchase-orders/PurchaseOrderForm.tsx @@ -300,6 +300,35 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro }) ); + // Build snapshot items — store full details so PO survives stock item deletion + const snapshotItems = await Promise.all( + values.items.map(async (item) => { + if (item.entryMode === 'manual') { + const existing = stockItems.find(si => si.sku === item.manualSku && item.manualSku); + const stockItemId = existing ? existing.id : ''; + return { + stockItemId, + itemName: item.manualDescription || item.manualSku || 'Unnamed Item', + sku: item.manualSku || '', + quantity: item.quantity, + costPrice: 0, + costPriceGBP: item.manualUnitPrice || 0, + entryMode: 'manual' as const, + }; + } + const stockItem = stockItems.find(si => si.id === item.stockItemId); + return { + stockItemId: item.stockItemId, + itemName: item.itemName, + sku: item.sku, + quantity: item.quantity, + costPrice: Number(item.costPrice || 0), + costPriceGBP: Number(stockItem?.costPriceGBP || 0), + entryMode: 'stock' as const, + }; + }) + ); + // Build the PO data const purchaseOrderData: Record = { order_number: values.orderNumber, @@ -311,6 +340,7 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro status: "Pending", items: resolvedItems, quantities: values.items.map(item => item.quantity), + snapshot_items: snapshotItems, }; // Use FormData for file attachments diff --git a/src/components/purchase-orders/columns.tsx b/src/components/purchase-orders/columns.tsx index b4d0de8..1f3fe3f 100644 --- a/src/components/purchase-orders/columns.tsx +++ b/src/components/purchase-orders/columns.tsx @@ -33,6 +33,7 @@ export type PurchaseOrder = { carriageCharge?: number items: Array<{ stockItemId: string; itemName: string; quantity: number; sku: string; costPrice: number; costPriceGBP: number }> quantities: number[] + snapshotItems?: Array<{ stockItemId: string; itemName: string; quantity: number; sku: string; costPrice: number; costPriceGBP: number; entryMode?: 'stock' | 'manual' }> supplier?: { name: string address: string @@ -141,12 +142,10 @@ export const createColumns = ({ onDownloadPdf, onViewOrder, onEditOrder, onManag View Order - {!!onEditOrder && ( - onEditOrder(order)}> - - Edit Order - - )} + onEditOrder?.(order)}> + + Edit Order + onDownloadPdf(order)}> Download PDF diff --git a/src/components/reports/LowStockReport.tsx b/src/components/reports/LowStockReport.tsx index b72154f..fada627 100644 --- a/src/components/reports/LowStockReport.tsx +++ b/src/components/reports/LowStockReport.tsx @@ -18,7 +18,7 @@ export function LowStockReport() { setLoading(true) try { const records = await pb.collection("stock_items").getFullList({ - filter: `quantity < ${threshold}`, + filter: `quantity < ${threshold} && stock_item = true`, sort: "quantity", }) const formatted: LowStockRecord[] = records.map((item: any) => ({ diff --git a/src/pages/PurchaseOrders.tsx b/src/pages/PurchaseOrders.tsx index 5723899..a540c81 100644 --- a/src/pages/PurchaseOrders.tsx +++ b/src/pages/PurchaseOrders.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { PurchaseOrderForm } from "@/components/purchase-orders/PurchaseOrderForm"; +import { EditPurchaseOrderDialog } from "@/components/purchase-orders/EditPurchaseOrderDialog"; import { Button } from "@/components/ui/button"; import { PlusCircle } from "lucide-react"; import { @@ -84,6 +85,8 @@ const PurchaseOrders = () => { const [selectedOrderForView, setSelectedOrderForView] = useState(null); const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false); const [selectedOrderForAttachments, setSelectedOrderForAttachments] = useState(null); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [selectedOrderForEdit, setSelectedOrderForEdit] = useState(null); const fetchPurchaseOrders = useCallback(async () => { try { @@ -92,33 +95,51 @@ const PurchaseOrders = () => { expand: 'items,supplierName', }); - const formattedData = records.items.map((record) => ({ - id: record.id, - supplierName: record.supplierName, - orderDate: record.orderDate, - status: record.status, - itemCount: record.items?.length || 0, - orderNumber: record.order_number || record.id, - 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, - sku: item.sku, - costPrice: item.cost_price, - costPriceGBP: item.cost_price_gbp || 0, - })), - quantities: record.quantities || [], - supplier: record.expand?.supplierName ? { - name: record.expand.supplierName.supplier_name || '', - address: record.expand.supplierName.suppier_address || '', - email: record.expand.supplierName.supplier_email || '', - phone: record.expand.supplierName.supplier_phone_number || '', - } : null, - attachments: record.attachments || [], - })); + const formattedData = records.items.map((record) => { + // Use snapshot_items if available, otherwise fall back to live expand + const useSnapshot = record.snapshot_items && Array.isArray(record.snapshot_items) && record.snapshot_items.length > 0; + const items = useSnapshot + ? record.snapshot_items.map((item: any) => ({ + stockItemId: item.stockItemId || '', + itemName: item.itemName || 'Unknown', + quantity: item.quantity || 0, + sku: item.sku || '', + costPrice: item.costPrice || 0, + costPriceGBP: item.costPriceGBP || 0, + })) + : (record.expand?.items || []).map((item: any) => ({ + stockItemId: item.id, + itemName: item.name, + quantity: item.quantity, + sku: item.sku, + costPrice: item.cost_price, + costPriceGBP: item.cost_price_gbp || 0, + })); + + const snapshotItems = useSnapshot ? record.snapshot_items : undefined; + + return { + id: record.id, + supplierName: record.supplierName, + orderDate: record.orderDate, + status: record.status, + itemCount: items.length, + orderNumber: record.order_number || record.id, + accountRef: record.account_ref || '', + salesOrderNo: record.sales_order_no || '', + carriageCharge: record.carriage_charge || 0, + items, + snapshotItems, + quantities: useSnapshot ? items.map((i: any) => i.quantity) : (record.quantities || []), + supplier: record.expand?.supplierName ? { + name: record.expand.supplierName.supplier_name || '', + address: record.expand.supplierName.suppier_address || '', + email: record.expand.supplierName.supplier_email || '', + phone: record.expand.supplierName.supplier_phone_number || '', + } : null, + attachments: record.attachments || [], + }; + }); setPurchaseOrders(formattedData); } catch (err) { @@ -148,7 +169,12 @@ const PurchaseOrders = () => { setAttachmentsDialogOpen(true); }, []); - const columns = createColumns({ onDownloadPdf: handleDownloadPdf, onViewOrder: handleViewOrder, onManageAttachments: handleManageAttachments }); + const handleEditOrder = useCallback((order: PurchaseOrder) => { + setSelectedOrderForEdit(order); + setEditDialogOpen(true); + }, []); + + const columns = createColumns({ onDownloadPdf: handleDownloadPdf, onViewOrder: handleViewOrder, onEditOrder: handleEditOrder, onManageAttachments: handleManageAttachments }); return (
@@ -236,6 +262,16 @@ const PurchaseOrders = () => { )} + + {/* Edit Order Dialog */} + {selectedOrderForEdit && ( + + )}
); };