Added case/link support

X-Lovable-Edit-ID: edt-981d405d-9d67-4508-a3bb-23f77f5537a8
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 21:38:49 +00:00
co-authored by renee-png
+109 -36
View File
@@ -1,19 +1,26 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FileDown } from "lucide-react";
import { ClientHomeownerPicker } from "./form-pickers";
import {
applyVariables,
clientAddressLines,
contactMailingLines,
fetchAccessibleCases,
fetchContacts,
fetchFirm,
fmtDateLong,
ownerMailingLines,
type ClientLite,
type HomeownerLite,
type CaseLite,
type ContactLite,
type FirmInfo,
} from "@/lib/forms-shared";
import { fillTokens, loadFormTemplate, type FormTemplateConfig } from "@/lib/form-templates";
@@ -40,10 +47,11 @@ async function loadLogoDataUrl(path: string | null | undefined): Promise<string
}
export function LetterGenerator() {
const [client, setClient] = useState<ClientLite | null>(null);
const [homeowner, setHomeowner] = useState<HomeownerLite | null>(null);
const [clientId, setClientId] = useState("");
const [homeownerId, setHomeownerId] = useState("");
const [cases, setCases] = useState<CaseLite[]>([]);
const [caseId, setCaseId] = useState("");
const [contacts, setContacts] = useState<ContactLite[]>([]);
const [caseContactIds, setCaseContactIds] = useState<Set<string>>(new Set());
const [recipientId, setRecipientId] = useState("");
const [firm, setFirm] = useState<FirmInfo | null>(null);
const [logoDataUrl, setLogoDataUrl] = useState<string | null>(null);
const [attorneyName, setAttorneyName] = useState("");
@@ -53,15 +61,31 @@ export function LetterGenerator() {
const [certifiedMailNumber, setCertifiedMailNumber] = useState("");
const [template, setTemplate] = useState<FormTemplateConfig | null>(null);
const recipient = useMemo(
() => contacts.find((c) => c.id === recipientId) ?? null,
[contacts, recipientId],
);
// Sort: case-linked contacts first, then everything else.
const sortedContacts = useMemo(() => {
const linked: ContactLite[] = [];
const others: ContactLite[] = [];
for (const c of contacts) {
(caseContactIds.has(c.id) ? linked : others).push(c);
}
return { linked, others };
}, [contacts, caseContactIds]);
useEffect(() => {
fetchFirm().then(async (f) => {
setFirm(f);
const url = await loadLogoDataUrl((f as any)?.logo_storage_path);
setLogoDataUrl(url);
});
fetchAccessibleCases().then(setCases);
fetchContacts().then(setContacts);
loadFormTemplate("letter").then((t) => {
setTemplate(t);
// Seed the editable body with the template default on first load.
setBody((prev) => prev || t.body || "");
});
supabase.auth.getUser().then(async ({ data }) => {
@@ -79,9 +103,24 @@ export function LetterGenerator() {
});
}, []);
// When case changes, fetch its linked contacts to surface them at the top.
useEffect(() => {
if (!caseId) {
setCaseContactIds(new Set());
return;
}
supabase
.from("case_contacts")
.select("contact_id")
.eq("case_id", caseId)
.then(({ data }) => {
setCaseContactIds(new Set((data ?? []).map((r: any) => r.contact_id)));
});
}, [caseId]);
const handleExport = () => {
if (!template) return;
const ctx = { client, homeowner, firmName: firm?.company_name ?? "" };
const ctx = { client: null, homeowner: null, firmName: firm?.company_name ?? "" };
const renderedBody = applyVariables(body, ctx);
const renderedSubject = applyVariables(subject, ctx);
@@ -104,8 +143,8 @@ export function LetterGenerator() {
"firm.phone": firm?.contact_phone ?? "",
"firm.email": firm?.contact_email ?? "",
"firm.website": (firm as any)?.website ?? "",
"owner.name": homeowner ? `${homeowner.first_name} ${homeowner.last_name}`.trim() : "",
"client.name": client?.name ?? "",
"recipient.name": recipient?.name ?? "",
"recipient.company": recipient?.company ?? "",
attorney: attorneyName,
date: fmtDateLong(date),
};
@@ -205,21 +244,13 @@ export function LetterGenerator() {
doc.text(fmtDateLong(date), centerX, y, { align: "center" });
y += 28;
// ----- Recipient -----
// ----- Recipient (from selected contact) -----
doc.setFont(bodyFont, "normal");
doc.setFontSize(bodyFontSize);
ownerMailingLines(homeowner).forEach((l) => {
contactMailingLines(recipient).forEach((l) => {
doc.text(l, margin, y);
y += lh - 1;
});
if (!homeowner && client) {
doc.text(client.name, margin, y);
y += lh - 1;
clientAddressLines(client).forEach((l) => {
doc.text(l, margin, y);
y += lh - 1;
});
}
// Certified mail line (right-aligned, italic) — only when a number is provided
if (certifiedMailNumber.trim() && template.showCertifiedMail !== false) {
@@ -312,18 +343,60 @@ export function LetterGenerator() {
<div className="space-y-4">
<Card>
<CardContent className="pt-6 space-y-4">
<ClientHomeownerPicker
clientId={clientId}
homeownerId={homeownerId}
onClientChange={(id, c) => {
setClientId(id);
setClient(c);
}}
onHomeownerChange={(id, h) => {
setHomeownerId(id);
setHomeowner(h);
}}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Case (optional)</Label>
<Select
value={caseId || "__none"}
onValueChange={(v) => setCaseId(v === "__none" ? "" : v)}
>
<SelectTrigger>
<SelectValue placeholder="Link to a case" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none">— No case —</SelectItem>
{cases.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.case_number} — {c.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>
Recipient (contact)
{caseId && sortedContacts.linked.length > 0 ? (
<span className="text-xs text-muted-foreground ml-2">
Case contacts shown first
</span>
) : null}
</Label>
<Select value={recipientId} onValueChange={setRecipientId}>
<SelectTrigger>
<SelectValue placeholder="Select a contact" />
</SelectTrigger>
<SelectContent>
{sortedContacts.linked.length > 0 && (
<>
{sortedContacts.linked.map((c) => (
<SelectItem key={c.id} value={c.id}>
★ {c.name}
{c.company ? ` — ${c.company}` : ""}
</SelectItem>
))}
</>
)}
{sortedContacts.others.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
{c.company ? ` — ${c.company}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Date</Label>