/** * Applicant Pipeline: which hiring stage each applicant is actually at, * one Kanban-style column per stage. Built from two real sources — * api.listCandidates() (whoever's resume has been parsed/scored) and * api.listAuditLog() (every application_submitted/document_verification/ * human_review_* event, GET /audit-log, azure-functions/api_blueprint.py) * — not a separate hand-maintained status field, so it can't drift from * what actually happened. * * "Real time": this is periodic polling (every 20s, see POLL_INTERVAL_MS * below) plus a manual refresh button, NOT a push-based live feed -- * there's no WebSocket/SignalR layer in this stack. Labeled as * "auto-refreshing" in the UI rather than oversold as instant. * * Stage order (furthest-evidence-wins, computed per candidate from the * audit trail, not a field anyone sets directly): * Applied -> Parsed & Scored -> Document Verified -> Reviewed * * A real gap this surfaces rather than hides: a candidate who just * submitted via the Candidate Portal (Applied) but whose resume the * blob-triggered function (function_app.py) hasn't processed yet has NO * candidate record at all -- they only exist as an application_submitted * audit entry. This view is the one place that "in flight, not yet * parsed" state is visible; everywhere else in the app only shows * candidates who've already been scored. */ var POLL_INTERVAL_MS = 20000; var STAGES = [ { key: "applied", label: "Applied" }, { key: "scored", label: "Parsed & Scored" }, { key: "doc_verified", label: "Document Verified" }, { key: "reviewed", label: "Reviewed" }, ]; function initialsForPipeline(name) { var parts = (name || "").trim().split(/\s+/).filter(Boolean); if (!parts.length) return "?"; return (parts[0][0] + (parts[1] ? parts[1][0] : "")).toUpperCase(); } function computeStage(candidateId, candidatesById, auditByCandidate) { var candidate = candidatesById[candidateId]; var entries = auditByCandidate[candidateId] || []; var actions = entries.map(function (e) { return e.action; }); if (actions.indexOf("human_review_approve") !== -1) return { key: "reviewed", detail: "Approved" }; if (actions.indexOf("human_review_reject") !== -1) return { key: "reviewed", detail: "Rejected" }; if (actions.indexOf("document_verification") !== -1) return { key: "doc_verified", detail: null }; // Still just the AI's own suggestion at this stage (no human decision // recorded yet) -- shown with the softer framing; contrast with the // "Approved"/"Rejected" cases above, which describe a REAL human // decision already made and correctly stay as plain past-tense text. if (candidate) return { key: "scored", detail: tierDisplayLabel(candidate.ranking_tier) }; if (actions.indexOf("application_submitted") !== -1) return { key: "applied", detail: "Awaiting processing" }; return { key: "scored", detail: null }; } function Pipeline(props) { var api = props.api; var permissions = props.permissions || []; var currentUser = props.currentUser; var state = React.useState({ loading: true, error: null, candidates: [], auditEntries: [], lastUpdated: null }); var data = state[0]; var setData = state[1]; var searchState = React.useState(""); var search = searchState[0]; var setSearch = searchState[1]; var modalState = React.useState(null); // { candidateId, candidateName } | null var modalCandidate = modalState[0]; var setModalCandidate = modalState[1]; var configOpenState = React.useState(false); var configOpen = configOpenState[0]; var setConfigOpen = configOpenState[1]; var pendingState = React.useState({ loading: true, error: null, decisions: [] }); var pending = pendingState[0]; var setPending = pendingState[1]; function loadPending() { return api.listBulkDecisions("pending") .then(function (result) { setPending({ loading: false, error: null, decisions: result.decisions }); }) .catch(function (err) { setPending({ loading: false, error: err.message, decisions: [] }); }); } function load() { return Promise.all([api.listCandidates(), api.listAuditLog()]) .then(function (results) { setData({ loading: false, error: null, candidates: results[0].candidates, auditEntries: results[1].entries, lastUpdated: new Date(), }); }) .catch(function (err) { setData(function (d) { return Object.assign({}, d, { loading: false, error: err.message }); }); }); } React.useEffect(function () { load(); loadPending(); var interval = setInterval(function () { load(); loadPending(); }, POLL_INTERVAL_MS); return function () { clearInterval(interval); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); if (data.loading) return
Loading…
Auto-refreshing every {POLL_INTERVAL_MS / 1000}s (polling, not a push-based live feed — see Pipeline.jsx's doc comment). Last updated {data.lastUpdated ? data.lastUpdated.toLocaleTimeString() : "—"}.
None
} {col.items.map(function (item) { return ({d.notes}
}