diff --git a/src/components/forms/itf-form.tsx b/src/components/forms/itf-form.tsx index 21f6c22..21d4d8f 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 (
@@ -223,13 +245,27 @@ export function ItfForm() {
Total due: ${fmtCurrency(total)} - +
+ + +
+ +
); } diff --git a/src/components/forms/itl-form.tsx b/src/components/forms/itl-form.tsx index 15722fc..206b494 100644 --- a/src/components/forms/itl-form.tsx +++ b/src/components/forms/itl-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 ItlForm() { @@ -26,6 +28,7 @@ export function ItlForm() { 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(""); @@ -53,10 +56,10 @@ export function ItlForm() { (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]; @@ -84,7 +87,7 @@ export function ItlForm() { total, }); - const doc = renderLetterPdf({ + return renderLetterPdf({ template, returnAddress, topRightLine: tokens.date as string, @@ -95,13 +98,33 @@ export function ItlForm() { total: { label: labels.totalDue ?? "TOTAL DUE", amount: total }, filename: `ITL_${ownerFullName(homeowner) || "draft"}_${letterDate}`, }); + }; - const name = promptFilename(`ITL_${ownerFullName(homeowner) || "draft"}_${letterDate}`, "pdf"); + const defaultName = `ITL_${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("ITL 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 (
@@ -192,13 +215,27 @@ export function ItlForm() {
Total due: ${fmtCurrency(total)} - +
+ + +
+ +
); } 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 (
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) => ( +
+ + +
+ ))} +
+
+ )} +
+ + + + + +
+
+ ); +} 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"); +} diff --git a/src/routes/documents.pleading.new.tsx b/src/routes/documents.pleading.new.tsx index 24a59f1..b9d9fd8 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"; @@ -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,41 @@ 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 { + 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 { @@ -306,6 +344,9 @@ function PleadingNewPage() { <> +
+ + );