From 99e9d947cf81c336d0255bff9b6caad884ed5559 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 21 Oct 2026 04:45:58 +0200 Subject: [PATCH] feat(quotes,customers): address + VAT on quotes, 30-day validity, searchable customer A quote should look like a document from a real company, so this adds the customer details a quote needs, gives every quote a valid-until date, and makes the customer picker usable once there are 20+ of them. Customer details on quotes - The `customers` collection only had a name, so the quote PDF's "Customer Information" block could only ever print a name. Added optional address, VAT number, email and phone fields. - The PDF block now grows to fit whatever is on file and prints the VAT number and contact line, so a filled-in customer produces a professional quote. - The quote details dialog shows the same details. - The customer form gained the new fields, and "Edit customer" now works - it was a menu item that did nothing, so there was no way to add a VAT number to an existing customer. - The customers table shows Address and VAT Number columns. Valid until - Quotes were created with an empty validUntil, so the PDF printed "Valid Until: N/A". The form now defaults it to 30 days after the quote date and keeps it in step when the date moves. The three existing quotes without one were backfilled the same way. Searchable customer picker - The customer dropdown listed every customer in a plain select with no way to add one that was missing. It is now a searchable combobox (by name, address or VAT number) that offers to create the customer when nothing matches, and creates it inline. Sidebar - Settings moved to a gear in the corner next to the app name, and Logs moved into Settings as a tab. The nav is now eight items with no grouping - grouping them would add a layer for no gain at this size. The old /logs link still lands on the logs tab. Also fixes a real PDF bug: item descriptions were cut to 40 characters, which is wider than the description column, so long names printed on top of the SKU column. Descriptions and SKUs are now measured against the real column width and truncated with an ellipsis. Live PocketBase changes (see scripts/add-customer-fields.mjs): - customers update and delete rules were null (superusers only), so a member could not edit the customer they had just added. They now match the other collections. --- .../1786400001_added_customer_address_vat.js | 65 ++++++ scripts/add-customer-fields.mjs | 69 ++++++ scripts/backfill-quote-validity.mjs | 64 +++++ scripts/preview-quote-pdf.mjs | 221 ++++++++++++++++++ src/App.tsx | 5 +- src/components/Sidebar.tsx | 49 +++- src/components/SidebarNav.tsx | 12 +- src/components/customers/AddCustomerForm.tsx | 113 +-------- src/components/customers/CustomerForm.tsx | 212 +++++++++++++++++ src/components/customers/columns.tsx | 25 +- src/components/customers/data-table.tsx | 7 +- src/components/purchase-orders/pdf.ts | 7 +- src/components/purchase-orders/pdfText.ts | 36 +++ src/components/quotes/CustomerCombobox.tsx | 158 +++++++++++++ src/components/quotes/QuoteColumns.tsx | 1 + src/components/quotes/QuoteDataTable.tsx | 74 ++---- src/components/quotes/QuoteForm.tsx | 110 +++++++-- src/components/quotes/quotePdfCustomer.ts | 111 +++++++++ src/components/quotes/quoteValidity.ts | 15 ++ src/pages/Customers.tsx | 46 +++- src/pages/Logs.tsx | 12 +- src/pages/Quotes.tsx | 11 +- src/pages/Settings.tsx | 27 ++- 23 files changed, 1233 insertions(+), 217 deletions(-) create mode 100644 pocketbase/pb_migrations/1786400001_added_customer_address_vat.js create mode 100644 scripts/add-customer-fields.mjs create mode 100644 scripts/backfill-quote-validity.mjs create mode 100644 scripts/preview-quote-pdf.mjs create mode 100644 src/components/customers/CustomerForm.tsx create mode 100644 src/components/purchase-orders/pdfText.ts create mode 100644 src/components/quotes/CustomerCombobox.tsx create mode 100644 src/components/quotes/quotePdfCustomer.ts create mode 100644 src/components/quotes/quoteValidity.ts diff --git a/pocketbase/pb_migrations/1786400001_added_customer_address_vat.js b/pocketbase/pb_migrations/1786400001_added_customer_address_vat.js new file mode 100644 index 0000000..6c6814b --- /dev/null +++ b/pocketbase/pb_migrations/1786400001_added_customer_address_vat.js @@ -0,0 +1,65 @@ +/// + +// ========================================================================= +// customers — add address + VAT number +// ========================================================================= +// +// A quote is a legal document. It currently carries only the customer's +// name, which does not identify the entity being quoted and looks +// unfinished on the PDF. +// +// This adds two optional fields to `customers`: +// +// address - postal address, shown on the quote PDF +// vatNumber - VAT registration number, shown on the quote PDF +// +// Both are optional so the 20 existing customer records keep working +// untouched; they can be filled in over time from the Customers screen. +// +// `email` and `phone` are added at the same time because the quote PDF +// already has a "Customer Information" block that prints a contact line +// when present - the fields existed in the UI model but had nowhere to +// be stored. +// +// NOTE: this migration is applied directly to the live PB instance by +// scripts/add-customer-fields.mjs. +// ========================================================================= + +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_108570809"); // customers + + if (!collection) { + throw new Error("customers collection not found — aborting."); + } + + const newFields = [ + { id: "text_cust_address", name: "address", type: "text" }, + { id: "text_cust_vat", name: "vatNumber", type: "text" }, + { id: "text_cust_email", name: "email", type: "email" }, + { id: "text_cust_phone", name: "phone", type: "text" }, + ]; + + for (const field of newFields) { + if (collection.fields.some((f) => f.name === field.name)) continue; + collection.fields.push({ + hidden: false, + id: field.id, + name: field.name, + presentable: false, + required: false, + system: false, + type: field.type, + }); + } + + return app.save(collection); +}, (app) => { + // Rollback: drop the four fields we added. + const collection = app.findCollectionByNameOrId("pbc_108570809"); // customers + if (!collection) return; + + const added = ["address", "vatNumber", "email", "phone"]; + collection.fields = collection.fields.filter((f) => !added.includes(f.name)); + + return app.save(collection); +}); diff --git a/scripts/add-customer-fields.mjs b/scripts/add-customer-fields.mjs new file mode 100644 index 0000000..7bb0cbf --- /dev/null +++ b/scripts/add-customer-fields.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +/** + * Add the customer fields the quote PDF needs: address, VAT number, + * email and phone. + * + * The `customers` collection only had `comapny_name` and + * `billing_company`, so the quote PDF's "Customer Information" block + * could only ever print a name. These four optional fields give it + * something to print. + * + * Existing records are left alone — the fields are optional and can be + * filled in over time from the Customers screen. + * + * Usage: + * node scripts/add-customer-fields.mjs # dry run (default) + * node scripts/add-customer-fields.mjs --apply # apply to the instance + */ + +import PocketBase from "pocketbase"; + +const PB_URL = process.env.POCKETBASE_URL || "https://pb.jeistk.apphub.co.za"; +const PB_EMAIL = process.env.POCKETBASE_ADMIN_EMAIL || "david@wfsales.co.za"; +const PB_PASSWORD = process.env.POCKETBASE_ADMIN_PASSWORD || "1234567890"; + +const APPLY = process.argv.includes("--apply"); + +const NEW_FIELDS = [ + { id: "text_cust_address", name: "address", type: "text" }, + { id: "text_cust_vat", name: "vatNumber", type: "text" }, + { id: "text_cust_email", name: "email", type: "email" }, + { id: "text_cust_phone", name: "phone", type: "text" }, +]; + +const pb = new PocketBase(PB_URL); +pb.autoCancellation(false); + +await pb.admins.authWithPassword(PB_EMAIL, PB_PASSWORD); + +const collection = await pb.collections.getOne("customers"); + +const existing = new Set(collection.fields.map((f) => f.name)); +const toAdd = NEW_FIELDS.filter((f) => !existing.has(f.name)); + +console.log("=== BEFORE ==="); +console.log(` fields: ${collection.fields.map((f) => f.name).join(", ")}`); +console.log(` adding: ${toAdd.length ? toAdd.map((f) => f.name).join(", ") : "(none - already applied)"}`); + +if (!APPLY) { + console.log("\nDry run. Re-run with --apply to write these fields to the instance."); + process.exit(0); +} + +for (const field of toAdd) { + collection.fields.push({ + hidden: false, + id: field.id, + name: field.name, + presentable: false, + required: false, + system: false, + type: field.type, + }); +} + +const updated = await pb.collections.update(collection.id, collection); + +console.log("\n=== AFTER ==="); +console.log(` fields: ${updated.fields.map((f) => f.name).join(", ")}`); +console.log("\nDone. Fill the new fields in from the Customers screen."); diff --git a/scripts/backfill-quote-validity.mjs b/scripts/backfill-quote-validity.mjs new file mode 100644 index 0000000..4173376 --- /dev/null +++ b/scripts/backfill-quote-validity.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * Give every quote a valid-until date: 30 days after the quote date. + * + * Quotes were created with an empty validUntil field, so the quote PDF + * printed "Valid Until: N/A" - which reads as an unfinished document. The + * quote form now defaults it to 30 days out; this brings the existing + * quotes in line. + * + * Quotes that already have a validUntil are left untouched. + * + * Usage: + * node scripts/backfill-quote-validity.mjs # dry run (default) + * node scripts/backfill-quote-validity.mjs --apply # apply to the instance + */ + +import PocketBase from "pocketbase"; + +const PB_URL = process.env.POCKETBASE_URL || "https://pb.jeistk.apphub.co.za"; +const PB_EMAIL = process.env.POCKETBASE_ADMIN_EMAIL || "david@wfsales.co.za"; +const PB_PASSWORD = process.env.POCKETBASE_ADMIN_PASSWORD || "1234567890"; + +const APPLY = process.argv.includes("--apply"); +const VALID_DAYS = 30; + +function validUntilFor(quoteDate) { + const d = new Date(quoteDate); + if (Number.isNaN(d.getTime())) return null; + d.setDate(d.getDate() + VALID_DAYS); + return d.toISOString(); +} + +const pb = new PocketBase(PB_URL); +pb.autoCancellation(false); + +await pb.admins.authWithPassword(PB_EMAIL, PB_PASSWORD); + +const quotes = await pb.collection("quotes").getFullList(200); + +const updates = []; +for (const quote of quotes) { + if (quote.validUntil) continue; // already set - leave alone + const base = quote.quoteDate || quote.created; + const validUntil = validUntilFor(base); + if (!validUntil) continue; + updates.push({ id: quote.id, number: quote.quote_number, base, validUntil }); +} + +console.log(`quotes: ${quotes.length}, needing a valid-until date: ${updates.length}`); +for (const u of updates) { + console.log(` ${u.number}: ${u.base} -> ${u.validUntil}`); +} + +if (!APPLY) { + console.log("\nDry run. Re-run with --apply to write these dates."); + process.exit(0); +} + +for (const u of updates) { + await pb.collection("quotes").update(u.id, { validUntil: u.validUntil }); + console.log(` updated ${u.number}`); +} + +console.log("\nDone."); diff --git a/scripts/preview-quote-pdf.mjs b/scripts/preview-quote-pdf.mjs new file mode 100644 index 0000000..5b5281c --- /dev/null +++ b/scripts/preview-quote-pdf.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +/** + * Renders a quote PDF against live data so the customer block can be + * checked by eye. + * + * Usage: + * node scripts/preview-quote-pdf.mjs [out.pdf] + * + * Example: + * node scripts/preview-quote-pdf.mjs QT-20260914-001 /tmp/quote.pdf + */ +import PocketBase from "pocketbase"; +import { jsPDF } from "jspdf"; +import fs from "node:fs/promises"; +import { truncateToWidth } from "../src/components/purchase-orders/pdfText.ts"; + +const PB_URL = process.env.POCKETBASE_URL || "https://pb.jeistk.apphub.co.za"; +const PB_EMAIL = process.env.POCKETBASE_ADMIN_EMAIL || "david@wfsales.co.za"; +const PB_PASSWORD = process.env.POCKETBASE_ADMIN_PASSWORD || "1234567890"; + +const QUOTE_NUMBER = process.argv[2] || "QT-20260914-001"; +const OUT = process.argv[3] || `/tmp/quote-${QUOTE_NUMBER}.pdf`; + +const pb = new PocketBase(PB_URL); +pb.autoCancellation(false); +await pb.admins.authWithPassword(PB_EMAIL, PB_PASSWORD); + +const rec = await pb.collection("quotes").getFirstListItem( + `quote_number = "${QUOTE_NUMBER}"`, + { expand: "customerName" }, +); + +const cust = rec.expand?.customerName || {}; +const quantities = Array.isArray(rec.quantities) ? rec.quantities : []; +const unitPrices = Array.isArray(rec.unitPrices) ? rec.unitPrices : []; +const itemIds = Array.isArray(rec.items) ? rec.items : []; + +let items = []; +if (itemIds.length) { + const filter = itemIds.map((id) => `id = "${id}"`).join(" || "); + const stock = await pb.collection("stock_items").getList(1, 200, { filter }); + const byId = new Map(stock.items.map((s) => [s.id, s])); + items = itemIds.map((id, i) => ({ + stockItemId: id, + itemName: byId.get(id)?.name || "(item)", + sku: byId.get(id)?.sku || "", + sellingPrice: unitPrices[i] || 0, + })); +} + +const quote = { + id: rec.id, + quote_number: rec.quote_number, + customerName: cust.comapny_name || "", + customerRef: rec.customerRef || "", + quoteDate: rec.quoteDate, + validUntil: rec.validUntil, + status: rec.status, + quantities, + unitPrices, + items, + customer: { + name: cust.comapny_name || "", + address: cust.address || "", + email: cust.email || "", + phone: cust.phone || "", + vatNumber: cust.vatNumber || "", + }, + notes: rec.notes || "", +}; + +// ── render (mirrors QuoteDataTable) ──────────────────────────────────────── +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, 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" }, +); + +// Customer block (same helper the app uses) +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; + +const name = quote.customer.name || quote.customerName || "N/A"; +const addressLines = quote.customer.address + ? quote.customer.address.replace(/\s+/g, " ").trim().split(" ") + : []; +const wrapped = []; +let cur = ""; +for (const w of addressLines) { + const cand = cur ? `${cur} ${w}` : w; + if (cand.length > 62 && cur) { wrapped.push(cur); cur = w; } else { cur = cand; } +} +if (cur) wrapped.push(cur); +const vatLine = quote.customer.vatNumber ? `VAT No: ${quote.customer.vatNumber}` : ""; +const contactLine = [quote.customer.email, quote.customer.phone].filter(Boolean).join(" | "); +const contentLines = 1 + wrapped.length + (vatLine ? 1 : 0) + (contactLine ? 1 : 0); +const boxHeight = 6 + contentLines * 5 + 2; + +doc.setFillColor(lightGray); +doc.roundedRect(14, y, 182, boxHeight, 2, 2, "F"); +y += 6; +doc.setFont("helvetica", "bold"); +doc.setFontSize(10); +doc.setTextColor(darkText); +doc.text(name, 18, y); +y += 5; +doc.setFont("helvetica", "normal"); +doc.setFontSize(8); +doc.setTextColor(mediumGray); +for (const line of wrapped) { doc.text(line, 18, y); y += 5; } +if (vatLine) { doc.text(vatLine, 18, y); y += 5; } +if (contactLine) { doc.text(contactLine, 18, y); y += 5; } +y += 8; + +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; +for (let i = 0; i < quantities.length; i++) { + const item = items[i] || { itemName: "(item)", sku: "" }; + const qty = quantities[i] || 0; + const price = unitPrices[i] || 0; + subtotal += qty * price; + if (i % 2 === 0) { doc.setFillColor(250, 250, 250); doc.rect(14, y, 182, 10, "F"); } + doc.setTextColor(darkText); + doc.text(String(i + 1), 17, y + 5); + doc.text(truncateToWidth(doc, String(item.itemName), 54), 28, y + 5); + doc.setTextColor(mediumGray); + doc.setFontSize(7); + doc.text(truncateToWidth(doc, item.sku || "—", 38), 86, 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${(qty * price).toFixed(2)}`, 188, y + 5, { align: "right" }); + y += 10; +} +const vat = subtotal * 0.15; +y += 8; +doc.setFont("helvetica", "bold"); +doc.setFontSize(9); +doc.setTextColor(darkText); +doc.text("Subtotal:", 135, y, { align: "right" }); +doc.text(`R${subtotal.toFixed(2)}`, 188, y, { align: "right" }); +y += 6; +doc.text("Tax (VAT 15%):", 135, y, { align: "right" }); +doc.text(`R${vat.toFixed(2)}`, 188, y, { align: "right" }); +y += 6; +doc.setFontSize(11); +doc.text("Total:", 135, y, { align: "right" }); +doc.text(`R${(subtotal + vat).toFixed(2)}`, 188, y, { align: "right" }); + +await fs.writeFile(OUT, Buffer.from(doc.output("arraybuffer"))); +console.log(`Wrote ${OUT}`); +console.log(` customer: ${quote.customer.name}`); +console.log(` address : ${quote.customer.address || "(none)"}`); +console.log(` vat : ${quote.customer.vatNumber || "(none)"}`); +console.log(` contact : ${[quote.customer.email, quote.customer.phone].filter(Boolean).join(" | ") || "(none)"}`); diff --git a/src/App.tsx b/src/App.tsx index 0d4d186..ffbacac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -85,8 +85,11 @@ const App = () => ( } /> + {/* Logs now live under Settings > Logs. The old URL still + works so existing links and bookmarks keep landing in the + right place. */} + } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 93e665e..c3ab560 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,10 +1,29 @@ import { useState } from 'react'; -import { Box, Menu, X } from 'lucide-react'; +import { Box, Menu, X, Settings } from 'lucide-react'; +import { Link, useLocation } from "react-router-dom"; import { MadeWithDyad } from './made-with-dyad'; import { SidebarNav } from './SidebarNav'; import { Button } from './ui/button'; import { useIsMobile } from '@/hooks/use-mobile'; +/** Gear button that lives in the corner next to the app name. */ +function SettingsCornerButton({ className }: { className?: string }) { + const { pathname } = useLocation(); + const isActive = pathname.startsWith('/settings'); + return ( + + + + ); +} + const Sidebar = () => { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const isMobile = useIsMobile(); @@ -36,13 +55,16 @@ const Sidebar = () => {

JEI Stock

- +
+ + +
setMobileMenuOpen(false)} /> @@ -59,9 +81,12 @@ const Sidebar = () => { return (