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.
This commit is contained in:
Dave 2026-10-21 04:45:58 +02:00
parent edc9f3da74
commit 99e9d947cf
23 changed files with 1233 additions and 217 deletions

View file

@ -0,0 +1,65 @@
/// <reference path="../pb_data/types.d.ts" />
// =========================================================================
// 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);
});

View file

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

View file

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

View file

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

View file

@ -85,8 +85,11 @@ const App = () => (
<Layout><Suppliers /></Layout>
</ProtectedRoute>
} />
{/* Logs now live under Settings > Logs. The old URL still
works so existing links and bookmarks keep landing in the
right place. */}
<Route path="/logs" element={
<ProtectedRoute requiredRoles={["admin", "manager"]}>
<ProtectedRoute>
<Layout><Logs /></Layout>
</ProtectedRoute>
} />

View file

@ -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 (
<Link
to="/settings"
className={className}
aria-label="Settings"
title="Settings"
>
<Settings
className={`h-5 w-5 ${isActive ? 'text-slate-900' : 'text-slate-700'}`}
/>
</Link>
);
}
const Sidebar = () => {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const isMobile = useIsMobile();
@ -36,6 +55,8 @@ const Sidebar = () => {
<Box className="h-8 w-8 text-slate-700 mr-2" />
<h1 className="text-lg font-bold text-foreground">JEI Stock</h1>
</div>
<div className="flex items-center gap-1">
<SettingsCornerButton className="p-2 rounded-md hover:bg-slate-100" />
<Button
variant="ghost"
size="sm"
@ -44,6 +65,7 @@ const Sidebar = () => {
<X className="h-5 w-5" />
</Button>
</div>
</div>
<SidebarNav onLinkClick={() => setMobileMenuOpen(false)} />
</div>
<div className="mt-auto">
@ -59,10 +81,13 @@ const Sidebar = () => {
return (
<aside className="w-64 flex-shrink-0 bg-white p-4 hidden md:flex flex-col">
<div>
<div className="flex items-center mb-8">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center">
<Box className="h-8 w-8 text-slate-700 mr-2" />
<h1 className="text-xl font-bold text-foreground">JEI Stock Take</h1>
</div>
<SettingsCornerButton className="p-2 rounded-md hover:bg-slate-100" />
</div>
<SidebarNav />
</div>
<div className="mt-auto">

View file

@ -1,7 +1,7 @@
import { Link, useLocation } from "react-router-dom";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
import { Home, Book, Users, ArrowLeftRight, Package, ShoppingCart, Truck, ScrollText, FileText, Settings } from "lucide-react";
import { Home, Book, Users, ArrowLeftRight, Package, ShoppingCart, Truck, FileText } from "lucide-react";
const sidebarNavItems = [
{
@ -44,16 +44,6 @@ const sidebarNavItems = [
href: "/suppliers",
icon: Truck,
},
{
title: "Logs",
href: "/logs",
icon: ScrollText,
},
{
title: "Settings",
href: "/settings",
icon: Settings,
},
];
interface SidebarNavProps {

View file

@ -1,107 +1,18 @@
"use client"
import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { CustomerForm } from "./CustomerForm";
import { Button } from "../ui/button"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../ui/form"
import { Input } from "../ui/input"
import { showSuccess, showError } from "../../utils/toast"
import { BillingCompanyCombobox } from "./BillingCompanyCombobox"
import pb from "@/lib/pocketbase"
const formSchema = z.object({
companyName: z.string().min(2, { message: "Company name must be at least 2 characters." }),
billingCompany: z.string({ required_error: "Please select or create a billing company." }),
})
const initialBillingCompanies = ["Isherwood", "CNL"];
interface AddCustomerFormProps {
/**
* Kept for existing imports. The form now also supports editing and
* carries the address / VAT / contact fields, so it lives in
* CustomerForm and this is a thin wrapper.
*/
export function AddCustomerForm({
setOpen,
refreshData,
}: {
setOpen: (open: boolean) => void;
refreshData: () => void;
}
export function AddCustomerForm({ setOpen, refreshData }: AddCustomerFormProps) {
const [billingCompanies, setBillingCompanies] = React.useState(initialBillingCompanies.map(c => ({ value: c.toLowerCase(), label: c })));
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
companyName: "",
},
})
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
// Save the customer to PocketBase
await pb.collection('customers').create({
comapny_name: values.companyName, // Note: PocketBase field name is 'comapny_name'
billing_company: values.billingCompany, // Note: PocketBase field name is 'billing_company'
});
showSuccess("New customer added successfully!");
form.reset(); // Reset form fields
setOpen(false); // Close the dialog on successful submission
refreshData(); // Trigger data refresh in Customers list
} catch (error) {
console.error("Error adding customer:", error);
showError("Failed to add customer. Please try again.");
}
}
const handleCompanyCreate = (newCompany: string) => {
const formattedCompany = {
value: newCompany.toLowerCase(),
label: newCompany,
};
setBillingCompanies(prev => [...prev, formattedCompany]);
};
return (
<div className="w-full max-w-2xl mx-auto px-4 sm:px-6">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 sm:space-y-5">
<FormField
control={form.control}
name="companyName"
render={({ field }) => (
<FormItem>
<FormLabel>Company Name</FormLabel>
<FormControl>
<Input placeholder="e.g., Acme Inc." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="billingCompany"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Billing Company</FormLabel>
<BillingCompanyCombobox
companies={billingCompanies}
value={field.value}
onChange={field.onChange}
onCompanyCreate={handleCompanyCreate}
/>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98] transition-all">Add Customer</Button>
</form>
</Form>
</div>
)
}) {
return <CustomerForm setOpen={setOpen} refreshData={refreshData} />;
}

View file

@ -0,0 +1,212 @@
"use client"
import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "../ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../ui/form"
import { Input } from "../ui/input"
import { Textarea } from "../ui/textarea"
import { showSuccess, showError } from "../../utils/toast"
import { BillingCompanyCombobox } from "./BillingCompanyCombobox"
import pb from "@/lib/pocketbase"
const formSchema = z.object({
companyName: z.string().min(2, { message: "Company name must be at least 2 characters." }),
billingCompany: z.string({ required_error: "Please select or create a billing company." }),
address: z.string().optional(),
vatNumber: z.string().optional(),
email: z.string().optional().refine(
(v) => !v || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
{ message: "Please enter a valid email address." },
),
phone: z.string().optional(),
})
const initialBillingCompanies = ["Isherwood", "CNL"];
/** The customer being edited, when the form is in edit mode. */
export type EditableCustomer = {
id: string
companyName: string
billingCompany: string
address: string
vatNumber: string
email: string
phone: string
}
interface CustomerFormProps {
setOpen: (open: boolean) => void
refreshData: () => void
/** When present the form edits this customer instead of creating a new one. */
customer?: EditableCustomer
}
export function CustomerForm({ setOpen, refreshData, customer }: CustomerFormProps) {
const [billingCompanies, setBillingCompanies] = React.useState(initialBillingCompanies.map(c => ({ value: c.toLowerCase(), label: c })))
const isEdit = !!customer
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
companyName: customer?.companyName ?? "",
billingCompany: customer?.billingCompany ?? "",
address: customer?.address ?? "",
vatNumber: customer?.vatNumber ?? "",
email: customer?.email ?? "",
phone: customer?.phone ?? "",
},
})
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
const payload = {
comapny_name: values.companyName, // Note: PocketBase field name is 'comapny_name'
billing_company: values.billingCompany, // Note: PocketBase field name is 'billing_company'
address: values.address || "",
vatNumber: values.vatNumber || "",
email: values.email || "",
phone: values.phone || "",
}
if (isEdit && customer) {
await pb.collection("customers").update(customer.id, payload)
showSuccess(`${values.companyName} updated successfully!`)
} else {
await pb.collection("customers").create(payload)
showSuccess("New customer added successfully!")
}
form.reset()
setOpen(false)
refreshData()
} catch (error) {
console.error("Error saving customer:", error)
showError(isEdit ? "Failed to update customer. Please try again." : "Failed to add customer. Please try again.")
}
}
const handleCompanyCreate = (newCompany: string) => {
const formattedCompany = {
value: newCompany.toLowerCase(),
label: newCompany,
}
setBillingCompanies(prev => [...prev, formattedCompany])
}
return (
<div className="w-full max-w-2xl mx-auto px-4 sm:px-6">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 sm:space-y-5">
<FormField
control={form.control}
name="companyName"
render={({ field }) => (
<FormItem>
<FormLabel>Company Name</FormLabel>
<FormControl>
<Input placeholder="e.g., Acme Inc." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="billingCompany"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Billing Company</FormLabel>
<FormControl>
<BillingCompanyCombobox
companies={billingCompanies}
value={field.value}
onChange={field.onChange}
onCompanyCreate={handleCompanyCreate}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address"
render={({ field }) => (
<FormItem>
<FormLabel>Address</FormLabel>
<FormControl>
<Textarea
placeholder="Street, suburb, city, postal code"
rows={2}
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription className="text-xs">
Shown on quotes and purchase documents.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
control={form.control}
name="vatNumber"
render={({ field }) => (
<FormItem>
<FormLabel>VAT Number</FormLabel>
<FormControl>
<Input placeholder="e.g., 1234567890" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="accounts@company.co.za" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone</FormLabel>
<FormControl>
<Input type="tel" placeholder="e.g., 011 234 5678" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98] transition-all">
{isEdit ? "Save Changes" : "Add Customer"}
</Button>
</form>
</Form>
</div>
)
}

View file

@ -18,10 +18,15 @@ export type Customer = {
id: string
companyName: string
billingCompany: string
address: string
vatNumber: string
email: string
phone: string
}
interface TableMeta {
onDelete?: (id: string) => void
onEdit?: (customer: Customer) => void
}
export const columns: ColumnDef<Customer>[] = [
@ -58,6 +63,22 @@ export const columns: ColumnDef<Customer>[] = [
accessorKey: "billingCompany",
header: "Billing Company",
},
{
accessorKey: "address",
header: "Address",
cell: ({ row }) => (
<span className="block max-w-[220px] truncate" title={row.original.address || undefined}>
{row.original.address || "—"}
</span>
),
},
{
accessorKey: "vatNumber",
header: "VAT Number",
cell: ({ row }) => (
<span className="whitespace-nowrap">{row.original.vatNumber || "—"}</span>
),
},
{
id: "actions",
cell: ({ row, table }: { row: Row<Customer>; table: any }) => {
@ -80,7 +101,9 @@ export const columns: ColumnDef<Customer>[] = [
Copy customer ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Edit customer</DropdownMenuItem>
<DropdownMenuItem onClick={() => meta?.onEdit?.(customer)}>
Edit customer
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={() => meta?.onDelete?.(customer.id)}

View file

@ -36,16 +36,19 @@ interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
onDelete?: (id: string) => void
onEdit?: (customer: TData) => void
}
interface TableMeta {
interface TableMeta<TData> {
onDelete?: (id: string) => void
onEdit?: (customer: TData) => void
}
export function DataTable<TData, TValue>({
columns,
data,
onDelete,
onEdit,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
@ -63,7 +66,7 @@ export function DataTable<TData, TValue>({
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
meta: { onDelete },
meta: { onDelete, onEdit },
state: {
sorting,
columnFilters,

View file

@ -2,6 +2,7 @@
import { jsPDF } from "jspdf"
import type { PurchaseOrder } from "./columns"
import { truncateToWidth } from "./pdfText"
const BRAND_BLACK = "#000000"
const ACCENT_YELLOW = "#ffc905"
@ -54,7 +55,7 @@ function renderItemsHeader(doc: jsPDF, startY: number) {
doc.setTextColor(255, 255, 255)
doc.text("#", 17, startY + 6)
doc.text("Description", 28, startY + 6)
doc.text("SKU", 100, startY + 6)
doc.text("SKU", 86, startY + 6)
doc.text("Qty", 130, startY + 6, { align: "center" })
doc.text("Unit Price", 155, startY + 6, { align: "right" })
doc.text("Total", 188, startY + 6, { align: "right" })
@ -191,7 +192,7 @@ export async function generatePurchaseOrderPdfBlob(order: PurchaseOrder): Promis
const itemTotal = orderedQuantity * unitPriceGBP
subtotal += itemTotal
const wrappedDesc = doc.splitTextToSize(item.itemName, 65)
const wrappedDesc = doc.splitTextToSize(item.itemName, 54)
const rowHeight = Math.max(10, wrappedDesc.length * 5 + 5)
const bottomLimit = ph - PAGE_BOTTOM_MARGIN
if (y + rowHeight > bottomLimit) {
@ -212,7 +213,7 @@ export async function generatePurchaseOrderPdfBlob(order: PurchaseOrder): Promis
doc.text(wrappedDesc, 28, y + 5)
doc.setTextColor(MEDIUM_GRAY)
doc.setFontSize(7)
doc.text(item.sku, 100, y + 5)
doc.text(truncateToWidth(doc, item.sku || '—', 38), 86, y + 5)
doc.setFontSize(8)
doc.setTextColor(DARK_TEXT)
doc.text(String(orderedQuantity), 130, y + 5, { align: "center" })

View file

@ -0,0 +1,36 @@
import { jsPDF } from "jspdf"
/**
* Truncates text so it fits the given width, measured with the font
* actually in use.
*
* The quote and purchase order tables both put a Description column
* next to a SKU column. Truncating by character count ("first 40
* characters") let long item names run under the SKU column and print
* on top of it, because 40 characters of 8pt Helvetica is much wider
* than the column. Measuring the real string width fixes that.
*/
export function truncateToWidth(
doc: jsPDF,
text: string,
maxWidth: number,
ellipsis = "…",
): string {
const value = String(text ?? "")
if (!value) return ""
if (doc.getTextWidth(value) <= maxWidth) return value
// Binary-search the longest prefix that still fits, then add the ellipsis.
let low = 0
let high = value.length
while (low < high) {
const mid = Math.ceil((low + high) / 2)
if (doc.getTextWidth(value.slice(0, mid) + ellipsis) <= maxWidth) {
low = mid
} else {
high = mid - 1
}
}
if (low <= 0) return ellipsis
return value.slice(0, low).trimEnd() + ellipsis
}

View file

@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { Check, ChevronsUpDown, Plus } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
export type CustomerOption = {
id: string
name: string
address?: string
vatNumber?: string
}
interface CustomerComboboxProps {
customers: CustomerOption[]
/** Currently selected customer id, or "" when nothing is picked. */
value: string
onChange: (customerId: string) => void
/** Called when the user asks to create a customer that is not in the list. */
onCreateNew: (name: string) => void
placeholder?: string
className?: string
}
/**
* Searchable customer picker.
*
* The plain <Select> this replaces listed every customer and offered no
* way to add one that was missing - with 20+ customers on the list that
* meant scrolling blind. This searches by name, address or VAT number,
* and offers to create the customer when nothing matches.
*/
export function CustomerCombobox({
customers,
value,
onChange,
onCreateNew,
placeholder = "Search customers...",
className,
}: CustomerComboboxProps) {
const [open, setOpen] = React.useState(false)
const [search, setSearch] = React.useState("")
const selected = customers.find((c) => c.id === value)
// cmdk filters on `value`; we filter ourselves so the address and VAT
// number are searchable too.
const matches = React.useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return customers
return customers.filter((c) =>
[c.name, c.address, c.vatNumber]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(q)),
)
}, [customers, search])
const canCreate =
search.trim().length > 0 &&
!customers.some((c) => c.name.toLowerCase() === search.trim().toLowerCase())
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("w-full justify-between font-normal", className)}
>
{selected ? (
<span className="truncate">{selected.name}</span>
) : (
<span className="truncate text-muted-foreground">{placeholder}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search by name, address or VAT number..."
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>
<span className="px-2 py-1.5 text-sm text-muted-foreground">
No customer matches "{search}".
</span>
</CommandEmpty>
{canCreate && (
<CommandGroup>
<CommandItem
value={`__create__${search}`}
onSelect={() => {
onCreateNew(search.trim())
setOpen(false)
setSearch("")
}}
className="gap-2"
>
<Plus className="h-4 w-4 opacity-70" />
<span>
Add <span className="font-medium">{search.trim()}</span> as a new customer
</span>
</CommandItem>
</CommandGroup>
)}
<CommandGroup>
{matches.map((customer) => (
<CommandItem
key={customer.id}
value={customer.id}
onSelect={() => {
onChange(customer.id)
setOpen(false)
setSearch("")
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === customer.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{customer.name}</div>
{customer.address && (
<div className="truncate text-xs text-muted-foreground">
{customer.address}
</div>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View file

@ -37,6 +37,7 @@ export type Quote = {
address: string
email: string
phone: string
vatNumber?: string
} | null
attachments?: string[]
notes?: string

View file

@ -14,6 +14,8 @@ import { jsPDF } from "jspdf"
import pb from "@/lib/pocketbase"
import { showSuccess } from "@/utils/toast"
import { createQuoteColumns, Quote } from "./QuoteColumns"
import { drawCustomerBlock } from "./quotePdfCustomer"
import { truncateToWidth } from "../purchase-orders/pdfText"
import { AttachmentUpload, AttachmentList } from "../purchase-orders/AttachmentUpload"
interface QuoteDataTableProps {
@ -121,6 +123,18 @@ export function QuoteDataTable({ columns: columnsProp, data, filterValue, onFilt
</DialogHeader>
<div className="space-y-4">
<p><strong>Customer:</strong> {selectedQuote?.customerName}</p>
{selectedQuote?.customer?.address && (
<p><strong>Address:</strong> {selectedQuote.customer.address}</p>
)}
{selectedQuote?.customer?.vatNumber && (
<p><strong>VAT No:</strong> {selectedQuote.customer.vatNumber}</p>
)}
{selectedQuote?.customer?.email && (
<p><strong>Email:</strong> {selectedQuote.customer.email}</p>
)}
{selectedQuote?.customer?.phone && (
<p><strong>Phone:</strong> {selectedQuote.customer.phone}</p>
)}
<p><strong>Date:</strong> {selectedQuote?.quoteDate ? new Date(selectedQuote.quoteDate).toLocaleDateString() : 'N/A'}</p>
<p><strong>Status:</strong> {selectedQuote?.status}</p>
<p><strong>Valid Until:</strong> {selectedQuote?.validUntil ? new Date(selectedQuote.validUntil).toLocaleDateString() : 'N/A'}</p>
@ -245,30 +259,10 @@ async function generateQuotePDF(quote: Quote) {
doc.text(quote.validUntil ? new Date(quote.validUntil).toLocaleDateString() : 'N/A', 193, 77, { align: "right" })
// Customer box
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 += 6
doc.setFont("helvetica", "normal")
doc.setFontSize(8)
doc.setTextColor(mediumGray)
if (quote.customer?.address) doc.text(quote.customer.address, 18, y)
let y = drawCustomerBlock(doc, quote.customer, quote.customerName, 105)
// Items table
y += 14
y += 6
doc.setFont("helvetica", "bold")
doc.setFontSize(11)
doc.setTextColor(brandBlack)
@ -285,7 +279,7 @@ async function generateQuotePDF(quote: Quote) {
doc.setTextColor(255, 255, 255)
doc.text("#", 17, y + 6)
doc.text("Description", 28, y + 6)
doc.text("SKU", 100, y + 6)
doc.text("SKU", 86, 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" })
@ -312,10 +306,10 @@ async function generateQuotePDF(quote: Quote) {
doc.setTextColor(darkText)
doc.text(String(i + 1), 17, y + 5)
doc.text(String(item.itemName || '(item)').substring(0, 40), 28, y + 5)
doc.text(truncateToWidth(doc, String(item.itemName || '(item)'), 54), 28, y + 5)
doc.setTextColor(mediumGray)
doc.setFontSize(7)
doc.text(item.sku || '—', 100, y + 5)
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" })
@ -422,30 +416,10 @@ export async function saveQuotePdfToPocketbase(quote: Quote, pdfFilename: string
doc.text(quote.validUntil ? new Date(quote.validUntil).toLocaleDateString() : 'N/A', 193, 77, { align: "right" })
// Customer box
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 += 6
doc.setFont("helvetica", "normal")
doc.setFontSize(8)
doc.setTextColor(mediumGray)
if (quote.customer?.address) doc.text(quote.customer.address, 18, y)
let y = drawCustomerBlock(doc, quote.customer, quote.customerName, 105)
// Items table
y += 14
y += 6
doc.setFont("helvetica", "bold")
doc.setFontSize(11)
doc.setTextColor(brandBlack)
@ -462,7 +436,7 @@ export async function saveQuotePdfToPocketbase(quote: Quote, pdfFilename: string
doc.setTextColor(255, 255, 255)
doc.text("#", 17, y + 6)
doc.text("Description", 28, y + 6)
doc.text("SKU", 100, y + 6)
doc.text("SKU", 86, 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" })
@ -489,10 +463,10 @@ export async function saveQuotePdfToPocketbase(quote: Quote, pdfFilename: string
doc.setTextColor(darkText)
doc.text(String(i + 1), 17, y + 5)
doc.text(String(item.itemName || '(item)').substring(0, 40), 28, y + 5)
doc.text(truncateToWidth(doc, String(item.itemName || '(item)'), 54), 28, y + 5)
doc.setTextColor(mediumGray)
doc.setFontSize(7)
doc.text(item.sku || '—', 100, y + 5)
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" })

View file

@ -11,12 +11,10 @@ import {
Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select"
import { showSuccess, showError } from "@/utils/toast"
import pb from "@/lib/pocketbase"
import { StockItemSearch } from "../purchase-orders/StockItemSearch"
import { CustomerCombobox } from "./CustomerCombobox"
import { AttachmentUpload } from "../purchase-orders/AttachmentUpload"
import { saveQuotePdfToPocketbase } from "./QuoteDataTable"
@ -36,7 +34,8 @@ const quoteItemSchema = z.object({
const quoteFormSchema = z.object({
quoteNumber: z.string(),
customerName: z.string().min(1, { message: "Customer is required." }),
// Holds the customer id, not the name.
customerId: z.string().min(1, { message: "Customer is required." }),
customerRef: z.string().optional(),
quoteDate: z.date({ required_error: "Quote date is required." }),
validUntil: z.date().optional(),
@ -44,6 +43,14 @@ const quoteFormSchema = z.object({
items: z.array(quoteItemSchema).min(1, { message: "At least one item is required." }),
})
/** Quotes are valid for 30 days from the quote date. */
const QUOTE_VALID_DAYS = 30
function defaultValidUntil(quoteDate: Date): Date {
const d = new Date(quoteDate)
d.setDate(d.getDate() + QUOTE_VALID_DAYS)
return d
}
type QuoteItemSchemaType = z.infer<typeof quoteItemSchema>
interface QuoteFormProps {
@ -51,7 +58,14 @@ interface QuoteFormProps {
refreshData: () => void
}
type Customer = { id: string; name: string }
type Customer = {
id: string;
name: string;
address?: string;
vatNumber?: string;
email?: string;
phone?: string;
};
type StockItem = { id: string; name: string; sku: string; selling_price: number }
export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) {
@ -81,7 +95,18 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) {
const fetchCustomers = useCallback(async () => {
try {
const records = await pb.collection('customers').getFullList({ sort: '-created' })
setCustomers(records.map((r: any) => ({ id: r.id, name: r.comapny_name || r.name || '' })).filter(c => c.name))
setCustomers(
records
.map((r: any) => ({
id: r.id,
name: r.comapny_name || r.name || '',
address: r.address || '',
vatNumber: r.vatNumber || '',
email: r.email || '',
phone: r.phone || '',
}))
.filter((c: Customer) => c.name),
)
} catch (err) { console.error(err) }
}, [])
@ -98,10 +123,11 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) {
resolver: zodResolver(quoteFormSchema),
defaultValues: {
quoteNumber: "",
customerName: "",
customerId: "",
customerRef: "",
quoteDate: new Date(),
validUntil: undefined,
// A quote is valid for 30 days from the quote date.
validUntil: defaultValidUntil(new Date()),
notes: "",
items: [{ entryMode: 'stock', stockItemId: "", itemName: "", quantity: 1, sku: "", unitPrice: 0, manualDescription: "", manualSku: "", manualUnitPrice: undefined }],
},
@ -111,6 +137,12 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) {
if (quoteNumber) form.setValue('quoteNumber', quoteNumber)
}, [quoteNumber, form])
// Keep "valid until" a month after the quote date whenever the date moves.
const quoteDateValue = form.watch('quoteDate')
useEffect(() => {
if (quoteDateValue) form.setValue('validUntil', defaultValidUntil(quoteDateValue))
}, [quoteDateValue, form])
const { fields, append, remove } = useFieldArray({ control: form.control, name: "items" })
// Live total. `form.watch("items")` returns the SAME array instance that
@ -141,9 +173,38 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) {
}
}
// Create a customer on the spot when the one we need is not in the list yet.
const handleCreateCustomer = async (name: string) => {
if (!name) return
try {
const created = await pb.collection('customers').create({
comapny_name: name, // Note: PocketBase field name is 'comapny_name'
billing_company: '',
address: '',
vatNumber: '',
email: '',
phone: '',
})
const newCustomer: Customer = {
id: created.id,
name,
address: '',
vatNumber: '',
email: '',
phone: '',
}
setCustomers((prev) => [newCustomer, ...prev])
form.setValue('customerId', newCustomer.id, { shouldDirty: true, shouldValidate: true })
showSuccess(`${name} added. You can add their address and VAT number from Customers.`)
} catch (err: any) {
console.error(err)
showError(err?.response?.message || "Failed to add customer.")
}
}
async function onSubmit(values: z.infer<typeof quoteFormSchema>) {
try {
const selectedCustomer = customers.find(c => c.name === values.customerName)
const selectedCustomer = customers.find(c => c.id === values.customerId)
if (!selectedCustomer) { showError("Invalid customer."); return }
// Process items: for manual entries, create stock items on-the-fly.
@ -193,7 +254,7 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) {
const quoteForPdf = {
id: created.id,
quote_number: values.quoteNumber,
customerName: customer?.name || values.customerName,
customerName: customer?.name || selectedCustomer.name,
quoteDate: values.quoteDate,
validUntil: values.validUntil,
status: 'Draft' as const,
@ -210,7 +271,15 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) {
}
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: customer.address || '',
email: customer.email || '',
phone: customer.phone || '',
vatNumber: customer.vatNumber || '',
}
: null,
notes: values.notes || '',
attachments: [] as string[],
itemCount: processedItems.length,
@ -242,17 +311,18 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) {
</FormItem>
)} />
<FormField control={form.control} name="customerName" render={({ field }) => (
<FormField control={form.control} name="customerId" render={({ field }) => (
<FormItem>
<FormLabel>Customer</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger><SelectValue placeholder="Select customer" /></SelectTrigger>
<CustomerCombobox
customers={customers}
value={field.value}
onChange={field.onChange}
onCreateNew={handleCreateCustomer}
placeholder="Search customers..."
/>
</FormControl>
<SelectContent className="z-50 max-h-[300px] overflow-y-auto">
{customers.map(c => <SelectItem key={c.id} value={c.name}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} />

View file

@ -0,0 +1,111 @@
import { jsPDF } from "jspdf"
/** Customer details printed in the "Customer Information" block. */
export type QuoteCustomer = {
name: string
address: string
email: string
phone: string
vatNumber?: string
}
const BRAND_BLACK = "#000000"
const ACCENT_YELLOW = "#ffc905"
const DARK_TEXT = "#1a1a1a"
const LIGHT_GRAY = "#f5f5f5"
const MEDIUM_GRAY = "#666666"
const LEFT = 14
const BOX_WIDTH = 182
const TEXT_LEFT = 18
const LINE_HEIGHT = 5
/** Splits a long address into lines that fit the box width. */
function wrapAddress(address: string, maxChars: number): string[] {
const words = address.replace(/\s+/g, " ").trim().split(" ")
const lines: string[] = []
let current = ""
for (const word of words) {
const candidate = current ? `${current} ${word}` : word
if (candidate.length > maxChars && current) {
lines.push(current)
current = word
} else {
current = candidate
}
}
if (current) lines.push(current)
return lines
}
/**
* Draws the "CUSTOMER INFORMATION" block and returns the y coordinate
* where the next section should start.
*
* The block grows to fit whatever the customer actually has on file, so a
* customer with a full address, VAT number and phone number gets all of
* it printed, and a customer with nothing but a name still gets a
* correctly sized box.
*/
export function drawCustomerBlock(
doc: jsPDF,
customer: QuoteCustomer | null | undefined,
customerName: string,
startY: number,
): number {
let y = startY
doc.setFont("helvetica", "bold")
doc.setFontSize(11)
doc.setTextColor(BRAND_BLACK)
doc.text("CUSTOMER INFORMATION", LEFT, y)
doc.setDrawColor(ACCENT_YELLOW)
doc.setLineWidth(1.5)
doc.line(LEFT, y + 2, LEFT + 61, y + 2)
y += 10
const name = customer?.name || customerName || "N/A"
const addressLines = customer?.address
? wrapAddress(customer.address, 62)
: []
const vatLine = customer?.vatNumber ? `VAT No: ${customer.vatNumber}` : ""
const contactLine = [customer?.email, customer?.phone].filter(Boolean).join(" | ")
// The box is sized to the content: one line per address line plus the
// name, and the VAT / contact lines when present.
const contentLines =
1 + // name
addressLines.length + // address
(vatLine ? 1 : 0) +
(contactLine ? 1 : 0)
const boxHeight = 6 + contentLines * LINE_HEIGHT + 2
doc.setFillColor(LIGHT_GRAY)
doc.roundedRect(LEFT, y, BOX_WIDTH, boxHeight, 2, 2, 'F')
y += 6
doc.setFont("helvetica", "bold")
doc.setFontSize(10)
doc.setTextColor(DARK_TEXT)
doc.text(name, TEXT_LEFT, y)
y += LINE_HEIGHT
doc.setFont("helvetica", "normal")
doc.setFontSize(8)
doc.setTextColor(MEDIUM_GRAY)
for (const line of addressLines) {
doc.text(line, TEXT_LEFT, y)
y += LINE_HEIGHT
}
if (vatLine) {
doc.text(vatLine, TEXT_LEFT, y)
y += LINE_HEIGHT
}
if (contactLine) {
doc.text(contactLine, TEXT_LEFT, y)
y += LINE_HEIGHT
}
// Leave a clear gap before the next section.
return y + 8
}

View file

@ -0,0 +1,15 @@
/** Quotes are valid for 30 days from the quote date. */
export const QUOTE_VALID_DAYS = 30
/**
* The date a quote stops being valid: 30 days after the quote date.
*
* Used when a quote is created and as the fallback for quotes written
* before the form defaulted this, so every quote shows a valid-until date.
*/
export function defaultValidUntil(quoteDate: string | Date): string {
const d = new Date(quoteDate)
if (Number.isNaN(d.getTime())) return ""
d.setDate(d.getDate() + QUOTE_VALID_DAYS)
return d.toISOString()
}

View file

@ -11,11 +11,12 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { AddCustomerForm } from "../components/customers/AddCustomerForm"
import { CustomerForm } from "../components/customers/CustomerForm"
import pb from "@/lib/pocketbase"
const Customers = () => {
const [addCustomerOpen, setAddCustomerOpen] = useState(false);
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [customers, setCustomers] = useState<Customer[]>([]);
const [refreshTrigger, setRefreshTrigger] = useState(0); // State to trigger data refresh
@ -28,6 +29,10 @@ const Customers = () => {
id: item.id,
companyName: item.comapny_name, // Note: PocketBase field name is 'comapny_name'
billingCompany: item.billing_company, // Note: PocketBase field name is 'billing_company'
address: item.address || '',
vatNumber: item.vatNumber || '',
email: item.email || '',
phone: item.phone || '',
}));
setCustomers(formattedData);
} catch (error) {
@ -43,6 +48,10 @@ const Customers = () => {
setRefreshTrigger(prev => prev + 1); // Increment to trigger useEffect
};
const handleEditCustomer = (customer: Customer) => {
setEditingCustomer(customer);
};
const handleDeleteCustomer = async (id: string) => {
if (!confirm("Are you sure you want to delete this customer?")) return;
try {
@ -68,20 +77,47 @@ const Customers = () => {
Add New Customer
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogContent className="sm:max-w-[425px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Add New Customer</DialogTitle>
<DialogDescription>
Fill in the details below to add a new customer.
</DialogDescription>
</DialogHeader>
<AddCustomerForm setOpen={setAddCustomerOpen} refreshData={handleRefreshData} />
<CustomerForm setOpen={setAddCustomerOpen} refreshData={handleRefreshData} />
</DialogContent>
</Dialog>
</div>
<div className="w-full overflow-hidden">
<DataTable columns={columns} data={customers} onDelete={handleDeleteCustomer} />
<DataTable
columns={columns}
data={customers}
onDelete={handleDeleteCustomer}
onEdit={handleEditCustomer}
/>
</div>
<Dialog
open={!!editingCustomer}
onOpenChange={(open) => !open && setEditingCustomer(null)}
>
<DialogContent className="sm:max-w-[425px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Customer</DialogTitle>
<DialogDescription>
Update {editingCustomer?.companyName || "this customer"}.
</DialogDescription>
</DialogHeader>
{editingCustomer && (
<CustomerForm
key={editingCustomer.id}
customer={editingCustomer}
setOpen={(open) => !open && setEditingCustomer(null)}
refreshData={handleRefreshData}
/>
)}
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -1,11 +1,11 @@
import { LogsView } from "@/components/LogsView";
import { Navigate } from "react-router-dom";
/**
* Logs moved into Settings > Logs. This route redirects so any existing
* link or bookmark to /logs still lands on the right screen.
*/
const Logs = () => {
return (
<div className="container mx-auto py-6">
<LogsView />
</div>
);
return <Navigate to="/settings?tab=logs" replace />;
};
export default Logs;

View file

@ -9,6 +9,7 @@ import {
import { createQuoteColumns, Quote } from "@/components/quotes/QuoteColumns"
import { QuoteDataTable } from "@/components/quotes/QuoteDataTable"
import { QuoteForm } from "@/components/quotes/QuoteForm"
import { defaultValidUntil } from "@/components/quotes/quoteValidity"
import pb from "@/lib/pocketbase"
const Quotes = () => {
@ -46,7 +47,10 @@ const Quotes = () => {
customerName: record.expand?.customerName?.comapny_name || record.expand?.customerName?.customer || record.customerName || '',
customerRef: record.customerRef || '',
quoteDate: record.quoteDate || record.created,
validUntil: record.validUntil || '',
// Older quotes were saved without a valid-until date. Fall back to
// the same 30-day window the form now uses, so every quote shows
// a valid-until date.
validUntil: record.validUntil || defaultValidUntil(record.quoteDate || record.created),
status: record.status || 'Draft',
itemCount,
quantities: qtyArray,
@ -54,9 +58,10 @@ const Quotes = () => {
items,
customer: record.expand?.customerName ? {
name: record.expand.customerName.comapny_name || record.expand.customerName.customer || '',
address: '',
address: record.expand.customerName.address || '',
email: record.expand.customerName.email || '',
phone: '',
phone: record.expand.customerName.phone || '',
vatNumber: record.expand.customerName.vatNumber || '',
} : null,
attachments: record.attachments || [],
notes: record.notes || '',

View file

@ -1,15 +1,38 @@
import { useSearchParams } from "react-router-dom";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { UserManagement } from "@/components/settings/UserManagement";
import { LogsView } from "@/components/LogsView";
const Settings = () => {
const [searchParams, setSearchParams] = useSearchParams();
// ?tab=logs opens straight to the logs tab (used by the old /logs link).
const tab = searchParams.get("tab") === "logs" ? "logs" : "users";
const handleTabChange = (value: string) => {
setSearchParams(value === "logs" ? { tab: "logs" } : {}, { replace: true });
};
return (
<div className="container mx-auto py-4 md:py-6 max-w-3xl">
<div className="container mx-auto py-4 md:py-6 max-w-5xl">
<div className="mb-6">
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
<p className="text-sm text-muted-foreground">
Manage who can access this app.
</p>
</div>
<Tabs value={tab} onValueChange={handleTabChange}>
<TabsList className="mb-6">
<TabsTrigger value="users">Users</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
</TabsList>
<TabsContent value="users">
<UserManagement />
</TabsContent>
<TabsContent value="logs">
<LogsView />
</TabsContent>
</Tabs>
</div>
);
};