HR-ATS-Portal/frontend/src/components/ReapplicantHistory.jsx

288 lines
9.8 KiB
JavaScript

import { Badge } from '../ui/primitives'
import { STAGE_FROM_STATUS } from '../api/pipeline'
import { fmtDateTime, toDate, toInstant } from '../lib/format'
import { Link } from 'react-router-dom'
const SOURCE_LABEL = {
inbox: 'Email',
manual: 'Manual',
form: 'Form',
ats: 'ATS',
filtered: 'Email',
}
const STAGE_BADGE = {
Shortlist: 'b-indigo',
Screening: 'b-teal',
Assessment: 'b-purple',
Interview: 'b-amber',
Offer: 'b-green',
Approved: 'b-green',
Hired: 'b-green',
'On Hold': 'b-amber',
Rejected: 'b-gray',
'Rejected — wrong format': 'b-red',
'No job assigned': 'b-gray',
}
export function applicationStatusLabel(status, item) {
if (item?.rejection_reason === 'wrong_format' || String(status || '').toUpperCase() === 'WRONG_FORMAT') {
return 'Rejected — wrong format'
}
const assigned = Boolean(item?.job_post_id || item?.jobPostId || (item?.source === 'form' && (item.job_title || item.jobTitle)))
if (!assigned && (status == null || status === '' || String(status).toUpperCase() === 'CLOSED')) {
return 'No job assigned'
}
if (status == null || status === '') return 'Shortlist'
const key = String(status).toUpperCase()
if (STAGE_FROM_STATUS[key]) return STAGE_FROM_STATUS[key]
const extras = {
UNREAD: 'Unread',
IMPORTED: 'Imported',
PROCESSED: 'Processed',
COMPLETED: 'ATS scored',
FAILED: 'ATS failed',
BANKED: 'CV bank',
}
if (extras[key]) return extras[key]
return key.charAt(0) + key.slice(1).toLowerCase()
}
function historyItemsOf(row) {
if (!row) return []
if (Array.isArray(row.previousApplications)) return row.previousApplications
if (Array.isArray(row.previous_applications)) return row.previous_applications
return []
}
/** Every application for this candidate, including the one currently open. */
export function candidateApplicationsOf(row) {
if (!row) return []
const current = currentRowIds(row)
const items = [...historyItemsOf(row)]
if (current.size && !items.some((item) => isSameApplication(item, current))) {
const self = syntheticCurrentApplication(row)
if (self) items.push(self)
}
items.sort((a, b) => (appliedAtMs(b?.applied_at) ?? 0) - (appliedAtMs(a?.applied_at) ?? 0))
return items
}
/** Applications other than the open row — used by the Reapplied chip. */
export function previousApplicationsOf(row) {
if (!row) return []
const current = currentRowIds(row)
return candidateApplicationsOf(row).filter((item) => !isSameApplication(item, current))
}
function syntheticCurrentApplication(row) {
const assigned = row.assignedPost || row.assigned_job_post
const position = row.kind !== 'email' && row.position && row.position !== '—' ? row.position : null
const jobTitle = assigned?.title || row.job_title || row.jobTitle || row.currentTitle || position || null
const kind = row.kind
let source = row.source
if (kind === 'form') source = 'form'
else if (kind === 'email') source = 'inbox'
else if (row.manualUploadId || row.manual_upload_candidate_id) source = 'manual'
if (source && String(source).includes('@')) source = 'inbox'
const id = row.id != null && row.id !== '' ? String(row.id) : null
return {
source: source || 'inbox',
inbox_id: row.inboxId || row.inbox_id || null,
message_id: kind === 'email' ? id : (row.message_id || null),
form_data_id: kind === 'form' ? id : (row.form_data_id || null),
manual_upload_candidate_id: row.manualUploadId || row.manual_upload_candidate_id || null,
candidate_id: row.candidate_id || (kind == null && row.jobId ? row.id : null) || null,
user_id: row.userId || row.user_id || null,
job_post_id: row.assignedId || row.jobId || row.job_post_id || null,
job_title: jobTitle,
status: row.applicationStatus || row.processingState || row.status || null,
applied_at: row.received || row.applied || row.applied_at || row.entry_date || row.when || null,
}
}
function appliedAtMs(value) {
if (value == null || value === '') return null
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.getTime()
}
const instant = toInstant(value)
if (instant) return instant.getTime()
const wall = toDate(value)
return wall ? wall.getTime() : null
}
function currentRowIds(row) {
return new Set(
[
row.id,
row.inboxId,
row.inbox_id,
row.message_id,
row.messageId,
row.form_data_id,
row.manualUploadId,
row.manual_upload_candidate_id,
row.candidate_id,
row.upstream_id,
]
.filter((v) => v != null && v !== '')
.map(String),
)
}
function isSameApplication(item, currentIds) {
if (!item) return false
return [
item.inbox_id,
item.message_id,
item.upstream_id,
item.form_data_id,
item.manual_upload_candidate_id,
item.candidate_id,
].some((id) => id != null && id !== '' && currentIds.has(String(id)))
}
function hasAssignedJob(item) {
if (!item) return false
if (item.job_post_id || item.jobPostId) return true
return item.source === 'form' && Boolean(item.job_title || item.jobTitle)
}
export function isReapplicant(row) {
if (!row) return false
return previousApplicationsOf(row).some(hasAssignedJob)
}
export function previousApplicationsTip(row) {
const items = previousApplicationsOf(row)
if (!items.length) return 'Applied before'
return items.map((item) => {
const job = item.job_title || item.jobTitle || 'No job assigned'
return `${job}${applicationStatusLabel(item.status, item)}`
}).join('\n')
}
/** Compact chip for tables, kanban cards, and inbox rows. */
export function ReappliedBadge({ row, className = '' }) {
if (!isReapplicant(row)) return null
const count = previousApplicationsOf(row).filter(hasAssignedJob).length
return (
<span
className={`badge b-amber badge-plain ${className}`.trim()}
style={{ marginLeft: 6, fontSize: 10, padding: '1px 6px' }}
title={previousApplicationsTip(row)}
>
Reapplied{count > 1 ? ` · ${count}` : ''}
</span>
)
}
export function hrefForPreviousApplication(item) {
if (!item) return null
if (item.source === 'form' && item.form_data_id) {
return `/inbox?open=${encodeURIComponent(item.form_data_id)}&kind=form`
}
if ((item.source === 'inbox' || item.source === 'filtered') && item.message_id) {
return `/inbox?open=${encodeURIComponent(item.message_id)}&kind=email`
}
if (item.source === 'manual') {
if (item.user_id) return `/candidate/${encodeURIComponent(item.user_id)}`
if (item.manual_upload_candidate_id) {
return `/matching?record=${encodeURIComponent(item.manual_upload_candidate_id)}`
}
}
if (item.user_id) return `/candidate/${encodeURIComponent(item.user_id)}`
if (item.form_data_id) {
return `/inbox?open=${encodeURIComponent(item.form_data_id)}&kind=form`
}
if (item.message_id) {
return `/inbox?open=${encodeURIComponent(item.message_id)}&kind=email`
}
return null
}
/** Full application list for profile / inbox / add-candidate. */
export function PreviousApplications({ row, title = 'Total applications' }) {
const items = candidateApplicationsOf(row)
// One row is the application already on screen — do not show a history card.
if (items.length < 2) return null
const current = currentRowIds(row)
const heading = title === 'Total applications' ? `Total applications (${items.length})` : title
return (
<div
className="card"
style={{
boxShadow: 'none',
background: 'var(--warning-soft)',
border: '1px solid var(--warning)',
marginBottom: 18,
}}
>
<div className="card-body">
<div
style={{
fontSize: 12,
color: 'var(--warning)',
fontWeight: 700,
textTransform: 'uppercase',
marginBottom: 10,
}}
>
{heading}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((item, idx) => {
const stage = applicationStatusLabel(item.status, item)
const job = item.job_title || item.jobTitle || 'No job assigned'
const href = hrefForPreviousApplication(item)
const isCurrent = current.size > 0 && isSameApplication(item, current)
const key = [
item.source,
item.inbox_id,
item.manual_upload_candidate_id,
item.form_data_id,
item.candidate_id,
item.job_post_id,
idx,
].filter(Boolean).join(':')
const jobLabel = (
<>
{job}
{isCurrent ? ' (Current)' : ''}
</>
)
return (
<div
key={key}
className={`reapplicant-history-row flex items-center gap-8${href ? ' is-link' : ''}`}
style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<div style={{ minWidth: 0 }}>
{href ? (
<Link
to={href}
className="reapplicant-job-link"
title={isCurrent ? 'This application' : 'Open this application'}
onClick={(e) => e.stopPropagation()}
>
{jobLabel}
</Link>
) : (
<div className="fw-600" style={{ fontSize: 13 }}>{jobLabel}</div>
)}
<div className="cell-sub">
{SOURCE_LABEL[item.source] || item.source || 'Application'}
{item.applied_at ? ` · ${fmtDateTime(toInstant(item.applied_at) || item.applied_at)}` : ''}
</div>
</div>
<Badge className={STAGE_BADGE[stage] || 'b-gray'}>{stage}</Badge>
</div>
)
})}
</div>
</div>
</div>
)
}