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:
parent
99e9d947cf
commit
9d2ba5a430
7 changed files with 302 additions and 36 deletions
|
|
@ -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);
|
||||
});
|
||||
58
scripts/add-quote-deleted-flag.mjs
Normal file
58
scripts/add-quote-deleted-flag.mjs
Normal 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.");
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 : []
|
||||
|
|
|
|||
Loading…
Reference in a new issue