Added client/case picker

X-Lovable-Edit-ID: edt-e0ef5555-7929-494d-8bbd-1e4081582060
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 19:11:58 +00:00
co-authored by renee-png
2 changed files with 332 additions and 2 deletions
+148
View File
@@ -0,0 +1,148 @@
import { supabase } from "@/integrations/supabase/client";
export type VarMap = Record<string, string>;
export type LoadedContext = {
client?: any;
cases?: any;
clientFields: { key: string; label: string; value: string }[];
caseFields: { key: string; label: string; value: string }[];
};
export type VarChip = { token: string; label: string; value: string };
export async function loadPleadingContext(
clientId: string | null,
caseId: string | null,
): Promise<LoadedContext> {
const result: LoadedContext = { clientFields: [], caseFields: [] };
if (clientId) {
const { data: client } = await supabase.from("clients").select("*").eq("id", clientId).maybeSingle();
if (client) result.client = client;
const [{ data: defs }, { data: vals }] = await Promise.all([
supabase.from("custom_client_fields").select("id, key, label").eq("active", true).order("sort_order"),
supabase.from("client_field_values").select("field_id, value").eq("client_id", clientId),
]);
const valMap = new Map((vals || []).map((v: any) => [v.field_id, v.value || ""]));
result.clientFields = (defs || []).map((d: any) => ({
key: d.key,
label: d.label,
value: valMap.get(d.id) || "",
}));
}
if (caseId) {
const { data: caseRow } = await supabase.from("cases").select("*").eq("id", caseId).maybeSingle();
if (caseRow) result.cases = caseRow;
const [{ data: defs }, { data: vals }] = await Promise.all([
supabase.from("custom_case_fields").select("id, key, label").eq("active", true).order("sort_order"),
supabase.from("case_field_values").select("field_id, value").eq("case_id", caseId),
]);
const valMap = new Map((vals || []).map((v: any) => [v.field_id, v.value || ""]));
result.caseFields = (defs || []).map((d: any) => ({
key: d.key,
label: d.label,
value: valMap.get(d.id) || "",
}));
}
return result;
}
function fmtAddress(o: any): string {
if (!o) return "";
const parts = [
o.address_line1,
o.address_line2,
[o.city, o.state, o.postal_code].filter(Boolean).join(", "),
].filter(Boolean);
return parts.join("\n");
}
export function buildVarMap(ctx: LoadedContext): VarMap {
const m: VarMap = {};
const c = ctx.client;
if (c) {
m["client.name"] = c.name || "";
m["client.address"] = fmtAddress(c);
m["client.address_line1"] = c.address_line1 || "";
m["client.address_line2"] = c.address_line2 || "";
m["client.city"] = c.city || "";
m["client.state"] = c.state || "";
m["client.postal_code"] = c.postal_code || "";
m["client.contact_name"] = c.primary_contact_name || "";
m["client.contact_email"] = c.primary_contact_email || "";
m["client.contact_phone"] = c.primary_contact_phone || "";
m["client.management_company"] = c.management_company || "";
}
const k = ctx.cases;
if (k) {
m["case.number"] = k.case_number || "";
m["case.title"] = k.title || "";
m["case.caption"] = k.case_caption || "";
m["case.court"] = k.court || "";
m["case.court_case_number"] = k.court_case_number || "";
m["case.judge"] = k.judge || "";
m["case.jurisdiction"] = k.jurisdiction || "";
m["case.opposing_party"] = k.opposing_party || "";
m["case.opposing_counsel"] = k.opposing_counsel || "";
m["case.opposing_counsel_firm"] = k.opposing_counsel_firm || "";
m["case.opposing_counsel_email"] = k.opposing_counsel_email || "";
m["case.opposing_counsel_phone"] = k.opposing_counsel_phone || "";
m["case.filing_date"] = k.filing_date || "";
m["case.next_hearing_date"] = k.next_hearing_date || "";
}
ctx.clientFields.forEach((f) => {
m[`client.field.${f.key}`] = f.value;
});
ctx.caseFields.forEach((f) => {
m[`case.field.${f.key}`] = f.value;
});
return m;
}
export function buildChips(ctx: LoadedContext): VarChip[] {
const m = buildVarMap(ctx);
const out: VarChip[] = [];
const labelMap: Record<string, string> = {
"client.name": "Client name",
"client.address": "Client address (full)",
"client.contact_name": "Client primary contact",
"client.contact_email": "Client contact email",
"client.contact_phone": "Client contact phone",
"client.management_company": "Management company",
"case.number": "Case number",
"case.title": "Case title",
"case.caption": "Case caption",
"case.court": "Court",
"case.court_case_number": "Court case number",
"case.judge": "Judge",
"case.opposing_party": "Opposing party",
"case.opposing_counsel": "Opposing counsel",
"case.opposing_counsel_firm": "Opposing counsel firm",
"case.filing_date": "Filing date",
"case.next_hearing_date": "Next hearing date",
};
for (const key of Object.keys(m)) {
let label = labelMap[key];
if (!label) {
if (key.startsWith("client.field.")) label = `Client · ${key.replace("client.field.", "")}`;
else if (key.startsWith("case.field.")) label = `Case · ${key.replace("case.field.", "")}`;
else label = key;
}
out.push({ token: `{{${key}}}`, label, value: m[key] });
}
return out;
}
const TOKEN_RE = /\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/g;
export function applyVars(text: string, map: VarMap): string {
if (!text) return text;
return text.replace(TOKEN_RE, (_, key) => (key in map ? map[key] : `{{${key}}}`));
}
export function applyVarsToHtml(html: string, map: VarMap): string {
return applyVars(html, map);
}
+184 -2
View File
@@ -20,7 +20,9 @@ import { ContactPickerPopover } from "@/components/clients/contact-picker-popove
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { toast } from "sonner";
import { Download, FileText, Save, Loader2, Plus, Trash2, PenLine, Users } from "lucide-react";
import { Download, FileText, Save, Loader2, Plus, Trash2, PenLine, Users, Variable, Copy } from "lucide-react";
import { applyVars, buildChips, buildVarMap, loadPleadingContext, type LoadedContext, type VarChip } from "@/lib/pleading-variables";
import { Badge } from "@/components/ui/badge";
export const Route = createFileRoute("/documents/pleading/new")({
component: PleadingNewPage,
@@ -50,6 +52,54 @@ function PleadingNewPage() {
const [serviceList, setServiceList] = useState<ServiceContact[]>([]);
const [serviceListTitle, setServiceListTitle] = useState("SERVICE LIST");
// Client/case linkage for variable substitution
const [clientId, setClientId] = useState<string>("");
const [caseId, setCaseId] = useState<string>("");
const [clientOptions, setClientOptions] = useState<{ id: string; name: string }[]>([]);
const [caseOptions, setCaseOptions] = useState<{ id: string; case_number: string; title: string; client_id: string | null }[]>([]);
const [varCtx, setVarCtx] = useState<LoadedContext>({ clientFields: [], caseFields: [] });
const [chips, setChips] = useState<VarChip[]>([]);
const [chipFilter, setChipFilter] = useState("");
useEffect(() => {
(async () => {
const [{ data: cl }, { data: ks }] = await Promise.all([
supabase.from("clients").select("id, name").is("archived_at", null).order("name"),
supabase.from("cases").select("id, case_number, title, client_id").is("archived_at", null).order("case_number", { ascending: false }).limit(500),
]);
setClientOptions((cl as any) || []);
setCaseOptions((ks as any) || []);
})();
}, []);
useEffect(() => {
(async () => {
const ctx = await loadPleadingContext(clientId || null, caseId || null);
setVarCtx(ctx);
setChips(buildChips(ctx));
})();
}, [clientId, caseId]);
const filteredCases = useMemo(
() => (clientId ? caseOptions.filter((k) => k.client_id === clientId) : caseOptions),
[caseOptions, clientId],
);
const filteredChips = useMemo(() => {
const q = chipFilter.trim().toLowerCase();
if (!q) return chips;
return chips.filter((c) => c.token.toLowerCase().includes(q) || c.label.toLowerCase().includes(q));
}, [chips, chipFilter]);
const copyChip = async (token: string) => {
try {
await navigator.clipboard.writeText(token);
toast.success(`Copied ${token}`);
} catch {
toast.error("Copy failed");
}
};
// Attorney signature (loaded from profile)
const [includeSignature, setIncludeSignature] = useState(true);
const [sigBlock, setSigBlock] = useState("");
@@ -129,6 +179,8 @@ function PleadingNewPage() {
if (typeof p.footerRight === "string") setFooterRight(p.footerRight);
if (Array.isArray(p.serviceList)) setServiceList(p.serviceList);
if (typeof p.serviceListTitle === "string") setServiceListTitle(p.serviceListTitle);
if (typeof p.clientId === "string") setClientId(p.clientId);
if (typeof p.caseId === "string") setCaseId(p.caseId);
setDocName(`${data.name} (copy)`);
toast.success("Loaded saved pleading");
})();
@@ -145,7 +197,38 @@ function PleadingNewPage() {
if (c && !c.counties.includes(county)) setCounty(c.counties[0]);
};
const varMap = useMemo(() => buildVarMap(varCtx), [varCtx]);
const sub = (s: string) => applyVars(s, varMap);
const subFootnotes = (fns: PleadingFootnote[]) => fns.map((f) => ({ ...f, text: sub(f.text) }));
const subServiceList = (list: ServiceContact[]) =>
list.map((c) => ({
...c,
name: sub(c.name),
role: c.role ? sub(c.role) : c.role,
company: c.company ? sub(c.company) : c.company,
email: c.email ? sub(c.email) : c.email,
phone: c.phone ? sub(c.phone) : c.phone,
address: c.address ? sub(c.address) : c.address,
}));
const input = {
courtType, circuit, county,
plaintiffs: sub(plaintiffs),
defendants: sub(defendants),
caseNumber: sub(caseNumber),
title: sub(title),
bodyHtml: sub(bodyHtml),
footnotes: subFootnotes(footnotes),
footerLeft: sub(footerLeft),
footerCenter: sub(footerCenter),
footerRight: sub(footerRight),
signature: buildSignature(),
serviceList: subServiceList(serviceList),
serviceListTitle: sub(serviceListTitle),
};
// Raw input (no substitution) for saving the source so tokens persist
const rawInput = {
courtType, circuit, county,
plaintiffs, defendants, caseNumber,
title, bodyHtml, footnotes,
@@ -153,6 +236,8 @@ function PleadingNewPage() {
signature: buildSignature(),
serviceList,
serviceListTitle,
clientId: clientId || null,
caseId: caseId || null,
};
const headerLine1 = `IN THE ${courtType} COURT OF THE ${circuit} JUDICIAL CIRCUIT,`;
@@ -180,7 +265,7 @@ function PleadingNewPage() {
const { error: insErr } = await supabase.from("generated_documents").insert({
name: docName || "Pleading",
kind: "pleading",
payload: input as any,
payload: rawInput as any,
storage_path: path,
created_by: user?.id,
});
@@ -223,6 +308,103 @@ function PleadingNewPage() {
}
/>
{/* Client / case linkage + variable picker */}
<Card className="mb-6">
<CardContent className="p-5 space-y-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<Label className="text-base flex items-center gap-2">
<Variable className="h-4 w-4 text-muted-foreground" /> Link client &amp; case
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Pick a client and/or case to expose variables (including custom fields). Insert tokens like{" "}
<code className="px-1 rounded bg-muted">{`{{client.name}}`}</code> anywhere in the body, caption, footnotes, or footer — they’ll be substituted on download &amp; save.
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Client</Label>
<Select
value={clientId || "__none__"}
onValueChange={(v) => {
const next = v === "__none__" ? "" : v;
setClientId(next);
if (next && caseId) {
const k = caseOptions.find((x) => x.id === caseId);
if (k && k.client_id !== next) setCaseId("");
}
}}
>
<SelectTrigger><SelectValue placeholder="None" /></SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value="__none__">— None —</SelectItem>
{clientOptions.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Case</Label>
<Select value={caseId || "__none__"} onValueChange={(v) => setCaseId(v === "__none__" ? "" : v)}>
<SelectTrigger><SelectValue placeholder="None" /></SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value="__none__">— None —</SelectItem>
{filteredCases.map((k) => (
<SelectItem key={k.id} value={k.id}>
{k.case_number} — {k.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{chips.length > 0 ? (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs">Available variables ({chips.length})</Label>
<Input
value={chipFilter}
onChange={(e) => setChipFilter(e.target.value)}
placeholder="Filter…"
className="h-8 max-w-[220px]"
/>
</div>
<div className="flex flex-wrap gap-1.5 max-h-48 overflow-auto p-2 border rounded bg-muted/20">
{filteredChips.length === 0 ? (
<span className="text-xs text-muted-foreground italic">No matching variables.</span>
) : (
filteredChips.map((c) => (
<button
key={c.token}
type="button"
onClick={() => copyChip(c.token)}
title={c.value ? `Click to copy · current value: ${c.value}` : `Click to copy · (empty)`}
className="group inline-flex items-center gap-1 rounded border bg-background px-2 py-1 text-xs hover:border-primary hover:bg-accent transition-colors"
>
<code className="font-mono">{c.token}</code>
<span className="text-muted-foreground">· {c.label}</span>
{!c.value && <Badge variant="secondary" className="ml-1 text-[10px] px-1 py-0">empty</Badge>}
<Copy className="h-3 w-3 opacity-0 group-hover:opacity-70 transition-opacity" />
</button>
))
)}
</div>
<p className="text-[11px] text-muted-foreground">
Click a chip to copy its token, then paste it where you want the value to appear.
</p>
</div>
) : (
<div className="text-xs text-muted-foreground italic border border-dashed rounded p-3">
Select a client or case to see available variables.
</div>
)}
</CardContent>
</Card>
{/* Top row: settings + caption preview */}
<div className="grid lg:grid-cols-2 gap-6">
<Card>