diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 83e55c5..78fdb4c 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -2162,6 +2162,8 @@ export type Database = { hourly_rate: number | null id: string phone: string | null + signature_block: string | null + signature_image_path: string | null timezone: string | null title: string | null updated_at: string @@ -2175,6 +2177,8 @@ export type Database = { hourly_rate?: number | null id: string phone?: string | null + signature_block?: string | null + signature_image_path?: string | null timezone?: string | null title?: string | null updated_at?: string @@ -2188,6 +2192,8 @@ export type Database = { hourly_rate?: number | null id?: string phone?: string | null + signature_block?: string | null + signature_image_path?: string | null timezone?: string | null title?: string | null updated_at?: string diff --git a/src/lib/docx-pleading.ts b/src/lib/docx-pleading.ts index e994a14..e40e8aa 100644 --- a/src/lib/docx-pleading.ts +++ b/src/lib/docx-pleading.ts @@ -2,6 +2,7 @@ import { AlignmentType, Document, HeightRule, + ImageRun, Packer, PageOrientation, Paragraph, @@ -23,6 +24,16 @@ export interface PleadingFootnote { text: string; } +export interface PleadingSignature { + /** Multi-line block placed under the signature line (name, bar no., firm, etc.) */ + block: string; + /** Optional PNG/JPG bytes of a scanned signature placed above the line (for DOCX) */ + imageBytes?: ArrayBuffer | Uint8Array; + /** Same image as a data URL (for PDF generator, which needs a string) */ + imageDataUrl?: string; + imageType?: "png" | "jpg" | "jpeg"; +} + export interface PleadingInput { courtType: "CIRCUIT" | "COUNTY"; circuit: string; // e.g. "ELEVENTH" @@ -37,6 +48,7 @@ export interface PleadingInput { footerLeft?: string; footerCenter?: string; footerRight?: string; // user text prepended to "Page X of Y" + signature?: PleadingSignature; } const FONT = "Bookman Old Style"; @@ -292,6 +304,38 @@ export function buildPleadingDoc(input: PleadingInput): Document { input.body.split("\n").forEach((line) => bodyParagraphs.push(p(line))); } + // Signature block (above footnotes, after body) + const sig = input.signature; + if (sig && (sig.block?.trim() || sig.imageBytes)) { + bodyParagraphs.push(emptyP()); + bodyParagraphs.push(emptyP()); + if (sig.imageBytes) { + const data = + sig.imageBytes instanceof Uint8Array + ? sig.imageBytes + : new Uint8Array(sig.imageBytes); + bodyParagraphs.push( + new Paragraph({ + children: [ + new ImageRun({ + type: (sig.imageType as any) || "png", + data, + transformation: { width: 220, height: 70 }, + altText: { title: "Signature", description: "Attorney signature", name: "signature" }, + }), + ], + }), + ); + } else { + // Reserve vertical space for a wet signature + bodyParagraphs.push(emptyP()); + bodyParagraphs.push(emptyP()); + } + // Signature line (underscore rule, ~3 inches) + bodyParagraphs.push(p("_______________________________")); + sig.block.split("\n").forEach((line) => bodyParagraphs.push(p(line))); + } + // Footnotes rendered as endnote-style block at bottom of document const footnotes = (input.footnotes || []).filter((f) => f.text.trim().length > 0); if (footnotes.length > 0) { diff --git a/src/lib/pdf-pleading.ts b/src/lib/pdf-pleading.ts index 204685f..1cc9ca5 100644 --- a/src/lib/pdf-pleading.ts +++ b/src/lib/pdf-pleading.ts @@ -270,6 +270,33 @@ export async function downloadPleadingPdf(input: PleadingInput, filename: string }); } + // Signature block (above footnotes) + const sig = input.signature; + if (sig && (sig.block?.trim() || sig.imageDataUrl)) { + ensureSpace(LINE_H * 6); + y += LINE_H * 1.5; + if (sig.imageDataUrl) { + try { + const fmt = (sig.imageType || "png").toUpperCase(); + // ~3 inches wide, ~1 inch tall + pdf.addImage(sig.imageDataUrl, fmt as any, MARGIN, y - LINE_H, 220, 70); + y += 70 - LINE_H + 4; + } catch { + y += LINE_H * 2; + } + } else { + y += LINE_H * 3; // reserve space for a wet signature + } + setFont(pdf, {}); + pdf.text("_______________________________", MARGIN, y); + y += LINE_H; + sig.block.split("\n").forEach((line) => { + ensureSpace(LINE_H); + pdf.text(line, MARGIN, y); + y += LINE_H; + }); + } + const fns = (input.footnotes || []).filter((f) => f.text.trim().length > 0); if (fns.length > 0) { ensureSpace(LINE_H * 2); diff --git a/src/routes/documents.pleading.new.tsx b/src/routes/documents.pleading.new.tsx index 4cbda98..44be252 100644 --- a/src/routes/documents.pleading.new.tsx +++ b/src/routes/documents.pleading.new.tsx @@ -10,15 +10,16 @@ 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 } from "@/lib/docx-pleading"; +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 } from "lucide-react"; +import { Download, FileText, Save, Loader2, Plus, Trash2, PenLine } from "lucide-react"; export const Route = createFileRoute("/documents/pleading/new")({ component: PleadingNewPage, @@ -46,6 +47,59 @@ function PleadingNewPage() { 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 [sigImagePath, setSigImagePath] = useState(""); + const [sigImageBytes, setSigImageBytes] = useState(null); + const [sigImageDataUrl, setSigImageDataUrl] = useState(""); + 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 ?? ""); + 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); + // Build a data URL for the PDF generator + 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) return undefined; + return { + block: sigBlock, + imageBytes: sigImageBytes ?? undefined, + imageDataUrl: sigImageDataUrl || undefined, + imageType: sigImageType, + }; + }; + useEffect(() => { if (!from) return; (async () => { @@ -89,6 +143,7 @@ function PleadingNewPage() { plaintiffs, defendants, caseNumber, title, bodyHtml, footnotes, footerLeft, footerCenter, footerRight, + signature: buildSignature(), }; const headerLine1 = `IN THE ${courtType} COURT OF THE ${circuit} JUDICIAL CIRCUIT,`; @@ -321,6 +376,56 @@ function PleadingNewPage() { + {/* Attorney signature */} + + +
+
+ +

+ Pulled from your profile. Toggle off to omit. Edit it in{" "} + Settings โ†’ Profile. +

+
+
+ + + {includeSignature ? "Included" : "Omitted"} + +
+
+ + {includeSignature ? ( + !sigBlock.trim() && !sigImagePath ? ( +
+ No signature found on your profile yet. Add one in Settings โ†’ Profile. +
+ ) : ( +
+ {sigImagePath && sigImageDataUrl ? ( + Signature + ) : ( +
+ )} +
_______________________________
+
+                    {sigBlock || (no block text)}
+                  
+
+ ) + ) : null} + + + {/* Footer */} diff --git a/src/routes/settings.profile.tsx b/src/routes/settings.profile.tsx index d7a5db9..1bc3fb0 100644 --- a/src/routes/settings.profile.tsx +++ b/src/routes/settings.profile.tsx @@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Loader2, Upload, Trash2, User as UserIcon } from "lucide-react"; +import { Loader2, Upload, Trash2, User as UserIcon, PenLine } from "lucide-react"; import { toast } from "sonner"; export const Route = createFileRoute("/settings/profile")({ @@ -22,6 +22,8 @@ const EMPTY = { bio: "", timezone: "", avatar_url: "" as string | null | "", + signature_block: "", + signature_image_path: "" as string | null | "", }; function initials(name: string, email: string) { @@ -34,9 +36,16 @@ function ProfilePage() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [uploading, setUploading] = useState(false); + const [uploadingSig, setUploadingSig] = useState(false); const [form, setForm] = useState({ ...EMPTY }); const [email, setEmail] = useState(""); const fileInput = useRef(null); + const sigInput = useRef(null); + + // Derive a public preview URL for the saved signature image + const signatureUrl = form.signature_image_path + ? supabase.storage.from("avatars").getPublicUrl(form.signature_image_path).data.publicUrl + : ""; const load = async () => { if (!user) return; @@ -48,13 +57,16 @@ function ProfilePage() { .maybeSingle(); if (data) { setEmail(data.email || user.email || ""); + const d = data as any; setForm({ full_name: data.full_name ?? "", - title: (data as any).title ?? "", - phone: (data as any).phone ?? "", - bio: (data as any).bio ?? "", - timezone: (data as any).timezone ?? "", - avatar_url: (data as any).avatar_url ?? "", + title: d.title ?? "", + phone: d.phone ?? "", + bio: d.bio ?? "", + timezone: d.timezone ?? "", + avatar_url: d.avatar_url ?? "", + signature_block: d.signature_block ?? "", + signature_image_path: d.signature_image_path ?? "", }); } setLoading(false); @@ -116,6 +128,50 @@ function ProfilePage() { toast.success("Photo removed"); }; + const onSignatureFile = async (file: File) => { + if (!user) return; + if (file.size > 2 * 1024 * 1024) { + toast.error("Signature image must be under 2 MB"); + return; + } + setUploadingSig(true); + const ext = (file.name.split(".").pop() || "png").toLowerCase(); + const path = `${user.id}/signature-${Date.now()}.${ext}`; + const { error } = await supabase.storage + .from("avatars") + .upload(path, file, { upsert: true, contentType: file.type, cacheControl: "3600" }); + if (error) { + setUploadingSig(false); + toast.error("Upload failed", { description: error.message }); + return; + } + const { error: upErr } = await supabase + .from("profiles") + .update({ signature_image_path: path } as any) + .eq("id", user.id); + setUploadingSig(false); + if (upErr) { + toast.error("Could not save signature", { description: upErr.message }); + return; + } + setForm((f) => ({ ...f, signature_image_path: path })); + toast.success("Signature image saved"); + }; + + const removeSignatureImage = async () => { + if (!user) return; + const { error } = await supabase + .from("profiles") + .update({ signature_image_path: null } as any) + .eq("id", user.id); + if (error) { + toast.error("Could not remove", { description: error.message }); + return; + } + setForm((f) => ({ ...f, signature_image_path: "" })); + toast.success("Signature image removed"); + }; + const save = async () => { if (!user) return; setSaving(true); @@ -127,7 +183,8 @@ function ProfilePage() { phone: form.phone || null, bio: form.bio || null, timezone: form.timezone || null, - }) + signature_block: form.signature_block || null, + } as any) .eq("id", user.id); setSaving(false); if (error) { @@ -243,6 +300,86 @@ function ProfilePage() { + + + + Attorney signature + + + +

+ Used in pleadings. The signature line is drawn automatically; the block + below appears under it. Optionally upload a scanned signature image to + place above the line. +

+ + +