Adds a Settings > Users screen where members can be added, turned off
and removed, and fixes three defects found while auditing the app on
a phone.
User management
- Settings is a real page with a Users section: add a member (name,
email, password), flip "Can sign in" to block or restore access
without deleting their history, or remove them outright.
- The app has no roles - membership is just the active flag - so the
Settings route no longer requires the "admin" role, which left it
unreachable for every real member. Settings is now in the sidebar.
- New members are created with active: true. AuthContext refuses to
sign in a user whose active flag is false, and the field had no
default, so previously created users were locked out of the app.
Live PocketBase rules for the `users` collection (see
scripts/fix-users-collection.mjs):
- createRule was "" which means PUBLIC - any anonymous visitor could
create an account on the instance. It now requires an authenticated
member.
- listRule was "id = @request.auth.id", so a member could only ever
see their own record and no member list could be rendered. It now
matches every other collection in the app: @request.auth.id != "".
Totals always showed R0.00
- form.watch("items") returns the same array instance that
react-hook-form mutates in place, so the useMemo keyed on it never
recomputed. Both the quote and purchase order totals stayed at zero
however many items were added. Switched to useWatch with compute,
which re-runs whenever the watched field really changes.
Mobile layout
- The page was zoomed out on phones: flex items default to
min-width:auto, so a wide table stretched the layout past the
viewport. Added min-w-0 to the layout chain.
- The sr-only labels inside the tables are position:absolute with no
positioned ancestor, so they escaped the table's overflow-x-auto
clip and stretched the document. Made the scroll containers
relative.
- DialogContent was w-full plus an mx-4 gutter, which overflowed the
viewport by 16px and clipped the right edge of every form. The
component now owns the gutter itself.
Verified at 390x844: all pages and the quote, purchase order and add
user dialogs fit the viewport with no horizontal overflow.
561 lines
No EOL
21 KiB
TypeScript
561 lines
No EOL
21 KiB
TypeScript
"use client"
|
||
|
||
import * as React from "react"
|
||
import {
|
||
ColumnDef, SortingState, ColumnFiltersState, VisibilityState,
|
||
flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel,
|
||
useReactTable,
|
||
} from "@tanstack/react-table"
|
||
import { Columns, Download, Eye } from "lucide-react"
|
||
import { Button } from "@/components/ui/button"
|
||
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||
import { jsPDF } from "jspdf"
|
||
import pb from "@/lib/pocketbase"
|
||
import { showSuccess } from "@/utils/toast"
|
||
import { createQuoteColumns, Quote } from "./QuoteColumns"
|
||
import { AttachmentUpload, AttachmentList } from "../purchase-orders/AttachmentUpload"
|
||
|
||
interface QuoteDataTableProps {
|
||
columns: ColumnDef<Quote, any>[]
|
||
data: Quote[]
|
||
filterValue: string
|
||
onFilterChange: (value: string) => void
|
||
refreshData: () => void
|
||
}
|
||
|
||
export function QuoteDataTable({ columns: columnsProp, data, filterValue, onFilterChange, refreshData }: QuoteDataTableProps) {
|
||
const [sorting, setSorting] = React.useState<SortingState>([])
|
||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
|
||
const [viewDialogOpen, setViewDialogOpen] = React.useState(false)
|
||
const [selectedQuote, setSelectedQuote] = React.useState<Quote | null>(null)
|
||
const [attachmentsDialogOpen, setAttachmentsDialogOpen] = React.useState(false)
|
||
const [selectedQuoteForAttachments, setSelectedQuoteForAttachments] = React.useState<Quote | null>(null)
|
||
|
||
const handleViewQuote = (quote: Quote) => { setSelectedQuote(quote); setViewDialogOpen(true) }
|
||
const openAttachmentsDialog = (quote: Quote) => { setSelectedQuoteForAttachments(quote); setAttachmentsDialogOpen(true) }
|
||
const handleDownloadPdf = React.useCallback((quote: Quote) => { void generateQuotePDF(quote) }, [])
|
||
const handleRegeneratePdf = React.useCallback(async (quote: Quote) => {
|
||
const filename = await generateQuotePDF(quote)
|
||
if (filename) await saveQuotePdfToPocketbase(quote, filename)
|
||
}, [])
|
||
|
||
const columnsWithActions = React.useMemo(
|
||
() => createQuoteColumns({
|
||
onDownloadPdf: handleDownloadPdf,
|
||
onViewQuote: handleViewQuote,
|
||
onManageAttachments: openAttachmentsDialog,
|
||
onRegeneratePdf: handleRegeneratePdf,
|
||
}),
|
||
[handleDownloadPdf, handleRegeneratePdf]
|
||
)
|
||
|
||
const table = useReactTable<Quote>({
|
||
data,
|
||
columns: columnsWithActions,
|
||
getCoreRowModel: getCoreRowModel(),
|
||
getPaginationRowModel: getPaginationRowModel(),
|
||
onSortingChange: setSorting,
|
||
getSortedRowModel: getSortedRowModel(),
|
||
onColumnFiltersChange: setColumnFilters,
|
||
getFilteredRowModel: getFilteredRowModel(),
|
||
onColumnVisibilityChange: setColumnVisibility,
|
||
state: { sorting, columnFilters, columnVisibility },
|
||
})
|
||
|
||
React.useEffect(() => { table.getColumn("customerName")?.setFilterValue(filterValue) }, [filterValue, table])
|
||
|
||
return (
|
||
<>
|
||
<div className="flex flex-col sm:flex-row gap-3 py-4">
|
||
<input type="text" placeholder="Filter by customer..." value={filterValue}
|
||
onChange={e => onFilterChange(e.target.value)} className="w-full sm:max-w-[280px] border border-input rounded-md px-3 py-2 text-sm" />
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="outline" size="icon" className="shrink-0"><Columns className="h-4 w-4" /></Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-48">
|
||
{table.getAllColumns().filter(c => c.getCanHide()).map(col => (
|
||
<DropdownMenuCheckboxItem key={col.id} checked={col.getIsVisible()} onCheckedChange={v => col.toggleVisibility(!!v)} className="capitalize">{col.id}</DropdownMenuCheckboxItem>
|
||
))}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
{/* relative: the sr-only labels inside the table are position:absolute.
|
||
Without a positioned ancestor they escape this scroll container and
|
||
stretch the whole page, which makes Chrome zoom out on phones. */}
|
||
<div className="relative rounded-md border bg-background overflow-x-auto">
|
||
<table className="w-full min-w-full table-auto">
|
||
<thead>
|
||
{table.getHeaderGroups().map(hg => (
|
||
<tr key={hg.id}>
|
||
{hg.headers.map(h => (
|
||
<th key={h.id} className="border-b px-3 py-2.5 text-left font-medium text-foreground text-xs sm:text-sm whitespace-nowrap">
|
||
{h.isPlaceholder ? null : flexRender(h.column.columnDef.header, h.getContext())}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</thead>
|
||
<tbody>
|
||
{table.getRowModel().rows?.length ? table.getRowModel().rows.map(row => (
|
||
<tr key={row.id} className="border-b hover:bg-muted/50">
|
||
{row.getVisibleCells().map(cell => (
|
||
<td key={cell.id} className="px-3 py-2.5 text-xs sm:text-sm whitespace-nowrap">{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
|
||
))}
|
||
</tr>
|
||
)) : (
|
||
<tr><td colSpan={99} className="h-24 text-center">No results.</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* View Dialog */}
|
||
<Dialog open={viewDialogOpen} onOpenChange={setViewDialogOpen}>
|
||
<DialogContent className="sm:max-w-xl md:max-w-2xl">
|
||
<DialogHeader className="pb-2">
|
||
<DialogTitle className="text-lg sm:text-xl">Quote Details</DialogTitle>
|
||
<DialogDescription className="text-xs sm:text-sm">{selectedQuote?.quote_number}</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<p><strong>Customer:</strong> {selectedQuote?.customerName}</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>
|
||
<h4 className="font-semibold">Items:</h4>
|
||
<ul className="list-disc list-inside">
|
||
{(selectedQuote?.items || []).map((item, i) => (
|
||
<li key={item.stockItemId || i}>{item.itemName || item.sku || '(item)'} (SKU: {item.sku || '—'}) — Qty: {selectedQuote.quantities[i] ?? 0} × R{(selectedQuote.unitPrices[i] ?? item.sellingPrice ?? 0).toFixed(2)}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
<div className="flex justify-end pt-4"><Button variant="outline" onClick={() => setViewDialogOpen(false)}>Close</Button></div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{selectedQuoteForAttachments && (
|
||
<QuoteAttachmentsDialog
|
||
open={attachmentsDialogOpen}
|
||
onOpenChange={setAttachmentsDialogOpen}
|
||
quote={selectedQuoteForAttachments}
|
||
refreshData={refreshData}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function QuoteAttachmentsDialog({ open, onOpenChange, quote, refreshData }: {
|
||
open: boolean; onOpenChange: (open: boolean) => void; quote: Quote; refreshData: () => void
|
||
}) {
|
||
const [files, setFiles] = React.useState<File[]>([])
|
||
const [existingFiles, setExistingFiles] = React.useState<string[]>(quote.attachments || [])
|
||
const [saving, setSaving] = React.useState(false)
|
||
|
||
React.useEffect(() => { setExistingFiles(quote.attachments || []) }, [quote.attachments])
|
||
|
||
const handleDelete = async (filename: string) => {
|
||
const updated = existingFiles.filter(f => f !== filename)
|
||
await pb.collection('quotes').update(quote.id, { attachments: updated })
|
||
setExistingFiles(updated)
|
||
refreshData()
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true)
|
||
try {
|
||
const fd = new FormData()
|
||
existingFiles.forEach(f => fd.append('attachments', f))
|
||
files.forEach(f => fd.append('attachments', f))
|
||
await pb.collection('quotes').update(quote.id, fd)
|
||
showSuccess("Attachments saved")
|
||
refreshData()
|
||
onOpenChange(false)
|
||
} catch (err) { console.error(err) }
|
||
finally { setSaving(false) }
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="sm:max-w-xl md:max-w-2xl">
|
||
<DialogHeader className="pb-2">
|
||
<DialogTitle className="text-lg sm:text-xl">Quote Attachments</DialogTitle>
|
||
<DialogDescription className="text-xs sm:text-sm">{quote.quote_number}</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<AttachmentList files={existingFiles} orderId={quote.id} onDelete={handleDelete} />
|
||
<AttachmentUpload orderId={quote.id} existingFiles={existingFiles} onFilesChange={setFiles} />
|
||
<div className="flex justify-end gap-2 pt-4">
|
||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||
<Button onClick={handleSave} disabled={saving}>{saving ? "Saving..." : "Save"}</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
// ─── PDF GENERATION ─────────────────────────────────────────────────────────────
|
||
|
||
async function generateQuotePDF(quote: Quote) {
|
||
try {
|
||
const doc = new jsPDF()
|
||
const brandBlack = "#000000"
|
||
const accentYellow = "#ffc905"
|
||
const darkText = "#1a1a1a"
|
||
const lightGray = "#f5f5f5"
|
||
const mediumGray = "#666666"
|
||
|
||
// Header
|
||
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)
|
||
|
||
// Quote details box
|
||
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 || quote.id, 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 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)
|
||
|
||
// Items table
|
||
y += 14
|
||
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
|
||
|
||
// Header row
|
||
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
|
||
// quantities is the source of truth — old quotes have empty items arrays
|
||
// because the previous schema truncated them, but quantities/unitPrices are intact.
|
||
const lineCount = Math.max(quote.quantities?.length ?? 0, quote.items?.length ?? 0, quote.unitPrices?.length ?? 0)
|
||
for (let i = 0; i < lineCount; i++) {
|
||
const item = quote.items?.[i] || { itemName: '(item)', sku: '', sellingPrice: 0 }
|
||
const qty = quote.quantities?.[i] || 0
|
||
const price = quote.unitPrices?.[i] || item.sellingPrice || 0
|
||
const total = qty * price
|
||
subtotal += total
|
||
|
||
const rowH = 10
|
||
if (i % 2 === 0) {
|
||
doc.setFillColor(250, 250, 250)
|
||
doc.rect(14, y, 182, rowH, 'F')
|
||
}
|
||
|
||
doc.setTextColor(darkText)
|
||
doc.text(String(i + 1), 17, y + 5)
|
||
doc.text(String(item.itemName || '(item)').substring(0, 40), 28, y + 5)
|
||
doc.setTextColor(mediumGray)
|
||
doc.setFontSize(7)
|
||
doc.text(item.sku || '—', 100, 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${total.toFixed(2)}`, 188, y + 5, { align: "right" })
|
||
y += rowH
|
||
}
|
||
|
||
// Totals
|
||
const vatRate = 0.15
|
||
const vatAmount = subtotal * vatRate
|
||
const totalWithVat = subtotal + vatAmount
|
||
|
||
y += 5
|
||
doc.setFillColor(lightGray)
|
||
doc.roundedRect(130, y, 66, 30, 2, 2, 'F')
|
||
y += 7
|
||
doc.setFont("helvetica", "normal")
|
||
doc.setFontSize(9)
|
||
doc.setTextColor(mediumGray)
|
||
doc.text("Subtotal:", 135, y)
|
||
doc.setTextColor(darkText)
|
||
doc.text(`R${subtotal.toFixed(2)}`, 188, y, { align: "right" })
|
||
y += 8
|
||
doc.setTextColor(mediumGray)
|
||
doc.text("Tax (VAT 15%):", 135, y)
|
||
doc.setTextColor(darkText)
|
||
doc.text(`R${vatAmount.toFixed(2)}`, 188, y, { align: "right" })
|
||
y += 8
|
||
doc.setFillColor(brandBlack)
|
||
doc.roundedRect(130, y - 3, 66, 10, 2, 2, 'F')
|
||
doc.setFont("helvetica", "bold")
|
||
doc.setFontSize(11)
|
||
doc.setTextColor(255, 255, 255)
|
||
doc.text("TOTAL:", 135, y + 4)
|
||
doc.text(`R${totalWithVat.toFixed(2)}`, 188, y + 4, { align: "right" })
|
||
|
||
// Notes
|
||
if (quote.notes) {
|
||
y += 20
|
||
doc.setFont("helvetica", "bold")
|
||
doc.setFontSize(10)
|
||
doc.setTextColor(brandBlack)
|
||
doc.text("Notes:", 14, y)
|
||
y += 5
|
||
doc.setFont("helvetica", "normal")
|
||
doc.setFontSize(8)
|
||
doc.setTextColor(mediumGray)
|
||
doc.text(quote.notes, 14, y)
|
||
}
|
||
|
||
doc.save(`QT_${(quote.quote_number || quote.id).replace(/[^A-Za-z0-9_-]/g, "_")}.pdf`)
|
||
|
||
// Return the generated filename for upload
|
||
return `QT_${(quote.quote_number || quote.id).replace(/[^A-Za-z0-9_-]/g, "_")}.pdf`
|
||
} catch (err) {
|
||
console.error("PDF error:", err)
|
||
return null
|
||
}
|
||
}
|
||
|
||
// Separate function to save PDF to PocketBase
|
||
export async function saveQuotePdfToPocketbase(quote: Quote, pdfFilename: string): Promise<string | null> {
|
||
try {
|
||
const doc = new jsPDF()
|
||
const brandBlack = "#000000"
|
||
const accentYellow = "#ffc905"
|
||
const darkText = "#1a1a1a"
|
||
const lightGray = "#f5f5f5"
|
||
const mediumGray = "#666666"
|
||
|
||
// Header
|
||
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)
|
||
|
||
// Quote details box
|
||
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 || quote.id, 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 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)
|
||
|
||
// Items table
|
||
y += 14
|
||
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
|
||
|
||
// Header row
|
||
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
|
||
// quantities is the source of truth — old quotes have empty items arrays
|
||
// because the previous schema truncated them, but quantities/unitPrices are intact.
|
||
const lineCount = Math.max(quote.quantities?.length ?? 0, quote.items?.length ?? 0, quote.unitPrices?.length ?? 0)
|
||
for (let i = 0; i < lineCount; i++) {
|
||
const item = quote.items?.[i] || { itemName: '(item)', sku: '', sellingPrice: 0 }
|
||
const qty = quote.quantities?.[i] || 0
|
||
const price = quote.unitPrices?.[i] || item.sellingPrice || 0
|
||
const total = qty * price
|
||
subtotal += total
|
||
|
||
const rowH = 10
|
||
if (i % 2 === 0) {
|
||
doc.setFillColor(250, 250, 250)
|
||
doc.rect(14, y, 182, rowH, 'F')
|
||
}
|
||
|
||
doc.setTextColor(darkText)
|
||
doc.text(String(i + 1), 17, y + 5)
|
||
doc.text(String(item.itemName || '(item)').substring(0, 40), 28, y + 5)
|
||
doc.setTextColor(mediumGray)
|
||
doc.setFontSize(7)
|
||
doc.text(item.sku || '—', 100, 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${total.toFixed(2)}`, 188, y + 5, { align: "right" })
|
||
y += rowH
|
||
}
|
||
|
||
// Totals
|
||
const vatRate = 0.15
|
||
const vatAmount = subtotal * vatRate
|
||
const totalWithVat = subtotal + vatAmount
|
||
|
||
y += 5
|
||
doc.setFillColor(lightGray)
|
||
doc.roundedRect(130, y, 66, 30, 2, 2, 'F')
|
||
y += 7
|
||
doc.setFont("helvetica", "normal")
|
||
doc.setFontSize(9)
|
||
doc.setTextColor(mediumGray)
|
||
doc.text("Subtotal:", 135, y)
|
||
doc.setTextColor(darkText)
|
||
doc.text(`R${subtotal.toFixed(2)}`, 188, y, { align: "right" })
|
||
y += 8
|
||
doc.setTextColor(mediumGray)
|
||
doc.text("Tax (VAT 15%):", 135, y)
|
||
doc.setTextColor(darkText)
|
||
doc.text(`R${vatAmount.toFixed(2)}`, 188, y, { align: "right" })
|
||
y += 8
|
||
doc.setFillColor(brandBlack)
|
||
doc.roundedRect(130, y - 3, 66, 10, 2, 2, 'F')
|
||
doc.setFont("helvetica", "bold")
|
||
doc.setFontSize(11)
|
||
doc.setTextColor(255, 255, 255)
|
||
doc.text("TOTAL:", 135, y + 4)
|
||
doc.text(`R${totalWithVat.toFixed(2)}`, 188, y + 4, { align: "right" })
|
||
|
||
// Notes
|
||
if (quote.notes) {
|
||
y += 20
|
||
doc.setFont("helvetica", "bold")
|
||
doc.setFontSize(10)
|
||
doc.setTextColor(brandBlack)
|
||
doc.text("Notes:", 14, y)
|
||
y += 5
|
||
doc.setFont("helvetica", "normal")
|
||
doc.setFontSize(8)
|
||
doc.setTextColor(mediumGray)
|
||
doc.text(quote.notes, 14, y)
|
||
}
|
||
|
||
// Get PDF as blob
|
||
const pdfBlob = doc.output('blob')
|
||
|
||
// Upload to PocketBase using existing attachments field
|
||
const formData = new FormData()
|
||
formData.append('attachments', pdfBlob, pdfFilename)
|
||
|
||
await pb.collection('quotes').update(quote.id, formData)
|
||
|
||
return pdfFilename
|
||
} catch (err) {
|
||
console.error("PDF upload error:", err)
|
||
return null
|
||
}
|
||
} |