mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50: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 { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import { formatMailingAddress } from '@/lib/formatMailingAddress';
|
import { formatMailingAddress } from '@/lib/formatMailingAddress';
|
||||||
import { FileText, Download, Users, Search, Plus, Save, Eye, FolderOpen, Trash2, UserPlus, X, FolderUp, CalendarIcon, Building2 } from "lucide-react";
|
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";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
@@ -333,8 +334,27 @@ const parseHtmlToSegments = (html) => {
|
|||||||
return blocks;
|
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() {
|
export default function LetterGenerator() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const location = useLocation();
|
||||||
const quillRef = useRef(null);
|
const quillRef = useRef(null);
|
||||||
const [associations, setAssociations] = useState([]);
|
const [associations, setAssociations] = useState([]);
|
||||||
const [selectedAssociation, setSelectedAssociation] = useState("");
|
const [selectedAssociation, setSelectedAssociation] = useState("");
|
||||||
@@ -351,6 +371,9 @@ export default function LetterGenerator() {
|
|||||||
// Custom variables for the association
|
// Custom variables for the association
|
||||||
const [customVars, setCustomVars] = useState([]);
|
const [customVars, setCustomVars] = useState([]);
|
||||||
|
|
||||||
|
// ARC decision-letter context (set when launched from the ARC page)
|
||||||
|
const [arcVars, setArcVars] = useState(null);
|
||||||
|
|
||||||
// Manual recipients
|
// Manual recipients
|
||||||
const [manualRecipients, setManualRecipients] = useState([]);
|
const [manualRecipients, setManualRecipients] = useState([]);
|
||||||
const [showManualDialog, setShowManualDialog] = useState(false);
|
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 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(""); };
|
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
|
// Insert page break handler
|
||||||
const insertPageBreak = useCallback(() => {
|
const insertPageBreak = useCallback(() => {
|
||||||
const quill = quillRef.current?.getEditor();
|
const quill = quillRef.current?.getEditor();
|
||||||
@@ -432,6 +465,18 @@ export default function LetterGenerator() {
|
|||||||
}
|
}
|
||||||
}, [insertPageBreak]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
supabase.from("associations").select("id, name").eq("status", "active").order("name").then(({ data }) => {
|
supabase.from("associations").select("id, name").eq("status", "active").order("name").then(({ data }) => {
|
||||||
if (data) setAssociations(data);
|
if (data) setAssociations(data);
|
||||||
@@ -798,13 +843,34 @@ export default function LetterGenerator() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve variables in HTML before parsing
|
// Single-line mailing address so {{mailingAddress}} flows inside a sentence
|
||||||
let resolvedHtml = (editorContent || "")
|
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(/\{\{clientName\}\}/gi, association?.name || "")
|
||||||
.replace(/\{\{AssociationName\}\}/gi, association?.name || "")
|
.replace(/\{\{AssociationName\}\}/gi, association?.name || "")
|
||||||
.replace(/\{\{ownerName\}\}/gi, recipientName || "")
|
.replace(/\{\{ownerName\}\}/gi, recipientName || "")
|
||||||
.replace(/\{\{ownersNames\}\}/gi, recipientName || "")
|
.replace(/\{\{ownersNames\}\}/gi, recipientName || "")
|
||||||
.replace(/\{\{Owners\}\}/gi, recipientName || "")
|
.replace(/\{\{Owners\}\}/gi, recipientName || "")
|
||||||
|
.replace(/\{\{mailingAddress\}\}/gi, mailingAddressInline)
|
||||||
.replace(/\{\{propertyAddress\}\}/gi, recipientAddr || "")
|
.replace(/\{\{propertyAddress\}\}/gi, recipientAddr || "")
|
||||||
.replace(/\{\{currentDate\}\}/gi, dateStr)
|
.replace(/\{\{currentDate\}\}/gi, dateStr)
|
||||||
.replace(/\{\{Date\}\}/gi, dateStr)
|
.replace(/\{\{Date\}\}/gi, dateStr)
|
||||||
@@ -1332,6 +1398,45 @@ export default function LetterGenerator() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* QR Code hint */}
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-2">
|
<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>
|
<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 {
|
import {
|
||||||
Plus, Search, Gavel, Filter, FileText, Calendar, Building2, User,
|
Plus, Search, Gavel, Filter, FileText, Calendar, Building2, User,
|
||||||
Home, Clock, ChevronRight, CheckCircle2, XCircle, AlertCircle,
|
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";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||||
@@ -835,6 +835,7 @@ function ARCDetailPanel({
|
|||||||
}) {
|
}) {
|
||||||
const st = getStatus(app.status);
|
const st = getStatus(app.status);
|
||||||
const StIcon = st.icon;
|
const StIcon = st.icon;
|
||||||
|
const navigate = useNavigate();
|
||||||
const [decisionNotes, setDecisionNotes] = useState(app.decision_notes || "");
|
const [decisionNotes, setDecisionNotes] = useState(app.decision_notes || "");
|
||||||
const [activeSection, setActiveSection] = useState<"details" | "review" | "timeline">("details");
|
const [activeSection, setActiveSection] = useState<"details" | "review" | "timeline">("details");
|
||||||
const [voteRefreshKey, setVoteRefreshKey] = useState(0);
|
const [voteRefreshKey, setVoteRefreshKey] = useState(0);
|
||||||
@@ -938,6 +939,38 @@ function ARCDetailPanel({
|
|||||||
<StIcon className="h-3 w-3" /> {st.label}
|
<StIcon className="h-3 w-3" /> {st.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
Reference in New Issue
Block a user