Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / frontend/src/screens/CandidateRecord.tsx |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | frontend/src/screens/CandidateRecord.tsx |
| Original SHA-256 | 641530dc9da7e535dc6ee4cce999df10f848a3b9b9bc7930014eeb3fe8c5db90 |
| Git blob SHA | 84d7c40ad8b388af27dfde0045d40168d8d40876 |
| Reading-copy SHA-256 | 641530dc9da7e535dc6ee4cce999df10f848a3b9b9bc7930014eeb3fe8c5db90 |
Recovered source transcript
Download complete source text ↓790 lines · 29.4 KB
import { useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import {
ArrowLeft,
Clock,
Download,
FileUp,
Hand,
Mail,
Paperclip,
Send,
ShieldCheck,
Users,
} from 'lucide-react'
import { Badge, Modal, Panel } from '../components/ui'
import { Empty, Labeled, Note, Select, btnGhost, btnPrimary, fieldCls } from '../components/form'
import { CAP, can } from '../lib/can'
import { useAuth } from '../lib/useAuth'
import { useCandidate } from '../lib/queries'
import { useOrgPolicy } from '../lib/settingsQueries'
import {
useAddToList,
useCandidateDocuments,
useCandidateMessages,
useClaim,
useLists,
useListsForCandidate,
useRelease,
useRemoveFromList,
useSendMessage,
useSupersedeDocument,
useTemplates,
useWorkActivity,
} from '../lib/workflowQueries'
import { CLAIM_ACTIVITIES, uploadCandidateDocument, workflowApi } from '../lib/workflow'
import { AEDT_DATA_CATEGORIES, useNoticeStatus, useRecordNotice, useSetAccess } from '../lib/governance'
import { accessColor } from '../lib/workflow'
// THE CANDIDATE RECORD.
//
// Everything the recruiter surfaces built this session hang off one person: who is working them, what has
// been sent to them, what documents are held, and which lists they are on. Four screens would have meant four
// places to look before making one phone call.
//
// OPENING THIS PAGE RECORDS THE VISIT. That is deliberate and it is the whole basis of the collision warning:
// presence only means anything if arriving is what registers it. Asking somebody to press a button to say
// they are looking at a record is asking them to do the thing the feature exists to avoid.
const DOCUMENT_TYPES = [
{ value: 'resume', label: 'Resume' },
{ value: 'certification', label: 'Certification' },
{ value: 'clearance', label: 'Clearance paperwork' },
{ value: 'reference', label: 'Reference' },
{ value: 'other', label: 'Other' },
]
export function CandidateRecord() {
const { candidateId = '' } = useParams()
const { principal } = useAuth()
const candidate = useCandidate(candidateId || null)
const mayWrite = can(principal, CAP.candidateWrite)
if (candidate.isError) {
return (
<Panel title="Candidate">
<Note tone="risk">
This record could not be opened. {(candidate.error as Error)?.message}
{' '}It may have been merged into another record, or it may be outside what you can see.
</Note>
</Panel>
)
}
const c = candidate.data
return (
<div className="space-y-4">
<Link to="/bench" className="inline-flex items-center gap-1.5 text-[11px] text-muted hover:text-ink">
<ArrowLeft size={12} /> Back to find people
</Link>
<Panel>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-lg font-bold text-ink">{c?.full_name ?? 'Loading'}</h2>
<p className="text-[11px] text-faint">
{c ? `${c.clearance_level} · ${c.clearance_status} · ${c.years_experience} years` : ''}
{c?.location ? ` · ${c.location}` : ''}
</p>
</div>
{c?.available && (
<Badge color="var(--color-ok)" subtle>
available
</Badge>
)}
</div>
{/* Stated once, where clearance appears, per the product rule. It is not a footnote: a customer FSO
is the only thing that verifies a clearance and this product never claims to. */}
<p className="mt-2 text-[11px] leading-relaxed text-faint">
Clearance here is a self reported indicator, never a verified status. The real check is the hiring
client FSO through DISS at onboarding.
</p>
</Panel>
<AccessPanel candidateId={candidateId} mayWrite={mayWrite} />
<NoticePanel candidateId={candidateId} mayWrite={mayWrite} />
<WorkPanel candidateId={candidateId} mayWrite={mayWrite} />
<div className="grid gap-4 lg:grid-cols-2">
<DocumentsPanel candidateId={candidateId} mayWrite={mayWrite} />
<ListsPanel candidateId={candidateId} mayWrite={mayWrite} />
</div>
<MessagesPanel candidateId={candidateId} mayWrite={mayWrite} />
</div>
)
}
// THE BREAK IN ACCESS CLOCK, which is the fact that decides whether a cleared person is placeable.
//
// Continuous vetting replaced periodic reinvestigation in October 2021, so the reinvestigation date this
// product has always stored describes a world that no longer exists. What matters now is the break: separation
// pulls somebody out of their sponsor's enrollment, and inside twenty four months a new sponsor can usually
// reinstate on paperwork. Outside it, a new investigation is months of lead time.
//
// The dates come from a phone call. There is no import for them, which is why the write is here rather than
// somewhere administrative.
function AccessPanel({ candidateId, mayWrite }: { candidateId: string; mayWrite: boolean }) {
const set = useSetAccess(candidateId)
const [ended, setEnded] = useState('')
const [adj, setAdj] = useState('')
const intel = set.data
return (
<Panel title="Reinstatement position" hint="continuous vetting and the break in access">
{intel ? (
<div className="flex flex-wrap items-center gap-2">
<span
className="rounded-full px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide"
style={{
color: accessColor(intel.posture),
background: `color-mix(in srgb, ${accessColor(intel.posture)} 14%, transparent)`,
}}
>
{intel.label}
</span>
<span className="text-[11px] leading-relaxed text-muted">{intel.detail}</span>
</div>
) : (
<Note tone="muted">
Record when this person was last in access and when their adjudication closed, and their
reinstatement position is worked out from there. Without those dates it cannot be known, which is a
gap in the record rather than a problem with the candidate.
</Note>
)}
{mayWrite && (
<div className="mt-3 flex flex-wrap items-end gap-2 border-t border-line/40 pt-3">
<Labeled label="Currently in access">
<button
type="button"
onClick={() => set.mutate({ cv_enrolled: true, access_ended_on: '' })}
className={btnGhost}
>
They are sponsored now
</button>
</Labeled>
<Labeled label="Access ended" hint="the day they came off their last sponsor">
<input type="date" value={ended} onChange={(e) => setEnded(e.target.value)} className={fieldCls} />
</Labeled>
<Labeled label="Adjudication closed" hint="must be within 5 years for the simple path">
<input type="date" value={adj} onChange={(e) => setAdj(e.target.value)} className={fieldCls} />
</Labeled>
<button
type="button"
disabled={!ended && !adj}
onClick={() =>
set.mutate({
cv_enrolled: ended ? false : undefined,
access_ended_on: ended || undefined,
last_adjudicated_on: adj || undefined,
})
}
className={btnPrimary}
>
{set.isPending ? 'Saving' : 'Save'}
</button>
</div>
)}
{set.isError && (
<div className="mt-2">
<Note tone="risk">{(set.error as Error).message}</Note>
</div>
)}
<p className="pt-2 text-[11px] leading-relaxed text-faint">
An indicator worked out from dates you entered, never an eligibility determination. The real check is
the hiring client FSO through DISS.
</p>
</Panel>
)
}
// THE AUTOMATED DECISION NOTICE.
//
// This product scores people with an automated employment decision tool. Several jurisdictions require the
// person to be told BEFORE that happens, to be told what is assessed, and require the employer to be able to
// prove it: New York City Local Law 144 puts a ten business day floor on the notice, and the EU AI Act treats
// hiring as high risk from August 2026.
//
// THE ROUTE TO RECORD ONE HAS EXISTED SINCE THE GOVERNANCE MODULE SHIPPED AND NOTHING CALLED IT. Production
// carried three scored candidates and zero notices. This panel is the missing half: it asks whether a notice
// exists, says so plainly when one does not, and records one in a click.
function NoticePanel({ candidateId, mayWrite }: { candidateId: string; mayWrite: boolean }) {
const status = useNoticeStatus(candidateId)
const record = useRecordNotice(candidateId)
const policy = useOrgPolicy()
const s = status.data
if (!s) return null
const required = policy.data?.aedt_notice_required ?? false
return (
<Panel title="Automated decision notice" hint={s.on_file ? 'on file' : 'not on file'}>
{s.on_file ? (
<>
<Note tone="ok">
Disclosed {s.disclosed_at ? new Date(s.disclosed_at).toLocaleDateString() : ''}
{s.notice_version ? `, version ${s.notice_version}` : ''}
{s.count > 1 ? `. ${s.count} notices on file, the newest is shown.` : '.'}
</Note>
{s.data_categories && s.data_categories.length > 0 && (
<p className="mt-2 text-[11px] leading-relaxed text-faint">
They were told this tool assesses: {s.data_categories.join(', ')}.
</p>
)}
</>
) : (
<Note tone={required ? 'risk' : 'warn'}>
No notice has been given to this person.{' '}
{required
? 'Your organization requires one before scoring, so this candidate cannot be scored until it is recorded.'
: 'Several jurisdictions require a candidate to be told before an automated tool assesses them, and to be able to prove it.'}
</Note>
)}
{mayWrite && (
<div className="mt-3 border-t border-line/40 pt-3">
<button
type="button"
disabled={record.isPending}
onClick={() => record.mutate({ version: 'v1', categories: AEDT_DATA_CATEGORIES })}
className={s.on_file ? btnGhost : btnPrimary}
>
<ShieldCheck size={13} />{' '}
{record.isPending ? 'Recording' : s.on_file ? 'Record a new notice' : 'Record the notice'}
</button>
{/* The categories are named ON the button's own screen rather than hidden behind it, because what is
being attested to is that the person was told THESE things. */}
<p className="mt-2 text-[11px] leading-relaxed text-faint">
Records that this person was told the tool assesses {AEDT_DATA_CATEGORIES.join(', ')}. It does not
send anything: delivering the notice is yours, this is the evidence that you did.
</p>
{record.isError && (
<div className="mt-2">
<Note tone="risk">{(record.error as Error).message}</Note>
</div>
)}
</div>
)}
</Panel>
)
}
// WorkPanel is the double work warning: who has this record claimed, and who else has been looking at it.
function WorkPanel({ candidateId, mayWrite }: { candidateId: string; mayWrite: boolean }) {
const activity = useWorkActivity('candidate', candidateId)
const claim = useClaim()
const release = useRelease()
const { principal } = useAuth()
const [why, setWhy] = useState('working')
const [note, setNote] = useState('')
const a = activity.data
if (!a) return null
// A record the caller is not allowed to see the activity on says so rather than rendering an empty panel
// that reads as nobody working it, which is the one wrong answer this feature must never give.
if (!a.visible_to_you) {
return (
<Panel title="Who is working this">
<Note tone="muted">
Your organization does not show recruiters each other's work. Your manager can see whether
somebody else is on this record.
</Note>
</Panel>
)
}
const held = a.claim
const mine = held?.mine ?? false
const others = a.touches.filter((t) => !t.mine)
return (
<Panel title="Who is working this" hint={a.claim_required ? 'claiming is expected here' : undefined}>
{held ? (
<div
className={`flex flex-wrap items-center gap-2 rounded-md border px-3 py-2 ${
mine ? 'border-blue/40 bg-blue/10' : 'border-warn/40 bg-warn/10'
}`}
>
<Hand size={14} className={mine ? 'text-blue-soft' : 'text-warn'} />
<span className="text-sm text-ink">
{mine ? 'You have this record' : `${held.user_name} has this record`}
{held.activity ? `, ${held.activity}` : ''}
</span>
{held.note && <span className="text-[11px] text-faint">{held.note}</span>}
<span className="text-[11px] text-faint">
until {new Date(held.expires_at).toLocaleString()}
</span>
{mayWrite && mine && (
<button
type="button"
className={`${btnGhost} ml-auto`}
onClick={() => release.mutate({ type: 'candidate', id: candidateId })}
>
Release it
</button>
)}
{/* TAKING A RECORD OFF A COLLEAGUE IS POSSIBLE AND IS NEVER QUIET. A desk has to be able to move on
a deadline day, so this is not a block; it is a deliberate act with the other person named. */}
{mayWrite && !mine && (
<button
type="button"
className={`${btnGhost} ml-auto`}
onClick={() =>
claim.mutate({
type: 'candidate',
id: candidateId,
activity: why,
note: note.trim(),
steal: true,
})
}
>
Take it from {held.user_name}
</button>
)}
</div>
) : (
mayWrite && (
<form
className="flex flex-wrap items-end gap-2"
onSubmit={(e) => {
e.preventDefault()
claim.mutate({ type: 'candidate', id: candidateId, activity: why, note: note.trim() })
}}
>
<Labeled label="Say you are on this">
<Select
value={why}
choices={CLAIM_ACTIVITIES.map((v) => ({ value: v, label: v }))}
onChange={setWhy}
/>
</Labeled>
<input
value={note}
maxLength={200}
placeholder="Optional note for colleagues"
onChange={(e) => setNote(e.target.value)}
className={`${fieldCls} max-w-xs`}
/>
<button type="submit" disabled={claim.isPending} className={btnPrimary}>
<Hand size={13} /> Claim it
</button>
</form>
)
)}
{a.desk_owner_name && (
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-faint">
<Users size={11} />
{a.desk_owner_user_id === principal?.user_id
? 'This record is on your desk.'
: `${a.desk_owner_name} owns this record.`}
</p>
)}
{others.length > 0 && (
<p className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-faint">
<Clock size={11} />
Recently opened by {others.map((t) => t.user_name).join(', ')}.
</p>
)}
{claim.isError && (
<div className="mt-2">
<Note tone="risk">{(claim.error as Error).message}</Note>
</div>
)}
</Panel>
)
}
function DocumentsPanel({ candidateId, mayWrite }: { candidateId: string; mayWrite: boolean }) {
const docs = useCandidateDocuments(candidateId)
const supersede = useSupersedeDocument(candidateId)
const fileRef = useRef<HTMLInputElement>(null)
const [kind, setKind] = useState('resume')
const [pct, setPct] = useState<number | null>(null)
const [err, setErr] = useState('')
const list = docs.data?.documents ?? []
const current = list.filter((d) => d.is_current)
const superseded = list.filter((d) => !d.is_current)
const upload = async (file: File) => {
setErr('')
setPct(0)
try {
await uploadCandidateDocument(candidateId, file, kind, setPct)
await docs.refetch()
} catch (e) {
setErr((e as Error).message)
} finally {
setPct(null)
if (fileRef.current) fileRef.current.value = ''
}
}
return (
<Panel title="Documents" hint={current.length === 1 ? '1 current' : `${current.length} current`}>
{list.length === 0 ? (
<Empty title="Nothing attached" reason="A resume, a certification, or clearance paperwork lives here." />
) : (
<ul className="space-y-1.5">
{current.map((d) => (
<li key={d.id} className="flex flex-wrap items-center gap-2 text-sm">
<Paperclip size={12} className="text-faint" />
<a
href={workflowApi.documentURL(d.id)}
className="font-semibold text-ink hover:underline"
download
>
{d.file_name}
</a>
<span className="text-[11px] text-faint">
{d.document_type} · {formatBytes(d.size_bytes)} ·{' '}
{new Date(d.created_at).toLocaleDateString()}
{d.uploaded_by_name ? ` · ${d.uploaded_by_name}` : ''}
</span>
{mayWrite && (
<button
type="button"
className="ml-auto text-[11px] text-faint hover:text-warn"
onClick={() => supersede.mutate(d.id)}
>
Mark out of date
</button>
)}
</li>
))}
</ul>
)}
{/* Superseded documents are KEPT and shown folded away rather than deleted. A resume from two years ago
is the evidence of what was true when a past decision was made, which an automated employment
decision tool has to be able to reconstruct. */}
{superseded.length > 0 && (
<details className="mt-3">
<summary className="cursor-pointer text-[11px] text-faint">
{superseded.length === 1 ? '1 older version' : `${superseded.length} older versions`}
</summary>
<ul className="mt-1.5 space-y-1">
{superseded.map((d) => (
<li key={d.id} className="flex items-center gap-2 text-[11px] text-faint">
<Download size={10} />
<a href={workflowApi.documentURL(d.id)} className="hover:underline" download>
{d.file_name}
</a>
<span>{new Date(d.created_at).toLocaleDateString()}</span>
</li>
))}
</ul>
</details>
)}
{mayWrite && (
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-line/40 pt-3">
<Select
value={kind}
choices={DOCUMENT_TYPES}
onChange={setKind}
className="text-xs"
/>
<input
ref={fileRef}
type="file"
className="text-[11px] text-muted file:mr-2 file:rounded-md file:border file:border-line/70 file:bg-transparent file:px-2 file:py-1 file:text-[11px] file:font-semibold file:text-muted"
onChange={(e) => {
const f = e.target.files?.[0]
if (f) void upload(f)
}}
/>
{pct !== null && (
<span className="flex items-center gap-1.5 text-[11px] text-muted">
<FileUp size={11} /> {pct}%
</span>
)}
</div>
)}
{err && (
<div className="mt-2">
<Note tone="risk">{err}</Note>
</div>
)}
</Panel>
)
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`
return `${(n / (1024 * 1024)).toFixed(1)} MB`
}
function ListsPanel({ candidateId, mayWrite }: { candidateId: string; mayWrite: boolean }) {
const on = useListsForCandidate(candidateId)
const all = useLists()
const add = useAddToList()
const remove = useRemoveFromList()
const memberOf = on.data?.lists ?? []
const memberIDs = new Set(memberOf.map((l) => l.id))
const addable = (all.data?.lists ?? []).filter((l) => l.status === 'active' && !memberIDs.has(l.id))
return (
<Panel title="Lists" hint={memberOf.length === 1 ? 'on 1 list' : `on ${memberOf.length} lists`}>
{memberOf.length === 0 ? (
<Empty title="Not on any list" reason="Lists are how a shortlist, a bench, or a do not contact record is kept." />
) : (
<ul className="flex flex-wrap gap-1.5">
{memberOf.map((l) => (
<li
key={l.id}
className="flex items-center gap-1.5 rounded-full border border-line/70 py-1 pl-3 pr-1 text-[11px] text-muted"
>
{l.purpose === 'do_not_contact' && (
<Badge color="var(--color-warn)" subtle>
do not contact
</Badge>
)}
{l.name}
{mayWrite && (
<button
type="button"
aria-label={`Remove from ${l.name}`}
onClick={() => remove.mutate({ listId: l.id, ids: [candidateId] })}
className="grid size-4 place-items-center rounded-full text-faint hover:text-risk"
>
×
</button>
)}
</li>
))}
</ul>
)}
{mayWrite && addable.length > 0 && (
<div className="mt-3 border-t border-line/40 pt-3">
<Select
value=""
choices={[{ value: '', label: 'Add to a list' }, ...addable.map((l) => ({ value: l.id, label: l.name }))]}
onChange={(v) => {
if (v) add.mutate({ listId: v, ids: [candidateId] })
}}
className="text-xs"
/>
</div>
)}
</Panel>
)
}
function MessagesPanel({ candidateId, mayWrite }: { candidateId: string; mayWrite: boolean }) {
const messages = useCandidateMessages(candidateId)
const policy = useOrgPolicy()
const [composeOpen, setComposeOpen] = useState(false)
const list = messages.data?.messages ?? []
// The organization has not proven a mail connection yet, so composing would produce a queued message that
// never leaves. Saying that here is better than a send button that reports success into a void.
const canSend = policy.data?.email_delivery_enabled ?? false
return (
<Panel title="Correspondence" hint={list.length === 1 ? '1 message' : `${list.length} messages`}>
{list.length === 0 ? (
<Empty
title="Nothing sent yet"
reason="Messages sent from here, and mail synced from a connected mailbox, appear on this record."
/>
) : (
<ul className="space-y-2">
{list.map((m) => (
<li key={m.id} className="rounded-md border border-line/60 bg-navy-950/30 px-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-semibold text-ink">{m.subject || 'No subject'}</span>
<span className="flex items-center gap-2 text-[11px] text-faint">
{m.sent_by_name}
{m.sent_at
? new Date(m.sent_at).toLocaleString()
: new Date(m.created_at).toLocaleString()}
<Badge
color={
m.status === 'sent'
? 'var(--color-ok)'
: m.status === 'failed'
? 'var(--color-risk)'
: 'var(--color-warn)'
}
subtle
>
{m.status}
</Badge>
</span>
</div>
<p className="mt-1 whitespace-pre-wrap text-[11px] leading-relaxed text-muted">{m.body}</p>
{m.error_text && (
<p className="mt-1 text-[11px] text-risk">It did not send: {m.error_text}</p>
)}
</li>
))}
</ul>
)}
{mayWrite && (
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-line/40 pt-3">
<button type="button" className={btnPrimary} disabled={!canSend} onClick={() => setComposeOpen(true)}>
<Mail size={13} /> Write to them
</button>
{!canSend && (
<span className="text-[11px] text-faint">
Sending is off until an owner sets up mail under settings.
</span>
)}
</div>
)}
<ComposeModal
open={composeOpen}
onClose={() => setComposeOpen(false)}
candidateId={candidateId}
/>
</Panel>
)
}
function ComposeModal({
open,
onClose,
candidateId,
}: {
open: boolean
onClose: () => void
candidateId: string
}) {
const templates = useTemplates()
const send = useSendMessage(candidateId)
const [templateID, setTemplateID] = useState('')
const [subject, setSubject] = useState('')
const [body, setBody] = useState('')
const [preview, setPreview] = useState<{
to_address: string
subject: string
body: string
sendable: boolean
note: string
} | null>(null)
const [previewErr, setPreviewErr] = useState('')
const runPreview = async () => {
setPreviewErr('')
try {
const p = await workflowApi.previewMessage({
candidate_id: candidateId,
template_id: templateID || undefined,
subject: subject || undefined,
body: body || undefined,
})
setPreview(p)
} catch (e) {
setPreviewErr((e as Error).message)
}
}
const candidateTemplates = (templates.data?.templates ?? []).filter(
(t) => t.audience === 'candidate' && t.status === 'active',
)
return (
<Modal open={open} onClose={onClose} title="Write to this candidate">
<div className="space-y-3">
{candidateTemplates.length > 0 && (
<Labeled label="Start from a template" hint="an administrator keeps these">
<Select
value={templateID}
choices={[
{ value: '', label: 'Write it myself' },
...candidateTemplates.map((t) => ({ value: t.id, label: t.name })),
]}
onChange={(v) => {
setTemplateID(v)
const t = candidateTemplates.find((x) => x.id === v)
if (t) {
setSubject(t.subject)
setBody(t.body)
}
}}
/>
</Labeled>
)}
<Labeled label="Subject">
<input value={subject} maxLength={240} onChange={(e) => setSubject(e.target.value)} className={fieldCls} />
</Labeled>
<Labeled
label="Message"
hint={
templates.data?.placeholders?.length
? `You can use ${templates.data.placeholders.join(', ')}`
: undefined
}
>
<textarea
value={body}
rows={8}
maxLength={20000}
onChange={(e) => setBody(e.target.value)}
className={fieldCls}
/>
</Labeled>
<div className="flex flex-wrap gap-2">
<button type="button" className={btnGhost} onClick={() => void runPreview()}>
See it as they will
</button>
<button
type="button"
className={btnPrimary}
disabled={!preview?.sendable || send.isPending}
onClick={() =>
send.mutate(
{ candidate_id: candidateId, template_id: templateID || undefined, subject, body },
{
onSuccess: () => {
setPreview(null)
onClose()
},
},
)
}
>
<Send size={13} /> {send.isPending ? 'Sending' : 'Send it'}
</button>
</div>
{previewErr && <Note tone="risk">{previewErr}</Note>}
{send.isError && <Note tone="risk">{(send.error as Error).message}</Note>}
{/* THE PREVIEW IS REQUIRED BEFORE SENDING, and that is on purpose. A template fills in a name and an
address from the record, so the only way to know a placeholder resolved is to look at the
resolved message. A send button that is live before then is a way to mail somebody a letter
addressed to a blank. */}
{preview && (
<div className="space-y-2 rounded-md border border-line/60 bg-navy-950/40 p-3">
<div className="text-[11px] uppercase tracking-wider text-faint">To</div>
<div className="text-sm text-ink">{preview.to_address || 'no address on this record'}</div>
<div className="text-[11px] uppercase tracking-wider text-faint">Subject</div>
<div className="text-sm text-ink">{preview.subject}</div>
<div className="text-[11px] uppercase tracking-wider text-faint">Message</div>
<div className="whitespace-pre-wrap text-sm text-muted">{preview.body}</div>
{!preview.sendable && <Note tone="warn">{preview.note || 'This cannot be sent as it stands.'}</Note>}
</div>
)}
{!preview && <p className="text-[11px] text-faint">Preview it before sending.</p>}
</div>
</Modal>
)
}