pipline code done
parent
1491931d95
commit
3b7e83a4a7
|
|
@ -23,7 +23,7 @@
|
|||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
|
||||
<script type="module" crossorigin src="/assets/index-Dz75jpEA.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-D1YVNnju.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Pipeline</h1>
|
||||
<p className="page-sub">Drag candidates between stages to update their status</p>
|
||||
<p className="page-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`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
|
||||
<option value="">All Jobs</option>
|
||||
{jobs.filter((j) => j.status === 'Open').map((j) => (
|
||||
{jobs.map((j) => (
|
||||
<option key={j.id} value={j.id}>{j.title}</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -83,61 +175,69 @@ export default function Pipeline() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<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">{cards.length}</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
|
||||
onDragStart={(e) => {
|
||||
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 } })
|
||||
}}
|
||||
>
|
||||
<div className="k-card-top">
|
||||
<Avatar name={c.name} initials={c.initials} color={c.color} />
|
||||
<div>
|
||||
<div className="kc-name">{c.name}</div>
|
||||
<div className="kc-role">{c.currentTitle}</div>
|
||||
{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">{cards.length}</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 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 } })
|
||||
}}
|
||||
>
|
||||
<div className="k-card-top">
|
||||
<Avatar name={c.name} />
|
||||
<div>
|
||||
<div className="kc-name">{c.name}</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 className="kc-role">{c.jobTitle}</div>
|
||||
<div className="k-tags">
|
||||
{c.skills.slice(0, 3).map((s) => <span className="tag" key={s}>{s}</span>)}
|
||||
</div>
|
||||
<div className="k-card-meta">
|
||||
<span className="cell-sub">{c.currentCompany}</span>
|
||||
<ScoreChip score={c.aiScore} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue