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 (