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
|
refreshData: () => void
|
||||||
/** When present the form edits this customer instead of creating a new one. */
|
/** When present the form edits this customer instead of creating a new one. */
|
||||||
customer?: EditableCustomer
|
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 [billingCompanies, setBillingCompanies] = React.useState(initialBillingCompanies.map(c => ({ value: c.toLowerCase(), label: c })))
|
||||||
const isEdit = !!customer
|
const isEdit = !!customer
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
companyName: customer?.companyName ?? "",
|
companyName: customer?.companyName ?? initialCompanyName ?? "",
|
||||||
billingCompany: customer?.billingCompany ?? "",
|
billingCompany: customer?.billingCompany ?? "",
|
||||||
address: customer?.address ?? "",
|
address: customer?.address ?? "",
|
||||||
vatNumber: customer?.vatNumber ?? "",
|
vatNumber: customer?.vatNumber ?? "",
|
||||||
|
|
@ -83,9 +90,27 @@ export function CustomerForm({ setOpen, refreshData, customer }: CustomerFormPro
|
||||||
if (isEdit && customer) {
|
if (isEdit && customer) {
|
||||||
await pb.collection("customers").update(customer.id, payload)
|
await pb.collection("customers").update(customer.id, payload)
|
||||||
showSuccess(`${values.companyName} updated successfully!`)
|
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 {
|
} else {
|
||||||
await pb.collection("customers").create(payload)
|
const created = await pb.collection("customers").create(payload)
|
||||||
showSuccess("New customer added successfully!")
|
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()
|
form.reset()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { ColumnDef } from "@tanstack/react-table"
|
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 { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -50,9 +50,10 @@ interface ActionsColumnProps {
|
||||||
onEditQuote?: (quote: Quote) => void
|
onEditQuote?: (quote: Quote) => void
|
||||||
onManageAttachments?: (quote: Quote) => void
|
onManageAttachments?: (quote: Quote) => void
|
||||||
onRegeneratePdf?: (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",
|
accessorKey: "quote_number",
|
||||||
header: "Quote No",
|
header: "Quote No",
|
||||||
|
|
@ -129,6 +130,15 @@ export const createQuoteColumns = ({ onDownloadPdf, onViewQuote, onEditQuote, on
|
||||||
<FileText className="mr-2 h-4 w-4" />Regenerate PDF
|
<FileText className="mr-2 h-4 w-4" />Regenerate PDF
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{!!onDelete && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onDelete(quote)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,19 @@ import { Columns, Download, Eye } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
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 { jsPDF } from "jspdf"
|
||||||
import pb from "@/lib/pocketbase"
|
import pb from "@/lib/pocketbase"
|
||||||
import { showSuccess } from "@/utils/toast"
|
import { showSuccess, showError } from "@/utils/toast"
|
||||||
import { createQuoteColumns, Quote } from "./QuoteColumns"
|
import { createQuoteColumns, Quote } from "./QuoteColumns"
|
||||||
import { drawCustomerBlock } from "./quotePdfCustomer"
|
import { drawCustomerBlock } from "./quotePdfCustomer"
|
||||||
import { truncateToWidth } from "../purchase-orders/pdfText"
|
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 [attachmentsDialogOpen, setAttachmentsDialogOpen] = React.useState(false)
|
||||||
const [selectedQuoteForAttachments, setSelectedQuoteForAttachments] = React.useState<Quote | null>(null)
|
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 handleViewQuote = (quote: Quote) => { setSelectedQuote(quote); setViewDialogOpen(true) }
|
||||||
const openAttachmentsDialog = (quote: Quote) => { setSelectedQuoteForAttachments(quote); setAttachmentsDialogOpen(true) }
|
const openAttachmentsDialog = (quote: Quote) => { setSelectedQuoteForAttachments(quote); setAttachmentsDialogOpen(true) }
|
||||||
const handleDownloadPdf = React.useCallback((quote: Quote) => { void generateQuotePDF(quote) }, [])
|
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)
|
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(
|
const columnsWithActions = React.useMemo(
|
||||||
() => createQuoteColumns({
|
() => createQuoteColumns({
|
||||||
onDownloadPdf: handleDownloadPdf,
|
onDownloadPdf: handleDownloadPdf,
|
||||||
onViewQuote: handleViewQuote,
|
onViewQuote: handleViewQuote,
|
||||||
onManageAttachments: openAttachmentsDialog,
|
onManageAttachments: openAttachmentsDialog,
|
||||||
onRegeneratePdf: handleRegeneratePdf,
|
onRegeneratePdf: handleRegeneratePdf,
|
||||||
|
onDelete: requestDelete,
|
||||||
}),
|
}),
|
||||||
[handleDownloadPdf, handleRegeneratePdf]
|
[handleDownloadPdf, handleRegeneratePdf, requestDelete]
|
||||||
)
|
)
|
||||||
|
|
||||||
const table = useReactTable<Quote>({
|
const table = useReactTable<Quote>({
|
||||||
|
|
@ -157,6 +197,36 @@ export function QuoteDataTable({ columns: columnsProp, data, filterValue, onFilt
|
||||||
refreshData={refreshData}
|
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 pb from "@/lib/pocketbase"
|
||||||
import { StockItemSearch } from "../purchase-orders/StockItemSearch"
|
import { StockItemSearch } from "../purchase-orders/StockItemSearch"
|
||||||
import { CustomerCombobox } from "./CustomerCombobox"
|
import { CustomerCombobox } from "./CustomerCombobox"
|
||||||
|
import { CustomerForm } from "../customers/CustomerForm"
|
||||||
import { AttachmentUpload } from "../purchase-orders/AttachmentUpload"
|
import { AttachmentUpload } from "../purchase-orders/AttachmentUpload"
|
||||||
import { saveQuotePdfToPocketbase } from "./QuoteDataTable"
|
import { saveQuotePdfToPocketbase } from "./QuoteDataTable"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
|
||||||
const quoteItemSchema = z.object({
|
const quoteItemSchema = z.object({
|
||||||
entryMode: z.enum(['stock', 'manual']).default('stock'),
|
entryMode: z.enum(['stock', 'manual']).default('stock'),
|
||||||
|
|
@ -73,6 +81,9 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) {
|
||||||
const [stockItems, setStockItems] = useState<StockItem[]>([])
|
const [stockItems, setStockItems] = useState<StockItem[]>([])
|
||||||
const [quoteNumber, setQuoteNumber] = useState("")
|
const [quoteNumber, setQuoteNumber] = useState("")
|
||||||
const [attachedFiles, setAttachedFiles] = useState<File[]>([])
|
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 () => {
|
const generateQuoteNumber = useCallback(async () => {
|
||||||
try {
|
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.
|
// The customer we typed into the picker isn't in the list yet, so open the
|
||||||
const handleCreateCustomer = async (name: string) => {
|
// 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
|
if (!name) return
|
||||||
try {
|
setNewCustomerName(name)
|
||||||
const created = await pb.collection('customers').create({
|
setNewCustomerOpen(true)
|
||||||
comapny_name: name, // Note: PocketBase field name is 'comapny_name'
|
}
|
||||||
billing_company: '',
|
|
||||||
address: '',
|
// A customer was created in the dialog above - add it to the list and
|
||||||
vatNumber: '',
|
// select it so the quote can carry on.
|
||||||
email: '',
|
const handleCustomerCreated = (saved: {
|
||||||
phone: '',
|
id: string
|
||||||
})
|
companyName: string
|
||||||
const newCustomer: Customer = {
|
billingCompany: string
|
||||||
id: created.id,
|
address: string
|
||||||
name,
|
vatNumber: string
|
||||||
address: '',
|
email: string
|
||||||
vatNumber: '',
|
phone: string
|
||||||
email: '',
|
}) => {
|
||||||
phone: '',
|
const newCustomer: Customer = {
|
||||||
}
|
id: saved.id,
|
||||||
setCustomers((prev) => [newCustomer, ...prev])
|
name: saved.companyName,
|
||||||
form.setValue('customerId', newCustomer.id, { shouldDirty: true, shouldValidate: true })
|
address: saved.address,
|
||||||
showSuccess(`${name} added. You can add their address and VAT number from Customers.`)
|
vatNumber: saved.vatNumber,
|
||||||
} catch (err: any) {
|
email: saved.email,
|
||||||
console.error(err)
|
phone: saved.phone,
|
||||||
showError(err?.response?.message || "Failed to add customer.")
|
|
||||||
}
|
}
|
||||||
|
setCustomers((prev) => [newCustomer, ...prev])
|
||||||
|
form.setValue('customerId', newCustomer.id, { shouldDirty: true, shouldValidate: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof quoteFormSchema>) {
|
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>
|
<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>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -22,10 +22,29 @@ const Quotes = () => {
|
||||||
|
|
||||||
const fetchQuotes = useCallback(async () => {
|
const fetchQuotes = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const records = await pb.collection('quotes').getList(1, 50, {
|
// Soft-deleted quotes are hidden from the list but kept in PocketBase.
|
||||||
sort: '-quoteDate',
|
// The filter is applied defensively: the `deleted` field is only present
|
||||||
expand: 'customerName',
|
// 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 formattedData = records.items.map((record: any) => {
|
||||||
const qtyArray: number[] = Array.isArray(record.quantities) ? record.quantities : []
|
const qtyArray: number[] = Array.isArray(record.quantities) ? record.quantities : []
|
||||||
const priceArray: number[] = Array.isArray(record.unitPrices) ? record.unitPrices : []
|
const priceArray: number[] = Array.isArray(record.unitPrices) ? record.unitPrices : []
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue