792 lines
34 KiB
JavaScript
792 lines
34 KiB
JavaScript
/* ============================================================
|
||
Recruitment Inbox — six seed-backed 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 { useMemo, useState } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
|
||
import Modal from '../ui/Modal'
|
||
import { Tabs } from '../ui/Tabs'
|
||
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
||
import { useToast } from '../ui/Toast'
|
||
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, companies, fmtDate, fmtShort, getJob,
|
||
initials as initialsOf, inboxSources, int, locations, pick, relTime, sourceMeta,
|
||
TODAY,
|
||
} from '../data/seed'
|
||
|
||
const TABS = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email']
|
||
|
||
/** The prototype computed "time ago" against a fixed 2026-07-09T20:00. */
|
||
const NOW = new Date('2026-07-09T20:00')
|
||
|
||
/**
|
||
* The seed candidate record importEmail() writes needs a number. The agent
|
||
* returns a verdict, not a score, so there is nothing on the wire to use —
|
||
* named here so the fabricated value is visible at its point of use instead of
|
||
* arriving disguised as a server field on every message.
|
||
*/
|
||
const SEED_ATS_SCORE = 70
|
||
|
||
/**
|
||
* 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
|
||
}
|
||
|
||
/**
|
||
* `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>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* GET /inbox/all-applications -> the shape the application tabs render.
|
||
*
|
||
* READ-ONLY: inbox_messages has no columns for processing state, duplicates,
|
||
* recruiter, phone, experience or an ATS score, so those arrive null and every
|
||
* mutating action on these tabs is disabled until the endpoints exist.
|
||
* `processing` is derived from message_read alone, which is why the Imported /
|
||
* Processed / Rejected / Duplicates tabs read empty.
|
||
*/
|
||
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',
|
||
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),
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* POST /inbox/{record_id}/read — flips message_read false -> true for one row.
|
||
*
|
||
* Optimistic, so the row un-bolds on click instead of after the round trip, and
|
||
* rolls 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 row snaps back to unread.
|
||
*/
|
||
function useMarkRead(toast) {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: (recordId) => inboxApi.markRead(recordId),
|
||
onMutate: async (recordId) => {
|
||
await qc.cancelQueries({ queryKey: qk.mailbox.all() })
|
||
const previous = qc.getQueriesData({ queryKey: qk.mailbox.all() })
|
||
qc.setQueriesData({ queryKey: qk.mailbox.all() }, (rows) => (
|
||
Array.isArray(rows)
|
||
? rows.map((r) => (r.id === recordId
|
||
? { ...r, unread: false, processing: r.processing === 'Unread' ? 'Read' : r.processing }
|
||
: r))
|
||
: rows
|
||
))
|
||
return { previous }
|
||
},
|
||
onError: (err, _recordId, ctx) => {
|
||
for (const [key, data] of ctx?.previous ?? []) qc.setQueryData(key, data)
|
||
toast(friendlyAuthError(err, 'Could not mark as read.'), 'error')
|
||
},
|
||
onSettled: () => qc.invalidateQueries({ queryKey: qk.mailbox.all() }),
|
||
})
|
||
}
|
||
|
||
export default function Inbox() {
|
||
const { toast } = useToast()
|
||
const navigate = useNavigate()
|
||
const qc = useQueryClient()
|
||
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
|
||
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
||
const updateInbox = useSeedMutation('inbox')
|
||
const updateCandidates = useSeedMutation('candidates')
|
||
|
||
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)
|
||
|
||
// Only the Unread tab filters server-side; every other tab omits the param and
|
||
// the backend's default (true) means "no filter".
|
||
const isread = tab === 'Unread' ? false : undefined
|
||
|
||
const applicationsQuery = useQuery({
|
||
queryKey: qk.mailbox.applications({ isread }),
|
||
queryFn: () => fetchApplications({ isread }),
|
||
enabled: tab !== 'Email',
|
||
})
|
||
|
||
/**
|
||
* The tab badges need whole-table counts, which a server-filtered response
|
||
* cannot give — and there is no counts endpoint. So the unfiltered set stays
|
||
* loaded for them. On every tab except Unread this resolves to the SAME query
|
||
* key as the list above, so React Query serves both from one request.
|
||
*/
|
||
const countsQuery = useQuery({
|
||
queryKey: qk.mailbox.applications({ isread: undefined }),
|
||
queryFn: () => fetchApplications({}),
|
||
enabled: tab !== 'Email',
|
||
})
|
||
|
||
const inbox = applicationsQuery.data ?? []
|
||
const allApplications = 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(
|
||
// Counted off the UNFILTERED set — `inbox` is server-filtered on the Unread
|
||
// tab, so counting it there would report the unread total for every badge.
|
||
() => ({
|
||
'All Applications': allApplications.length,
|
||
Unread: allApplications.filter((i) => i.processing === 'Unread').length,
|
||
Imported: allApplications.filter((i) => i.processing === 'Imported').length,
|
||
Processed: allApplications.filter((i) => i.processing === 'Processed').length,
|
||
Rejected: allApplications.filter((i) => i.processing === 'Rejected').length,
|
||
Duplicates: allApplications.filter((i) => i.duplicate).length,
|
||
Email: (emailsQuery.data ?? []).filter((e) => e.unread).length,
|
||
}),
|
||
[allApplications, emailsQuery.data],
|
||
)
|
||
|
||
const list = useMemo(() => {
|
||
let l = inbox
|
||
// Unread is already filtered server-side; re-applying it client-side is what
|
||
// makes the optimistic mark-read drop the row from the list immediately
|
||
// instead of leaving it until the refetch lands.
|
||
if (tab === 'Unread') l = l.filter((i) => i.processing === 'Unread')
|
||
else if (tab === 'Imported') l = l.filter((i) => i.processing === 'Imported')
|
||
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])
|
||
|
||
const selected = inbox.find((i) => i.id === selectedId)
|
||
|
||
// The one mutation these tabs CAN persist — everything else on them is
|
||
// disabled until the endpoints exist.
|
||
const markRead = useMarkRead(toast)
|
||
|
||
function select(id) {
|
||
setSelectedId(id)
|
||
const item = inbox.find((i) => i.id === id)
|
||
if (item?.unread) markRead.mutate(id)
|
||
}
|
||
|
||
function makeCandidate(item, job, cs) {
|
||
return {
|
||
id: `CAN-${5001 + cs.length}`,
|
||
name: item.name, initials: item.initials, color: item.color,
|
||
email: item.email, phone: item.phone,
|
||
jobId: job.id, jobTitle: job.title, department: job.department,
|
||
experience: item.experience, currentCompany: pick(companies), currentTitle: job.title,
|
||
location: pick(locations), stage: 'Applied', status: 'Applied',
|
||
aiScore: item.atsScore, source: item.source, recruiter: item.recruiter, recruiterId: '',
|
||
applied: new Date(TODAY), education: "Bachelor's Degree",
|
||
skills: job.skills.slice(0, 4), rating: '4.0', salary: int(90, 180) * 1000,
|
||
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
|
||
recommendation: item.atsScore >= 82 ? 'Strong Match' : 'Potential Match',
|
||
subScores: { skills: item.atsScore, experience: item.atsScore, education: 80, keywords: item.atsScore, location: 100, salary: 90 },
|
||
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
|
||
favorite: false, interviewStatus: 'Not Scheduled',
|
||
}
|
||
}
|
||
|
||
function importItem(item) {
|
||
const job = getJob(item.jobId) || jobs[0]
|
||
updateCandidates((cs) => [makeCandidate(item, job, cs), ...cs])
|
||
updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, processing: 'Imported', unread: false } : i)))
|
||
toast(`${item.name} imported → Applied stage of ${job.title}`, 'success')
|
||
}
|
||
|
||
function parseResume(item) {
|
||
updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, resumeStatus: 'Parsing' } : i)))
|
||
toast('Parsing resume with AI…', 'info')
|
||
setTimeout(() => {
|
||
updateInbox((items) =>
|
||
items.map((i) => (i.id === item.id ? { ...i, resumeStatus: 'Parsed', atsScore: int(60, 96) } : i)),
|
||
)
|
||
toast('Resume parsed — profile fields extracted', 'success')
|
||
}, 1100)
|
||
}
|
||
|
||
function moveToPipeline(item) {
|
||
if (item.processing !== 'Imported' && item.processing !== 'Processed') importItem(item)
|
||
updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, processing: 'Processed' } : i)))
|
||
toast(`${item.name} moved to pipeline`, 'success')
|
||
setTimeout(() => navigate('/pipeline'), 700)
|
||
}
|
||
|
||
function reject(item) {
|
||
updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, processing: 'Rejected', unread: false } : i)))
|
||
toast(`${item.name} rejected`, 'warning')
|
||
}
|
||
|
||
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) }}
|
||
tabs={TABS.map((t) => ({ key: t, label: t, count: counts[t] }))}
|
||
/>
|
||
</div>
|
||
|
||
{tab === 'Email' ? (
|
||
<EmailTab query={emailsQuery} jobs={jobs} updateCandidates={updateCandidates} toast={toast} />
|
||
) : (
|
||
<div className="split">
|
||
<div className="split-list">
|
||
<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="Couldn’t 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)}
|
||
>
|
||
<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></div>
|
||
</div>
|
||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||
<div className="ii-time">
|
||
{i.received ? relTime(Math.round((NOW - i.received) / 60000)) : '—'}
|
||
</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>
|
||
) : (
|
||
<ApplicationDetail
|
||
item={selected}
|
||
onPreview={() => setPreviewing(selected)}
|
||
onImport={() => importItem(selected)}
|
||
onParse={() => parseResume(selected)}
|
||
onAssign={() => setAssigning(selected)}
|
||
onMove={() => moveToPipeline(selected)}
|
||
onNote={() => setNoting(selected)}
|
||
onReject={() => reject(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) }}
|
||
disabled
|
||
title="Needs a backend endpoint — not implemented yet"
|
||
>
|
||
<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, onPreview, onImport, onParse, onAssign, onMove, onNote, onReject }) {
|
||
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)'
|
||
// Every action below writes to a table column or an endpoint that does not
|
||
// exist yet, so they are disabled rather than silently dropping the click.
|
||
const noBackend = 'Needs a backend endpoint — not implemented yet'
|
||
|
||
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>{' '}
|
||
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
|
||
{i.resumeStatus}
|
||
</Badge>
|
||
</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.atsScore != null && (
|
||
<div className="info-item">
|
||
<div className="il">Match</div>
|
||
<div className="iv"><Badge className={atsRecommendationClass(recLabel)}>{recLabel}</Badge></div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{i.hasAttachment && (
|
||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
|
||
<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)}</div>
|
||
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
|
||
</div>
|
||
{/* The real extracted PDF text (inbox_messages.resume_text), written
|
||
by the matching task. Empty until that task has run. */}
|
||
<pre className="resume-thumb">
|
||
{i.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-8" style={{ flexWrap: 'wrap' }}>
|
||
<button className="btn btn-primary" onClick={onImport} disabled title={noBackend}>
|
||
<Icon name="user-plus" /> Import Candidate
|
||
</button>
|
||
<button className="btn btn-secondary" onClick={onParse} disabled title={noBackend}>
|
||
<Icon name="sparkles" /> Parse Resume
|
||
</button>
|
||
<button className="btn btn-secondary" onClick={onAssign} disabled title={noBackend}>
|
||
<Icon name="users" /> Assign Recruiter
|
||
</button>
|
||
<button className="btn btn-secondary" onClick={onMove} disabled title={noBackend}>
|
||
<Icon name="layers" /> Move to Pipeline
|
||
</button>
|
||
<button className="btn btn-secondary" onClick={onNote} disabled title={noBackend}>
|
||
<Icon name="edit" /> Add Note
|
||
</button>
|
||
<button
|
||
className="btn btn-ghost"
|
||
style={{ color: 'var(--danger)' }}
|
||
onClick={onReject}
|
||
disabled
|
||
title={noBackend}
|
||
>
|
||
<Icon name="x" /> Reject
|
||
</button>
|
||
</div>
|
||
</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, jobs, updateCandidates, toast }) {
|
||
const qc = useQueryClient()
|
||
const [selectedId, setSelectedId] = useState(null)
|
||
const [imported, setImported] = useState(() => new Set())
|
||
|
||
const emails = query.data ?? []
|
||
const selected = emails.find((e) => e.id === selectedId)
|
||
const unread = emails.filter((e) => e.unread).length
|
||
|
||
const markRead = useMarkRead(toast)
|
||
|
||
// Refetching the list alone only re-reads rows already in our DB. GET
|
||
// /email/fetch is the Graph proxy pull that inserts new mail and enqueues the
|
||
// matching agent, so it has to run FIRST — then the list is invalidated to
|
||
// pick up whatever it wrote.
|
||
const sync = useMutation({
|
||
mutationFn: () => inboxApi.syncMailbox(),
|
||
onSuccess: async () => {
|
||
await qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
||
toast('Mailbox synced', 'success')
|
||
},
|
||
onError: (err) => toast(friendlyAuthError(err, 'Sync failed'), 'error'),
|
||
})
|
||
|
||
function importEmail(e) {
|
||
const job = jobs[0]
|
||
if (!job) return
|
||
updateCandidates((cs) => [
|
||
{
|
||
id: `CAN-${5001 + cs.length}`,
|
||
name: e.from, initials: initialsOf(e.from), color: avatarColor(e.from),
|
||
email: e.fromEmail, phone: '+1 (555) 000-0000',
|
||
jobId: job.id, jobTitle: job.title, department: job.department,
|
||
experience: int(2, 10), currentCompany: pick(companies), currentTitle: job.title,
|
||
location: pick(locations), stage: 'Applied', status: 'Applied',
|
||
aiScore: SEED_ATS_SCORE, source: 'Microsoft Outlook', recruiter: job.recruiter, recruiterId: '',
|
||
applied: new Date(TODAY), education: "Bachelor's Degree",
|
||
skills: job.skills.slice(0, 4), rating: '4.0', salary: 130000,
|
||
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
|
||
recommendation: 'Potential Match',
|
||
subScores: { skills: SEED_ATS_SCORE, experience: 80, education: 80, keywords: SEED_ATS_SCORE, location: 100, salary: 90 },
|
||
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
|
||
favorite: false, interviewStatus: 'Not Scheduled',
|
||
},
|
||
...cs,
|
||
])
|
||
setImported((s) => new Set(s).add(e.id))
|
||
toast(`${e.from} imported from Outlook → ${job.title}`, 'success')
|
||
}
|
||
|
||
const isImported = (e) => imported.has(e.id)
|
||
|
||
function selectEmail(e) {
|
||
setSelectedId(e.id)
|
||
if (e.unread) markRead.mutate(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">
|
||
<div className="split-list">
|
||
{query.isPending && <EmptyState icon="mail" title="Loading…">Fetching mailbox from the server.</EmptyState>}
|
||
{query.isError && (
|
||
<EmptyState icon="mail" title="Couldn’t 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)}
|
||
>
|
||
<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">{e.when ? fmtShort(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: 6 }}>
|
||
<h2 style={{ fontSize: 18, flex: 1 }}>{selected.subject}</h2>
|
||
{isImported(selected) ? <Badge className="b-green">Imported</Badge> : <Badge className="b-blue">New</Badge>}
|
||
</div>
|
||
<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>
|
||
|
||
{/* Rendered as TEXT. This is js/inbox.js:292, the widest XSS sink
|
||
in the prototype, and inbound mail is attacker-supplied. */}
|
||
<div className="email-preview" style={{ marginBottom: 18, whiteSpace: 'pre-wrap' }}>
|
||
{selected.body}
|
||
</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 className="flex items-center gap-8">
|
||
<button className="btn btn-secondary btn-sm" onClick={() => toast('Opening attachment preview', 'info')}>
|
||
<Icon name="eye" /> Preview
|
||
</button>
|
||
</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" onClick={() => importEmail(selected)}>
|
||
<Icon name="user-plus" /> Import Candidate
|
||
</button>
|
||
)}
|
||
<button className="btn btn-secondary" onClick={() => toast('Reply drafted', 'info')}>
|
||
<Icon name="mail" /> Reply
|
||
</button>
|
||
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={() => toast('Email archived', 'info')}>
|
||
<Icon name="trash" /> Archive
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|