Files
mylegal-stage-law/src/routes/documents.pleading.new.tsx
T
2026-04-18 19:14:42 +00:00

802 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import { ProtectedLayout } from "@/components/protected-layout";
import { PageContainer, PageHeader } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
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 { RichTextEditor } from "@/components/documents/rich-text-editor";
import { ContactPickerPopover } from "@/components/clients/contact-picker-popover";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { toast } from "sonner";
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 { promptFilename } from "@/lib/prompt-filename";
import { Badge } from "@/components/ui/badge";
export const Route = createFileRoute("/documents/pleading/new")({
component: PleadingNewPage,
validateSearch: (search: Record<string, unknown>) => ({
from: typeof search.from === "string" ? search.from : undefined,
}),
});
function PleadingNewPage() {
const { user } = useAuth();
const navigate = useNavigate();
const { from } = Route.useSearch();
const [courtType, setCourtType] = useState<"CIRCUIT" | "COUNTY">("CIRCUIT");
const [circuit, setCircuit] = useState<string>("ELEVENTH");
const [county, setCounty] = useState<string>("Miami-Dade");
const [plaintiffs, setPlaintiffs] = useState("");
const [defendants, setDefendants] = useState("");
const [caseNumber, setCaseNumber] = useState("");
const [title, setTitle] = useState("");
const [bodyHtml, setBodyHtml] = useState("<p></p>");
const [footnotes, setFootnotes] = useState<PleadingFootnote[]>([]);
const [footerLeft, setFooterLeft] = useState("");
const [footerCenter, setFooterCenter] = useState("");
const [footerRight, setFooterRight] = useState("");
const [docName, setDocName] = useState("Pleading");
const [saving, setSaving] = useState(false);
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("");
const [sigTyped, setSigTyped] = useState("");
const [sigImagePath, setSigImagePath] = useState<string>("");
const [sigImageBytes, setSigImageBytes] = useState<Uint8Array | null>(null);
const [sigImageDataUrl, setSigImageDataUrl] = useState<string>("");
const [sigImageType, setSigImageType] = useState<"png" | "jpg" | "jpeg">("png");
useEffect(() => {
if (!user) return;
(async () => {
const { data } = await supabase
.from("profiles")
.select("*")
.eq("id", user.id)
.maybeSingle();
if (!data) return;
const d = data as any;
setSigBlock(d.signature_block ?? "");
setSigTyped(d.signature_typed ?? "");
setSigImagePath(d.signature_image_path ?? "");
if (d.signature_image_path) {
try {
const url = supabase.storage
.from("avatars")
.getPublicUrl(d.signature_image_path).data.publicUrl;
const res = await fetch(url);
const blob = await res.blob();
const buf = new Uint8Array(await blob.arrayBuffer());
setSigImageBytes(buf);
const reader = new FileReader();
reader.onload = () => setSigImageDataUrl(String(reader.result || ""));
reader.readAsDataURL(blob);
const ext = (d.signature_image_path.split(".").pop() || "png").toLowerCase();
setSigImageType(ext === "jpg" || ext === "jpeg" ? "jpg" : "png");
} catch {
// Ignore – signature image is optional
}
}
})();
}, [user?.id]);
const buildSignature = (): PleadingSignature | undefined => {
if (!includeSignature) return undefined;
if (!sigBlock.trim() && !sigImageBytes && !sigTyped.trim()) return undefined;
return {
block: sigBlock,
typed: sigTyped || undefined,
imageBytes: sigImageBytes ?? undefined,
imageDataUrl: sigImageDataUrl || undefined,
imageType: sigImageType,
};
};
useEffect(() => {
if (!from) return;
(async () => {
const { data, error } = await supabase
.from("generated_documents")
.select("name, payload")
.eq("id", from)
.maybeSingle();
if (error || !data) return;
const p = (data.payload || {}) as any;
if (p.courtType) setCourtType(p.courtType);
if (p.circuit) setCircuit(p.circuit);
if (p.county) setCounty(p.county);
if (typeof p.plaintiffs === "string") setPlaintiffs(p.plaintiffs);
if (typeof p.defendants === "string") setDefendants(p.defendants);
if (typeof p.caseNumber === "string") setCaseNumber(p.caseNumber);
if (typeof p.title === "string") setTitle(p.title);
if (typeof p.bodyHtml === "string") setBodyHtml(p.bodyHtml);
if (Array.isArray(p.footnotes)) setFootnotes(p.footnotes);
if (typeof p.footerLeft === "string") setFooterLeft(p.footerLeft);
if (typeof p.footerCenter === "string") setFooterCenter(p.footerCenter);
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");
})();
}, [from]);
const counties = useMemo(() => {
const c = FL_CIRCUITS.find((x) => x.value === circuit);
return c?.counties ?? [];
}, [circuit]);
const handleCircuitChange = (v: string) => {
setCircuit(v);
const c = FL_CIRCUITS.find((x) => x.value === v);
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,
footerLeft, footerCenter, footerRight,
signature: buildSignature(),
serviceList,
serviceListTitle,
clientId: clientId || null,
caseId: caseId || null,
};
const headerLine1 = `IN THE ${courtType} COURT OF THE ${circuit} JUDICIAL CIRCUIT,`;
const headerLine2 = `IN AND FOR ${county.toUpperCase()} COUNTY, FLORIDA`;
const onDownload = async () => {
const name = promptFilename(docName || "Pleading", "docx");
if (!name) return;
await downloadPleading(input, name);
};
const onDownloadPdf = async () => {
const name = promptFilename(docName || "Pleading", "pdf");
if (!name) return;
await downloadPleadingPdf(input, name);
};
const onSave = async () => {
setSaving(true);
try {
const doc = buildPleadingDoc(input);
const blob = await Packer.toBlob(doc);
const safe = (docName || "Pleading").replace(/[^a-zA-Z0-9._-]/g, "_");
const path = `${user?.id}/${Date.now()}-${safe}.docx`;
const { error: upErr } = await supabase.storage.from("generated-documents").upload(path, blob, {
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
});
if (upErr) throw upErr;
const { error: insErr } = await supabase.from("generated_documents").insert({
name: docName || "Pleading",
kind: "pleading",
payload: rawInput as any,
storage_path: path,
created_by: user?.id,
});
if (insErr) throw insErr;
toast.success("Pleading saved");
navigate({ to: "/documents" });
} catch (e: any) {
toast.error(e.message ?? "Save failed");
} finally {
setSaving(false);
}
};
const addFootnote = () => {
const nextId = (footnotes[footnotes.length - 1]?.id ?? 0) + 1;
setFootnotes([...footnotes, { id: nextId, text: "" }]);
};
const updateFootnote = (id: number, text: string) =>
setFootnotes(footnotes.map((f) => (f.id === id ? { ...f, text } : f)));
const removeFootnote = (id: number) => {
const remaining = footnotes.filter((f) => f.id !== id).map((f, i) => ({ ...f, id: i + 1 }));
setFootnotes(remaining);
};
return (
<ProtectedLayout>
<PageContainer>
<PageHeader
title="New Pleading"
description="Florida court caption · Bookman Old Style, 12pt"
actions={
<>
<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 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
</Button>
</>
}
/>
{/* 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>
<CardContent className="p-5 space-y-5">
<div className="space-y-1.5">
<Label>Document name</Label>
<Input value={docName} onChange={(e) => setDocName(e.target.value)} placeholder="e.g. Complaint - Smith v. Jones" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label>Court</Label>
<Select value={courtType} onValueChange={(v) => setCourtType(v as any)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="CIRCUIT">CIRCUIT</SelectItem>
<SelectItem value="COUNTY">COUNTY</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Judicial circuit</Label>
<Select value={circuit} onValueChange={handleCircuitChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72">
{FL_CIRCUITS.map((c) => (
<SelectItem key={c.value} value={c.value}>{c.label} ({c.value})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>County</Label>
<Select value={county} onValueChange={setCounty}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72">
{counties.map((co) => (
<SelectItem key={co} value={co}>{co}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Plaintiff(s)</Label>
<Textarea rows={3} value={plaintiffs} onChange={(e) => setPlaintiffs(e.target.value)} placeholder="One name per line" />
</div>
<div className="space-y-1.5">
<Label>Defendant(s)</Label>
<Textarea rows={3} value={defendants} onChange={(e) => setDefendants(e.target.value)} placeholder="One name per line" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Case No.</Label>
<Input value={caseNumber} onChange={(e) => setCaseNumber(e.target.value)} placeholder="e.g. 2025-CA-001234" />
</div>
<div className="space-y-1.5">
<Label>Pleading title</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. COMPLAINT FOR DAMAGES" />
</div>
</div>
</CardContent>
</Card>
{/* Caption preview */}
<Card>
<CardContent className="p-0">
<div className="bg-muted/30 px-4 py-2 border-b text-xs uppercase tracking-wider text-muted-foreground">Caption preview</div>
<div
className="p-8 bg-white text-black"
style={{ fontFamily: '"Bookman Old Style", "URW Bookman", Georgia, serif', fontSize: 12 }}
>
<div className="text-center font-bold leading-snug">
<div>{headerLine1}</div>
<div>{headerLine2}</div>
</div>
<div className="mt-6 flex">
<div className="flex-1 pr-4 border-b border-black font-bold pb-2">
<div className="whitespace-pre-line min-h-[1.5em]">{plaintiffs || " "}</div>
<div className="mt-3 pl-8">Plaintiff(s),</div>
<div className="mt-3">v.</div>
<div className="mt-3 whitespace-pre-line min-h-[1.5em]">{defendants || " "}</div>
<div className="mt-3 pl-8">Defendant(s).</div>
</div>
<div className="w-[40%] pl-4 pb-2">
<div className="font-bold">CASE NO.: {caseNumber}</div>
</div>
</div>
{title && (
<div className="text-center font-bold mt-8">{title.toUpperCase()}</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* Full-width body editor */}
<Card className="mt-6">
<CardContent className="p-5 space-y-3">
<div className="flex items-center justify-between">
<div>
<Label className="text-base">Body</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Bookman Old Style, 12pt · formatting and lists carry into the .docx
</p>
</div>
</div>
<RichTextEditor
value={bodyHtml}
onChange={setBodyHtml}
minHeight={500}
/>
</CardContent>
</Card>
{/* Footnotes */}
<Card className="mt-6">
<CardContent className="p-5 space-y-3">
<div className="flex items-center justify-between">
<div>
<Label className="text-base">Footnotes</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Reference them in the body with <code className="px-1 rounded bg-muted">[1]</code>, <code className="px-1 rounded bg-muted">[2]</code>… (use the Footnote toolbar button to insert a superscript marker)
</p>
</div>
<Button variant="outline" size="sm" onClick={addFootnote}>
<Plus className="h-4 w-4 mr-1" /> Add footnote
</Button>
</div>
{footnotes.length === 0 ? (
<div className="text-sm text-muted-foreground italic py-4 text-center border border-dashed rounded">
No footnotes yet.
</div>
) : (
<div className="space-y-2">
{footnotes.map((f) => (
<div key={f.id} className="flex gap-2 items-start">
<div className="font-bold text-sm pt-2 w-8 text-right">{f.id}.</div>
<Textarea
rows={2}
value={f.text}
onChange={(e) => updateFootnote(f.id, e.target.value)}
placeholder="Footnote text…"
className="flex-1"
/>
<Button variant="ghost" size="icon" onClick={() => removeFootnote(f.id)} title="Remove">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Attorney signature */}
<Card className="mt-6">
<CardContent className="p-5 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<Label className="text-base flex items-center gap-2">
<PenLine className="h-4 w-4 text-muted-foreground" /> Attorney signature
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Pulled from your profile. Toggle off to omit. Edit it in{" "}
<a className="underline" href="/settings/profile">Settings → Profile</a>.
</p>
</div>
<div className="flex items-center gap-2">
<Switch checked={includeSignature} onCheckedChange={setIncludeSignature} />
<span className="text-xs text-muted-foreground">
{includeSignature ? "Included" : "Omitted"}
</span>
</div>
</div>
{includeSignature ? (
!sigBlock.trim() && !sigImagePath && !sigTyped.trim() ? (
<div className="text-sm italic text-muted-foreground border border-dashed rounded p-3">
No signature found on your profile yet. Add one in Settings → Profile.
</div>
) : (
<div
className="border rounded-md p-4 bg-white text-black"
style={{ fontFamily: '"Bookman Old Style", Georgia, serif', fontSize: 12 }}
>
<div style={{ marginLeft: "66.6%" }}>
{sigImagePath && sigImageDataUrl ? (
<img
src={sigImageDataUrl}
alt="Signature"
className="mb-1 max-h-[50px] object-contain"
/>
) : sigTyped.trim() ? (
<div
className="mb-1"
style={{ fontFamily: '"Lucida Handwriting", "Brush Script MT", cursive', fontSize: 18 }}
>
{sigTyped}
</div>
) : (
<div className="h-10" />
)}
<div>_______________________________</div>
<pre className="whitespace-pre-wrap font-[inherit] text-[12px] leading-snug mt-1">
{sigBlock || <span className="text-muted-foreground italic">(no block text)</span>}
</pre>
</div>
</div>
)
) : null}
</CardContent>
</Card>
{/* Service list (optional) */}
<Card className="mt-6">
<CardContent className="p-5 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<Label className="text-base flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" /> Service list
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Optional. If you add any contacts, a dedicated page is appended to the pleading listing each one.
</p>
</div>
<ContactPickerPopover
label="Add from contacts"
onPick={(c) => {
setServiceList((prev) => [
...prev,
{
name: c.name,
role: c.title || undefined,
company: c.company || undefined,
email: c.email || undefined,
phone: c.phone || undefined,
address: "",
},
]);
}}
/>
</div>
<div className="space-y-1.5 max-w-sm">
<Label className="text-xs">Page title</Label>
<Input
value={serviceListTitle}
onChange={(e) => setServiceListTitle(e.target.value)}
placeholder="SERVICE LIST"
/>
</div>
{serviceList.length === 0 ? (
<div className="text-sm text-muted-foreground italic py-4 text-center border border-dashed rounded">
No contacts added. Use “Add from contacts” to include a service list page.
</div>
) : (
<div className="space-y-3">
{serviceList.map((c, idx) => (
<div key={idx} className="border rounded-md p-3 space-y-2 bg-muted/10">
<div className="flex items-start justify-between gap-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 flex-1">
<Input
placeholder="Name"
value={c.name}
onChange={(e) =>
setServiceList((prev) =>
prev.map((x, i) => (i === idx ? { ...x, name: e.target.value } : x)),
)
}
/>
<Input
placeholder="Role / title"
value={c.role || ""}
onChange={(e) =>
setServiceList((prev) =>
prev.map((x, i) => (i === idx ? { ...x, role: e.target.value } : x)),
)
}
/>
<Input
placeholder="Company / firm"
value={c.company || ""}
onChange={(e) =>
setServiceList((prev) =>
prev.map((x, i) => (i === idx ? { ...x, company: e.target.value } : x)),
)
}
/>
<Input
placeholder="Email"
value={c.email || ""}
onChange={(e) =>
setServiceList((prev) =>
prev.map((x, i) => (i === idx ? { ...x, email: e.target.value } : x)),
)
}
/>
<Input
placeholder="Phone"
value={c.phone || ""}
onChange={(e) =>
setServiceList((prev) =>
prev.map((x, i) => (i === idx ? { ...x, phone: e.target.value } : x)),
)
}
/>
<Textarea
rows={2}
placeholder="Address (one line per row)"
value={c.address || ""}
onChange={(e) =>
setServiceList((prev) =>
prev.map((x, i) => (i === idx ? { ...x, address: e.target.value } : x)),
)
}
/>
</div>
<Button
variant="ghost"
size="icon"
onClick={() =>
setServiceList((prev) => prev.filter((_, i) => i !== idx))
}
title="Remove"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() =>
setServiceList((prev) => [
...prev,
{ name: "", role: "", company: "", email: "", phone: "", address: "" },
])
}
>
<Plus className="h-4 w-4 mr-1" /> Add blank entry
</Button>
</div>
)}
</CardContent>
</Card>
<Card className="mt-6">
<CardContent className="p-5 space-y-3">
<div>
<Label className="text-base">Page footer</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Three columns. The right column always ends with <code className="px-1 rounded bg-muted">Page X of Y</code>.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Left</Label>
<Input value={footerLeft} onChange={(e) => setFooterLeft(e.target.value)} placeholder="e.g. Smith v. Jones" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Center</Label>
<Input value={footerCenter} onChange={(e) => setFooterCenter(e.target.value)} placeholder="e.g. Case No. 2025-CA-001234" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Right (prefix)</Label>
<Input value={footerRight} onChange={(e) => setFooterRight(e.target.value)} placeholder="(optional text before Page X of Y)" />
</div>
</div>
<div className="border rounded-md p-3 bg-muted/20 grid grid-cols-3 text-xs" style={{ fontFamily: '"Bookman Old Style", Georgia, serif' }}>
<div className="text-left truncate">{footerLeft || <span className="text-muted-foreground italic">left</span>}</div>
<div className="text-center truncate">{footerCenter || <span className="text-muted-foreground italic">center</span>}</div>
<div className="text-right truncate">{footerRight ? `${footerRight} ` : ""}Page 1 of N</div>
</div>
</CardContent>
</Card>
</PageContainer>
</ProtectedLayout>
);
}