/* ============================================================
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 11–60). 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: 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 (
{label}
)
}
/**
* 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 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
a
b
would collapse to
// "ab". Turn breaks and closing block tags into newlines BEFORE parsing.
const withBreaks = raw
.replace(/ /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= -> 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 || '',
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 (
{ e.stopPropagation(); onToggle() }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
onToggle()
}
}}
>
)
}
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 (
0
? `${n} selected`
: `${total} ${total === 1 ? 'message' : 'messages'}${unreadCount ? ` · ${unreadCount} unread` : ''}`}
>
{n > 0
? `${n} selected`
: `${total}${unreadCount ? ` · ${unreadCount}` : ''}`}
{/* Same switch as the Per-page "All" entry below, surfaced at the top of
the queue where the eye actually is. */}
{onToggleShowAll && n === 0 && (
)}
{n > 0 ? (
<>
>
) : (
<>
>
)}
)
}
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)
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,
...(q.trim() ? { search: q.trim() } : {}),
}), [tabFilter, skip, pageSize, q, 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,
...(q.trim() ? { search: q.trim() } : {}),
}), [formSheet, skip, pageSize, q, formTabFilter, isAllChannel])
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 || isAllChannel,
})
const countsQuery = useQuery({
queryKey: qk.mailbox.counts(),
queryFn: fetchInboxCounts,
enabled: !isForms,
})
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,
})
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
? {
isPending: applicationsQuery.isPending || formQuery.isPending,
// one healthy source still renders; error only when both are down
isError: applicationsQuery.isError && formQuery.isError,
isSuccess: applicationsQuery.isSuccess && formQuery.isSuccess,
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 = q.trim()
? (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 = 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 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, ...(q.trim() ? { search: q.trim() } : {}) },
// 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)
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 })
}
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 (
{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.'}
{/* Assign only renders once there is a selection to act on —
a permanently disabled primary button read as broken UI. */}
{selection && selection !== i.assignedId && (
)}