+
{result.answer}
+ {result.intent && ( +
+
+ Interview records attach to an email application — this candidate was added + manually, so scheduling is unavailable here. +
+ )} > ) } @@ -1038,6 +1149,12 @@ function ActivityTab({ userId, inboxId, rows }) { >+ The activity log attaches to an email application — this candidate was added + manually, so logging is unavailable here. +
+ )} > ) } @@ -1266,6 +1383,12 @@ function FeedbackTab({ userId, inboxId, rows }) { >+ Scorecards attach to an email application — this candidate was added manually, + so submitting is unavailable here. +
+ )} > ) } diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 067afd3..d1aece4 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -141,14 +141,18 @@ export default function Candidates() { const openProfile = useCallback( (c) => { - setProfileFor(c) qc.setQueryData(qk.seed.recentlyViewed(), (old = []) => { const next = [c.id, ...old.filter((id) => id !== c.id)].slice(0, 12) persist('tf-recent', next) return next }) + // Real candidates get the full profile PAGE; the modal stays only as the + // fallback for rows without a user account. + const uid = c.userId || c.id + if (uid) navigate(`/candidate/${uid}`) + else setProfileFor(c) }, - [qc], + [qc, navigate], ) // Deep links from Talent Pool, global search, dashboard… @@ -156,11 +160,8 @@ export default function Candidates() { const st = location.state if (!st) return if (st.openAdd) setAdding(true) - if (st.openCandidate) { - const c = candidates.find((x) => x.id === st.openCandidate) - if (c) openProfile(c) - } - }, [location.state, candidates, openProfile]) + if (st.openCandidate) navigate(`/candidate/${st.openCandidate}`, { replace: true }) + }, [location.state, navigate]) const rows = useMemo(() => { const f = filters diff --git a/frontend/src/screens/Dashboard.jsx b/frontend/src/screens/Dashboard.jsx index 226b73f..c2d90d4 100644 --- a/frontend/src/screens/Dashboard.jsx +++ b/frontend/src/screens/Dashboard.jsx @@ -41,6 +41,33 @@ function dayDelta(cur, prior) { return `${d > 0 ? '-' : '+'}${Math.abs(d)} days` } +/** + * Trend chip props for one KPI. Arrow only when a delta is computable — a + * green up-arrow beside "—" reads as an improvement that never happened. For + * lower-is-better metrics (time to hire, cost per hire) the colour tracks + * goodness while the arrow tracks the data direction, so "-3 days" never + * ships with an up arrow. + */ +function trendProps(cur, prior, { lowerIsBetter = false, fmt = pctDelta } = {}) { + const text = fmt(cur, prior) + if (!text) return { trend: '—', dir: 'flat' } + const went = Number(cur) >= Number(prior) ? 'up' : 'down' + const good = lowerIsBetter ? went === 'down' : went === 'up' + return { trend: text, dir: good ? 'up' : 'down', arrow: went } +} + +/* Display order for pipeline stages: progression first, then held/terminal. + The API returns enum order, which interleaves them (PROCESS before PENDING, + CLOSED before SCREENING). */ +const STAGE_ORDER = [ + 'PENDING', 'SCREENING', 'PROCESS', 'ASSESSMENT', 'INTERVIEW', + 'OFFER', 'APPROVED', 'HIRED', 'ONHOLD', 'CLOSED', +] +const stageRank = (s) => { + const i = STAGE_ORDER.indexOf(s) + return i === -1 ? STAGE_ORDER.length : i +} + function greetingFor(now = new Date()) { const h = now.getHours() if (h < 12) return 'Good morning' @@ -250,33 +277,33 @@ export default function Dashboard() { () => asList(trendQuery.data?.applications), [trendQuery.data], ) - const hireSpark = useMemo( - () => asList(trendQuery.data?.hires), - [trendQuery.data], - ) + /* "Active by stage" means exactly that: REJECTED is excluded (matching the + Analytics screen's pipeline card), and each bar is that stage's share of + the ACTIVE total — the old base was the first row's count, which is the + PROCESS stage in enum order, so an empty PROCESS stage zeroed every bar + while the doughnut centre said candidates existed. */ const pipeRows = useMemo(() => { const rows = asList(funnelQuery.data) - const base = rows[0]?.count || 0 + .filter((r) => r.stage !== 'REJECTED') + .sort((a, b) => stageRank(a.stage) - stageRank(b.stage)) + const total = rows.reduce((sum, r) => sum + (r.count || 0), 0) const pal = Charts.PALETTE return rows.map((r, i) => ({ stage: r.stage, count: r.count, - pct: base ? Math.round((r.count / base) * 100) : 0, + pct: total ? Math.round(((r.count || 0) / total) * 100) : 0, color: pal[i % pal.length], })) }, [funnelQuery.data]) - const pipelineDoughnut = useMemo(() => { - const rows = asList(funnelQuery.data) - return { - labels: rows.map((p) => p.stage), - data: rows.map((p) => p.count), - colors: Charts.PALETTE, - centerValue: rows.reduce((sum, s) => sum + (s.count || 0), 0), - centerLabel: 'In pipeline', - } - }, [funnelQuery.data]) + const pipelineDoughnut = useMemo(() => ({ + labels: pipeRows.map((p) => p.stage), + data: pipeRows.map((p) => p.count), + colors: Charts.PALETTE, + centerValue: pipeRows.reduce((sum, s) => sum + (s.count || 0), 0), + centerLabel: 'In pipeline', + }), [pipeRows]) const legend = useMemo( () => [ @@ -293,15 +320,13 @@ export default function Dashboard() { { label: 'Open Jobs', value: dash(k?.open_jobs), - trend: pctDelta(k?.open_jobs, k?.open_jobs_prior) || '—', - dir: Number(k?.open_jobs) >= Number(k?.open_jobs_prior) ? 'up' : 'down', + ...trendProps(k?.open_jobs, k?.open_jobs_prior), spark: null, }, { label: 'Total Candidates', value: dash(k?.total_candidates), - trend: pctDelta(k?.total_candidates, k?.total_candidates_prior) || '—', - dir: Number(k?.total_candidates) >= Number(k?.total_candidates_prior) ? 'up' : 'down', + ...trendProps(k?.total_candidates, k?.total_candidates_prior), spark: candidateSpark, sparkColor: Charts.PALETTE[4], }, @@ -313,37 +338,33 @@ export default function Dashboard() { spark: null, }, { + // No sparkline: the only monthly series in the payload are applications + // and hires, and a hires line under an "Offers Accepted" label plots the + // wrong metric. label: 'Offers Accepted', value: dash(k?.offers_accepted), - trend: pctDelta(k?.offers_accepted, k?.offers_accepted_prior) || '—', - dir: Number(k?.offers_accepted) >= Number(k?.offers_accepted_prior) ? 'up' : 'down', - spark: hireSpark, - sparkColor: Charts.PALETTE[0], + ...trendProps(k?.offers_accepted, k?.offers_accepted_prior), + spark: null, }, { label: 'Time to Hire', value: k?.time_to_hire != null && !pending ? `${Math.round(k.time_to_hire)} days` : '—', - trend: dayDelta(k?.time_to_hire, k?.time_to_hire_prior) || '—', - dir: Number(k?.time_to_hire) <= Number(k?.time_to_hire_prior) ? 'up' : 'down', + ...trendProps(k?.time_to_hire, k?.time_to_hire_prior, { lowerIsBetter: true, fmt: dayDelta }), spark: null, }, { label: 'Cost per Hire', value: k?.cost_per_hire != null && !pending ? money(Math.round(k.cost_per_hire)) : '—', - trend: pctDelta(k?.cost_per_hire, k?.cost_per_hire_prior) || '—', - dir: Number(k?.cost_per_hire) <= Number(k?.cost_per_hire_prior) ? 'up' : 'down', + ...trendProps(k?.cost_per_hire, k?.cost_per_hire_prior, { lowerIsBetter: true }), spark: null, }, { + // Closed jobs means closed requisitions, full stop — the tile used to + // add hires on top, which double-counts a hire on a still-open req and + // mislabels the metric. label: 'Closed Jobs', - value: dash( - k == null ? null : Number(k.closed_jobs || 0) + Number(k.hires || 0), - ), - trend: pctDelta( - Number(k?.closed_jobs || 0) + Number(k?.hires || 0), - Number(k?.closed_jobs_prior || 0) + Number(k?.hires_prior || 0), - ) || '—', - dir: 'up', + value: dash(k?.closed_jobs), + ...trendProps(k?.closed_jobs, k?.closed_jobs_prior), spark: null, }, ] @@ -435,7 +456,9 @@ export default function Dashboard() {{jobs.length} requisitions · {openCount} currently open
reports.view permission.
+ {children}
} +{crit}
+ +{p.summary}
+ > + )} + + {p.skills.length > 0 && ( + <> +{e.description}
+ )} + {e.skills.length > 0 && ( +Source matching LinkedIn profiles for a job via Apify
++ {friendlyAuthError(jobsQuery.error, 'Could not load job posts')} +
+ )} + {jobId && statusRun && ( +
+
+ This starts a paid Apify search of LinkedIn for people matching + this job's title, technical requirements and experience level, {locationLabel} — + up to 25 profiles per run (roughly $0.20). Repeating the same search continues + deeper into the results, so each run surfaces new people; anyone already found + is refreshed, not duplicated. +
+