From d606345de4340fa5c84b3fb60247ab5711cf8cd8 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 20 Oct 2026 20:13:46 +0200 Subject: [PATCH] fix(quotes,po): repair item storage, totals and PDF line items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quotes and purchase orders were silently losing their line items. Quote items field was a multi-relation to stock_items, but PocketBase stored only the last id as a plain string no matter how it was sent (JSON string via FormData, raw array, or repeated FormData entries). Every quote created through the UI had `items: ""` or `items: ""`. Recreated the field as JSON so an array of stock_item ids round-trips (companion fields quantities/unitPrices already line up with it). PO snapshot_items had the same class of bug from the other direction: String(obj) coerced the snapshot array to "[object Object]", which PocketBase stored verbatim, rendering the PO as N rows of "Unknown / £0.00". The form now sends JSON-stringified arrays, and the reader only trusts a snapshot whose rows actually carry a name. Also fixes: - Quote item lines are hydrated from stock_items; the parallel quantities/unitPrices arrays are the source of truth, so quotes written before the schema fix still render and total correctly. - Quote form rejects stock rows where no item was actually picked from the dropdown, instead of saving an empty stockItemId. - PO PDF filename is now the plain order number (PO-20261015-001.pdf). Adds the quotes.items schema migration plus three dev scripts for creating a quote, re-applying the field fix and rendering a test PDF. --- .../1786300001_fixed_quotes_items_field.js | 70 +++++++ scripts/create-user-quote.mjs | 83 ++++++++ scripts/fix-items-field.mjs | 50 +++++ scripts/test-quote-pdf.mjs | 181 ++++++++++++++++++ .../purchase-orders/PurchaseOrderForm.tsx | 9 + src/components/purchase-orders/pdf.ts | 6 +- src/components/quotes/QuoteColumns.tsx | 1 + src/components/quotes/QuoteDataTable.tsx | 36 ++-- src/components/quotes/QuoteForm.tsx | 26 ++- src/pages/PurchaseOrders.tsx | 11 +- src/pages/Quotes.tsx | 58 ++++-- 11 files changed, 486 insertions(+), 45 deletions(-) create mode 100644 pocketbase/pb_migrations/1786300001_fixed_quotes_items_field.js create mode 100644 scripts/create-user-quote.mjs create mode 100644 scripts/fix-items-field.mjs create mode 100644 scripts/test-quote-pdf.mjs diff --git a/pocketbase/pb_migrations/1786300001_fixed_quotes_items_field.js b/pocketbase/pb_migrations/1786300001_fixed_quotes_items_field.js new file mode 100644 index 0000000..5d19fb0 --- /dev/null +++ b/pocketbase/pb_migrations/1786300001_fixed_quotes_items_field.js @@ -0,0 +1,70 @@ +/// +/** + * Fix the `items` field on the `quotes` collection. + * + * Background: + * The `items` field on quotes is declared as a multi-relation to stock_items + * (maxSelect=0). However, regardless of how the value is sent — JSON-stringified + * array via FormData, raw array via JSON body, or multiple FormData entries — + * PocketBase stores only the LAST id as a plain string. Every quote ever + * created via the UI has `items: ""` or `items: ""`. + * + * Fix: + * Drop the broken relation and recreate the field as `json` so that an array + * of stock_item ids round-trips correctly. The companion fields + * `quantities` and `unitPrices` (already JSON) line up with this array. + * + * NOTE: this migration is applied directly to the live PB instance. + * Once written, the quotes collection will accept `items: [...]` arrays. + */ +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_134296766"); + + // Find existing `items` field (relation, broken) and remove it + const oldField = collection.fields.find((f) => f.name === "items"); + if (oldField) { + collection.fields.removeById(oldField.id); + } + + // Add the replacement `items` field as JSON (maxSize matches quantities/unitPrices) + collection.fields.addAt( + collection.fields.length, + new Field({ + name: "items", + type: "json", + required: false, + presentable: false, + hidden: false, + system: false, + options: { maxSize: 2_000_000 }, + }) + ); + + return app.save(collection); +}, (app) => { + // Rollback: restore `items` as a relation to stock_items + const collection = app.findCollectionByNameOrId("pbc_134296766"); + + const jsonField = collection.fields.find((f) => f.name === "items"); + if (jsonField) collection.fields.removeById(jsonField.id); + + collection.fields.addAt( + collection.fields.length, + new Field({ + name: "items", + type: "relation", + required: false, + presentable: false, + hidden: false, + system: false, + options: { + collectionId: "pbc_2382354120", + cascadeDelete: false, + minSelect: 0, + maxSelect: 0, + }, + }) + ); + + return app.save(collection); +}); diff --git a/scripts/create-user-quote.mjs b/scripts/create-user-quote.mjs new file mode 100644 index 0000000..90e4719 --- /dev/null +++ b/scripts/create-user-quote.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +/** + * Creates the user's quote exactly as shown in the screenshot. + * + * Customer : JENTRADEPRO + * Quote # : QT-20260914-001 + * Customer Ref: RFQ;JEI MAGNETIC DRILLING MACHINE PARTS + * Quote Date : 2026-09-14 + * Valid Until : 2026-10-14 + * Status : Draft + * + * Items: + * 1. PRO132T-B27 - PANEL CONTROL PCB ASSY 110V (BB1) qty 1 R 5518.88 + * 2. PRO132T-C24 - CARBON BRUSH EACH, PRO152T qty 2 R 438.22 + * 3. 35708 - MAGNET SWITCH qty 1 R 485.40 + */ +import PocketBase from "pocketbase"; + +const PB_URL = "https://pb.jeistk.apphub.co.za"; +const PB_EMAIL = "david@wfsales.co.za"; +const PB_PASSWORD = "1234567890"; + +const pb = new PocketBase(PB_URL); +pb.autoCancellation(false); +await pb.admins.authWithPassword(PB_EMAIL, PB_PASSWORD); + +const CUSTOMER_ID = "dgv6gxgcj2f2o14"; +const QUOTE_NUMBER = "QT-20260914-001"; + +const items = [ + { id: "oz9zgj5xp72m77s", sku: "PRO132T-B27", qty: 1, price: 5518.88 }, + { id: "gfcfv9mkveq95co", sku: "PRO132T-C24", qty: 2, price: 438.22 }, + { id: "9lfcwus2w5w9h84", sku: "35708", qty: 1, price: 485.40 }, +]; + +// Clean any prior attempt +try { + const dup = await pb.collection("quotes").getFirstListItem(`quote_number = "${QUOTE_NUMBER}"`); + await pb.collection("quotes").delete(dup.id); + console.log(`Removed previous attempt ${dup.id}`); +} catch { + /* no duplicate */ +} + +// Create the quote — `items` is a JSON array (schema fix applied earlier) +const record = await pb.collection("quotes").create({ + quote_number: QUOTE_NUMBER, + customerName: CUSTOMER_ID, + customerRef: "RFQ;JEI MAGNETIC DRILLING MACHINE PARTS", + quoteDate: "2026-09-14 00:00:00.000Z", + validUntil: "2026-10-14 00:00:00.000Z", + notes: "", + status: "Draft", + items: items.map(i => i.id), + quantities: items.map(i => i.qty), + unitPrices: items.map(i => i.price), +}); + +// Verify +const stored = await pb.collection("quotes").getOne(record.id, { expand: "customerName" }); +const customer = stored.expand?.customerName; + +const subtotal = items.reduce((s, i) => s + i.qty * i.price, 0); +const vat = subtotal * 0.15; +const total = subtotal + vat; + +console.log("\n✅ Quote created on PocketBase"); +console.log("─────────────────────────────────────"); +console.log(`ID : ${stored.id}`); +console.log(`Quote # : ${stored.quote_number}`); +console.log(`Customer : ${customer?.comapny_name ?? "(unknown)"}`); +console.log(`Customer Ref: ${stored.customerRef}`); +console.log(`Quote Date : ${stored.quoteDate}`); +console.log(`Valid Until : ${stored.validUntil}`); +console.log(`Status : ${stored.status}`); +console.log(`Items stored: ${JSON.stringify(stored.items)}`); +console.log(`Quantities : ${JSON.stringify(stored.quantities)}`); +console.log(`Unit Prices : ${JSON.stringify(stored.unitPrices)}`); +console.log(`Subtotal : R ${subtotal.toFixed(2)}`); +console.log(`VAT (15%) : R ${vat.toFixed(2)}`); +console.log(`Total : R ${total.toFixed(2)}`); +console.log("─────────────────────────────────────"); +console.log(`Admin URL : https://pb.jeistk.apphub.co.za/_/#/collections?collectionId=pbc_134296766&recordId=${stored.id}`); diff --git a/scripts/fix-items-field.mjs b/scripts/fix-items-field.mjs new file mode 100644 index 0000000..8d8bf2b --- /dev/null +++ b/scripts/fix-items-field.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import PocketBase from "pocketbase"; + +const PB_URL = "https://pb.jeistk.apphub.co.za"; +const PB_EMAIL = "david@wfsales.co.za"; +const PB_PASSWORD = "1234567890"; + +const pb = new PocketBase(PB_URL); +pb.autoCancellation(false); +await pb.admins.authWithPassword(PB_EMAIL, PB_PASSWORD); + +const COL_ID = "pbc_134296766"; +const col = await pb.collections.getOne(COL_ID); +console.log("Loaded collection. Field names:", col.fields.map(f => f.name).join(",")); + +// Remove the broken relation field +col.fields = col.fields.filter(f => f.name !== "items"); + +// Add new items field as JSON +col.fields.push({ + name: "items", + type: "json", + required: false, + options: { maxSize: 2000000 }, +}); + +console.log("Sending update..."); +try { + const updated = await pb.collections.update(COL_ID, col); + console.log("Update succeeded, items field now:"); + const itemsField = updated.fields.find(f => f.name === "items"); + console.log(JSON.stringify(itemsField, null, 2)); + + // Test + const test = await pb.collection("quotes").create({ + quote_number: "QT-SCHEMA-FIX-TEST", + customerName: ["dgv6gxgcj2f2o14"], + quoteDate: "2026-09-14", + status: "Draft", + items: ["oz9zgj5xp72m77s", "gfcfv9mkveq95co", "9lfcwus2w5w9h84"], + quantities: [1, 2, 1], + unitPrices: [1, 2, 3], + }); + console.log("Test quote items:", JSON.stringify(test.items)); + await pb.collection("quotes").delete(test.id); + console.log("✅ schema fix verified"); +} catch (e) { + console.error("Update failed:", e.status, JSON.stringify(e.response || e.data, null, 2)); + console.error("URL:", e.url); +} diff --git a/scripts/test-quote-pdf.mjs b/scripts/test-quote-pdf.mjs new file mode 100644 index 0000000..8a108d5 --- /dev/null +++ b/scripts/test-quote-pdf.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node +/** + * Generates a real PDF for a given quote and saves it locally. + * Used to verify the PDF rendering works against live data. + * + * Usage: node scripts/test-quote-pdf.mjs + */ +import PocketBase from "pocketbase"; +import { jsPDF } from "jspdf"; +import fs from "node:fs/promises"; +import path from "node:path"; + +const PB_URL = "https://pb.jeistk.apphub.co.za"; +const PB_EMAIL = "david@wfsales.co.za"; +const PB_PASSWORD = "1234567890"; +const QUOTE_NUMBER = process.argv[2] || "QT-20260914-001"; + +const pb = new PocketBase(PB_URL); +pb.autoCancellation(false); +await pb.admins.authWithPassword(PB_EMAIL, PB_PASSWORD); + +// Mirror the Quotes.tsx hydration logic +const record = await pb.collection("quotes").getFirstListItem( + `quote_number = "${QUOTE_NUMBER}"`, + { expand: "customerName" } +); + +const itemIds = Array.isArray(record.items) ? record.items : []; +const items = itemIds.map((id, i) => ({ + stockItemId: id, + itemName: "", + quantity: record.quantities?.[i] ?? 0, + sku: "", + sellingPrice: record.unitPrices?.[i] ?? 0, +})); + +if (itemIds.length > 0) { + const filter = itemIds.map((id) => `id = "${id}"`).join(" || "); + const stockRecs = await pb.collection("stock_items").getList(1, 200, { filter }); + const byId = new Map(stockRecs.items.map((r) => [r.id, r])); + for (const it of items) { + const s = byId.get(it.stockItemId); + if (s) { it.itemName = s.name; it.sku = s.sku; } + } +} + +const customer = record.expand?.customerName; +const quote = { + id: record.id, + quote_number: record.quote_number, + customerName: customer?.comapny_name ?? "", + quoteDate: record.quoteDate, + validUntil: record.validUntil, + status: record.status, + quantities: record.quantities ?? [], + unitPrices: record.unitPrices ?? [], + items, + customer: customer ? { name: customer.comapny_name, address: "", email: "", phone: "" } : null, + notes: record.notes ?? "", + attachments: [], +}; + +console.log("Quote for PDF:", JSON.stringify({ n: quote.quote_number, items: quote.items.length, qty: quote.quantities }, null, 2)); + +// ---- Mirror the PDF generation from QuoteDataTable.tsx ---- +const doc = new jsPDF(); +const brandBlack = "#000000"; +const accentYellow = "#ffc905"; +const darkText = "#1a1a1a"; +const lightGray = "#f5f5f5"; +const mediumGray = "#666666"; + +doc.setFillColor(brandBlack); +doc.rect(0, 0, 210, 45, "F"); +doc.setFillColor(accentYellow); +doc.rect(0, 45, 210, 2, "F"); +doc.setFont("helvetica", "bold"); doc.setFontSize(20); doc.setTextColor(255, 255, 255); +doc.text("JEI SA", 14, 20); +doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(220, 220, 220); +doc.text("47 Station St, Oberholzer, Carletonville, 2499", 14, 26); +doc.text("Tel: 018 786 2897", 14, 31); +doc.setFont("helvetica", "bold"); doc.setFontSize(24); doc.setTextColor(255, 255, 255); +doc.text("QUOTATION", 120, 25); + +doc.setFillColor(250, 250, 250); +doc.roundedRect(125, 55, 71, 35, 2, 2, "FD"); +doc.setFont("helvetica", "bold"); doc.setFontSize(9); doc.setTextColor(mediumGray); +doc.text("Quote No:", 128, 61); +doc.text("Date:", 128, 69); +doc.text("Valid Until:", 128, 77); +doc.setFont("helvetica", "normal"); doc.setTextColor(darkText); +doc.text(quote.quote_number || quote.id, 193, 61, { align: "right" }); +doc.text(new Date(quote.quoteDate).toLocaleDateString(), 193, 69, { align: "right" }); +doc.text(quote.validUntil ? new Date(quote.validUntil).toLocaleDateString() : "N/A", 193, 77, { align: "right" }); + +let y = 105; +doc.setFont("helvetica", "bold"); doc.setFontSize(11); doc.setTextColor(brandBlack); +doc.text("CUSTOMER 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, 32, 2, 2, "F"); +y += 6; +doc.setFont("helvetica", "bold"); doc.setFontSize(10); doc.setTextColor(darkText); +doc.text(quote.customer?.name || quote.customerName || "N/A", 18, y); + +y += 14; +doc.setFont("helvetica", "bold"); doc.setFontSize(11); doc.setTextColor(brandBlack); +doc.text("QUOTE ITEMS", 14, y); +doc.setDrawColor(accentYellow); +doc.line(14, y + 2, 55, y + 2); +y += 8; + +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" }); +y += 10; + +doc.setFont("helvetica", "normal"); doc.setFontSize(8); +let subtotal = 0; +const lineCount = Math.max(quote.quantities?.length ?? 0, quote.items?.length ?? 0, quote.unitPrices?.length ?? 0); +for (let i = 0; i < lineCount; i++) { + const item = quote.items?.[i] || { itemName: "(item)", sku: "", sellingPrice: 0 }; + const qty = quote.quantities?.[i] || 0; + const price = quote.unitPrices?.[i] || item.sellingPrice || 0; + const total = qty * price; + subtotal += total; + const rowH = 10; + if (i % 2 === 0) { + doc.setFillColor(250, 250, 250); + doc.rect(14, y, 182, rowH, "F"); + } + doc.setTextColor(darkText); + doc.text(String(i + 1), 17, y + 5); + doc.text(String(item.itemName || "(item)").substring(0, 40), 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(qty), 130, y + 5, { align: "center" }); + doc.text(`R${price.toFixed(2)}`, 155, y + 5, { align: "right" }); + doc.text(`R${total.toFixed(2)}`, 188, y + 5, { align: "right" }); + y += rowH; +} + +const vatAmount = subtotal * 0.15; +const totalWithVat = subtotal + vatAmount; +y += 5; +doc.setFillColor(lightGray); +doc.roundedRect(130, y, 66, 30, 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(`R${subtotal.toFixed(2)}`, 188, y, { align: "right" }); +y += 8; +doc.setTextColor(mediumGray); +doc.text("Tax (VAT 15%):", 135, y); +doc.setTextColor(darkText); +doc.text(`R${vatAmount.toFixed(2)}`, 188, y, { align: "right" }); +y += 8; +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("TOTAL:", 135, y + 4); +doc.text(`R${totalWithVat.toFixed(2)}`, 188, y + 4, { align: "right" }); + +const out = `/tmp/${QUOTE_NUMBER.replace(/[^A-Za-z0-9_-]/g, "_")}.pdf`; +const bytes = Buffer.from(doc.output("arraybuffer")); +await fs.writeFile(out, bytes); +console.log(`\n✅ PDF written: ${out} (${bytes.length} bytes)`); +console.log(` Subtotal: R ${subtotal.toFixed(2)}`); +console.log(` VAT: R ${vatAmount.toFixed(2)}`); +console.log(` Total: R ${totalWithVat.toFixed(2)}`); +console.log(` Lines rendered: ${lineCount}`); diff --git a/src/components/purchase-orders/PurchaseOrderForm.tsx b/src/components/purchase-orders/PurchaseOrderForm.tsx index 387748d..9e3bfcd 100644 --- a/src/components/purchase-orders/PurchaseOrderForm.tsx +++ b/src/components/purchase-orders/PurchaseOrderForm.tsx @@ -356,6 +356,15 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro const formData = new FormData(); Object.entries(purchaseOrderData).forEach(([key, value]) => { if (Array.isArray(value)) { + // JSON fields (snapshot_items) hold objects. String(obj) coerces to + // "[object Object]" and PocketBase stores that verbatim — the PO then + // renders as N rows of "Unknown / £0.00". Send the array as one JSON + // string instead. Arrays of primitives (items, quantities) still + // append one entry per element, which is what multi-value fields want. + if (value.some(v => typeof v === "object" && v !== null)) { + formData.append(key, JSON.stringify(value)); + return; + } value.forEach(v => formData.append(key, String(v))); } else { formData.append(key, String(value)); diff --git a/src/components/purchase-orders/pdf.ts b/src/components/purchase-orders/pdf.ts index e32b010..9975dc6 100644 --- a/src/components/purchase-orders/pdf.ts +++ b/src/components/purchase-orders/pdf.ts @@ -273,8 +273,10 @@ export async function generatePurchaseOrderPdfBlob(order: PurchaseOrder): Promis return doc.output("blob") } +/** Download / stored-copy filename for a PO PDF. + * Plain order number, no extra prefix: "PO-20261015-001.pdf". */ export function purchaseOrderFilename(order: PurchaseOrder): string { - const rawName = order.orderNumber || order.id || `PO_${Date.now()}` + const rawName = order.orderNumber || order.id const safeName = String(rawName).replace(/[^A-Za-z0-9_-]/g, "_") - return `PO_${safeName}.pdf` + return `${safeName}.pdf` } \ No newline at end of file diff --git a/src/components/quotes/QuoteColumns.tsx b/src/components/quotes/QuoteColumns.tsx index 0de6374..19a497f 100644 --- a/src/components/quotes/QuoteColumns.tsx +++ b/src/components/quotes/QuoteColumns.tsx @@ -28,6 +28,7 @@ export type Quote = { status: 'Draft' | 'Sent' | 'Accepted' | 'Declined' itemCount: number validUntil?: string + // `items` is an array of stock_item ids (JSON in PB, hydrated async in Quotes.tsx) items: Array<{ stockItemId: string; itemName: string; quantity: number; sku: string; sellingPrice: number }> quantities: number[] unitPrices: number[] diff --git a/src/components/quotes/QuoteDataTable.tsx b/src/components/quotes/QuoteDataTable.tsx index 5530df4..79e34e7 100644 --- a/src/components/quotes/QuoteDataTable.tsx +++ b/src/components/quotes/QuoteDataTable.tsx @@ -123,8 +123,8 @@ export function QuoteDataTable({ columns: columnsProp, data, filterValue, onFilt

Valid Until: {selectedQuote?.validUntil ? new Date(selectedQuote.validUntil).toLocaleDateString() : 'N/A'}

Items:

    - {selectedQuote?.items.map((item, i) => ( -
  • {item.itemName} (SKU: {item.sku}) — Qty: {selectedQuote.quantities[i]} × R{item.sellingPrice?.toFixed(2) || '0.00'}
  • + {(selectedQuote?.items || []).map((item, i) => ( +
  • {item.itemName || item.sku || '(item)'} (SKU: {item.sku || '—'}) — Qty: {selectedQuote.quantities[i] ?? 0} × R{(selectedQuote.unitPrices[i] ?? item.sellingPrice ?? 0).toFixed(2)}
  • ))}
@@ -291,9 +291,13 @@ async function generateQuotePDF(quote: Quote) { doc.setFont("helvetica", "normal") doc.setFontSize(8) let subtotal = 0 - quote.items.forEach((item, i) => { - const qty = quote.quantities[i] || 0 - const price = quote.unitPrices[i] || item.sellingPrice || 0 + // quantities is the source of truth — old quotes have empty items arrays + // because the previous schema truncated them, but quantities/unitPrices are intact. + const lineCount = Math.max(quote.quantities?.length ?? 0, quote.items?.length ?? 0, quote.unitPrices?.length ?? 0) + for (let i = 0; i < lineCount; i++) { + const item = quote.items?.[i] || { itemName: '(item)', sku: '', sellingPrice: 0 } + const qty = quote.quantities?.[i] || 0 + const price = quote.unitPrices?.[i] || item.sellingPrice || 0 const total = qty * price subtotal += total @@ -305,17 +309,17 @@ async function generateQuotePDF(quote: Quote) { doc.setTextColor(darkText) doc.text(String(i + 1), 17, y + 5) - doc.text(item.itemName.substring(0, 40), 28, y + 5) + doc.text(String(item.itemName || '(item)').substring(0, 40), 28, y + 5) doc.setTextColor(mediumGray) doc.setFontSize(7) - doc.text(item.sku, 100, y + 5) + doc.text(item.sku || '—', 100, y + 5) doc.setFontSize(8) doc.setTextColor(darkText) doc.text(String(qty), 130, y + 5, { align: "center" }) doc.text(`R${price.toFixed(2)}`, 155, y + 5, { align: "right" }) doc.text(`R${total.toFixed(2)}`, 188, y + 5, { align: "right" }) y += rowH - }) + } // Totals const vatRate = 0.15 @@ -464,9 +468,13 @@ export async function saveQuotePdfToPocketbase(quote: Quote, pdfFilename: string doc.setFont("helvetica", "normal") doc.setFontSize(8) let subtotal = 0 - quote.items.forEach((item, i) => { - const qty = quote.quantities[i] || 0 - const price = quote.unitPrices[i] || item.sellingPrice || 0 + // quantities is the source of truth — old quotes have empty items arrays + // because the previous schema truncated them, but quantities/unitPrices are intact. + const lineCount = Math.max(quote.quantities?.length ?? 0, quote.items?.length ?? 0, quote.unitPrices?.length ?? 0) + for (let i = 0; i < lineCount; i++) { + const item = quote.items?.[i] || { itemName: '(item)', sku: '', sellingPrice: 0 } + const qty = quote.quantities?.[i] || 0 + const price = quote.unitPrices?.[i] || item.sellingPrice || 0 const total = qty * price subtotal += total @@ -478,17 +486,17 @@ export async function saveQuotePdfToPocketbase(quote: Quote, pdfFilename: string doc.setTextColor(darkText) doc.text(String(i + 1), 17, y + 5) - doc.text(item.itemName.substring(0, 40), 28, y + 5) + doc.text(String(item.itemName || '(item)').substring(0, 40), 28, y + 5) doc.setTextColor(mediumGray) doc.setFontSize(7) - doc.text(item.sku, 100, y + 5) + doc.text(item.sku || '—', 100, y + 5) doc.setFontSize(8) doc.setTextColor(darkText) doc.text(String(qty), 130, y + 5, { align: "center" }) doc.text(`R${price.toFixed(2)}`, 155, y + 5, { align: "right" }) doc.text(`R${total.toFixed(2)}`, 188, y + 5, { align: "right" }) y += rowH - }) + } // Totals const vatRate = 0.15 diff --git a/src/components/quotes/QuoteForm.tsx b/src/components/quotes/QuoteForm.tsx index 6ca815d..c553e08 100644 --- a/src/components/quotes/QuoteForm.tsx +++ b/src/components/quotes/QuoteForm.tsx @@ -140,8 +140,11 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) { const selectedCustomer = customers.find(c => c.name === values.customerName) if (!selectedCustomer) { showError("Invalid customer."); return } - // Process items: for manual entries, create stock items on-the-fly - const processedItems = await Promise.all(values.items.map(async (item) => { + // Process items: for manual entries, create stock items on-the-fly. + // For stock entries, the user MUST have actually selected an item from the + // dropdown — otherwise the SKU / stockItemId will be empty. + const processedItems: Array<{ stockItemId: string; quantity: number; unitPrice: number }> = [] + for (const item of values.items) { if (item.entryMode === 'manual') { // Create a new stock item const newStockItem = await pb.collection('stock_items').create({ @@ -151,10 +154,16 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) { quantity: 0, category: 'manual', }) - return { stockItemId: newStockItem.id, quantity: item.quantity, unitPrice: item.manualUnitPrice || 0 } + processedItems.push({ stockItemId: newStockItem.id, quantity: item.quantity, unitPrice: item.manualUnitPrice || 0 }) + continue } - return { stockItemId: item.stockItemId, quantity: item.quantity, unitPrice: item.unitPrice } - })) + // Stock mode: validate that the user picked a real item. + if (!item.stockItemId) { + showError(`Item ${item.itemName || '(unnamed)'} was not selected from the stock list. Pick it from the dropdown or switch to Manual Entry.`) + return + } + processedItems.push({ stockItemId: item.stockItemId, quantity: item.quantity, unitPrice: item.unitPrice }) + } const formData = new FormData() formData.append('quote_number', values.quoteNumber) @@ -164,6 +173,7 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) { formData.append('validUntil', values.validUntil?.toISOString() || "") formData.append('notes', values.notes || "") formData.append('status', 'Draft') + // items is a JSON array of stock_item ids (schema fix applied to PB) formData.append('items', JSON.stringify(processedItems.map(i => i.stockItemId))) formData.append('quantities', JSON.stringify(processedItems.map(i => i.quantity))) formData.append('unitPrices', JSON.stringify(processedItems.map(i => i.unitPrice))) @@ -189,14 +199,16 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) { // Try to fetch the stock item try { const item = await pb.collection('stock_items').getOne(pi.stockItemId) - return { stockItemId: pi.stockItemId, itemName: item.name, quantity: item.quantity, sku: item.sku, sellingPrice: item.selling_price || 0 } + return { stockItemId: pi.stockItemId, itemName: item.name, quantity: Number(item.quantity ?? 0), sku: item.sku, sellingPrice: Number(item.selling_price ?? 0) } } catch { return { stockItemId: pi.stockItemId, itemName: '', quantity: 0, sku: '', sellingPrice: 0 } } } - return { stockItemId: si.id, itemName: si.name, quantity: si.quantity || 0, sku: si.sku, sellingPrice: si.selling_price || 0 } + return { stockItemId: si.id, itemName: si.name, quantity: Number(si.quantity ?? 0), sku: si.sku, sellingPrice: Number(si.selling_price ?? 0) } })), customer: customer ? { name: customer.name, address: '', email: '', phone: '' } : null, notes: values.notes || '', attachments: [] as string[], + itemCount: processedItems.length, + customerRef: values.customerRef || '', } const pdfFilename = `QT_${values.quoteNumber.replace(/[^A-Za-z0-9_-]/g, "_")}.pdf` await saveQuotePdfToPocketbase(quoteForPdf, pdfFilename) diff --git a/src/pages/PurchaseOrders.tsx b/src/pages/PurchaseOrders.tsx index 53716c2..28e0d97 100644 --- a/src/pages/PurchaseOrders.tsx +++ b/src/pages/PurchaseOrders.tsx @@ -50,12 +50,19 @@ const PurchaseOrders = () => { if (!records) throw lastErr; const formattedData: PurchaseOrder[] = records.items.map((record: any) => { + // Only trust the snapshot when it actually carries readable rows. + // A snapshot that saved as ["[object Object]"] is non-empty but useless; + // falling back to the live relation keeps the PO (and its PDF) correct. const useSnapshot = - Array.isArray(record.snapshot_items) && record.snapshot_items.length > 0; + Array.isArray(record.snapshot_items) && + record.snapshot_items.length > 0 && + record.snapshot_items.every( + (it: any) => it && typeof it === "object" && typeof it.itemName === "string" && it.itemName !== "", + ); const items = useSnapshot ? record.snapshot_items.map((item: any) => ({ stockItemId: item.stockItemId || "", - itemName: item.itemName || "Unknown", + itemName: item.itemName, quantity: item.quantity || 0, sku: item.sku || "", costPrice: item.costPrice || 0, diff --git a/src/pages/Quotes.tsx b/src/pages/Quotes.tsx index 7b9f6c6..77836ae 100644 --- a/src/pages/Quotes.tsx +++ b/src/pages/Quotes.tsx @@ -23,44 +23,62 @@ const Quotes = () => { try { const records = await pb.collection('quotes').getList(1, 50, { sort: '-quoteDate', - expand: 'customerName,items', + expand: 'customerName', }) const formattedData = records.items.map((record: any) => { - // quantities is the source of truth for item count - const qtyArray = record.quantities || [] - const itemCount = qtyArray.length || 1 - // items field may be a single ID string or null when no expand - const itemId = record.items || '' + const qtyArray: number[] = Array.isArray(record.quantities) ? record.quantities : [] + const priceArray: number[] = Array.isArray(record.unitPrices) ? record.unitPrices : [] + const itemIds: string[] = Array.isArray(record.items) ? record.items : [] + const itemCount = qtyArray.length || itemIds.length || 0 + + // Hydrate item details from stock_items using the parallel `items` array. + const items = itemIds.map((stockItemId: string, i: number) => ({ + stockItemId, + itemName: '', + quantity: qtyArray[i] ?? 0, + sku: '', + sellingPrice: priceArray[i] ?? 0, + })) + return { id: record.id, quote_number: record.quote_number || record.id, - customerName: record.expand?.customerName?.comapny_name || record.customerName || '', + customerName: record.expand?.customerName?.comapny_name || record.expand?.customerName?.customer || record.customerName || '', customerRef: record.customerRef || '', quoteDate: record.quoteDate || record.created, validUntil: record.validUntil || '', status: record.status || 'Draft', itemCount, quantities: qtyArray, - unitPrices: record.unitPrices || [], - items: record.expand?.items ? [ - { - stockItemId: record.expand.items.id, - itemName: record.expand.items.name, - quantity: record.expand.items.quantity || 0, - sku: record.expand.items.sku, - sellingPrice: record.expand.items.selling_price || 0, - } - ] : (itemId ? [{ stockItemId: itemId, itemName: '', quantity: 0, sku: '', sellingPrice: 0 }] : []), + unitPrices: priceArray, + items, customer: record.expand?.customerName ? { - name: record.expand.customerName.comapny_name || '', - address: record.expand.customerName.address || '', + name: record.expand.customerName.comapny_name || record.expand.customerName.customer || '', + address: '', email: record.expand.customerName.email || '', - phone: record.expand.customerName.phone || '', + phone: '', } : null, attachments: record.attachments || [], notes: record.notes || '', } }) + + // Async-hydrate item names/skus from stock_items for display + const stockIds = Array.from(new Set(formattedData.flatMap(q => q.items.map(i => i.stockItemId)).filter(Boolean))) + if (stockIds.length > 0) { + try { + const filter = stockIds.map(id => `id = "${id}"`).join(' || ') + const stockRecs = await pb.collection('stock_items').getList(1, 200, { filter }) + const byId = new Map(stockRecs.items.map((r: any) => [r.id, r])) + for (const q of formattedData) { + for (const it of q.items) { + const s = byId.get(it.stockItemId) + if (s) { it.itemName = s.name; it.sku = s.sku; it.quantity = s.quantity ?? 0 } + } + } + } catch (e) { console.warn('stock_items hydrate failed', e) } + } + setQuotes(formattedData) } catch (err) { console.error("Error fetching quotes:", err)