Customer: {selectedQuote?.customerName}
+ {selectedQuote?.customer?.address && (
+
Address: {selectedQuote.customer.address}
+ )}
+ {selectedQuote?.customer?.vatNumber && (
+
VAT No: {selectedQuote.customer.vatNumber}
+ )}
+ {selectedQuote?.customer?.email && (
+
Email: {selectedQuote.customer.email}
+ )}
+ {selectedQuote?.customer?.phone && (
+
Phone: {selectedQuote.customer.phone}
+ )}
Date: {selectedQuote?.quoteDate ? new Date(selectedQuote.quoteDate).toLocaleDateString() : 'N/A'}
Status: {selectedQuote?.status}
Valid Until: {selectedQuote?.validUntil ? new Date(selectedQuote.validUntil).toLocaleDateString() : 'N/A'}
@@ -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" })
diff --git a/src/components/quotes/QuoteForm.tsx b/src/components/quotes/QuoteForm.tsx
index f8cd9d8..e0d5ef5 100644
--- a/src/components/quotes/QuoteForm.tsx
+++ b/src/components/quotes/QuoteForm.tsx
@@ -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
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) {
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) {
)} />
- (
+ (
Customer
-
+
+
+
)} />
diff --git a/src/components/quotes/quotePdfCustomer.ts b/src/components/quotes/quotePdfCustomer.ts
new file mode 100644
index 0000000..3533583
--- /dev/null
+++ b/src/components/quotes/quotePdfCustomer.ts
@@ -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
+}
diff --git a/src/components/quotes/quoteValidity.ts b/src/components/quotes/quoteValidity.ts
new file mode 100644
index 0000000..84a2ede
--- /dev/null
+++ b/src/components/quotes/quoteValidity.ts
@@ -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()
+}
diff --git a/src/pages/Customers.tsx b/src/pages/Customers.tsx
index ed96c7d..6246cf0 100644
--- a/src/pages/Customers.tsx
+++ b/src/pages/Customers.tsx
@@ -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(null);
const [customers, setCustomers] = useState([]);
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
-
+
Add New Customer
Fill in the details below to add a new customer.
-
+