From 78c7e16db0106c2e1f90446406465da7197308ab Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:38:26 +0000 Subject: [PATCH 01/11] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/forms/save-to-case-dialog.tsx | 191 +++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 src/components/forms/save-to-case-dialog.tsx diff --git a/src/components/forms/save-to-case-dialog.tsx b/src/components/forms/save-to-case-dialog.tsx new file mode 100644 index 0000000..a93e08d --- /dev/null +++ b/src/components/forms/save-to-case-dialog.tsx @@ -0,0 +1,191 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { fetchAccessibleCases, type CaseLite } from "@/lib/forms-shared"; +import { FileText, Loader2 } from "lucide-react"; + +export type SaveFormat = "pdf" | "docx" | "csv"; + +interface Props { + open: boolean; + onOpenChange: (v: boolean) => void; + /** Pre-selected case (e.g. when called from a case detail screen). */ + defaultCaseId?: string; + /** Allowed save formats. Defaults to ["pdf"]. */ + formats?: SaveFormat[]; + /** Suggested base filename (no extension). */ + defaultName: string; + /** Folder name within case-documents. Defaults to "Forms & Letters". */ + defaultFolder?: string; + /** Called when the user confirms. Should perform the actual upload. */ + onConfirm: (opts: { + caseId: string; + name: string; + format: SaveFormat; + folder: string; + }) => Promise | void; + title?: string; + description?: string; +} + +/** + * Reusable picker for saving a generated document directly into a case's + * Documents tab. Loads accessible cases on first open. + */ +export function SaveToCaseDialog({ + open, + onOpenChange, + defaultCaseId, + formats = ["pdf"], + defaultName, + defaultFolder = "Forms & Letters", + onConfirm, + title = "Save to case files", + description = "Pick a case and the file will be uploaded to its Documents tab.", +}: Props) { + const [cases, setCases] = useState([]); + const [loading, setLoading] = useState(false); + const [caseId, setCaseId] = useState(defaultCaseId ?? ""); + const [name, setName] = useState(defaultName); + const [folder, setFolder] = useState(defaultFolder); + const [format, setFormat] = useState(formats[0]); + const [search, setSearch] = useState(""); + const [saving, setSaving] = useState(false); + + // Load cases the first time the dialog opens. + useEffect(() => { + if (!open) return; + setName(defaultName); + setCaseId(defaultCaseId ?? ""); + if (cases.length === 0) { + setLoading(true); + fetchAccessibleCases() + .then(setCases) + .finally(() => setLoading(false)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return cases.slice(0, 50); + return cases + .filter( + (c) => + c.case_number.toLowerCase().includes(q) || + c.title.toLowerCase().includes(q), + ) + .slice(0, 50); + }, [cases, search]); + + const handleConfirm = async () => { + if (!caseId || !name.trim()) return; + setSaving(true); + try { + await onConfirm({ caseId, name: name.trim(), format, folder: folder.trim() || "Forms & Letters" }); + onOpenChange(false); + } finally { + setSaving(false); + } + }; + + return ( + + + + {title} + {description} + + +
+
+ + setSearch(e.target.value)} + placeholder="Case number or title…" + /> +
+ {loading ? ( +
+ Loading cases… +
+ ) : filtered.length === 0 ? ( +
No cases match.
+ ) : ( + filtered.map((c) => ( + + )) + )} +
+
+ +
+
+ + setName(e.target.value)} /> +
+
+ + setFolder(e.target.value)} /> +
+
+ + {formats.length > 1 && ( +
+ + setFormat(v as SaveFormat)} + className="flex gap-4 mt-2" + > + {formats.map((f) => ( +
+ + +
+ ))} +
+
+ )} +
+ + + + + +
+
+ ); +} From 51b6a3f5e822c2e04ee319f1f8fd8acca2f96af9 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:38:49 +0000 Subject: [PATCH 02/11] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/documents.pleading.new.tsx | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/routes/documents.pleading.new.tsx b/src/routes/documents.pleading.new.tsx index 24a59f1..7ada058 100644 --- a/src/routes/documents.pleading.new.tsx +++ b/src/routes/documents.pleading.new.tsx @@ -25,6 +25,8 @@ import { Download, FileText, Save, Loader2, Plus, Trash2, PenLine, Users, Variab import { applyVars, buildChips, buildVarMap, loadPleadingContext, type LoadedContext, type VarChip } from "@/lib/pleading-variables"; import { promptFilename } from "@/lib/prompt-filename"; import { Badge } from "@/components/ui/badge"; +import { SaveToCaseDialog } from "@/components/forms/save-to-case-dialog"; +import { savePdfToDocuments } from "@/lib/forms-shared"; export const Route = createFileRoute("/documents/pleading/new")({ component: PleadingNewPage, @@ -53,6 +55,7 @@ function PleadingNewPage() { const [saving, setSaving] = useState(false); const [serviceList, setServiceList] = useState([]); const [serviceListTitle, setServiceListTitle] = useState("SERVICE LIST"); + const [saveToCaseOpen, setSaveToCaseOpen] = useState(false); // Client/case linkage for variable substitution const [clientId, setClientId] = useState(""); @@ -257,6 +260,45 @@ function PleadingNewPage() { await downloadPleadingPdf(input, name); }; + const onSaveToCase = async ({ + caseId: targetCaseId, + name, + format, + folder, + }: { caseId: string; name: string; format: "pdf" | "docx" | "csv"; folder: string }) => { + try { + let blob: Blob; + let mimeType: string; + let ext: string; + if (format === "docx") { + const doc = buildPleadingDoc(input); + blob = await Packer.toBlob(doc); + mimeType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + ext = "docx"; + } else { + // Generate PDF blob via downloadPleadingPdf path — but we need the bytes, + // so build it inline mirroring the helper. The helper saves directly, + // so we use a workaround: dynamically import jsPDF output. + const { buildPleadingPdfBlob } = await import("@/lib/pdf-pleading"); + blob = await buildPleadingPdfBlob(input); + mimeType = "application/pdf"; + ext = "pdf"; + } + const filename = name.endsWith(`.${ext}`) ? name : `${name}.${ext}`; + await savePdfToDocuments({ + blob, + caseId: targetCaseId, + name: filename, + folder, + mimeType, + }); + toast.success("Saved to case files"); + } catch (e: any) { + toast.error(e.message ?? "Save failed"); + throw e; + } + }; + const onSave = async () => { setSaving(true); try { From 0ad13f03bb6d7e1fffdeccc712233b26ecd84b39 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:38:58 +0000 Subject: [PATCH 03/11] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/lib/pdf-pleading.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/pdf-pleading.ts b/src/lib/pdf-pleading.ts index bf1f975..432b80c 100644 --- a/src/lib/pdf-pleading.ts +++ b/src/lib/pdf-pleading.ts @@ -196,7 +196,7 @@ function drawFooter(pdf: jsPDF, page: number, totalPages: number, left?: string, pdf.text(rightText, PAGE_W - MARGIN - rw, y); } -export async function downloadPleadingPdf(input: PleadingInput, filename: string) { +async function buildPleadingPdf(input: PleadingInput): Promise { const pdf = new jsPDF({ unit: "pt", format: "letter" }); registerBookmanFont(pdf); registerBirdsFont(pdf); @@ -415,5 +415,15 @@ export async function downloadPleadingPdf(input: PleadingInput, filename: string drawFooter(pdf, i, total, input.footerLeft, input.footerCenter, input.footerRight); } + return pdf; +} + +export async function downloadPleadingPdf(input: PleadingInput, filename: string) { + const pdf = await buildPleadingPdf(input); pdf.save(filename.endsWith(".pdf") ? filename : `${filename}.pdf`); } + +export async function buildPleadingPdfBlob(input: PleadingInput): Promise { + const pdf = await buildPleadingPdf(input); + return pdf.output("blob"); +} From 4f1eaa5dd6e31f3eb509a75c52d7da1a08b33226 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:39:09 +0000 Subject: [PATCH 04/11] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/documents.pleading.new.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/routes/documents.pleading.new.tsx b/src/routes/documents.pleading.new.tsx index 7ada058..702ddf8 100644 --- a/src/routes/documents.pleading.new.tsx +++ b/src/routes/documents.pleading.new.tsx @@ -15,7 +15,7 @@ import { Switch } from "@/components/ui/switch"; import { FL_CIRCUITS } from "@/lib/florida"; import { Packer } from "docx"; import { buildPleadingDoc, downloadPleading, type PleadingFootnote, type PleadingSignature, type ServiceContact } from "@/lib/docx-pleading"; -import { downloadPleadingPdf } from "@/lib/pdf-pleading"; +import { downloadPleadingPdf, buildPleadingPdfBlob } from "@/lib/pdf-pleading"; import { RichTextEditor } from "@/components/documents/rich-text-editor"; import { ContactPickerPopover } from "@/components/clients/contact-picker-popover"; import { supabase } from "@/integrations/supabase/client"; @@ -276,10 +276,6 @@ function PleadingNewPage() { mimeType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; ext = "docx"; } else { - // Generate PDF blob via downloadPleadingPdf path — but we need the bytes, - // so build it inline mirroring the helper. The helper saves directly, - // so we use a workaround: dynamically import jsPDF output. - const { buildPleadingPdfBlob } = await import("@/lib/pdf-pleading"); blob = await buildPleadingPdfBlob(input); mimeType = "application/pdf"; ext = "pdf"; From 2d86c7da9a9e06ebab8f73b98aa72fd38923b969 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:39:26 +0000 Subject: [PATCH 05/11] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/documents.pleading.new.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/routes/documents.pleading.new.tsx b/src/routes/documents.pleading.new.tsx index 702ddf8..b9d9fd8 100644 --- a/src/routes/documents.pleading.new.tsx +++ b/src/routes/documents.pleading.new.tsx @@ -344,6 +344,9 @@ function PleadingNewPage() { <> + +
+ + +
+ + ); } From a76d75d912004fc3d9a8cbbc9529eab276cacaa8 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:40:52 +0000 Subject: [PATCH 09/11] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/forms/itf-form.tsx | 32 ++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/components/forms/itf-form.tsx b/src/components/forms/itf-form.tsx index 21f6c22..58d1675 100644 --- a/src/components/forms/itf-form.tsx +++ b/src/components/forms/itf-form.tsx @@ -11,12 +11,14 @@ import { fmtCurrency, fmtDateLong, ownerFullName, + savePdfToDocuments, type ClientLite, type HomeownerLite, type FirmInfo, } from "@/lib/forms-shared"; import { buildLetterTokens, loadFormTemplate, renderLetterPdf, type FormTemplateConfig } from "@/lib/form-templates"; import { promptFilename } from "@/lib/prompt-filename"; +import { SaveToCaseDialog } from "./save-to-case-dialog"; import { toast } from "sonner"; export function ItfForm() { @@ -26,6 +28,7 @@ export function ItfForm() { const [homeownerId, setHomeownerId] = useState(""); const [firm, setFirm] = useState(null); const [template, setTemplate] = useState(null); + const [saveToCaseOpen, setSaveToCaseOpen] = useState(false); const [letterDate, setLetterDate] = useState(new Date().toISOString().slice(0, 10)); const [certifiedNo, setCertifiedNo] = useState(""); @@ -55,10 +58,10 @@ export function ItfForm() { (parseFloat(adminFees) || 0) - (parseFloat(lessPayments) || 0); - const handleExport = () => { + const buildPdf = () => { if (!client || !template) { toast.error("Select a client first"); - return; + return null; } const returnAddress = [client.name]; @@ -68,7 +71,6 @@ export function ItfForm() { const recipient: string[] = [ownerFullName(homeowner) || "[Homeowner]"]; if (homeowner?.address) recipient.push(homeowner.address); - // Compose lien reference inline so users don't need to edit raw template. const lienRef = lienBookPage || lienRecordedDate ? ` A Claim of Lien was recorded against the subject property${ @@ -95,7 +97,7 @@ export function ItfForm() { extra: { lienRef }, }); - const doc = renderLetterPdf({ + return renderLetterPdf({ template, returnAddress, topRightLine: tokens.date as string, @@ -106,13 +108,33 @@ export function ItfForm() { total: { label: labels.totalDue ?? "TOTAL DUE", amount: total }, filename: `ITF_${ownerFullName(homeowner) || "draft"}_${letterDate}`, }); + }; - const name = promptFilename(`ITF_${ownerFullName(homeowner) || "draft"}_${letterDate}`, "pdf"); + const defaultName = `ITF_${ownerFullName(homeowner) || "draft"}_${letterDate}`; + + const handleExport = () => { + const doc = buildPdf(); + if (!doc) return; + const name = promptFilename(defaultName, "pdf"); if (!name) return; doc.save(`${name}.pdf`); toast.success("ITF PDF downloaded"); }; + const handleSaveToCase = async ({ caseId, name, folder }: { caseId: string; name: string; folder: string }) => { + const doc = buildPdf(); + if (!doc) return; + const filename = name.endsWith(".pdf") ? name : `${name}.pdf`; + await savePdfToDocuments({ + blob: doc.output("blob"), + caseId, + name: filename, + folder, + mimeType: "application/pdf", + }); + toast.success("Saved to case files"); + }; + return (
From f204f9c33e29e08646c60efff4cb501a223b0d59 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:41:10 +0000 Subject: [PATCH 10/11] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/forms/itf-form.tsx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/components/forms/itf-form.tsx b/src/components/forms/itf-form.tsx index 58d1675..21d4d8f 100644 --- a/src/components/forms/itf-form.tsx +++ b/src/components/forms/itf-form.tsx @@ -245,13 +245,27 @@ export function ItfForm() {
Total due: ${fmtCurrency(total)} - +
+ + +
+ +
); } From e219a8cd69c0bb7840031beef9f2b1369bf4756b Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:41:37 +0000 Subject: [PATCH 11/11] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/forms/nola-form.tsx | 31 ++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/components/forms/nola-form.tsx b/src/components/forms/nola-form.tsx index a5d4f29..8251b0e 100644 --- a/src/components/forms/nola-form.tsx +++ b/src/components/forms/nola-form.tsx @@ -10,12 +10,14 @@ import { fetchFirm, fmtCurrency, ownerFullName, + savePdfToDocuments, type ClientLite, type HomeownerLite, type FirmInfo, } from "@/lib/forms-shared"; import { buildLetterTokens, loadFormTemplate, renderLetterPdf, type FormTemplateConfig } from "@/lib/form-templates"; import { promptFilename } from "@/lib/prompt-filename"; +import { SaveToCaseDialog } from "./save-to-case-dialog"; import { toast } from "sonner"; interface Item { @@ -30,6 +32,7 @@ export function NolaForm() { const [homeownerId, setHomeownerId] = useState(""); const [firm, setFirm] = useState(null); const [template, setTemplate] = useState(null); + const [saveToCaseOpen, setSaveToCaseOpen] = useState(false); const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); const [dueDate, setDueDate] = useState(() => { @@ -61,10 +64,10 @@ export function NolaForm() { const addItem = () => setItems((p) => [...p, { description: "", amount: "0.00" }]); const removeItem = (i: number) => setItems((p) => p.filter((_, idx) => idx !== i)); - const handleExport = () => { + const buildPdf = () => { if (!client || !template) { toast.error("Select a client first"); - return; + return null; } const returnAddress = [client.name]; @@ -83,7 +86,7 @@ export function NolaForm() { total, }); - const doc = renderLetterPdf({ + return renderLetterPdf({ template, returnAddress, topRightLine: tokens.date as string, @@ -94,13 +97,33 @@ export function NolaForm() { total: { label: template.itemLabels?.totalDue ?? "TOTAL DUE", amount: total }, filename: `NOLA_${ownerFullName(homeowner) || "draft"}_${date}`, }); + }; - const name = promptFilename(`NOLA_${ownerFullName(homeowner) || "draft"}_${date}`, "pdf"); + const defaultName = `NOLA_${ownerFullName(homeowner) || "draft"}_${date}`; + + const handleExport = () => { + const doc = buildPdf(); + if (!doc) return; + const name = promptFilename(defaultName, "pdf"); if (!name) return; doc.save(`${name}.pdf`); toast.success("NOLA PDF downloaded"); }; + const handleSaveToCase = async ({ caseId, name, folder }: { caseId: string; name: string; folder: string }) => { + const doc = buildPdf(); + if (!doc) return; + const filename = name.endsWith(".pdf") ? name : `${name}.pdf`; + await savePdfToDocuments({ + blob: doc.output("blob"), + caseId, + name: filename, + folder, + mimeType: "application/pdf", + }); + toast.success("Saved to case files"); + }; + return (