586 lines
23 KiB
JavaScript
586 lines
23 KiB
JavaScript
/* ============================================================
|
||
CV Bank — people we already have, for jobs we do not have yet.
|
||
|
||
Two populations, one table (GET /candidate/cv-bank/fetch):
|
||
|
||
Speculative a CV uploaded with no job attached. Skills, title,
|
||
company and years are extracted at upload, which is what
|
||
makes the row searchable at all.
|
||
Silver medalist someone who applied, scored well, and did not get the
|
||
job. Read live from their application rather than copied
|
||
here, so there is one source of truth and nothing to sync.
|
||
|
||
ATS is a real paid score. Each row picks a job, then Run ATS scores that
|
||
one candidate. Speculative rows are assigned to the job first (so the banked
|
||
CV links like any other application), then scored. Silver medalists stay on
|
||
their inbox application and score via score_inbox when a message id exists.
|
||
============================================================ */
|
||
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
|
||
import Modal from '../ui/Modal'
|
||
import OpenResumeButton from '../ui/OpenResumeButton'
|
||
import PageHeader from '../ui/PageHeader'
|
||
import { DataTableHead, DEFAULT_PAGE_SIZE, Pagination, pageWindow, useDataTable } from '../ui/DataTable'
|
||
import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
|
||
import { PickRoleModal } from '../ui/SuggestedRoles'
|
||
import { useToast } from '../ui/Toast'
|
||
import { qk } from '../lib/queryKeys'
|
||
import { exportStyledXlsx } from '../lib/exportXlsx'
|
||
import { friendlyAuthError } from '../lib/errors'
|
||
import * as candidatesApi from '../api/candidates'
|
||
import * as s3Api from '../api/s3'
|
||
import { avatarColor, initials as initialsOf } from '../data/seed'
|
||
|
||
const SEARCH_DEBOUNCE_MS = 300
|
||
const SOURCE_FILTERS = ['speculative', 'silver_medalist']
|
||
const SOURCE_LABELS = candidatesApi.BANK_SOURCE_LABELS
|
||
const BAND_FILTERS = ['Strong Match', 'Potential Match', 'Weak Match', 'Unscored']
|
||
const YEARS_FILTERS = ['1', '2', '3', '5', '8', '10']
|
||
const BAND_BADGE = {
|
||
'Strong Match': 'b-green',
|
||
'Potential Match': 'b-amber',
|
||
'Weak Match': 'b-gray',
|
||
}
|
||
const SOURCE_BADGE = {
|
||
speculative: 'b-indigo',
|
||
silver_medalist: 'b-teal',
|
||
}
|
||
/* Chips past this are collapsed into "+N" — a CV with 25 skills would
|
||
otherwise make one row taller than the rest of the page. */
|
||
const SKILL_CHIPS = 4
|
||
const SUGGESTED_CHIPS = 3
|
||
|
||
const EMPTY_FILTERS = { source: '', band: '', years: '' }
|
||
|
||
async function fetchBank({ limit, offset, search, filters }) {
|
||
const res = await candidatesApi.listCvBank({
|
||
top: limit,
|
||
skip: offset,
|
||
search: search || undefined,
|
||
source: filters.source || undefined,
|
||
band: filters.band || undefined,
|
||
minYears: filters.years ? Number(filters.years) : undefined,
|
||
})
|
||
const rows = Array.isArray(res?.data) ? res.data : []
|
||
return {
|
||
rows: rows.map(candidatesApi.toBankRowView),
|
||
total: Number(res?.total ?? rows.length) || 0,
|
||
}
|
||
}
|
||
|
||
function AtsCell({ score, recommendation }) {
|
||
if (score == null) return <span className="text-muted text-sm">Not scored</span>
|
||
return (
|
||
<div>
|
||
<ScoreChip score={score} />
|
||
{recommendation && (
|
||
<div className="cell-sub">
|
||
<Badge className={BAND_BADGE[recommendation] || 'b-gray'}>{recommendation}</Badge>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SuggestedJobsCell({ jobs }) {
|
||
if (!jobs?.length) return null
|
||
return (
|
||
<div className="k-tags">
|
||
{jobs.slice(0, SUGGESTED_CHIPS).map((j) => (
|
||
<span className="tag" key={j.id} title={j.title}>{j.title || 'Job'}</span>
|
||
))}
|
||
{jobs.length > SUGGESTED_CHIPS && (
|
||
<span className="tag" title={jobs.slice(SUGGESTED_CHIPS).map((j) => j.title).join(', ')}>
|
||
+{jobs.length - SUGGESTED_CHIPS}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function jobFor(row, pickedById) {
|
||
const local = pickedById[row.id]
|
||
if (local?.id) return local
|
||
if (row.scoredJobPostId) {
|
||
return { id: row.scoredJobPostId, title: row.scoredJobTitle || 'Selected job' }
|
||
}
|
||
if (row.assignedJobPostId) {
|
||
return { id: row.assignedJobPostId, title: row.assignedJobTitle || 'Assigned job' }
|
||
}
|
||
return null
|
||
}
|
||
|
||
function scoredRow(res) {
|
||
const row = Array.isArray(res?.data) ? res.data[0] : res?.data
|
||
return row && typeof row === 'object' ? row : null
|
||
}
|
||
|
||
export default function CvBank() {
|
||
const { toast } = useToast()
|
||
const qc = useQueryClient()
|
||
const navigate = useNavigate()
|
||
|
||
const [q, setQ] = useState('')
|
||
const [search, setSearch] = useState('')
|
||
const [filters, setFilters] = useState(EMPTY_FILTERS)
|
||
const [showFilters, setShowFilters] = useState(false)
|
||
const [skip, setSkip] = useState(0)
|
||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||
const [preview, setPreview] = useState(null) // { name, url } — object URL we own
|
||
const [pickingRow, setPickingRow] = useState(null)
|
||
const [pickedById, setPickedById] = useState({})
|
||
|
||
useEffect(() => {
|
||
const t = setTimeout(() => setSearch(q.trim()), SEARCH_DEBOUNCE_MS)
|
||
return () => clearTimeout(t)
|
||
}, [q])
|
||
useEffect(() => { setSkip(0) }, [search])
|
||
|
||
const bankQuery = useQuery({
|
||
queryKey: qk.cvBank.list({ limit: pageSize, offset: skip, search, ...filters }),
|
||
queryFn: () => fetchBank({ limit: pageSize, offset: skip, search, filters }),
|
||
})
|
||
|
||
const rows = useMemo(() => bankQuery.data?.rows ?? [], [bankQuery.data])
|
||
const total = bankQuery.data?.total ?? 0
|
||
|
||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||
const from = total ? skip + 1 : 0
|
||
const to = total ? skip + rows.length : 0
|
||
const currentPage = Math.min(Math.floor(skip / pageSize) + 1, pages)
|
||
|
||
useEffect(() => {
|
||
if (total <= 0 || skip < total) return
|
||
setSkip(Math.max(0, Math.floor((total - 1) / pageSize) * pageSize))
|
||
}, [total, pageSize, skip])
|
||
|
||
const columns = useMemo(() => [
|
||
{ key: 'name', label: 'Candidate', sortable: true },
|
||
{ key: 'source', label: 'Source', sortable: true },
|
||
{ key: 'title', label: 'Role', sortable: true },
|
||
{ key: 'years', label: 'Years', sortable: true },
|
||
{ key: 'skills', label: 'Skills', sortable: false },
|
||
{ key: 'suggestedJobs', label: 'Suggested jobs', sortable: false },
|
||
{ key: 'aiScore', label: 'ATS', sortable: true },
|
||
{ key: 'job', label: 'Job', sortable: false },
|
||
{ key: 'added', label: 'Added', sortable: true },
|
||
{ key: 'actions', label: '', sortable: false },
|
||
], [])
|
||
|
||
// The server already sorted and paged; pageSize here is just "show them all".
|
||
const t = useDataTable({ columns, rows, pageSize: Math.max(rows.length, 1) })
|
||
|
||
const setFilter = (k, v) => {
|
||
setFilters((f) => ({ ...f, [k]: v }))
|
||
setSkip(0)
|
||
}
|
||
|
||
const removing = useMutation({
|
||
mutationFn: (id) => candidatesApi.deleteCvBankCv(id),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: qk.cvBank.all() })
|
||
toast('CV removed from the bank', 'success')
|
||
},
|
||
onError: (err) => toast(friendlyAuthError(err, 'Could not remove the CV'), 'error'),
|
||
})
|
||
|
||
const runAts = useMutation({
|
||
mutationFn: async ({ row, jobId }) => {
|
||
if (row.isStoredCv) {
|
||
await candidatesApi.assignMatchingJob(row.recordId, jobId)
|
||
return candidatesApi.scoreCvBank(jobId, [row.recordId])
|
||
}
|
||
if (row.messageId) {
|
||
return candidatesApi.scoreInbox(jobId, [row.messageId])
|
||
}
|
||
throw new Error('This applicant cannot be scored from the CV Bank')
|
||
},
|
||
onSuccess: (res, vars) => {
|
||
const row = scoredRow(res)
|
||
const title = vars.jobTitle || 'the selected job'
|
||
if (row?.status === 'completed' || (row?.match_score != null && row?.status !== 'failed')) {
|
||
toast(`Scored ${row.match_score}/100 against ${title}`, 'success')
|
||
} else {
|
||
toast(`Could not score the CV${row?.error_code ? ` — ${row.error_code}` : ''}`, 'warning')
|
||
}
|
||
qc.invalidateQueries({ queryKey: qk.cvBank.all() })
|
||
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
|
||
},
|
||
onError: (err) => toast(friendlyAuthError(err, 'Scoring failed'), 'error'),
|
||
})
|
||
|
||
async function view(row) {
|
||
if (!row.isStoredCv) {
|
||
if (row.userId) navigate(`/candidate/${row.userId}`)
|
||
else toast('This applicant has no profile to open', 'info')
|
||
return
|
||
}
|
||
const tab = s3Api.canOpen(row.filePath) ? window.open('about:blank', '_blank') : null
|
||
try {
|
||
if (s3Api.canOpen(row.filePath)) {
|
||
await s3Api.openPdf(row.filePath, { tab })
|
||
return
|
||
}
|
||
const url = await candidatesApi.viewCvBankCv(row.recordId)
|
||
if (!url) {
|
||
toast('The CV file could not be found', 'error')
|
||
return
|
||
}
|
||
setPreview({ name: row.fileName || row.name || 'CV', url })
|
||
} catch (err) {
|
||
if (tab && !tab.closed) tab.close()
|
||
toast(friendlyAuthError(err, 'Could not open the CV'), 'error')
|
||
}
|
||
}
|
||
|
||
function closePreview() {
|
||
if (preview) URL.revokeObjectURL(preview.url)
|
||
setPreview(null)
|
||
}
|
||
|
||
async function download(row) {
|
||
try {
|
||
await candidatesApi.downloadCvBankCv(row.recordId)
|
||
} catch (err) {
|
||
toast(friendlyAuthError(err, 'Could not download the CV'), 'error')
|
||
}
|
||
}
|
||
|
||
async function exportRows() {
|
||
if (!rows.length) {
|
||
toast('Nothing to export — current filters match no CVs', 'warning')
|
||
return
|
||
}
|
||
try {
|
||
await exportStyledXlsx({
|
||
filename: `cv-bank-${new Date().toISOString().slice(0, 10)}`,
|
||
title: 'CV Bank',
|
||
subtitle: `${rows.length} CV${rows.length === 1 ? '' : 's'} · exported ${new Date().toLocaleDateString()}`,
|
||
columns: [
|
||
{ header: 'Name', key: 'name', width: 26 },
|
||
{ header: 'Email', key: 'email', width: 30 },
|
||
{ header: 'Source', key: 'source', width: 16 },
|
||
{ header: 'Title', key: 'title', width: 24 },
|
||
{ header: 'Company', key: 'company', width: 24 },
|
||
{ header: 'Years', key: 'years', width: 8 },
|
||
{ header: 'Skills', key: 'skills', width: 42 },
|
||
{ header: 'Suggested jobs', key: 'suggested', width: 32 },
|
||
{ header: 'ATS', key: 'ats', width: 10 },
|
||
{ header: 'Scored job', key: 'scoredJob', width: 24 },
|
||
{ header: 'Added', key: 'added', width: 12 },
|
||
],
|
||
rows: rows.map((r) => ({
|
||
name: r.name,
|
||
email: r.email || '',
|
||
source: r.sourceLabel,
|
||
title: r.title || '',
|
||
company: r.company || '',
|
||
years: r.years ?? '',
|
||
skills: r.skills.join(', '),
|
||
suggested: r.suggestedJobs.map((j) => j.title).filter(Boolean).join(', '),
|
||
ats: r.aiScore ?? '',
|
||
scoredJob: jobFor(r, pickedById)?.title || '',
|
||
added: r.added ? r.added.toLocaleDateString() : '',
|
||
})),
|
||
})
|
||
toast(`Exported ${rows.length} CV${rows.length === 1 ? '' : 's'}`, 'success')
|
||
} catch {
|
||
toast('Export failed', 'error')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="page">
|
||
<PageHeader
|
||
title="CV Bank"
|
||
sub={
|
||
bankQuery.isSuccess
|
||
? <>{total} CV{total === 1 ? '' : 's'} held for future roles</>
|
||
: 'CVs held for future roles'
|
||
}
|
||
actions={<>
|
||
<button className="btn btn-secondary" onClick={exportRows}>
|
||
<Icon name="download" /> Export
|
||
</button>
|
||
<button className="btn btn-primary" onClick={() => navigate('/import')}>
|
||
<Icon name="upload" /> Add CVs
|
||
</button>
|
||
</>}
|
||
/>
|
||
|
||
<div className="card">
|
||
<div className="card-body" style={{ paddingBottom: 0 }}>
|
||
<div className="toolbar">
|
||
<div className="toolbar-search">
|
||
<Icon name="search" />
|
||
<input
|
||
value={q}
|
||
onChange={(e) => setQ(e.target.value)}
|
||
placeholder="Search name, email, company, or skill…"
|
||
/>
|
||
</div>
|
||
<button className="btn btn-secondary" onClick={() => setShowFilters((s) => !s)}>
|
||
<Icon name="filter" /> Filters
|
||
</button>
|
||
<div className="spacer" />
|
||
</div>
|
||
|
||
{showFilters && (
|
||
<div
|
||
className="filter-panel"
|
||
style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }}
|
||
>
|
||
<Facet
|
||
label="Source"
|
||
value={filters.source}
|
||
onChange={(v) => setFilter('source', v)}
|
||
any="Any source"
|
||
options={SOURCE_FILTERS}
|
||
labels={SOURCE_LABELS}
|
||
/>
|
||
<Facet
|
||
label="ATS band"
|
||
value={filters.band}
|
||
onChange={(v) => setFilter('band', v)}
|
||
any="Any band"
|
||
options={BAND_FILTERS}
|
||
/>
|
||
<Facet
|
||
label="Minimum years"
|
||
value={filters.years}
|
||
onChange={(v) => setFilter('years', v)}
|
||
any="Any experience"
|
||
options={YEARS_FILTERS}
|
||
labels={Object.fromEntries(YEARS_FILTERS.map((y) => [y, `${y}+ years`]))}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{bankQuery.isPending && (
|
||
<div className="card-body"><SkeletonRows rows={6} /></div>
|
||
)}
|
||
{bankQuery.isError && (
|
||
<div className="card-body">
|
||
<EmptyState icon="file" title="Couldn’t load the CV Bank">
|
||
{friendlyAuthError(bankQuery.error, 'Request failed')}
|
||
</EmptyState>
|
||
</div>
|
||
)}
|
||
|
||
{bankQuery.isSuccess && (
|
||
<div className="dt">
|
||
<div className="table-wrap">
|
||
<table className="data">
|
||
<DataTableHead columns={columns} sort={t.sort} toggleSort={t.toggleSort} />
|
||
<tbody>
|
||
{t.pageRows.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={columns.length}>
|
||
{search || filters.source || filters.band || filters.years ? (
|
||
<EmptyState title="No matches">
|
||
No held CV matches these filters. Try widening them.
|
||
</EmptyState>
|
||
) : (
|
||
<EmptyState icon="file" title="The CV Bank is empty">
|
||
Import CVs with “No job — store in CV bank” selected, and
|
||
rejected applicants who scored well will show up here too.
|
||
</EmptyState>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
t.pageRows.map((r) => {
|
||
const picked = jobFor(r, pickedById)
|
||
const scoringThis = runAts.isPending && runAts.variables?.row?.id === r.id
|
||
const runDisabled = !r.canRunAts || !picked || runAts.isPending
|
||
const runTitle = !r.canRunAts
|
||
? 'Silver medalists are scored from their application — this row has no inbox CV to score'
|
||
: !picked
|
||
? 'Pick a job first'
|
||
: 'Run the ATS score for this candidate'
|
||
return (
|
||
<tr key={r.id}>
|
||
<td>
|
||
<div className="user-cell">
|
||
<Avatar name={r.name} initials={initialsOf(r.name)} color={avatarColor(r.name)} />
|
||
<div style={{ minWidth: 0 }}>
|
||
<div className="cell-primary">{r.name}</div>
|
||
<div className="cell-sub">{r.email || r.fileName || 'No email detected'}</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<Badge className={SOURCE_BADGE[r.source] || 'b-gray'}>{r.sourceLabel}</Badge>
|
||
{r.lastJobTitle && (
|
||
<div className="cell-sub cell-clip" title={r.lastJobTitle}>
|
||
applied for {r.lastJobTitle}
|
||
</div>
|
||
)}
|
||
{r.expiresAt && (
|
||
<div className="cell-sub">{candidatesApi.expiryLabel(r.expiresAt)}</div>
|
||
)}
|
||
</td>
|
||
<td>
|
||
<div className="text-sm cell-clip" title={r.title || undefined}>{r.title || '—'}</div>
|
||
{r.company && <div className="cell-sub cell-clip" title={r.company}>{r.company}</div>}
|
||
</td>
|
||
<td>
|
||
<span className="text-sm">{r.years == null ? '—' : r.years}</span>
|
||
</td>
|
||
<td>
|
||
{r.skills.length ? (
|
||
<div className="k-tags">
|
||
{r.skills.slice(0, SKILL_CHIPS).map((s) => (
|
||
<span className="tag" key={s}>{s}</span>
|
||
))}
|
||
{r.skills.length > SKILL_CHIPS && (
|
||
<span className="tag" title={r.skills.slice(SKILL_CHIPS).join(', ')}>
|
||
+{r.skills.length - SKILL_CHIPS}
|
||
</span>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<span className="text-muted text-sm">None extracted</span>
|
||
)}
|
||
</td>
|
||
<td><SuggestedJobsCell jobs={r.suggestedJobs} /></td>
|
||
<td><AtsCell score={r.aiScore} recommendation={r.recommendation} /></td>
|
||
<td>
|
||
<div className="flex items-center gap-8" style={{ flexWrap: 'wrap' }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-sm"
|
||
onClick={() => setPickingRow(r)}
|
||
title={picked ? `ATS job: ${picked.title}` : 'Pick a job to score against'}
|
||
>
|
||
{picked?.title || 'Pick a job'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary btn-sm"
|
||
disabled={runDisabled}
|
||
title={runTitle}
|
||
onClick={() => {
|
||
if (!picked || runDisabled) return
|
||
runAts.mutate({
|
||
row: r,
|
||
jobId: picked.id,
|
||
jobTitle: picked.title,
|
||
})
|
||
}}
|
||
>
|
||
{scoringThis ? 'Scoring…' : 'Run ATS'}
|
||
</button>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span className="text-sm">{r.added ? r.added.toLocaleDateString() : '—'}</span>
|
||
</td>
|
||
<td>
|
||
<div className="flex items-center gap-8" style={{ flexShrink: 0 }}>
|
||
{r.isStoredCv && <OpenResumeButton filePath={r.filePath} className="btn btn-secondary btn-sm" />}
|
||
<button className="act-btn" data-tip="View" aria-label="View CV" onClick={() => view(r)}>
|
||
<Icon name="eye" />
|
||
</button>
|
||
{r.isStoredCv && (<>
|
||
<button className="act-btn" data-tip="Download" aria-label="Download CV" onClick={() => download(r)}>
|
||
<Icon name="download" />
|
||
</button>
|
||
{!r.assignedJobPostId && (
|
||
<button
|
||
className="act-btn"
|
||
data-tip="Remove"
|
||
aria-label="Remove CV from bank"
|
||
disabled={removing.isPending}
|
||
onClick={() => {
|
||
if (window.confirm(`Remove “${r.fileName || r.name}” from the CV Bank? The file is deleted permanently.`)) {
|
||
removing.mutate(r.recordId)
|
||
}
|
||
}}
|
||
>
|
||
<Icon name="trash" />
|
||
</button>
|
||
)}
|
||
</>)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<Pagination
|
||
from={from}
|
||
to={to}
|
||
total={total}
|
||
page={currentPage}
|
||
pages={pages}
|
||
setPage={(p) => setSkip((p - 1) * pageSize)}
|
||
pageButtons={pageWindow(currentPage, pages)}
|
||
pageSize={pageSize}
|
||
onPageSizeChange={(n) => setPageSize(n)}
|
||
pageSizeMax={500}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<p className="text-muted text-sm mt-18">
|
||
<Icon name="info" /> Pick a job on a row, then <strong>Run ATS</strong> for a
|
||
real scored result. Speculative CVs are linked to that job; silver medalists
|
||
stay on their existing application.
|
||
</p>
|
||
|
||
{pickingRow && (
|
||
<PickRoleModal
|
||
onClose={() => setPickingRow(null)}
|
||
onPick={(post) => {
|
||
if (!post?.id) return
|
||
setPickedById((m) => ({
|
||
...m,
|
||
[pickingRow.id]: { id: String(post.id), title: post.title || 'Selected job' },
|
||
}))
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{preview && (
|
||
<Modal
|
||
title={preview.name}
|
||
subtitle="CV preview"
|
||
size="modal-lg"
|
||
onClose={closePreview}
|
||
footer={<button className="btn btn-secondary" onClick={closePreview}>Close</button>}
|
||
>
|
||
{/* Blob URL re-typed to application/pdf so the browser's built-in
|
||
viewer renders inline instead of triggering a download. */}
|
||
<iframe
|
||
src={preview.url}
|
||
title={`Preview of ${preview.name}`}
|
||
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 10, background: 'var(--bg-sunken)' }}
|
||
/>
|
||
</Modal>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Facet({ label, value, onChange, any, options, labels }) {
|
||
return (
|
||
<div className="form-field">
|
||
<label>{label}</label>
|
||
<select value={value} onChange={(e) => onChange(e.target.value)}>
|
||
<option value="">{any}</option>
|
||
{options.map((o) => <option key={o} value={o}>{labels?.[o] ?? o}</option>)}
|
||
</select>
|
||
</div>
|
||
)
|
||
}
|