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:
parent
0b8f003f87
commit
d606345de4
11 changed files with 486 additions and 45 deletions
|
|
@ -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);
|
||||||
|
});
|
||||||
83
scripts/create-user-quote.mjs
Normal file
83
scripts/create-user-quote.mjs
Normal 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}`);
|
||||||
50
scripts/fix-items-field.mjs
Normal file
50
scripts/fix-items-field.mjs
Normal 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
181
scripts/test-quote-pdf.mjs
Normal 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}`);
|
||||||
|
|
@ -356,6 +356,15 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
Object.entries(purchaseOrderData).forEach(([key, value]) => {
|
Object.entries(purchaseOrderData).forEach(([key, value]) => {
|
||||||
if (Array.isArray(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)));
|
value.forEach(v => formData.append(key, String(v)));
|
||||||
} else {
|
} else {
|
||||||
formData.append(key, String(value));
|
formData.append(key, String(value));
|
||||||
|
|
|
||||||
|
|
@ -273,8 +273,10 @@ export async function generatePurchaseOrderPdfBlob(order: PurchaseOrder): Promis
|
||||||
return doc.output("blob")
|
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 {
|
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, "_")
|
const safeName = String(rawName).replace(/[^A-Za-z0-9_-]/g, "_")
|
||||||
return `PO_${safeName}.pdf`
|
return `${safeName}.pdf`
|
||||||
}
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ export type Quote = {
|
||||||
status: 'Draft' | 'Sent' | 'Accepted' | 'Declined'
|
status: 'Draft' | 'Sent' | 'Accepted' | 'Declined'
|
||||||
itemCount: number
|
itemCount: number
|
||||||
validUntil?: string
|
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 }>
|
items: Array<{ stockItemId: string; itemName: string; quantity: number; sku: string; sellingPrice: number }>
|
||||||
quantities: number[]
|
quantities: number[]
|
||||||
unitPrices: number[]
|
unitPrices: number[]
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<p><strong>Valid Until:</strong> {selectedQuote?.validUntil ? new Date(selectedQuote.validUntil).toLocaleDateString() : 'N/A'}</p>
|
||||||
<h4 className="font-semibold">Items:</h4>
|
<h4 className="font-semibold">Items:</h4>
|
||||||
<ul className="list-disc list-inside">
|
<ul className="list-disc list-inside">
|
||||||
{selectedQuote?.items.map((item, i) => (
|
{(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>
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -291,9 +291,13 @@ async function generateQuotePDF(quote: Quote) {
|
||||||
doc.setFont("helvetica", "normal")
|
doc.setFont("helvetica", "normal")
|
||||||
doc.setFontSize(8)
|
doc.setFontSize(8)
|
||||||
let subtotal = 0
|
let subtotal = 0
|
||||||
quote.items.forEach((item, i) => {
|
// quantities is the source of truth — old quotes have empty items arrays
|
||||||
const qty = quote.quantities[i] || 0
|
// because the previous schema truncated them, but quantities/unitPrices are intact.
|
||||||
const price = quote.unitPrices[i] || item.sellingPrice || 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
|
const total = qty * price
|
||||||
subtotal += total
|
subtotal += total
|
||||||
|
|
||||||
|
|
@ -305,17 +309,17 @@ async function generateQuotePDF(quote: Quote) {
|
||||||
|
|
||||||
doc.setTextColor(darkText)
|
doc.setTextColor(darkText)
|
||||||
doc.text(String(i + 1), 17, y + 5)
|
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.setTextColor(mediumGray)
|
||||||
doc.setFontSize(7)
|
doc.setFontSize(7)
|
||||||
doc.text(item.sku, 100, y + 5)
|
doc.text(item.sku || '—', 100, y + 5)
|
||||||
doc.setFontSize(8)
|
doc.setFontSize(8)
|
||||||
doc.setTextColor(darkText)
|
doc.setTextColor(darkText)
|
||||||
doc.text(String(qty), 130, y + 5, { align: "center" })
|
doc.text(String(qty), 130, y + 5, { align: "center" })
|
||||||
doc.text(`R${price.toFixed(2)}`, 155, y + 5, { align: "right" })
|
doc.text(`R${price.toFixed(2)}`, 155, y + 5, { align: "right" })
|
||||||
doc.text(`R${total.toFixed(2)}`, 188, y + 5, { align: "right" })
|
doc.text(`R${total.toFixed(2)}`, 188, y + 5, { align: "right" })
|
||||||
y += rowH
|
y += rowH
|
||||||
})
|
}
|
||||||
|
|
||||||
// Totals
|
// Totals
|
||||||
const vatRate = 0.15
|
const vatRate = 0.15
|
||||||
|
|
@ -464,9 +468,13 @@ export async function saveQuotePdfToPocketbase(quote: Quote, pdfFilename: string
|
||||||
doc.setFont("helvetica", "normal")
|
doc.setFont("helvetica", "normal")
|
||||||
doc.setFontSize(8)
|
doc.setFontSize(8)
|
||||||
let subtotal = 0
|
let subtotal = 0
|
||||||
quote.items.forEach((item, i) => {
|
// quantities is the source of truth — old quotes have empty items arrays
|
||||||
const qty = quote.quantities[i] || 0
|
// because the previous schema truncated them, but quantities/unitPrices are intact.
|
||||||
const price = quote.unitPrices[i] || item.sellingPrice || 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
|
const total = qty * price
|
||||||
subtotal += total
|
subtotal += total
|
||||||
|
|
||||||
|
|
@ -478,17 +486,17 @@ export async function saveQuotePdfToPocketbase(quote: Quote, pdfFilename: string
|
||||||
|
|
||||||
doc.setTextColor(darkText)
|
doc.setTextColor(darkText)
|
||||||
doc.text(String(i + 1), 17, y + 5)
|
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.setTextColor(mediumGray)
|
||||||
doc.setFontSize(7)
|
doc.setFontSize(7)
|
||||||
doc.text(item.sku, 100, y + 5)
|
doc.text(item.sku || '—', 100, y + 5)
|
||||||
doc.setFontSize(8)
|
doc.setFontSize(8)
|
||||||
doc.setTextColor(darkText)
|
doc.setTextColor(darkText)
|
||||||
doc.text(String(qty), 130, y + 5, { align: "center" })
|
doc.text(String(qty), 130, y + 5, { align: "center" })
|
||||||
doc.text(`R${price.toFixed(2)}`, 155, y + 5, { align: "right" })
|
doc.text(`R${price.toFixed(2)}`, 155, y + 5, { align: "right" })
|
||||||
doc.text(`R${total.toFixed(2)}`, 188, y + 5, { align: "right" })
|
doc.text(`R${total.toFixed(2)}`, 188, y + 5, { align: "right" })
|
||||||
y += rowH
|
y += rowH
|
||||||
})
|
}
|
||||||
|
|
||||||
// Totals
|
// Totals
|
||||||
const vatRate = 0.15
|
const vatRate = 0.15
|
||||||
|
|
|
||||||
|
|
@ -140,8 +140,11 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) {
|
||||||
const selectedCustomer = customers.find(c => c.name === values.customerName)
|
const selectedCustomer = customers.find(c => c.name === values.customerName)
|
||||||
if (!selectedCustomer) { showError("Invalid customer."); return }
|
if (!selectedCustomer) { showError("Invalid customer."); return }
|
||||||
|
|
||||||
// Process items: for manual entries, create stock items on-the-fly
|
// Process items: for manual entries, create stock items on-the-fly.
|
||||||
const processedItems = await Promise.all(values.items.map(async (item) => {
|
// 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') {
|
if (item.entryMode === 'manual') {
|
||||||
// Create a new stock item
|
// Create a new stock item
|
||||||
const newStockItem = await pb.collection('stock_items').create({
|
const newStockItem = await pb.collection('stock_items').create({
|
||||||
|
|
@ -151,10 +154,16 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) {
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
category: 'manual',
|
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()
|
const formData = new FormData()
|
||||||
formData.append('quote_number', values.quoteNumber)
|
formData.append('quote_number', values.quoteNumber)
|
||||||
|
|
@ -164,6 +173,7 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) {
|
||||||
formData.append('validUntil', values.validUntil?.toISOString() || "")
|
formData.append('validUntil', values.validUntil?.toISOString() || "")
|
||||||
formData.append('notes', values.notes || "")
|
formData.append('notes', values.notes || "")
|
||||||
formData.append('status', 'Draft')
|
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('items', JSON.stringify(processedItems.map(i => i.stockItemId)))
|
||||||
formData.append('quantities', JSON.stringify(processedItems.map(i => i.quantity)))
|
formData.append('quantities', JSON.stringify(processedItems.map(i => i.quantity)))
|
||||||
formData.append('unitPrices', JSON.stringify(processedItems.map(i => i.unitPrice)))
|
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 to fetch the stock item
|
||||||
try {
|
try {
|
||||||
const item = await pb.collection('stock_items').getOne(pi.stockItemId)
|
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 } }
|
} 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,
|
customer: customer ? { name: customer.name, address: '', email: '', phone: '' } : null,
|
||||||
notes: values.notes || '',
|
notes: values.notes || '',
|
||||||
attachments: [] as string[],
|
attachments: [] as string[],
|
||||||
|
itemCount: processedItems.length,
|
||||||
|
customerRef: values.customerRef || '',
|
||||||
}
|
}
|
||||||
const pdfFilename = `QT_${values.quoteNumber.replace(/[^A-Za-z0-9_-]/g, "_")}.pdf`
|
const pdfFilename = `QT_${values.quoteNumber.replace(/[^A-Za-z0-9_-]/g, "_")}.pdf`
|
||||||
await saveQuotePdfToPocketbase(quoteForPdf, pdfFilename)
|
await saveQuotePdfToPocketbase(quoteForPdf, pdfFilename)
|
||||||
|
|
|
||||||
|
|
@ -50,12 +50,19 @@ const PurchaseOrders = () => {
|
||||||
if (!records) throw lastErr;
|
if (!records) throw lastErr;
|
||||||
|
|
||||||
const formattedData: PurchaseOrder[] = records.items.map((record: any) => {
|
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 =
|
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
|
const items = useSnapshot
|
||||||
? record.snapshot_items.map((item: any) => ({
|
? record.snapshot_items.map((item: any) => ({
|
||||||
stockItemId: item.stockItemId || "",
|
stockItemId: item.stockItemId || "",
|
||||||
itemName: item.itemName || "Unknown",
|
itemName: item.itemName,
|
||||||
quantity: item.quantity || 0,
|
quantity: item.quantity || 0,
|
||||||
sku: item.sku || "",
|
sku: item.sku || "",
|
||||||
costPrice: item.costPrice || 0,
|
costPrice: item.costPrice || 0,
|
||||||
|
|
|
||||||
|
|
@ -23,44 +23,62 @@ const Quotes = () => {
|
||||||
try {
|
try {
|
||||||
const records = await pb.collection('quotes').getList(1, 50, {
|
const records = await pb.collection('quotes').getList(1, 50, {
|
||||||
sort: '-quoteDate',
|
sort: '-quoteDate',
|
||||||
expand: 'customerName,items',
|
expand: 'customerName',
|
||||||
})
|
})
|
||||||
const formattedData = records.items.map((record: any) => {
|
const formattedData = records.items.map((record: any) => {
|
||||||
// quantities is the source of truth for item count
|
const qtyArray: number[] = Array.isArray(record.quantities) ? record.quantities : []
|
||||||
const qtyArray = record.quantities || []
|
const priceArray: number[] = Array.isArray(record.unitPrices) ? record.unitPrices : []
|
||||||
const itemCount = qtyArray.length || 1
|
const itemIds: string[] = Array.isArray(record.items) ? record.items : []
|
||||||
// items field may be a single ID string or null when no expand
|
const itemCount = qtyArray.length || itemIds.length || 0
|
||||||
const itemId = record.items || ''
|
|
||||||
|
// 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 {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
quote_number: record.quote_number || 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 || '',
|
customerRef: record.customerRef || '',
|
||||||
quoteDate: record.quoteDate || record.created,
|
quoteDate: record.quoteDate || record.created,
|
||||||
validUntil: record.validUntil || '',
|
validUntil: record.validUntil || '',
|
||||||
status: record.status || 'Draft',
|
status: record.status || 'Draft',
|
||||||
itemCount,
|
itemCount,
|
||||||
quantities: qtyArray,
|
quantities: qtyArray,
|
||||||
unitPrices: record.unitPrices || [],
|
unitPrices: priceArray,
|
||||||
items: record.expand?.items ? [
|
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 }] : []),
|
|
||||||
customer: record.expand?.customerName ? {
|
customer: record.expand?.customerName ? {
|
||||||
name: record.expand.customerName.comapny_name || '',
|
name: record.expand.customerName.comapny_name || record.expand.customerName.customer || '',
|
||||||
address: record.expand.customerName.address || '',
|
address: '',
|
||||||
email: record.expand.customerName.email || '',
|
email: record.expand.customerName.email || '',
|
||||||
phone: record.expand.customerName.phone || '',
|
phone: '',
|
||||||
} : null,
|
} : null,
|
||||||
attachments: record.attachments || [],
|
attachments: record.attachments || [],
|
||||||
notes: record.notes || '',
|
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)
|
setQuotes(formattedData)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching quotes:", err)
|
console.error("Error fetching quotes:", err)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue