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

195 lines
6.9 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'
/**
* Candidate_application_Status (backend/inbox/enums.py) -> the board column.
*
* The enum has 11 values and the board 7 columns, so this is deliberately
* many-to-one: CLOSED is the column DEFAULT (untriaged, never mailed anywhere)
* and reads as Applied rather than as an outcome, ONHOLD parks in Screening, and
* APPROVED is the pre-HIRED spelling of a hire.
*
* Anything unmapped falls through to Applied rather than vanishing from the
* board — a card with no column is a candidate nobody sees.
*/
export const STAGE_FROM_STATUS = {
PENDING: 'Applied',
CLOSED: 'Applied',
PROCESS: 'Screening',
ONHOLD: 'Screening',
SCREENING: 'Screening',
ASSESSMENT: 'Assessment',
INTERVIEW: 'Interview',
OFFER: 'Offer',
HIRED: 'Hired',
APPROVED: 'Hired',
REJECTED: 'Rejected',
}
/**
* Column -> the status WRITTEN on a drop. Not the inverse of the map above: the
* legacy spellings (PROCESS, ONHOLD, APPROVED, CLOSED) are readable but are
* never written, so the vocabulary converges on the canonical value as cards get
* moved. Applied writes PENDING because the enum has no APPLIED member.
*/
export const STATUS_FROM_STAGE = {
Applied: 'PENDING',
Screening: 'SCREENING',
Assessment: 'ASSESSMENT',
Interview: 'INTERVIEW',
Offer: 'OFFER',
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 } = {}) {
return request('/pipeline/candidates/fetch', {
params: { job_post_id: jobId, limit, offset },
})
}
/**
* Fold the 11 status counts into the 7 board columns. Unmapped keys (UNKNOWN)
* land in Applied, 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] ?? 'Applied'
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
}
function sourceFields(row, kind) {
if (kind === 'manual') {
return {
id: `manual:${row.id}`,
inboxId: null,
manualUploadId: row.id,
jobId: row.job_post_id ?? null,
jobTitle: row.title ?? null,
currentTitle: row.current_position || null,
currentCompany: row.current_company || null,
}
}
return {
id: row.inbox_id,
inboxId: row.inbox_id,
manualUploadId: null,
jobId: row.assigned_job_post_id ?? null,
jobTitle: row.title ?? null,
currentTitle: row.current_title || null,
currentCompany: row.current_employment || null,
}
}
/**
* Pipeline inbox or manual-upload row -> one kanban card.
*
* Inbox `id` is the inbox id, not the user id: the board is one card per
* APPLICATION. `userId` rides along for the deep link into the profile.
*/
export function toBoardCard(row, kind = 'inbox') {
const src = sourceFields(row, kind)
return {
...src,
userId: row.user_id ?? null,
name: row.name || row.email || 'Unknown',
email: row.email ?? null,
stage: STAGE_FROM_STATUS[row.application_status] ?? 'Applied',
status: row.application_status ?? null,
experience: row.experience || null,
aiScore: row.ats_result?.overall_score ?? null,
recommendation: row.ats_result?.band ?? null,
applied: row.created_at ? new Date(row.created_at) : null,
}
}
/** GET /pipeline/candidates/fetch `manual_upload` row -> one kanban card. */
export function toManualBoardCard(row) {
return toBoardCard(row, 'manual')
}