feat(quotes): delete quotes from the app, add customer inline with full details

Two things that meant going into PocketBase by hand or jumping between
screens.

Deleting a quote
- Quotes had no delete action - a test quote could only be removed
  through the PocketBase admin UI. Purchase orders already soft delete
  by flipping a `deleted` flag, so quotes now do the same: the Delete
  action confirms first, then hides the quote from the list while the
  record and its PDF stay in PocketBase for audit.
- The quotes list filters out soft-deleted quotes, so a deleted quote
  is out of the way but recoverable by clearing the flag.

Adding a customer while writing a quote
- Typing a customer name that was not in the list used to create a
  name-only stub, which then had to be edited from the Customers screen
  before the quote could show an address or VAT number.
- The picker now opens the full customer form right there - address,
  VAT number, email and phone - with the name pre-filled. The new
  customer is created, added to the list and selected, so the quote
  carries on and the PDF has the details.
This commit is contained in:
Dave 2026-10-21 05:09:11 +02:00
parent 99e9d947cf
commit 9d2ba5a430
7 changed files with 302 additions and 36 deletions

View file

@ -0,0 +1,50 @@
/// <reference path="../pb_data/types.d.ts" />
// =========================================================================
// quotes — add a `deleted` flag for soft delete
// =========================================================================
//
// Purchase orders already soft delete by flipping a `deleted` boolean:
// the record and its PDF stay in PocketBase for audit, but the list
// hides it. Quotes had no such flag, so a test quote could only be
// removed by opening the PocketBase admin UI by hand.
//
// This adds the same flag to `quotes` so the quote list can hide a
// deleted quote the same way, and gives the app a Delete action.
//
// Existing quotes keep deleted = false (the field default), so nothing
// disappears from the list.
//
// NOTE: this migration is applied directly to the live PB instance by
// scripts/add-quote-deleted-flag.mjs.
// =========================================================================
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_134296766"); // quotes
if (!collection) {
throw new Error("quotes collection not found — aborting.");
}
if (!collection.fields.some((f) => f.name === "deleted")) {
collection.fields.push({
hidden: false,
id: "bool_quote_deleted",
name: "deleted",
presentable: false,
required: false,
system: false,
type: "bool",
});
}
return app.save(collection);
}, (app) => {
// Rollback: drop the `deleted` field we added.
const collection = app.findCollectionByNameOrId("pbc_134296766"); // quotes
if (!collection) return;
collection.fields = collection.fields.filter((f) => f.name !== "deleted");
return app.save(collection);
});

View file

@ -0,0 +1,58 @@
#!/usr/bin/env node
/**
* Add the `deleted` flag to the `quotes` collection.
*
* Purchase orders already soft delete this way - the record and its PDF
* stay in PocketBase for audit, but the list hides it. Quotes had no
* such flag, so a test quote could only be removed through the
* PocketBase admin UI by hand.
*
* Usage:
* node scripts/add-quote-deleted-flag.mjs # dry run (default)
* node scripts/add-quote-deleted-flag.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 pb = new PocketBase(PB_URL);
pb.autoCancellation(false);
await pb.admins.authWithPassword(PB_EMAIL, PB_PASSWORD);
const collection = await pb.collections.getOne("quotes");
const hasField = collection.fields.some((f) => f.name === "deleted");
console.log("=== BEFORE ===");
console.log(` fields: ${collection.fields.map((f) => f.name).join(", ")}`);
console.log(` deleted flag: ${hasField ? "already present" : "MISSING - will be added"}`);
if (!APPLY) {
console.log("\nDry run. Re-run with --apply to write the field to the instance.");
process.exit(0);
}
if (!hasField) {
collection.fields.push({
hidden: false,
id: "bool_quote_deleted",
name: "deleted",
presentable: false,
required: false,
system: false,
type: "bool",
});
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. Existing quotes keep deleted = false - nothing disappears.");

View file

@ -51,16 +51,23 @@ interface CustomerFormProps {
refreshData: () => void
/** When present the form edits this customer instead of creating a new one. */
customer?: EditableCustomer
/** Pre-fills the company name when creating a new customer. */
initialCompanyName?: string
/**
* Called after a customer is created or updated, with the saved record.
* Lets a caller (e.g. the quote form) select the customer straight away.
*/
onSaved?: (customer: EditableCustomer) => void
}
export function CustomerForm({ setOpen, refreshData, customer }: CustomerFormProps) {
export function CustomerForm({ setOpen, refreshData, customer, initialCompanyName, onSaved }: 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 ?? "",
companyName: customer?.companyName ?? initialCompanyName ?? "",
billingCompany: customer?.billingCompany ?? "",
address: customer?.address ?? "",
vatNumber: customer?.vatNumber ?? "",
@ -83,9 +90,27 @@ export function CustomerForm({ setOpen, refreshData, customer }: CustomerFormPro
if (isEdit && customer) {
await pb.collection("customers").update(customer.id, payload)
showSuccess(`${values.companyName} updated successfully!`)
onSaved?.({
id: customer.id,
companyName: values.companyName,
billingCompany: values.billingCompany,
address: values.address || "",
vatNumber: values.vatNumber || "",
email: values.email || "",
phone: values.phone || "",
})
} else {
await pb.collection("customers").create(payload)
const created = await pb.collection("customers").create(payload)
showSuccess("New customer added successfully!")
onSaved?.({
id: created.id,
companyName: values.companyName,
billingCompany: values.billingCompany,
address: values.address || "",
vatNumber: values.vatNumber || "",
email: values.email || "",
phone: values.phone || "",
})
}
form.reset()

View file

@ -1,7 +1,7 @@
"use client"
import { ColumnDef } from "@tanstack/react-table"
import { MoreHorizontal, Download, Eye, Pencil, FileText } from "lucide-react"
import { MoreHorizontal, Download, Eye, Pencil, FileText, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
@ -50,9 +50,10 @@ interface ActionsColumnProps {
onEditQuote?: (quote: Quote) => void
onManageAttachments?: (quote: Quote) => void
onRegeneratePdf?: (quote: Quote) => void
onDelete?: (quote: Quote) => void
}
export const createQuoteColumns = ({ onDownloadPdf, onViewQuote, onEditQuote, onManageAttachments, onRegeneratePdf }: ActionsColumnProps): ColumnDef<Quote>[] => [
export const createQuoteColumns = ({ onDownloadPdf, onViewQuote, onEditQuote, onManageAttachments, onRegeneratePdf, onDelete }: ActionsColumnProps): ColumnDef<Quote>[] => [
{
accessorKey: "quote_number",
header: "Quote No",
@ -129,6 +130,15 @@ export const createQuoteColumns = ({ onDownloadPdf, onViewQuote, onEditQuote, on
<FileText className="mr-2 h-4 w-4" />Regenerate PDF
</DropdownMenuItem>
)}
{!!onDelete && (
<DropdownMenuItem
onClick={() => onDelete(quote)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)

View file

@ -10,9 +10,19 @@ 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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { jsPDF } from "jspdf"
import pb from "@/lib/pocketbase"
import { showSuccess } from "@/utils/toast"
import { showSuccess, showError } from "@/utils/toast"
import { createQuoteColumns, Quote } from "./QuoteColumns"
import { drawCustomerBlock } from "./quotePdfCustomer"
import { truncateToWidth } from "../purchase-orders/pdfText"
@ -35,6 +45,11 @@ export function QuoteDataTable({ columns: columnsProp, data, filterValue, onFilt
const [attachmentsDialogOpen, setAttachmentsDialogOpen] = React.useState(false)
const [selectedQuoteForAttachments, setSelectedQuoteForAttachments] = React.useState<Quote | null>(null)
// Soft-delete confirmation state
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
const [pendingDelete, setPendingDelete] = React.useState<Quote | null>(null)
const [deleting, setDeleting] = React.useState(false)
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) }, [])
@ -43,14 +58,39 @@ export function QuoteDataTable({ columns: columnsProp, data, filterValue, onFilt
if (filename) await saveQuotePdfToPocketbase(quote, filename)
}, [])
// Soft delete — flips the `deleted` flag. The list filter hides it; the
// record and its PDF stay in PocketBase for audit.
const requestDelete = React.useCallback((quote: Quote) => {
setPendingDelete(quote)
setDeleteDialogOpen(true)
}, [])
const confirmDelete = React.useCallback(async () => {
if (!pendingDelete) return;
setDeleting(true);
try {
await pb.collection("quotes").update(pendingDelete.id, { deleted: true });
showSuccess(`Deleted ${pendingDelete.quote_number || pendingDelete.id}`);
setDeleteDialogOpen(false);
setPendingDelete(null);
refreshData();
} catch (err) {
console.error("Failed to delete quote:", err);
showError("Could not delete quote.");
} finally {
setDeleting(false);
}
}, [pendingDelete, refreshData]);
const columnsWithActions = React.useMemo(
() => createQuoteColumns({
onDownloadPdf: handleDownloadPdf,
onViewQuote: handleViewQuote,
onManageAttachments: openAttachmentsDialog,
onRegeneratePdf: handleRegeneratePdf,
onDelete: requestDelete,
}),
[handleDownloadPdf, handleRegeneratePdf]
[handleDownloadPdf, handleRegeneratePdf, requestDelete]
)
const table = useReactTable<Quote>({
@ -157,6 +197,36 @@ export function QuoteDataTable({ columns: columnsProp, data, filterValue, onFilt
refreshData={refreshData}
/>
)}
{/* Soft-delete confirmation */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete quote?</AlertDialogTitle>
<AlertDialogDescription>
<span className="font-medium">
{pendingDelete?.quote_number || pendingDelete?.id}
</span>{" "}
will be removed from the list. The record and its PDF are kept in
PocketBase for audit — only the list hides it. To restore, set{" "}
<code className="text-xs">deleted = false</code> on the record.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
confirmDelete();
}}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View file

@ -15,8 +15,16 @@ import { showSuccess, showError } from "@/utils/toast"
import pb from "@/lib/pocketbase"
import { StockItemSearch } from "../purchase-orders/StockItemSearch"
import { CustomerCombobox } from "./CustomerCombobox"
import { CustomerForm } from "../customers/CustomerForm"
import { AttachmentUpload } from "../purchase-orders/AttachmentUpload"
import { saveQuotePdfToPocketbase } from "./QuoteDataTable"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
const quoteItemSchema = z.object({
entryMode: z.enum(['stock', 'manual']).default('stock'),
@ -73,6 +81,9 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) {
const [stockItems, setStockItems] = useState<StockItem[]>([])
const [quoteNumber, setQuoteNumber] = useState("")
const [attachedFiles, setAttachedFiles] = useState<File[]>([])
// Inline "add a customer" - opens the full customer form in a dialog.
const [newCustomerOpen, setNewCustomerOpen] = useState(false)
const [newCustomerName, setNewCustomerName] = useState("")
const generateQuoteNumber = useCallback(async () => {
try {
@ -173,33 +184,36 @@ 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) => {
// The customer we typed into the picker isn't in the list yet, so open the
// full customer form (name, address, VAT number, phone, email) right here
// rather than making a name-only stub that has to be edited later.
const handleCreateCustomer = (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.")
setNewCustomerName(name)
setNewCustomerOpen(true)
}
// A customer was created in the dialog above - add it to the list and
// select it so the quote can carry on.
const handleCustomerCreated = (saved: {
id: string
companyName: string
billingCompany: string
address: string
vatNumber: string
email: string
phone: string
}) => {
const newCustomer: Customer = {
id: saved.id,
name: saved.companyName,
address: saved.address,
vatNumber: saved.vatNumber,
email: saved.email,
phone: saved.phone,
}
setCustomers((prev) => [newCustomer, ...prev])
form.setValue('customerId', newCustomer.id, { shouldDirty: true, shouldValidate: true })
}
async function onSubmit(values: z.infer<typeof quoteFormSchema>) {
@ -507,6 +521,26 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) {
<Button type="submit" className="w-full bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98] transition-all">Create Quote</Button>
</form>
</Form>
{/* Add a customer without leaving the quote. Opens the full customer
form so the address, VAT number and phone go in at the same time. */}
<Dialog open={newCustomerOpen} onOpenChange={setNewCustomerOpen}>
<DialogContent className="sm:max-w-[425px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Add Customer</DialogTitle>
<DialogDescription>
Fill in the details now so the quote can show them straight away.
</DialogDescription>
</DialogHeader>
<CustomerForm
key={newCustomerName}
initialCompanyName={newCustomerName}
setOpen={setNewCustomerOpen}
refreshData={fetchCustomers}
onSaved={handleCustomerCreated}
/>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -22,10 +22,29 @@ const Quotes = () => {
const fetchQuotes = useCallback(async () => {
try {
const records = await pb.collection('quotes').getList(1, 50, {
sort: '-quoteDate',
expand: 'customerName',
})
// Soft-deleted quotes are hidden from the list but kept in PocketBase.
// The filter is applied defensively: the `deleted` field is only present
// once the 1786500001 migration has been applied, and PB rejects a
// filter on a missing field with a 400.
const filtersToTry = ["deleted != true", ""];
let records: any = null;
for (const f of filtersToTry) {
try {
records = await pb.collection('quotes').getList(1, 50, {
sort: '-quoteDate',
expand: 'customerName',
...(f ? { filter: f } : {}),
});
// Belt-and-braces: hide anything flagged deleted client-side too.
if (f === "" && records?.items) {
records.items = records.items.filter((r: any) => r.deleted !== true);
}
break;
} catch (err) {
continue;
}
}
if (!records) return;
const formattedData = records.items.map((record: any) => {
const qtyArray: number[] = Array.isArray(record.quantities) ? record.quantities : []
const priceArray: number[] = Array.isArray(record.unitPrices) ? record.unitPrices : []