Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 16:45:35 +00:00
co-authored by renee-png
parent 46070b958d
commit 85f1ce98ef
3 changed files with 316 additions and 18 deletions
+297
View File
@@ -0,0 +1,297 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useAuth } from "@/lib/auth";
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 { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Loader2, RotateCcw, Save } from "lucide-react";
import { toast } from "sonner";
import {
DEFAULT_TEMPLATES,
FORM_LABELS,
loadFormTemplate,
saveFormTemplate,
type FormKey,
type FormTemplateConfig,
} from "@/lib/form-templates";
export const Route = createFileRoute("/settings/form-templates")({
component: FormTemplatesPage,
});
const KEYS: FormKey[] = ["nola", "itl", "itf", "estoppel", "letter"];
function FormTemplatesPage() {
const { isAdmin, user, loading: authLoading } = useAuth();
const [tab, setTab] = useState<FormKey>("nola");
if (!authLoading && !isAdmin) {
throw redirect({ to: "/settings" });
}
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Form templates</h2>
<p className="text-sm text-muted-foreground">
Edit the title, body text, fonts, margins, item labels and signature block used by each
built-in form. Changes apply the next time anyone generates a PDF for that form.
</p>
</div>
<Tabs value={tab} onValueChange={(v) => setTab(v as FormKey)}>
<TabsList className="flex-wrap h-auto">
{KEYS.map((k) => (
<TabsTrigger key={k} value={k}>
{FORM_LABELS[k]}
</TabsTrigger>
))}
</TabsList>
{KEYS.map((k) => (
<TabsContent key={k} value={k} className="mt-4">
<TemplateEditor formKey={k} userId={user?.id ?? null} />
</TabsContent>
))}
</Tabs>
</div>
);
}
function TemplateEditor({ formKey, userId }: { formKey: FormKey; userId: string | null }) {
const [config, setConfig] = useState<FormTemplateConfig | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
setConfig(null);
loadFormTemplate(formKey).then(setConfig);
}, [formKey]);
if (!config) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-8">
<Loader2 className="h-4 w-4 animate-spin" /> Loading template…
</div>
);
}
const update = <K extends keyof FormTemplateConfig>(key: K, value: FormTemplateConfig[K]) =>
setConfig({ ...config, [key]: value });
const updateLabel = (
key: keyof NonNullable<FormTemplateConfig["itemLabels"]>,
value: string,
) => setConfig({ ...config, itemLabels: { ...(config.itemLabels ?? {}), [key]: value } });
const handleSave = async () => {
setSaving(true);
try {
await saveFormTemplate(formKey, config, userId);
toast.success("Template saved");
} catch (e: any) {
toast.error(e.message ?? "Save failed");
} finally {
setSaving(false);
}
};
const handleReset = () => {
if (!confirm("Reset this template to the built-in defaults? Unsaved changes will be lost.")) return;
setConfig({ ...DEFAULT_TEMPLATES[formKey] });
};
const showItemLabels = ["itl", "itf", "estoppel"].includes(formKey);
const showDefaultLineItems = formKey === "nola";
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>
<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>Signature block</Label>
<Textarea
rows={4}
value={config.signature ?? ""}
onChange={(e) => update("signature", e.target.value)}
className="font-mono text-sm"
placeholder="Sincerely,&#10;&#10;{{firm.name}}"
/>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Font</Label>
<Select value={config.font ?? "helvetica"} onValueChange={(v) => update("font", v as any)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="helvetica">Helvetica</SelectItem>
<SelectItem value="times">Times</SelectItem>
<SelectItem value="courier">Courier</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Body size (pt)</Label>
<Input
type="number"
value={config.fontSizePt ?? 10}
onChange={(e) => update("fontSizePt", Number(e.target.value) || 10)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Title size (pt)</Label>
<Input
type="number"
value={config.titleFontSizePt ?? 13}
onChange={(e) => update("titleFontSizePt", Number(e.target.value) || 13)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Margin (pt)</Label>
<Input
type="number"
value={config.marginPt ?? 54}
onChange={(e) => update("marginPt", Number(e.target.value) || 54)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Line height (pt)</Label>
<Input
type="number"
value={config.lineHeightPt ?? 13}
onChange={(e) => update("lineHeightPt", Number(e.target.value) || 13)}
/>
</div>
</div>
{showItemLabels && (
<div className="space-y-2">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
Itemized row labels
</Label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{(["assessments", "interest", "lateFees", "adminFees", "lessPayments", "totalDue"] as const).map(
(k) => (
<div key={k} className="space-y-1.5">
<Label className="text-xs capitalize">{k.replace(/([A-Z])/g, " $1")}</Label>
<Input
value={config.itemLabels?.[k] ?? ""}
onChange={(e) => updateLabel(k, e.target.value)}
/>
</div>
),
)}
</div>
</div>
)}
{showDefaultLineItems && (
<div className="space-y-2">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
Default line items (pre-filled when opening this form)
</Label>
{(config.defaultLineItems ?? []).map((it, i) => (
<div key={i} className="grid grid-cols-[1fr_140px_auto] gap-2 items-center">
<Input
value={it.description}
onChange={(e) => {
const next = [...(config.defaultLineItems ?? [])];
next[i] = { ...next[i], description: e.target.value };
update("defaultLineItems", next);
}}
/>
<Input
value={it.amount}
onChange={(e) => {
const next = [...(config.defaultLineItems ?? [])];
next[i] = { ...next[i], amount: e.target.value };
update("defaultLineItems", next);
}}
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
const next = (config.defaultLineItems ?? []).filter((_, idx) => idx !== i);
update("defaultLineItems", next);
}}
>
Remove
</Button>
</div>
))}
<Button
size="sm"
variant="outline"
onClick={() =>
update("defaultLineItems", [
...(config.defaultLineItems ?? []),
{ description: "", amount: "0.00" },
])
}
>
Add line item
</Button>
</div>
)}
<div className="flex items-center justify-between pt-2 border-t">
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="h-4 w-4 mr-2" /> Reset to defaults
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
Save template
</Button>
</div>
</CardContent>
</Card>
);
}
+1
View File
@@ -14,6 +14,7 @@ const TABS = [
{ to: "/settings", label: "Company", exact: true },
{ to: "/settings/fees", label: "Fee schedule" },
{ to: "/settings/custom-fields", label: "Custom case fields" },
{ to: "/settings/form-templates", label: "Form templates" },
{ to: "/settings/workflow", label: "Collections workflow" },
{ to: "/settings/workflows", label: "Task workflows" },
{ to: "/settings/smtp", label: "Email (SMTP)" },