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:
@@ -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);
|
||||
}
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user