Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
46070b958d
commit
85f1ce98ef
@@ -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, {{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>
|
||||
);
|
||||
}
|
||||
@@ -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)" },
|
||||
|
||||
Reference in New Issue
Block a user