Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / frontend/src/tools/recruitmatch/Pipeline.tsx |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | frontend/src/tools/recruitmatch/Pipeline.tsx |
| Original SHA-256 | 9ae263e5d785af0b24feedf19413b1293f542d8024f2b16c43e1386cfe1d086d |
| Git blob SHA | 68bdf0274fb1d7adb06c24a3df4439150c2e9fbd |
| Reading-copy SHA-256 | 9ae263e5d785af0b24feedf19413b1293f542d8024f2b16c43e1386cfe1d086d |
Every recorded occurrence
These 2 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / frontend/src/tools/recruitmatch/Pipeline.tsx | feature/opportunity-to-outcome | 720d47fae476b0d38477ae4401697d372b1ea2bc | Open original |
| Janady13/missionmatch / frontend/src/tools/recruitmatch/Pipeline.tsx | feature/aioe-upgrade | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 | Open original |
Recovered source transcript
Download complete source text ↓408 lines · 18.1 KB
import { useState } from 'react'
import { ArrowLeft, ArrowRight, Plus, ShieldCheck, UserX } from 'lucide-react'
import { Link, useParams } from 'react-router-dom'
import { ToolHeader } from '../../components/ToolHeader'
import { Modal, Panel } from '../../components/ui'
import { useCandidates } from '../../lib/queries'
import type { RApplication, RFunnelStage } from '../../lib/types'
import { recruitBrand } from './brand'
import { useDispositions, useMoveStage, usePipelineBoard, useSubmitCandidate } from './pipelineQueries'
const inputCls =
'w-full rounded-lg border border-line/70 bg-navy-900 px-3 py-2 text-sm text-ink outline-none focus:border-recruit'
// The ladder, mirrored so the board can offer the next and previous rung. The server is the authority on
// whether a move is legal; this only decides what is offered.
const LADDER = ['sourced', 'screened', 'submitted', 'interviewing', 'offered', 'placed']
function stageColor(stage: string): string {
if (stage === 'placed') return 'var(--color-ok)'
if (stage === 'rejected' || stage === 'withdrawn') return 'var(--color-muted)'
if (stage === 'offered') return 'var(--color-gold)'
return 'var(--color-blue-soft)'
}
// The funnel bar. Width is the share of the widest rung, so the shape reads as a funnel; the label
// carries the actual count so the picture never has to be trusted on its own.
function FunnelBar({ stage, max }: { stage: RFunnelStage; max: number }) {
const pct = max > 0 ? (stage.count / max) * 100 : 0
return (
<div className="flex items-center gap-3">
<span className="w-24 shrink-0 text-[11px] font-semibold text-muted">{stage.label}</span>
<div className="h-6 flex-1 overflow-hidden rounded-md bg-navy-950/60">
<div
className="flex h-full items-center justify-end rounded-md px-2 transition-all"
style={{
width: `${Math.max(pct, stage.count > 0 ? 6 : 0)}%`,
background: `linear-gradient(90deg, color-mix(in srgb, ${stageColor(stage.stage)} 55%, transparent), ${stageColor(stage.stage)})`,
}}
>
{stage.count > 0 && <span className="text-[10px] font-bold text-white">{stage.count}</span>}
</div>
</div>
<span className="w-14 shrink-0 text-right text-[11px] text-faint">{stage.pct}%</span>
</div>
)
}
export function Pipeline() {
const { reqId } = useParams()
const q = usePipelineBoard(reqId ?? null)
const disp = useDispositions()
const submit = useSubmitCandidate(reqId ?? '')
const move = useMoveStage(reqId ?? undefined)
// The candidate list is reused rather than duplicated: submitting to a pipeline is choosing from the
// bench the rest of the product already maintains. Through the shared hook, not a second inline query
// on the same key, so any option added there (a select, a staleTime) actually reaches this screen.
const candidates = useCandidates()
const [addOpen, setAddOpen] = useState(false)
const [candId, setCandId] = useState('')
const [startStage, setStartStage] = useState('sourced')
const [touch, setTouch] = useState('')
const [disposing, setDisposing] = useState<{ app: RApplication; to: string } | null>(null)
const [code, setCode] = useState('')
const [reason, setReason] = useState('')
if (q.isLoading) return <Panel className="grid place-items-center py-24 text-muted">Loading pipeline…</Panel>
if (q.isError || !q.data) {
return (
<Panel className="grid place-items-center py-24 text-warn">
Could not load this pipeline. {(q.error as Error)?.message}
</Panel>
)
}
const b = q.data
const maxRung = b.funnel.length ? b.funnel[0].count : 0
const doMove = (app: RApplication, to: string) => {
if (to === 'rejected' || to === 'withdrawn') {
setDisposing({ app, to })
setCode('')
setReason('')
return
}
move.mutate({ id: app.id, expected_stage: app.stage, to_stage: to })
}
const confirmDisposition = () => {
if (!disposing) return
move.mutate(
{
id: disposing.app.id,
expected_stage: disposing.app.stage,
to_stage: disposing.to,
disposition_code: code,
reason,
},
{ onSuccess: () => setDisposing(null) },
)
}
const options = (disp.data?.dispositions ?? []).filter(
(d) => !disposing || d.outcome === '' || d.outcome === disposing.to,
)
return (
<div className="space-y-4">
<ToolHeader name={recruitBrand.name} tagline="Pipeline" accent={recruitBrand.accent}>
<Link
to={`/recruit/requisitions/${reqId}`}
className="flex items-center gap-2 rounded-lg border border-line/70 px-3 py-2 text-sm font-semibold text-muted hover:text-ink"
>
<ArrowLeft size={14} />
Job order
</Link>
<button
onClick={() => setAddOpen(true)}
className="flex items-center gap-2 rounded-lg bg-gradient-to-r from-blue to-navy-700 px-4 py-2.5 text-sm font-bold text-white hover:brightness-110"
>
<Plus size={14} />
Add candidate
</button>
</ToolHeader>
<Panel>
<div className="flex flex-wrap items-baseline justify-between gap-3">
<h2 className="text-lg font-extrabold tracking-tight">{b.requisition_title}</h2>
<p className="text-[11px] text-faint">
{b.total} in pipeline · {b.active} active · {b.placed} of {b.openings} placed ·{' '}
<strong style={{ color: b.seats_left === 0 ? 'var(--color-ok)' : 'var(--color-muted)' }}>
{b.seats_left === 0 ? 'all seats filled' : `${b.seats_left} seat${b.seats_left === 1 ? '' : 's'} left`}
</strong>
</p>
</div>
</Panel>
<div className="grid gap-4 lg:grid-cols-2">
<Panel title="Funnel" hint="everyone who ever reached each stage">
{/* Counted as high water marks, not current position. A candidate rejected at interview still
counts as having reached interview, which is why this series can never widen. */}
<div className="space-y-2">
{b.funnel.map((s) => (
<FunnelBar key={s.stage} stage={s} max={maxRung} />
))}
</div>
{b.total === 0 && (
<p className="mt-3 text-center text-[11px] text-faint">
No candidates yet. Add one to start the pipeline.
</p>
)}
</Panel>
<Panel title="Stage conversion" hint="share continuing to the next stage">
{b.total === 0 ? (
<p className="py-6 text-center text-[11px] text-faint">Conversion appears once candidates move.</p>
) : (
<ul className="space-y-1.5">
{b.conversion.map((c) => (
<li key={`${c.from}-${c.to}`} className="flex items-center gap-2 text-[11px]">
<span className="w-40 shrink-0 text-muted">
{c.from_label} <ArrowRight size={9} className="inline" /> {c.to_label}
</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-navy-950/60">
<div className="h-full rounded-full bg-recruit" style={{ width: `${c.rate}%` }} />
</div>
<span className="w-14 shrink-0 text-right font-semibold">{c.rate}%</span>
</li>
))}
</ul>
)}
{b.dispositions.length > 0 && (
<div className="mt-4 border-t border-line/50 pt-3">
<p className="mb-1.5 text-[10px] font-bold uppercase tracking-wider text-faint">
Why candidates left
</p>
{/* Coded, not prose, because this is the grouping an adverse impact review needs. */}
<ul className="space-y-1">
{b.dispositions.map((d) => (
<li key={d.code} className="flex justify-between text-[11px]">
<span className="text-muted">
{disp.data?.dispositions.find((x) => x.code === d.code)?.label ?? d.code}
</span>
<span className="text-faint">
{d.count} · {d.pct}%
</span>
</li>
))}
</ul>
</div>
)}
</Panel>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{b.columns.map((col) => (
<Panel key={col.stage} title={col.label} hint={String(col.count)}>
{col.applications.length === 0 ? (
<p className="py-4 text-center text-[11px] text-faint">Nobody here.</p>
) : (
<ul className="space-y-2">
{col.applications.map((a) => {
const i = LADDER.indexOf(a.stage)
const next = i >= 0 && i + 1 < LADDER.length ? LADDER[i + 1] : null
const prev = i > 0 ? LADDER[i - 1] : null
return (
<li key={a.id} className="rounded-lg border border-line/60 bg-navy-950/40 p-2.5">
<Link
to={`/recruit/applications/${a.id}`}
className="block truncate text-sm font-semibold hover:text-recruit"
>
{a.candidate_name}
</Link>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[10px] text-faint">
{/* Clearance is a self reported indicator throughout this product, never a
verified status. The client FSO verifies through DISS at onboarding. */}
<span className="flex items-center gap-1" title="Self reported clearance indicator">
<ShieldCheck size={9} />
{a.clearance_level.replace(/_/g, ' ')}
</span>
<span>{a.days_in_stage.toFixed(1)}d here</span>
{a.furthest_stage !== a.stage && <span>reached {a.furthest_stage_label}</span>}
</p>
{a.disposition_code ? (
<p className="mt-1 text-[10px] text-muted">
{disp.data?.dispositions.find((x) => x.code === a.disposition_code)?.label ??
a.disposition_code}
</p>
) : (
<div className="mt-1.5 flex flex-wrap gap-1">
{prev && (
<button
onClick={() => doMove(a, prev)}
disabled={move.isPending}
className="rounded border border-line/60 px-1.5 py-0.5 text-[10px] font-semibold text-faint hover:text-ink disabled:opacity-50"
title={`Back to ${prev}`}
>
<ArrowLeft size={9} />
</button>
)}
{next && (
<button
onClick={() => doMove(a, next)}
disabled={move.isPending}
className="rounded border border-recruit/50 px-1.5 py-0.5 text-[10px] font-semibold text-recruit hover:bg-recruit/10 disabled:opacity-50"
>
{next} <ArrowRight size={9} className="inline" />
</button>
)}
<button
onClick={() => doMove(a, 'rejected')}
disabled={move.isPending}
className="rounded border border-line/60 px-1.5 py-0.5 text-[10px] font-semibold text-faint hover:text-risk disabled:opacity-50"
title="Disposition"
>
<UserX size={9} />
</button>
</div>
)}
</li>
)
})}
</ul>
)}
</Panel>
))}
</div>
{move.isError && (
<p className="rounded-lg border border-risk/40 bg-navy-950/40 p-3 text-xs text-risk">
{(move.error as Error).message}
</p>
)}
<Modal open={addOpen} onClose={() => setAddOpen(false)} title="Add a candidate to this pipeline">
<div className="space-y-3">
<label className="flex flex-col gap-1.5">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted">Candidate</span>
<select value={candId} onChange={(e) => setCandId(e.target.value)} className={inputCls}>
<option value="">Choose a candidate</option>
{(candidates.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.full_name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted">Start at</span>
{/* A known candidate is often submitted straight to the client rather than walked up from
sourced, so the starting rung is a choice. It cannot start at an outcome. */}
<select value={startStage} onChange={(e) => setStartStage(e.target.value)} className={inputCls}>
{LADDER.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted">Source</span>
<input
value={touch}
onChange={(e) => setTouch(e.target.value)}
placeholder="referral, job board, bench"
className={inputCls}
/>
</label>
{submit.isError && <p className="text-xs text-risk">{(submit.error as Error).message}</p>}
<div className="flex justify-end gap-2 pt-1">
<button
onClick={() => setAddOpen(false)}
className="rounded-lg border border-line/70 px-3 py-2 text-sm font-semibold text-muted hover:text-ink"
>
Cancel
</button>
<button
onClick={() =>
submit.mutate(
{ candidate_id: candId, stage: startStage, source_touch: touch },
{
onSuccess: () => {
setCandId('')
setTouch('')
setStartStage('sourced')
setAddOpen(false)
},
},
)
}
disabled={!candId || submit.isPending}
className="rounded-lg bg-gradient-to-r from-blue to-navy-700 px-4 py-2 text-sm font-bold text-white hover:brightness-110 disabled:opacity-50"
>
Add
</button>
</div>
</div>
</Modal>
<Modal open={!!disposing} onClose={() => setDisposing(null)} title="Why is this candidate leaving?">
<div className="space-y-3">
<p className="text-sm text-muted">
{disposing?.app.candidate_name} will be marked{' '}
<strong>{disposing?.to === 'withdrawn' ? 'withdrawn' : 'rejected'}</strong>.
</p>
<div className="flex gap-2">
{(['rejected', 'withdrawn'] as const).map((t) => (
<button
key={t}
onClick={() => {
setDisposing(disposing ? { ...disposing, to: t } : null)
setCode('')
}}
className={`rounded-full px-3 py-1 text-xs font-semibold ${
disposing?.to === t
? 'bg-recruit/25 text-recruit shadow-[inset_0_0_0_1px_var(--color-recruit)]'
: 'border border-line/60 bg-navy-900 text-faint hover:text-muted'
}`}
>
{t === 'rejected' ? 'We passed' : 'They withdrew'}
</button>
))}
</div>
<label className="flex flex-col gap-1.5">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted">Reason</span>
{/* A closed vocabulary, served by the API. It is what makes an adverse impact report
computable, and free text here would make it impossible. */}
<select value={code} onChange={(e) => setCode(e.target.value)} className={inputCls}>
<option value="">Choose a reason</option>
{options.map((d) => (
<option key={d.code} value={d.code}>
{d.label}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted">Note (optional)</span>
<textarea value={reason} onChange={(e) => setReason(e.target.value)} rows={2} className={inputCls} />
</label>
<p className="text-[10px] text-faint">
The reason is recorded in a fixed vocabulary so selection rates can be reported by reason. It
never records anything about the candidate as a person.
</p>
{move.isError && <p className="text-xs text-risk">{(move.error as Error).message}</p>}
<div className="flex justify-end gap-2 pt-1">
<button
onClick={() => setDisposing(null)}
className="rounded-lg border border-line/70 px-3 py-2 text-sm font-semibold text-muted hover:text-ink"
>
Cancel
</button>
<button
onClick={confirmDisposition}
disabled={!code || move.isPending}
className="rounded-lg bg-gradient-to-r from-blue to-navy-700 px-4 py-2 text-sm font-bold text-white hover:brightness-110 disabled:opacity-50"
>
Confirm
</button>
</div>
</div>
</Modal>
</div>
)
}