HR-ATS-Portal/frontend/src/api/pipeline.js

228 lines
8.2 KiB
JavaScript

/* ============================================================
pipeline.js — the kanban board's endpoints (backend/job/app.py).
The board is one read: GET /pipeline/candidates/fetch returns inbox +
manual_upload cards plus per-status counts. Dropping a card fires
PATCH /candidate/stage with `inbox_id` or `manual_upload_id`.
Inbox stage lives on inbox_messages.application_status; manual stage lives
on manual_upload_candidate.status (same Candidate_application_Status values).
History is application_stage_transitions in both cases.
============================================================ */
import { request } from '../lib/apiClient'
import { toDate } from '../lib/format'
/**
* Candidate_application_Status (backend/inbox/enums.py) -> the board column.
*
* The enum has 11 values. Approved and On Hold are first-class columns (they
* used to be folded into Hired / Screening). CLOSED is the inbox default and
* is shown as CLOSED. Only REJECTED reads as Rejected.
*
* Anything unmapped falls through to Shortlist rather than vanishing from the
* board — a card with no column is a candidate nobody sees.
*/
export const STAGE_FROM_STATUS = {
PENDING: 'Shortlist',
PROCESS: 'Screening',
SCREENING: 'Screening',
ONHOLD: 'On Hold',
ASSESSMENT: 'Assessment',
INTERVIEW: 'Interview',
OFFER: 'Offer',
APPROVED: 'Approved',
HIRED: 'Hired',
CLOSED: 'CLOSED',
REJECTED: 'Rejected',
}
/**
* Column -> the status WRITTEN on a drop. Not the inverse of the map above:
* PROCESS is readable as Screening but is never written, so the vocabulary
* converges on the canonical value as cards get moved. Shortlist writes
* PENDING because the enum has no SHORTLIST member. Rejected writes REJECTED
* (not CLOSED) so new drops are distinguishable from the inbox default.
*/
export const STATUS_FROM_STAGE = {
CLOSED: 'CLOSED',
Shortlist: 'PENDING',
Screening: 'SCREENING',
'On Hold': 'ONHOLD',
Assessment: 'ASSESSMENT',
Interview: 'INTERVIEW',
Offer: 'OFFER',
Approved: 'APPROVED',
Hired: 'HIRED',
Rejected: 'REJECTED',
}
/**
* Move one application to another stage. Requires pipeline.edit.
*
* Inbox cards send `inboxId` (INTEGER inbox.id). Manual-upload cards send
* `manualUploadId` (manual_upload_candidate.id UUID). The server 400s if both
* or neither are present, and 400s a no-op move ("already at stage").
*/
export function changeStage({ inboxId, manualUploadId, toStage, changeReason }) {
return request('/candidate/stage', {
method: 'PATCH',
body: {
...(inboxId != null ? { inbox_id: inboxId } : {}),
...(manualUploadId != null ? { manual_upload_id: manualUploadId } : {}),
to_stage: toStage,
change_reason: changeReason ?? null,
},
})
}
/**
* Inbox + manual-upload applications for the board — GET /pipeline/candidates/fetch
* (pipeline.view). Envelope is `{ data: { inbox, manual_upload }, counts, total }`.
* `jobId === ''` (All Jobs) is dropped by buildUrl and sends no filter.
*/
export function listApplications({ jobId, limit, offset, search } = {}) {
return request('/pipeline/candidates/fetch', {
params: { job_post_id: jobId, limit, offset, search },
})
}
/**
* Fold the 11 status counts into the board columns. Unmapped keys (UNKNOWN)
* land in Shortlist, same as STAGE_FROM_STATUS's card fallback.
*/
export function toStageCounts(byStatus) {
const counts = Object.fromEntries(Object.keys(STATUS_FROM_STAGE).map((name) => [name, 0]))
for (const [status, n] of Object.entries(byStatus || {})) {
const stage = STAGE_FROM_STATUS[status] ?? 'Shortlist'
counts[stage] = (counts[stage] ?? 0) + (n || 0)
}
return counts
}
/**
* Stage history for one application — GET /pipeline/transitions/fetch
* (pipeline.view). Rows are valid-time intervals: `valid_to` null is the stage
* the candidate is in now. The board itself does not render history; this is the
* feed behind a stage timeline on the profile.
*
* One of inboxId / manualUploadId / transitionId is required — the route 400s
* with none of them.
*/
export function listTransitions({ inboxId, manualUploadId, transitionId } = {}) {
return request('/pipeline/transitions/fetch', {
params: { inbox_id: inboxId, manual_upload_id: manualUploadId, transition_id: transitionId },
})
}
/**
* One candidate's CURRENT ATS score — GET /pipeline/candidate/score/fetch
* (pipeline.view). Envelope is `{ data: { manual, inbox }, total, status_code }`,
* where each side is `{overall_score, band, job_post_id, computed_at,
* candidate_id, user_id}` or null.
*
* Score only. It carries no matched/missing keywords and no critique — those
* live on the scored-candidate row (/candidate/scored/fetch).
*
* `jobPostId` pins the score to one application; omitted (as the talent pool
* does, where a candidate need not have an assigned post) the newest current
* score across the candidate's applications wins.
*/
export function fetchCandidateScore({ userId, jobPostId } = {}) {
return request('/pipeline/candidate/score/fetch', {
params: { user_id: userId, job_post_id: jobPostId },
})
}
/**
* The two-sided envelope -> one score row, or null.
*
* A candidate is reached either through the inbox (they mailed us) or through
* Add Candidate (manual_upload); both sides resolve the same ats_results table,
* so whichever side answered is the score. Inbox wins a tie because an emailed
* application is the one the pipeline board is showing.
*/
export function toAtsScore(res) {
const data = res?.data ?? {}
return data.inbox ?? data.manual ?? null
}
/** Card fields are rendered as React children — objects throw, not just look blank. */
function asText(value, fallback = null) {
if (value == null) return fallback
if (typeof value === 'string') {
const trimmed = value.trim()
return trimmed || fallback
}
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
return fallback
}
function asScore(value) {
if (value == null || value === '') return null
const n = typeof value === 'number' ? value : Number(value)
return Number.isFinite(n) ? n : null
}
function statusKey(value) {
if (typeof value === 'string') return value
if (value && typeof value === 'object' && typeof value.value === 'string') return value.value
return null
}
function sourceFields(row, kind) {
if (kind === 'manual') {
const id = row.id
return {
id: id != null ? `manual:${id}` : `manual:${row.user_id ?? row.email ?? 'unknown'}`,
inboxId: null,
manualUploadId: id ?? null,
jobId: row.job_post_id ?? null,
jobTitle: asText(row.title),
currentTitle: asText(row.current_position),
currentCompany: asText(row.current_company),
source: asText(row.platform) || asText(row.apply_via) || 'Manual',
}
}
return {
id: row.inbox_id ?? `inbox:${row.user_id ?? row.email ?? 'unknown'}`,
inboxId: row.inbox_id ?? null,
manualUploadId: null,
jobId: row.assigned_job_post_id ?? null,
jobTitle: asText(row.title),
currentTitle: asText(row.current_title),
currentCompany: asText(row.current_employment),
source: null,
}
}
/**
* Pipeline inbox or manual-upload row -> one kanban card, or null if the row
* cannot be rendered. Inbox `id` is the inbox id, not the user id: the board
* is one card per APPLICATION. `userId` rides along for the profile deep link.
*/
export function toBoardCard(row, kind = 'inbox') {
if (!row || typeof row !== 'object') return null
const src = sourceFields(row, kind)
const status = statusKey(row.application_status)
return {
...src,
userId: row.user_id ?? null,
name: asText(row.name) || asText(row.email) || 'Unknown',
email: asText(row.email),
stage: STAGE_FROM_STATUS[status] ?? 'Shortlist',
status,
experience: asText(row.experience),
aiScore: asScore(row.ats_result?.overall_score),
recommendation: asText(row.ats_result?.band),
applied: toDate(row.created_at),
isReapplicant: Boolean(row.is_reapplicant),
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
}
}
/** GET /pipeline/candidates/fetch `manual_upload` row -> one kanban card. */
export function toManualBoardCard(row) {
return toBoardCard(row, 'manual')
}