Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / frontend/src/screens/Bench.tsx |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | frontend/src/screens/Bench.tsx |
| Original SHA-256 | 63744f80e6e369fc425cae61b45b0351a424bcc8b730041d5a14f994292ecf97 |
| Git blob SHA | 982f1d4802fe982ca1839a2ffc8e427d69148db7 |
| Reading-copy SHA-256 | 63744f80e6e369fc425cae61b45b0351a424bcc8b730041d5a14f994292ecf97 |
Recovered source transcript
Download complete source text ↓843 lines · 31.7 KB
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import {
Bookmark,
CopyCheck,
BookmarkPlus,
EyeOff,
Filter,
ListPlus,
Search,
Trash2,
UserCheck,
Users,
X,
} from 'lucide-react'
import { Badge, Modal, Panel } from '../components/ui'
import { Empty, Labeled, Note, btnGhost, btnPrimary, fieldCls } from '../components/form'
import { CAP, can } from '../lib/can'
import { useAuth } from '../lib/useAuth'
import { useOrgPolicy } from '../lib/settingsQueries'
import {
useAddToList,
useBulkAssignOwner,
useCandidateSearch,
useCreateList,
useDeleteSavedSearch,
useLists,
useSaveSearch,
useSavedSearches,
} from '../lib/workflowQueries'
import { ACCESS_POSTURES, LIST_PURPOSES, accessColor, type CandidateQuery, type SavedSearch } from '../lib/workflow'
// THE BENCH: the question a recruiter asks all day, which this product could not be asked.
//
// GET /candidates took a limit and an offset and nothing else, so "TS/SCI cleared network engineers in
// Maryland who are available" had no route and no screen. This is the screen.
//
// IT IS NOT THE CANDIDATES INTELLIGENCE VIEW AND MUST NOT BECOME IT. That screen ranks a pool against open
// requirements and answers "who is proposal ready". This one answers "who is there", filtered, across the
// whole organization, and it lives outside every tool gate for the same reason the server mounts it outside
// one: finding a person you already have is not a feature of a purchased product.
//
// EVERY LIST HERE IS ALREADY NARROWED BY THE SERVER, twice: by record scope, which decides which records
// exist for this caller at all, and by field sensitivity, which decides whether an email comes back. The
// screen never re decides either. It only EXPLAINS them, because an answer that is short for a reason reads
// as a broken search unless the reason is on the page.
const CLEARANCE_LEVELS = ['none', 'public_trust', 'secret', 'top_secret', 'ts_sci']
const CLEARANCE_LABELS: Record<string, string> = {
none: 'None',
public_trust: 'Public Trust',
secret: 'Secret',
top_secret: 'Top Secret',
ts_sci: 'TS/SCI',
}
const CLEARANCE_STATUS = ['active', 'current', 'expired', 'unknown']
const emptyQuery: CandidateQuery = { limit: 50, offset: 0 }
export function Bench() {
const { principal } = useAuth()
const policy = useOrgPolicy()
const [draft, setDraft] = useState<CandidateQuery>(emptyQuery)
// The RUNNING query is separate from the draft so typing does not fire a request per keystroke against a
// full text index. The draft is what is on screen; this is what was asked.
const [query, setQuery] = useState<CandidateQuery>(emptyQuery)
const [picked, setPicked] = useState<Set<string>>(new Set())
const [saveOpen, setSaveOpen] = useState(false)
const [listOpen, setListOpen] = useState(false)
const results = useCandidateSearch(query)
const saved = useSavedSearches()
const mayWrite = can(principal, CAP.candidateWrite)
const rows = results.data?.candidates ?? []
const total = results.data?.total ?? 0
const scopeApplied = results.data?.scope_applied ?? false
const allPicked = rows.length > 0 && rows.every((r) => picked.has(r.id))
const activeFilters = useMemo(() => countFilters(query), [query])
const run = (q: CandidateQuery) => {
setQuery({ ...q, offset: 0 })
setDraft({ ...q, offset: 0 })
setPicked(new Set())
}
const toggle = (id: string) => {
const next = new Set(picked)
if (next.has(id)) next.delete(id)
else next.add(id)
setPicked(next)
}
return (
<div className="space-y-4">
<Panel title="Find people" hint="across every record you can see">
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault()
run(draft)
}}
>
<div className="flex gap-2">
<input
value={draft.text ?? ''}
maxLength={400}
placeholder="Name, title, skill, anything on the record"
onChange={(e) => setDraft({ ...draft, text: e.target.value })}
className={fieldCls}
/>
<button type="submit" className={btnPrimary}>
<Search size={14} /> Search
</button>
</div>
<div className="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-4">
<Labeled label="Clearance">
<ChipGroup
options={CLEARANCE_LEVELS.map((v) => ({ value: v, label: CLEARANCE_LABELS[v] ?? v }))}
selected={draft.clearance_levels ?? []}
onChange={(v) => setDraft({ ...draft, clearance_levels: v })}
/>
</Labeled>
<Labeled label="Clearance status">
<ChipGroup
options={CLEARANCE_STATUS.map((v) => ({ value: v, label: v }))}
selected={draft.clearance_status ?? []}
onChange={(v) => setDraft({ ...draft, clearance_status: v })}
/>
</Labeled>
<Labeled label="Location" hint="a partial match, so Columbia finds Columbia, MD">
<input
value={draft.location ?? ''}
maxLength={120}
onChange={(e) => setDraft({ ...draft, location: e.target.value })}
className={fieldCls}
/>
</Labeled>
<Labeled label="Skills" hint="separate with commas, a person must have all of them">
<input
value={(draft.skills ?? []).join(', ')}
onChange={(e) =>
setDraft({
...draft,
skills: e.target.value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
})
}
className={fieldCls}
/>
</Labeled>
<Labeled label="Years of experience">
<div className="flex items-center gap-2">
<input
type="number"
min={0}
placeholder="from"
value={draft.min_years ?? ''}
onChange={(e) =>
setDraft({ ...draft, min_years: e.target.value === '' ? undefined : Number(e.target.value) })
}
className={fieldCls}
/>
<input
type="number"
min={0}
placeholder="to"
value={draft.max_years ?? ''}
onChange={(e) =>
setDraft({ ...draft, max_years: e.target.value === '' ? undefined : Number(e.target.value) })
}
className={fieldCls}
/>
</div>
</Labeled>
<Labeled label="Clearance expiring within" hint="the reinvestigation list nobody keeps by hand">
<select
value={draft.clearance_expiring_days ?? ''}
onChange={(e) =>
setDraft({
...draft,
clearance_expiring_days: e.target.value === '' ? undefined : Number(e.target.value),
})
}
className={fieldCls}
>
<option value="" className="bg-navy-900">
Any
</option>
<option value={30} className="bg-navy-900">
30 days
</option>
<option value={90} className="bg-navy-900">
90 days
</option>
<option value={180} className="bg-navy-900">
180 days
</option>
</select>
</Labeled>
<Labeled
label="Reinstatement"
hint="a cleared person out of access past 24 months needs a new investigation, which is months of lead time"
>
<ChipGroup
options={ACCESS_POSTURES.map((p) => ({ value: p.value, label: p.label }))}
selected={draft.access_postures ?? []}
onChange={(v) => setDraft({ ...draft, access_postures: v })}
/>
</Labeled>
<Labeled label="Ownership">
<select
value={draft.unowned ? 'unowned' : draft.owner_user_id === principal?.user_id ? 'mine' : 'any'}
onChange={(e) => {
const v = e.target.value
setDraft({
...draft,
unowned: v === 'unowned' ? true : undefined,
owner_user_id: v === 'mine' ? principal?.user_id : undefined,
})
}}
className={fieldCls}
>
<option value="any" className="bg-navy-900">
Anyone
</option>
<option value="mine" className="bg-navy-900">
Mine
</option>
<option value="unowned" className="bg-navy-900">
Nobody owns them
</option>
</select>
</Labeled>
<Labeled label="Availability">
<div className="flex flex-wrap gap-1.5">
<Chip
on={draft.available === true}
label="Available"
onClick={() => setDraft({ ...draft, available: draft.available ? undefined : true })}
/>
<Chip
on={draft.remote === true}
label="Remote"
onClick={() => setDraft({ ...draft, remote: draft.remote ? undefined : true })}
/>
<Chip
on={draft.relocatable === true}
label="Will relocate"
onClick={() => setDraft({ ...draft, relocatable: draft.relocatable ? undefined : true })}
/>
</div>
</Labeled>
</div>
<div className="flex flex-wrap items-center gap-2 border-t border-line/40 pt-3">
<span className="flex items-center gap-1.5 text-[11px] text-faint">
<Filter size={12} />
{activeFilters === 0 ? 'No filters' : activeFilters === 1 ? '1 filter' : `${activeFilters} filters`}
</span>
{activeFilters > 0 && (
<button type="button" className={btnGhost} onClick={() => run(emptyQuery)}>
<X size={12} /> Clear
</button>
)}
<button
type="button"
className={btnGhost}
disabled={activeFilters === 0}
onClick={() => setSaveOpen(true)}
>
<BookmarkPlus size={12} /> Save this search
</button>
{/* The duplicate review lives beside the search rather than in its own corner of the navigation,
because noticing you hold somebody twice is something you realise while looking for them. */}
<Link to="/bench/duplicates" className={`${btnGhost} ml-auto`}>
<CopyCheck size={12} /> Possible duplicates
</Link>
</div>
</form>
</Panel>
<SavedSearchBar
searches={saved.data?.saved_searches ?? []}
onRun={(s) => run({ ...s.criteria, limit: 50, offset: 0 })}
currentUserID={principal?.user_id ?? ''}
/>
<Panel
title="Results"
hint={results.isFetching ? 'searching' : total === 1 ? '1 person' : `${total} people`}
>
{/* SCOPE IS EXPLAINED, NEVER SILENT. A recruiter narrowed to their own desk who searches the whole
company and sees four people has to be told why, or they conclude the bench is empty and stop
using the search that was working correctly. */}
{scopeApplied && (
<div className="mb-3">
<Note tone="muted">
You are seeing the records within your scope, not every record in the organization. Your
administrator sets this under organization settings.
</Note>
</div>
)}
{results.isError ? (
<Note tone="risk">The search could not run. {(results.error as Error)?.message}</Note>
) : rows.length === 0 ? (
<Empty
title={activeFilters === 0 ? 'Search the bench' : 'Nobody matches those filters'}
reason={
activeFilters === 0
? 'Type a name or a skill, or narrow by clearance and location, and the matching people appear here.'
: scopeApplied
? 'Nobody within your scope matches. A colleague with a wider scope may see people you cannot.'
: 'Try removing a filter. Skills must all match, so three skills is a narrow question.'
}
/>
) : (
<>
{mayWrite && picked.size > 0 && (
<BulkBar
count={picked.size}
onAddToList={() => setListOpen(true)}
onClear={() => setPicked(new Set())}
ids={[...picked]}
onDone={() => setPicked(new Set())}
/>
)}
<div className="overflow-x-auto">
<table className="w-full min-w-[52rem] text-sm">
<thead>
<tr className="text-left text-[11px] uppercase tracking-wider text-faint">
{mayWrite && (
<th className="w-8 pb-2">
<input
type="checkbox"
aria-label="Select every result on this page"
checked={allPicked}
onChange={() =>
setPicked(allPicked ? new Set() : new Set(rows.map((r) => r.id)))
}
/>
</th>
)}
<th className="pb-2 font-semibold">Candidate</th>
<th className="pb-2 font-semibold">Clearance</th>
<th className="pb-2 font-semibold">Reinstatement</th>
<th className="pb-2 font-semibold">Experience</th>
<th className="pb-2 font-semibold">Location</th>
<th className="pb-2 font-semibold">Contact</th>
</tr>
</thead>
<tbody className="divide-y divide-line/40">
{rows.map((c) => (
<tr key={c.id} className="align-top">
{mayWrite && (
<td className="py-2">
<input
type="checkbox"
aria-label={`Select ${c.full_name}`}
checked={picked.has(c.id)}
onChange={() => toggle(c.id)}
/>
</td>
)}
<td className="py-2">
<Link
to={`/bench/candidate/${encodeURIComponent(c.id)}`}
className="font-semibold text-ink hover:underline"
>
{c.full_name}
</Link>
{c.headline && <div className="text-[11px] text-faint">{c.headline}</div>}
</td>
<td className="py-2">
<span className="text-muted">
{CLEARANCE_LABELS[c.clearance_level] ?? c.clearance_level}
</span>
<div className="text-[11px] text-faint">{c.clearance_status}</div>
</td>
{/* THE POSTURE IN THE LIST, not only on the record. It is the fact that decides
whether a name is worth calling, and a recruiter should not open twenty records to
find the three people they can actually place this month. */}
<td className="py-2">
{c.access ? (
<span
className="rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"
style={{
color: accessColor(c.access.posture),
background: `color-mix(in srgb, ${accessColor(c.access.posture)} 14%, transparent)`,
}}
title={c.access.detail}
>
{c.access.label}
</span>
) : (
<span className="text-[11px] text-faint">not known</span>
)}
</td>
<td className="py-2 tabular-nums text-muted">{c.years_experience} yrs</td>
<td className="py-2 text-muted">
{c.location ?? 'not recorded'}
{c.remote && <span className="ml-1 text-[10px] text-faint">remote</span>}
</td>
<td className="py-2">
{/* Three distinct states, said as three different things. An email; nothing on file;
or something on file that this person is not shown. Rendering the last two the
same way is what sends somebody looking for an address that was there all along. */}
{c.email ? (
<a href={`mailto:${c.email}`} className="text-blue-soft hover:underline">
{c.email}
</a>
) : c.contact_redacted ? (
<span className="flex items-center gap-1 text-[11px] text-faint">
<EyeOff size={11} /> hidden
</span>
) : (
<span className="text-[11px] text-faint">none on file</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{policy.data && !policy.data.can_see_contacts && (
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-faint">
<EyeOff size={11} />
Contact details are hidden for your role in this organization.
</p>
)}
<Pager
total={total}
limit={query.limit ?? 50}
offset={query.offset ?? 0}
onPage={(offset) => setQuery({ ...query, offset })}
/>
</>
)}
</Panel>
<SaveSearchModal open={saveOpen} onClose={() => setSaveOpen(false)} criteria={query} />
<AddToListModal
open={listOpen}
onClose={() => setListOpen(false)}
ids={[...picked]}
onDone={() => {
setListOpen(false)
setPicked(new Set())
}}
/>
</div>
)
}
function countFilters(q: CandidateQuery): number {
let n = 0
if (q.text) n++
if (q.clearance_levels?.length) n++
if (q.clearance_status?.length) n++
if (q.skills?.length) n++
if (q.location) n++
if (q.available) n++
if (q.remote) n++
if (q.relocatable) n++
if (q.min_years !== undefined || q.max_years !== undefined) n++
if (q.clearance_expiring_days !== undefined) n++
if (q.access_postures?.length) n++
if (q.owner_user_id || q.unowned) n++
if (q.list_id || q.exclude_list_id) n++
return n
}
function Chip({ on, label, onClick }: { on: boolean; label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={on}
className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold transition ${
on ? 'border-blue/60 bg-blue/20 text-blue-soft' : 'border-line/70 text-muted hover:text-ink'
}`}
>
{label}
</button>
)
}
function ChipGroup({
options,
selected,
onChange,
}: {
options: { value: string; label: string }[]
selected: string[]
onChange: (v: string[]) => void
}) {
return (
<div className="flex flex-wrap gap-1.5">
{options.map((o) => (
<Chip
key={o.value}
on={selected.includes(o.value)}
label={o.label}
onClick={() =>
onChange(
selected.includes(o.value) ? selected.filter((v) => v !== o.value) : [...selected, o.value],
)
}
/>
))}
</div>
)
}
function Pager({
total,
limit,
offset,
onPage,
}: {
total: number
limit: number
offset: number
onPage: (offset: number) => void
}) {
if (total <= limit) return null
const from = offset + 1
const to = Math.min(offset + limit, total)
return (
<div className="mt-3 flex items-center justify-between border-t border-line/40 pt-3">
<span className="text-[11px] text-faint">
{from} to {to} of {total}
</span>
<div className="flex gap-2">
<button
type="button"
className={btnGhost}
disabled={offset === 0}
onClick={() => onPage(Math.max(0, offset - limit))}
>
Previous
</button>
<button
type="button"
className={btnGhost}
disabled={to >= total}
onClick={() => onPage(offset + limit)}
>
Next
</button>
</div>
</div>
)
}
function SavedSearchBar({
searches,
onRun,
currentUserID,
}: {
searches: SavedSearch[]
onRun: (s: SavedSearch) => void
currentUserID: string
}) {
const del = useDeleteSavedSearch()
if (searches.length === 0) return null
const pinned = [...searches].sort((a, b) => Number(b.pinned) - Number(a.pinned))
return (
<Panel title="Saved searches" hint="yours and the ones your team shared">
<div className="flex flex-wrap gap-2">
{pinned.map((s) => (
<span
key={s.id}
className="flex items-center gap-1.5 rounded-full border border-line/70 py-1 pl-3 pr-1 text-xs"
>
<button type="button" onClick={() => onRun(s)} className="font-semibold text-muted hover:text-ink">
{s.pinned && <Bookmark size={11} className="mr-1 inline text-gold" />}
{s.name}
</button>
{s.visibility === 'shared' && (
<span className="text-[10px] uppercase tracking-wide text-faint">
{s.owner_user_id === currentUserID ? 'shared' : (s.owner_name ?? 'shared')}
</span>
)}
{/* Only the owner of a saved search may remove it. Somebody deleting a colleague's shared search
would be removing a tool from a desk they do not sit at. */}
{s.owner_user_id === currentUserID && (
<button
type="button"
aria-label={`Delete the saved search ${s.name}`}
onClick={() => del.mutate(s.id)}
className="grid size-4 place-items-center rounded-full text-faint hover:text-risk"
>
<Trash2 size={10} />
</button>
)}
</span>
))}
</div>
</Panel>
)
}
function SaveSearchModal({
open,
onClose,
criteria,
}: {
open: boolean
onClose: () => void
criteria: CandidateQuery
}) {
const save = useSaveSearch()
const [name, setName] = useState('')
const [shared, setShared] = useState(false)
const [pinned, setPinned] = useState(false)
return (
<Modal open={open} onClose={onClose} title="Save this search">
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault()
if (!name.trim()) return
save.mutate(
{ name: name.trim(), criteria, visibility: shared ? 'shared' : 'private', pinned },
{
onSuccess: () => {
setName('')
onClose()
},
},
)
}}
>
<Labeled label="Name it" hint="what you would call it out loud, for example Cleared cyber, Maryland">
<input value={name} maxLength={120} onChange={(e) => setName(e.target.value)} className={fieldCls} />
</Labeled>
<label className="flex items-center gap-2 text-sm text-muted">
<input type="checkbox" checked={shared} onChange={(e) => setShared(e.target.checked)} />
Share it with the organization
</label>
<label className="flex items-center gap-2 text-sm text-muted">
<input type="checkbox" checked={pinned} onChange={(e) => setPinned(e.target.checked)} />
Pin it to the front
</label>
{/* The filters are shown back before saving. A saved search is a thing somebody will run for months
without opening it, so what it actually asks has to be visible at the moment it is named. */}
<div className="rounded-md border border-line/60 bg-navy-950/40 p-2 text-[11px] text-faint">
{describeQuery(criteria)}
</div>
{save.isError && <Note tone="risk">{(save.error as Error).message}</Note>}
<button type="submit" disabled={!name.trim() || save.isPending} className={btnPrimary}>
{save.isPending ? 'Saving' : 'Save it'}
</button>
</form>
</Modal>
)
}
// describeQuery says in words what a saved search will ask. It is deliberately plain: a recruiter reading it
// six weeks later should not have to decode a filter grid.
function describeQuery(q: CandidateQuery): string {
const parts: string[] = []
if (q.text) parts.push(`matching "${q.text}"`)
if (q.clearance_levels?.length)
parts.push(`cleared at ${q.clearance_levels.map((c) => CLEARANCE_LABELS[c] ?? c).join(' or ')}`)
if (q.clearance_status?.length) parts.push(`clearance ${q.clearance_status.join(' or ')}`)
if (q.skills?.length) parts.push(`with ${q.skills.join(' and ')}`)
if (q.location) parts.push(`near ${q.location}`)
if (q.min_years !== undefined) parts.push(`at least ${q.min_years} years`)
if (q.max_years !== undefined) parts.push(`at most ${q.max_years} years`)
if (q.available) parts.push('available now')
if (q.remote) parts.push('open to remote')
if (q.relocatable) parts.push('willing to relocate')
if (q.clearance_expiring_days) parts.push(`clearance expiring within ${q.clearance_expiring_days} days`)
if (q.access_postures?.length)
parts.push(
`whose reinstatement position is ${q.access_postures
.map((p) => ACCESS_POSTURES.find((a) => a.value === p)?.label ?? p)
.join(' or ')}`,
)
if (q.unowned) parts.push('owned by nobody')
else if (q.owner_user_id) parts.push('owned by you')
return parts.length ? `People ${parts.join(', ')}.` : 'Everybody you can see.'
}
function BulkBar({
count,
ids,
onAddToList,
onClear,
onDone,
}: {
count: number
ids: string[]
onAddToList: () => void
onClear: () => void
onDone: () => void
}) {
const { principal } = useAuth()
const assign = useBulkAssignOwner()
return (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-lg border border-blue/40 bg-blue/10 px-3 py-2">
<span className="flex items-center gap-1.5 text-sm font-semibold text-ink">
<Users size={14} />
{count === 1 ? '1 person selected' : `${count} people selected`}
</span>
<button type="button" className={btnGhost} onClick={onAddToList}>
<ListPlus size={13} /> Add to a list
</button>
<button
type="button"
className={btnGhost}
disabled={assign.isPending}
onClick={() =>
assign.mutate({ ids, ownerUserId: principal?.user_id ?? null }, { onSuccess: onDone })
}
>
<UserCheck size={13} /> Take ownership
</button>
<button type="button" className={btnGhost} onClick={onClear}>
Clear
</button>
{/* The server reports what it actually did rather than what was asked. A bulk action over a scoped
bench can legitimately skip records, and reporting the request count as the result is how a
recruiter comes to believe an action worked when it partly did not. */}
{assign.isSuccess && assign.data && (
<span className="text-[11px] text-muted">
{assign.data.applied} of {assign.data.requested} changed
{assign.data.skipped > 0 && `, ${assign.data.skipped} skipped`}
{assign.data.note ? `. ${assign.data.note}` : '.'}
</span>
)}
{assign.isError && <span className="text-[11px] text-risk">{(assign.error as Error).message}</span>}
</div>
)
}
function AddToListModal({
open,
onClose,
ids,
onDone,
}: {
open: boolean
onClose: () => void
ids: string[]
onDone: () => void
}) {
const lists = useLists()
const add = useAddToList()
const create = useCreateList()
const [newName, setNewName] = useState('')
const [purpose, setPurpose] = useState('shortlist')
const active = (lists.data?.lists ?? []).filter((l) => l.status === 'active')
return (
<Modal open={open} onClose={onClose} title={`Add ${ids.length === 1 ? '1 person' : `${ids.length} people`} to a list`}>
<div className="space-y-3">
{active.length === 0 ? (
<Empty title="No lists yet" reason="A list is a group you keep: a shortlist, a bench, or a do not contact record." />
) : (
<ul className="space-y-1">
{active.map((l) => (
<li key={l.id}>
<button
type="button"
disabled={add.isPending}
onClick={() => add.mutate({ listId: l.id, ids }, { onSuccess: onDone })}
className="flex w-full items-center justify-between rounded-md border border-line/60 px-3 py-2 text-left text-sm hover:border-blue/60"
>
<span className="font-semibold text-ink">{l.name}</span>
<span className="flex items-center gap-2 text-[11px] text-faint">
{/* A do not contact list is a compliance artefact, not a convenience, so it is marked
rather than left to be recognised by its title. */}
{l.purpose === 'do_not_contact' && (
<Badge color="var(--color-warn)" subtle>
do not contact
</Badge>
)}
{l.members ?? 0} on it
</span>
</button>
</li>
))}
</ul>
)}
<form
className="space-y-2 border-t border-line/40 pt-3"
onSubmit={(e) => {
e.preventDefault()
if (!newName.trim()) return
create.mutate(
{ name: newName.trim(), purpose },
{
onSuccess: (r) => {
setNewName('')
add.mutate({ listId: r.id, ids }, { onSuccess: onDone })
},
},
)
}}
>
<Labeled label="Or start a new list">
<input
value={newName}
maxLength={120}
placeholder="Name it"
onChange={(e) => setNewName(e.target.value)}
className={fieldCls}
/>
</Labeled>
<select value={purpose} onChange={(e) => setPurpose(e.target.value)} className={fieldCls}>
{LIST_PURPOSES.map((p) => (
<option key={p.value} value={p.value} className="bg-navy-900">
{p.label}
{p.hint ? `, ${p.hint}` : ''}
</option>
))}
</select>
<button type="submit" disabled={!newName.trim() || create.isPending} className={btnPrimary}>
Create it and add them
</button>
</form>
{(add.isError || create.isError) && (
<Note tone="risk">{((add.error ?? create.error) as Error).message}</Note>
)}
</div>
</Modal>
)
}