import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect, useMemo, useState } from "react"; import { ProtectedLayout } from "@/components/protected-layout"; import { PageContainer, PageHeader } from "@/components/app-shell"; 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 { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/lib/auth"; import { toast } from "sonner"; import { Download, Loader2, Save, Trash2 } from "lucide-react"; import { downloadGeneric } from "@/lib/docx-pleading"; import { Packer } from "docx"; export const Route = createFileRoute("/documents/templates/$templateId")({ component: TemplateDetailPage, }); interface FieldDef { key: string; label: string; type: "text" | "textarea" | "date" | "number" } function mergeBody(body: string, values: Record) { // Replace tokens, treating unknown tokens as empty so optional fields don't leak const merged = body.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, k) => values[k] ?? ""); // Collapse runs of 3+ blank lines down to 2 (one blank line of separation) return merged.replace(/\n{3,}/g, "\n\n").replace(/[ \t]+\n/g, "\n"); } function formatToday() { return new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); } interface FirmSettings { company_name?: string | null; address_line1?: string | null; address_line2?: string | null; city?: string | null; state?: string | null; postal_code?: string | null; contact_phone?: string | null; contact_email?: string | null; website?: string | null; } function buildFirmHeader(f: FirmSettings | null): string { if (!f) return ""; const lines: string[] = []; if (f.company_name) lines.push(f.company_name); if (f.address_line1) lines.push(f.address_line1); if (f.address_line2) lines.push(f.address_line2); const csz = [f.city, f.state, f.postal_code].filter(Boolean).join(", ").replace(/, (\d)/, " $1"); if (csz) lines.push(csz); const contact = [f.contact_phone, f.contact_email].filter(Boolean).join(" • "); if (contact) lines.push(contact); if (f.website) lines.push(f.website); return lines.join("\n"); } function TemplateDetailPage() { const { templateId } = Route.useParams(); const { user } = useAuth(); const navigate = useNavigate(); const [tpl, setTpl] = useState(null); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [body, setBody] = useState(""); const [fields, setFields] = useState([]); const [values, setValues] = useState>({}); const [docName, setDocName] = useState("Document"); const [saving, setSaving] = useState(false); const [generating, setGenerating] = useState(false); const [firm, setFirm] = useState(null); useEffect(() => { supabase.from("document_templates").select("*").eq("id", templateId).single().then(({ data }) => { if (!data) return; setTpl(data); setName(data.name); setDescription(data.description ?? ""); setBody(data.body ?? ""); setFields(Array.isArray(data.fields) ? (data.fields as unknown as FieldDef[]) : []); setDocName(data.name); }); supabase.from("firm_settings").select("*").maybeSingle().then(({ data }) => setFirm(data as FirmSettings | null)); }, [templateId]); const autoValues = useMemo>(() => { const v: Record = { today: formatToday(), firm_header: buildFirmHeader(firm), re_line: values.re?.trim() ? `Re: ${values.re.trim()}\n\n` : "", certified_mail_line: values.certified_mail_no?.trim() ? `VIA CERTIFIED MAIL NO. ${values.certified_mail_no.trim()}\n` : "", fedex_line: values.fedex_no?.trim() ? `VIA FEDEX TRACKING NO. ${values.fedex_no.trim()}\n` : "", }; return v; }, [firm, values.re, values.certified_mail_no, values.fedex_no]); const merged = useMemo(() => mergeBody(body, { ...autoValues, ...values }), [body, values, autoValues]); const saveTemplate = async () => { setSaving(true); const { error } = await supabase.from("document_templates").update({ name: name.trim(), description: description.trim() || null, body, fields: fields as any, }).eq("id", templateId); setSaving(false); if (error) toast.error(error.message); else toast.success("Saved"); }; const deleteTemplate = async () => { if (!confirm(`Delete template "${name}"?`)) return; const { error } = await supabase.from("document_templates").delete().eq("id", templateId); if (error) { toast.error(error.message); return; } toast.success("Deleted"); navigate({ to: "/documents/templates" }); }; const isLetter = tpl?.kind === "correspondence"; const fontFamily: string = tpl?.font_family || "Bookman Old Style"; const fontSizePt: number = tpl?.font_size_pt || 12; const downloadOnly = async () => { await downloadGeneric( { title: isLetter ? undefined : docName, body: merged, fontFamily, fontSizePt }, docName || "Document", ); }; const generateAndSave = async () => { setGenerating(true); try { const { Document, Paragraph, TextRun, AlignmentType } = await import("docx"); const font = fontFamily; const size = fontSizePt * 2; const mkP = (text: string, bold = false, center = false) => new Paragraph({ alignment: center ? AlignmentType.CENTER : undefined, children: [new TextRun({ text, bold, font, size })] }); const children: any[] = []; if (docName && !isLetter) { children.push(mkP(docName, true, true)); children.push(new Paragraph({ children: [new TextRun({ text: "", font, size })] })); } merged.split("\n").forEach((line) => children.push(mkP(line))); const doc = new Document({ styles: { default: { document: { run: { font, size } } } }, sections: [{ properties: { page: { size: { width: 12240, height: 15840 }, margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } } }, children }], }); const blob = await Packer.toBlob(doc); const safe = (docName || "Document").replace(/[^a-zA-Z0-9._-]/g, "_"); const path = `${user?.id}/${Date.now()}-${safe}.docx`; const { error: upErr } = await supabase.storage.from("generated-documents").upload(path, blob, { contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", }); if (upErr) throw upErr; const { error: insErr } = await supabase.from("generated_documents").insert({ name: docName, kind: "general", template_id: templateId, payload: { values }, body: merged, storage_path: path, created_by: user?.id, }); if (insErr) throw insErr; toast.success("Document generated"); await downloadOnly(); } catch (e: any) { toast.error(e.message ?? "Generate failed"); } finally { setGenerating(false); } }; if (!tpl) return
Loading...
; return ( } />
{/* Left: Template editor */}
Template
setName(e.target.value)} />
setDescription(e.target.value)} />