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

2422 lines
97 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 { keepPreviousData, 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 { exportStyledXlsx } from '../lib/exportXlsx'
import { formatRole } from '../lib/format'
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
/**
* Cache policy for the queue.
*
* This list is a local table, not a live feed. Applications only appear when
* the Sync worker writes them, and that path already invalidates
* qk.mailbox.all() the moment a run completes — so refetching on every visit
* bought nothing and cost a full-width skeleton each time. The All channel
* makes that worse: it fetches BOTH sources unpaged, so the "loading" state a
* recruiter saw on re-entry was thousands of rows being re-downloaded to
* render the same ten.
*
* staleTime therefore covers a normal working stretch, and keepPreviousData
* means a tab switch, a page turn or a keystroke re-renders the rows already
* on screen instead of blanking them. Nothing here can hide new mail: Sync
* invalidates, and every write mutation on this screen already does too.
*/
const INBOX_STALE_MS = 10 * 60_000
const INBOX_GC_MS = 60 * 60_000
const LIST_CACHE = {
staleTime: INBOX_STALE_MS,
gcTime: INBOX_GC_MS,
placeholderData: keepPreviousData,
}
const COUNT_CACHE = { staleTime: INBOX_STALE_MS, gcTime: INBOX_GC_MS }
/** Typing must not put a query key (and a skeleton) on screen per keystroke. */
const SEARCH_DEBOUNCE_MS = 300
/** "Updated 4 min ago" under the search box — the honest label for a cached list. */
function agoLabel(ts) {
if (!ts) return null
const secs = Math.max(0, Math.round((Date.now() - ts) / 1000))
if (secs < 45) return 'just now'
const mins = Math.round(secs / 60)
if (mins < 60) return `${mins} min ago`
const hours = Math.round(mins / 60)
if (hours < 24) return `${hours} hr ago`
return `${Math.round(hours / 24)} d ago`
}
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: both sources combined (default), Outlook email, or Google Form rows. */
const CHANNELS = [
{ key: 'all', label: 'All', icon: 'inbox' },
{ 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 RESUME_STATUS_CLASS = { Parsed: 'b-green', Failed: 'b-red' }
/**
* What each parse state means, in words a recruiter can act on. "Failed" alone
* says nothing about what to do next — the attachment is still there to open by
* hand, and that is the point worth making.
*/
const RESUME_STATUS_TIP = {
Parsed: 'Text was read from this CV.',
Parsing: 'Still reading the text from this CV.',
Failed: 'No text could be read from this CV, so it was never matched to a job. Open the attachment to read it by hand.',
Pending: 'This CV has not been matched to a job yet.',
}
/**
* Parse state -> badge colour. Anything not settled one way or the other —
* Parsing, Pending, or a label the backend adds later — lands on amber.
*
* Shared by the queue row and the detail pane so the same state can never paint
* two different colours on the two halves of this screen.
*/
function resumeStatusClass(status) {
return RESUME_STATUS_CLASS[status] ?? 'b-amber'
}
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 {
kind: 'email',
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 {
kind: 'email',
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 || '',
// resume_text is not on the list payload (serialize_application
// light=True). The detail query fetches it for the open row.
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, showAll, onToggleShowAll, tabTotal }) {
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} unread` : ''}`}
</span>
{/* Same switch as the Per-page "All" entry below, surfaced at the top of
the queue where the eye actually is. */}
{onToggleShowAll && n === 0 && (
<button
className={`btn btn-sm ${showAll ? 'btn-primary' : 'btn-secondary'}`}
title={showAll ? 'Back to paged view' : `Show all ${tabTotal || ''} in one list`}
onClick={onToggleShowAll}
>
{showAll ? 'Show paged' : `Show all${tabTotal ? ` (${tabTotal})` : ''}`}
</button>
)}
<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>
)
}
/**
* "Updated 4 min ago" + Refresh, under the search box.
*
* A cached list that never says how old it is reads as a broken list. This is
* the honest label, and the button next to it re-reads the DB — deliberately
* NOT the same act as Sync, which pulls new mail from Outlook. The tooltips
* say which is which because the two were previously indistinguishable.
*
* Owns its own interval so the minute count stays current without re-rendering
* the queue: under "Show all" that parent render is a thousand rows.
*/
function QueueFreshness({ at, refreshing, onRefresh }) {
const [, tick] = useState(0)
useEffect(() => {
if (!at) return undefined
const t = setInterval(() => tick((n) => n + 1), 30_000)
return () => clearInterval(t)
}, [at])
return (
<div className="inbox-freshness">
<span className="cell-sub">
{refreshing
? 'Updating…'
: at
? `Updated ${agoLabel(at)}`
: 'Loading…'}
</span>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={onRefresh}
disabled={refreshing}
title="Re-read the applications already saved here. Use Sync to pull new mail from Outlook."
>
<Icon name="refresh" /> Refresh
</button>
</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('all')
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)
// The box updates on every keystroke; the QUERY KEY only settles when typing
// pauses. Untyped, each character produced a fresh key, an in-flight request
// and a skeleton — the list strobed while you searched.
const [search, setSearch] = useState('')
useEffect(() => {
const t = setTimeout(() => setSearch(q.trim()), SEARCH_DEBOUNCE_MS)
return () => clearTimeout(t)
}, [q])
const isForms = channel === 'forms'
// Combined channel: both sources fetched UNPAGED (each endpoint reads a
// missing top/limit as no LIMIT), merged by date, and paged client-side —
// per-source skip/top cannot compose into a correct global page.
const isAllChannel = channel === 'all'
const channelTabs = isForms || isAllChannel ? FORM_TABS : TABS
const tabFilter = TAB_FILTERS[tab] ?? {}
const formTabFilter = FORM_TAB_FILTERS[tab] ?? {}
const listParams = useMemo(() => ({
...tabFilter,
// 'all' page size and the All channel drop the param entirely — the
// endpoint reads a missing top as unpaged.
top: pageSize === 'all' || isAllChannel ? undefined : pageSize,
skip: isAllChannel ? 0 : skip,
...(search ? { search } : {}),
}), [tabFilter, skip, pageSize, search, isAllChannel])
const formParams = useMemo(() => ({
// All channel spans every sheet tab, not just the selected one.
sheet: isAllChannel ? undefined : (formSheet || undefined),
offset: isAllChannel ? 0 : skip,
limit: pageSize === 'all' || isAllChannel ? undefined : pageSize,
...formTabFilter,
...(search ? { search } : {}),
}), [formSheet, skip, pageSize, search, formTabFilter, isAllChannel])
const applicationsQuery = useQuery({
queryKey: qk.mailbox.applications(listParams),
queryFn: () => fetchApplications(listParams),
enabled: !isForms,
...LIST_CACHE,
})
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 || isAllChannel,
...LIST_CACHE,
})
const countsQuery = useQuery({
queryKey: qk.mailbox.counts(),
queryFn: fetchInboxCounts,
enabled: !isForms,
...COUNT_CACHE,
})
const formCountsSheet = isAllChannel ? undefined : (formSheet || undefined)
const formCountsQuery = useQuery({
queryKey: qk.mailbox.formCounts({ sheet: formCountsSheet }),
queryFn: async () => {
const res = await sheetApi.fetchFormCounts({ sheet: formCountsSheet })
return res?.data ?? {}
},
enabled: isForms || isAllChannel,
...COUNT_CACHE,
})
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: formCountsSheet }),
queryFn: async () => {
const res = await sheetApi.countFormData({ sheet: formCountsSheet })
return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0)
},
enabled: isForms || isAllChannel,
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])
// All channel: both sources arrive unpaged; merge newest-first and let the
// pager slice the merged array below.
const mergedRows = useMemo(() => {
if (!isAllChannel) return null
const emails = Array.isArray(applicationsQuery.data?.rows) ? applicationsQuery.data.rows : []
const forms = Array.isArray(formQuery.data?.rows) ? formQuery.data.rows : []
return [...emails, ...forms].sort(
(a, b) => (b.received?.getTime() ?? 0) - (a.received?.getTime() ?? 0),
)
}, [isAllChannel, applicationsQuery.data, formQuery.data])
const activeQuery = isAllChannel
? {
// Pending only while NOTHING has arrived. The old OR kept the
// combined query "pending" until the slower source finished, which
// painted six skeletons on top of the email rows that were already
// on screen — the list looked broken on every visit.
isPending:
(applicationsQuery.isPending || formQuery.isPending)
&& !(mergedRows && mergedRows.length),
// one healthy source still renders; error only when both are down
isError: applicationsQuery.isError && formQuery.isError,
isSuccess: applicationsQuery.isSuccess && formQuery.isSuccess,
// Either source still on the wire keeps the quiet refresh bar up.
isFetching: applicationsQuery.isFetching || formQuery.isFetching,
// The OLDER of the two: the merged list is only as fresh as its
// staler half, and claiming otherwise would be a lie on the label.
dataUpdatedAt: Math.min(
applicationsQuery.dataUpdatedAt || Infinity,
formQuery.dataUpdatedAt || Infinity,
),
error: applicationsQuery.error ?? formQuery.error,
data: { rows: mergedRows ?? [], total: mergedRows?.length ?? 0 },
}
: (isForms ? formQuery : applicationsQuery)
const inbox = Array.isArray(activeQuery.data?.rows) ? activeQuery.data.rows : []
const counts = useMemo(
() => {
const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
const e = countsQuery.data ?? {}
const f = formCountsQuery.data ?? {}
const pick = (key) => (isAllChannel
? n(e[key]) + n(f[key])
: n((isForms ? f : e)[key]))
return {
'All Applications': pick('all'),
Unread: pick('unread'),
Processed: pick('processed'),
Rejected: pick('rejected'),
Duplicates: pick('duplicates'),
}
},
[countsQuery.data, formCountsQuery.data, isForms, isAllChannel],
)
const poolTotal = isAllChannel
? (emailTotalQuery.data ?? 0) + (formTotalQuery.data ?? 0)
: (isForms ? (formTotalQuery.data ?? 0) : (emailTotalQuery.data ?? 0))
const tabTotal = counts[tab] ?? 0
const countsReady = isAllChannel
? countsQuery.isSuccess && formCountsQuery.isSuccess
: (isForms ? formCountsQuery.isSuccess : countsQuery.isSuccess)
const total = search
? (activeQuery.data?.total ?? 0)
: (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0)))
// 'all' = unpaged: the fetch omits top/limit (the endpoints treat a missing
// page size as no LIMIT), so the whole tab is one page.
const showAll = pageSize === 'all'
const pages = showAll ? 1 : Math.max(1, Math.ceil(total / pageSize))
const from = total ? skip + 1 : 0
const to = showAll ? total : Math.min(skip + pageSize, total)
const currentPage = showAll ? 1 : Math.min(Math.floor(skip / pageSize) + 1, pages)
useEffect(() => {
if (showAll) { if (skip !== 0) setSkip(0); return }
if (total <= 0 || skip < total) return
setSkip(Math.max(0, Math.floor((total - 1) / pageSize) * pageSize))
}, [total, pageSize, skip, showAll])
// All channel pages the merged array client-side; single channels page on the server.
const list = isAllChannel && !showAll ? inbox.slice(skip, skip + pageSize) : inbox
// Mixed rows: the row's own kind picks the detail endpoint, not the channel.
const selectedKind = inbox.find((i) => i.id === selectedId)?.kind
?? (isForms ? 'form' : 'email')
const detailQuery = useQuery({
queryKey: selectedKind === 'form' ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId),
queryFn: () => (selectedKind === 'form' ? 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 = search
? `the ${list.length} row${list.length === 1 ? '' : 's'} matching this search`
: tab === 'All Applications'
? 'every application'
: `the ${tab} tab`
/**
* Loading state, in priority order: rows already on screen ALWAYS win.
*
* The old rule was `activeQuery.isPending && <SkeletonRows/>`, which on the
* All channel put six placeholders ABOVE the email rows that had already
* arrived while the sheet fetch finished — a list that looked broken while
* it worked. Placeholders now mean "there is genuinely nothing to show yet";
* a refresh over existing rows is a hairline bar and one word of text.
*/
const hasRows = list.length > 0
const showSkeleton = !hasRows && (activeQuery.isPending || activeQuery.isFetching)
const refreshing = hasRows && Boolean(activeQuery.isFetching)
const updatedAt = Number.isFinite(activeQuery.dataUpdatedAt) && activeQuery.dataUpdatedAt > 0
? activeQuery.dataUpdatedAt
: null
/** Re-read what is already stored here. Sync is the one that fetches mail. */
const refreshQueue = useCallback(() => {
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
}, [qc])
function switchChannel(next) {
if (next === channel) return
setChannel(next)
setSkip(0)
setSelectedId(null)
setQ('')
setSearch('') // clear the committed term too, or the new channel's first
// fetch carries the old channel's search for 300ms
// Unread is email-only; leave it behind when opening Sheet Forms or All.
if (next !== 'email' && 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, ...(search ? { search } : {}) },
// sheet rows carry no mailbox read state — email rows only
ids: list.filter((i) => i.kind !== 'form').map((i) => i.id),
})
return
}
const ids = list.filter((i) => i.kind !== 'form').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)
// Phones swap the list for the detail pane — bring its top into view.
if (window.matchMedia?.('(max-width: 900px)').matches) {
window.scrollTo(0, 0)
document.querySelector('.content')?.scrollTo?.(0, 0)
}
const item = inbox.find((i) => i.id === id)
if (item?.kind === 'form') return // sheet rows have no mailbox read state
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 })
}
/** Styled XLSX of the rows already loaded in this view — the DB filtered
them when the list was fetched (channel, tab, search); no extra request.
Paged channels export the loaded page; the All channel and the "All"
page size hold the whole view, so those export everything. */
async function exportRows() {
const rows = inbox
if (!rows.length) {
toast('Nothing to export in this view', 'info')
return
}
try {
const channelLabel = CHANNELS.find((c) => c.key === channel)?.label ?? channel
await exportStyledXlsx({
filename: `inbox-${channel}-${tab.toLowerCase().replaceAll(' ', '-')}-${new Date().toISOString().slice(0, 10)}`,
title: 'Recruitment Inbox',
subtitle: `${tab} · ${channelLabel} channel · ${rows.length} row${rows.length === 1 ? '' : 's'} · exported ${fmtDate(new Date())}`,
columns: [
{ header: 'Name', key: 'name', width: 26 },
{ header: 'Email', key: 'email', width: 28 },
{ header: 'Phone', key: 'phone', width: 15 },
{ header: 'Position', key: 'position', width: 34 },
{ header: 'Channel', key: 'channel', width: 12 },
{ header: 'Source', key: 'source', width: 20 },
{ header: 'Received', key: 'received', width: 12 },
{ header: 'Status', key: 'status', width: 12 },
{ header: 'City', key: 'city', width: 14 },
{ header: 'Notice period', key: 'notice', width: 13 },
{ header: 'ATS score', key: 'ats', width: 10 },
{ header: 'Assigned job', key: 'job', width: 24 },
],
rows: rows.map((r) => ({
name: r.name, email: r.email, phone: r.phone, position: r.position,
channel: r.kind === 'form' ? 'Sheet Form' : 'Email',
source: r.source,
received: r.received ? r.received.toISOString().slice(0, 10) : '',
status: r.processing, city: r.residingCity, notice: r.noticePeriod,
ats: r.atsScore, job: r.assignedPost?.title,
})),
})
toast(`Exported ${rows.length} application${rows.length === 1 ? '' : 's'}`, 'success')
} catch {
toast('Export failed', 'error')
}
}
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={
isAllChannel
? 'Email and Sheet Forms together — one combined stream'
: 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 && (
<SyncButton
run={syncRun.data}
pending={sync.isPending}
disabled={!canEdit}
onClick={() => sync.mutate()}
/>
)}
<button className="btn btn-secondary" onClick={exportRows}>
<Icon name="download" /> Export
</button>
<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${selected ? ' has-selection' : ''}`}>
<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}
showAll={showAll}
tabTotal={total}
onToggleShowAll={() => {
setPageSize(showAll ? DEFAULT_PAGE_SIZE : 'all')
setSkip(0)
setSelectedId(null)
selection.clear()
}}
/>
)}
<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>
<QueueFreshness at={updatedAt} refreshing={refreshing} onRefresh={refreshQueue} />
</div>
<div className="inbox-list-body">
{/* Hairline, not a skeleton: the rows below stay readable and
clickable while the refetch lands. */}
{refreshing && <div className="inbox-refresh-bar" role="status" aria-label="Updating applications" />}
{showSkeleton && (
<SkeletonRows rows={6} />
)}
{/* The big error state only when there is truly nothing to
show — cached rows beat a scary banner over a working list. */}
{activeQuery.isError && list.length === 0 && (
<EmptyState icon="inbox" title={isForms ? "Couldn't load form applicants" : "Couldn't load applications"}>
{friendlyAuthError(activeQuery.error, 'Request failed')}
</EmptyState>
)}
{/* All channel, one source home and one still travelling: say
which. The merge re-sorts by date when the second lands, so
the list is about to reshuffle and that should not be a
surprise. */}
{isAllChannel && hasRows
&& (Boolean(applicationsQuery.isFetching) !== Boolean(formQuery.isFetching)) && (
<div className="inbox-partial-note">
{applicationsQuery.isFetching
? 'Still loading email applications…'
: 'Still loading Sheet Form applications…'}
</div>
)}
{/* All channel with one source down: keep the healthy list,
note the gap in one line instead of a full error state. */}
{isAllChannel && applicationsQuery.isError !== formQuery.isError
&& (applicationsQuery.isError || formQuery.isError) && (
<div
className="cell-sub"
style={{
padding: '9px 16px',
borderBottom: '1px solid var(--border)',
background: 'var(--warning-soft)',
color: 'var(--warning)',
}}
>
{applicationsQuery.isError
? 'Email applications couldnt load right now — showing Sheet Forms only.'
: 'Sheet Form applications couldnt load right now — showing Email only.'}
</div>
)}
{/* `!showSkeleton` matters now that keepPreviousData holds the
query in `success` while a new key loads: without it a cold
tab flashed "Nothing here" over a request still in flight. */}
{activeQuery.isSuccess && list.length === 0 && !showSkeleton ? (
<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)}
>
{i.kind !== 'form' && (
<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>
)}
{i.kind === 'form' && i.noticePeriod && (
<div className="cell-sub" style={{ marginTop: 6 }}>{i.noticePeriod}</div>
)}
</div>
<div className="ii-meta">
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
{/* Exceptions only: a healthy row shows nothing, so the
badge stays a signal rather than decoration.
The kind check is redundant TODAY — mapFormRow sets no
resumeStatus, so the truthiness test already excludes
sheet rows. It is kept because those applicants have no
mailbox attachment to parse at all, and a future field
named resumeStatus on a form row must not be read as a
parse verdict. */}
{i.kind !== 'form' && i.resumeStatus && i.resumeStatus !== 'Parsed' && (
<Badge
className={resumeStatusClass(i.resumeStatus)}
title={RESUME_STATUS_TIP[i.resumeStatus]}
>
{i.resumeStatus}
</Badge>
)}
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<Badge>{formatRole(i.applicationStatus)}</Badge>
)}
{i.kind === 'form' && 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) => { if (showAll) return; setSkip((p - 1) * pageSize); setSelectedId(null); selection.clear() }}
pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
pageSizeMax={PAGE_SIZE_MAX}
allowAll
onPageSizeChange={(n) => {
setPageSize(n)
if (n === 'all') setSkip(0) // numeric sizes keep their page (clamp effect above)
setSelectedId(null)
selection.clear()
}}
/>
)}
</div>
<div className="split-detail">
{selected && (
<button
type="button"
className="btn btn-ghost btn-sm inbox-back"
onClick={() => setSelectedId(null)}
>
<Icon name="chevron-left" /> Back to list
</button>
)}
{!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>
) : selected?.kind === 'form' ? (
<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>
</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, display: 'flex', flexDirection: 'column', gap: 18 }}>
<div className="card" style={{ boxShadow: 'none' }}>
<div className="card-head"><h3>Contact &amp; application</h3></div>
<div className="card-body">
<div className="info-grid">
<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>
</div>
<div className="card" style={{ boxShadow: 'none' }}>
<div className="card-head"><h3>Profile</h3></div>
<div className="card-body">
<div className="info-grid">
<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>
</div>
</div>
{i.hrComments && (
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
<div className="card-body">
<div className="fw-600" style={{ marginBottom: 6 }}>HR comment</div>
<p style={{ margin: 0 }}>{i.hrComments}</p>
</div>
</div>
)}
</div>
<div
role="radiogroup"
aria-label="Matching roles"
className="card"
style={{ flex: '1 1 300px', minWidth: 260, boxShadow: 'none', alignSelf: 'start' }}
>
<div className="card-head">
<div>
<h3>Matching roles</h3>
<span className="ch-sub">Matched by position: {orDash(i.position)}</span>
</div>
</div>
<div className="card-body">
{matchCards.length === 0 && !manualPost ? (
<EmptyState icon="target" title="No matching roles">
<p>No job post title matches this position pick one manually.</p>
</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))}
/>
)}
</>
)}
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginTop: 12 }}>
<button
className={`btn ${matchCards.length === 0 && !manualPost ? 'btn-primary' : 'btn-secondary'}`}
style={{ flex: '1 1 auto' }}
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => setShowPicker(true)}
>
<Icon name="search" /> Browse roles
</button>
{/* Assign only renders once there is a selection to act on —
a permanently disabled primary button read as broken UI. */}
{selection && selection !== i.assignedId && (
<button
className="btn btn-primary"
style={{ flex: '1 1 auto' }}
disabled={!canAssign}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => {
if (!selection || !canEdit) return
assignMutation.mutate({ recordId: i.id, jobPostId: selection })
}}
>
<Icon name="check" /> Assign{selectedPost?.title ? `${selectedPost.title}` : ''}
</button>
)}
</div>
</div>
</div>
</div>
{i.sheet && (
<div className="cell-sub" style={{ marginBottom: 14 }}>
Imported from {i.sheet}{i.rowNumber != null ? ` · row ${i.rowNumber}` : ''}
</div>
)}
<div className="flex gap-8" style={{ flexWrap: 'wrap', borderTop: '1px solid var(--border)', paddingTop: 16 }}>
<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>{formatRole(i.applicationStatus)}</Badge>{' '}</>
)}
<Badge
className={resumeStatusClass(i.resumeStatus)}
title={RESUME_STATUS_TIP[i.resumeStatus]}
>
{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', borderTop: '1px solid var(--border)', paddingTop: 16 }}>
<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>
)
}