Added save-to-case for forms/ledgers

X-Lovable-Edit-ID: edt-d57aff93-c9d1-491d-bfb1-83b80d6b465e
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-20 00:41:50 +00:00
co-authored by renee-png
6 changed files with 371 additions and 23 deletions
+45 -9
View File
@@ -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<FirmInfo | null>(null);
const [template, setTemplate] = useState<FormTemplateConfig | null>(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 (
<div className="space-y-4">
<Card>
@@ -223,13 +245,27 @@ export function ItfForm() {
<div className="flex items-center justify-between pt-2">
<span className="text-sm font-medium">Total due: ${fmtCurrency(total)}</span>
<Button onClick={handleExport} disabled={!template}>
<FileDown className="h-4 w-4 mr-2" />
Export PDF
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setSaveToCaseOpen(true)} disabled={!template || !client}>
<FileDown className="h-4 w-4 mr-2" />
Save to case
</Button>
<Button onClick={handleExport} disabled={!template}>
<FileDown className="h-4 w-4 mr-2" />
Export PDF
</Button>
</div>
</div>
</CardContent>
</Card>
<SaveToCaseDialog
open={saveToCaseOpen}
onOpenChange={setSaveToCaseOpen}
defaultName={defaultName}
defaultFolder="Forms & Letters"
onConfirm={handleSaveToCase}
/>
</div>
);
}
+45 -8
View File
@@ -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<FirmInfo | null>(null);
const [template, setTemplate] = useState<FormTemplateConfig | null>(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 (
<div className="space-y-4">
<Card>
@@ -192,13 +215,27 @@ export function ItlForm() {
<div className="flex items-center justify-between pt-2">
<span className="text-sm font-medium">Total due: ${fmtCurrency(total)}</span>
<Button onClick={handleExport} disabled={!template}>
<FileDown className="h-4 w-4 mr-2" />
Export PDF
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setSaveToCaseOpen(true)} disabled={!template || !client}>
<FileDown className="h-4 w-4 mr-2" />
Save to case
</Button>
<Button onClick={handleExport} disabled={!template}>
<FileDown className="h-4 w-4 mr-2" />
Export PDF
</Button>
</div>
</div>
</CardContent>
</Card>
<SaveToCaseDialog
open={saveToCaseOpen}
onOpenChange={setSaveToCaseOpen}
defaultName={defaultName}
defaultFolder="Forms & Letters"
onConfirm={handleSaveToCase}
/>
</div>
);
}
+27 -4
View File
@@ -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<FirmInfo | null>(null);
const [template, setTemplate] = useState<FormTemplateConfig | null>(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 (
<div className="space-y-4">
<Card>
@@ -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> | 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<CaseLite[]>([]);
const [loading, setLoading] = useState(false);
const [caseId, setCaseId] = useState(defaultCaseId ?? "");
const [name, setName] = useState(defaultName);
const [folder, setFolder] = useState(defaultFolder);
const [format, setFormat] = useState<SaveFormat>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Search cases</Label>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Case number or title…"
/>
<div className="mt-2 max-h-56 overflow-auto rounded-md border">
{loading ? (
<div className="flex items-center justify-center p-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Loading cases…
</div>
) : filtered.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground">No cases match.</div>
) : (
filtered.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setCaseId(c.id)}
className={`block w-full text-left px-3 py-2 border-b last:border-b-0 text-sm hover:bg-muted ${
caseId === c.id ? "bg-muted font-medium" : ""
}`}
>
<div>{c.title}</div>
<div className="text-xs text-muted-foreground">{c.case_number}</div>
</button>
))
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<Label>File name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<Label>Folder</Label>
<Input value={folder} onChange={(e) => setFolder(e.target.value)} />
</div>
</div>
{formats.length > 1 && (
<div>
<Label>Format</Label>
<RadioGroup
value={format}
onValueChange={(v) => setFormat(v as SaveFormat)}
className="flex gap-4 mt-2"
>
{formats.map((f) => (
<div key={f} className="flex items-center gap-2">
<RadioGroupItem value={f} id={`fmt-${f}`} />
<Label htmlFor={`fmt-${f}`} className="text-sm uppercase">
{f}
</Label>
</div>
))}
</RadioGroup>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button disabled={!caseId || !name.trim() || saving} onClick={handleConfirm}>
{saving ? (
<Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
) : (
<FileText className="h-4 w-4 mr-1.5" />
)}
Save to case
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+11 -1
View File
@@ -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<jsPDF> {
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<Blob> {
const pdf = await buildPleadingPdf(input);
return pdf.output("blob");
}
+52 -1
View File
@@ -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<ServiceContact[]>([]);
const [serviceListTitle, setServiceListTitle] = useState("SERVICE LIST");
const [saveToCaseOpen, setSaveToCaseOpen] = useState(false);
// Client/case linkage for variable substitution
const [clientId, setClientId] = useState<string>("");
@@ -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() {
<>
<Button variant="outline" onClick={onDownloadPdf}><FileText className="h-4 w-4 mr-2" /> Download PDF</Button>
<Button variant="outline" onClick={onDownload}><Download className="h-4 w-4 mr-2" /> Download .docx</Button>
<Button variant="outline" onClick={() => setSaveToCaseOpen(true)}>
<FileText className="h-4 w-4 mr-2" /> Save to case
</Button>
<Button onClick={onSave} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
Save & download
@@ -799,6 +840,16 @@ function PleadingNewPage() {
</div>
</CardContent>
</Card>
<SaveToCaseDialog
open={saveToCaseOpen}
onOpenChange={setSaveToCaseOpen}
defaultCaseId={caseId || undefined}
formats={["pdf", "docx"]}
defaultName={(docName || "Pleading").replace(/[^a-zA-Z0-9._-]/g, "_")}
defaultFolder="Pleadings"
onConfirm={onSaveToCase}
/>
</PageContainer>
</ProtectedLayout>
);