Added letterhead/footer editor
X-Lovable-Edit-ID: edt-fc8d27c5-de45-46fc-aa02-adab061a3717 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -83,7 +83,31 @@ export function LetterGenerator() {
|
||||
const renderedBody = applyVariables(body, ctx);
|
||||
const renderedSubject = applyVariables(subject, ctx);
|
||||
|
||||
// Use the template's font / size / margin choices for the body.
|
||||
// Build firm token map (used by both letterhead and footer).
|
||||
const firmAddress = [
|
||||
firm?.address_line1,
|
||||
firm?.address_line2,
|
||||
[firm?.city, firm?.state, firm?.postal_code].filter(Boolean).join(", "),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const tokenMap: Record<string, string> = {
|
||||
"firm.name": firm?.company_name ?? "",
|
||||
"firm.address": firmAddress,
|
||||
"firm.address1": firm?.address_line1 ?? "",
|
||||
"firm.address2": firm?.address_line2 ?? "",
|
||||
"firm.city": firm?.city ?? "",
|
||||
"firm.state": firm?.state ?? "",
|
||||
"firm.postal": firm?.postal_code ?? "",
|
||||
"firm.phone": firm?.contact_phone ?? "",
|
||||
"firm.email": firm?.contact_email ?? "",
|
||||
"firm.website": (firm as any)?.website ?? "",
|
||||
"owner.name": homeowner ? `${homeowner.first_name} ${homeowner.last_name}`.trim() : "",
|
||||
"client.name": client?.name ?? "",
|
||||
attorney: attorneyName,
|
||||
date: fmtDateLong(date),
|
||||
};
|
||||
|
||||
const bodyFont = (template.font ?? "times") as "helvetica" | "times" | "courier";
|
||||
const bodyFontSize = template.fontSizePt ?? 11;
|
||||
const margin = template.marginPt ?? 60;
|
||||
@@ -91,64 +115,95 @@ export function LetterGenerator() {
|
||||
|
||||
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
||||
const pageW = doc.internal.pageSize.getWidth();
|
||||
const pageH = doc.internal.pageSize.getHeight();
|
||||
const maxW = pageW - margin * 2;
|
||||
const centerX = pageW / 2;
|
||||
|
||||
// ----- Letterhead -----
|
||||
const lhAlign = template.letterheadAlign ?? "center";
|
||||
const lhFontSize = template.letterheadFontSizePt ?? 10;
|
||||
const logoPos = template.logoPosition ?? "above-center";
|
||||
const logoW = template.logoWidthPt ?? 50;
|
||||
const logoH = logoW; // square assumption; jsPDF will scale if not perfectly square
|
||||
const letterheadLines = (template.letterhead ?? "")
|
||||
.split("\n")
|
||||
.map((l) => fillTokens(l, tokenMap));
|
||||
|
||||
let y = 50;
|
||||
|
||||
// Centered logo
|
||||
if (logoDataUrl) {
|
||||
// Logo above
|
||||
const hasLogo =
|
||||
logoDataUrl && logoPos !== "none" && (logoPos === "above-center" || logoPos === "above-left");
|
||||
if (hasLogo) {
|
||||
try {
|
||||
const imgW = 50;
|
||||
const imgH = 50;
|
||||
doc.addImage(logoDataUrl, "PNG", centerX - imgW / 2, y, imgW, imgH);
|
||||
y += imgH + 8;
|
||||
const lx = logoPos === "above-center" ? centerX - logoW / 2 : margin;
|
||||
doc.addImage(logoDataUrl!, "PNG", lx, y, logoW, logoH);
|
||||
y += logoH + 8;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// Firm name (left) + Attorney name italic (right)
|
||||
const firmName = (firm?.company_name ?? "").toUpperCase();
|
||||
doc.setFont(bodyFont, "bold");
|
||||
doc.setFontSize(12);
|
||||
if (firmName) doc.text(firmName, margin, y);
|
||||
if (attorneyName) {
|
||||
doc.setFont(bodyFont, "italic");
|
||||
doc.setFontSize(11);
|
||||
doc.text(attorneyName, pageW - margin, y, { align: "right" });
|
||||
// Letterhead text (with optional inline left/right logo)
|
||||
const inlineLogoLeft = logoDataUrl && logoPos === "left";
|
||||
const inlineLogoRight = logoDataUrl && logoPos === "right";
|
||||
const textLeft = inlineLogoLeft ? margin + logoW + 12 : margin;
|
||||
const textRight = inlineLogoRight ? pageW - margin - logoW - 12 : pageW - margin;
|
||||
const textCenter = (textLeft + textRight) / 2;
|
||||
const textBlockTop = y;
|
||||
|
||||
if (inlineLogoLeft) {
|
||||
try {
|
||||
doc.addImage(logoDataUrl!, "PNG", margin, y, logoW, logoH);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
y += 14;
|
||||
if (inlineLogoRight) {
|
||||
try {
|
||||
doc.addImage(logoDataUrl!, "PNG", pageW - margin - logoW, y, logoW, logoH);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Contact row centered, dot-separated
|
||||
const contactParts: string[] = [];
|
||||
const addr = [
|
||||
firm?.address_line1,
|
||||
[firm?.city, firm?.state, firm?.postal_code].filter(Boolean).join(", "),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
if (addr) contactParts.push(addr);
|
||||
if (firm?.contact_phone) contactParts.push(firm.contact_phone);
|
||||
if (firm?.contact_email) contactParts.push(firm.contact_email);
|
||||
doc.setFont(bodyFont, "normal");
|
||||
doc.setFontSize(10);
|
||||
if (contactParts.length) {
|
||||
doc.text(contactParts.join(" \u00B7 "), centerX, y, { align: "center" });
|
||||
y += 8;
|
||||
doc.setFontSize(lhFontSize);
|
||||
letterheadLines.forEach((line, i) => {
|
||||
// First line treated as the firm name → bold + slightly larger.
|
||||
if (i === 0 && line.trim()) {
|
||||
doc.setFont(bodyFont, "bold");
|
||||
doc.setFontSize(lhFontSize + 2);
|
||||
} else {
|
||||
doc.setFont(bodyFont, "normal");
|
||||
doc.setFontSize(lhFontSize);
|
||||
}
|
||||
if (!line.trim()) {
|
||||
y += lhFontSize;
|
||||
return;
|
||||
}
|
||||
const x =
|
||||
lhAlign === "left" ? textLeft : lhAlign === "right" ? textRight : textCenter;
|
||||
const opts = { align: lhAlign } as const;
|
||||
doc.text(line, x, y, opts as any);
|
||||
y += (i === 0 ? lhFontSize + 4 : lhFontSize) + 2;
|
||||
});
|
||||
|
||||
// Make sure y clears any inline logo
|
||||
if (inlineLogoLeft || inlineLogoRight) {
|
||||
y = Math.max(y, textBlockTop + logoH);
|
||||
}
|
||||
y += 6;
|
||||
|
||||
if (template.letterheadRule !== false) {
|
||||
doc.setLineWidth(0.75);
|
||||
doc.line(margin, y, pageW - margin, y);
|
||||
y += 22;
|
||||
}
|
||||
|
||||
doc.setLineWidth(0.75);
|
||||
doc.line(margin, y, pageW - margin, y);
|
||||
y += 28;
|
||||
|
||||
// Centered bold date
|
||||
// ----- Date (centered, bold) -----
|
||||
doc.setFont(bodyFont, "bold");
|
||||
doc.setFontSize(11);
|
||||
doc.text(fmtDateLong(date), centerX, y, { align: "center" });
|
||||
y += 28;
|
||||
|
||||
// Recipient block
|
||||
// ----- Recipient -----
|
||||
doc.setFont(bodyFont, "normal");
|
||||
doc.setFontSize(bodyFontSize);
|
||||
ownerMailingLines(homeowner).forEach((l) => {
|
||||
@@ -173,34 +228,68 @@ export function LetterGenerator() {
|
||||
doc.setFont(bodyFont, "normal");
|
||||
}
|
||||
|
||||
// ----- Body (reserve room for footer) -----
|
||||
const footerLines = (template.footer ?? "")
|
||||
.split("\n")
|
||||
.map((l) => fillTokens(l, tokenMap))
|
||||
.filter((l) => l.trim().length > 0);
|
||||
const footerFontSize = template.footerFontSizePt ?? 9;
|
||||
const footerHeight =
|
||||
footerLines.length > 0
|
||||
? footerLines.length * (footerFontSize + 2) + (template.footerRule !== false ? 8 : 0) + 12
|
||||
: 0;
|
||||
const bodyBottomLimit = pageH - margin - footerHeight - 8;
|
||||
|
||||
const bodyLines = doc.splitTextToSize(renderedBody, maxW);
|
||||
for (const line of bodyLines) {
|
||||
if (y > 740) {
|
||||
if (y > bodyBottomLimit) {
|
||||
doc.addPage();
|
||||
y = 60;
|
||||
y = margin;
|
||||
}
|
||||
doc.text(line, margin, y);
|
||||
y += lh;
|
||||
}
|
||||
|
||||
// Optional signature block from the template (admins can edit it).
|
||||
if (template.signature) {
|
||||
y += 18;
|
||||
const sig = fillTokens(template.signature, {
|
||||
"firm.name": firm?.company_name ?? "",
|
||||
"owner.name": homeowner ? `${homeowner.first_name} ${homeowner.last_name}`.trim() : "",
|
||||
"client.name": client?.name ?? "",
|
||||
});
|
||||
const sig = fillTokens(template.signature, tokenMap);
|
||||
sig.split("\n").forEach((line) => {
|
||||
if (y > 740) {
|
||||
if (y > bodyBottomLimit) {
|
||||
doc.addPage();
|
||||
y = 60;
|
||||
y = margin;
|
||||
}
|
||||
doc.text(line, margin, y);
|
||||
y += lh;
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Footer on every page -----
|
||||
if (footerLines.length > 0) {
|
||||
const total = doc.getNumberOfPages();
|
||||
const footerAlign = template.footerAlign ?? "center";
|
||||
for (let p = 1; p <= total; p++) {
|
||||
doc.setPage(p);
|
||||
let fy = pageH - margin - footerHeight + 8;
|
||||
if (template.footerRule !== false) {
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(margin, fy, pageW - margin, fy);
|
||||
fy += 10;
|
||||
}
|
||||
doc.setFont(bodyFont, "normal");
|
||||
doc.setFontSize(footerFontSize);
|
||||
footerLines.forEach((line) => {
|
||||
const x =
|
||||
footerAlign === "left"
|
||||
? margin
|
||||
: footerAlign === "right"
|
||||
? pageW - margin
|
||||
: centerX;
|
||||
doc.text(line, x, fy, { align: footerAlign } as any);
|
||||
fy += footerFontSize + 2;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
doc.save(`Letter_${date}.pdf`);
|
||||
toast.success("Letter PDF downloaded");
|
||||
};
|
||||
|
||||
@@ -44,6 +44,28 @@ export interface FormTemplateConfig {
|
||||
showCertifiedMail?: boolean;
|
||||
/** Header label for the certified-mail line. */
|
||||
certifiedMailLabel?: string;
|
||||
/** Letterhead (header) shown at the top of letters. Lines support `\n` and `{{firm.*}}` tokens. */
|
||||
letterhead?: string;
|
||||
/** Letterhead alignment. */
|
||||
letterheadAlign?: "left" | "center" | "right";
|
||||
/** Letterhead font size in points. */
|
||||
letterheadFontSizePt?: number;
|
||||
/** Show the firm logo above/with the letterhead. */
|
||||
showLogo?: boolean;
|
||||
/** Logo placement relative to the letterhead text block. */
|
||||
logoPosition?: "above-center" | "above-left" | "left" | "right" | "none";
|
||||
/** Logo width in points. Height auto-scales to keep aspect ratio. */
|
||||
logoWidthPt?: number;
|
||||
/** Show a horizontal rule below the letterhead. */
|
||||
letterheadRule?: boolean;
|
||||
/** Footer text shown at the bottom of every page. Supports `\n` and `{{firm.*}}` tokens. */
|
||||
footer?: string;
|
||||
/** Footer alignment. */
|
||||
footerAlign?: "left" | "center" | "right";
|
||||
/** Footer font size in points. */
|
||||
footerFontSizePt?: number;
|
||||
/** Show a horizontal rule above the footer. */
|
||||
footerRule?: boolean;
|
||||
}
|
||||
|
||||
export const FORM_LABELS: Record<FormKey, string> = {
|
||||
@@ -162,6 +184,18 @@ export const DEFAULT_TEMPLATES: Record<FormKey, FormTemplateConfig> = {
|
||||
titleFontSizePt: 11,
|
||||
marginPt: 60,
|
||||
lineHeightPt: 14,
|
||||
letterhead:
|
||||
"{{firm.name}}\n{{firm.address}}\n{{firm.phone}} · {{firm.email}}",
|
||||
letterheadAlign: "center",
|
||||
letterheadFontSizePt: 10,
|
||||
showLogo: true,
|
||||
logoPosition: "above-center",
|
||||
logoWidthPt: 50,
|
||||
letterheadRule: true,
|
||||
footer: "{{firm.name}} · {{firm.address}} · {{firm.phone}}",
|
||||
footerAlign: "center",
|
||||
footerFontSizePt: 9,
|
||||
footerRule: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Loader2, RotateCcw, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -102,69 +103,78 @@ function TemplateEditor({ formKey, userId }: { formKey: FormKey; userId: string
|
||||
setConfig({ ...DEFAULT_TEMPLATES[formKey] });
|
||||
};
|
||||
|
||||
const showItemLabels = true;
|
||||
const showDefaultLineItems = true;
|
||||
const isLetter = formKey === "letter";
|
||||
const showItemLabels = !isLetter;
|
||||
const showDefaultLineItems = !isLetter;
|
||||
const showLetterheadEditor = isLetter;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Title (centered, top of document)</Label>
|
||||
<Input
|
||||
value={config.title ?? ""}
|
||||
onChange={(e) => update("title", e.target.value)}
|
||||
placeholder="e.g. NOTICE OF LATE ASSESSMENT"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Certified mail label</Label>
|
||||
<Input
|
||||
value={config.certifiedMailLabel ?? ""}
|
||||
onChange={(e) => update("certifiedMailLabel", e.target.value)}
|
||||
placeholder="U.S. Certified Mail #"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!isLetter && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Title (centered, top of document)</Label>
|
||||
<Input
|
||||
value={config.title ?? ""}
|
||||
onChange={(e) => update("title", e.target.value)}
|
||||
placeholder="e.g. NOTICE OF LATE ASSESSMENT"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Certified mail label</Label>
|
||||
<Input
|
||||
value={config.certifiedMailLabel ?? ""}
|
||||
onChange={(e) => update("certifiedMailLabel", e.target.value)}
|
||||
placeholder="U.S. Certified Mail #"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Body</Label>
|
||||
<Textarea
|
||||
rows={8}
|
||||
value={config.body ?? ""}
|
||||
onChange={(e) => update("body", e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Tokens: <code>{`{{client.name}}`}</code>, <code>{`{{owner.name}}`}</code>,{" "}
|
||||
<code>{`{{firm.name}}`}</code>, <code>{`{{date}}`}</code>,{" "}
|
||||
<code>{`{{dueDate}}`}</code>, <code>{`{{total}}`}</code>
|
||||
{formKey === "itf" && <> , <code>{`{{lienRef}}`}</code></>}. Use a blank line to start a
|
||||
new paragraph.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Body</Label>
|
||||
<Textarea
|
||||
rows={8}
|
||||
value={config.body ?? ""}
|
||||
onChange={(e) => update("body", e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Tokens: <code>{`{{client.name}}`}</code>, <code>{`{{owner.name}}`}</code>,{" "}
|
||||
<code>{`{{firm.name}}`}</code>, <code>{`{{date}}`}</code>,{" "}
|
||||
<code>{`{{dueDate}}`}</code>, <code>{`{{total}}`}</code>
|
||||
{formKey === "itf" && <> , <code>{`{{lienRef}}`}</code></>}. Use a blank line to start a
|
||||
new paragraph.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Closing paragraph</Label>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={config.closing ?? ""}
|
||||
onChange={(e) => update("closing", e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Closing paragraph</Label>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={config.closing ?? ""}
|
||||
onChange={(e) => update("closing", e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Signature block</Label>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={config.signature ?? ""}
|
||||
onChange={(e) => update("signature", e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
placeholder="Sincerely, {{firm.name}}"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Signature block</Label>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={config.signature ?? ""}
|
||||
onChange={(e) => update("signature", e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
placeholder="Sincerely, {{firm.name}}"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showLetterheadEditor && (
|
||||
<LetterheadFooterEditor config={config} update={update} />
|
||||
)}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Font</Label>
|
||||
@@ -295,3 +305,144 @@ function TemplateEditor({ formKey, userId }: { formKey: FormKey; userId: string
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LetterheadFooterEditor({
|
||||
config,
|
||||
update,
|
||||
}: {
|
||||
config: FormTemplateConfig;
|
||||
update: <K extends keyof FormTemplateConfig>(key: K, value: FormTemplateConfig[K]) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Company letterhead</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Designed once here, then printed on every letter generated from{" "}
|
||||
<strong>Forms → Letter</strong>. Use tokens like <code>{`{{firm.name}}`}</code>,{" "}
|
||||
<code>{`{{firm.address}}`}</code>, <code>{`{{firm.phone}}`}</code>,{" "}
|
||||
<code>{`{{firm.email}}`}</code>, <code>{`{{firm.website}}`}</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Letterhead text</Label>
|
||||
<Textarea
|
||||
rows={5}
|
||||
value={config.letterhead ?? ""}
|
||||
onChange={(e) => update("letterhead", e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
placeholder={"{{firm.name}}\n{{firm.address}}\n{{firm.phone}} · {{firm.email}}"}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">One line per row. Blank lines add spacing.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Alignment</Label>
|
||||
<Select
|
||||
value={config.letterheadAlign ?? "center"}
|
||||
onValueChange={(v) => update("letterheadAlign", v as any)}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">Left</SelectItem>
|
||||
<SelectItem value="center">Center</SelectItem>
|
||||
<SelectItem value="right">Right</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Font size (pt)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.letterheadFontSizePt ?? 10}
|
||||
onChange={(e) => update("letterheadFontSizePt", Number(e.target.value) || 10)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Logo placement</Label>
|
||||
<Select
|
||||
value={config.logoPosition ?? "above-center"}
|
||||
onValueChange={(v) => update("logoPosition", v as any)}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No logo</SelectItem>
|
||||
<SelectItem value="above-center">Above (centered)</SelectItem>
|
||||
<SelectItem value="above-left">Above (left)</SelectItem>
|
||||
<SelectItem value="left">Left of text</SelectItem>
|
||||
<SelectItem value="right">Right of text</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Logo width (pt)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.logoWidthPt ?? 50}
|
||||
onChange={(e) => update("logoWidthPt", Number(e.target.value) || 50)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={config.letterheadRule ?? true}
|
||||
onCheckedChange={(v) => update("letterheadRule", v)}
|
||||
/>
|
||||
<Label className="text-sm">Draw a horizontal rule below the letterhead</Label>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-5">
|
||||
<h3 className="text-sm font-semibold">Page footer</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Printed at the bottom of every page. Page numbers are added automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Footer text</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={config.footer ?? ""}
|
||||
onChange={(e) => update("footer", e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
placeholder="{{firm.name}} · {{firm.address}} · {{firm.phone}}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Alignment</Label>
|
||||
<Select
|
||||
value={config.footerAlign ?? "center"}
|
||||
onValueChange={(v) => update("footerAlign", v as any)}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">Left</SelectItem>
|
||||
<SelectItem value="center">Center</SelectItem>
|
||||
<SelectItem value="right">Right</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Font size (pt)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.footerFontSizePt ?? 9}
|
||||
onChange={(e) => update("footerFontSizePt", Number(e.target.value) || 9)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-3 pb-1">
|
||||
<Switch
|
||||
checked={config.footerRule ?? true}
|
||||
onCheckedChange={(v) => update("footerRule", v)}
|
||||
/>
|
||||
<Label className="text-sm">Rule above footer</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user