diff --git a/pocketbase/pb_migrations/1786500001_added_quote_deleted_flag.js b/pocketbase/pb_migrations/1786500001_added_quote_deleted_flag.js new file mode 100644 index 0000000..208ee45 --- /dev/null +++ b/pocketbase/pb_migrations/1786500001_added_quote_deleted_flag.js @@ -0,0 +1,50 @@ +/// + +// ========================================================================= +// 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); +}); diff --git a/scripts/add-quote-deleted-flag.mjs b/scripts/add-quote-deleted-flag.mjs new file mode 100644 index 0000000..7d4cfed --- /dev/null +++ b/scripts/add-quote-deleted-flag.mjs @@ -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."); diff --git a/src/components/customers/CustomerForm.tsx b/src/components/customers/CustomerForm.tsx index 6a218e0..2485c31 100644 --- a/src/components/customers/CustomerForm.tsx +++ b/src/components/customers/CustomerForm.tsx @@ -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>({ 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() diff --git a/src/components/quotes/QuoteColumns.tsx b/src/components/quotes/QuoteColumns.tsx index 7bbc69c..f7c130a 100644 --- a/src/components/quotes/QuoteColumns.tsx +++ b/src/components/quotes/QuoteColumns.tsx @@ -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[] => [ +export const createQuoteColumns = ({ onDownloadPdf, onViewQuote, onEditQuote, onManageAttachments, onRegeneratePdf, onDelete }: ActionsColumnProps): ColumnDef[] => [ { accessorKey: "quote_number", header: "Quote No", @@ -129,6 +130,15 @@ export const createQuoteColumns = ({ onDownloadPdf, onViewQuote, onEditQuote, on Regenerate PDF )} + {!!onDelete && ( + onDelete(quote)} + className="text-destructive focus:text-destructive" + > + + Delete + + )} ) diff --git a/src/components/quotes/QuoteDataTable.tsx b/src/components/quotes/QuoteDataTable.tsx index a8b71aa..f503a23 100644 --- a/src/components/quotes/QuoteDataTable.tsx +++ b/src/components/quotes/QuoteDataTable.tsx @@ -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(null) + // Soft-delete confirmation state + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) + const [pendingDelete, setPendingDelete] = React.useState(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({ @@ -157,6 +197,36 @@ export function QuoteDataTable({ columns: columnsProp, data, filterValue, onFilt refreshData={refreshData} /> )} + + {/* Soft-delete confirmation */} + + + + Delete quote? + + + {pendingDelete?.quote_number || pendingDelete?.id} + {" "} + 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{" "} + deleted = false on the record. + + + + Cancel + { + e.preventDefault(); + confirmDelete(); + }} + disabled={deleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deleting ? "Deleting..." : "Delete"} + + + + ) } diff --git a/src/components/quotes/QuoteForm.tsx b/src/components/quotes/QuoteForm.tsx index e0d5ef5..a1bc103 100644 --- a/src/components/quotes/QuoteForm.tsx +++ b/src/components/quotes/QuoteForm.tsx @@ -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([]) const [quoteNumber, setQuoteNumber] = useState("") const [attachedFiles, setAttachedFiles] = useState([]) + // 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) { @@ -507,6 +521,26 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) { + + {/* 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. */} + + + + Add Customer + + Fill in the details now so the quote can show them straight away. + + + + + ) } \ No newline at end of file diff --git a/src/pages/Quotes.tsx b/src/pages/Quotes.tsx index 2d48a76..3e3bd72 100644 --- a/src/pages/Quotes.tsx +++ b/src/pages/Quotes.tsx @@ -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 : []