Added attorney signatures
X-Lovable-Edit-ID: edt-eecc55c9-7ca4-41fe-8368-e030ad5c7d1c Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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 ?? "");
|
||||
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() {
|
||||
</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 ? (
|
||||
<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 }}
|
||||
>
|
||||
{sigImagePath && sigImageDataUrl ? (
|
||||
<img
|
||||
src={sigImageDataUrl}
|
||||
alt="Signature"
|
||||
className="mb-1 max-h-[60px] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<Card className="mt-6">
|
||||
<CardContent className="p-5 space-y-3">
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const sigInput = useRef<HTMLInputElement>(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() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-serif text-base flex items-center gap-2">
|
||||
<PenLine className="h-4 w-4 text-muted-foreground" /> Attorney signature
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<Field label="Signature block">
|
||||
<Textarea
|
||||
rows={6}
|
||||
value={form.signature_block}
|
||||
onChange={(e) => update("signature_block", e.target.value)}
|
||||
placeholder={`Jane Q. Attorney, Esq.\nFlorida Bar No. 123456\nSmith & Associates, P.A.\n123 Main St., Suite 200\nMiami, FL 33131\n(305) 555-0100 · jane@smithlaw.com`}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Signature image (optional)</Label>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="border rounded-md bg-white p-3 min-w-[220px] min-h-[80px] flex items-center justify-center">
|
||||
{signatureUrl ? (
|
||||
<img
|
||||
src={signatureUrl}
|
||||
alt="Signature"
|
||||
className="max-h-[80px] max-w-[260px] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
No signature image
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
ref={sigInput}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) onSignatureFile(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => sigInput.current?.click()}
|
||||
disabled={uploadingSig}
|
||||
>
|
||||
{uploadingSig ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Upload signature
|
||||
</Button>
|
||||
{form.signature_image_path && (
|
||||
<Button variant="ghost" size="sm" onClick={removeSignatureImage}>
|
||||
<Trash2 className="h-4 w-4 mr-2" /> Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
PNG or JPG with transparent or white background, up to 2 MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Attorney signatures stored on profile
|
||||
ALTER TABLE public.profiles
|
||||
ADD COLUMN IF NOT EXISTS signature_block text,
|
||||
ADD COLUMN IF NOT EXISTS signature_image_path text;
|
||||
Reference in New Issue
Block a user