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

1607 lines
63 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 plus the Email tab, which is the
app's oldest real network call (GET /inbox/fetch, previously the only fetch
in the entire prototype).
The email body used to be interpolated raw into markup at js/inbox.js:292 —
the single widest XSS sink in the repository, and the one that mattered most
because inbound mail is attacker-supplied by definition. It renders as text
now, which is the structural fix.
============================================================ */
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 { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } 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 {
atsRecommendationClass, avatarColor, fmtDate, initials as initialsOf,
inboxSources, sourceMeta,
} from '../data/seed'
const TABS = ['All Applications', 'Unread', 'Processed', 'Rejected', 'Duplicates', 'Email']
/**
* Tabs that do not read /inbox/all-applications at all. Email reads
* /inbox/fetch; every other tab is a view over the applications list.
*/
const SPECIAL_TABS = new Set(['Email'])
/**
* Server-side filters for tabs /inbox/all-applications can narrow.
* Processed / Rejected / Duplicates filter client-side on
* processing_state + is_duplicate (see GET /inbox/counts).
*/
const TAB_FILTERS = {
Unread: { isread: false },
}
/**
* Tabs whose visible set is EXACTLY what the server returns for TAB_FILTERS[tab].
*
* Only these may use the scope endpoint for "mark all". Processed / Rejected /
* Duplicates narrow client-side over rows the server already handed back in full,
* so a scope call from one of those carries no such predicate and would mark the
* entire mailbox — rows the user never saw, with no undo. Those tabs send the
* visible ids instead.
*/
const SERVER_SCOPED_TABS = new Set(['All Applications', 'Unread'])
/**
* 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 + AM/PM time.
*/
function outlookListTime(value) {
if (!value) return '—'
const d = value instanceof Date ? value : new Date(value)
if (Number.isNaN(d.getTime())) return '—'
const time = d.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
const daysAgo = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000)
if (daysAgo < 7) {
const weekday = d.toLocaleDateString(undefined, { weekday: 'short' })
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()} ${time}`
}
/**
* `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 = (messageTo || '').trim()
if (!raw) 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 }
}
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.
return (
<span className="source-chip" style={{ '--chip': item.sourceMeta?.color }}>
<span className="source-dot" />
{item.source}
</span>
)
}
/**
* Graph delivers the body as text/html, so rendering it verbatim as text — which
* is what keeps it XSS-safe — prints the raw markup at the user.
*
* DOMParser builds a DETACHED document: it is never adopted into the live DOM, so
* scripts do not run and <img onerror> never fires. Reading textContent off it is
* therefore both safe and readable, and needs no dangerouslySetInnerHTML.
*/
function htmlToText(value) {
const raw = (value || '').trim()
if (!raw) return ''
if (!/<[a-z!/]/i.test(raw)) return raw // already plain text
// textContent ignores block boundaries, so <p>a</p><p>b</p> would collapse to
// "ab". Turn breaks and closing block tags into newlines BEFORE parsing.
const withBreaks = raw
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|tr|h[1-6]|blockquote|table)\s*>/gi, '\n')
const doc = new DOMParser().parseFromString(withBreaks, 'text/html')
doc.querySelectorAll('script, style, head').forEach((n) => n.remove())
return (doc.body?.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
}
/** match_status -> the resume badge, mirroring _RESUME_STATUS in inbox/serializers.py. */
const RESUME_STATUS = {
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
failed: 'Failed', dlq: 'Failed', skipped: 'Pending',
}
const SHORTLIST_JOB_WARNING = 'Choose one of the suggested jobs above to add this candidate to the shortlist.'
/**
* GET /inbox/fetch?record_id=<pk> -> the detail behind one application row.
*
* Returns serialize_message, a different shape from serialize_application, so it
* is remapped onto the row shape here and OVERLAID on the list row rather than
* replacing it: serialize_message carries the body, decoded attachments, and
* the hydrated suggested_job_posts / assigned_job_post the role picker needs.
*/
async function fetchMessageDetail(recordId) {
const res = await inboxApi.getMessage(recordId)
const row = res?.data
if (!row) return null
const name = row.sender_name || row.fromEmail || 'Unknown'
return {
id: String(row.id),
name,
initials: initialsOf(name),
color: avatarColor(name),
email: row.fromEmail || '',
position: row.subject || '(no subject)',
...sourceFrom(row.message_to),
received: parseDate(row.when) ?? parseDate(row.message_sent_time),
unread: Boolean(row.unread),
processing: row.unread ? 'Unread' : 'Read',
resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending',
attachment: row.attachment_name,
hasAttachment: Boolean(row.attachment),
body: htmlToText(row.body),
// Raw markup for the HTML viewer; `body` stays the plain-text fallback.
bodyHtml: row.body || '',
cc: row.message_cc || '',
bcc: row.message_bcc || '',
sentAt: parseDate(row.message_sent_time),
files: Array.isArray(row.files) ? row.files : [],
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: (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.
*/
async function fetchApplications(params) {
const res = await inboxApi.listApplications(params)
const rows = Array.isArray(res?.data) ? res.data : []
return 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),
resumeText: row.resume_text || '',
atsScore: row.ats_score,
phone: row.phone,
experience: row.experience,
recruiter: row.recruiter,
duplicate: Boolean(row.duplicate),
suggestedIds: (row.suggested_job_post_ids || []).map(String),
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
}
})
}
// 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, 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 && 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) {
if (!Array.isArray(data)) continue
for (const r of data) 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(() => rows.map((r) => r.id), [rows])
useEffect(() => {
setSelectedIds((prev) => {
if (prev.size === 0) return prev
const visible = new Set(visibleIds)
const next = new Set()
for (const id of prev) if (visible.has(id)) next.add(id)
// Same size means nothing was pruned. Returning `prev` keeps the Set
// identity stable, so this effect cannot retrigger itself.
return next.size === prev.size ? prev : next
})
}, [visibleIds])
const toggle = useCallback((id) => setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
}), [])
const clear = useCallback(() => setSelectedIds((prev) => (prev.size ? new Set() : prev)), [])
const toggleAll = useCallback(() => setSelectedIds((prev) => (
prev.size >= visibleIds.length ? new Set() : new Set(visibleIds)
)), [visibleIds])
return {
selectedIds,
toggle,
toggleAll,
clear,
allSelected: visibleIds.length > 0 && selectedIds.size === visibleIds.length,
}
}
/**
* The row tick. stopPropagation is load-bearing: without it a tick also runs the
* row's onClick, which opens the detail pane AND auto-marks it read — instantly
* undoing the "mark unread" the user is selecting rows for.
*/
function RowCheck({ checked, onToggle, label }) {
return (
<span
className={`checkbox${checked ? ' on' : ''}`}
role="checkbox"
aria-checked={checked}
aria-label={label}
tabIndex={0}
style={{ alignSelf: 'center' }}
onClick={(e) => { e.stopPropagation(); onToggle() }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
onToggle()
}
}}
>
<Icon name="check" />
</span>
)
}
const READ_TICK_MS = 1000
/**
* Select-all plus the read/unread actions for one list.
*
* The leading ✓ is not a mode indicator. It only appears on the Mark read /
* Mark all read button that was just clicked, then clears from both after 1s.
*
* `rows` rather than a count, because the bar needs each row's read state, and
* `selectedIds` narrows it to what the selected pair of buttons would touch.
*/
function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, scopeLabel }) {
const { selectedIds, toggleAll, clear, allSelected } = selection
const n = selectedIds.size
const total = rows.length
// What the visible pair of buttons acts on: the ticked rows when there is a
// selection, the whole view otherwise.
const target = n > 0 ? rows.filter((r) => selectedIds.has(r.id)) : rows
const unreadCount = target.reduce((acc, r) => acc + (r.unread ? 1 : 0), 0)
const readCount = target.length - unreadCount
const blocked = !canEdit || busy
const tip = !canEdit ? 'Requires inbox.edit' : undefined
const scopeTip = !canEdit ? 'Requires inbox.edit' : `Applies to ${scopeLabel}`
const nothingTo = (word) => `Nothing to mark ${word} here`
// `selected` = Mark read, `all` = Mark all read. Never both at once in the
// bar, but the flash still resets both so a leftover tick cannot linger
// after the selection-vs-all swap.
const [ticked, setTicked] = useState(null)
const tickTimer = useRef(null)
useEffect(() => () => {
if (tickTimer.current) clearTimeout(tickTimer.current)
}, [])
function flashRead(which, action) {
if (tickTimer.current) clearTimeout(tickTimer.current)
setTicked(which)
action()
tickTimer.current = setTimeout(() => {
setTicked(null)
tickTimer.current = null
}, READ_TICK_MS)
}
return (
<div className="inbox-bulk-bar">
<RowCheck
checked={allSelected}
onToggle={toggleAll}
label={allSelected ? 'Clear selection' : 'Select all'}
/>
<span className="inbox-bulk-count" title={n > 0
? `${n} selected`
: `${total} ${total === 1 ? 'message' : 'messages'}${unreadCount ? ` · ${unreadCount} unread` : ''}`}
>
{n > 0
? `${n} selected`
: `${total}${unreadCount ? ` · ${unreadCount}` : ''}`}
</span>
<div className="inbox-bulk-actions">
{n > 0 ? (
<>
<button
className="btn btn-secondary btn-sm"
disabled={blocked || unreadCount === 0}
title={unreadCount === 0 ? nothingTo('read') : tip || 'Mark selected read'}
onClick={() => flashRead('selected', () => onSetRead(true))}
>
{ticked === 'selected' && <Icon name="check" />}
Mark as Read
</button>
<button
className="btn btn-secondary btn-sm"
disabled={blocked || readCount === 0}
title={readCount === 0 ? nothingTo('unread') : tip || 'Mark selected unread'}
onClick={() => onSetRead(false)}
>
Mark as Unread
</button>
<button className="btn btn-ghost btn-sm" onClick={clear}>Clear</button>
</>
) : (
<>
<button
className="btn btn-secondary btn-sm"
disabled={blocked || unreadCount === 0}
title={unreadCount === 0 ? nothingTo('read') : scopeTip}
onClick={() => flashRead('all', () => onSetAllRead(true))}
>
{ticked === 'all' && <Icon name="check" />}
Mark all Read
</button>
<button
className="btn btn-secondary btn-sm"
disabled={blocked || readCount === 0}
title={readCount === 0 ? nothingTo('unread') : scopeTip}
onClick={() => onSetAllRead(false)}
>
Mark all Unread
</button>
</>
)}
</div>
</div>
)
}
export default function Inbox() {
const { toast } = useToast()
const navigate = useNavigate()
const qc = useQueryClient()
const { can } = useAuth()
const canEdit = can('inbox.edit')
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const updateInbox = useSeedMutation('inbox')
const [tab, setTab] = useState('All Applications')
const [selectedId, setSelectedId] = useState(null)
const [q, setQ] = useState('')
const [previewing, setPreviewing] = useState(null)
const [assigning, setAssigning] = useState(null)
const [noting, setNoting] = useState(null)
// Tabs with a server-side filter pass their params; everything else (and
// countsQuery) passes `{}` so the backend defaults mean "no filter".
const tabFilter = TAB_FILTERS[tab] ?? {}
const applicationsQuery = useQuery({
queryKey: qk.mailbox.applications(tabFilter),
queryFn: () => fetchApplications(tabFilter),
enabled: !SPECIAL_TABS.has(tab),
})
const countsQuery = useQuery({
queryKey: qk.mailbox.counts(),
queryFn: fetchInboxCounts,
enabled: tab !== 'Email',
})
const inbox = applicationsQuery.data ?? []
const serverCounts = countsQuery.data ?? {}
const emailsQuery = useQuery({
queryKey: qk.mailbox.messages(),
queryFn: async () => {
const res = await inboxApi.listMessages()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({
id: String(row.id),
from: row.sender_name || row.fromEmail || 'Unknown',
fromEmail: row.fromEmail || '',
subject: row.subject || '',
body: row.body || '',
when: parseDate(row.when) ?? parseDate(row.message_sent_time),
unread: Boolean(row.unread),
attachment: row.attachment_name || 'Resume.pdf',
attachmentSize: '—',
// The agent's verdict, straight off backend/inbox/serializers.py:44-48.
// suggested_job_post_ids is deliberately NOT carried: job posts stay
// dark to the inbox.
matchStatus: row.match_status || null,
matchSummary: row.match_summary || '',
matchReasoning: row.match_reasoning || '',
matchError: row.match_error || '',
matchedAt: parseDate(row.matched_at),
imported: false,
}))
},
enabled: tab === 'Email',
})
const counts = useMemo(
() => ({
'All Applications': serverCounts.all ?? 0,
Unread: serverCounts.unread ?? 0,
Processed: serverCounts.processed ?? 0,
Rejected: serverCounts.rejected ?? 0,
Duplicates: serverCounts.duplicates ?? 0,
Email: (emailsQuery.data ?? []).filter((e) => e.unread).length,
}),
[serverCounts, emailsQuery.data],
)
const list = useMemo(() => {
let l = inbox
if (tab === 'Unread') l = l.filter((i) => i.processing === 'Unread')
else if (tab === 'Processed') l = l.filter((i) => i.processing === 'Processed')
else if (tab === 'Rejected') l = l.filter((i) => i.processing === 'Rejected')
else if (tab === 'Duplicates') l = l.filter((i) => i.duplicate)
if (q) l = l.filter((i) => (i.name + i.position + i.source).toLowerCase().includes(q.toLowerCase()))
return l
}, [inbox, tab, q])
// Clicking a row fetches that one record from /inbox/fetch. The list row is
// kept as the base and the detail is overlaid, so the pane paints instantly
// from cached list data and fills in body/attachments when the fetch lands.
const detailQuery = useQuery({
queryKey: qk.mailbox.message(selectedId),
queryFn: () => fetchMessageDetail(selectedId),
enabled: !SPECIAL_TABS.has(tab) && 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 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) {
// The scope endpoint may only be used where the server-side filter IS the
// view. The search box narrows client-side on name/position/source while the
// server's `search` matches subject/from/body, and three of the tabs narrow
// client-side entirely — handing either to a WHERE clause would mark rows
// that were never on screen. Those cases send the visible ids instead, which
// is exact and, since the list endpoint is unpaginated, complete.
if (SERVER_SCOPED_TABS.has(tab) && !q.trim()) {
// `ids` drives the optimistic patch only; the write itself is the filter.
setReadAll.mutate({ read, filter: tabFilter, 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 }) => 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() })
},
})
const markDuplicate = useMutation({
mutationFn: ({ 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?.unread) setRead.mutate({ ids: [id], read: true })
}
function importItem(item) {
setState.mutate({ id: item.id, state: 'imported', name: item.name })
}
function moveToPipeline(item) {
setState.mutate({ id: item.id, state: 'processed', name: item.name })
}
function reject(item) {
setState.mutate({ id: item.id, state: 'rejected', name: item.name })
}
function toggleDuplicate(item) {
markDuplicate.mutate({ id: item.id, isDuplicate: !item.duplicate })
}
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Recruitment Inbox</h1>
<p className="page-sub">Every candidate, every source one unified queue</p>
</div>
<div className="page-head-actions">
<span className="integration-status"><span className="pulse" />Microsoft Graph API · Connected</span>
<button
className="btn btn-secondary"
onClick={() => {
toast('Syncing all sources…', 'info')
setTimeout(() => toast('Inbox synced', 'success'), 900)
}}
>
<Icon name="refresh" /> Sync
</button>
<button className="btn btn-primary" onClick={() => navigate('/import')}>
<Icon name="upload" /> Upload CVs
</button>
</div>
</div>
<div className="card">
<div style={{ margin: '0 16px', paddingTop: 8 }}>
<Tabs
value={tab}
onChange={(t) => { setTab(t); setSelectedId(null); selection.clear() }}
tabs={TABS.map((t) => ({ key: t, label: t, count: counts[t] }))}
/>
</div>
{tab === 'Email' ? (
<EmailTab query={emailsQuery} toast={toast} />
) : (
<div className="split inbox-split">
<div className="split-list inbox-queue">
{applicationsQuery.isSuccess && (
<BulkReadBar
rows={list}
selection={selection}
onSetRead={setReadSelected}
onSetAllRead={setReadEverything}
busy={setRead.isPending || setReadAll.isPending}
canEdit={canEdit}
scopeLabel={scopeLabel}
/>
)}
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
<div className="toolbar-search" style={{ maxWidth: 'none' }}>
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search applications…" />
</div>
</div>
<div>
{applicationsQuery.isPending && (
<EmptyState icon="inbox" title="Loading…">Fetching applications from the server.</EmptyState>
)}
{applicationsQuery.isError && (
<EmptyState icon="inbox" title="Couldnt load applications">
{friendlyAuthError(applicationsQuery.error, 'Request failed')}
</EmptyState>
)}
{applicationsQuery.isSuccess && list.length === 0 ? (
<EmptyState icon="inbox" title="Nothing here">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)}
>
<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">
{i.name}{' '}
{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-meta">
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<Badge>{i.applicationStatus}</Badge>
)}
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div className="ii-time">
{outlookListTime(i.received)}
</div>
{/* No ATS score exists server-side — the agent returns a
verdict, not a number. The chip stays off rather than
rendering a placeholder that reads as a real score. */}
{i.atsScore != null && (
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
)}
</div>
</div>
))
)}
</div>
</div>
<div className="split-detail">
{!selected ? (
<div style={{ padding: '100px 20px' }}>
<EmptyState icon="inbox" title="Select an application">
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="Couldnt load this application">
{friendlyAuthError(detailQuery.error, 'Request failed')}
</EmptyState>
</div>
) : (
<ApplicationDetail
item={selected}
loading={detailQuery.isPending}
busy={setState.isPending || markDuplicate.isPending}
canEdit={canEdit}
toast={toast}
onPreview={() => setPreviewing(selected)}
onImport={() => importItem(selected)}
onMove={() => moveToPipeline(selected)}
onNote={() => setNoting(selected)}
onReject={() => reject(selected)}
onToggleDuplicate={() => toggleDuplicate(selected)}
/>
)}
</div>
</div>
)}
</div>
{previewing && (
<Modal
title={previewing.attachment}
subtitle={`Resume preview · ${previewing.name}`}
size="modal-lg"
onClose={() => setPreviewing(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setPreviewing(null)}>Close</button>
<button
className="btn btn-primary"
onClick={() => { const it = previewing; setPreviewing(null); importItem(it) }}
>
<Icon name="user-plus" /> Import Candidate
</button>
</>
}
>
<pre className="resume-thumb" style={{ maxHeight: 'none', fontSize: 12 }}>
{previewing.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
</pre>
</Modal>
)}
{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 ApplicationDetail({
item: i, loading, busy, canEdit, toast, onPreview, 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) })
},
})
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
async function handleMove() {
if (busy || alreadyProcessed) return
if (!jobChosen) {
toast(SHORTLIST_JOB_WARNING, 'warning')
return
}
const jobId = selection || i.assignedId
if (jobId && String(jobId) !== String(i.assignedId || '')) {
try {
await assignMutation.mutateAsync({ recordId: i.id, jobPostId: jobId })
} catch {
return
}
}
onMove()
}
return (
<div style={{ padding: 24 }}>
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
<div className="ph-role">{i.position}</div>
<div className="ph-tags" style={{ marginTop: 8 }}>
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
{i.duplicate && <><Badge className="b-red">Duplicate</Badge>{' '}</>}
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<><Badge>{i.applicationStatus}</Badge>{' '}</>
)}
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
{i.resumeStatus}
</Badge>{' '}
{loading && <span className="cell-sub">Loading details</span>}
</div>
</div>
{i.atsScore != null && (
<div style={{ textAlign: 'center' }}>
<div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}>
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{i.atsScore}</div></div>
</div>
<div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
</div>
)}
</div>
<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>
)}
{i.hasAttachment && (
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
<div className="card-body">
<div className="flex items-center gap-12" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
<div className="fw-600">
<Icon name="paperclip" /> {orDash(i.attachment)}
{i.files?.[0]?.size != null && (
<span className="cell-sub"> · {Math.round(i.files[0].size / 1024)} KB</span>
)}
</div>
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
</div>
<pre className="resume-thumb">
{resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
</pre>
</div>
</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">
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap', marginTop: 10 }}>
<button
className="btn btn-secondary btn-sm"
disabled={!canEdit || rematchMutation.isPending}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => rematchMutation.mutate(i.id)}
>
Retry match
</button>
<button
className="btn btn-primary btn-sm"
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => setShowPicker(true)}
>
Choose a role
</button>
</div>
</EmptyState>
) : (
suggestionCards.map(({ rank, post }) => (
<JobCard
key={post.id}
post={post}
rank={rank}
selected={String(selection) === String(post.id)}
onSelect={(id) => setSelection(String(id))}
resumeText={resumeText}
/>
))
)}
{manualPost && (
<JobCard
post={manualPost}
rank={0}
manual
selected={String(selection) === String(manualPost.id)}
onSelect={(id) => setSelection(String(id))}
resumeText={resumeText}
/>
)}
<button
className="btn btn-secondary"
style={{ width: '100%', marginTop: 8 }}
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => setShowPicker(true)}
>
Choose a different role
</button>
<button
className="btn btn-primary"
style={{ width: '100%', marginTop: 8 }}
disabled={!canAssign}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => {
if (!selection || !canEdit) return
assignMutation.mutate({ recordId: i.id, jobPostId: selection })
}}
>
{selectedPost?.title ? `Assign`:'Assign'}
</button>
</div>
</div>
<div className="flex gap-8" style={{ flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={onImport} disabled={panelBusy || i.processing === 'Imported'}>
<Icon name="user-plus" /> {i.processing === 'Imported' ? 'Imported' : 'Import Candidate'}
</button>
<button
className="btn btn-secondary"
aria-disabled={shortlistLocked || panelBusy || alreadyProcessed}
disabled={panelBusy || alreadyProcessed}
style={shortlistLocked ? { opacity: 0.55, cursor: 'not-allowed' } : undefined}
title={shortlistLocked ? SHORTLIST_JOB_WARNING : undefined}
onClick={handleMove}
>
<Icon name="layers" /> Move to Shortlist
</button>
<button className="btn btn-secondary" onClick={onNote} disabled title="Notes attach to a candidate profile — open the candidate first">
<Icon name="edit" /> Add Note
</button>
<button className="btn btn-secondary" onClick={onToggleDuplicate} disabled={panelBusy}>
<Icon name="alert" /> {i.duplicate ? 'Clear Duplicate' : 'Mark Duplicate'}
</button>
<button
className="btn btn-ghost"
style={{ color: 'var(--danger)' }}
onClick={onReject}
disabled={panelBusy || i.processing === 'Rejected'}
>
<Icon name="trash" /> Reject
</button>
</div>
{showPicker && (
<PickRoleModal
onClose={() => setShowPicker(false)}
onPick={(post) => {
setManualPost(post)
setSelection(String(post.id))
}}
/>
)}
</div>
)
}
function AssignRecruiter({ item, recruiters, onClose, onSave }) {
const [name, setName] = useState(item.recruiter)
const current = recruiters.find((r) => r.name === item.recruiter)
return (
<Modal
title="Assign Recruiter"
subtitle={item.name}
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => onSave(name)}>Assign</button>
</>
}
>
<div className="form-field">
<label>Recruiter</label>
<select value={name} onChange={(e) => setName(e.target.value)}>
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}
</select>
</div>
<p className="text-muted text-sm" style={{ marginTop: 10 }}>
Current workload is factored automatically. This recruiter has {current?.openReqs ?? 5} open reqs.
</p>
</Modal>
)
}
/** The live tab: real fetch, real loading state, real error state. */
function EmailTab({ query, toast }) {
const qc = useQueryClient()
const { can } = useAuth()
const canEdit = can('inbox.edit')
const [selectedId, setSelectedId] = useState(null)
const [replying, setReplying] = useState(null)
const [replyBody, setReplyBody] = useState('')
const [importedIds, setImportedIds] = useState(() => new Set())
const emails = query.data ?? []
const selected = emails.find((e) => e.id === selectedId)
const unread = emails.filter((e) => e.unread).length
const selection = useRowSelection(emails)
const setRead = useSetRead(toast)
const setReadAll = useSetReadAll(toast)
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()
},
})
}
/**
* This list is /inbox/fetch with no params — every persisted message, no
* filter, no pagination — so the empty scope really is the whole mailbox and
* the WHERE clause matches what is on screen exactly.
*/
function setReadEverything(read) {
setReadAll.mutate({ read, filter: {}, ids: emails.map((e) => e.id) })
}
const sync = useMutation({
mutationFn: () => inboxApi.syncMailbox(),
onSuccess: async (res) => {
await qc.invalidateQueries({ queryKey: qk.mailbox.all() })
// /email/fetch now reports what the intake gate did with the page. The
// key is additive and absent when the gate is disabled, so fall back to
// the old message rather than rendering "undefined filtered out".
const t = res?.triage
toast(
t
? `Mailbox synced — ${t.ingested} imported, ${t.skipped} filtered out`
: 'Mailbox synced',
'success',
)
},
onError: (err) => toast(friendlyAuthError(err, 'Sync failed'), 'error'),
})
const importMsg = useMutation({
mutationFn: (id) => inboxApi.setProcessingState(id, 'imported'),
onSuccess: (_d, id) => {
setImportedIds((s) => new Set(s).add(id))
toast('Marked as imported', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not import.'), 'error'),
onSettled: () => {
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
qc.invalidateQueries({ queryKey: qk.mailbox.counts() })
},
})
const reply = useMutation({
mutationFn: ({ recordId, body }) => inboxApi.replyEmail({ recordId, body }),
onSuccess: () => {
toast('Reply sent', 'success')
setReplying(null)
setReplyBody('')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not send reply.'), 'error'),
})
function selectEmail(e) {
setSelectedId(e.id)
if (e.unread) setRead.mutate({ ids: [e.id], read: true })
}
const isImported = (e) => importedIds.has(e.id)
return (
<>
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12 }}>
<span className="integration-status"><span className="pulse" />Outlook · Microsoft Graph API</span>
<span className="text-muted text-sm">
{query.isPending ? 'Loading…' : query.isError ? 'Sync failed' : `${emails.length} messages · ${unread} unread`}
</span>
<button
className="btn btn-secondary btn-sm"
style={{ marginLeft: 'auto' }}
disabled={sync.isPending}
onClick={() => { toast('Fetching from Outlook…', 'info'); sync.mutate() }}
>
<Icon name="refresh" /> {sync.isPending ? 'Syncing…' : 'Sync Mailbox'}
</button>
</div>
<div className="split inbox-split">
<div className="split-list inbox-queue">
{query.isSuccess && emails.length > 0 && (
<BulkReadBar
rows={emails}
selection={selection}
onSetRead={setReadSelected}
onSetAllRead={setReadEverything}
busy={setRead.isPending || setReadAll.isPending}
canEdit={canEdit}
scopeLabel="every message in the mailbox"
/>
)}
{query.isPending && <EmptyState icon="mail" title="Loading…">Fetching mailbox from the server.</EmptyState>}
{query.isError && (
<EmptyState icon="mail" title="Couldnt load mailbox">
{friendlyAuthError(query.error, 'Request failed')}
</EmptyState>
)}
{query.isSuccess && emails.length === 0 && (
<EmptyState icon="mail" title="Nothing here">No emails in the mailbox.</EmptyState>
)}
{query.isSuccess && emails.map((e) => (
<div
key={e.id}
className={`inbox-item${e.unread ? ' unread' : ''}${selectedId === e.id ? ' active' : ''}`}
onClick={() => selectEmail(e)}
>
<RowCheck
checked={selection.selectedIds.has(e.id)}
onToggle={() => selection.toggle(e.id)}
label={`Select mail from ${e.from}`}
/>
<Avatar name={e.from} />
<div className="ii-main">
<div className="ii-name">{e.from}</div>
<div className="ii-pos">{e.subject}</div>
<div className="ii-meta">
<span className="source-chip" style={{ '--chip': '#0078d4' }}>
<Icon name="mail" />Outlook
</span>
{isImported(e) && <Badge className="b-green">Imported</Badge>}
</div>
</div>
<div className="ii-time">{outlookListTime(e.when)}</div>
</div>
))}
</div>
<div className="split-detail">
{!selected ? (
<div style={{ padding: '100px 20px' }}>
<EmptyState icon="mail" title="Select an email">
Preview email body and resume attachments here.
</EmptyState>
</div>
) : (
<div style={{ padding: 24 }}>
<div className="flex items-center gap-12" style={{ marginBottom: 20 }}>
<Avatar name={selected.from} />
<div>
<div className="fw-600">{selected.from}</div>
<div className="cell-sub">
{selected.fromEmail} · {selected.when ? fmtDate(selected.when) : 'Date unavailable'}
</div>
</div>
</div>
{/* Same Subject-strip + framed-body template as /matching. It replaces
the old <h2> subject rather than sitting under it — two subject
lines on one panel is worse than none. The Imported/New badge
moves into the strip, which is where a mail client puts status. */}
<div style={{ marginBottom: 18 }}>
<div className="email-head flex items-center gap-12" style={{ justifyContent: 'space-between' }}>
<span>Subject: {selected.subject || '(no subject)'}</span>
{isImported(selected) ? <Badge className="b-green">Imported</Badge> : <Badge className="b-blue">New</Badge>}
</div>
{looksLikeHtml(selected.body) ? (
<EmailBody html={selected.body} />
) : (
<pre className="resume-thumb is-full email-plain">
{htmlToText(selected.body) || 'This email has no message body.'}
</pre>
)}
</div>
<div className="attach-card" style={{ marginBottom: 18 }}>
<span className="attach-icn"><Icon name="file" /></span>
<div style={{ flex: 1 }}>
<div className="fw-600">{selected.attachment}</div>
<div className="cell-sub">{selected.attachmentSize} · PDF</div>
</div>
</div>
<div className="flex gap-8">
{isImported(selected) ? (
<button className="btn btn-secondary" disabled><Icon name="check" /> Already Imported</button>
) : (
<button
className="btn btn-primary"
disabled={importMsg.isPending}
onClick={() => importMsg.mutate(selected.id)}
>
<Icon name="user-plus" /> Import Candidate
</button>
)}
<button className="btn btn-secondary" onClick={() => { setReplying(selected); setReplyBody('') }}>
<Icon name="mail" /> Reply
</button>
</div>
</div>
)}
</div>
</div>
{replying && (
<Modal
title="Reply"
subtitle={`Re: ${replying.subject || '(no subject)'}`}
onClose={() => setReplying(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setReplying(null)} disabled={reply.isPending}>Cancel</button>
<button
className="btn btn-primary"
disabled={reply.isPending || !replyBody.trim()}
onClick={() => reply.mutate({ recordId: replying.id, body: replyBody.trim() })}
>
<Icon name="send" /> {reply.isPending ? 'Sending…' : 'Send Reply'}
</button>
</>
}
>
<div className="form-field">
<label>To</label>
<input value={replying.fromEmail || ''} disabled />
</div>
<div className="form-field">
<label>Message</label>
<textarea
rows={6}
value={replyBody}
onChange={(e) => setReplyBody(e.target.value)}
placeholder="Write your reply…"
/>
</div>
</Modal>
)}
</>
)
}