HR-ATS-Portal/frontend/src/screens/Pipeline.jsx

312 lines
12 KiB
JavaScript

/* ============================================================
Pipeline — the kanban board, on live backend data.
Cards and column counts come from GET /pipeline/candidates/fetch. Dropping a
card fires PATCH /candidate/stage with `inbox_id` or `manual_upload_id`. The
server closes the open application_stage_transitions interval and opens a
new one in the same commit.
The job filter is server-side (`job_post_id`). Applications with no assigned
post are omitted by the list query, so they do not appear under All Jobs.
The card's skill tags are gone: the list payload has ats_result but no
matched_keywords, and the Candidates screen set the precedent that a column
with no source is dropped rather than rendered as blanks.
============================================================ */
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as jobPostsApi from '../api/jobPosts'
import * as pipelineApi from '../api/pipeline'
import { ReappliedBadge } from '../components/ReapplicantHistory'
/* Stage colours reference CSS tokens so the board re-tints with the theme. */
export const KANBAN_STAGES = [
{ name: 'CLOSED', color: 'var(--stage-9)' },
{ name: 'Shortlist', color: 'var(--stage-1)' },
{ name: 'Screening', color: 'var(--stage-2)' },
{ name: 'Assessment', color: 'var(--stage-3)' },
{ name: 'Interview', color: 'var(--stage-4)' },
{ name: 'Offer', color: 'var(--stage-5)' },
{ name: 'Approved', color: 'var(--stage-8)' },
{ name: 'Hired', color: 'var(--stage-6)' },
{ name: 'On Hold', color: 'var(--stage-9)' },
{ name: 'Rejected', color: 'var(--stage-7)' },
]
const BOARD_LIMIT = 200
const JOB_LIMIT = 100
/**
* Highest AI score first, unscored candidates last, newest first within a tie.
*
* The API sorts each list this way already, but it returns `inbox` and
* `manual_upload` as two arrays from two queries — concatenating them would
* rank each source separately and show two descending runs per column. One
* ranking across both sources can only happen after the merge.
*/
function byScoreDesc(a, b) {
if (a.aiScore == null && b.aiScore == null) return (b.applied ?? 0) - (a.applied ?? 0)
if (a.aiScore == null) return 1
if (b.aiScore == null) return -1
return b.aiScore - a.aiScore || (b.applied ?? 0) - (a.applied ?? 0)
}
function mapCards(rows, mapper) {
const cards = []
for (const row of rows) {
try {
const card = mapper(row)
if (card) cards.push(card)
} catch {
// A poisoned row must not fail the query (that is the "Could not load"
// empty state) or reach the card renderer (ErrorBoundary).
}
}
return cards
}
async function fetchBoard(jobId) {
const res = await pipelineApi.listApplications({ jobId, limit: BOARD_LIMIT })
const inbox = Array.isArray(res?.data?.inbox) ? res.data.inbox : []
const manuals = Array.isArray(res?.data?.manual_upload) ? res.data.manual_upload : []
return {
cards: [
...mapCards(inbox, (row) => pipelineApi.toBoardCard(row)),
...mapCards(manuals, (row) => pipelineApi.toManualBoardCard(row)),
].sort(byScoreDesc),
total: res?.total ?? 0,
stageCounts: pipelineApi.toStageCounts(res?.counts?.by_status),
}
}
async function fetchJobs() {
const res = await jobPostsApi.list({ activeOnly: true, top: JOB_LIMIT })
const rows = Array.isArray(res?.data) ? res.data : []
return rows
.filter((row) => row && row.id != null)
.map((row) => ({
id: row.id,
title: typeof row.title === 'string' ? row.title : String(row.title ?? ''),
}))
}
/**
* Native HTML5 drag-and-drop, kept rather than swapped for a library. React
* supports draggable/onDragStart/onDragOver/onDrop as props, the frozen CSS
* already styles `.dragging` and `.drag-over`, and the prototype has no touch
* drag either — so adopting @dnd-kit would be a feature addition smuggled into
* a 1:1 port. If touch kanban is wanted it is a scoped follow-up confined to
* this file.
*/
export default function Pipeline() {
const { toast } = useToast()
const { can } = useAuth()
const navigate = useNavigate()
const qc = useQueryClient()
const [jobId, setJobId] = useState('')
const [draggingId, setDraggingId] = useState(null)
const [overStage, setOverStage] = useState(null)
const boardKey = useMemo(
() => qk.pipeline.board({ limit: BOARD_LIMIT, jobId: jobId || null }),
[jobId],
)
const board = useQuery({
queryKey: boardKey,
queryFn: () => fetchBoard(jobId),
placeholderData: keepPreviousData,
})
const jobsQuery = useQuery({
queryKey: qk.jobPosts.list({ top: JOB_LIMIT, scope: 'pipeline' }),
queryFn: fetchJobs,
})
const jobs = Array.isArray(jobsQuery.data) ? jobsQuery.data : []
/* The route is behind pipeline.view, but the WRITE needs pipeline.edit — a
viewer gets a read-only board instead of drags that 403 on drop. */
const canEdit = can('pipeline.edit')
const candidates = Array.isArray(board.data?.cards) ? board.data.cards.filter(Boolean) : []
const total = board.data?.total ?? 0
const stageCounts = board.data?.stageCounts && typeof board.data.stageCounts === 'object'
? board.data.stageCounts
: {}
const byStage = useMemo(() => {
const map = Object.fromEntries(KANBAN_STAGES.map((s) => [s.name, []]))
for (const c of candidates) {
if (c && map[c.stage]) map[c.stage].push(c)
}
return map
}, [candidates])
/* Optimistic: a drag that only repaints after the round trip reads as a failed
drop. The card snaps back on error and the server's own value wins on the
refetch in onSettled. Counts move with the card so badges do not lag. */
const move = useMutation({
mutationFn: ({ card, stage }) =>
pipelineApi.changeStage({
inboxId: card.inboxId,
manualUploadId: card.manualUploadId,
toStage: pipelineApi.STATUS_FROM_STAGE[stage],
}),
onMutate: async ({ card, stage }) => {
await qc.cancelQueries({ queryKey: boardKey })
const previous = qc.getQueryData(boardKey)
qc.setQueryData(boardKey, (old) => {
if (!old || !Array.isArray(old.cards)) return old
const from = card.stage
const nextCounts = { ...(old.stageCounts || {}) }
if (from && from !== stage) {
nextCounts[from] = Math.max(0, (nextCounts[from] ?? 0) - 1)
nextCounts[stage] = (nextCounts[stage] ?? 0) + 1
}
return {
...old,
cards: old.cards.map((c) => (c.id === card.id ? { ...c, stage } : c)),
stageCounts: nextCounts,
}
})
return { previous }
},
onError: (err, _vars, ctx) => {
if (ctx?.previous) qc.setQueryData(boardKey, ctx.previous)
toast(friendlyAuthError(err, 'Could not move the candidate.'), 'error')
},
onSuccess: (_data, { card, stage }) => toast(`${card.name} moved to ${stage}`, 'success'),
onSettled: () => {
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
// Stage lives on the inbox row every candidate screen reads, so their
// caches are stale too the moment this lands.
qc.invalidateQueries({ queryKey: qk.candidates.all() })
// Dashboard / Analytics funnel is a separate cache; without this a drag
// to Interview leaves the doughnut on the previous snapshot for up to 60s.
qc.invalidateQueries({ queryKey: qk.analytics.all() })
},
})
function onDrop(stage) {
setOverStage(null)
const id = draggingId
setDraggingId(null)
if (id == null || !canEdit) return
const cand = candidates.find((c) => c.id === id)
// A no-op move is a 400 server-side ("already at stage"), so it never leaves.
if (!cand || cand.stage === stage) return
if (cand.inboxId == null && cand.manualUploadId == null) {
toast(`${cand.name} has no application to move`, 'warning')
return
}
move.mutate({ card: cand, stage })
}
return (
<div className="page">
<PageHeader
title="Pipeline"
sub={<>
{canEdit
? 'Drag candidates between stages to update their status'
: 'Read-only — moving a candidate needs the pipeline.edit permission'}
{total > candidates.length && ` · showing ${candidates.length} of ${total} applications`}
</>}
actions={<>
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
<option value="">All Jobs</option>
{jobs.map((j) => (
<option key={j.id} value={j.id}>{j.title || 'Untitled'}</option>
))}
</select>
<button
className="btn btn-primary"
onClick={() => navigate('/candidates', { state: { openAdd: true } })}
>
<Icon name="plus" /> Add Candidate
</button>
</>}
/>
{board.isError ? (
<EmptyState title="Could not load the pipeline">
{friendlyAuthError(board.error, 'Please try again.')}
</EmptyState>
) : board.isPending ? (
<EmptyState title="Loading pipeline…">Fetching applications.</EmptyState>
) : (
<div className="kanban">
{KANBAN_STAGES.map((st) => {
const cards = byStage[st.name] ?? []
return (
<div className="kanban-col" key={st.name}>
<div className="kanban-col-head">
<span className="k-dot" style={{ background: st.color }} />
<h4>{st.name}</h4>
<span className="k-count">{stageCounts[st.name] ?? 0}</span>
</div>
<div
className={`kanban-cards${overStage === st.name ? ' drag-over' : ''}`}
onDragOver={(e) => { e.preventDefault(); setOverStage(st.name) }}
onDragLeave={() => setOverStage((s) => (s === st.name ? null : s))}
onDrop={(e) => { e.preventDefault(); onDrop(st.name) }}
>
{cards.map((c) => (
<div
key={c.id}
className={`k-card${draggingId === c.id ? ' dragging' : ''}`}
draggable={canEdit}
onDragStart={(e) => {
setDraggingId(c.id)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(c.id))
}}
onDragEnd={() => { setDraggingId(null); setOverStage(null) }}
onClick={() => {
// Don't open the profile on the click that ends a drag.
if (draggingId) return
// The profile page keys off users.id, so an application
// with no linked account cannot deep-link.
if (!c.userId) return
navigate(`/candidate/${c.userId}`)
}}
>
<div className="k-card-top">
<Avatar name={c.name} />
<div>
<div className="kc-name" style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span>{c.name}</span>
{c.source === 'Form' && (
<Badge className="b-gray" style={{ fontSize: 10 }}>Form</Badge>
)}
<ReappliedBadge row={c} />
</div>
<div className="kc-role">{c.currentTitle}</div>
</div>
</div>
<div className="kc-role">{c.jobTitle}</div>
<div className="k-card-meta">
<span className="cell-sub">{c.currentCompany}</span>
{c.aiScore != null && <ScoreChip score={c.aiScore} />}
</div>
</div>
))}
</div>
</div>
)
})}
</div>
)}
</div>
)
}