pipline code done

pull/14/head
ahmed.mujtaba 2026-08-13 13:13:19 +05:00
parent 1491931d95
commit 3b7e83a4a7
5 changed files with 298 additions and 68 deletions

View File

@ -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>

View File

@ -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 })

View File

@ -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,
}
}

View File

@ -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],

View File

@ -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>
)
}