/* ============================================================ 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 (
{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={<> } /> {board.isError ? ( {friendlyAuthError(board.error, 'Please try again.')} ) : board.isPending ? ( Fetching applications. ) : (
{KANBAN_STAGES.map((st) => { const cards = byStage[st.name] ?? [] return (

{st.name}

{stageCounts[st.name] ?? 0}
{ e.preventDefault(); setOverStage(st.name) }} onDragLeave={() => setOverStage((s) => (s === st.name ? null : s))} onDrop={(e) => { e.preventDefault(); onDrop(st.name) }} > {cards.map((c) => (
{ 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}`) }} >
{c.name} {c.source === 'Form' && ( Form )}
{c.currentTitle}
{c.jobTitle}
{c.currentCompany} {c.aiScore != null && }
))}
) })}
)}
) }