2143 lines
84 KiB
JavaScript
2143 lines
84 KiB
JavaScript
/* ============================================================
|
||
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 (
|
||
<span className="source-chip" style={{ '--chip': item.sourceMeta?.color }} title={source}>
|
||
<span className="source-dot" />
|
||
<span className="source-chip-label">{label}</span>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Graph delivers the body as text/html, so rendering it verbatim as text — which
|
||
* is what keeps it XSS-safe — prints the raw markup at the user.
|
||
*
|
||
* DOMParser builds a DETACHED document: it is never adopted into the live DOM, so
|
||
* scripts do not run and <img onerror> never fires. Reading textContent off it is
|
||
* therefore both safe and readable, and needs no dangerouslySetInnerHTML.
|
||
*/
|
||
function htmlToText(value) {
|
||
const raw = (value || '').trim()
|
||
if (!raw) return ''
|
||
if (!/<[a-z!/]/i.test(raw)) return raw // already plain text
|
||
// textContent ignores block boundaries, so <p>a</p><p>b</p> would collapse to
|
||
// "ab". Turn breaks and closing block tags into newlines BEFORE parsing.
|
||
const withBreaks = raw
|
||
.replace(/<br\s*\/?>/gi, '\n')
|
||
.replace(/<\/(p|div|li|tr|h[1-6]|blockquote|table)\s*>/gi, '\n')
|
||
const doc = new DOMParser().parseFromString(withBreaks, 'text/html')
|
||
doc.querySelectorAll('script, style, head').forEach((n) => n.remove())
|
||
return (doc.body?.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
|
||
}
|
||
|
||
/** match_status -> the resume badge, mirroring _RESUME_STATUS in inbox/serializers.py. */
|
||
const RESUME_STATUS = {
|
||
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
|
||
failed: 'Failed', dlq: 'Failed', skipped: 'Pending',
|
||
}
|
||
|
||
const SHORTLIST_JOB_WARNING = 'Choose a matching job above to add this candidate to the shortlist.'
|
||
|
||
/**
|
||
* GET /inbox/fetch?record_id=<pk> -> the detail behind one application row.
|
||
*
|
||
* Returns serialize_message, a different shape from serialize_application, so it
|
||
* is remapped onto the row shape here and OVERLAID on the list row rather than
|
||
* replacing it: serialize_message carries the body, decoded attachments, and
|
||
* the hydrated suggested_job_posts / assigned_job_post the role picker needs.
|
||
*/
|
||
async function fetchMessageDetail(recordId) {
|
||
const res = await inboxApi.getMessage(recordId)
|
||
const row = res?.data
|
||
if (!row) return null
|
||
const name = row.sender_name || row.fromEmail || 'Unknown'
|
||
return {
|
||
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 (
|
||
<span
|
||
className={`checkbox${checked ? ' on' : ''}`}
|
||
role="checkbox"
|
||
aria-checked={checked}
|
||
aria-label={label}
|
||
tabIndex={0}
|
||
style={{ alignSelf: 'center' }}
|
||
onClick={(e) => { e.stopPropagation(); onToggle() }}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
onToggle()
|
||
}
|
||
}}
|
||
>
|
||
<Icon name="check" />
|
||
</span>
|
||
)
|
||
}
|
||
|
||
const READ_TICK_MS = 1000
|
||
|
||
/**
|
||
* Select-all plus the read/unread actions for one list.
|
||
*
|
||
* The leading ✓ is not a mode indicator. It only appears on the Mark read /
|
||
* Mark all read button that was just clicked, then clears from both after 1s.
|
||
*
|
||
* `rows` rather than a count, because the bar needs each row's read state, and
|
||
* `selectedIds` narrows it to what the selected pair of buttons would touch.
|
||
*/
|
||
function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, scopeLabel, showAll, onToggleShowAll, tabTotal }) {
|
||
const { selectedIds, toggleAll, clear, allSelected } = selection
|
||
const n = selectedIds.size
|
||
const total = rows.length
|
||
|
||
// What the visible pair of buttons acts on: the ticked rows when there is a
|
||
// selection, the whole view otherwise.
|
||
const target = n > 0 ? rows.filter((r) => selectedIds.has(r.id)) : rows
|
||
const unreadCount = target.reduce((acc, r) => acc + (r.unread ? 1 : 0), 0)
|
||
const readCount = target.length - unreadCount
|
||
|
||
const blocked = !canEdit || busy
|
||
const tip = !canEdit ? 'Requires inbox.edit' : undefined
|
||
const scopeTip = !canEdit ? 'Requires inbox.edit' : `Applies to ${scopeLabel}`
|
||
const nothingTo = (word) => `Nothing to mark ${word} here`
|
||
|
||
// `selected` = Mark read, `all` = Mark all read. Never both at once in the
|
||
// bar, but the flash still resets both so a leftover tick cannot linger
|
||
// after the selection-vs-all swap.
|
||
const [ticked, setTicked] = useState(null)
|
||
const tickTimer = useRef(null)
|
||
|
||
useEffect(() => () => {
|
||
if (tickTimer.current) clearTimeout(tickTimer.current)
|
||
}, [])
|
||
|
||
function flashRead(which, action) {
|
||
if (tickTimer.current) clearTimeout(tickTimer.current)
|
||
setTicked(which)
|
||
action()
|
||
tickTimer.current = setTimeout(() => {
|
||
setTicked(null)
|
||
tickTimer.current = null
|
||
}, READ_TICK_MS)
|
||
}
|
||
|
||
return (
|
||
<div className="inbox-bulk-bar">
|
||
<RowCheck
|
||
checked={allSelected}
|
||
onToggle={toggleAll}
|
||
label={allSelected ? 'Clear selection' : 'Select all'}
|
||
/>
|
||
<span className="inbox-bulk-count" title={n > 0
|
||
? `${n} selected`
|
||
: `${total} ${total === 1 ? 'message' : 'messages'}${unreadCount ? ` · ${unreadCount} unread` : ''}`}
|
||
>
|
||
{n > 0
|
||
? `${n} selected`
|
||
: `${total}${unreadCount ? ` · ${unreadCount}` : ''}`}
|
||
</span>
|
||
{/* Same switch as the Per-page "All" entry below, surfaced at the top of
|
||
the queue where the eye actually is. */}
|
||
{onToggleShowAll && n === 0 && (
|
||
<button
|
||
className={`btn btn-sm ${showAll ? 'btn-primary' : 'btn-secondary'}`}
|
||
title={showAll ? 'Back to paged view' : `Show all ${tabTotal || ''} in one list`}
|
||
onClick={onToggleShowAll}
|
||
>
|
||
{showAll ? 'Show paged' : `Show all${tabTotal ? ` (${tabTotal})` : ''}`}
|
||
</button>
|
||
)}
|
||
<div className="inbox-bulk-actions">
|
||
{n > 0 ? (
|
||
<>
|
||
<button
|
||
className="btn btn-secondary btn-sm"
|
||
disabled={blocked || unreadCount === 0}
|
||
title={unreadCount === 0 ? nothingTo('read') : tip || 'Mark selected read'}
|
||
onClick={() => flashRead('selected', () => onSetRead(true))}
|
||
>
|
||
{ticked === 'selected' && <Icon name="check" />}
|
||
Mark as Read
|
||
</button>
|
||
<button
|
||
className="btn btn-secondary btn-sm"
|
||
disabled={blocked || readCount === 0}
|
||
title={readCount === 0 ? nothingTo('unread') : tip || 'Mark selected unread'}
|
||
onClick={() => onSetRead(false)}
|
||
>
|
||
Mark as Unread
|
||
</button>
|
||
<button className="btn btn-ghost btn-sm" onClick={clear}>Clear</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button
|
||
className="btn btn-secondary btn-sm"
|
||
disabled={blocked || unreadCount === 0}
|
||
title={unreadCount === 0 ? nothingTo('read') : scopeTip}
|
||
onClick={() => flashRead('all', () => onSetAllRead(true))}
|
||
>
|
||
{ticked === 'all' && <Icon name="check" />}
|
||
Mark all Read
|
||
</button>
|
||
<button
|
||
className="btn btn-secondary btn-sm"
|
||
disabled={blocked || readCount === 0}
|
||
title={readCount === 0 ? nothingTo('unread') : scopeTip}
|
||
onClick={() => onSetAllRead(false)}
|
||
>
|
||
Mark all Unread
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className="page">
|
||
<PageHeader
|
||
title="Recruitment Inbox"
|
||
sub={
|
||
isAllChannel
|
||
? 'Email and Sheet Forms together — one combined stream'
|
||
: isForms
|
||
? 'Google Form applicants — same queue energy, profile-first cards'
|
||
: 'Every candidate, every source — one unified queue'
|
||
}
|
||
actions={<>
|
||
<div className="pill-tabs" role="tablist" aria-label="Inbox channel">
|
||
{CHANNELS.map((c) => (
|
||
<button
|
||
key={c.key}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={channel === c.key}
|
||
className={`pill-tab${channel === c.key ? ' active' : ''}`}
|
||
onClick={() => switchChannel(c.key)}
|
||
>
|
||
<Icon name={c.icon} /> {c.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{!isForms && (
|
||
<SyncButton
|
||
run={syncRun.data}
|
||
pending={sync.isPending}
|
||
disabled={!canEdit}
|
||
onClick={() => sync.mutate()}
|
||
/>
|
||
)}
|
||
<button className="btn btn-primary" onClick={() => navigate('/import')}>
|
||
<Icon name="upload" /> Upload CVs
|
||
</button>
|
||
</>}
|
||
/>
|
||
|
||
<div className="card">
|
||
<div style={{ margin: '0 16px', paddingTop: 8 }}>
|
||
<Tabs
|
||
className="tabs tabs-wrap"
|
||
value={tab}
|
||
onChange={(t) => {
|
||
setTab(t)
|
||
setSkip(0)
|
||
setSelectedId(null)
|
||
selection.clear()
|
||
}}
|
||
tabs={channelTabs.map((t) => ({ key: t, label: t, count: counts[t] }))}
|
||
/>
|
||
</div>
|
||
|
||
<div className="split inbox-split">
|
||
<div className="split-list inbox-queue">
|
||
{!isForms && applicationsQuery.isSuccess && (
|
||
<BulkReadBar
|
||
rows={list}
|
||
selection={selection}
|
||
onSetRead={setReadSelected}
|
||
onSetAllRead={setReadEverything}
|
||
busy={setRead.isPending || setReadAll.isPending}
|
||
canEdit={canEdit}
|
||
scopeLabel={scopeLabel}
|
||
showAll={showAll}
|
||
tabTotal={total}
|
||
onToggleShowAll={() => {
|
||
setPageSize(showAll ? DEFAULT_PAGE_SIZE : 'all')
|
||
setSkip(0)
|
||
setSelectedId(null)
|
||
selection.clear()
|
||
}}
|
||
/>
|
||
)}
|
||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
|
||
{isForms && (
|
||
<div style={{ marginBottom: 10 }}>
|
||
<label className="cell-sub" htmlFor="inbox-form-sheet" style={{ display: 'block', marginBottom: 4 }}>
|
||
Sheet tab
|
||
</label>
|
||
<select
|
||
id="inbox-form-sheet"
|
||
value={formSheet}
|
||
onChange={(e) => {
|
||
setFormSheet(e.target.value)
|
||
setSkip(0)
|
||
setSelectedId(null)
|
||
}}
|
||
style={{ width: '100%' }}
|
||
>
|
||
{formSheetOptions.map((name) => (
|
||
<option key={name} value={name}>{name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
<div className="toolbar-search" style={{ maxWidth: 'none' }}>
|
||
<Icon name="search" />
|
||
<input
|
||
value={q}
|
||
onChange={(e) => { setQ(e.target.value); setSkip(0) }}
|
||
placeholder={isForms ? 'Search name, email, position, city…' : 'Search applications…'}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
{activeQuery.isPending && (
|
||
<SkeletonRows rows={6} />
|
||
)}
|
||
{activeQuery.isError && (
|
||
<EmptyState icon="inbox" title={isForms ? "Couldn't load form applicants" : "Couldn't load applications"}>
|
||
{friendlyAuthError(activeQuery.error, 'Request failed')}
|
||
</EmptyState>
|
||
)}
|
||
{activeQuery.isSuccess && list.length === 0 ? (
|
||
<EmptyState icon="inbox" title="Nothing here">
|
||
{isForms ? 'No form responses in this sheet tab.' : 'No applications in this view.'}
|
||
</EmptyState>
|
||
) : (
|
||
list.map((i) => (
|
||
<div
|
||
key={i.id}
|
||
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
|
||
onClick={() => select(i.id)}
|
||
>
|
||
{i.kind !== 'form' && (
|
||
<RowCheck
|
||
checked={selection.selectedIds.has(i.id)}
|
||
onToggle={() => selection.toggle(i.id)}
|
||
label={`Select ${i.name}`}
|
||
/>
|
||
)}
|
||
<Avatar name={i.name} initials={i.initials} color={i.color} />
|
||
<div className="ii-main">
|
||
<div className="ii-name">
|
||
<span className="truncate min-w-0" title={i.name}>{i.name}</span>
|
||
{i.duplicate && (
|
||
<span className="badge b-red badge-plain" style={{ padding: '1px 6px', fontSize: 10 }}>DUP</span>
|
||
)}
|
||
</div>
|
||
<div className="ii-pos">{i.position}</div>
|
||
<div className="ii-aside">
|
||
<div className="ii-time">
|
||
{outlookListTime(i.received)}
|
||
</div>
|
||
{i.atsScore != null && (
|
||
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
|
||
)}
|
||
{i.kind === 'form' && i.noticePeriod && (
|
||
<div className="cell-sub" style={{ marginTop: 6 }}>{i.noticePeriod}</div>
|
||
)}
|
||
</div>
|
||
<div className="ii-meta">
|
||
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
|
||
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
|
||
<Badge>{i.applicationStatus}</Badge>
|
||
)}
|
||
{i.kind === 'form' && i.residingCity && (
|
||
<span className="cell-sub">{i.residingCity}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
{activeQuery.isSuccess && total > 0 && (
|
||
<Pagination
|
||
from={from}
|
||
to={to}
|
||
total={total}
|
||
page={currentPage}
|
||
pages={pages}
|
||
setPage={(p) => { if (showAll) return; setSkip((p - 1) * pageSize); setSelectedId(null); selection.clear() }}
|
||
pageButtons={pageWindow(currentPage, pages)}
|
||
pageSize={pageSize}
|
||
pageSizeMax={PAGE_SIZE_MAX}
|
||
allowAll
|
||
onPageSizeChange={(n) => {
|
||
setPageSize(n)
|
||
if (n === 'all') setSkip(0) // numeric sizes keep their page (clamp effect above)
|
||
setSelectedId(null)
|
||
selection.clear()
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<div className="split-detail">
|
||
{!selected ? (
|
||
<div style={{ padding: '100px 20px' }}>
|
||
<EmptyState icon="inbox" title="Select an application">
|
||
{isForms
|
||
? 'Pick a form applicant to see their profile, resume, and screening notes.'
|
||
: 'Choose an item from the list to view details and take action.'}
|
||
</EmptyState>
|
||
</div>
|
||
) : detailQuery.isError ? (
|
||
<div style={{ padding: '100px 20px' }}>
|
||
<EmptyState icon="inbox" title="Couldn't load this application">
|
||
{friendlyAuthError(detailQuery.error, 'Request failed')}
|
||
</EmptyState>
|
||
</div>
|
||
) : selected?.kind === 'form' ? (
|
||
<FormApplicantDetail
|
||
item={selected}
|
||
loading={detailQuery.isPending}
|
||
busy={setState.isPending || markDuplicate.isPending}
|
||
canEdit={canEdit}
|
||
toast={toast}
|
||
onImport={() => importItem(selected)}
|
||
onMove={() => moveToPipeline(selected)}
|
||
onNote={() => setNoting(selected)}
|
||
onReject={() => reject(selected)}
|
||
onToggleDuplicate={() => toggleDuplicate(selected)}
|
||
/>
|
||
) : (
|
||
<ApplicationDetail
|
||
item={selected}
|
||
loading={detailQuery.isPending}
|
||
busy={setState.isPending || markDuplicate.isPending}
|
||
canEdit={canEdit}
|
||
toast={toast}
|
||
onImport={() => importItem(selected)}
|
||
onMove={() => moveToPipeline(selected)}
|
||
onNote={() => setNoting(selected)}
|
||
onReject={() => reject(selected)}
|
||
onToggleDuplicate={() => toggleDuplicate(selected)}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{assigning && (
|
||
<AssignRecruiter
|
||
item={assigning}
|
||
recruiters={recruiters}
|
||
onClose={() => setAssigning(null)}
|
||
onSave={(name) => {
|
||
updateInbox((items) => items.map((i) => (i.id === assigning.id ? { ...i, recruiter: name } : i)))
|
||
setAssigning(null)
|
||
toast(`Recruiter assigned to ${assigning.name}`, 'success')
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{noting && (
|
||
<Modal
|
||
title="Add Note"
|
||
subtitle={noting.name}
|
||
onClose={() => setNoting(null)}
|
||
footer={
|
||
<>
|
||
<button className="btn btn-secondary" onClick={() => setNoting(null)}>Cancel</button>
|
||
<button className="btn btn-primary" onClick={() => { setNoting(null); toast('Note added', 'success') }}>
|
||
<Icon name="check" /> Save Note
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="form-field">
|
||
<label>Note</label>
|
||
<textarea placeholder="Add a note about this application…" />
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** Fields inbox_messages has no column for come back null; show a dash, not "null". */
|
||
function orDash(value, suffix = '') {
|
||
return value == null || value === '' ? '—' : `${value}${suffix}`
|
||
}
|
||
|
||
function externalHref(url) {
|
||
const raw = (url || '').trim()
|
||
if (!raw) return null
|
||
if (/^https?:\/\//i.test(raw)) return raw
|
||
return `https://${raw}`
|
||
}
|
||
|
||
/** Persistable /in/<slug> → a public profile URL. Empty string means scanned, none found. */
|
||
function linkedinHrefFromSlug(slug) {
|
||
const cleaned = (slug || '').trim()
|
||
if (!cleaned) return null
|
||
return `https://www.linkedin.com/in/${cleaned}`
|
||
}
|
||
|
||
function firstResumeKey(item) {
|
||
const fromFiles = (item?.files || []).map((f) => f.url).find(Boolean)
|
||
if (fromFiles) return fromFiles
|
||
return s3Api.firstKey(item?.filePath)
|
||
}
|
||
|
||
/**
|
||
* Sheet form applicant detail — profile grids + resume/LinkedIn links +
|
||
* title-matched job selection (position_applied_for ↔ job_posts.title) +
|
||
* the same Import / Shortlist / Duplicate / Reject actions as email.
|
||
*/
|
||
function FormApplicantDetail({
|
||
item: i, loading, busy, canEdit, toast,
|
||
onImport, onMove, onNote, onReject, onToggleDuplicate,
|
||
}) {
|
||
const qc = useQueryClient()
|
||
const resumeHref = externalHref(i.resumeLink)
|
||
const profileHref = externalHref(i.profileLink)
|
||
const location = [i.residingCity, i.residingCountry].filter(Boolean).join(', ')
|
||
const education = [i.degree, i.university].filter(Boolean).join(' · ')
|
||
|
||
const [selection, setSelection] = useState(null)
|
||
const [manualPost, setManualPost] = useState(null)
|
||
const [showPicker, setShowPicker] = useState(false)
|
||
|
||
useEffect(() => {
|
||
setManualPost(null)
|
||
setSelection(i.assignedId || null)
|
||
}, [i.id, i.assignedId])
|
||
|
||
const matchCards = useMemo(() => {
|
||
return (i.jobPosts || []).map((post, idx) => ({
|
||
rank: idx + 1,
|
||
post,
|
||
}))
|
||
}, [i.jobPosts])
|
||
|
||
const selectedPost = useMemo(() => {
|
||
if (!selection) return null
|
||
if (manualPost && String(manualPost.id) === String(selection)) return manualPost
|
||
if (i.assignedPost && String(i.assignedPost.id) === String(selection)) return i.assignedPost
|
||
const hit = matchCards.find((c) => String(c.post.id) === String(selection))
|
||
return hit?.post || null
|
||
}, [selection, manualPost, i.assignedPost, matchCards])
|
||
|
||
const assignMutation = useMutation({
|
||
mutationFn: ({ recordId, jobPostId }) => sheetApi.assignJobPost(recordId, jobPostId),
|
||
onError: (err) => toast(friendlyAuthError(err, 'Could not assign job post.'), 'error'),
|
||
onSuccess: (_res, vars) => {
|
||
const title = selectedPost?.title || 'role'
|
||
if (vars.jobPostId) toast(`${i.name} → ${title}`, 'success')
|
||
else toast(`${i.name} unassigned`, 'success')
|
||
},
|
||
onSettled: (_res, _err, vars) => {
|
||
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
||
qc.invalidateQueries({ queryKey: qk.mailbox.formRow(vars.recordId) })
|
||
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
|
||
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||
qc.invalidateQueries({ queryKey: qk.analytics.all() })
|
||
},
|
||
})
|
||
|
||
const assigned = i.assignedPost
|
||
const canAssign = canEdit && selection && selection !== i.assignedId && !assignMutation.isPending
|
||
const jobChosen = Boolean(i.assignedId || selection)
|
||
const alreadyProcessed = i.processing === 'Processed'
|
||
const shortlistLocked = !alreadyProcessed && !jobChosen
|
||
const panelBusy = busy || assignMutation.isPending
|
||
|
||
async function handleMove() {
|
||
if (busy || alreadyProcessed) return
|
||
if (!jobChosen) {
|
||
toast(SHORTLIST_JOB_WARNING, 'warning')
|
||
return
|
||
}
|
||
const jobId = selection || i.assignedId
|
||
if (jobId && String(jobId) !== String(i.assignedId || '')) {
|
||
try {
|
||
await assignMutation.mutateAsync({ recordId: i.id, jobPostId: jobId })
|
||
} catch {
|
||
return
|
||
}
|
||
}
|
||
onMove()
|
||
}
|
||
|
||
return (
|
||
<div style={{ padding: 24 }}>
|
||
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
|
||
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
|
||
<div className="ph-role">{i.position}</div>
|
||
<div className="ph-tags" style={{ marginTop: 8 }}>
|
||
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
|
||
{i.duplicate && <><Badge className="b-red">Duplicate</Badge>{' '}</>}
|
||
{i.hoAvailability && (
|
||
<Badge className={String(i.hoAvailability).toLowerCase() === 'yes' ? 'b-green' : 'b-amber'}>
|
||
Relocate: {i.hoAvailability}
|
||
</Badge>
|
||
)}{' '}
|
||
{loading && <span className="cell-sub">Loading details…</span>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{(resumeHref || profileHref) && (
|
||
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
|
||
{resumeHref && (
|
||
s3Api.canOpen(resumeHref) ? (
|
||
<OpenResumeButton filePath={resumeHref} />
|
||
) : (
|
||
<a className="btn btn-primary btn-sm" href={resumeHref} target="_blank" rel="noopener noreferrer">
|
||
<Icon name="paperclip" /> Open resume
|
||
</a>
|
||
)
|
||
)}
|
||
{profileHref && (
|
||
<a className="btn btn-secondary btn-sm" href={profileHref} target="_blank" rel="noopener noreferrer">
|
||
<Icon name="linkedin" /> LinkedIn
|
||
</a>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{assigned && (
|
||
<div
|
||
className="card"
|
||
style={{
|
||
boxShadow: 'none',
|
||
background: 'var(--primary-soft)',
|
||
border: '1px solid var(--primary-border)',
|
||
marginBottom: 18,
|
||
}}
|
||
>
|
||
<div className="card-body flex items-center gap-12" style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||
<div className="flex items-center gap-8">
|
||
<Icon name="check-circle" />
|
||
<div>
|
||
<div>Assigned to <b>{assigned.title}</b></div>
|
||
<div className="cell-sub">
|
||
{[assigned.employment_type, assigned.location].filter(Boolean).join(' · ') || '—'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-8">
|
||
<button
|
||
className="btn btn-secondary btn-sm"
|
||
disabled={!canEdit}
|
||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||
onClick={() => setShowPicker(true)}
|
||
>
|
||
Change
|
||
</button>
|
||
<button
|
||
className="btn btn-ghost btn-sm"
|
||
disabled={!canEdit || assignMutation.isPending}
|
||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||
onClick={() => assignMutation.mutate({ recordId: i.id, jobPostId: null })}
|
||
>
|
||
Unassign
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: 18,
|
||
alignItems: 'start',
|
||
marginBottom: 20,
|
||
}}
|
||
>
|
||
<div style={{ flex: '1 1 320px', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||
<div className="card" style={{ boxShadow: 'none' }}>
|
||
<div className="card-head"><h3>Contact & application</h3></div>
|
||
<div className="card-body">
|
||
<div className="info-grid">
|
||
<div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
|
||
<div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
|
||
<div className="info-item"><div className="il">Applied</div><div className="iv">{i.received ? fmtDate(i.received) : '—'}</div></div>
|
||
<div className="info-item"><div className="il">Source</div><div className="iv">{orDash(i.source)}</div></div>
|
||
<div className="info-item"><div className="il">Screened by</div><div className="iv">{orDash(i.screenedBy)}</div></div>
|
||
<div className="info-item"><div className="il">Notice period</div><div className="iv">{orDash(i.noticePeriod)}</div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card" style={{ boxShadow: 'none' }}>
|
||
<div className="card-head"><h3>Profile</h3></div>
|
||
<div className="card-body">
|
||
<div className="info-grid">
|
||
<div className="info-item"><div className="il">Gender</div><div className="iv">{orDash(i.gender)}</div></div>
|
||
<div className="info-item"><div className="il">Date of birth</div><div className="iv">{i.dateOfBirth ? fmtDate(i.dateOfBirth) : '—'}</div></div>
|
||
<div className="info-item"><div className="il">CNIC</div><div className="iv">{orDash(i.cnic)}</div></div>
|
||
<div className="info-item"><div className="il">Marital status</div><div className="iv">{orDash(i.maritalStatus)}</div></div>
|
||
<div className="info-item"><div className="il">Location</div><div className="iv">{orDash(location)}</div></div>
|
||
<div className="info-item"><div className="il">Education</div><div className="iv">{orDash(education)}</div></div>
|
||
<div className="info-item"><div className="il">Graduation</div><div className="iv">{orDash(i.graduationYear)}</div></div>
|
||
<div className="info-item"><div className="il">Other university</div><div className="iv">{orDash(i.universityOther)}</div></div>
|
||
<div className="info-item"><div className="il">Current salary</div><div className="iv">{orDash(i.currentSalary)}</div></div>
|
||
<div className="info-item"><div className="il">Expected salary</div><div className="iv">{orDash(i.expectedSalary)}</div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{i.hrComments && (
|
||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
|
||
<div className="card-body">
|
||
<div className="fw-600" style={{ marginBottom: 6 }}>HR comment</div>
|
||
<p style={{ margin: 0 }}>{i.hrComments}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
role="radiogroup"
|
||
aria-label="Matching roles"
|
||
className="card"
|
||
style={{ flex: '1 1 300px', minWidth: 260, boxShadow: 'none', alignSelf: 'start' }}
|
||
>
|
||
<div className="card-head">
|
||
<div>
|
||
<h3>Matching roles</h3>
|
||
<span className="ch-sub">Matched by position: {orDash(i.position)}</span>
|
||
</div>
|
||
</div>
|
||
<div className="card-body">
|
||
{matchCards.length === 0 && !manualPost ? (
|
||
<EmptyState icon="target" title="No matching roles">
|
||
<p>No job post title matches this position — pick one manually.</p>
|
||
</EmptyState>
|
||
) : (
|
||
<>
|
||
{matchCards.map(({ rank, post }) => (
|
||
<JobCard
|
||
key={post.id}
|
||
post={post}
|
||
rank={rank}
|
||
badge={`Match #${rank}`}
|
||
selected={String(selection) === String(post.id)}
|
||
onSelect={(id) => setSelection(String(id))}
|
||
/>
|
||
))}
|
||
{manualPost && (
|
||
<JobCard
|
||
post={manualPost}
|
||
rank={0}
|
||
manual
|
||
selected={String(selection) === String(manualPost.id)}
|
||
onSelect={(id) => setSelection(String(id))}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginTop: 12 }}>
|
||
<button
|
||
className={`btn ${matchCards.length === 0 && !manualPost ? 'btn-primary' : 'btn-secondary'}`}
|
||
style={{ flex: '1 1 auto' }}
|
||
disabled={!canEdit}
|
||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||
onClick={() => setShowPicker(true)}
|
||
>
|
||
<Icon name="search" /> Browse roles…
|
||
</button>
|
||
{/* Assign only renders once there is a selection to act on —
|
||
a permanently disabled primary button read as broken UI. */}
|
||
{selection && selection !== i.assignedId && (
|
||
<button
|
||
className="btn btn-primary"
|
||
style={{ flex: '1 1 auto' }}
|
||
disabled={!canAssign}
|
||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||
onClick={() => {
|
||
if (!selection || !canEdit) return
|
||
assignMutation.mutate({ recordId: i.id, jobPostId: selection })
|
||
}}
|
||
>
|
||
<Icon name="check" /> Assign{selectedPost?.title ? ` — ${selectedPost.title}` : ''}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{i.sheet && (
|
||
<div className="cell-sub" style={{ marginBottom: 14 }}>
|
||
Imported from {i.sheet}{i.rowNumber != null ? ` · row ${i.rowNumber}` : ''}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-8" style={{ flexWrap: 'wrap', borderTop: '1px solid var(--border)', paddingTop: 16 }}>
|
||
<button className="btn btn-primary" onClick={onImport} disabled={panelBusy || i.processing === 'Imported'}>
|
||
<Icon name="user-plus" /> {i.processing === 'Imported' ? 'Imported' : 'Import Candidate'}
|
||
</button>
|
||
<button
|
||
className="btn btn-secondary"
|
||
aria-disabled={shortlistLocked || panelBusy || alreadyProcessed}
|
||
disabled={panelBusy || alreadyProcessed}
|
||
style={shortlistLocked ? { opacity: 0.55, cursor: 'not-allowed' } : undefined}
|
||
title={shortlistLocked ? SHORTLIST_JOB_WARNING : undefined}
|
||
onClick={handleMove}
|
||
>
|
||
<Icon name="layers" /> Move to Shortlist
|
||
</button>
|
||
<button className="btn btn-secondary" onClick={onNote} disabled title="Notes attach to a candidate profile — open the candidate first">
|
||
<Icon name="edit" /> Add Note
|
||
</button>
|
||
<button className="btn btn-secondary" onClick={onToggleDuplicate} disabled={panelBusy}>
|
||
<Icon name="alert" /> {i.duplicate ? 'Clear Duplicate' : 'Mark Duplicate'}
|
||
</button>
|
||
<button
|
||
className="btn btn-ghost"
|
||
style={{ color: 'var(--danger)' }}
|
||
onClick={onReject}
|
||
disabled={panelBusy || i.processing === 'Rejected'}
|
||
>
|
||
<Icon name="trash" /> Reject
|
||
</button>
|
||
</div>
|
||
|
||
{showPicker && (
|
||
<PickRoleModal
|
||
onClose={() => setShowPicker(false)}
|
||
onPick={(post) => {
|
||
setManualPost(post)
|
||
setSelection(String(post.id))
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ApplicationDetail({
|
||
item: i, loading, busy, canEdit, toast, onImport, onMove, onNote, onReject, onToggleDuplicate,
|
||
}) {
|
||
const qc = useQueryClient()
|
||
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'
|
||
const ringColor = i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)'
|
||
|
||
const [selection, setSelection] = useState(null)
|
||
const [manualPost, setManualPost] = useState(null)
|
||
const [showPicker, setShowPicker] = useState(false)
|
||
const [whyOpen, setWhyOpen] = useState(false)
|
||
|
||
// Do not auto-pick the first suggestion: Shortlist stays locked until the
|
||
// recruiter actually chooses a card (or the row already has an assigned job).
|
||
useEffect(() => {
|
||
setManualPost(null)
|
||
setWhyOpen(false)
|
||
setSelection(i.assignedId || null)
|
||
}, [i.id, i.assignedId])
|
||
|
||
const suggestionCards = useMemo(() => {
|
||
const byId = new Map((i.suggestedPosts || []).map((p) => [String(p.id), p]))
|
||
return (i.suggestedIds || []).map((id, idx) => ({
|
||
rank: idx + 1,
|
||
post: byId.get(id) || { id, unavailable: true },
|
||
}))
|
||
}, [i.suggestedPosts, i.suggestedIds])
|
||
|
||
const selectedPost = useMemo(() => {
|
||
if (!selection) return null
|
||
if (manualPost && String(manualPost.id) === String(selection)) return manualPost
|
||
if (i.assignedPost && String(i.assignedPost.id) === String(selection)) return i.assignedPost
|
||
const hit = suggestionCards.find((c) => String(c.post.id) === String(selection))
|
||
return hit?.post || null
|
||
}, [selection, manualPost, i.assignedPost, suggestionCards])
|
||
|
||
const assignMutation = useMutation({
|
||
mutationFn: ({ recordId, jobPostId }) => inboxApi.assignJobPost(recordId, jobPostId),
|
||
onError: (err) => toast(friendlyAuthError(err, 'Could not assign job post.'), 'error'),
|
||
onSuccess: (_res, vars) => {
|
||
const title = selectedPost?.title || 'role'
|
||
if (vars.jobPostId) toast(`${i.name} → ${title}`, 'success')
|
||
else toast(`${i.name} unassigned`, 'success')
|
||
},
|
||
onSettled: (_res, _err, vars) => {
|
||
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
||
qc.invalidateQueries({ queryKey: qk.mailbox.message(vars.recordId) })
|
||
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
|
||
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||
},
|
||
})
|
||
|
||
const rematchMutation = useMutation({
|
||
mutationFn: (recordId) => inboxApi.rematch(recordId),
|
||
onError: (err) => toast(friendlyAuthError(err, 'Could not retry match.'), 'error'),
|
||
onSuccess: () => toast('Match re-queued', 'success'),
|
||
onSettled: (_r, _e, recordId) => {
|
||
qc.invalidateQueries({ queryKey: qk.mailbox.message(recordId) })
|
||
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
||
},
|
||
})
|
||
|
||
const jobChosen = Boolean(i.assignedId || selection)
|
||
const alreadyProcessed = i.processing === 'Processed'
|
||
const shortlistLocked = !alreadyProcessed && !jobChosen
|
||
const matchFailed = ['failed', 'no_text', 'dlq'].includes(i.matchStatus)
|
||
const resumeText = i.resumeText || ''
|
||
const assigned = i.assignedPost
|
||
const panelBusy = busy || assignMutation.isPending || rematchMutation.isPending
|
||
const canAssign = canEdit && selection && selection !== i.assignedId && !assignMutation.isPending
|
||
const resumeKey = firstResumeKey(i)
|
||
const canOpenResume = s3Api.canOpen(resumeKey)
|
||
const profileHref = i.linkedinUrl || linkedinHrefFromSlug(i.linkedinSlug)
|
||
|
||
const openResume = useMutation({
|
||
mutationFn: async (tab) => {
|
||
try {
|
||
return await s3Api.openPdf(firstResumeKey(i), { tab })
|
||
} catch (err) {
|
||
if (tab && !tab.closed) tab.close()
|
||
throw err
|
||
}
|
||
},
|
||
onError: (err) => toast(friendlyAuthError(err, 'Could not open resume'), 'error'),
|
||
})
|
||
|
||
async function handleMove() {
|
||
if (busy || alreadyProcessed) return
|
||
if (!jobChosen) {
|
||
toast(SHORTLIST_JOB_WARNING, 'warning')
|
||
return
|
||
}
|
||
const jobId = selection || i.assignedId
|
||
if (jobId && String(jobId) !== String(i.assignedId || '')) {
|
||
try {
|
||
await assignMutation.mutateAsync({ recordId: i.id, jobPostId: jobId })
|
||
} catch {
|
||
return
|
||
}
|
||
}
|
||
onMove()
|
||
}
|
||
|
||
return (
|
||
<div style={{ padding: 24 }}>
|
||
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
|
||
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
|
||
<div style={{ flex: 1 }}>
|
||
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
|
||
<div className="ph-role">{i.position}</div>
|
||
<div className="ph-tags" style={{ marginTop: 8 }}>
|
||
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
|
||
{i.duplicate && <><Badge className="b-red">Duplicate</Badge>{' '}</>}
|
||
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
|
||
<><Badge>{i.applicationStatus}</Badge>{' '}</>
|
||
)}
|
||
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
|
||
{i.resumeStatus}
|
||
</Badge>{' '}
|
||
{loading && <span className="cell-sub">Loading details…</span>}
|
||
</div>
|
||
</div>
|
||
{i.atsScore != null && (
|
||
<div style={{ textAlign: 'center' }}>
|
||
<div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}>
|
||
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{i.atsScore}</div></div>
|
||
</div>
|
||
<div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{(resumeKey || i.hasAttachment || profileHref) && (
|
||
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
|
||
{(resumeKey || i.hasAttachment) && (
|
||
<button
|
||
className="btn btn-primary btn-sm"
|
||
disabled={openResume.isPending || !canOpenResume}
|
||
onClick={() => openResume.mutate(window.open('about:blank', '_blank'))}
|
||
>
|
||
<Icon name="paperclip" /> Open resume
|
||
</button>
|
||
)}
|
||
{profileHref && (
|
||
<a className="btn btn-secondary btn-sm" href={profileHref} target="_blank" rel="noopener noreferrer">
|
||
<Icon name="linkedin" /> LinkedIn
|
||
</a>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="info-grid" style={{ marginBottom: 20 }}>
|
||
<div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
|
||
<div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
|
||
<div className="info-item"><div className="il">Experience</div><div className="iv">{orDash(i.experience, ' years')}</div></div>
|
||
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{orDash(i.recruiter)}</div></div>
|
||
<div className="info-item">
|
||
<div className="il">Received</div>
|
||
<div className="iv">{i.received ? fmtDate(i.received) : '—'}</div>
|
||
</div>
|
||
{i.sentAt && (
|
||
<div className="info-item"><div className="il">Sent</div><div className="iv">{fmtDate(i.sentAt)}</div></div>
|
||
)}
|
||
{i.cc && <div className="info-item"><div className="il">CC</div><div className="iv">{i.cc}</div></div>}
|
||
{i.bcc && <div className="info-item"><div className="il">BCC</div><div className="iv">{i.bcc}</div></div>}
|
||
{i.atsScore != null && (
|
||
<div className="info-item">
|
||
<div className="il">Match</div>
|
||
<div className="iv"><Badge className={atsRecommendationClass(recLabel)}>{recLabel}</Badge></div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{assigned && (
|
||
<div
|
||
className="card"
|
||
style={{
|
||
boxShadow: 'none',
|
||
background: 'var(--primary-soft)',
|
||
border: '1px solid var(--primary-border)',
|
||
marginBottom: 18,
|
||
}}
|
||
>
|
||
<div className="card-body flex items-center gap-12" style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||
<div className="flex items-center gap-8">
|
||
<Icon name="check-circle" />
|
||
<div>
|
||
<div>Assigned to <b>{assigned.title}</b></div>
|
||
<div className="cell-sub">
|
||
{[assigned.employment_type, assigned.location].filter(Boolean).join(' · ') || '—'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-8">
|
||
<button
|
||
className="btn btn-secondary btn-sm"
|
||
disabled={!canEdit}
|
||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||
onClick={() => setShowPicker(true)}
|
||
>
|
||
Change
|
||
</button>
|
||
<button
|
||
className="btn btn-ghost btn-sm"
|
||
disabled={!canEdit || assignMutation.isPending}
|
||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||
onClick={() => assignMutation.mutate({ recordId: i.id, jobPostId: null })}
|
||
>
|
||
Unassign
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: 18,
|
||
alignItems: 'start',
|
||
marginBottom: 20,
|
||
}}
|
||
>
|
||
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
|
||
{!loading && (
|
||
<div style={{ marginBottom: 16 }}>
|
||
<div className="email-head">Subject: {i.position || '(no subject)'}</div>
|
||
{looksLikeHtml(i.bodyHtml) ? (
|
||
<EmailBody html={i.bodyHtml} />
|
||
) : (
|
||
<pre className="resume-thumb is-full email-plain">
|
||
{i.body || 'This email has no message body.'}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
|
||
{matchFailed ? (
|
||
<div className="alert alert-danger" style={{ marginBottom: 16 }}>
|
||
<div style={{ marginBottom: 8 }}>{i.matchError || 'Matching failed for this application.'}</div>
|
||
<button
|
||
className="btn btn-secondary btn-sm"
|
||
disabled={!canEdit || rematchMutation.isPending}
|
||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||
onClick={() => rematchMutation.mutate(i.id)}
|
||
>
|
||
<Icon name="sparkles" /> Retry match
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div style={{ marginBottom: 16 }}>
|
||
<div className="fw-600" style={{ marginBottom: 6 }}>AI verdict</div>
|
||
<p style={{ marginBottom: 4 }}>{i.matchSummary || 'No match summary yet.'}</p>
|
||
{i.matchedAt && (
|
||
<div className="cell-sub">Matched {fmtDate(i.matchedAt)}</div>
|
||
)}
|
||
{i.matchReasoning && (
|
||
<button
|
||
className="btn btn-ghost btn-sm"
|
||
style={{ marginTop: 8, paddingLeft: 0 }}
|
||
onClick={() => setWhyOpen((v) => !v)}
|
||
>
|
||
{whyOpen ? '▾' : '▸'} Why these roles?
|
||
</button>
|
||
)}
|
||
{whyOpen && (
|
||
<p className="text-muted text-sm" style={{ marginTop: 8 }}>{i.matchReasoning}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="fw-600" style={{ marginBottom: 8 }}>Suggested roles</div>
|
||
{suggestionCards.length === 0 && !manualPost ? (
|
||
<EmptyState icon="alert" title="No suggested roles">
|
||
<p>No job post was suggested. Choose a role manually.</p>
|
||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap', marginTop: 10 }}>
|
||
<button
|
||
className="btn btn-secondary btn-sm"
|
||
disabled={!canEdit || rematchMutation.isPending}
|
||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||
onClick={() => rematchMutation.mutate(i.id)}
|
||
>
|
||
Retry match
|
||
</button>
|
||
<button
|
||
className="btn btn-primary btn-sm"
|
||
disabled={!canEdit}
|
||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||
onClick={() => setShowPicker(true)}
|
||
>
|
||
Choose a role
|
||
</button>
|
||
</div>
|
||
</EmptyState>
|
||
) : (
|
||
suggestionCards.map(({ rank, post }) => (
|
||
<JobCard
|
||
key={post.id}
|
||
post={post}
|
||
rank={rank}
|
||
selected={String(selection) === String(post.id)}
|
||
onSelect={(id) => setSelection(String(id))}
|
||
resumeText={resumeText}
|
||
/>
|
||
))
|
||
)}
|
||
{manualPost && (
|
||
<JobCard
|
||
post={manualPost}
|
||
rank={0}
|
||
manual
|
||
selected={String(selection) === String(manualPost.id)}
|
||
onSelect={(id) => setSelection(String(id))}
|
||
resumeText={resumeText}
|
||
/>
|
||
)}
|
||
<button
|
||
className="btn btn-secondary"
|
||
style={{ width: '100%', marginTop: 8 }}
|
||
disabled={!canEdit}
|
||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||
onClick={() => setShowPicker(true)}
|
||
>
|
||
Choose a different role…
|
||
</button>
|
||
<button
|
||
className="btn btn-primary"
|
||
style={{ width: '100%', marginTop: 8 }}
|
||
disabled={!canAssign}
|
||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||
onClick={() => {
|
||
if (!selection || !canEdit) return
|
||
assignMutation.mutate({ recordId: i.id, jobPostId: selection })
|
||
}}
|
||
>
|
||
{selectedPost?.title ? `Assign`:'Assign'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-8" style={{ flexWrap: 'wrap', borderTop: '1px solid var(--border)', paddingTop: 16 }}>
|
||
<button className="btn btn-primary" onClick={onImport} disabled={panelBusy || i.processing === 'Imported'}>
|
||
<Icon name="user-plus" /> {i.processing === 'Imported' ? 'Imported' : 'Import Candidate'}
|
||
</button>
|
||
<button
|
||
className="btn btn-secondary"
|
||
aria-disabled={shortlistLocked || panelBusy || alreadyProcessed}
|
||
disabled={panelBusy || alreadyProcessed}
|
||
style={shortlistLocked ? { opacity: 0.55, cursor: 'not-allowed' } : undefined}
|
||
title={shortlistLocked ? SHORTLIST_JOB_WARNING : undefined}
|
||
onClick={handleMove}
|
||
>
|
||
<Icon name="layers" /> Move to Shortlist
|
||
</button>
|
||
<button className="btn btn-secondary" onClick={onNote} disabled title="Notes attach to a candidate profile — open the candidate first">
|
||
<Icon name="edit" /> Add Note
|
||
</button>
|
||
<button className="btn btn-secondary" onClick={onToggleDuplicate} disabled={panelBusy}>
|
||
<Icon name="alert" /> {i.duplicate ? 'Clear Duplicate' : 'Mark Duplicate'}
|
||
</button>
|
||
<button
|
||
className="btn btn-ghost"
|
||
style={{ color: 'var(--danger)' }}
|
||
onClick={onReject}
|
||
disabled={panelBusy || i.processing === 'Rejected'}
|
||
>
|
||
<Icon name="trash" /> Reject
|
||
</button>
|
||
</div>
|
||
|
||
{showPicker && (
|
||
<PickRoleModal
|
||
onClose={() => setShowPicker(false)}
|
||
onPick={(post) => {
|
||
setManualPost(post)
|
||
setSelection(String(post.id))
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function AssignRecruiter({ item, recruiters, onClose, onSave }) {
|
||
const [name, setName] = useState(item.recruiter)
|
||
const current = recruiters.find((r) => r.name === item.recruiter)
|
||
return (
|
||
<Modal
|
||
title="Assign Recruiter"
|
||
subtitle={item.name}
|
||
onClose={onClose}
|
||
footer={
|
||
<>
|
||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||
<button className="btn btn-primary" onClick={() => onSave(name)}>Assign</button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="form-field">
|
||
<label>Recruiter</label>
|
||
<select value={name} onChange={(e) => setName(e.target.value)}>
|
||
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<p className="text-muted text-sm" style={{ marginTop: 10 }}>
|
||
Current workload is factored automatically. This recruiter has {current?.openReqs ?? 5} open reqs.
|
||
</p>
|
||
</Modal>
|
||
)
|
||
}
|