ARC decision letters: launch letter editor prefilled with ARC merge fields

- ARC detail header: Decision Letter button -> Forms & Letters (Letters tab) with
  association/owner/subject + projectTitle/decisionStatus/arcDate/decisionDate/
  propertyAddress/decisionNotes context
- LetterGenerator: resolves ARC tokens (before generic ones), new {{mailingAddress}}
  token, click-to-insert merge-field palette; templates via existing Save Template flow

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 15:56:27 -04:00
parent c1fad194f7
commit 4c7fe7840b
2 changed files with 141 additions and 3 deletions
+107 -2
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { useLocation } from "react-router-dom";
import { formatMailingAddress } from '@/lib/formatMailingAddress';
import { FileText, Download, Users, Search, Plus, Save, Eye, FolderOpen, Trash2, UserPlus, X, FolderUp, CalendarIcon, Building2 } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -333,8 +334,27 @@ const parseHtmlToSegments = (html) => {
return blocks;
};
// Merge fields available in the letter body. ARC-scoped tokens resolve to the
// application the editor was launched from (ARC page → "Decision Letter");
// elsewhere they resolve to blank, so templates can be authored any time.
const LETTER_VARIABLES = [
{ token: "{{ownerName}}", label: "Owner name(s)" },
{ token: "{{mailingAddress}}", label: "Mailing address" },
{ token: "{{propertyAddress}}", label: "Property address" },
{ token: "{{associationName}}", label: "Association name" },
{ token: "{{currentDate}}", label: "Letter date" },
];
const ARC_VARIABLES = [
{ token: "{{projectTitle}}", label: "Project title" },
{ token: "{{decisionStatus}}", label: "Decision status" },
{ token: "{{arcDate}}", label: "ARC date (submitted)" },
{ token: "{{decisionDate}}", label: "Decision date" },
{ token: "{{decisionNotes}}", label: "Decision notes" },
];
export default function LetterGenerator() {
const { toast } = useToast();
const location = useLocation();
const quillRef = useRef(null);
const [associations, setAssociations] = useState([]);
const [selectedAssociation, setSelectedAssociation] = useState("");
@@ -351,6 +371,9 @@ export default function LetterGenerator() {
// Custom variables for the association
const [customVars, setCustomVars] = useState([]);
// ARC decision-letter context (set when launched from the ARC page)
const [arcVars, setArcVars] = useState(null);
// Manual recipients
const [manualRecipients, setManualRecipients] = useState([]);
const [showManualDialog, setShowManualDialog] = useState(false);
@@ -383,6 +406,16 @@ export default function LetterGenerator() {
const handleLoadTemplate = async (id) => { const r = await templates.loadTemplate(id); if (r?.form_data) applyFormData(r.form_data); };
const handleNewTemplate = () => { templates.newTemplate(); setSelectedAssociation(""); setSelectedOwners([]); setSubject(""); setCertifiedMail(""); setTemplateName(""); setEditorContent(""); setManualRecipients([]); setCurrentLetterId(null); setLetterName(""); };
// Insert a {{token}} at the current cursor position in the editor
const insertVariable = useCallback((token) => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = quill.getSelection(true);
const index = range ? range.index : quill.getLength();
quill.insertText(index, token);
quill.setSelection(index + token.length);
}, []);
// Insert page break handler
const insertPageBreak = useCallback(() => {
const quill = quillRef.current?.getEditor();
@@ -432,6 +465,18 @@ export default function LetterGenerator() {
}
}, [insertPageBreak]);
// Prefill from the ARC page ("Decision Letter" button): association, owner,
// subject, and the application's merge-field values.
useEffect(() => {
const ctx = location.state?.arcLetter;
if (!ctx) return;
if (ctx.associationId) setSelectedAssociation(ctx.associationId);
if (ctx.ownerId) setSelectedOwners([ctx.ownerId]);
if (ctx.subject) setSubject((prev) => prev || ctx.subject);
setArcVars(ctx.vars || null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
supabase.from("associations").select("id, name").eq("status", "active").order("name").then(({ data }) => {
if (data) setAssociations(data);
@@ -798,13 +843,34 @@ export default function LetterGenerator() {
});
}
// Resolve variables in HTML before parsing
let resolvedHtml = (editorContent || "")
// Single-line mailing address so {{mailingAddress}} flows inside a sentence
const mailingAddressInline = (recipientAddr || "")
.split(/\n+/).map((l) => l.trim()).filter(Boolean).join(", ");
// Resolve variables in HTML before parsing. ARC decision-letter tokens
// resolve FIRST so {{propertyAddress}} means the ARC property (not the
// recipient's mailing address) when launched from an ARC application.
let resolvedHtml = editorContent || "";
if (arcVars) {
resolvedHtml = resolvedHtml
.replace(/\{\{projectTitle\}\}/gi, arcVars.projectTitle || "")
.replace(/\{\{decisionStatus\}\}/gi, arcVars.decisionStatus || "")
.replace(/\{\{arcDate\}\}/gi, arcVars.arcDate || "")
.replace(/\{\{decisionDate\}\}/gi, arcVars.decisionDate || "")
.replace(/\{\{decisionNotes\}\}/gi, arcVars.decisionNotes || "")
.replace(/\{\{propertyAddress\}\}/gi, arcVars.propertyAddress || "");
} else {
// Authored-anywhere templates: ARC tokens resolve to blank outside ARC mode
resolvedHtml = resolvedHtml
.replace(/\{\{(projectTitle|decisionStatus|arcDate|decisionDate|decisionNotes)\}\}/gi, "");
}
resolvedHtml = resolvedHtml
.replace(/\{\{clientName\}\}/gi, association?.name || "")
.replace(/\{\{AssociationName\}\}/gi, association?.name || "")
.replace(/\{\{ownerName\}\}/gi, recipientName || "")
.replace(/\{\{ownersNames\}\}/gi, recipientName || "")
.replace(/\{\{Owners\}\}/gi, recipientName || "")
.replace(/\{\{mailingAddress\}\}/gi, mailingAddressInline)
.replace(/\{\{propertyAddress\}\}/gi, recipientAddr || "")
.replace(/\{\{currentDate\}\}/gi, dateStr)
.replace(/\{\{Date\}\}/gi, dateStr)
@@ -1332,6 +1398,45 @@ export default function LetterGenerator() {
/>
</div>
{/* Merge fields — click to insert at the cursor */}
<div className="rounded-md border border-border bg-muted/30 px-3 py-2">
<p className="text-[10px] font-semibold uppercase text-muted-foreground mb-1.5">Merge Fields</p>
<div className="flex flex-wrap gap-1">
{LETTER_VARIABLES.map((v) => (
<button
key={v.token}
type="button"
onClick={() => insertVariable(v.token)}
title={`Insert ${v.token}`}
className="px-1.5 py-0.5 rounded border border-border bg-background font-mono text-[10px] text-primary hover:bg-primary/10 transition-colors"
>
{v.label}
</button>
))}
</div>
<p className="text-[10px] font-semibold uppercase text-muted-foreground mt-2 mb-1.5">
ARC Decision Fields
{arcVars?.projectTitle ? (
<span className="ml-1 normal-case font-normal text-primary">· loaded: {arcVars.projectTitle}</span>
) : (
<span className="ml-1 normal-case font-normal">· filled when opened from an ARC application</span>
)}
</p>
<div className="flex flex-wrap gap-1">
{ARC_VARIABLES.map((v) => (
<button
key={v.token}
type="button"
onClick={() => insertVariable(v.token)}
title={`Insert ${v.token}`}
className="px-1.5 py-0.5 rounded border border-border bg-background font-mono text-[10px] text-primary hover:bg-primary/10 transition-colors"
>
{v.label}
</button>
))}
</div>
</div>
{/* QR Code hint */}
<div className="rounded-md border border-border bg-muted/30 px-3 py-2">
<p className="text-[10px] font-semibold uppercase text-muted-foreground mb-1">QR Code Variable</p>
+34 -1
View File
@@ -19,7 +19,7 @@ import { useDropzone } from "react-dropzone";
import {
Plus, Search, Gavel, Filter, FileText, Calendar, Building2, User,
Home, Clock, ChevronRight, CheckCircle2, XCircle, AlertCircle,
MessageSquare, Image, Paperclip, RefreshCw, Download, UploadCloud, X, File, Trash2,
MessageSquare, Image, Paperclip, RefreshCw, Download, UploadCloud, X, File, Trash2, Mail,
} from "lucide-react";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
@@ -835,6 +835,7 @@ function ARCDetailPanel({
}) {
const st = getStatus(app.status);
const StIcon = st.icon;
const navigate = useNavigate();
const [decisionNotes, setDecisionNotes] = useState(app.decision_notes || "");
const [activeSection, setActiveSection] = useState<"details" | "review" | "timeline">("details");
const [voteRefreshKey, setVoteRefreshKey] = useState(0);
@@ -938,6 +939,38 @@ function ARCDetailPanel({
<StIcon className="h-3 w-3" /> {st.label}
</Badge>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] gap-1"
title="Open the letter editor prefilled with this application's owner and merge fields"
onClick={() => {
const decisionLabel =
app.status === "approved" ? "Approved" :
app.status === "denied" ? "Denied" :
st.label;
const fmt = (d: string | null | undefined) => (d ? format(new Date(d), "MMMM d, yyyy") : "");
navigate("/dashboard/forms-letters?tab=letters", {
state: {
arcLetter: {
associationId: app.association_id,
ownerId: app.owner_id || null,
subject: `ARC Application ${decisionLabel}: ${app.title || ""}`,
vars: {
projectTitle: app.title || "",
decisionStatus: decisionLabel,
arcDate: fmt(app.submitted_date || app.created_at),
decisionDate: fmt(app.review_date),
propertyAddress: app.units?.address || app.owners?.property_address || "",
decisionNotes: app.decision_notes || "",
},
},
},
});
}}
>
<Mail className="h-3 w-3" /> Decision Letter
</Button>
<Button
size="sm"
variant="outline"