288 lines
9.8 KiB
JavaScript
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>
|
|
)
|
|
}
|