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

474 lines
20 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 } from "@/lib/docx-pleading";
import { downloadPleadingPdf } from "@/lib/pdf-pleading";
import { RichTextEditor } from "@/components/documents/rich-text-editor";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { toast } from "sonner";
import { Download, FileText, Save, Loader2, Plus, Trash2, PenLine } from "lucide-react";
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);
// 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);
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 input = {
courtType, circuit, county,
plaintiffs, defendants, caseNumber,
title, bodyHtml, footnotes,
footerLeft, footerCenter, footerRight,
signature: buildSignature(),
};
const headerLine1 = `IN THE ${courtType} COURT OF THE ${circuit} JUDICIAL CIRCUIT,`;
const headerLine2 = `IN AND FOR ${county.toUpperCase()} COUNTY, FLORIDA`;
const onDownload = async () => {
await downloadPleading(input, docName || "Pleading");
};
const onDownloadPdf = async () => {
await downloadPleadingPdf(input, docName || "Pleading");
};
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: input 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>
</>
}
/>
{/* 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-r border-b border-black font-bold">
<div className="whitespace-pre-line min-h-[1.5em]">{plaintiffs || " "}</div>
<div className="mt-3">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">Defendant(s).</div>
</div>
<div className="w-[40%] pl-4">
<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>
{/* Footer */}
<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>
);
}