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. */}
+
)
}
\ 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 : []