diff --git a/frontend/dist/index.html b/frontend/dist/index.html index f972f04..db30cad 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -23,7 +23,7 @@ - + diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 8741241..1b37bf0 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -186,8 +186,8 @@ export function toRows(res) { /** * favorite/rating live on the `inbox` row, not on the user, so the server applies * the change to EVERY application belonging to the candidate and hands back the - * refreshed detail payload. Pipeline stage is not writable here — no endpoint - * updates inbox_messages.application_status yet. + * refreshed detail payload. Pipeline stage is not writable here — it moves one + * APPLICATION at a time through PATCH /candidate/stage (api/pipeline.js). */ export function update(userId, payload) { return request('/candidate/update', { method: 'PATCH', params: { user_id: userId }, body: payload }) diff --git a/frontend/src/api/pipeline.js b/frontend/src/api/pipeline.js new file mode 100644 index 0000000..c1c0dab --- /dev/null +++ b/frontend/src/api/pipeline.js @@ -0,0 +1,122 @@ +/* ============================================================ + pipeline.js — the kanban board's endpoints (backend/job/app.py). + + The board is stitched from two modules, because there is no pipeline-specific + READ endpoint: + + - rows come from GET /candidate/fetch (api/candidates.js `list`), the + inbox -> users -> roles join, which is the only list payload carrying BOTH + `application_status` (the stage) and `inbox_id` (what the write below needs); + - the write is PATCH /candidate/stage, here. + + Stage lives on inbox_messages.application_status and the transition history in + application_stage_transitions; the server closes the open interval and opens a + new one in the same commit, so the board never has to touch history itself. + ============================================================ */ + +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. + * + * `inboxId` is the INTEGER inbox.id — the row the candidate profile returns as + * `inbox_id`, not the inbox_messages uuid the Inbox screen calls `id`; the route + * runs int() on it and 404s on anything else. + * + * The server rejects a no-op move with 400 ("already at stage"), so callers must + * not fire on a drop into the card's current column. + */ +export function changeStage({ inboxId, toStage, changeReason }) { + return request('/candidate/stage', { + method: 'PATCH', + body: { inbox_id: inboxId, to_stage: toStage, change_reason: changeReason ?? null }, + }) +} + +/** + * 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 / transitionId is required — the route 400s with neither. + */ +export function listTransitions({ inboxId, transitionId } = {}) { + return request('/pipeline/transitions/fetch', { + params: { inbox_id: inboxId, transition_id: transitionId }, + }) +} + +/** + * Candidate-profile row -> one kanban card. + * + * `id` is the inbox id, not the user id: the board is one card per APPLICATION + * and `inbox` holds one row per (user, message), so a candidate who mailed us + * three times legitimately occupies three cards with three independent stages. + * `userId` rides along for the deep link into the profile. + * + * Skills are absent by construction — the list payload carries ai_score but not + * matched_keywords (job/candidate/views.py::attach_job_posts sets only the + * score), so the card drops its tag row rather than rendering three blanks. + */ +export function toBoardCard(row) { + const jobTitle = row.job_title ?? row.assigned_job_post?.title ?? null + return { + id: row.inbox_id, + inboxId: row.inbox_id, + 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, + jobId: row.assigned_job_post_id ?? null, + jobTitle, + currentTitle: row.current_title || null, + currentCompany: row.current_employment || null, + experience: row.experience || null, + aiScore: row.ai_score ?? null, + recommendation: row.recommendation ?? null, + applied: row.created_at ? new Date(row.created_at) : null, + } +} diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 8df4a7c..6501ffd 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -36,6 +36,14 @@ export const qk = { list: (p = {}) => ['candidates', 'list', p], detail: (id) => ['candidates', 'detail', id], }, + // Board rows come from the same endpoint as qk.candidates.list but are cached + // MAPPED (kanban cards, not the raw envelope), so they need their own key — + // sharing one would poison whichever screen mounted first. + pipeline: { + all: () => ['pipeline'], + board: (p = {}) => ['pipeline', 'board', p], + transitions: (inboxId) => ['pipeline', 'transitions', inboxId], + }, analytics: { all: () => ['analytics'], kpis: (p = {}) => ['analytics', 'kpis', p], diff --git a/frontend/src/screens/Pipeline.jsx b/frontend/src/screens/Pipeline.jsx index f8dd88d..9c75bb5 100644 --- a/frontend/src/screens/Pipeline.jsx +++ b/frontend/src/screens/Pipeline.jsx @@ -1,10 +1,33 @@ +/* ============================================================ + Pipeline — the kanban board, on live backend data. + + Cards come from GET /candidate/fetch (the inbox -> users -> roles join), the + only list payload that carries the stage (`application_status`) together with + the `inbox_id` that PATCH /candidate/stage writes against. Dropping a card + fires that PATCH; the server closes the open application_stage_transitions + interval and opens a new one in the same commit. + + The job filter reads live posts from GET /job/fetch and matches on + `assigned_job_post_id`, so an application nobody has assigned to a post shows + under All Jobs only. + + The card's skill tags are gone: the list payload has ai_score 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 { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Avatar, Icon, ScoreChip } from '../ui/primitives' +import { Avatar, EmptyState, Icon, ScoreChip } from '../ui/primitives' import { useToast } from '../ui/Toast' -import { seedQuery, useSeedMutation } from '../data/seedQueries' +import { useAuth } from '../auth/AuthContext' +import { qk } from '../lib/queryKeys' +import { friendlyAuthError } from '../lib/errors' +import * as candidatesApi from '../api/candidates' +import * as jobPostsApi from '../api/jobPosts' +import * as pipelineApi from '../api/pipeline' /* Stage colours reference CSS tokens so the board re-tints with the theme. */ export const KANBAN_STAGES = [ @@ -17,6 +40,26 @@ export const KANBAN_STAGES = [ { name: 'Rejected', color: 'var(--stage-7)' }, ] +/* /candidate/fetch pages with limit/offset and has no job filter, so the board + pulls one page and filters client-side. Rows past this are not on the board — + the header says so rather than silently showing a partial pipeline. */ +const BOARD_LIMIT = 200 +const BOARD_KEY = qk.pipeline.board({ limit: BOARD_LIMIT }) + +const JOB_LIMIT = 100 + +async function fetchBoard() { + const res = await candidatesApi.list({ limit: BOARD_LIMIT }) + const rows = candidatesApi.toRows(res) + return { cards: rows.map(pipelineApi.toBoardCard), total: res?.total ?? rows.length } +} + +async function fetchJobs() { + const res = await jobPostsApi.list({ activeOnly: true, top: JOB_LIMIT }) + const rows = Array.isArray(res?.data) ? res.data : [] + return rows.map((row) => ({ id: row.id, title: 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 @@ -27,15 +70,24 @@ export const KANBAN_STAGES = [ */ export default function Pipeline() { const { toast } = useToast() + const { can } = useAuth() const navigate = useNavigate() - const { data: candidates = [] } = useQuery(seedQuery('candidates')) - const { data: jobs = [] } = useQuery(seedQuery('jobs')) - const updateCandidates = useSeedMutation('candidates') + const qc = useQueryClient() + + const board = useQuery({ queryKey: BOARD_KEY, queryFn: fetchBoard }) + const { data: jobs = [] } = useQuery({ queryKey: qk.jobPosts.list({ top: JOB_LIMIT }), queryFn: fetchJobs }) const [jobId, setJobId] = useState('') const [draggingId, setDraggingId] = useState(null) const [overStage, setOverStage] = useState(null) + /* 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 = board.data?.cards ?? [] + const total = board.data?.total ?? 0 + const list = useMemo( () => (jobId ? candidates.filter((c) => c.jobId === jobId) : candidates), [candidates, jobId], @@ -47,17 +99,52 @@ export default function Pipeline() { return map }, [list]) + /* 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. */ + const move = useMutation({ + mutationFn: ({ card, stage }) => + pipelineApi.changeStage({ + inboxId: card.inboxId, + toStage: pipelineApi.STATUS_FROM_STAGE[stage], + }), + onMutate: async ({ card, stage }) => { + await qc.cancelQueries({ queryKey: BOARD_KEY }) + const previous = qc.getQueryData(BOARD_KEY) + qc.setQueryData(BOARD_KEY, (old) => + old && { + ...old, + cards: old.cards.map((c) => (c.inboxId === card.inboxId ? { ...c, stage } : c)), + }, + ) + return { previous } + }, + onError: (err, _vars, ctx) => { + if (ctx?.previous) qc.setQueryData(BOARD_KEY, 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() }) + }, + }) + function onDrop(stage) { setOverStage(null) const id = draggingId setDraggingId(null) - if (!id) return + 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 - // Mutating the cache re-renders every screen reading candidates, so the - // move is visible on Candidates and Talent Pool too. - updateCandidates((cs) => cs.map((c) => (c.id === id ? { ...c, stage, status: stage } : c))) - toast(`${cand.name} moved to ${stage}`, 'success') + if (cand.inboxId == null) { + toast(`${cand.name} has no application to move`, 'warning') + return + } + move.mutate({ card: cand, stage }) } return ( @@ -65,12 +152,17 @@ export default function Pipeline() {

Pipeline

-

Drag candidates between stages to update their status

+

+ {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`} +

@@ -83,61 +175,69 @@ export default function Pipeline() {
-
- {KANBAN_STAGES.map((st) => { - const cards = byStage[st.name] ?? [] - return ( -
-
- -

{st.name}

- {cards.length} -
-
{ 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', c.id) - }} - onDragEnd={() => { setDraggingId(null); setOverStage(null) }} - onClick={() => { - // Don't open the profile on the click that ends a drag. - if (draggingId) return - navigate('/candidates', { state: { openCandidate: c.id } }) - }} - > -
- -
-
{c.name}
-
{c.currentTitle}
+ {board.isError ? ( + + {friendlyAuthError(board.error, 'Please try again.')} + + ) : board.isPending ? ( + Fetching applications. + ) : ( +
+ {KANBAN_STAGES.map((st) => { + const cards = byStage[st.name] ?? [] + return ( +
+
+ +

{st.name}

+ {cards.length} +
+
{ 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 Candidates screen keys its rows by users.id, so an + // application with no linked account cannot deep-link. + if (!c.userId) return + navigate('/candidates', { state: { openCandidate: c.userId } }) + }} + > +
+ +
+
{c.name}
+
{c.currentTitle}
+
+
+
{c.jobTitle}
+
+ {c.currentCompany} + {c.aiScore != null && }
-
{c.jobTitle}
-
- {c.skills.slice(0, 3).map((s) => {s})} -
-
- {c.currentCompany} - -
-
- ))} + ))} +
-
- ) - })} -
+ ) + })} +
+ )}
) }