312 lines
12 KiB
JavaScript
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'
|
|
import { openCandidateProfile } from '../lib/candidateBrowse'
|
|
|
|
/* Stage colours reference CSS tokens so the board re-tints with the theme. */
|
|
export const KANBAN_STAGES = [
|
|
{ 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
|
|
openCandidateProfile(navigate, c.userId, candidates)
|
|
}}
|
|
>
|
|
<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>
|
|
)
|
|
}
|