feat(hiring): final-selection queue, honest seat counts, and human interviews
Authored in a parallel session alongside the TypeScript migration; committed separately so the two never share a commit. No TypeScript migration file is included here. Candidates becomes the queue of hiring decisions waiting on a person, rather than a second Talent Pool listing every application the org ever took. Final selection is DERIVED - there is no `final_selection` value in the `application_status` enum and none is added. The fact it reads is the existence of an interview row, a NOT NULL foreign key, rather than `job_applications.interview_id`, which the schema keeps as an unconstrained soft reference precisely so it may dangle. `status = 'interview'` is set both when an interview is arranged and when one is completed, so status alone cannot tell a queue of people who have been interviewed from a queue of people merely booked in. Seats on a position are counted from the employment records instead of a stored column. A `filled` counter would be a second source of truth, and the day it disagreed with `staff` nothing could say which was lying. Someone who has left frees their seat, and over-hiring floors at zero rather than going negative. `DEMO_FILL` is gone. `hiringRecords.js` padded the hires list with five invented people so Hired History read as a history rather than as three rows; the padding reached Analytics too, where "total hires" counted eight against a database holding three. Hires now come only from `staff`. Both paths that file an application on somebody's behalf now carry `worker_profile_id`, the link back to the talent-pool record. The column is nullable, so omitting it saved cleanly and failed silently: the application belonged to an email address rather than to a person, and the hire it became could not be traced back to the profile it came from. `HiredChronology` used to `return null` with no hires, taking the `chronology` node identity out of the DOM with it - so on an honest empty dataset the section could not be addressed by Owliver or the layout editor at all. It now renders an empty state inside the section it keeps. Nine new checks cover the above; `npm test` reports 1684/1691. SIX SSR PARITY CHECKS FAIL ON PURPOSE, and `scripts/__baseline__/README.md` documents each with verified tag counts. Five are the `DEMO_FILL` removal: the Hired History and Analytics baselines were captured while the padding was in effect and, because they render with queries disabled, the padding is all they contain. The sixth is this change to what Candidates says. Do not regenerate those baselines to clear them - two of the checks exist to prove the UI node tree migration added exactly two `<div>`s, and that proof needs the baseline to be pre-migration markup. Recapturing now would write post-migration markup into a file named `pre-migration` and the check would compare the current render against itself forever. The debt is held until the migration work lands, when both files are recaptured together. The seventh failure, the stale backend seed fixture, predates all of this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
@@ -147,11 +147,21 @@ export default function PositionDetail() {
|
||||
const [messageApp, setMessageApp] = useState(null);
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
|
||||
// Turn a matched pool talent into an applicant so they enter the pipeline
|
||||
/**
|
||||
* Turn a matched pool talent into an applicant so they enter the pipeline.
|
||||
*
|
||||
* `worker_profile_id` is the link back to the talent-pool record, and it is
|
||||
* what makes this one person applying to several positions rather than a
|
||||
* fresh stranger each time. The column is nullable, so leaving it out still
|
||||
* saved — and every join from a hire back to the person it was then had to
|
||||
* fall back to matching email strings. `staff` copies this id across on hire,
|
||||
* so setting it here is what completes talent pool → application → employee.
|
||||
*/
|
||||
const admitTalent = async (profile, status = 'applied') => {
|
||||
const existing = jobApps.find((a) => a.email && profile.email && a.email.toLowerCase() === profile.email.toLowerCase());
|
||||
if (existing) return existing;
|
||||
return createApp.mutateAsync({
|
||||
worker_profile_id: profile.id,
|
||||
applicant_name: profile.full_name,
|
||||
email: profile.email || '',
|
||||
phone: profile.phone || '',
|
||||
|
||||
@@ -10,7 +10,7 @@ import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/analytics/nodes';
|
||||
import {
|
||||
buildEfficiency, buildFunnel, buildInsights, buildTrend, byDepartment, byPosition,
|
||||
hiresWithFill, summarise,
|
||||
buildHires, summarise,
|
||||
} from '@/lib/hiringRecords';
|
||||
|
||||
/**
|
||||
@@ -41,7 +41,7 @@ export default function AdminAnalytics() {
|
||||
const { data: postings = [] } = useJobPostings();
|
||||
|
||||
const hires = useMemo(
|
||||
() => hiresWithFill({ staff, applications, postings }),
|
||||
() => buildHires({ staff, applications, postings }),
|
||||
[staff, applications, postings]
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { useApplications, useInterviews, useJobPostings, useUpdateApplication, useHireCandidate } from '@/lib/krowHooks';
|
||||
import { isFinalSelection } from '@/lib/hiringRecords';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
@@ -23,17 +24,60 @@ const SCORE_BANDS = {
|
||||
unscored: (s) => !s,
|
||||
};
|
||||
|
||||
/**
|
||||
* The queue this page exists to be.
|
||||
*
|
||||
* Candidates is not a second Talent Pool. It is the final-selection queue: the
|
||||
* people whose interview is done and who are waiting on a human to decide. So
|
||||
* that is what it opens on, and the other stages stay reachable behind the
|
||||
* filter rather than crowding the decision.
|
||||
*/
|
||||
export const DEFAULT_STAGE = 'final';
|
||||
|
||||
/**
|
||||
* Which applications a stage selection admits.
|
||||
*
|
||||
* `final` defers to `isFinalSelection`, which reads `ai_interviews.application_id`
|
||||
* — a NOT NULL foreign key, so "the interview happened" is a fact in the
|
||||
* database. Three things it deliberately does NOT do:
|
||||
*
|
||||
* - It does not trust `job_applications.interview_id`. That is a soft
|
||||
* reference, not an FK, and it is already dangling in seed data: Devon
|
||||
* Carter carries one with no interview row behind it. Trusting it would put
|
||||
* a candidate who was never interviewed in front of a hiring decision.
|
||||
* - It does not gate on `ai_score`, `verdict` or `hire_recommendation`. Those
|
||||
* assist the recruiter; they never make the selection. Dana Whitfield scored
|
||||
* 0 and completed an interview — a score gate would silently drop her.
|
||||
* - It does not read `screened_at`, which nothing writes.
|
||||
*
|
||||
* `interview` is the honest counterpart: everyone whose application currently
|
||||
* sits at that status, *including* the ones whose completion evidence is
|
||||
* missing. That is where Devon Carter shows up, and where the gap is visible
|
||||
* rather than silent.
|
||||
*
|
||||
* Every other value is the application status itself — the field the rows
|
||||
* actually carry. The bug this replaces compared against `a.stage`, which no
|
||||
* application has, so choosing any stage emptied the page.
|
||||
*/
|
||||
const matchesStage = (application, stage, interviews) => {
|
||||
if (stage === 'all') return true;
|
||||
if (stage === 'final') return isFinalSelection(application, interviews);
|
||||
return application.status === stage;
|
||||
};
|
||||
|
||||
|
||||
export default function AdminCandidates() {
|
||||
const { data: applications = [], isLoading } = useApplications();
|
||||
const { data: postings = [] } = useJobPostings();
|
||||
useInterviews();
|
||||
/* Fetched and then discarded until now, which is why the queue could not be
|
||||
derived: the interview rows are the evidence the derivation runs on. */
|
||||
const { data: interviews = [] } = useInterviews();
|
||||
const updateApp = useUpdateApplication();
|
||||
const hireCandidate = useHireCandidate();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [position, setPosition] = useState('all');
|
||||
const [stage, setStage] = useState('all');
|
||||
const [stage, setStage] = useState(DEFAULT_STAGE);
|
||||
const [band, setBand] = useState('all');
|
||||
const [sort, setSort] = useState('score');
|
||||
|
||||
@@ -88,15 +132,18 @@ export default function AdminCandidates() {
|
||||
.filter((a) => {
|
||||
if (q && !`${a.applicant_name} ${a.email} ${a.job_title} ${(a.skills || []).join(' ')}`.toLowerCase().includes(q)) return false;
|
||||
if (position !== 'all' && a.job_title !== position) return false;
|
||||
if (stage !== 'all' && a.stage !== stage) return false;
|
||||
if (!matchesStage(a, stage, interviews)) return false;
|
||||
if (band !== 'all' && !SCORE_BANDS[band](a.ai_score)) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(SORTS[sort].compare);
|
||||
}, [applications, search, position, stage, band, sort]);
|
||||
}, [applications, interviews, search, position, stage, band, sort]);
|
||||
|
||||
const isFiltered = Boolean(search) || [position, stage, band].some((v) => v !== 'all');
|
||||
const clearFilters = () => { setSearch(''); setPosition('all'); setStage('all'); setBand('all'); };
|
||||
/* The queue is the resting state, so sitting in it is not "filtered" —
|
||||
clearing returns you to the decisions waiting, not to every application
|
||||
the org has ever taken. */
|
||||
const isFiltered = Boolean(search) || position !== 'all' || band !== 'all' || stage !== DEFAULT_STAGE;
|
||||
const clearFilters = () => { setSearch(''); setPosition('all'); setStage(DEFAULT_STAGE); setBand('all'); };
|
||||
|
||||
const postingById = useMemo(() => Object.fromEntries(postings.map(p => [p.id, p])), [postings]);
|
||||
const resolveTitle = (a) => a.job_title || postingById[a.job_posting_id]?.title || '';
|
||||
@@ -105,7 +152,8 @@ export default function AdminCandidates() {
|
||||
applications, filtered, isLoading, isFiltered, clearFilters, positions, resolveTitle,
|
||||
search, setSearch, position, setPosition, stage, setStage, band, setBand, sort, setSort,
|
||||
handleAction, setMessageApp, setScheduleApp, handleDecline, handleDelete, handleHire,
|
||||
}), [applications, filtered, isLoading, isFiltered, positions, search, position, stage, band, sort]);
|
||||
interviews,
|
||||
}), [applications, interviews, filtered, isLoading, isFiltered, positions, search, position, stage, band, sort]);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Avatar, Badge, Drawer, StatusBadge, Surface } from '@/components/ds';
|
||||
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
|
||||
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { hiresWithFill, summarise } from '@/lib/hiringRecords';
|
||||
import { buildHires, summarise } from '@/lib/hiringRecords';
|
||||
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
@@ -126,7 +126,7 @@ export default function AdminHiredHistory() {
|
||||
const [selected, setSelected] = useState(null);
|
||||
|
||||
const hires = useMemo(
|
||||
() => hiresWithFill({ staff, applications, postings }),
|
||||
() => buildHires({ staff, applications, postings }),
|
||||
[staff, applications, postings]
|
||||
);
|
||||
|
||||
|
||||
216
src/pages/admin/TalentProfile.jsx
Normal file
216
src/pages/admin/TalentProfile.jsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Briefcase, CalendarDays, MapPin, Plus, Sparkles } from 'lucide-react';
|
||||
import {
|
||||
Avatar, Badge, Button, EmptyState, MetricStrip, StatusBadge, Surface,
|
||||
} from '@/components/ds';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
import { useApplications, useJobPostings, useInterviews, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
|
||||
import { applicationsForProfile, isFinalSelection } from '@/lib/hiringRecords';
|
||||
import AddToPositionModal from '@/components/krow/AddToPositionModal';
|
||||
|
||||
/**
|
||||
* The person, not the application.
|
||||
*
|
||||
* KROW has two profiles and they answer different questions, so they are two
|
||||
* routes rather than one screen with a mode:
|
||||
*
|
||||
* /admin/talent/:workerProfileId this person — who they are, and every
|
||||
* position they are being considered for
|
||||
* /admin/candidates/:applicationId this person FOR ONE POSITION — the score,
|
||||
* the interview, the hire decision
|
||||
*
|
||||
* Collapsing them would recreate the thing this work exists to remove: a
|
||||
* candidate record per position. `worker_profiles` is the master record, and
|
||||
* `job_applications` hangs off it, many to one.
|
||||
*/
|
||||
|
||||
/** What stage an application is at, in words a reader can act on. */
|
||||
const stageLabel = (application, interviews) => {
|
||||
if (application.status === 'hired' || application.status === 'assigned') return 'Hired';
|
||||
if (application.status === 'rejected') return 'Not taken forward';
|
||||
if (isFinalSelection(application, interviews)) return 'Final selection';
|
||||
if (application.status === 'interview') return 'Interview arranged';
|
||||
if (application.status === 'shortlisted') return 'Shortlisted';
|
||||
if (application.status === 'ai_screened') return 'Screened';
|
||||
return 'Applied';
|
||||
};
|
||||
|
||||
function Facts({ profile }) {
|
||||
const facts = [
|
||||
profile.current_position && { icon: Briefcase, label: profile.current_position },
|
||||
profile.address && { icon: MapPin, label: profile.address },
|
||||
profile.availability?.length && { icon: CalendarDays, label: profile.availability.join(', ') },
|
||||
].filter(Boolean);
|
||||
|
||||
if (!facts.length) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-body-sm text-ink-3">
|
||||
{facts.map(({ icon: Icon, label }) => (
|
||||
<span key={label} className="inline-flex items-center gap-1.5">
|
||||
<Icon className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tags({ title, values = [] }) {
|
||||
if (!values.length) return null;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-caption font-semibold uppercase tracking-wide text-ink-4">{title}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{values.map((v) => <Badge key={v} variant="neutral" size="sm">{v}</Badge>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TalentProfile() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: profiles = [], isLoading } = useWorkerProfiles();
|
||||
const { data: applications = [] } = useApplications();
|
||||
const { data: postings = [] } = useJobPostings();
|
||||
const { data: interviews = [] } = useInterviews();
|
||||
const { data: staff = [] } = useStaff();
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
|
||||
const profile = useMemo(() => profiles.find((p) => p.id === id), [profiles, id]);
|
||||
const mine = useMemo(
|
||||
() => applicationsForProfile(applications, profile),
|
||||
[applications, profile]
|
||||
);
|
||||
const postingFor = (a) => postings.find((p) => p.id === a.job_posting_id);
|
||||
const hired = useMemo(
|
||||
() => staff.filter((s) => s.worker_profile_id === profile?.id),
|
||||
[staff, profile]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <AdminPage title="Loading…"><div className="h-40" /></AdminPage>;
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<AdminPage title="Talent profile">
|
||||
<EmptyState
|
||||
title="That profile is not in this talent pool"
|
||||
description="It may have been removed, or the link may be out of date."
|
||||
action={<Button variant="outline" onClick={() => navigate('/admin/talent-pool')}>Back to Talent Pool</Button>}
|
||||
/>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<AdminPage
|
||||
title={profile.full_name}
|
||||
subtitle={profile.email}
|
||||
meta={`${mine.length} ${mine.length === 1 ? 'position' : 'positions'} considered`}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" onClick={() => navigate('/admin/talent-pool')}>
|
||||
<ArrowLeft className="h-4 w-4" /> Talent Pool
|
||||
</Button>
|
||||
<Button onClick={() => setAddOpen(true)}>
|
||||
<Plus className="h-4 w-4" /> Consider for a position
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<Avatar name={profile.full_name} src={profile.selfie_url} size="lg" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Facts profile={profile} />
|
||||
{profile.career_goals && (
|
||||
<p className="text-body-sm leading-relaxed text-ink-2">{profile.career_goals}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<MetricStrip
|
||||
items={[
|
||||
{ label: 'KROW score', value: profile.krow_score ?? 0 },
|
||||
{ label: 'Experience', value: `${profile.experience_years ?? 0}y` },
|
||||
{ label: 'Reliability', value: profile.reliability_score ?? 0 },
|
||||
{ label: 'Hired', value: hired.length },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<Tags title="Skills" values={profile.skills} />
|
||||
<Tags title="Certifications" values={profile.certifications} />
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
{/* The whole reason this page is separate from the candidate page:
|
||||
one person, every position, each at its own stage. */}
|
||||
<section aria-labelledby="talent-positions" className="space-y-3">
|
||||
<h2 id="talent-positions" className="font-heading text-body font-semibold text-ink-1">
|
||||
Positions considered for
|
||||
</h2>
|
||||
|
||||
{mine.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Not yet considered for any position"
|
||||
description="Adding someone to a position files an application and starts the hiring pipeline for that vacancy. The same person can be considered for several."
|
||||
action={<Button onClick={() => setAddOpen(true)}><Plus className="h-4 w-4" /> Consider for a position</Button>}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{mine.map((a) => {
|
||||
const posting = postingFor(a);
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/candidates/${a.id}`)}
|
||||
className="flex w-full flex-wrap items-center justify-between gap-3 rounded-xl border border-border/70 bg-surface px-4 py-3 text-left transition-colors hover:border-krow-blue/40 hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-ink-1">
|
||||
{posting?.title || a.job_title || 'Position'}
|
||||
</p>
|
||||
<p className="truncate text-body-sm text-ink-3">
|
||||
{posting?.company || '—'}
|
||||
{a.ai_score ? ` · scored ${a.ai_score}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{isFinalSelection(a, interviews) && (
|
||||
<Badge variant="info" size="sm">
|
||||
<Sparkles className="mr-1 h-3 w-3" aria-hidden="true" />
|
||||
Awaiting decision
|
||||
</Badge>
|
||||
)}
|
||||
<StatusBadge status={a.status} />
|
||||
<span className="text-caption text-ink-4">{stageLabel(a, interviews)}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</AdminPage>
|
||||
|
||||
<AddToPositionModal
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
profile={profile}
|
||||
existing={mine}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import CandidateCard from '@/components/krow/CandidateCard';
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { registerPageComposition } from '@/lib/ui/composition';
|
||||
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { hasInterview } from '@/lib/hiringRecords';
|
||||
|
||||
/**
|
||||
* Candidates, as addressable nodes.
|
||||
@@ -27,6 +28,7 @@ function CandidatesToolbar() {
|
||||
positions, filtered, applications, isFiltered,
|
||||
} = useUiContext();
|
||||
|
||||
|
||||
return (
|
||||
<Toolbar
|
||||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}
|
||||
@@ -34,10 +36,17 @@ function CandidatesToolbar() {
|
||||
<>
|
||||
<FilterSelect value={position} onChange={setPosition} label="Position"
|
||||
options={[{ value: 'all', label: 'All positions' }, ...positions.map((p) => ({ value: p, label: p }))]} />
|
||||
{/* Final selection leads because it is what the page is for. Every
|
||||
one of the seven `application_status` values follows it, so no
|
||||
application is unreachable — `shortlisted` and `assigned` were
|
||||
missing here, and the rows carrying them could not be found at
|
||||
all. */}
|
||||
<FilterSelect value={stage} onChange={setStage} label="Stage" options={[
|
||||
{ value: 'all', label: 'All stages' }, { value: 'applied', label: 'Applied' },
|
||||
{ value: 'ai_screened', label: 'AI Screened' }, { value: 'interview', label: 'Interviewing' },
|
||||
{ value: 'hired', label: 'Hired' }, { value: 'rejected', label: 'Declined' },
|
||||
{ value: 'final', label: 'Final selection' }, { value: 'all', label: 'All stages' },
|
||||
{ value: 'applied', label: 'Applied' }, { value: 'ai_screened', label: 'AI Screened' },
|
||||
{ value: 'shortlisted', label: 'Shortlisted' }, { value: 'interview', label: 'Interviewing' },
|
||||
{ value: 'hired', label: 'Hired' }, { value: 'assigned', label: 'Assigned' },
|
||||
{ value: 'rejected', label: 'Declined' },
|
||||
]} />
|
||||
<FilterSelect value={band} onChange={setBand} label="Score" options={[
|
||||
{ value: 'all', label: 'All scores' }, { value: 'top', label: '80+ Top' },
|
||||
@@ -55,10 +64,18 @@ function CandidatesToolbar() {
|
||||
|
||||
function CandidatesList() {
|
||||
const {
|
||||
isLoading, filtered, isFiltered, clearFilters, resolveTitle,
|
||||
isLoading, filtered, isFiltered, clearFilters, resolveTitle, stage, interviews = [],
|
||||
handleAction, setMessageApp, setScheduleApp, handleDecline, handleDelete, handleHire,
|
||||
} = useUiContext();
|
||||
|
||||
/* Applications sitting at `interview` with no interview row behind them.
|
||||
They are correctly absent from the final-selection queue — there is no
|
||||
evidence an interview happened — but saying so beats their simply not
|
||||
being anywhere. This is the Devon Carter case. */
|
||||
const missingEvidence = stage === 'interview'
|
||||
? filtered.filter((a) => !hasInterview(a, interviews)).length
|
||||
: 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -69,10 +86,22 @@ function CandidatesList() {
|
||||
);
|
||||
}
|
||||
|
||||
/* An empty queue and an empty filter result are different facts, and the
|
||||
page used to report both as the second one. With no filters applied there
|
||||
is nothing to clear, and "no candidates match your filters" is simply
|
||||
untrue: nobody is waiting on a decision. */
|
||||
if (filtered.length === 0) {
|
||||
const emptyQueue = stage === 'final' && !isFiltered;
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-body-sm text-ink-3">No candidates match your filters</p>
|
||||
<p className="text-body-sm text-ink-3">
|
||||
{emptyQueue ? 'Nobody is awaiting a decision' : 'No candidates match your filters'}
|
||||
</p>
|
||||
{emptyQueue && (
|
||||
<p className="mt-1 text-caption text-ink-4">
|
||||
Candidates arrive here once their interview is completed, and leave once they are hired or declined.
|
||||
</p>
|
||||
)}
|
||||
{isFiltered && (
|
||||
<Button size="xs" variant="outline" className="mt-3" onClick={clearFilters}>
|
||||
Clear filters
|
||||
@@ -84,6 +113,13 @@ function CandidatesList() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{missingEvidence > 0 && (
|
||||
<p className="text-caption text-ink-4">
|
||||
{missingEvidence === 1
|
||||
? '1 of these has no completed interview on record, so it is not in the final-selection queue.'
|
||||
: `${missingEvidence} of these have no completed interview on record, so they are not in the final-selection queue.`}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((app, idx) => (
|
||||
<CandidateCard
|
||||
key={app.id}
|
||||
|
||||
@@ -90,30 +90,46 @@ function HiredSummary() {
|
||||
}
|
||||
|
||||
/* 3. The chronology. */
|
||||
/**
|
||||
* This section used to `return null` when there were no hires, and that was
|
||||
* invisible for as long as the page padded itself with invented people.
|
||||
*
|
||||
* A section that removes itself takes its node identity with it: `chronology`
|
||||
* disappears from the DOM, so Owliver and the layout editor have nothing to
|
||||
* address, and the one moment a reader most needs to be told "no hires yet" is
|
||||
* the moment the page says nothing at all. An empty state is a state, so it is
|
||||
* rendered like one — inside the section, which stays.
|
||||
*/
|
||||
function HiredChronology({ attrs = {} }) {
|
||||
const { recent, filtered } = useUiContext();
|
||||
if (!recent.length) return null;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="chronology" className="space-y-3" {...attrs}>
|
||||
<SectionTitle
|
||||
id="chronology"
|
||||
title="Recent hiring timeline"
|
||||
meta={`Last ${recent.length} of ${filtered.length}`}
|
||||
meta={recent.length ? `Last ${recent.length} of ${filtered.length}` : 'Nothing yet'}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<Timeline
|
||||
items={recent.map((h, i) => ({
|
||||
id: h.id,
|
||||
title: h.name,
|
||||
description: [h.role, h.company !== '—' ? h.company : null]
|
||||
.filter(Boolean).join(' · '),
|
||||
timestamp: formatDate(h.hire_date),
|
||||
tone: i === 0 ? 'brand' : 'neutral',
|
||||
current: i === 0,
|
||||
icon: UserCheck,
|
||||
}))}
|
||||
/>
|
||||
{recent.length ? (
|
||||
<Timeline
|
||||
items={recent.map((h, i) => ({
|
||||
id: h.id,
|
||||
title: h.name,
|
||||
description: [h.role, h.company !== '—' ? h.company : null]
|
||||
.filter(Boolean).join(' · '),
|
||||
timestamp: formatDate(h.hire_date),
|
||||
tone: i === 0 ? 'brand' : 'neutral',
|
||||
current: i === 0,
|
||||
icon: UserCheck,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No hires yet"
|
||||
description="The timeline fills in as candidates are hired for a position."
|
||||
/>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Bookmark, Star, UserRound, Trophy, CheckCircle2, TrendingUp, Sparkles } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Bookmark, Star, UserRound, Trophy, CheckCircle2, TrendingUp, Sparkles, BriefcaseBusiness } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Avatar, Badge, DataTable, IconButton, ProgressRing, SearchInput, toast,
|
||||
Avatar, Badge, DataTable, IconButton, ProgressRing, SearchInput,
|
||||
} from '@/components/ds';
|
||||
import { useApplications } from '@/lib/krowHooks';
|
||||
import { applicationsForProfile } from '@/lib/hiringRecords';
|
||||
import AddToPositionModal from '@/components/krow/AddToPositionModal';
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
import { SectionTitle, Toolbar } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
@@ -167,6 +171,12 @@ function TalentSegments({ attrs = {} }) {
|
||||
|
||||
/** The directory: its heading, its filters and the ranked table. */
|
||||
function TalentDirectory({ attrs = {} }) {
|
||||
const navigate = useNavigate();
|
||||
/* Which positions this person is already up for, so the picker can say so
|
||||
rather than offering a vacancy the database will refuse. */
|
||||
const { data: applications = [] } = useApplications();
|
||||
const [considering, setConsidering] = useState(null);
|
||||
|
||||
const {
|
||||
profiles, filtered, isLoading, isFiltered, clearFilters, saved, toggleSave,
|
||||
search, setSearch, skill, setSkill, experience, setExperience, location, setLocation,
|
||||
@@ -213,6 +223,10 @@ function TalentDirectory({ attrs = {} }) {
|
||||
pageSize={15}
|
||||
isFiltered={isFiltered}
|
||||
onClearFilters={clearFilters}
|
||||
/* The table rendered inert rows: `DataTable` only wires the handler and
|
||||
the pointer when it is given one, so clicking a person did nothing and
|
||||
the pool was a list you could read and not use. */
|
||||
onRowClick={(p) => navigate(`/admin/talent/${p.id}`)}
|
||||
caption="Talent pool ranked by career score"
|
||||
columns={[
|
||||
{
|
||||
@@ -284,18 +298,32 @@ function TalentDirectory({ attrs = {} }) {
|
||||
size="sm"
|
||||
onClick={() => toggleSave(p.id, p.full_name)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={BriefcaseBusiness}
|
||||
label={`Consider ${p.full_name} for a position`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConsidering(p)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={UserRound}
|
||||
label={`View ${p.full_name}'s profile`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toast.info(`Opening ${p.full_name}'s KROW Identity`)}
|
||||
onClick={() => navigate(`/admin/talent/${p.id}`)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<AddToPositionModal
|
||||
open={Boolean(considering)}
|
||||
onOpenChange={(next) => { if (!next) setConsidering(null); }}
|
||||
profile={considering}
|
||||
existing={applicationsForProfile(applications, considering)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user