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

583 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* ============================================================
Recruitment Inbox — 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 { 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, int, locations, pick, relTime, skillsPool, 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')
function resumeText(i) {
return `${i.name.toUpperCase()}
${i.email} · ${i.phone}
${'—'.repeat(30)}
PROFESSIONAL SUMMARY
${i.experience} years of experience. Applied for ${i.position} via ${i.source}.
EXPERIENCE
${pick(companies)} — Senior role (2021Present)
${pick(companies)} — Associate (20182021)
EDUCATION
• Bachelor's Degree, Computer Science
SKILLS
${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}`
}
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>
)
}
export default function Inbox() {
const { toast } = useToast()
const navigate = useNavigate()
const { data: inbox = [] } = useQuery(seedQuery('inbox'))
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)
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: row.when ? new Date(row.when) : new Date(),
unread: Boolean(row.unread),
attachment: row.attachment_name || 'Resume.pdf',
attachmentSize: '—',
atsScore: 70,
imported: false,
}))
},
enabled: tab === 'Email',
})
const counts = useMemo(
() => ({
'All Applications': inbox.length,
Unread: inbox.filter((i) => i.processing === 'Unread').length,
Imported: inbox.filter((i) => i.processing === 'Imported').length,
Processed: inbox.filter((i) => i.processing === 'Processed').length,
Rejected: inbox.filter((i) => i.processing === 'Rejected').length,
Duplicates: inbox.filter((i) => i.duplicate).length,
Email: (emailsQuery.data ?? []).filter((e) => e.unread).length,
}),
[inbox, emailsQuery.data],
)
const list = useMemo(() => {
let l = inbox
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)
function select(id) {
setSelectedId(id)
updateInbox((items) => items.map((i) => (i.id === id ? { ...i, unread: false } : i)))
}
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>
{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">{relTime(Math.round((NOW - i.received) / 60000))}</div>
<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) }}
>
<Icon name="user-plus" /> Import Candidate
</button>
</>
}
>
<pre className="resume-thumb" style={{ maxHeight: 'none', fontSize: 12 }}>{resumeText(previewing)}</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>
)
}
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)'
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>
<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">{i.email}</div></div>
<div className="info-item"><div className="il">Phone</div><div className="iv">{i.phone}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{i.experience} years</div></div>
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{i.recruiter}</div></div>
<div className="info-item"><div className="il">Received</div><div className="iv">{fmtDate(i.received)}</div></div>
<div className="info-item">
<div className="il">Match</div>
<div className="iv"><Badge className={atsRecommendationClass(recLabel)}>{recLabel}</Badge></div>
</div>
</div>
<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" /> {i.attachment}</div>
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
</div>
<pre className="resume-thumb">{resumeText(i)}</pre>
</div>
</div>
<div className="flex gap-8" style={{ flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={onImport}><Icon name="user-plus" /> Import Candidate</button>
<button className="btn btn-secondary" onClick={onParse}><Icon name="sparkles" /> Parse Resume</button>
<button className="btn btn-secondary" onClick={onAssign}><Icon name="users" /> Assign Recruiter</button>
<button className="btn btn-secondary" onClick={onMove}><Icon name="layers" /> Move to Pipeline</button>
<button className="btn btn-secondary" onClick={onNote}><Icon name="edit" /> Add Note</button>
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={onReject}>
<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
async function sync() {
toast('Fetching from Outlook…', 'info')
const res = await qc.refetchQueries({ queryKey: qk.mailbox.messages() })
if (query.isError) toast('Sync failed', 'error')
else toast('Mailbox synced', 'success')
return res
}
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: e.atsScore, 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: e.atsScore, experience: 80, education: 80, keywords: e.atsScore, 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)
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' }} onClick={sync}>
<Icon name="refresh" /> 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="Couldnt load mailbox">
{friendlyAuthError(query.error, 'Request failed')}
</EmptyState>
)}
{query.isSuccess && emails.length === 0 && (
<EmptyState icon="mail" title="Nothing here">No emails in the mailbox.</EmptyState>
)}
{query.isSuccess && emails.map((e) => (
<div
key={e.id}
className={`inbox-item${e.unread && selectedId !== e.id ? ' unread' : ''}${selectedId === e.id ? ' active' : ''}`}
onClick={() => setSelectedId(e.id)}
>
<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">{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} · {fmtDate(selected.when)}</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">
<ScoreChip score={selected.atsScore} />
<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>
</>
)
}