/* ============================================================ 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: Outlook email queue vs imported Google Form rows. */ const CHANNELS = [ { key: 'email', label: 'Email', icon: 'mail' }, { key: 'forms', label: 'Sheet Forms', icon: 'layers' }, ] const DEFAULT_FORM_SHEET = 'Form Responses - Candidate Database Sheet 2026' const SHEET_SOURCE_META = { icon: 'layers', color: 'var(--c4)', channel: 'Sheet' } /** * Server-side filters for each tab. Email Processed / Rejected follow * processing_state (the same writes as Import / Shortlist / Reject). Sheet * Forms use form_data.processing_state. */ const TAB_FILTERS = { Unread: { isread: false }, Processed: { processingState: 'processed' }, Rejected: { processingState: 'rejected' }, Duplicates: { isDuplicate: true }, } const FORM_TAB_FILTERS = { Processed: { processing_state: 'processed' }, Rejected: { processing_state: 'rejected' }, Duplicates: { is_duplicate: true }, } const FORM_PROCESSING_LABEL = { unread: 'New', imported: 'Imported', processed: 'Processed', rejected: 'Rejected', } /** Every tab above is a true server-side scope — safe for "mark all". */ const SERVER_SCOPED_TABS = new Set(TABS) /** * message_received_time / message_sent_time are plain string columns * (backend/inbox/models.py:54-56), not timestamps. An unparseable value yields * an Invalid Date that every fmt* helper renders as the literal "Invalid Date", * so return null instead and let the call sites decide what to show. */ function parseDate(value) { if (!value) return null const d = new Date(value) return Number.isNaN(d.getTime()) ? null : d } function startOfDay(d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()) } /** * Outlook-style list timestamps in the client's local timezone. * Within 7 days: weekday + AM/PM time. Older: dd/mm/yyyy only — like Outlook. * The date-plus-time form was ~105px wide, and in the 380px queue rail that * squeezed .ii-main to 148px: sender names painted over the timestamp and the * meta chips wrapped one-per-line. The full timestamp is in the detail pane. */ function outlookListTime(value) { if (!value) return '—' const d = value instanceof Date ? value : new Date(value) if (Number.isNaN(d.getTime())) return '—' const daysAgo = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000) if (daysAgo < 7) { const weekday = d.toLocaleDateString(undefined, { weekday: 'short' }) const time = d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true, }) return `${weekday} ${time}` } const dd = String(d.getDate()).padStart(2, '0') const mm = String(d.getMonth() + 1).padStart(2, '0') return `${dd}/${mm}/${d.getFullYear()}` } /** * `source` arrives as the raw To address, because that is where the board tag * lands — careers-rozee@, employee-referral@, mustakbil@ and so on. Strip * everything but letters from both sides so "Employee Referral" still matches * "employee-referral@", and keep the brand colour SourceChip paints from. * Nothing matches -> show the first recipient verbatim rather than guess. */ function sourceFrom(messageTo) { const raw = typeof messageTo === 'string' ? messageTo.trim() : String(messageTo ?? '').trim() if (!raw || raw === '[object Object]') return { source: 'Unknown', sourceMeta: null } const flat = raw.toLowerCase().replace(/[^a-z]/g, '') const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, ''))) if (hit) return { source: hit, sourceMeta: sourceMeta[hit] } return { source: raw.split(',')[0].trim(), sourceMeta: null } } /** * Form `source_of_application` is a free-text label (LinkedIn, Indeed, …), not a * To-address. Reuse the email source palette when the spelling matches; otherwise * tag the row as a Sheet Forms entry so the chip still paints. */ function formSourceFrom(raw) { const label = (raw || '').trim() if (!label) return { source: 'Google Forms', sourceMeta: SHEET_SOURCE_META } const flat = label.toLowerCase().replace(/[^a-z]/g, '') const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, ''))) if (hit) return { source: hit, sourceMeta: sourceMeta[hit] } return { source: label, sourceMeta: SHEET_SOURCE_META } } /** entry_date is midnight UTC; entry_time is a separate "HH:MM" string from the sheet. */ function formReceivedAt(entryDate, entryTime) { const d = parseDate(entryDate) if (!d) return null const m = String(entryTime || '').match(/(\d{1,2}):(\d{2})/) if (m) d.setHours(Number(m[1]), Number(m[2]), 0, 0) return d } /** * GET /sheet/form-data/fetch row → the same list/detail shape the email channel * uses for name / avatar / position / source / time, plus form-only profile fields. * job_posts are title-matched (position_applied_for ↔ job_posts.title), not AI. */ function mapFormRow(row) { const name = (row.name || row.candidate_email || 'Unknown').trim() const jobPosts = Array.isArray(row.job_posts) ? row.job_posts : [] const state = row.processing_state || 'unread' return { kind: 'form', id: String(row.id), name, initials: initialsOf(name), color: avatarColor(name), email: row.candidate_email || '', phone: row.candidate_number || '', position: row.position_applied_for || '—', ...formSourceFrom(row.source_of_application), received: formReceivedAt(row.entry_date, row.entry_time), screenedBy: row.screened_by || '', hrComments: row.hr_comments || '', gender: row.gender || '', dateOfBirth: parseDate(row.date_of_birth), cnic: row.cnic || '', degree: row.degree || '', university: row.university || '', universityOther: row.university_other || '', graduationYear: row.entry_year || '', residingCity: row.residing_city || '', residingCountry: row.residing_country || '', maritalStatus: row.marital_status || '', hoAvailability: row.ho_availability || '', noticePeriod: row.notice_period || '', currentSalary: row.current_salary || '', expectedSalary: row.expected_salary || '', profileLink: row.profile_link || '', resumeLink: row.resume_link || '', sheet: row.sheet || '', rowNumber: row.row_number ?? null, unread: state === 'unread', processing: FORM_PROCESSING_LABEL[state] || (row.screened_by ? 'Screened' : 'New'), processingState: state, duplicate: Boolean(row.is_duplicate), jobPosts, assignedId: row.job_post_id ? String(row.job_post_id) : null, assignedPost: row.assigned_job_post || null, } } async function fetchFormApplications(params) { const res = await sheetApi.listFormData(params) const rows = Array.isArray(res?.data) ? res.data : [] return { rows: rows.map(mapFormRow), total: Number(res?.total ?? rows.length) || 0, } } async function fetchFormDetail(recordId) { const res = await sheetApi.getFormData(recordId) const row = res?.data return row ? mapFormRow(row) : null } function SourceChip({ item }) { // The dot carries the partner's brand colour; the label uses theme text — // 11px labels in the partner colour failed AA in both themes. // When no board matched, `source` is the raw To-address; keep it out of the // chip — show "Email", and leave the address to the tooltip and the opened // message. const source = String(item.source ?? '') const label = source.includes('@') ? 'Email' : source return ( {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 { id: String(row.id), name, initials: initialsOf(name), color: avatarColor(name), email: row.fromEmail || '', position: row.subject || '(no subject)', ...sourceFrom(row.message_to), received: parseDate(row.when) ?? parseDate(row.message_sent_time), unread: Boolean(row.unread), processing: row.unread ? 'Unread' : 'Read', resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending', attachment: row.attachment_name, hasAttachment: Boolean(row.attachment), body: htmlToText(row.body), // Raw markup for the HTML viewer; `body` stays the plain-text fallback. bodyHtml: row.body || '', cc: row.message_cc || '', bcc: row.message_bcc || '', sentAt: parseDate(row.message_sent_time), files: Array.isArray(row.files) ? row.files : [], filePath: row.file_path || '', linkedinSlug: row.linkedin_slug || '', linkedinUrl: row.linkedin_url || '', matchStatus: row.match_status || null, matchSummary: row.match_summary || '', matchReasoning: row.match_reasoning || '', matchError: row.match_error || '', matchedAt: parseDate(row.matched_at), resumeText: row.resume_text || '', suggestedIds: Array.isArray(row.suggested_job_post_ids) ? row.suggested_job_post_ids.map(String) : [], suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [], assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, assignedPost: row.assigned_job_post || null, } } /** * GET /inbox/all-applications -> the shape the application tabs render. * * `processing` prefers processing_state (imported/processed/rejected); otherwise * Read/Unread from message_read. Duplicate comes from is_duplicate. * Returns `{ rows, total }` so the pager can show page 1 / 2 / 3… without * loading the whole mailbox. */ async function fetchApplications(params) { const res = await inboxApi.listApplications(params) const rows = Array.isArray(res?.data) ? res.data : [] return { rows: rows.map((row) => { const name = row.name || row.email || 'Unknown' return { id: String(row.id), name, initials: initialsOf(name), color: avatarColor(name), email: row.email || '', position: row.position || '(no subject)', ...sourceFrom(row.source), received: parseDate(row.received), unread: Boolean(row.unread), processing: row.processing || 'Unread', processingState: row.processing_state || null, applicationStatus: row.application_status || null, resumeStatus: row.resume_status || 'Pending', attachment: row.attachment, hasAttachment: Boolean(row.has_attachment), filePath: row.file_path || '', linkedinSlug: row.linkedin_slug || '', linkedinUrl: row.linkedin_url || '', resumeText: row.resume_text || '', atsScore: row.ats_score, phone: row.phone, experience: row.experience, recruiter: row.recruiter, duplicate: Boolean(row.duplicate), suggestedIds: Array.isArray(row.suggested_job_post_ids) ? row.suggested_job_post_ids.map(String) : [], assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, } }), total: Number(res?.total ?? rows.length) || 0, } } // Shared with the sidebar badge through qk.mailbox.counts — see inboxApi.fetchCounts. const fetchInboxCounts = inboxApi.fetchCounts /** 500 is MAX_BULK_READ_IDS in backend/inbox/views.py; a longer list gets a 413. */ const BULK_READ_CHUNK = 500 async function setReadChunked(ids, read) { let updated = 0 for (let i = 0; i < ids.length; i += BULK_READ_CHUNK) { // Sequential on purpose. Each chunk is one UPDATE over a few hundred rows of // the same table; firing them together only puts them in each other's way. // eslint-disable-next-line no-await-in-loop const res = await inboxApi.bulkSetRead(ids.slice(i, i + BULK_READ_CHUNK), read) updated += res?.data?.updated ?? 0 } return { updated, requested: ids.length } } /** * Rewrites ONE cache entry for a read-state flip: a row list, a paginated * `{rows, total}` page, or the single-message detail object. Anything else * (the counts object) passes through untouched — it has no `id`, and its * delta is applied separately. */ function patchReadState(data, idSet, read) { const patchRow = (r) => (idSet.has(r.id) ? { ...r, unread: !read, // `processing` also carries Imported / Processed / Rejected, which are a // different column. Only the read-derived pair moves with this one. processing: r.processing === 'Unread' || r.processing === 'Read' ? (read ? 'Read' : 'Unread') : r.processing, } : r) if (Array.isArray(data)) return data.map(patchRow) if (data && Array.isArray(data.rows)) return { ...data, rows: data.rows.map(patchRow) } if (data && typeof data === 'object' && data.id && idSet.has(data.id)) return patchRow(data) return data } /** * PATCH /inbox/read — flips message_read for one row or for two hundred. * * The single-row route (POST /inbox/{id}/read) still exists, but every flip goes * through the bulk one so there is exactly ONE optimistic patch to reason about. * * Optimistic, so rows un-bold on click instead of after the round trip, and roll * back if the server rejects. Both mailbox caches hold {id, unread} rows, so one * setQueriesData over qk.mailbox.all() covers the Email tab and the application * tabs at once; `processing` is derived from the same column, so it moves with it. * * NOTE: the route requires INBOX_EDIT while the lists only require INBOX_VIEW, so * a view-only user gets a 403 here and the rows snap back. The bulk bars disable * themselves for those users; click-to-read cannot, so it still relies on rollback. */ async function optimisticRead(qc, ids, read) { await qc.cancelQueries({ queryKey: qk.mailbox.all() }) const previous = qc.getQueriesData({ queryKey: qk.mailbox.all() }) const idSet = new Set(ids) // Which rows actually FLIP, counted BEFORE the patch: the counts object is a // bag of totals with no per-row detail to derive the delta from afterwards. // A Set, because the same id appears in several cached lists at once. const flipping = new Set() for (const [, data] of previous) { const rows = Array.isArray(data) ? data : data?.rows if (!Array.isArray(rows)) continue for (const r of rows) if (idSet.has(r.id) && Boolean(r.unread) === read) flipping.add(r.id) } qc.setQueriesData({ queryKey: qk.mailbox.all() }, (data) => patchReadState(data, idSet, read)) // The Unread tab badge and the sidebar badge share this entry. Without the // delta they sit stale until the refetch lands — invisible for one row, // glaring for two hundred. `previous` already covers it for rollback, // because ['mailbox'] is a prefix of ['mailbox','counts']. qc.setQueryData(qk.mailbox.counts(), (c) => (c ? { ...c, unread: Math.max(0, (c.unread ?? 0) + (read ? -flipping.size : flipping.size)) } : c)) return { previous } } function useSetRead(toast) { const qc = useQueryClient() return useMutation({ mutationFn: ({ ids, read }) => setReadChunked(ids, read), onMutate: ({ ids, read }) => optimisticRead(qc, ids, read), onError: (err, _vars, ctx) => { for (const [key, data] of ctx?.previous ?? []) qc.setQueryData(key, data) toast(friendlyAuthError(err, 'Could not update read state.'), 'error') }, onSettled: () => qc.invalidateQueries({ queryKey: qk.mailbox.all() }), }) } /** * PATCH /inbox/read-all — every row matching a server-side list filter. * * The WRITE is a WHERE clause, but the optimistic patch still runs over the ids * the caller can see: this endpoint is only ever used where the visible list IS * the server's whole result (see SERVER_SCOPED_TABS, and the list endpoint takes * no pagination), so those ids are the scope, not a sample of it. Without the * patch the rows sit unchanged for a whole round trip plus a refetch, which reads * as a dead button — and the reconciling refetch on settle corrects it anyway if * the scope ever did reach further. */ function useSetReadAll(toast) { const qc = useQueryClient() return useMutation({ mutationFn: ({ read, filter }) => inboxApi.setReadAll({ read, ...filter }), onMutate: ({ ids, read }) => optimisticRead(qc, ids ?? [], read), onSuccess: (res, { read }) => { const n = res?.data?.updated ?? 0 toast( n === 0 ? `Nothing to mark ${read ? 'read' : 'unread'} here` : `${n} ${n === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`, 'success', ) }, onError: (err, _vars, ctx) => { for (const [key, data] of ctx?.previous ?? []) qc.setQueryData(key, data) toast(friendlyAuthError(err, 'Could not update read state.'), 'error') }, onSettled: () => qc.invalidateQueries({ queryKey: qk.mailbox.all() }), }) } /** * Multi-select over a row list: the Set, the toggles, and the pruning. * * The pruning is not optional. Rows leave a list under their own steam — the * Unread tab drops a row the instant it is marked read — so a Set left alone * keeps counting ids that are no longer on screen, and "Mark 12 read" quietly * acts on 9 of them. */ function useRowSelection(rows) { const [selectedIds, setSelectedIds] = useState(() => new Set()) const visibleIds = useMemo( () => (Array.isArray(rows) ? rows.map((r) => r.id) : []), [rows], ) useEffect(() => { setSelectedIds((prev) => { if (prev.size === 0) return prev const visible = new Set(visibleIds) const next = new Set() for (const id of prev) if (visible.has(id)) next.add(id) // Same size means nothing was pruned. Returning `prev` keeps the Set // identity stable, so this effect cannot retrigger itself. return next.size === prev.size ? prev : next }) }, [visibleIds]) const toggle = useCallback((id) => setSelectedIds((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }), []) const clear = useCallback(() => setSelectedIds((prev) => (prev.size ? new Set() : prev)), []) const toggleAll = useCallback(() => setSelectedIds((prev) => ( prev.size >= visibleIds.length ? new Set() : new Set(visibleIds) )), [visibleIds]) return { selectedIds, toggle, toggleAll, clear, allSelected: visibleIds.length > 0 && selectedIds.size === visibleIds.length, } } /** * The row tick. stopPropagation is load-bearing: without it a tick also runs the * row's onClick, which opens the detail pane AND auto-marks it read — instantly * undoing the "mark unread" the user is selecting rows for. */ function RowCheck({ checked, onToggle, label }) { return ( { 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 }) { 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}` : ''}`}
{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('email') const [formSheet, setFormSheet] = useState(DEFAULT_FORM_SHEET) const [tab, setTab] = useState('All Applications') const [skip, setSkip] = useState(0) const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE) const [selectedId, setSelectedId] = useState(null) const [q, setQ] = useState('') const [assigning, setAssigning] = useState(null) const [noting, setNoting] = useState(null) const isForms = channel === 'forms' const channelTabs = isForms ? FORM_TABS : TABS const tabFilter = TAB_FILTERS[tab] ?? {} const formTabFilter = FORM_TAB_FILTERS[tab] ?? {} const listParams = useMemo(() => ({ ...tabFilter, top: pageSize, skip, ...(q.trim() ? { search: q.trim() } : {}), }), [tabFilter, skip, pageSize, q]) const formParams = useMemo(() => ({ sheet: formSheet || undefined, offset: skip, limit: pageSize, ...formTabFilter, ...(q.trim() ? { search: q.trim() } : {}), }), [formSheet, skip, pageSize, q, formTabFilter]) const applicationsQuery = useQuery({ queryKey: qk.mailbox.applications(listParams), queryFn: () => fetchApplications(listParams), enabled: !isForms, }) const formSheetsQuery = useQuery({ queryKey: qk.mailbox.formSheets(), queryFn: async () => { const res = await sheetApi.listFormDataSheets() const sheets = res?.data?.sheets return Array.isArray(sheets) ? sheets : [] }, enabled: isForms, }) const formQuery = useQuery({ queryKey: qk.mailbox.formData(formParams), queryFn: () => fetchFormApplications(formParams), enabled: isForms, }) const countsQuery = useQuery({ queryKey: qk.mailbox.counts(), queryFn: fetchInboxCounts, enabled: !isForms, }) const formCountsQuery = useQuery({ queryKey: qk.mailbox.formCounts({ sheet: formSheet || undefined }), queryFn: async () => { const res = await sheetApi.fetchFormCounts({ sheet: formSheet || undefined }) return res?.data ?? {} }, enabled: isForms, }) const emailTotalQuery = useQuery({ queryKey: qk.mailbox.applicationTotal(), queryFn: async () => { const res = await inboxApi.countApplications() return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0) }, enabled: !isForms, staleTime: Infinity, }) const formTotalQuery = useQuery({ queryKey: qk.mailbox.formTotal({ sheet: formSheet || undefined }), queryFn: async () => { const res = await sheetApi.countFormData({ sheet: formSheet || undefined }) return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0) }, enabled: isForms, staleTime: Infinity, }) // Prefer the imported sheet list; keep the known 2026 tab even when the // sheets endpoint is still loading so the first paint is not blank. const formSheetOptions = useMemo(() => { const fromApi = formSheetsQuery.data ?? [] if (fromApi.length) return fromApi return formSheet ? [formSheet] : [DEFAULT_FORM_SHEET] }, [formSheetsQuery.data, formSheet]) useEffect(() => { if (!isForms || !formSheetsQuery.data?.length) return if (!formSheetsQuery.data.includes(formSheet)) { setFormSheet(formSheetsQuery.data[0]) } }, [isForms, formSheetsQuery.data, formSheet]) const activeQuery = isForms ? formQuery : applicationsQuery const inbox = Array.isArray(activeQuery.data?.rows) ? activeQuery.data.rows : [] const serverCounts = isForms ? (formCountsQuery.data ?? {}) : (countsQuery.data ?? {}) const counts = useMemo( () => { const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0) return { 'All Applications': n(serverCounts.all), Unread: n(serverCounts.unread), Processed: n(serverCounts.processed), Rejected: n(serverCounts.rejected), Duplicates: n(serverCounts.duplicates), } }, [serverCounts], ) const poolTotal = isForms ? (formTotalQuery.data ?? 0) : (emailTotalQuery.data ?? 0) const tabTotal = counts[tab] ?? 0 const countsReady = isForms ? formCountsQuery.isSuccess : countsQuery.isSuccess const total = q.trim() ? (activeQuery.data?.total ?? 0) : (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0))) const pages = Math.max(1, Math.ceil(total / pageSize)) const from = total ? skip + 1 : 0 const to = Math.min(skip + pageSize, total) const currentPage = Math.min(Math.floor(skip / pageSize) + 1, pages) useEffect(() => { if (total <= 0 || skip < total) return setSkip(Math.max(0, Math.floor((total - 1) / pageSize) * pageSize)) }, [total, pageSize, skip]) const list = inbox const detailQuery = useQuery({ queryKey: isForms ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId), queryFn: () => (isForms ? fetchFormDetail(selectedId) : fetchMessageDetail(selectedId)), enabled: Boolean(selectedId), }) const selectedRow = inbox.find((i) => i.id === selectedId) const selected = selectedRow || detailQuery.data ? { ...selectedRow, ...(detailQuery.data ?? {}) } : null // Bound to `list`, not `inbox`: the tick boxes sit on the rows the user can // actually see, and the hook prunes the Set as that set changes. const selection = useRowSelection(list) const setRead = useSetRead(toast) const setReadAll = useSetReadAll(toast) /** * What "Mark all" means here, in words, for the button tooltip. Kept next to * setReadEverything so the label and the scope cannot drift apart. */ const scopeLabel = q.trim() ? `the ${list.length} row${list.length === 1 ? '' : 's'} matching this search` : tab === 'All Applications' ? 'every application' : `the ${tab} tab` function switchChannel(next) { if (next === channel) return setChannel(next) setSkip(0) setSelectedId(null) setQ('') // Unread is email-only; leave it behind when opening Sheet Forms. if (next === 'forms' && tab === 'Unread') setTab('All Applications') selection.clear() } function setReadSelected(read) { const ids = [...selection.selectedIds] if (!ids.length) return setRead.mutate({ ids, read }, { onSuccess: () => { toast(`${ids.length} ${ids.length === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`, 'success') selection.clear() }, }) } function setReadEverything(read) { // Search goes on the wire now, so every tab is a true server scope. if (SERVER_SCOPED_TABS.has(tab)) { setReadAll.mutate({ read, filter: { ...tabFilter, ...(q.trim() ? { search: q.trim() } : {}) }, ids: list.map((i) => i.id), }) return } const ids = list.map((i) => i.id) if (!ids.length) return setRead.mutate({ ids, read }, { onSuccess: () => { toast(`${ids.length} ${ids.length === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`, 'success') selection.clear() }, }) } const setState = useMutation({ mutationFn: ({ id, state, kind }) => ( kind === 'form' ? sheetApi.setProcessingState(id, state) : inboxApi.setProcessingState(id, state) ), onSuccess: (_data, vars) => { const labels = { imported: 'Imported', processed: 'Processed', rejected: 'Rejected', unread: 'Unread' } toast(`${vars.name || 'Application'} marked ${labels[vars.state] || vars.state}`, vars.state === 'rejected' ? 'warning' : 'success') if (vars.state === 'processed') setTimeout(() => navigate('/pipeline'), 700) }, onError: (err) => toast(friendlyAuthError(err, 'Could not update processing state.'), 'error'), onSettled: () => { qc.invalidateQueries({ queryKey: qk.mailbox.all() }) qc.invalidateQueries({ queryKey: qk.mailbox.counts() }) qc.invalidateQueries({ queryKey: qk.pipeline.all() }) qc.invalidateQueries({ queryKey: qk.candidates.all() }) qc.invalidateQueries({ queryKey: qk.analytics.all() }) }, }) const markDuplicate = useMutation({ mutationFn: ({ id, isDuplicate, kind }) => ( kind === 'form' ? sheetApi.setDuplicate(id, isDuplicate) : inboxApi.setDuplicate(id, isDuplicate) ), onSuccess: (_d, vars) => toast(vars.isDuplicate ? 'Marked as duplicate' : 'Duplicate cleared', 'success'), onError: (err) => toast(friendlyAuthError(err, 'Could not update duplicate flag.'), 'error'), onSettled: () => { qc.invalidateQueries({ queryKey: qk.mailbox.all() }) qc.invalidateQueries({ queryKey: qk.mailbox.counts() }) }, }) function select(id) { setSelectedId(id) if (isForms) return const item = inbox.find((i) => i.id === id) if (item?.unread) setRead.mutate({ ids: [id], read: true }) } function importItem(item) { setState.mutate({ id: item.id, state: 'imported', name: item.name, kind: item.kind }) } function moveToPipeline(item) { setState.mutate({ id: item.id, state: 'processed', name: item.name, kind: item.kind }) } function reject(item) { setState.mutate({ id: item.id, state: 'rejected', name: item.name, kind: item.kind }) } function toggleDuplicate(item) { markDuplicate.mutate({ id: item.id, isDuplicate: !item.duplicate, kind: item.kind }) } const [syncRunId, setSyncRunId] = useState(() => readStoredSyncRunId()) const syncToastShown = useRef(null) const sync = useMutation({ mutationFn: () => inboxApi.startMailboxSync(), onSuccess: (res) => { const id = res?.data?.id if (id) { storeSyncRunId(id) setSyncRunId(id) } toast('Mailbox sync started — safe to leave this page', 'info') }, onError: (err) => toast(friendlyAuthError(err, 'Sync failed'), 'error'), }) const syncRun = useQuery({ queryKey: qk.mailbox.sync(syncRunId), queryFn: async () => { const res = await inboxApi.getMailboxSync(syncRunId) return res?.data ?? null }, enabled: Boolean(syncRunId), refetchInterval: (q) => { const status = q.state.data?.status return status === 'queued' || status === 'running' ? 800 : false }, }) useEffect(() => { const run = syncRun.data if (!run?.id) return undefined if (run.status === 'completed' && syncToastShown.current !== run.id) { syncToastShown.current = run.id const t = run.triage toast( t ? `Mailbox synced — ${t.ingested ?? 0} imported, ${t.skipped ?? 0} filtered out` : 'Mailbox synced', 'success', ) qc.invalidateQueries({ queryKey: qk.mailbox.all() }) const timer = setTimeout(() => { storeSyncRunId(null) setSyncRunId(null) }, 1800) return () => clearTimeout(timer) } if (run.status === 'failed' && syncToastShown.current !== run.id) { syncToastShown.current = run.id toast(run.error || 'Sync failed', 'error') storeSyncRunId(null) setSyncRunId(null) } return undefined }, [qc, syncRun.data, toast]) return (
{CHANNELS.map((c) => ( ))}
{!isForms && ( Microsoft Graph API · Connected )} {isForms && ( Google Sheets · Form data )} {!isForms && ( sync.mutate()} /> )} } />
{ setTab(t) setSkip(0) setSelectedId(null) selection.clear() }} tabs={channelTabs.map((t) => ({ key: t, label: t, count: counts[t] }))} />
{!isForms && applicationsQuery.isSuccess && ( )}
{isForms && (
)}
{ setQ(e.target.value); setSkip(0) }} placeholder={isForms ? 'Search name, email, position, city…' : 'Search applications…'} />
{activeQuery.isPending && ( )} {activeQuery.isError && ( {friendlyAuthError(activeQuery.error, 'Request failed')} )} {activeQuery.isSuccess && list.length === 0 ? ( {isForms ? 'No form responses in this sheet tab.' : 'No applications in this view.'} ) : ( list.map((i) => (
select(i.id)} > {!isForms && ( selection.toggle(i.id)} label={`Select ${i.name}`} /> )}
{i.name} {i.duplicate && ( DUP )}
{i.position}
{outlookListTime(i.received)}
{i.atsScore != null && (
)} {isForms && i.noticePeriod && (
{i.noticePeriod}
)}
{i.processing} {i.applicationStatus && i.applicationStatus !== 'CLOSED' && ( {i.applicationStatus} )} {isForms && i.residingCity && ( {i.residingCity} )}
)) )}
{activeQuery.isSuccess && total > 0 && ( { setSkip((p - 1) * pageSize); setSelectedId(null); selection.clear() }} pageButtons={pageWindow(currentPage, pages)} pageSize={pageSize} pageSizeMax={PAGE_SIZE_MAX} onPageSizeChange={(n) => { setPageSize(n) setSelectedId(null) selection.clear() }} /> )}
{!selected ? (
{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.'}
) : detailQuery.isError ? (
{friendlyAuthError(detailQuery.error, 'Request failed')}
) : isForms ? ( importItem(selected)} onMove={() => moveToPipeline(selected)} onNote={() => setNoting(selected)} onReject={() => reject(selected)} onToggleDuplicate={() => toggleDuplicate(selected)} /> ) : ( importItem(selected)} onMove={() => moveToPipeline(selected)} onNote={() => setNoting(selected)} onReject={() => reject(selected)} onToggleDuplicate={() => toggleDuplicate(selected)} /> )}
{assigning && ( 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 && ( setNoting(null)} footer={ <> } >