mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user