HR-ATS-Portal/frontend/src/screens/Inbox.jsx

2089 lines
80 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* ============================================================
Recruitment Inbox — application tabs over GET /inbox/all-applications.
Page size defaults to 10 (dropdown: 10 / 50 / 100). skip/offset is the
window start and is not recomputed when Per page changes: growing 10 to
50 on a window that started at row 11 requests skip=10, top=50
(rows 1160). Clicking a
page number realigns skip = (page-1)*limit. Total comes from a count
endpoint called once when the page opens.
============================================================ */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
import OpenResumeButton from '../ui/OpenResumeButton'
import PageHeader from '../ui/PageHeader'
import SyncButton from '../ui/SyncButton'
import { Tabs } from '../ui/Tabs'
import { DEFAULT_PAGE_SIZE, Pagination, pageWindow } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
import { JobCard, PickRoleModal } from '../ui/SuggestedRoles'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as inboxApi from '../api/inbox'
import * as sheetApi from '../api/sheet'
import * as s3Api from '../api/s3'
import {
atsRecommendationClass, avatarColor, fmtDate, initials as initialsOf,
inboxSources, sourceMeta,
} from '../data/seed'
const TABS = ['All Applications', 'Unread', 'Processed', 'Rejected', 'Duplicates']
/** Sheet Forms have no mailbox read state — no Unread tab on that channel. */
const FORM_TABS = ['All Applications', 'Processed', 'Rejected', 'Duplicates']
/** Inbox GET `top` / sheet GET `limit` both cap at 500. */
const PAGE_SIZE_MAX = 500
const SYNC_RUN_KEY = 'mailbox_sync_run_id'
function readStoredSyncRunId() {
try {
return localStorage.getItem(SYNC_RUN_KEY) || null
} catch {
return null
}
}
function storeSyncRunId(id) {
try {
if (id) localStorage.setItem(SYNC_RUN_KEY, id)
else localStorage.removeItem(SYNC_RUN_KEY)
} catch {
/* private mode / quota — polling still works in-session */
}
}
/** Inbox channel: Outlook email queue vs imported Google Form rows. */
const CHANNELS = [
{ key: 'email', label: 'Email', icon: 'mail' },
{ key: 'forms', label: 'Sheet Forms', icon: 'layers' },
]
const DEFAULT_FORM_SHEET = 'Form Responses - Candidate Database Sheet 2026'
const SHEET_SOURCE_META = { icon: 'layers', color: 'var(--c4)', channel: 'Sheet' }
/**
* Server-side filters for each tab. Email Processed / Rejected follow
* processing_state (the same writes as Import / Shortlist / Reject). Sheet
* Forms use form_data.processing_state.
*/
const TAB_FILTERS = {
Unread: { isread: false },
Processed: { processingState: 'processed' },
Rejected: { processingState: 'rejected' },
Duplicates: { isDuplicate: true },
}
const FORM_TAB_FILTERS = {
Processed: { processing_state: 'processed' },
Rejected: { processing_state: 'rejected' },
Duplicates: { is_duplicate: true },
}
const FORM_PROCESSING_LABEL = {
unread: 'New',
imported: 'Imported',
processed: 'Processed',
rejected: 'Rejected',
}
/** Every tab above is a true server-side scope — safe for "mark all". */
const SERVER_SCOPED_TABS = new Set(TABS)
/**
* message_received_time / message_sent_time are plain string columns
* (backend/inbox/models.py:54-56), not timestamps. An unparseable value yields
* an Invalid Date that every fmt* helper renders as the literal "Invalid Date",
* so return null instead and let the call sites decide what to show.
*/
function parseDate(value) {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d
}
function startOfDay(d) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
}
/**
* Outlook-style list timestamps in the client's local timezone.
* Within 7 days: weekday + AM/PM time. Older: dd/mm/yyyy only — like Outlook.
* The date-plus-time form was ~105px wide, and in the 380px queue rail that
* squeezed .ii-main to 148px: sender names painted over the timestamp and the
* meta chips wrapped one-per-line. The full timestamp is in the detail pane.
*/
function outlookListTime(value) {
if (!value) return '—'
const d = value instanceof Date ? value : new Date(value)
if (Number.isNaN(d.getTime())) return '—'
const daysAgo = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000)
if (daysAgo < 7) {
const weekday = d.toLocaleDateString(undefined, { weekday: 'short' })
const time = d.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
return `${weekday} ${time}`
}
const dd = String(d.getDate()).padStart(2, '0')
const mm = String(d.getMonth() + 1).padStart(2, '0')
return `${dd}/${mm}/${d.getFullYear()}`
}
/**
* `source` arrives as the raw To address, because that is where the board tag
* lands — careers-rozee@, employee-referral@, mustakbil@ and so on. Strip
* everything but letters from both sides so "Employee Referral" still matches
* "employee-referral@", and keep the brand colour SourceChip paints from.
* Nothing matches -> show the first recipient verbatim rather than guess.
*/
function sourceFrom(messageTo) {
const raw = typeof messageTo === 'string' ? messageTo.trim() : String(messageTo ?? '').trim()
if (!raw || raw === '[object Object]') return { source: 'Unknown', sourceMeta: null }
const flat = raw.toLowerCase().replace(/[^a-z]/g, '')
const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, '')))
if (hit) return { source: hit, sourceMeta: sourceMeta[hit] }
return { source: raw.split(',')[0].trim(), sourceMeta: null }
}
/**
* Form `source_of_application` is a free-text label (LinkedIn, Indeed, …), not a
* To-address. Reuse the email source palette when the spelling matches; otherwise
* tag the row as a Sheet Forms entry so the chip still paints.
*/
function formSourceFrom(raw) {
const label = (raw || '').trim()
if (!label) return { source: 'Google Forms', sourceMeta: SHEET_SOURCE_META }
const flat = label.toLowerCase().replace(/[^a-z]/g, '')
const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, '')))
if (hit) return { source: hit, sourceMeta: sourceMeta[hit] }
return { source: label, sourceMeta: SHEET_SOURCE_META }
}
/** entry_date is midnight UTC; entry_time is a separate "HH:MM" string from the sheet. */
function formReceivedAt(entryDate, entryTime) {
const d = parseDate(entryDate)
if (!d) return null
const m = String(entryTime || '').match(/(\d{1,2}):(\d{2})/)
if (m) d.setHours(Number(m[1]), Number(m[2]), 0, 0)
return d
}
/**
* GET /sheet/form-data/fetch row → the same list/detail shape the email channel
* uses for name / avatar / position / source / time, plus form-only profile fields.
* job_posts are title-matched (position_applied_for ↔ job_posts.title), not AI.
*/
function mapFormRow(row) {
const name = (row.name || row.candidate_email || 'Unknown').trim()
const jobPosts = Array.isArray(row.job_posts) ? row.job_posts : []
const state = row.processing_state || 'unread'
return {
kind: 'form',
id: String(row.id),
name,
initials: initialsOf(name),
color: avatarColor(name),
email: row.candidate_email || '',
phone: row.candidate_number || '',
position: row.position_applied_for || '—',
...formSourceFrom(row.source_of_application),
received: formReceivedAt(row.entry_date, row.entry_time),
screenedBy: row.screened_by || '',
hrComments: row.hr_comments || '',
gender: row.gender || '',
dateOfBirth: parseDate(row.date_of_birth),
cnic: row.cnic || '',
degree: row.degree || '',
university: row.university || '',
universityOther: row.university_other || '',
graduationYear: row.entry_year || '',
residingCity: row.residing_city || '',
residingCountry: row.residing_country || '',
maritalStatus: row.marital_status || '',
hoAvailability: row.ho_availability || '',
noticePeriod: row.notice_period || '',
currentSalary: row.current_salary || '',
expectedSalary: row.expected_salary || '',
profileLink: row.profile_link || '',
resumeLink: row.resume_link || '',
sheet: row.sheet || '',
rowNumber: row.row_number ?? null,
unread: state === 'unread',
processing: FORM_PROCESSING_LABEL[state]
|| (row.screened_by ? 'Screened' : 'New'),
processingState: state,
duplicate: Boolean(row.is_duplicate),
jobPosts,
assignedId: row.job_post_id ? String(row.job_post_id) : null,
assignedPost: row.assigned_job_post || null,
}
}
async function fetchFormApplications(params) {
const res = await sheetApi.listFormData(params)
const rows = Array.isArray(res?.data) ? res.data : []
return {
rows: rows.map(mapFormRow),
total: Number(res?.total ?? rows.length) || 0,
}
}
async function fetchFormDetail(recordId) {
const res = await sheetApi.getFormData(recordId)
const row = res?.data
return row ? mapFormRow(row) : null
}
function SourceChip({ item }) {
// The dot carries the partner's brand colour; the label uses theme text —
// 11px labels in the partner colour failed AA in both themes.
// When no board matched, `source` is the raw To-address; keep it out of the
// chip — show "Email", and leave the address to the tooltip and the opened
// message.
const source = String(item.source ?? '')
const label = source.includes('@') ? 'Email' : source
return (
<span className="source-chip" style={{ '--chip': item.sourceMeta?.color }} title={source}>
<span className="source-dot" />
<span className="source-chip-label">{label}</span>
</span>
)
}
/**
* Graph delivers the body as text/html, so rendering it verbatim as text — which
* is what keeps it XSS-safe — prints the raw markup at the user.
*
* DOMParser builds a DETACHED document: it is never adopted into the live DOM, so
* scripts do not run and <img onerror> never fires. Reading textContent off it is
* therefore both safe and readable, and needs no dangerouslySetInnerHTML.
*/
function htmlToText(value) {
const raw = (value || '').trim()
if (!raw) return ''
if (!/<[a-z!/]/i.test(raw)) return raw // already plain text
// textContent ignores block boundaries, so <p>a</p><p>b</p> would collapse to
// "ab". Turn breaks and closing block tags into newlines BEFORE parsing.
const withBreaks = raw
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|tr|h[1-6]|blockquote|table)\s*>/gi, '\n')
const doc = new DOMParser().parseFromString(withBreaks, 'text/html')
doc.querySelectorAll('script, style, head').forEach((n) => n.remove())
return (doc.body?.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
}
/** match_status -> the resume badge, mirroring _RESUME_STATUS in inbox/serializers.py. */
const RESUME_STATUS = {
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
failed: 'Failed', dlq: 'Failed', skipped: 'Pending',
}
const SHORTLIST_JOB_WARNING = 'Choose a matching job above to add this candidate to the shortlist.'
/**
* GET /inbox/fetch?record_id=<pk> -> the detail behind one application row.
*
* Returns serialize_message, a different shape from serialize_application, so it
* is remapped onto the row shape here and OVERLAID on the list row rather than
* replacing it: serialize_message carries the body, decoded attachments, and
* the hydrated suggested_job_posts / assigned_job_post the role picker needs.
*/
async function fetchMessageDetail(recordId) {
const res = await inboxApi.getMessage(recordId)
const row = res?.data
if (!row) return null
const name = row.sender_name || row.fromEmail || 'Unknown'
return {
id: String(row.id),
name,
initials: initialsOf(name),
color: avatarColor(name),
email: row.fromEmail || '',
position: row.subject || '(no subject)',
...sourceFrom(row.message_to),
received: parseDate(row.when) ?? parseDate(row.message_sent_time),
unread: Boolean(row.unread),
processing: row.unread ? 'Unread' : 'Read',
resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending',
attachment: row.attachment_name,
hasAttachment: Boolean(row.attachment),
body: htmlToText(row.body),
// Raw markup for the HTML viewer; `body` stays the plain-text fallback.
bodyHtml: row.body || '',
cc: row.message_cc || '',
bcc: row.message_bcc || '',
sentAt: parseDate(row.message_sent_time),
files: Array.isArray(row.files) ? row.files : [],
filePath: row.file_path || '',
linkedinSlug: row.linkedin_slug || '',
linkedinUrl: row.linkedin_url || '',
matchStatus: row.match_status || null,
matchSummary: row.match_summary || '',
matchReasoning: row.match_reasoning || '',
matchError: row.match_error || '',
matchedAt: parseDate(row.matched_at),
resumeText: row.resume_text || '',
suggestedIds: Array.isArray(row.suggested_job_post_ids)
? row.suggested_job_post_ids.map(String)
: [],
suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [],
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
assignedPost: row.assigned_job_post || null,
}
}
/**
* GET /inbox/all-applications -> the shape the application tabs render.
*
* `processing` prefers processing_state (imported/processed/rejected); otherwise
* Read/Unread from message_read. Duplicate comes from is_duplicate.
* Returns `{ rows, total }` so the pager can show page 1 / 2 / 3… without
* loading the whole mailbox.
*/
async function fetchApplications(params) {
const res = await inboxApi.listApplications(params)
const rows = Array.isArray(res?.data) ? res.data : []
return {
rows: rows.map((row) => {
const name = row.name || row.email || 'Unknown'
return {
id: String(row.id),
name,
initials: initialsOf(name),
color: avatarColor(name),
email: row.email || '',
position: row.position || '(no subject)',
...sourceFrom(row.source),
received: parseDate(row.received),
unread: Boolean(row.unread),
processing: row.processing || 'Unread',
processingState: row.processing_state || null,
applicationStatus: row.application_status || null,
resumeStatus: row.resume_status || 'Pending',
attachment: row.attachment,
hasAttachment: Boolean(row.has_attachment),
filePath: row.file_path || '',
linkedinSlug: row.linkedin_slug || '',
linkedinUrl: row.linkedin_url || '',
resumeText: row.resume_text || '',
atsScore: row.ats_score,
phone: row.phone,
experience: row.experience,
recruiter: row.recruiter,
duplicate: Boolean(row.duplicate),
suggestedIds: Array.isArray(row.suggested_job_post_ids)
? row.suggested_job_post_ids.map(String)
: [],
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
}
}),
total: Number(res?.total ?? rows.length) || 0,
}
}
// Shared with the sidebar badge through qk.mailbox.counts — see inboxApi.fetchCounts.
const fetchInboxCounts = inboxApi.fetchCounts
/** 500 is MAX_BULK_READ_IDS in backend/inbox/views.py; a longer list gets a 413. */
const BULK_READ_CHUNK = 500
async function setReadChunked(ids, read) {
let updated = 0
for (let i = 0; i < ids.length; i += BULK_READ_CHUNK) {
// Sequential on purpose. Each chunk is one UPDATE over a few hundred rows of
// the same table; firing them together only puts them in each other's way.
// eslint-disable-next-line no-await-in-loop
const res = await inboxApi.bulkSetRead(ids.slice(i, i + BULK_READ_CHUNK), read)
updated += res?.data?.updated ?? 0
}
return { updated, requested: ids.length }
}
/**
* Rewrites ONE cache entry for a read-state flip: a row list, a paginated
* `{rows, total}` page, or the single-message detail object. Anything else
* (the counts object) passes through untouched — it has no `id`, and its
* delta is applied separately.
*/
function patchReadState(data, idSet, read) {
const patchRow = (r) => (idSet.has(r.id)
? {
...r,
unread: !read,
// `processing` also carries Imported / Processed / Rejected, which are a
// different column. Only the read-derived pair moves with this one.
processing: r.processing === 'Unread' || r.processing === 'Read'
? (read ? 'Read' : 'Unread')
: r.processing,
}
: r)
if (Array.isArray(data)) return data.map(patchRow)
if (data && Array.isArray(data.rows)) return { ...data, rows: data.rows.map(patchRow) }
if (data && typeof data === 'object' && data.id && idSet.has(data.id)) return patchRow(data)
return data
}
/**
* PATCH /inbox/read — flips message_read for one row or for two hundred.
*
* The single-row route (POST /inbox/{id}/read) still exists, but every flip goes
* through the bulk one so there is exactly ONE optimistic patch to reason about.
*
* Optimistic, so rows un-bold on click instead of after the round trip, and roll
* back if the server rejects. Both mailbox caches hold {id, unread} rows, so one
* setQueriesData over qk.mailbox.all() covers the Email tab and the application
* tabs at once; `processing` is derived from the same column, so it moves with it.
*
* NOTE: the route requires INBOX_EDIT while the lists only require INBOX_VIEW, so
* a view-only user gets a 403 here and the rows snap back. The bulk bars disable
* themselves for those users; click-to-read cannot, so it still relies on rollback.
*/
async function optimisticRead(qc, ids, read) {
await qc.cancelQueries({ queryKey: qk.mailbox.all() })
const previous = qc.getQueriesData({ queryKey: qk.mailbox.all() })
const idSet = new Set(ids)
// Which rows actually FLIP, counted BEFORE the patch: the counts object is a
// bag of totals with no per-row detail to derive the delta from afterwards.
// A Set, because the same id appears in several cached lists at once.
const flipping = new Set()
for (const [, data] of previous) {
const rows = Array.isArray(data) ? data : data?.rows
if (!Array.isArray(rows)) continue
for (const r of rows) if (idSet.has(r.id) && Boolean(r.unread) === read) flipping.add(r.id)
}
qc.setQueriesData({ queryKey: qk.mailbox.all() }, (data) => patchReadState(data, idSet, read))
// The Unread tab badge and the sidebar badge share this entry. Without the
// delta they sit stale until the refetch lands — invisible for one row,
// glaring for two hundred. `previous` already covers it for rollback,
// because ['mailbox'] is a prefix of ['mailbox','counts'].
qc.setQueryData(qk.mailbox.counts(), (c) => (c
? { ...c, unread: Math.max(0, (c.unread ?? 0) + (read ? -flipping.size : flipping.size)) }
: c))
return { previous }
}
function useSetRead(toast) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ ids, read }) => setReadChunked(ids, read),
onMutate: ({ ids, read }) => optimisticRead(qc, ids, read),
onError: (err, _vars, ctx) => {
for (const [key, data] of ctx?.previous ?? []) qc.setQueryData(key, data)
toast(friendlyAuthError(err, 'Could not update read state.'), 'error')
},
onSettled: () => qc.invalidateQueries({ queryKey: qk.mailbox.all() }),
})
}
/**
* PATCH /inbox/read-all — every row matching a server-side list filter.
*
* The WRITE is a WHERE clause, but the optimistic patch still runs over the ids
* the caller can see: this endpoint is only ever used where the visible list IS
* the server's whole result (see SERVER_SCOPED_TABS, and the list endpoint takes
* no pagination), so those ids are the scope, not a sample of it. Without the
* patch the rows sit unchanged for a whole round trip plus a refetch, which reads
* as a dead button — and the reconciling refetch on settle corrects it anyway if
* the scope ever did reach further.
*/
function useSetReadAll(toast) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ read, filter }) => inboxApi.setReadAll({ read, ...filter }),
onMutate: ({ ids, read }) => optimisticRead(qc, ids ?? [], read),
onSuccess: (res, { read }) => {
const n = res?.data?.updated ?? 0
toast(
n === 0
? `Nothing to mark ${read ? 'read' : 'unread'} here`
: `${n} ${n === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`,
'success',
)
},
onError: (err, _vars, ctx) => {
for (const [key, data] of ctx?.previous ?? []) qc.setQueryData(key, data)
toast(friendlyAuthError(err, 'Could not update read state.'), 'error')
},
onSettled: () => qc.invalidateQueries({ queryKey: qk.mailbox.all() }),
})
}
/**
* Multi-select over a row list: the Set, the toggles, and the pruning.
*
* The pruning is not optional. Rows leave a list under their own steam — the
* Unread tab drops a row the instant it is marked read — so a Set left alone
* keeps counting ids that are no longer on screen, and "Mark 12 read" quietly
* acts on 9 of them.
*/
function useRowSelection(rows) {
const [selectedIds, setSelectedIds] = useState(() => new Set())
const visibleIds = useMemo(
() => (Array.isArray(rows) ? rows.map((r) => r.id) : []),
[rows],
)
useEffect(() => {
setSelectedIds((prev) => {
if (prev.size === 0) return prev
const visible = new Set(visibleIds)
const next = new Set()
for (const id of prev) if (visible.has(id)) next.add(id)
// Same size means nothing was pruned. Returning `prev` keeps the Set
// identity stable, so this effect cannot retrigger itself.
return next.size === prev.size ? prev : next
})
}, [visibleIds])
const toggle = useCallback((id) => setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
}), [])
const clear = useCallback(() => setSelectedIds((prev) => (prev.size ? new Set() : prev)), [])
const toggleAll = useCallback(() => setSelectedIds((prev) => (
prev.size >= visibleIds.length ? new Set() : new Set(visibleIds)
)), [visibleIds])
return {
selectedIds,
toggle,
toggleAll,
clear,
allSelected: visibleIds.length > 0 && selectedIds.size === visibleIds.length,
}
}
/**
* The row tick. stopPropagation is load-bearing: without it a tick also runs the
* row's onClick, which opens the detail pane AND auto-marks it read — instantly
* undoing the "mark unread" the user is selecting rows for.
*/
function RowCheck({ checked, onToggle, label }) {
return (
<span
className={`checkbox${checked ? ' on' : ''}`}
role="checkbox"
aria-checked={checked}
aria-label={label}
tabIndex={0}
style={{ alignSelf: 'center' }}
onClick={(e) => { e.stopPropagation(); onToggle() }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
onToggle()
}
}}
>
<Icon name="check" />
</span>
)
}
const READ_TICK_MS = 1000
/**
* Select-all plus the read/unread actions for one list.
*
* The leading ✓ is not a mode indicator. It only appears on the Mark read /
* Mark all read button that was just clicked, then clears from both after 1s.
*
* `rows` rather than a count, because the bar needs each row's read state, and
* `selectedIds` narrows it to what the selected pair of buttons would touch.
*/
function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, scopeLabel }) {
const { selectedIds, toggleAll, clear, allSelected } = selection
const n = selectedIds.size
const total = rows.length
// What the visible pair of buttons acts on: the ticked rows when there is a
// selection, the whole view otherwise.
const target = n > 0 ? rows.filter((r) => selectedIds.has(r.id)) : rows
const unreadCount = target.reduce((acc, r) => acc + (r.unread ? 1 : 0), 0)
const readCount = target.length - unreadCount
const blocked = !canEdit || busy
const tip = !canEdit ? 'Requires inbox.edit' : undefined
const scopeTip = !canEdit ? 'Requires inbox.edit' : `Applies to ${scopeLabel}`
const nothingTo = (word) => `Nothing to mark ${word} here`
// `selected` = Mark read, `all` = Mark all read. Never both at once in the
// bar, but the flash still resets both so a leftover tick cannot linger
// after the selection-vs-all swap.
const [ticked, setTicked] = useState(null)
const tickTimer = useRef(null)
useEffect(() => () => {
if (tickTimer.current) clearTimeout(tickTimer.current)
}, [])
function flashRead(which, action) {
if (tickTimer.current) clearTimeout(tickTimer.current)
setTicked(which)
action()
tickTimer.current = setTimeout(() => {
setTicked(null)
tickTimer.current = null
}, READ_TICK_MS)
}
return (
<div className="inbox-bulk-bar">
<RowCheck
checked={allSelected}
onToggle={toggleAll}
label={allSelected ? 'Clear selection' : 'Select all'}
/>
<span className="inbox-bulk-count" title={n > 0
? `${n} selected`
: `${total} ${total === 1 ? 'message' : 'messages'}${unreadCount ? ` · ${unreadCount} unread` : ''}`}
>
{n > 0
? `${n} selected`
: `${total}${unreadCount ? ` · ${unreadCount}` : ''}`}
</span>
<div className="inbox-bulk-actions">
{n > 0 ? (
<>
<button
className="btn btn-secondary btn-sm"
disabled={blocked || unreadCount === 0}
title={unreadCount === 0 ? nothingTo('read') : tip || 'Mark selected read'}
onClick={() => flashRead('selected', () => onSetRead(true))}
>
{ticked === 'selected' && <Icon name="check" />}
Mark as Read
</button>
<button
className="btn btn-secondary btn-sm"
disabled={blocked || readCount === 0}
title={readCount === 0 ? nothingTo('unread') : tip || 'Mark selected unread'}
onClick={() => onSetRead(false)}
>
Mark as Unread
</button>
<button className="btn btn-ghost btn-sm" onClick={clear}>Clear</button>
</>
) : (
<>
<button
className="btn btn-secondary btn-sm"
disabled={blocked || unreadCount === 0}
title={unreadCount === 0 ? nothingTo('read') : scopeTip}
onClick={() => flashRead('all', () => onSetAllRead(true))}
>
{ticked === 'all' && <Icon name="check" />}
Mark all Read
</button>
<button
className="btn btn-secondary btn-sm"
disabled={blocked || readCount === 0}
title={readCount === 0 ? nothingTo('unread') : scopeTip}
onClick={() => onSetAllRead(false)}
>
Mark all Unread
</button>
</>
)}
</div>
</div>
)
}
export default function Inbox() {
const { toast } = useToast()
const navigate = useNavigate()
const qc = useQueryClient()
const { can } = useAuth()
const canEdit = can('inbox.edit')
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const updateInbox = useSeedMutation('inbox')
const [channel, setChannel] = useState('email')
const [formSheet, setFormSheet] = useState(DEFAULT_FORM_SHEET)
const [tab, setTab] = useState('All Applications')
const [skip, setSkip] = useState(0)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [selectedId, setSelectedId] = useState(null)
const [q, setQ] = useState('')
const [assigning, setAssigning] = useState(null)
const [noting, setNoting] = useState(null)
const isForms = channel === 'forms'
const channelTabs = isForms ? FORM_TABS : TABS
const tabFilter = TAB_FILTERS[tab] ?? {}
const formTabFilter = FORM_TAB_FILTERS[tab] ?? {}
const listParams = useMemo(() => ({
...tabFilter,
top: pageSize,
skip,
...(q.trim() ? { search: q.trim() } : {}),
}), [tabFilter, skip, pageSize, q])
const formParams = useMemo(() => ({
sheet: formSheet || undefined,
offset: skip,
limit: pageSize,
...formTabFilter,
...(q.trim() ? { search: q.trim() } : {}),
}), [formSheet, skip, pageSize, q, formTabFilter])
const applicationsQuery = useQuery({
queryKey: qk.mailbox.applications(listParams),
queryFn: () => fetchApplications(listParams),
enabled: !isForms,
})
const formSheetsQuery = useQuery({
queryKey: qk.mailbox.formSheets(),
queryFn: async () => {
const res = await sheetApi.listFormDataSheets()
const sheets = res?.data?.sheets
return Array.isArray(sheets) ? sheets : []
},
enabled: isForms,
})
const formQuery = useQuery({
queryKey: qk.mailbox.formData(formParams),
queryFn: () => fetchFormApplications(formParams),
enabled: isForms,
})
const countsQuery = useQuery({
queryKey: qk.mailbox.counts(),
queryFn: fetchInboxCounts,
enabled: !isForms,
})
const formCountsQuery = useQuery({
queryKey: qk.mailbox.formCounts({ sheet: formSheet || undefined }),
queryFn: async () => {
const res = await sheetApi.fetchFormCounts({ sheet: formSheet || undefined })
return res?.data ?? {}
},
enabled: isForms,
})
const emailTotalQuery = useQuery({
queryKey: qk.mailbox.applicationTotal(),
queryFn: async () => {
const res = await inboxApi.countApplications()
return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0)
},
enabled: !isForms,
staleTime: Infinity,
})
const formTotalQuery = useQuery({
queryKey: qk.mailbox.formTotal({ sheet: formSheet || undefined }),
queryFn: async () => {
const res = await sheetApi.countFormData({ sheet: formSheet || undefined })
return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0)
},
enabled: isForms,
staleTime: Infinity,
})
// Prefer the imported sheet list; keep the known 2026 tab even when the
// sheets endpoint is still loading so the first paint is not blank.
const formSheetOptions = useMemo(() => {
const fromApi = formSheetsQuery.data ?? []
if (fromApi.length) return fromApi
return formSheet ? [formSheet] : [DEFAULT_FORM_SHEET]
}, [formSheetsQuery.data, formSheet])
useEffect(() => {
if (!isForms || !formSheetsQuery.data?.length) return
if (!formSheetsQuery.data.includes(formSheet)) {
setFormSheet(formSheetsQuery.data[0])
}
}, [isForms, formSheetsQuery.data, formSheet])
const activeQuery = isForms ? formQuery : applicationsQuery
const inbox = Array.isArray(activeQuery.data?.rows) ? activeQuery.data.rows : []
const serverCounts = isForms ? (formCountsQuery.data ?? {}) : (countsQuery.data ?? {})
const counts = useMemo(
() => {
const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
return {
'All Applications': n(serverCounts.all),
Unread: n(serverCounts.unread),
Processed: n(serverCounts.processed),
Rejected: n(serverCounts.rejected),
Duplicates: n(serverCounts.duplicates),
}
},
[serverCounts],
)
const poolTotal = isForms ? (formTotalQuery.data ?? 0) : (emailTotalQuery.data ?? 0)
const tabTotal = counts[tab] ?? 0
const countsReady = isForms ? formCountsQuery.isSuccess : countsQuery.isSuccess
const total = q.trim()
? (activeQuery.data?.total ?? 0)
: (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0)))
const pages = Math.max(1, Math.ceil(total / pageSize))
const from = total ? skip + 1 : 0
const to = Math.min(skip + pageSize, total)
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 list = inbox
const detailQuery = useQuery({
queryKey: isForms ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId),
queryFn: () => (isForms ? fetchFormDetail(selectedId) : fetchMessageDetail(selectedId)),
enabled: Boolean(selectedId),
})
const selectedRow = inbox.find((i) => i.id === selectedId)
const selected = selectedRow || detailQuery.data
? { ...selectedRow, ...(detailQuery.data ?? {}) }
: null
// Bound to `list`, not `inbox`: the tick boxes sit on the rows the user can
// actually see, and the hook prunes the Set as that set changes.
const selection = useRowSelection(list)
const setRead = useSetRead(toast)
const setReadAll = useSetReadAll(toast)
/**
* What "Mark all" means here, in words, for the button tooltip. Kept next to
* setReadEverything so the label and the scope cannot drift apart.
*/
const scopeLabel = q.trim()
? `the ${list.length} row${list.length === 1 ? '' : 's'} matching this search`
: tab === 'All Applications'
? 'every application'
: `the ${tab} tab`
function switchChannel(next) {
if (next === channel) return
setChannel(next)
setSkip(0)
setSelectedId(null)
setQ('')
// Unread is email-only; leave it behind when opening Sheet Forms.
if (next === 'forms' && tab === 'Unread') setTab('All Applications')
selection.clear()
}
function setReadSelected(read) {
const ids = [...selection.selectedIds]
if (!ids.length) return
setRead.mutate({ ids, read }, {
onSuccess: () => {
toast(`${ids.length} ${ids.length === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`, 'success')
selection.clear()
},
})
}
function setReadEverything(read) {
// Search goes on the wire now, so every tab is a true server scope.
if (SERVER_SCOPED_TABS.has(tab)) {
setReadAll.mutate({
read,
filter: { ...tabFilter, ...(q.trim() ? { search: q.trim() } : {}) },
ids: list.map((i) => i.id),
})
return
}
const ids = list.map((i) => i.id)
if (!ids.length) return
setRead.mutate({ ids, read }, {
onSuccess: () => {
toast(`${ids.length} ${ids.length === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`, 'success')
selection.clear()
},
})
}
const setState = useMutation({
mutationFn: ({ id, state, kind }) => (
kind === 'form'
? sheetApi.setProcessingState(id, state)
: inboxApi.setProcessingState(id, state)
),
onSuccess: (_data, vars) => {
const labels = { imported: 'Imported', processed: 'Processed', rejected: 'Rejected', unread: 'Unread' }
toast(`${vars.name || 'Application'} marked ${labels[vars.state] || vars.state}`, vars.state === 'rejected' ? 'warning' : 'success')
if (vars.state === 'processed') setTimeout(() => navigate('/pipeline'), 700)
},
onError: (err) => toast(friendlyAuthError(err, 'Could not update processing state.'), 'error'),
onSettled: () => {
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
qc.invalidateQueries({ queryKey: qk.mailbox.counts() })
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
qc.invalidateQueries({ queryKey: qk.candidates.all() })
qc.invalidateQueries({ queryKey: qk.analytics.all() })
},
})
const markDuplicate = useMutation({
mutationFn: ({ id, isDuplicate, kind }) => (
kind === 'form'
? sheetApi.setDuplicate(id, isDuplicate)
: inboxApi.setDuplicate(id, isDuplicate)
),
onSuccess: (_d, vars) => toast(vars.isDuplicate ? 'Marked as duplicate' : 'Duplicate cleared', 'success'),
onError: (err) => toast(friendlyAuthError(err, 'Could not update duplicate flag.'), 'error'),
onSettled: () => {
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
qc.invalidateQueries({ queryKey: qk.mailbox.counts() })
},
})
function select(id) {
setSelectedId(id)
if (isForms) return
const item = inbox.find((i) => i.id === id)
if (item?.unread) setRead.mutate({ ids: [id], read: true })
}
function importItem(item) {
setState.mutate({ id: item.id, state: 'imported', name: item.name, kind: item.kind })
}
function moveToPipeline(item) {
setState.mutate({ id: item.id, state: 'processed', name: item.name, kind: item.kind })
}
function reject(item) {
setState.mutate({ id: item.id, state: 'rejected', name: item.name, kind: item.kind })
}
function toggleDuplicate(item) {
markDuplicate.mutate({ id: item.id, isDuplicate: !item.duplicate, kind: item.kind })
}
const [syncRunId, setSyncRunId] = useState(() => readStoredSyncRunId())
const syncToastShown = useRef(null)
const sync = useMutation({
mutationFn: () => inboxApi.startMailboxSync(),
onSuccess: (res) => {
const id = res?.data?.id
if (id) {
storeSyncRunId(id)
setSyncRunId(id)
}
toast('Mailbox sync started — safe to leave this page', 'info')
},
onError: (err) => toast(friendlyAuthError(err, 'Sync failed'), 'error'),
})
const syncRun = useQuery({
queryKey: qk.mailbox.sync(syncRunId),
queryFn: async () => {
const res = await inboxApi.getMailboxSync(syncRunId)
return res?.data ?? null
},
enabled: Boolean(syncRunId),
refetchInterval: (q) => {
const status = q.state.data?.status
return status === 'queued' || status === 'running' ? 800 : false
},
})
useEffect(() => {
const run = syncRun.data
if (!run?.id) return undefined
if (run.status === 'completed' && syncToastShown.current !== run.id) {
syncToastShown.current = run.id
const t = run.triage
toast(
t
? `Mailbox synced — ${t.ingested ?? 0} imported, ${t.skipped ?? 0} filtered out`
: 'Mailbox synced',
'success',
)
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
const timer = setTimeout(() => {
storeSyncRunId(null)
setSyncRunId(null)
}, 1800)
return () => clearTimeout(timer)
}
if (run.status === 'failed' && syncToastShown.current !== run.id) {
syncToastShown.current = run.id
toast(run.error || 'Sync failed', 'error')
storeSyncRunId(null)
setSyncRunId(null)
}
return undefined
}, [qc, syncRun.data, toast])
return (
<div className="page">
<PageHeader
title="Recruitment Inbox"
sub={
isForms
? 'Google Form applicants — same queue energy, profile-first cards'
: 'Every candidate, every source — one unified queue'
}
actions={<>
<div className="pill-tabs" role="tablist" aria-label="Inbox channel">
{CHANNELS.map((c) => (
<button
key={c.key}
type="button"
role="tab"
aria-selected={channel === c.key}
className={`pill-tab${channel === c.key ? ' active' : ''}`}
onClick={() => switchChannel(c.key)}
>
<Icon name={c.icon} /> {c.label}
</button>
))}
</div>
{!isForms && (
<span className="integration-status"><span className="pulse" />Microsoft Graph API · Connected</span>
)}
{isForms && (
<span className="integration-status"><span className="pulse" />Google Sheets · Form data</span>
)}
{!isForms && (
<SyncButton
run={syncRun.data}
pending={sync.isPending}
disabled={!canEdit}
onClick={() => sync.mutate()}
/>
)}
<button className="btn btn-primary" onClick={() => navigate('/import')}>
<Icon name="upload" /> Upload CVs
</button>
</>}
/>
<div className="card">
<div style={{ margin: '0 16px', paddingTop: 8 }}>
<Tabs
className="tabs tabs-wrap"
value={tab}
onChange={(t) => {
setTab(t)
setSkip(0)
setSelectedId(null)
selection.clear()
}}
tabs={channelTabs.map((t) => ({ key: t, label: t, count: counts[t] }))}
/>
</div>
<div className="split inbox-split">
<div className="split-list inbox-queue">
{!isForms && applicationsQuery.isSuccess && (
<BulkReadBar
rows={list}
selection={selection}
onSetRead={setReadSelected}
onSetAllRead={setReadEverything}
busy={setRead.isPending || setReadAll.isPending}
canEdit={canEdit}
scopeLabel={scopeLabel}
/>
)}
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
{isForms && (
<div style={{ marginBottom: 10 }}>
<label className="cell-sub" htmlFor="inbox-form-sheet" style={{ display: 'block', marginBottom: 4 }}>
Sheet tab
</label>
<select
id="inbox-form-sheet"
value={formSheet}
onChange={(e) => {
setFormSheet(e.target.value)
setSkip(0)
setSelectedId(null)
}}
style={{ width: '100%' }}
>
{formSheetOptions.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div>
)}
<div className="toolbar-search" style={{ maxWidth: 'none' }}>
<Icon name="search" />
<input
value={q}
onChange={(e) => { setQ(e.target.value); setSkip(0) }}
placeholder={isForms ? 'Search name, email, position, city…' : 'Search applications…'}
/>
</div>
</div>
<div>
{activeQuery.isPending && (
<SkeletonRows rows={6} />
)}
{activeQuery.isError && (
<EmptyState icon="inbox" title={isForms ? "Couldn't load form applicants" : "Couldn't load applications"}>
{friendlyAuthError(activeQuery.error, 'Request failed')}
</EmptyState>
)}
{activeQuery.isSuccess && list.length === 0 ? (
<EmptyState icon="inbox" title="Nothing here">
{isForms ? 'No form responses in this sheet tab.' : 'No applications in this view.'}
</EmptyState>
) : (
list.map((i) => (
<div
key={i.id}
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
onClick={() => select(i.id)}
>
{!isForms && (
<RowCheck
checked={selection.selectedIds.has(i.id)}
onToggle={() => selection.toggle(i.id)}
label={`Select ${i.name}`}
/>
)}
<Avatar name={i.name} initials={i.initials} color={i.color} />
<div className="ii-main">
<div className="ii-name">
<span className="truncate min-w-0" title={i.name}>{i.name}</span>
{i.duplicate && (
<span className="badge b-red badge-plain" style={{ padding: '1px 6px', fontSize: 10 }}>DUP</span>
)}
</div>
<div className="ii-pos">{i.position}</div>
<div className="ii-aside">
<div className="ii-time">
{outlookListTime(i.received)}
</div>
{i.atsScore != null && (
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
)}
{isForms && i.noticePeriod && (
<div className="cell-sub" style={{ marginTop: 6 }}>{i.noticePeriod}</div>
)}
</div>
<div className="ii-meta">
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<Badge>{i.applicationStatus}</Badge>
)}
{isForms && i.residingCity && (
<span className="cell-sub">{i.residingCity}</span>
)}
</div>
</div>
</div>
))
)}
</div>
{activeQuery.isSuccess && total > 0 && (
<Pagination
from={from}
to={to}
total={total}
page={currentPage}
pages={pages}
setPage={(p) => { setSkip((p - 1) * pageSize); setSelectedId(null); selection.clear() }}
pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
pageSizeMax={PAGE_SIZE_MAX}
onPageSizeChange={(n) => {
setPageSize(n)
setSelectedId(null)
selection.clear()
}}
/>
)}
</div>
<div className="split-detail">
{!selected ? (
<div style={{ padding: '100px 20px' }}>
<EmptyState icon="inbox" title="Select an application">
{isForms
? 'Pick a form applicant to see their profile, resume, and screening notes.'
: 'Choose an item from the list to view details and take action.'}
</EmptyState>
</div>
) : detailQuery.isError ? (
<div style={{ padding: '100px 20px' }}>
<EmptyState icon="inbox" title="Couldn't load this application">
{friendlyAuthError(detailQuery.error, 'Request failed')}
</EmptyState>
</div>
) : isForms ? (
<FormApplicantDetail
item={selected}
loading={detailQuery.isPending}
busy={setState.isPending || markDuplicate.isPending}
canEdit={canEdit}
toast={toast}
onImport={() => importItem(selected)}
onMove={() => moveToPipeline(selected)}
onNote={() => setNoting(selected)}
onReject={() => reject(selected)}
onToggleDuplicate={() => toggleDuplicate(selected)}
/>
) : (
<ApplicationDetail
item={selected}
loading={detailQuery.isPending}
busy={setState.isPending || markDuplicate.isPending}
canEdit={canEdit}
toast={toast}
onImport={() => importItem(selected)}
onMove={() => moveToPipeline(selected)}
onNote={() => setNoting(selected)}
onReject={() => reject(selected)}
onToggleDuplicate={() => toggleDuplicate(selected)}
/>
)}
</div>
</div>
</div>
{assigning && (
<AssignRecruiter
item={assigning}
recruiters={recruiters}
onClose={() => setAssigning(null)}
onSave={(name) => {
updateInbox((items) => items.map((i) => (i.id === assigning.id ? { ...i, recruiter: name } : i)))
setAssigning(null)
toast(`Recruiter assigned to ${assigning.name}`, 'success')
}}
/>
)}
{noting && (
<Modal
title="Add Note"
subtitle={noting.name}
onClose={() => setNoting(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setNoting(null)}>Cancel</button>
<button className="btn btn-primary" onClick={() => { setNoting(null); toast('Note added', 'success') }}>
<Icon name="check" /> Save Note
</button>
</>
}
>
<div className="form-field">
<label>Note</label>
<textarea placeholder="Add a note about this application…" />
</div>
</Modal>
)}
</div>
)
}
/** Fields inbox_messages has no column for come back null; show a dash, not "null". */
function orDash(value, suffix = '') {
return value == null || value === '' ? '—' : `${value}${suffix}`
}
function externalHref(url) {
const raw = (url || '').trim()
if (!raw) return null
if (/^https?:\/\//i.test(raw)) return raw
return `https://${raw}`
}
/** Persistable /in/<slug> → a public profile URL. Empty string means scanned, none found. */
function linkedinHrefFromSlug(slug) {
const cleaned = (slug || '').trim()
if (!cleaned) return null
return `https://www.linkedin.com/in/${cleaned}`
}
function firstResumeKey(item) {
const fromFiles = (item?.files || []).map((f) => f.url).find(Boolean)
if (fromFiles) return fromFiles
return s3Api.firstKey(item?.filePath)
}
/**
* Sheet form applicant detail — profile grids + resume/LinkedIn links +
* title-matched job selection (position_applied_for ↔ job_posts.title) +
* the same Import / Shortlist / Duplicate / Reject actions as email.
*/
function FormApplicantDetail({
item: i, loading, busy, canEdit, toast,
onImport, onMove, onNote, onReject, onToggleDuplicate,
}) {
const qc = useQueryClient()
const resumeHref = externalHref(i.resumeLink)
const profileHref = externalHref(i.profileLink)
const location = [i.residingCity, i.residingCountry].filter(Boolean).join(', ')
const education = [i.degree, i.university].filter(Boolean).join(' · ')
const [selection, setSelection] = useState(null)
const [manualPost, setManualPost] = useState(null)
const [showPicker, setShowPicker] = useState(false)
useEffect(() => {
setManualPost(null)
setSelection(i.assignedId || null)
}, [i.id, i.assignedId])
const matchCards = useMemo(() => {
return (i.jobPosts || []).map((post, idx) => ({
rank: idx + 1,
post,
}))
}, [i.jobPosts])
const selectedPost = useMemo(() => {
if (!selection) return null
if (manualPost && String(manualPost.id) === String(selection)) return manualPost
if (i.assignedPost && String(i.assignedPost.id) === String(selection)) return i.assignedPost
const hit = matchCards.find((c) => String(c.post.id) === String(selection))
return hit?.post || null
}, [selection, manualPost, i.assignedPost, matchCards])
const assignMutation = useMutation({
mutationFn: ({ recordId, jobPostId }) => sheetApi.assignJobPost(recordId, jobPostId),
onError: (err) => toast(friendlyAuthError(err, 'Could not assign job post.'), 'error'),
onSuccess: (_res, vars) => {
const title = selectedPost?.title || 'role'
if (vars.jobPostId) toast(`${i.name}${title}`, 'success')
else toast(`${i.name} unassigned`, 'success')
},
onSettled: (_res, _err, vars) => {
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
qc.invalidateQueries({ queryKey: qk.mailbox.formRow(vars.recordId) })
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
qc.invalidateQueries({ queryKey: qk.candidates.all() })
qc.invalidateQueries({ queryKey: qk.analytics.all() })
},
})
const assigned = i.assignedPost
const canAssign = canEdit && selection && selection !== i.assignedId && !assignMutation.isPending
const jobChosen = Boolean(i.assignedId || selection)
const alreadyProcessed = i.processing === 'Processed'
const shortlistLocked = !alreadyProcessed && !jobChosen
const panelBusy = busy || assignMutation.isPending
async function handleMove() {
if (busy || alreadyProcessed) return
if (!jobChosen) {
toast(SHORTLIST_JOB_WARNING, 'warning')
return
}
const jobId = selection || i.assignedId
if (jobId && String(jobId) !== String(i.assignedId || '')) {
try {
await assignMutation.mutateAsync({ recordId: i.id, jobPostId: jobId })
} catch {
return
}
}
onMove()
}
return (
<div style={{ padding: 24 }}>
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
<div className="ph-role">{i.position}</div>
<div className="ph-tags" style={{ marginTop: 8 }}>
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
{i.duplicate && <><Badge className="b-red">Duplicate</Badge>{' '}</>}
{i.hoAvailability && (
<Badge className={String(i.hoAvailability).toLowerCase() === 'yes' ? 'b-green' : 'b-amber'}>
Relocate: {i.hoAvailability}
</Badge>
)}{' '}
{loading && <span className="cell-sub">Loading details</span>}
</div>
</div>
{i.rowNumber != null && (
<div style={{ textAlign: 'right' }}>
<div className="cell-sub">Sheet row</div>
<div className="fw-600">{i.rowNumber}</div>
</div>
)}
</div>
{(resumeHref || profileHref) && (
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
{resumeHref && (
s3Api.canOpen(resumeHref) ? (
<OpenResumeButton filePath={resumeHref} />
) : (
<a className="btn btn-primary btn-sm" href={resumeHref} target="_blank" rel="noopener noreferrer">
<Icon name="paperclip" /> Open resume
</a>
)
)}
{profileHref && (
<a className="btn btn-secondary btn-sm" href={profileHref} target="_blank" rel="noopener noreferrer">
<Icon name="linkedin" /> LinkedIn
</a>
)}
</div>
)}
{assigned && (
<div
className="card"
style={{
boxShadow: 'none',
background: 'var(--primary-soft)',
border: '1px solid var(--primary-border)',
marginBottom: 18,
}}
>
<div className="card-body flex items-center gap-12" style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
<div className="flex items-center gap-8">
<Icon name="check-circle" />
<div>
<div>Assigned to <b>{assigned.title}</b></div>
<div className="cell-sub">
{[assigned.employment_type, assigned.location].filter(Boolean).join(' · ') || '—'}
</div>
</div>
</div>
<div className="flex gap-8">
<button
className="btn btn-secondary btn-sm"
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => setShowPicker(true)}
>
Change
</button>
<button
className="btn btn-ghost btn-sm"
disabled={!canEdit || assignMutation.isPending}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => assignMutation.mutate({ recordId: i.id, jobPostId: null })}
>
Unassign
</button>
</div>
</div>
</div>
)}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 18,
alignItems: 'start',
marginBottom: 20,
}}
>
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
<div className="email-head" style={{ borderRadius: '10px 10px 0 0' }}>Contact &amp; application</div>
<div
className="info-grid"
style={{
marginBottom: 20,
border: '1px solid var(--border)',
borderTop: 'none',
borderRadius: '0 0 10px 10px',
padding: '14px 16px',
background: 'var(--bg-elev)',
}}
>
<div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
<div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
<div className="info-item"><div className="il">Applied</div><div className="iv">{i.received ? fmtDate(i.received) : ''}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{orDash(i.source)}</div></div>
<div className="info-item"><div className="il">Screened by</div><div className="iv">{orDash(i.screenedBy)}</div></div>
<div className="info-item"><div className="il">Notice period</div><div className="iv">{orDash(i.noticePeriod)}</div></div>
</div>
<div className="email-head" style={{ borderRadius: '10px 10px 0 0' }}>Profile</div>
<div
className="info-grid"
style={{
marginBottom: 20,
border: '1px solid var(--border)',
borderTop: 'none',
borderRadius: '0 0 10px 10px',
padding: '14px 16px',
background: 'var(--bg-elev)',
}}
>
<div className="info-item"><div className="il">Gender</div><div className="iv">{orDash(i.gender)}</div></div>
<div className="info-item"><div className="il">Date of birth</div><div className="iv">{i.dateOfBirth ? fmtDate(i.dateOfBirth) : ''}</div></div>
<div className="info-item"><div className="il">CNIC</div><div className="iv">{orDash(i.cnic)}</div></div>
<div className="info-item"><div className="il">Marital status</div><div className="iv">{orDash(i.maritalStatus)}</div></div>
<div className="info-item"><div className="il">Location</div><div className="iv">{orDash(location)}</div></div>
<div className="info-item"><div className="il">Education</div><div className="iv">{orDash(education)}</div></div>
<div className="info-item"><div className="il">Graduation</div><div className="iv">{orDash(i.graduationYear)}</div></div>
<div className="info-item"><div className="il">Other university</div><div className="iv">{orDash(i.universityOther)}</div></div>
<div className="info-item"><div className="il">Current salary</div><div className="iv">{orDash(i.currentSalary)}</div></div>
<div className="info-item"><div className="il">Expected salary</div><div className="iv">{orDash(i.expectedSalary)}</div></div>
</div>
{i.hrComments && (
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 8 }}>
<div className="card-body">
<div className="fw-600" style={{ marginBottom: 6 }}>HR comment</div>
<p style={{ margin: 0 }}>{i.hrComments}</p>
</div>
</div>
)}
{i.sheet && (
<div className="cell-sub" style={{ marginTop: 12 }}>
Imported from {i.sheet}
</div>
)}
</div>
<div role="radiogroup" aria-label="Matching roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
<div className="fw-600" style={{ marginBottom: 8 }}>Matching roles</div>
<div className="cell-sub" style={{ marginBottom: 10 }}>
Matched by position applied for: {orDash(i.position)}
</div>
{matchCards.length === 0 && !manualPost ? (
<EmptyState icon="alert" title="No matching roles">
<p>No job post title matches this position. Choose a role manually.</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap', marginTop: 10 }}>
<button
className="btn btn-primary btn-sm"
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => setShowPicker(true)}
>
Choose a role
</button>
</div>
</EmptyState>
) : (
matchCards.map(({ rank, post }) => (
<JobCard
key={post.id}
post={post}
rank={rank}
badge={`Match #${rank}`}
selected={String(selection) === String(post.id)}
onSelect={(id) => setSelection(String(id))}
/>
))
)}
{manualPost && (
<JobCard
post={manualPost}
rank={0}
manual
selected={String(selection) === String(manualPost.id)}
onSelect={(id) => setSelection(String(id))}
/>
)}
<button
className="btn btn-secondary"
style={{ width: '100%', marginTop: 8 }}
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => setShowPicker(true)}
>
Choose a different role
</button>
<button
className="btn btn-primary"
style={{ width: '100%', marginTop: 8 }}
disabled={!canAssign}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => {
if (!selection || !canEdit) return
assignMutation.mutate({ recordId: i.id, jobPostId: selection })
}}
>
Assign
</button>
</div>
</div>
<div className="flex gap-8" style={{ flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={onImport} disabled={panelBusy || i.processing === 'Imported'}>
<Icon name="user-plus" /> {i.processing === 'Imported' ? 'Imported' : 'Import Candidate'}
</button>
<button
className="btn btn-secondary"
aria-disabled={shortlistLocked || panelBusy || alreadyProcessed}
disabled={panelBusy || alreadyProcessed}
style={shortlistLocked ? { opacity: 0.55, cursor: 'not-allowed' } : undefined}
title={shortlistLocked ? SHORTLIST_JOB_WARNING : undefined}
onClick={handleMove}
>
<Icon name="layers" /> Move to Shortlist
</button>
<button className="btn btn-secondary" onClick={onNote} disabled title="Notes attach to a candidate profile — open the candidate first">
<Icon name="edit" /> Add Note
</button>
<button className="btn btn-secondary" onClick={onToggleDuplicate} disabled={panelBusy}>
<Icon name="alert" /> {i.duplicate ? 'Clear Duplicate' : 'Mark Duplicate'}
</button>
<button
className="btn btn-ghost"
style={{ color: 'var(--danger)' }}
onClick={onReject}
disabled={panelBusy || i.processing === 'Rejected'}
>
<Icon name="trash" /> Reject
</button>
</div>
{showPicker && (
<PickRoleModal
onClose={() => setShowPicker(false)}
onPick={(post) => {
setManualPost(post)
setSelection(String(post.id))
}}
/>
)}
</div>
)
}
function ApplicationDetail({
item: i, loading, busy, canEdit, toast, onImport, onMove, onNote, onReject, onToggleDuplicate,
}) {
const qc = useQueryClient()
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'
const ringColor = i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)'
const [selection, setSelection] = useState(null)
const [manualPost, setManualPost] = useState(null)
const [showPicker, setShowPicker] = useState(false)
const [whyOpen, setWhyOpen] = useState(false)
// Do not auto-pick the first suggestion: Shortlist stays locked until the
// recruiter actually chooses a card (or the row already has an assigned job).
useEffect(() => {
setManualPost(null)
setWhyOpen(false)
setSelection(i.assignedId || null)
}, [i.id, i.assignedId])
const suggestionCards = useMemo(() => {
const byId = new Map((i.suggestedPosts || []).map((p) => [String(p.id), p]))
return (i.suggestedIds || []).map((id, idx) => ({
rank: idx + 1,
post: byId.get(id) || { id, unavailable: true },
}))
}, [i.suggestedPosts, i.suggestedIds])
const selectedPost = useMemo(() => {
if (!selection) return null
if (manualPost && String(manualPost.id) === String(selection)) return manualPost
if (i.assignedPost && String(i.assignedPost.id) === String(selection)) return i.assignedPost
const hit = suggestionCards.find((c) => String(c.post.id) === String(selection))
return hit?.post || null
}, [selection, manualPost, i.assignedPost, suggestionCards])
const assignMutation = useMutation({
mutationFn: ({ recordId, jobPostId }) => inboxApi.assignJobPost(recordId, jobPostId),
onError: (err) => toast(friendlyAuthError(err, 'Could not assign job post.'), 'error'),
onSuccess: (_res, vars) => {
const title = selectedPost?.title || 'role'
if (vars.jobPostId) toast(`${i.name}${title}`, 'success')
else toast(`${i.name} unassigned`, 'success')
},
onSettled: (_res, _err, vars) => {
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
qc.invalidateQueries({ queryKey: qk.mailbox.message(vars.recordId) })
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
qc.invalidateQueries({ queryKey: qk.candidates.all() })
},
})
const rematchMutation = useMutation({
mutationFn: (recordId) => inboxApi.rematch(recordId),
onError: (err) => toast(friendlyAuthError(err, 'Could not retry match.'), 'error'),
onSuccess: () => toast('Match re-queued', 'success'),
onSettled: (_r, _e, recordId) => {
qc.invalidateQueries({ queryKey: qk.mailbox.message(recordId) })
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
},
})
const jobChosen = Boolean(i.assignedId || selection)
const alreadyProcessed = i.processing === 'Processed'
const shortlistLocked = !alreadyProcessed && !jobChosen
const matchFailed = ['failed', 'no_text', 'dlq'].includes(i.matchStatus)
const resumeText = i.resumeText || ''
const assigned = i.assignedPost
const panelBusy = busy || assignMutation.isPending || rematchMutation.isPending
const canAssign = canEdit && selection && selection !== i.assignedId && !assignMutation.isPending
const resumeKey = firstResumeKey(i)
const canOpenResume = s3Api.canOpen(resumeKey)
const profileHref = i.linkedinUrl || linkedinHrefFromSlug(i.linkedinSlug)
const openResume = useMutation({
mutationFn: async (tab) => {
try {
return await s3Api.openPdf(firstResumeKey(i), { tab })
} catch (err) {
if (tab && !tab.closed) tab.close()
throw err
}
},
onError: (err) => toast(friendlyAuthError(err, 'Could not open resume'), 'error'),
})
async function handleMove() {
if (busy || alreadyProcessed) return
if (!jobChosen) {
toast(SHORTLIST_JOB_WARNING, 'warning')
return
}
const jobId = selection || i.assignedId
if (jobId && String(jobId) !== String(i.assignedId || '')) {
try {
await assignMutation.mutateAsync({ recordId: i.id, jobPostId: jobId })
} catch {
return
}
}
onMove()
}
return (
<div style={{ padding: 24 }}>
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
<div className="ph-role">{i.position}</div>
<div className="ph-tags" style={{ marginTop: 8 }}>
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
{i.duplicate && <><Badge className="b-red">Duplicate</Badge>{' '}</>}
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<><Badge>{i.applicationStatus}</Badge>{' '}</>
)}
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
{i.resumeStatus}
</Badge>{' '}
{loading && <span className="cell-sub">Loading details</span>}
</div>
</div>
{i.atsScore != null && (
<div style={{ textAlign: 'center' }}>
<div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}>
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{i.atsScore}</div></div>
</div>
<div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
</div>
)}
</div>
{(resumeKey || i.hasAttachment || profileHref) && (
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
{(resumeKey || i.hasAttachment) && (
<button
className="btn btn-primary btn-sm"
disabled={openResume.isPending || !canOpenResume}
onClick={() => openResume.mutate(window.open('about:blank', '_blank'))}
>
<Icon name="paperclip" /> Open resume
</button>
)}
{profileHref && (
<a className="btn btn-secondary btn-sm" href={profileHref} target="_blank" rel="noopener noreferrer">
<Icon name="linkedin" /> LinkedIn
</a>
)}
</div>
)}
<div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
<div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{orDash(i.experience, ' years')}</div></div>
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{orDash(i.recruiter)}</div></div>
<div className="info-item">
<div className="il">Received</div>
<div className="iv">{i.received ? fmtDate(i.received) : '—'}</div>
</div>
{i.sentAt && (
<div className="info-item"><div className="il">Sent</div><div className="iv">{fmtDate(i.sentAt)}</div></div>
)}
{i.cc && <div className="info-item"><div className="il">CC</div><div className="iv">{i.cc}</div></div>}
{i.bcc && <div className="info-item"><div className="il">BCC</div><div className="iv">{i.bcc}</div></div>}
{i.atsScore != null && (
<div className="info-item">
<div className="il">Match</div>
<div className="iv"><Badge className={atsRecommendationClass(recLabel)}>{recLabel}</Badge></div>
</div>
)}
</div>
{assigned && (
<div
className="card"
style={{
boxShadow: 'none',
background: 'var(--primary-soft)',
border: '1px solid var(--primary-border)',
marginBottom: 18,
}}
>
<div className="card-body flex items-center gap-12" style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
<div className="flex items-center gap-8">
<Icon name="check-circle" />
<div>
<div>Assigned to <b>{assigned.title}</b></div>
<div className="cell-sub">
{[assigned.employment_type, assigned.location].filter(Boolean).join(' · ') || '—'}
</div>
</div>
</div>
<div className="flex gap-8">
<button
className="btn btn-secondary btn-sm"
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => setShowPicker(true)}
>
Change
</button>
<button
className="btn btn-ghost btn-sm"
disabled={!canEdit || assignMutation.isPending}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => assignMutation.mutate({ recordId: i.id, jobPostId: null })}
>
Unassign
</button>
</div>
</div>
</div>
)}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 18,
alignItems: 'start',
marginBottom: 20,
}}
>
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
{!loading && (
<div style={{ marginBottom: 16 }}>
<div className="email-head">Subject: {i.position || '(no subject)'}</div>
{looksLikeHtml(i.bodyHtml) ? (
<EmailBody html={i.bodyHtml} />
) : (
<pre className="resume-thumb is-full email-plain">
{i.body || 'This email has no message body.'}
</pre>
)}
</div>
)}
</div>
<div role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
{matchFailed ? (
<div className="alert alert-danger" style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8 }}>{i.matchError || 'Matching failed for this application.'}</div>
<button
className="btn btn-secondary btn-sm"
disabled={!canEdit || rematchMutation.isPending}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => rematchMutation.mutate(i.id)}
>
<Icon name="sparkles" /> Retry match
</button>
</div>
) : (
<div style={{ marginBottom: 16 }}>
<div className="fw-600" style={{ marginBottom: 6 }}>AI verdict</div>
<p style={{ marginBottom: 4 }}>{i.matchSummary || 'No match summary yet.'}</p>
{i.matchedAt && (
<div className="cell-sub">Matched {fmtDate(i.matchedAt)}</div>
)}
{i.matchReasoning && (
<button
className="btn btn-ghost btn-sm"
style={{ marginTop: 8, paddingLeft: 0 }}
onClick={() => setWhyOpen((v) => !v)}
>
{whyOpen ? '▾' : '▸'} Why these roles?
</button>
)}
{whyOpen && (
<p className="text-muted text-sm" style={{ marginTop: 8 }}>{i.matchReasoning}</p>
)}
</div>
)}
<div className="fw-600" style={{ marginBottom: 8 }}>Suggested roles</div>
{suggestionCards.length === 0 && !manualPost ? (
<EmptyState icon="alert" title="No suggested roles">
<p>No job post was suggested. Choose a role manually.</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap', marginTop: 10 }}>
<button
className="btn btn-secondary btn-sm"
disabled={!canEdit || rematchMutation.isPending}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => rematchMutation.mutate(i.id)}
>
Retry match
</button>
<button
className="btn btn-primary btn-sm"
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => setShowPicker(true)}
>
Choose a role
</button>
</div>
</EmptyState>
) : (
suggestionCards.map(({ rank, post }) => (
<JobCard
key={post.id}
post={post}
rank={rank}
selected={String(selection) === String(post.id)}
onSelect={(id) => setSelection(String(id))}
resumeText={resumeText}
/>
))
)}
{manualPost && (
<JobCard
post={manualPost}
rank={0}
manual
selected={String(selection) === String(manualPost.id)}
onSelect={(id) => setSelection(String(id))}
resumeText={resumeText}
/>
)}
<button
className="btn btn-secondary"
style={{ width: '100%', marginTop: 8 }}
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => setShowPicker(true)}
>
Choose a different role
</button>
<button
className="btn btn-primary"
style={{ width: '100%', marginTop: 8 }}
disabled={!canAssign}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => {
if (!selection || !canEdit) return
assignMutation.mutate({ recordId: i.id, jobPostId: selection })
}}
>
{selectedPost?.title ? `Assign`:'Assign'}
</button>
</div>
</div>
<div className="flex gap-8" style={{ flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={onImport} disabled={panelBusy || i.processing === 'Imported'}>
<Icon name="user-plus" /> {i.processing === 'Imported' ? 'Imported' : 'Import Candidate'}
</button>
<button
className="btn btn-secondary"
aria-disabled={shortlistLocked || panelBusy || alreadyProcessed}
disabled={panelBusy || alreadyProcessed}
style={shortlistLocked ? { opacity: 0.55, cursor: 'not-allowed' } : undefined}
title={shortlistLocked ? SHORTLIST_JOB_WARNING : undefined}
onClick={handleMove}
>
<Icon name="layers" /> Move to Shortlist
</button>
<button className="btn btn-secondary" onClick={onNote} disabled title="Notes attach to a candidate profile — open the candidate first">
<Icon name="edit" /> Add Note
</button>
<button className="btn btn-secondary" onClick={onToggleDuplicate} disabled={panelBusy}>
<Icon name="alert" /> {i.duplicate ? 'Clear Duplicate' : 'Mark Duplicate'}
</button>
<button
className="btn btn-ghost"
style={{ color: 'var(--danger)' }}
onClick={onReject}
disabled={panelBusy || i.processing === 'Rejected'}
>
<Icon name="trash" /> Reject
</button>
</div>
{showPicker && (
<PickRoleModal
onClose={() => setShowPicker(false)}
onPick={(post) => {
setManualPost(post)
setSelection(String(post.id))
}}
/>
)}
</div>
)
}
function AssignRecruiter({ item, recruiters, onClose, onSave }) {
const [name, setName] = useState(item.recruiter)
const current = recruiters.find((r) => r.name === item.recruiter)
return (
<Modal
title="Assign Recruiter"
subtitle={item.name}
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => onSave(name)}>Assign</button>
</>
}
>
<div className="form-field">
<label>Recruiter</label>
<select value={name} onChange={(e) => setName(e.target.value)}>
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}
</select>
</div>
<p className="text-muted text-sm" style={{ marginTop: 10 }}>
Current workload is factored automatically. This recruiter has {current?.openReqs ?? 5} open reqs.
</p>
</Modal>
)
}