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:
parent
edc9f3da74
commit
99e9d947cf
23 changed files with 1233 additions and 217 deletions
|
|
@ -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);
|
||||
});
|
||||
69
scripts/add-customer-fields.mjs
Normal file
69
scripts/add-customer-fields.mjs
Normal 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.");
|
||||
64
scripts/backfill-quote-validity.mjs
Normal file
64
scripts/backfill-quote-validity.mjs
Normal 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.");
|
||||
221
scripts/preview-quote-pdf.mjs
Normal file
221
scripts/preview-quote-pdf.mjs
Normal 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)"}`);
|
||||
|
|
@ -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>
|
||||
} />
|
||||
|
|
|
|||
|
|
@ -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,13 +55,16 @@ 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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<SettingsCornerButton className="p-2 rounded-md hover:bg-slate-100" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SidebarNav onLinkClick={() => setMobileMenuOpen(false)} />
|
||||
</div>
|
||||
|
|
@ -59,9 +81,12 @@ 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">
|
||||
<Box className="h-8 w-8 text-slate-700 mr-2" />
|
||||
<h1 className="text-xl font-bold text-foreground">JEI Stock Take</h1>
|
||||
<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>
|
||||
|
|
@ -72,4 +97,4 @@ const Sidebar = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
export default Sidebar;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}) {
|
||||
return <CustomerForm setOpen={setOpen} refreshData={refreshData} />;
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
212
src/components/customers/CustomerForm.tsx
Normal file
212
src/components/customers/CustomerForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
|
|
|||
36
src/components/purchase-orders/pdfText.ts
Normal file
36
src/components/purchase-orders/pdfText.ts
Normal 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
|
||||
}
|
||||
158
src/components/quotes/CustomerCombobox.tsx
Normal file
158
src/components/quotes/CustomerCombobox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ export type Quote = {
|
|||
address: string
|
||||
email: string
|
||||
phone: string
|
||||
vatNumber?: string
|
||||
} | null
|
||||
attachments?: string[]
|
||||
notes?: string
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</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>
|
||||
<FormControl>
|
||||
<CustomerCombobox
|
||||
customers={customers}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onCreateNew={handleCreateCustomer}
|
||||
placeholder="Search customers..."
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
|
|
|||
111
src/components/quotes/quotePdfCustomer.ts
Normal file
111
src/components/quotes/quotePdfCustomer.ts
Normal 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
|
||||
}
|
||||
15
src/components/quotes/quoteValidity.ts
Normal file
15
src/components/quotes/quoteValidity.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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,22 +77,49 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
export default Customers;
|
||||
export default Customers;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<UserManagement />
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue