fix(quotes,po): repair item storage, totals and PDF line items

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: "<id>"`.
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.
This commit is contained in:
Dave 2026-10-20 20:13:46 +02:00
parent 0b8f003f87
commit d606345de4
11 changed files with 486 additions and 45 deletions

View file

@ -0,0 +1,70 @@
/// <reference path="../pb_data/types.d.ts" />
/**
* 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: "<one id>"`.
*
* 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);
});

View file

@ -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}`);

View file

@ -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);
}

181
scripts/test-quote-pdf.mjs Normal file
View file

@ -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 <quote_number>
*/
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}`);

View file

@ -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));

View file

@ -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`
}

View file

@ -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[]

View file

@ -123,8 +123,8 @@ export function QuoteDataTable({ columns: columnsProp, data, filterValue, onFilt
<p><strong>Valid Until:</strong> {selectedQuote?.validUntil ? new Date(selectedQuote.validUntil).toLocaleDateString() : 'N/A'}</p>
<h4 className="font-semibold">Items:</h4>
<ul className="list-disc list-inside">
{selectedQuote?.items.map((item, i) => (
<li key={item.stockItemId}>{item.itemName} (SKU: {item.sku}) — Qty: {selectedQuote.quantities[i]} × R{item.sellingPrice?.toFixed(2) || '0.00'}</li>
{(selectedQuote?.items || []).map((item, i) => (
<li key={item.stockItemId || i}>{item.itemName || item.sku || '(item)'} (SKU: {item.sku || '—'}) — Qty: {selectedQuote.quantities[i] ?? 0} × R{(selectedQuote.unitPrices[i] ?? item.sellingPrice ?? 0).toFixed(2)}</li>
))}
</ul>
</div>
@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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)