From 4c7fe7840be4c38c0bb1a40ec97064f01414aa27 Mon Sep 17 00:00:00 2001 From: renee-png Date: Fri, 12 Jun 2026 15:56:27 -0400 Subject: [PATCH] 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 --- src/components/forms/LetterGenerator.jsx | 109 ++++++++++++++++++++++- src/pages/ARCApplicationsPage.tsx | 35 +++++++- 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/src/components/forms/LetterGenerator.jsx b/src/components/forms/LetterGenerator.jsx index 5df4dd3..f7bd84e 100644 --- a/src/components/forms/LetterGenerator.jsx +++ b/src/components/forms/LetterGenerator.jsx @@ -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() { /> + {/* Merge fields — click to insert at the cursor */} +
+

Merge Fields

+
+ {LETTER_VARIABLES.map((v) => ( + + ))} +
+

+ ARC Decision Fields + {arcVars?.projectTitle ? ( + · loaded: {arcVars.projectTitle} + ) : ( + · filled when opened from an ARC application + )} +

+
+ {ARC_VARIABLES.map((v) => ( + + ))} +
+
+ {/* QR Code hint */}

QR Code Variable

diff --git a/src/pages/ARCApplicationsPage.tsx b/src/pages/ARCApplicationsPage.tsx index 923678e..ffcbb78 100644 --- a/src/pages/ARCApplicationsPage.tsx +++ b/src/pages/ARCApplicationsPage.tsx @@ -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({ {st.label}
+