diff --git a/scripts/__baseline__/README.md b/scripts/__baseline__/README.md
index 0f261e0..28ddb66 100644
--- a/scripts/__baseline__/README.md
+++ b/scripts/__baseline__/README.md
@@ -5,6 +5,60 @@ agent layer existed. `skill-check.mjs` asserts against it on every run.
Regenerating it is a deliberate act, and the reason belongs here.
+## OUTSTANDING — the HTML baselines contain data that no longer exists
+
+**Six checks fail on purpose. Do not regenerate these baselines to clear them.**
+
+ Hired History paints the same styled elements, in the same order
+ Hired History shows the same words
+ Hired History added only identity wrappers
+ analytics: paints the same styled elements, in the same order
+ analytics: shows the same words
+ candidates: shows the same words
+
+The first five are the `DEMO_FILL` removal, described immediately below. The
+sixth is the final-selection queue and has its own dated entry further down.
+
+`hiringRecords.js` used to pad the hires list with five invented people
+(`DEMO_FILL`) so Hired History read as a history rather than as three rows. The
+padding applied to Analytics too, so "total hires" counted eight where the
+database held three. It has been removed: hires now come only from `staff`.
+
+`hired-history.pre-migration.html` and `analytics.pre-migration.html` were
+captured **while the padding was in effect** — and because these baselines render
+with queries disabled, the padding is *all* they contain. The Hired History
+baseline is 31,453 characters holding all five invented names; the genuine empty
+state is 11,947 and holds none.
+
+**Verified before leaving them failing**, so the drift is known rather than
+assumed. Tag counts, baseline → now:
+
+
0 five table rows of people who were never hired
+ 13
+ 39
+
7
+ 8
+
+Every difference is content that was fabricated. No styling, ordering or
+structural rule changed.
+
+**Why they are not regenerated yet.** Two of them — `added only identity
+wrappers` and `carries node identity in the DOM` — exist to prove the UI node
+tree migration added exactly two ``s and nothing else. 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 and pass forever without proving anything.
+
+So the debt is held until the migration work lands, at which point both files
+are recaptured together and this section is replaced by a dated entry saying so.
+
+**One related fix was made rather than deferred.** `HiredChronology` used to
+`return null` with no hires, which took the `chronology` node identity out of the
+DOM with it — so with an honest empty dataset the section could not be addressed
+by Owliver or the layout editor at all, and the page said nothing where it most
+needed to. It now renders an empty state inside the section, which the section
+keeps. `carries node identity in the DOM` passes again because of it.
+
## 2026-08-27 — the seed gained the three statuses nothing exercised
`application_status` has seven values. The fixture produced four: `applied`,
@@ -63,3 +117,47 @@ with it. It now names the page's topics in a sentence.
Every intent `kind` was unchanged. No routing moved, no skill matching changed.
That is why this regeneration was safe: the diff was read first, and it was one
cosmetic change on a path that only runs when no agent is configured at all.
+
+## 2026-09-11 — Candidates became the final-selection queue
+
+`candidates: shows the same words` now fails, and it is the only check this
+change breaks.
+
+The page used to list every application the org had ever taken — all seven
+statuses at once — which made it a second Talent Pool rather than the queue of
+hiring decisions waiting on a human. It now opens on final selection: interview
+completed, not hired, not rejected. That is a change to what the page *says*, so
+a check asserting the page says exactly what it said before was always going to
+fail. There is no version of this work that leaves those words alone.
+
+**What drifted, verified before leaving it failing.** The rendered delta is the
+empty state and nothing else:
+
+ before "…0 of 0 candidates No candidates match your filters"
+ now "…0 of 0 candidates Nobody is awaiting a decision
+ Candidates arrive here once their interview is completed,
+ and leave once they are hired or declined."
+
+Title, subtitle and toolbar meta are byte-identical. The old copy was not merely
+different, it was untrue: with no filters applied there is nothing to clear, and
+"no candidates match your filters" describes a filter that was never set.
+
+Tag and class counts, baseline → now:
+
+ class="…" 26 -> 27 one inserted: the description paragraph
+
+One insertion, nothing changed and nothing dropped — which is why
+`candidates: paints the same styled elements, in the same order` and
+`candidates: carries node identity in the DOM` both still pass. Those two are
+the migration proof; only the words moved.
+
+The new stage filter options cost nothing here. `FilterSelect` is a Radix
+`Select`, so its options live in a portal that is closed in static markup, and
+`SelectValue` renders empty on the server — the baseline contains neither the
+old option labels nor the new ones.
+
+**Why it is not regenerated.** The same reason as the five above:
+`candidates.pre-migration.html` is load-bearing for the UI node tree proof, and
+recapturing it now would write post-migration markup into a file named
+`pre-migration`. It is held until the migration work lands and all of these are
+recaptured together.
diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs
index 53eef2a..dae668a 100644
--- a/scripts/skill-check.mjs
+++ b/scripts/skill-check.mjs
@@ -5959,6 +5959,354 @@ record('an assigned candidate counts as hired',
records.HIRED_STATUSES.includes('assigned') &&
records.rankOf('assigned') >= records.STAGE_ORDER.indexOf('hired'));
+/* ── Final selection, and seats ──────────────────────────────────────────
+ *
+ * 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, which is 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.
+ *
+ * That distinction is the whole point: `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 who
+ * have merely been booked in.
+ */
+const fsApps = [
+ { id: 'a-applied', status: 'applied' },
+ { id: 'a-booked', status: 'interview' }, // arranged, never sat
+ { id: 'a-done', status: 'interview' }, // sat it
+ { id: 'a-hired', status: 'hired' },
+ { id: 'a-assigned', status: 'assigned' },
+ { id: 'a-rejected', status: 'rejected' },
+];
+const fsInterviews = [
+ { id: 'i1', application_id: 'a-done' },
+ { id: 'i2', application_id: 'a-hired' },
+ { id: 'i3', application_id: 'a-rejected' },
+];
+const queue = records.finalSelection(fsApps, fsInterviews).map((a) => a.id);
+
+record('final selection holds only the interviewed and undecided',
+ queue.length === 1 && queue[0] === 'a-done', queue.join(', ') || 'empty');
+record('...so an interview merely arranged does not qualify',
+ !records.isFinalSelection(fsApps[1], fsInterviews),
+ 'status interview with no interview row');
+record('...and a decided application never reappears in the queue',
+ ['a-hired', 'a-assigned', 'a-rejected']
+ .every((id) => !queue.includes(id)),
+ 'hired, assigned and rejected all excluded');
+record('...while the interview record, not interview_id, is what is read',
+ !records.isFinalSelection({ id: 'x', status: 'interview', interview_id: 'dangling' }, []),
+ 'a soft reference alone proves nothing');
+
+/* Seats are counted from the employment records. A `filled` column would be a
+ second source of truth, and nothing could say which one was right. */
+const seatPosting = { id: 'p1', headcount: 2 };
+const seatStaff = [
+ { id: 's1', job_posting_id: 'p1', status: 'active' },
+ { id: 's2', job_posting_id: 'p1', status: 'onboarding' },
+ { id: 's3', job_posting_id: 'p1', status: 'inactive' }, // left; seat reopens
+ { id: 's4', job_posting_id: 'p2', status: 'active' }, // another position
+];
+record('seats filled are counted from the staff records',
+ records.filledFor('p1', seatStaff) === 2, `${records.filledFor('p1', seatStaff)} of 2`);
+record('...someone who has left frees their seat',
+ records.remainingFor({ id: 'p1', headcount: 3 }, seatStaff) === 1,
+ 'inactive staff do not hold a seat');
+record('...a full position reports no remaining seats, never a negative number',
+ records.isFullyStaffed(seatPosting, seatStaff)
+ && records.remainingFor({ id: 'p1', headcount: 1 }, seatStaff) === 0,
+ 'over-hiring floors at zero');
+
+/* ── The Candidates page IS the final-selection queue ─────────────────────
+ *
+ * The derivation above is only worth having if the page that decides hires
+ * actually opens on it. Candidates used to list every application the org had
+ * ever taken — all seven statuses at once — which made it a second Talent Pool
+ * rather than a queue of decisions waiting.
+ *
+ * Two rules these assertions hold in place, both of them safety rules rather
+ * than presentation ones:
+ *
+ * The evidence is the interview ROW. `interview_id` is a soft reference the
+ * schema deliberately leaves unconstrained, and it dangles in real data.
+ *
+ * The score never gates. `ai_score`, `verdict` and `hire_recommendation`
+ * assist a recruiter; a human makes the selection. A candidate who scored
+ * zero and sat the interview is still a decision somebody owes them.
+ */
+record('a candidate who scored zero still reaches the queue',
+ records.isFinalSelection({ id: 'a-zero', status: 'interview', ai_score: 0 },
+ [{ id: 'i9', application_id: 'a-zero' }]),
+ 'screening is not a gate on the decision');
+
+{
+ /* Comments are stripped first: these assert what the page DOES, and a
+ comment explaining the bug that was fixed must not read as the bug. */
+ const decomment = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
+ const candidatesSrc = decomment(readFileSync(join(ROOT, 'src/pages/admin/Candidates.jsx'), 'utf8'));
+ const nodesSrc = decomment(readFileSync(join(ROOT, 'src/pages/admin/candidates/nodes.jsx'), 'utf8'));
+ /* Read from source rather than imported: the page pulls in the interview
+ modal, which touches `window` at module scope, and standing up a DOM to
+ read one string constant would be a worse test than reading the string. */
+ const defaultStage = candidatesSrc.match(/export const DEFAULT_STAGE = '([a-z_]+)'/)?.[1];
+
+ record('the candidate page opens on the final-selection queue',
+ defaultStage === 'final' && /useState\(DEFAULT_STAGE\)/.test(candidatesSrc),
+ String(defaultStage));
+
+ /* The interview rows were fetched and then thrown away — `useInterviews();`
+ on its own line, its result unbound — which is why the queue could not be
+ derived on the one page that needed it. */
+ record('the candidate page keeps the interview rows it fetches',
+ /(const|let)\s*\{[^}]*\}\s*=\s*useInterviews\(\)/.test(candidatesSrc),
+ 'useInterviews() is bound, not discarded');
+
+ /* The filter compared `a.stage`. Applications carry `status`; no application
+ has ever had a `stage` property, so every stage selection emptied the
+ page. */
+ record('the stage filter reads a field applications actually carry',
+ !/\ba\.stage\b/.test(candidatesSrc) && /matchesStage\(/.test(candidatesSrc),
+ 'status, not an undefined stage');
+
+ /* Narrowing the default view is only safe if nothing becomes unreachable.
+ `shortlisted` and `assigned` were absent from these options, so the rows
+ holding them could not be found by any selection. */
+ const offered = [...nodesSrc.matchAll(/\{ value: '([a-z_]+)', label: '[^']+' \}/g)]
+ .map((m) => m[1]);
+ const unreachable = records.APPLICATION_STATUSES.filter((st) => !offered.includes(st));
+ record('every application status is still reachable from the stage filter',
+ unreachable.length === 0, unreachable.join(', ') || 'all seven offered');
+
+ record('...and the queue itself is offered alongside them',
+ offered.includes('final') && offered.includes('all'),
+ 'final selection and all stages');
+}
+
+/* ── Human interviews leave a trace, and only completion counts ───────────
+ *
+ * A human interview used to persist nothing at all: the modal collected a
+ * type, a date, a time and notes, announced "Interview scheduled!", and dropped
+ * every one of them. So the final-selection queue could only ever hold people
+ * an AI had interviewed, and anyone seen by a person was invisible to it.
+ *
+ * What changed is where each fact lives, and the split is the safety property:
+ *
+ * booking → a `user_activity` row. Advances nobody.
+ * completion→ an `ai_interviews` row. The evidence, unchanged since Step 6.
+ *
+ * `isFinalSelection` is not touched by any of this and is not expected to be.
+ * These assertions exist to prove that a booking cannot masquerade as an
+ * interview, which is the one way this work could put a candidate in front of a
+ * hiring decision that nobody has actually met.
+ */
+const human = await server.ssrLoadModule('/src/lib/humanInterviews.js');
+
+{
+ const app = { id: 'a-human', status: 'interview', job_posting_id: 'p1', applicant_name: 'A' };
+ const at = (n) => new Date(Date.UTC(2026, 0, n)).toISOString();
+
+ const bookedEvent = {
+ event_type: human.SCHEDULED, application_id: 'a-human', created_date: at(1),
+ metadata: { interview_type: 'video', scheduled_date: '2026-01-09', scheduled_time: '10:00', notes: 'prep' },
+ };
+ const completedRow = { id: 'i-human', application_id: 'a-human' };
+
+ /* Booked, and nothing else. */
+ record('a booked human interview is recorded',
+ human.humanInterviewState(app, [bookedEvent], []) === 'scheduled');
+ record('...and the booking keeps what was entered',
+ (() => {
+ const d = human.scheduleDetails(human.scheduledInterview(app, [bookedEvent]));
+ return d.type === 'video' && d.date === '2026-01-09' && d.time === '10:00' && d.notes === 'prep';
+ })(), 'type, date, time and notes survive');
+ record('...but booking alone is NOT final selection',
+ !records.isFinalSelection(app, []),
+ 'no interview row, no decision to make');
+
+ /* Held. */
+ record('a completed human interview is recorded',
+ human.humanInterviewState(app, [bookedEvent], [completedRow]) === 'completed');
+ record('...and a completed human interview DOES reach final selection',
+ records.isFinalSelection(app, [completedRow]));
+ record('...even with no score, because a human interview measures none',
+ records.isFinalSelection({ ...app, ai_score: 0 }, [completedRow]),
+ 'absence of an AI score is not absence of an interview');
+
+ /* Not held. Both outcomes are events, and neither is an interview. */
+ for (const [label, type] of [['cancelled', human.CANCELLED], ['not attended', human.NO_SHOW]]) {
+ const events = [bookedEvent, { event_type: type, application_id: 'a-human', created_date: at(2) }];
+ record(`an interview ${label} leaves no completion record`,
+ human.humanInterviewState(app, events, []) === (type === human.CANCELLED ? 'cancelled' : 'no_show'));
+ record(`...so ${label} never reaches final selection`,
+ !records.isFinalSelection(app, []));
+ }
+
+ /* A decided application is out regardless of how it was interviewed. */
+ for (const status of ['hired', 'assigned', 'rejected']) {
+ record(`a ${status} application leaves the queue even with a human interview`,
+ !records.isFinalSelection({ ...app, status }, [completedRow]));
+ }
+
+ /* The Step 6 guard, restated against a human booking: a booking may well set
+ interview_id one day, and it must still prove nothing on its own. */
+ record('a dangling interview_id still does not qualify a human interview',
+ !records.isFinalSelection({ ...app, interview_id: 'nope' }, []),
+ 'the row is the evidence, not the reference');
+
+ /* An event claiming completion with no row behind it. The log is not the
+ record, and treating it as one would reintroduce exactly the soft-reference
+ problem interview_id already demonstrates. */
+ record('a completion EVENT without an interview row is not completion',
+ human.humanInterviewState(app,
+ [{ event_type: human.COMPLETED, application_id: 'a-human', created_date: at(3) }], []) === 'none',
+ 'user_activity is a log, not the evidence');
+}
+
+/* ── Nothing about AI is invented for a human interview ───────────────────
+ *
+ * The completed-interview record lives in `ai_interviews`, which is currently
+ * reused as the durable completion record for both flows. Reusing the table is
+ * a deliberate decision; filling in its AI columns with plausible numbers no
+ * machine produced would not be. A fabricated score or verdict would be read by
+ * analytics, by the candidate profile and by a recruiter as a measurement.
+ *
+ * `overall_interview_score` matters twice over: the server copies it onto the
+ * application only when the request mentions it, so sending a 0 would overwrite
+ * a real screening score with one this interview never took.
+ */
+{
+ const hooksSrc = readFileSync(join(ROOT, 'src/lib/krowHooks.js'), 'utf8');
+ const complete = hooksSrc.slice(
+ hooksSrc.indexOf('export function useCompleteHumanInterview'),
+ hooksSrc.indexOf('export function useRecordInterviewNotHeld')
+ );
+ const schedule = hooksSrc.slice(
+ hooksSrc.indexOf('export function useScheduleHumanInterview'),
+ hooksSrc.indexOf('export function useCompleteHumanInterview')
+ );
+
+ record('the human completion path exists at all', complete.length > 0 && schedule.length > 0);
+
+ const fabricated = ['overall_interview_score', 'integrity_score', 'ai_flags', 'category_scores',
+ 'hire_recommendation', 'messages', 'ai_score']
+ .filter((f) => new RegExp(`^\\s*${f}\\s*:`, 'm').test(complete));
+ record('a human interview fabricates no AI data',
+ fabricated.length === 0, fabricated.join(', ') || 'no AI column is written');
+
+ record('...and the interviewer\'s own assessment is only sent when given',
+ /if \(assessment\) record\.verdict = assessment;/.test(complete),
+ 'verdict is human-entered or left to the schema');
+
+ record('...while the facts it does write are the ones it knows',
+ /application_id: application\.id/.test(complete)
+ && /job_posting_id: application\.job_posting_id/.test(complete));
+
+ /* The rule the whole step turns on. */
+ record('scheduling never writes an interview record',
+ !/AIInterview|useCreateInterview|createInterview/.test(schedule),
+ 'a booking touches user_activity only');
+
+ record('...and completion goes through the existing interview client',
+ /createInterview\.mutateAsync/.test(complete),
+ 'no second interview API');
+
+ /* Not held writes an event and nothing else — no row, and no invented
+ application status to go with it. */
+ const notHeld = hooksSrc.slice(hooksSrc.indexOf('export function useRecordInterviewNotHeld'));
+ record('a cancelled or unattended interview writes no record and no new status',
+ !/AIInterview|createInterview/.test(notHeld) && !/JobApplication\.update/.test(notHeld),
+ 'an event, and nothing else');
+}
+
+/* The AI flow is untouched: it still writes its own record at completion, and
+ still does it through the same client this now shares. */
+{
+ const aiSrc = readFileSync(join(ROOT, 'src/components/krow/AIInterviewModal.jsx'), 'utf8');
+ record('the AI interview still records itself on completion',
+ /const finishInterview = useCallback/.test(aiSrc)
+ && /createInterview\.mutateAsync\(\{/.test(aiSrc)
+ && /messages: finalMessages/.test(aiSrc),
+ 'unchanged by the human path');
+}
+
+/* The modal is no longer theatre. */
+{
+ const modalSrc = readFileSync(join(ROOT, 'src/components/krow/ScheduleInterviewModal.jsx'), 'utf8');
+ record('scheduling an interview persists it',
+ /useScheduleHumanInterview/.test(modalSrc) && /schedule\.mutateAsync/.test(modalSrc));
+ record('...the props its six call sites pass are unchanged',
+ /\{ open, onClose, application, position \}/.test(modalSrc),
+ 'open, onClose, application — position optional');
+ record('...and a failed save is reported rather than celebrated',
+ /setError\(/.test(modalSrc) && !/setScheduled\(true\)/.test(modalSrc),
+ 'the old version always claimed success');
+}
+
+/* Every path that files an application on somebody's behalf must carry the
+ link back to the talent-pool record. The column is nullable, so omitting it
+ saves cleanly and fails silently: the application belongs to an email address
+ instead of to a person, and the hire it becomes cannot be traced back to the
+ profile it came from. Both call sites are asserted because they were written
+ at different times and only one of them is on the position page. */
+const positionDetailSource = readFileSync(join(ROOT, 'src/pages/PositionDetail.jsx'), 'utf8');
+const hooksSource = readFileSync(join(ROOT, 'src/lib/krowHooks.js'), 'utf8');
+record('admitting talent to a position links the application to the profile',
+ /worker_profile_id: profile\.id/.test(positionDetailSource),
+ 'PositionDetail.admitTalent');
+record('...and so does assigning a worker straight from the pool',
+ /worker_profile_id: worker\.profile\?\.id/.test(hooksSource),
+ 'useAssignWorkers filing an application');
+
+/* ── One person, many positions ──────────────────────────────────────────
+ *
+ * The two profile routes answer different questions and must stay separate.
+ * `/admin/talent/:id` is the PERSON — a `worker_profiles` row, and every
+ * position they are up for. `/admin/candidates/:id` is that person FOR ONE
+ * POSITION — a `job_applications` row, with its score, interview and decision.
+ *
+ * One screen doing both is how a product ends up with a candidate record per
+ * vacancy, which is exactly what `worker_profiles` exists to prevent.
+ */
+record('the person and the application have separate routes',
+ routed.has('/admin/talent/:id') && routed.has('/admin/candidates/:id'),
+ [...routed].filter((r) => r.includes('talent') || r.includes('candidates')).join(', '));
+
+/* Which applications are this person's. The foreign key is the answer; email is
+ the fallback for rows written before the key was being set, and it has to be
+ there or every application already in the database detaches from its person. */
+const whoProfile = { id: 'wp-1', email: 'Maria@Example.com' };
+const whoApps = [
+ { id: 'by-key', worker_profile_id: 'wp-1', email: 'nothing@else.com' },
+ { id: 'by-email', worker_profile_id: null, email: 'maria@example.com' },
+ { id: 'by-email-case', email: 'MARIA@EXAMPLE.COM' },
+ { id: 'somebody-else', worker_profile_id: 'wp-2', email: 'other@example.com' },
+];
+const whoIds = records.applicationsForProfile(whoApps, whoProfile).map((a) => a.id);
+record('an application finds its person by foreign key',
+ whoIds.includes('by-key'), 'worker_profile_id');
+record('...and falls back to email, case-insensitively, for older rows',
+ whoIds.includes('by-email') && whoIds.includes('by-email-case'),
+ 'citext identity, as worker_profiles already asserts');
+record('...without collecting somebody else',
+ !whoIds.includes('somebody-else') && whoIds.length === 3, whoIds.join(', '));
+record('...and asks for nobody when there is no profile',
+ records.applicationsForProfile(whoApps, null).length === 0, 'empty, not everything');
+
+/* The reported symptom: a talent pool you could read and not use. */
+const talentPoolSource = readFileSync(join(ROOT, 'src/pages/admin/talent-pool/nodes.jsx'), 'utf8');
+record('a talent pool row opens that person',
+ /onRowClick=\{\(p\) => navigate\(`\/admin\/talent\/\$\{p\.id\}`\)\}/.test(talentPoolSource),
+ 'DataTable wires the handler only when it is given one');
+record('...and no action pretends to open something it does not',
+ !/toast\.info\(`Opening/.test(talentPoolSource),
+ 'the "view profile" toast is gone');
+record('...and a person can be put in front of a vacancy from the pool',
+ /setConsidering\(p\)/.test(talentPoolSource)
+ && /AddToPositionModal/.test(talentPoolSource),
+ 'the step that was missing between pool and position');
+
/* "Screened and waiting on a decision" includes the shortlisted. This counted
`ai_screened` alone, which read as correct only while nothing was ever
shortlisted — the first shortlisted candidate dropped out of the count in
diff --git a/src/App.jsx b/src/App.jsx
index 98cba13..fef4ad2 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -38,6 +38,7 @@ import AdminCandidateProfile from '@/pages/admin/CandidateProfile';
import AdminCandidatesAnalysis from '@/pages/admin/CandidatesAnalysis';
import AdminHiredHistory from '@/pages/admin/HiredHistory';
import AdminTalentPool from '@/pages/admin/TalentPool';
+import AdminTalentProfile from '@/pages/admin/TalentProfile';
import AdminAnalytics from '@/pages/admin/Analytics';
import AdminActivity from '@/pages/admin/Activity';
import AdminProfile from '@/pages/admin/Profile';
@@ -107,6 +108,12 @@ const AuthenticatedApp = () => {
} />
} />
} />
+ {/* The person, keyed by worker profile — distinct from
+ `candidates/:id`, which is one person FOR ONE POSITION. Two
+ routes because they answer different questions; one screen
+ would push the product back towards a candidate record per
+ vacancy, which is the thing `worker_profiles` exists to stop. */}
+
} />
} />
} />
} />
diff --git a/src/components/ai-assistant/KrowAssistant.jsx b/src/components/ai-assistant/KrowAssistant.jsx
index eba1960..e54f137 100644
--- a/src/components/ai-assistant/KrowAssistant.jsx
+++ b/src/components/ai-assistant/KrowAssistant.jsx
@@ -1080,9 +1080,6 @@ export default function KrowAssistant({
>
{composer}
-
- Owliver reads this page's data. Check anything you act on.
-
);
diff --git a/src/components/krow/AddToPositionModal.jsx b/src/components/krow/AddToPositionModal.jsx
new file mode 100644
index 0000000..0af75c0
--- /dev/null
+++ b/src/components/krow/AddToPositionModal.jsx
@@ -0,0 +1,144 @@
+import React, { useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Badge, Button, EmptyState, Modal, StatusBadge, toast } from '@/components/ds';
+import { useCreateApplication, useJobPostings } from '@/lib/krowHooks';
+import { remainingFor } from '@/lib/hiringRecords';
+
+/**
+ * Put a person from the talent pool in front of a vacancy.
+ *
+ * This is the step the product was missing. The talent pool held people, the
+ * positions held vacancies, and nothing in the interface joined the two — so a
+ * candidate could only enter a pipeline from the position's own suggestion
+ * panel, and the Candidates page filled up with applications nobody could trace
+ * back to a person.
+ *
+ * What it writes is one `job_applications` row, which is the join:
+ * `worker_profile_id` names the person, `job_posting_id` names the vacancy. The
+ * master record is never copied — the same profile can hold an application
+ * against every position it is considered for.
+ *
+ * Filing the same person against the same position twice is refused by the
+ * database (`UNIQUE (job_posting_id, email)`), so positions they are already up
+ * for are shown as such rather than offered and then rejected.
+ */
+export default function AddToPositionModal({ open, onOpenChange, profile, existing = [], staff = [] }) {
+ const navigate = useNavigate();
+ const { data: postings = [], isLoading } = useJobPostings();
+ const createApplication = useCreateApplication();
+ const [busyId, setBusyId] = useState(null);
+
+ /* A vacancy you can still be considered for: open, and not one this person is
+ already in the running for. Drafts and closed roles are not offers. */
+ const open_ = useMemo(
+ () => postings.filter((p) => p.status === 'active' || p.status === 'paused'),
+ [postings]
+ );
+
+ const appliedTo = useMemo(() => {
+ const byPosting = new Map();
+ for (const a of existing) byPosting.set(a.job_posting_id, a);
+ return byPosting;
+ }, [existing]);
+
+ const add = async (posting) => {
+ if (!profile) return;
+ setBusyId(posting.id);
+ try {
+ const application = await createApplication.mutateAsync({
+ /* The link back to the person. Without it the row saves anyway — the
+ column is nullable — and the hire it may become cannot be traced to
+ the profile it came from. */
+ worker_profile_id: profile.id,
+ applicant_name: profile.full_name,
+ email: profile.email || '',
+ phone: profile.phone || '',
+ years_experience: profile.experience_years || 0,
+ skills: profile.skills || [],
+ availability: profile.availability || [],
+ certifications: profile.certifications || [],
+ companies_worked: (profile.experience || []).map((e) => e.company).filter(Boolean),
+ selfie_url: profile.selfie_url || '',
+ professional_summary: profile.career_goals || '',
+ cover_letter: '',
+ job_posting_id: posting.id,
+ job_title: posting.title,
+ status: 'applied',
+ ai_score: profile.krow_score || 0,
+ });
+ toast.success(`${profile.full_name} is now up for ${posting.title}`);
+ onOpenChange?.(false);
+ if (application?.id) navigate(`/admin/candidates/${application.id}`);
+ } catch (error) {
+ /* The duplicate guard lives in the database, so this is also the path a
+ race takes — two operators adding the same person at once. Saying what
+ happened beats a generic failure. */
+ const message = String(error?.message || '');
+ toast.error(/conflict|exists|duplicate/i.test(message)
+ ? `${profile.full_name} is already up for ${posting.title}`
+ : message || 'That position could not be opened for this candidate');
+ } finally {
+ setBusyId(null);
+ }
+ };
+
+ return (
+
+ {isLoading &&
}
+
+ {!isLoading && open_.length === 0 && (
+ { onOpenChange?.(false); navigate('/admin/positions'); }}>Go to Positions}
+ />
+ )}
+
+ {!isLoading && open_.length > 0 && (
+
+ {open_.map((p) => {
+ const already = appliedTo.get(p.id);
+ const remaining = staff.length ? remainingFor(p, staff) : null;
+ return (
+
+
+
{p.title}
+
+ {[p.company, p.location].filter(Boolean).join(' · ') || '—'}
+ {remaining !== null && ` · ${remaining} of ${p.headcount ?? 1} open`}
+
+
+
+ {already ? (
+
+ Already considered
+
+
+ ) : (
+ add(p)}
+ loading={busyId === p.id}
+ disabled={Boolean(busyId)}
+ >
+ Consider
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/components/krow/ScheduleInterviewModal.jsx b/src/components/krow/ScheduleInterviewModal.jsx
index 1340670..9a64120 100644
--- a/src/components/krow/ScheduleInterviewModal.jsx
+++ b/src/components/krow/ScheduleInterviewModal.jsx
@@ -1,50 +1,284 @@
-import React, { useState } from 'react';
+import React, { useMemo, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
-import { Calendar, Clock, Video, Phone, User } from 'lucide-react';
+import { Calendar, CalendarX, Check, Clock, Phone, User, UserX, Video } from 'lucide-react';
+import {
+ useCompleteHumanInterview, useInterviews, useRecordInterviewNotHeld,
+ useScheduleHumanInterview, useUserActivity,
+} from '@/lib/krowHooks';
+import {
+ CANCELLED, NO_SHOW, humanInterviewState, scheduleDetails, scheduledInterview,
+} from '@/lib/humanInterviews';
+
+/**
+ * Booking a human interview, and recording what came of it.
+ *
+ * This modal used to be a decoration. It collected a type, a date, a time and
+ * some notes, said "Interview scheduled!", and threw all four away — no
+ * request, no record, and nobody notified despite the message saying so. A
+ * candidate interviewed by a person therefore left no trace anywhere, which is
+ * why the final-selection queue could only ever contain AI interviews.
+ *
+ * It now covers the whole of a human interview's life, in the order it happens:
+ *
+ * nothing booked → the booking form, as before
+ * booked → what happened: held, not attended, or called off
+ * held → nothing left to do; the record exists
+ *
+ * THE ONE RULE THIS FILE EXISTS TO KEEP
+ *
+ * Booking an interview and completing one are different writes with different
+ * consequences. The booking is a `user_activity` row and advances nobody. Only
+ * completion writes the `ai_interviews` record that `isFinalSelection` reads,
+ * and completion is an explicit act by whoever ran the interview. So a
+ * candidate cannot reach a hiring decision by being put in a calendar.
+ *
+ * The props are unchanged — `open`, `onClose`, `application` — because six
+ * places mount this: admin Candidates (directly and through its node tree),
+ * Candidate Profile, Position Detail, the older Candidates page, and
+ * CandidateCard's own fallback. `position` is optional and only sharpens the
+ * activity record when a caller has it.
+ */
+
+const TYPES = [
+ { value: 'video', label: 'Video', icon: Video },
+ { value: 'phone', label: 'Phone', icon: Phone },
+ { value: 'inperson', label: 'In Person', icon: User },
+];
+
+const TYPE_LABEL = Object.fromEntries(TYPES.map((t) => [t.value, t.label]));
+
+/* The interviewer's own reading, in the three words the column already holds.
+ Recorded because a person chose it, and never acted on automatically — the
+ hire control is elsewhere and stays a deliberate click. */
+const ASSESSMENTS = [
+ { value: 'hire', label: 'Would hire' },
+ { value: 'maybe', label: 'Unsure' },
+ { value: 'no', label: 'Would not hire' },
+];
+
+const field = 'w-full mt-1.5 min-h-[60px] p-2.5 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none';
+
+export default function ScheduleInterviewModal({ open, onClose, application, position }) {
+ const { data: activity = [] } = useUserActivity();
+ const { data: interviews = [] } = useInterviews();
+
+ const schedule = useScheduleHumanInterview();
+ const complete = useCompleteHumanInterview();
+ const notHeld = useRecordInterviewNotHeld();
-export default function ScheduleInterviewModal({ open, onClose, application }) {
const [type, setType] = useState('video');
const [date, setDate] = useState('');
const [time, setTime] = useState('');
const [notes, setNotes] = useState('');
- const [scheduled, setScheduled] = useState(false);
- const handleSchedule = () => {
- setScheduled(true);
- setTimeout(() => {
- setScheduled(false);
- onClose();
- }, 1500);
+ const [recording, setRecording] = useState(null); // 'completed' | NO_SHOW | CANCELLED
+ const [assessment, setAssessment] = useState('');
+ const [outcomeNotes, setOutcomeNotes] = useState('');
+
+ const [done, setDone] = useState('');
+ const [error, setError] = useState('');
+
+ const state = useMemo(
+ () => humanInterviewState(application, activity, interviews),
+ [application, activity, interviews]
+ );
+ const booking = useMemo(
+ () => scheduledInterview(application, activity),
+ [application, activity]
+ );
+ const booked = booking ? scheduleDetails(booking) : null;
+
+ const busy = schedule.isPending || complete.isPending || notHeld.isPending;
+
+ const finish = (message) => {
+ setDone(message);
+ setTimeout(() => { setDone(''); onClose(); }, 1500);
};
+ const run = async (work, message) => {
+ setError('');
+ try {
+ await work();
+ finish(message);
+ } catch (e) {
+ /* The booking is a real write now, so a failure is a real failure and
+ says so, rather than the old unconditional "Interview scheduled!". */
+ setError(e?.message || 'That could not be saved. Please try again.');
+ }
+ };
+
+ const submitSchedule = () => run(
+ () => schedule.mutateAsync({ application, position, type, date, time, notes }),
+ 'Interview scheduled'
+ );
+
+ const submitOutcome = () => {
+ if (recording === 'completed') {
+ return run(
+ () => complete.mutateAsync({ application, position, assessment, notes: outcomeNotes }),
+ 'Interview recorded'
+ );
+ }
+ return run(
+ () => notHeld.mutateAsync({ application, position, outcome: recording, notes: outcomeNotes }),
+ recording === NO_SHOW ? 'Marked as not attended' : 'Interview cancelled'
+ );
+ };
+
+ const title = state === 'scheduled' ? 'Interview' : 'Schedule Interview';
+
return (
!v && onClose()}>
- Schedule Interview
+ {title}
- {scheduled ? (
+ {done ? (
-
+
-
Interview scheduled!
-
{application?.applicant_name} will be notified.
+
{done}
+
{application?.applicant_name}
+
+ ) : state === 'completed' ? (
+ /* The interview record exists, so there is nothing to book and
+ nothing to record. This is also the state that puts the candidate
+ in the final-selection queue. */
+
+
+
+
+
Interview already recorded
+
+ {application?.applicant_name} is awaiting a hiring decision.
+
+
+ Close
+
+
+ ) : state === 'scheduled' && !recording ? (
+
+
+
+ {TYPE_LABEL[booked?.type] || 'Video'} interview booked
+
+
+ {booked?.date} at {booked?.time}
+
+ {booked?.notes && (
+
{booked.notes}
+ )}
+
+
+
+
What happened?
+
+ Only a completed interview puts this candidate up for a hiring decision.
+
+
+ setRecording('completed')}
+ className="flex items-center gap-2.5 p-2.5 rounded-lg border border-[#E5E7EB] hover:border-[#0838E0] hover:bg-[#EEF3FE] text-left transition-colors"
+ >
+
+ Interview completed
+
+ setRecording(NO_SHOW)}
+ className="flex items-center gap-2.5 p-2.5 rounded-lg border border-[#E5E7EB] hover:border-[#0838E0] hover:bg-[#F9FAFB] text-left transition-colors"
+ >
+
+ Candidate did not attend
+
+ setRecording(CANCELLED)}
+ className="flex items-center gap-2.5 p-2.5 rounded-lg border border-[#E5E7EB] hover:border-[#0838E0] hover:bg-[#F9FAFB] text-left transition-colors"
+ >
+
+ Interview cancelled
+
+
+
+
+ {error &&
{error}
}
+
+
+ Close
+
+
+ ) : recording ? (
+
+ {recording === 'completed' ? (
+
+
Interviewer's assessment (optional)
+
+ Your own reading, entered by you. It is recorded alongside the interview
+ and never decides anything — hiring stays a separate, deliberate action.
+
+
+ {ASSESSMENTS.map((a) => (
+ setAssessment(assessment === a.value ? '' : a.value)}
+ className={`py-2.5 rounded-lg border text-[12px] font-medium transition-all ${
+ assessment === a.value
+ ? 'border-[#0838E0] bg-[#EEF3FE] text-[#0838E0]'
+ : 'border-[#E5E7EB] text-[#6B7280] hover:bg-[#F9FAFB]'
+ }`}
+ >
+ {a.label}
+
+ ))}
+
+
+ ) : (
+
+ {recording === NO_SHOW
+ ? 'Recorded as not attended. No interview record is created, so this candidate does not go forward for a decision.'
+ : 'Recorded as cancelled. No interview record is created, so this candidate does not go forward for a decision.'}
+
+ )}
+
+
+ Notes (optional)
+
+
+ {error &&
{error}
}
+
+
+ { setRecording(null); setError(''); }} disabled={busy}>
+ Back
+
+
+ {recording === 'completed' ? 'Record interview' : 'Save'}
+
+
) : (
+ {(state === 'cancelled' || state === 'no_show') && (
+
+ {state === 'no_show'
+ ? 'The last interview was not attended. Booking another replaces it.'
+ : 'The last interview was cancelled. Booking another replaces it.'}
+
+ )}
+
Interview Type
- {[
- { value: 'video', label: 'Video', icon: Video },
- { value: 'phone', label: 'Phone', icon: Phone },
- { value: 'inperson', label: 'In Person', icon: User },
- ].map((opt) => (
+ {TYPES.map((opt) => (
setType(opt.value)}
@@ -76,13 +310,22 @@ export default function ScheduleInterviewModal({ open, onClose, application }) {
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add any prep notes..."
- className="w-full mt-1.5 min-h-[60px] p-2.5 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none"
+ className={field}
/>
+ {/* Said plainly, because the old version said the opposite of the
+ truth: booking records the booking, and nothing more. */}
+
+
+ Scheduling records the booking. Come back here afterwards to record what happened.
+
+
+ {error &&
{error}
}
+
- Cancel
-
+ Cancel
+
Schedule
@@ -91,4 +334,4 @@ export default function ScheduleInterviewModal({ open, onClose, application }) {
);
-}
\ No newline at end of file
+}
diff --git a/src/lib/hiringRecords.js b/src/lib/hiringRecords.js
index a4da7e9..7f6a831 100644
--- a/src/lib/hiringRecords.js
+++ b/src/lib/hiringRecords.js
@@ -48,41 +48,19 @@ export function buildHires({ staff = [], applications = [], postings = [] }) {
});
}
-/**
- * Demo fill, carried over from the page this module was extracted from.
+/*
+ * There is deliberately no demo fill here.
*
- * The store seeds three Staff records; Hired History has always padded that to
- * eight so the page reads as a hiring history rather than as three rows. That
- * padding is pre-existing product behaviour, not something derived — it is kept
- * here, named for what it is, so both pages show what the page has always shown
- * and there is one list to delete when the deployment has real volume.
+ * This module used to pad the hires list with five invented people so Hired
+ * History read as a history rather than as three rows. The padding was applied
+ * to Analytics too, so "total hires" counted eight where the database held
+ * three, and no page said which figure was which. A dashboard that quietly
+ * inflates a count is worse than a sparse one: the sparse page is merely
+ * disappointing, and the inflated page is wrong in a way nobody can see.
*
- * It only ever *adds* people the store does not already have, matched on email,
- * so a real hire is never shadowed by a demo one.
+ * Hires now come only from `staff` — the rows the hire workflow actually
+ * writes. If a deployment has three hires, every page says three.
*/
-const DEMO_FILL = [
- { id: 's4', name: 'Sophia Chen', email: 'sophia.chen@email.com', role: 'Guest Relations Lead', department: 'Front Desk', profile_tier: 'expert', score: 90, timeToHire: 2, hire_date: '2026-07-22', status: 'hired' },
- { id: 's5', name: 'Oliver Bennett', email: 'oliver.b@email.com', role: 'Event Coordinator', department: 'Event Manager', profile_tier: 'skilled', score: 91, timeToHire: 3, hire_date: '2026-07-20', status: 'hired' },
- { id: 's6', name: 'Aaliyah Patel', email: 'aaliyah.p@email.com', role: 'Operations Supervisor', department: 'Housekeeping', profile_tier: 'solid', score: 87, timeToHire: 2, hire_date: '2026-07-18', status: 'hired' },
- { id: 's7', name: 'Lucas Wright', email: 'lucas.w@email.com', role: 'Concierge Lead', department: 'Front Desk', profile_tier: 'solid', score: 88, timeToHire: 2, hire_date: '2026-07-15', status: 'hired' },
- { id: 's8', name: 'Elena Rostova', email: 'elena.r@email.com', role: 'Lead Security Officer', department: 'Security', profile_tier: 'expert', score: 95, timeToHire: 1, hire_date: '2026-07-12', status: 'hired' },
-];
-
-/** The joined hires, with the demo fill applied for anyone not already on file. */
-export function hiresWithFill(sources) {
- const live = buildHires(sources);
- const seen = new Set(live.map((h) => String(h.email || h.name).toLowerCase()));
-
- const filled = [...live];
- for (const person of DEMO_FILL) {
- const key = String(person.email || person.name).toLowerCase();
- if (!seen.has(key)) {
- filled.push({ ...person, company: person.company || '—' });
- seen.add(key);
- }
- }
- return filled;
-}
/** Headline figures: volume, speed, quality, and how many are still on. */
export function summarise(hires) {
@@ -116,6 +94,86 @@ export const APPLICATION_STATUSES = [
/** Counted as hired. `assigned` is hired and then rostered, not a separate fate. */
export const HIRED_STATUSES = ['hired', 'assigned'];
+/** Terminal fates. Neither is awaiting anybody's decision. */
+export const DECIDED_STATUSES = [...HIRED_STATUSES, 'rejected'];
+
+/**
+ * The applications that belong to one talent-pool person.
+ *
+ * Two ways in, in order of trust. `worker_profile_id` is the foreign key and is
+ * set whenever an application is filed from the pool. Email is the fallback,
+ * because rows written before that link existed carry only an address — and
+ * within an organization an address already identifies a person, which is what
+ * `worker_profiles`' own `UNIQUE (org_id, email)` asserts. It is a weaker claim
+ * than a key, so it is second, and it is here rather than in two pages so both
+ * agree about who somebody is.
+ */
+export const applicationsForProfile = (applications = [], profile) => {
+ if (!profile) return [];
+ const email = String(profile.email || '').toLowerCase();
+ return applications.filter((a) => (
+ (a.worker_profile_id && a.worker_profile_id === profile.id)
+ || (email && String(a.email || '').toLowerCase() === email)
+ ));
+};
+
+/**
+ * Whether an interview actually took place for this application.
+ *
+ * Read from the interview rows rather than from `job_applications.interview_id`,
+ * for two reasons. `ai_interviews.application_id` is a real NOT NULL foreign
+ * key, while `interview_id` is a deliberately unconstrained soft reference the
+ * schema's own migration notes can dangle. And `status = 'interview'` is set
+ * both when an interview is *arranged* and when one is *completed* — only the
+ * interview row distinguishes the two.
+ */
+export const hasInterview = (application, interviews = []) =>
+ interviews.some((i) => i.application_id === application?.id);
+
+/**
+ * Final selection: this person did the process for this position, and a human
+ * has not yet decided.
+ *
+ * Derived rather than stored. `application_status` has no `final_selection`
+ * value and this deliberately does not add one: "the interview happened" is
+ * already a fact in the database, so a status flag would be a second copy of it
+ * that somebody has to remember to write. Deriving also means the rule can
+ * change without a migration, which matters while the funnel is still settling.
+ *
+ * The interview verdict is deliberately NOT a gate here — a `no` verdict still
+ * belongs in front of a person, because the decision is theirs to make.
+ */
+export const isFinalSelection = (application, interviews = []) =>
+ Boolean(application)
+ && !DECIDED_STATUSES.includes(application.status)
+ && hasInterview(application, interviews);
+
+/** Everyone awaiting a decision, newest-scored first. */
+export const finalSelection = (applications = [], interviews = []) =>
+ applications
+ .filter((a) => isFinalSelection(a, interviews))
+ .sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0));
+
+/**
+ * Seats filled on a position — counted from the employment records, never
+ * stored.
+ *
+ * There is no `filled` column on `job_postings` and this does not invent one: a
+ * counter is a second source of truth, and the day it disagrees with the `staff`
+ * rows there is no way to tell which is lying. Someone who has left
+ * (`status: 'inactive'`) is not occupying a seat, so the role is open again.
+ */
+export const filledFor = (postingId, staff = []) =>
+ staff.filter((s) => s.job_posting_id === postingId && s.status !== 'inactive').length;
+
+/** Seats still open. Never negative — over-hiring is a fact, not a negative. */
+export const remainingFor = (posting, staff = []) =>
+ Math.max(0, (posting?.headcount ?? 1) - filledFor(posting?.id, staff));
+
+/** Whether this position has every seat taken. */
+export const isFullyStaffed = (posting, staff = []) =>
+ Boolean(posting) && remainingFor(posting, staff) === 0;
+
/**
* How far a candidate got.
*
diff --git a/src/lib/humanInterviews.js b/src/lib/humanInterviews.js
new file mode 100644
index 0000000..3c1acec
--- /dev/null
+++ b/src/lib/humanInterviews.js
@@ -0,0 +1,102 @@
+import { hasInterview } from '@/lib/hiringRecords';
+
+/**
+ * Human interviews, read back from the record KROW already keeps.
+ *
+ * A human interview has three moments — booked, held, and whatever happened
+ * instead — and until now none of them survived the modal that collected them.
+ * This module is the read half of fixing that. The write half is in
+ * `krowHooks.js`; what it writes are `user_activity` rows, which is the
+ * organization's append-only log and already carries `application_id`,
+ * `interview_id` and a `metadata` object per event.
+ *
+ * WHAT COUNTS AS A COMPLETED INTERVIEW, AND WHAT DOES NOT
+ *
+ * Nothing in this file decides that. Completion is `hasInterview` — the
+ * existence of an `ai_interviews` row whose `application_id` is a NOT NULL
+ * foreign key — exactly as it was before Step 7, and `isFinalSelection` is
+ * untouched. The events below record a booking and its outcome; they are never
+ * the proof that an interview happened.
+ *
+ * That split is the whole safety property. `logActivity` is fire-and-forget by
+ * design, the log has no uniqueness constraint, and its list endpoint is capped
+ * at 500 rows — none of which is a foundation for a hiring decision. So a
+ * booking may be lost without a candidate being wrongly advanced, because a
+ * booking never advances anyone. Only the interview row does, and that is
+ * written transactionally.
+ */
+
+export const SCHEDULED = 'interview_scheduled';
+export const COMPLETED = 'interview_completed';
+export const CANCELLED = 'interview_cancelled';
+export const NO_SHOW = 'interview_no_show';
+
+/** Every event type this module writes or reads. */
+export const INTERVIEW_EVENTS = [SCHEDULED, COMPLETED, CANCELLED, NO_SHOW];
+
+/** The outcomes an interviewer can record against a booking. */
+export const OUTCOMES = [COMPLETED, NO_SHOW, CANCELLED];
+
+const time = (event) => {
+ const at = Date.parse(event?.created_date ?? '');
+ return Number.isNaN(at) ? 0 : at;
+};
+
+/**
+ * This application's interview events, newest first.
+ *
+ * The list arrives sorted by the server, but it is re-sorted here rather than
+ * trusted: a caller may hand over a filtered or merged array, and "newest"
+ * decides which booking is the standing one.
+ */
+export const interviewEventsFor = (application, activity = []) => {
+ if (!application?.id) return [];
+ return activity
+ .filter((e) => e?.application_id === application.id && INTERVIEW_EVENTS.includes(e.event_type))
+ .slice()
+ .sort((a, b) => time(b) - time(a));
+};
+
+/**
+ * The booking that currently stands, or null.
+ *
+ * A booking stands until something later supersedes it — held, cancelled, or
+ * not attended. Reading the newest event first and stopping at the first one
+ * that is not a booking is what makes rescheduling work without a second
+ * record to keep in step.
+ */
+export const scheduledInterview = (application, activity = []) => {
+ const [latest] = interviewEventsFor(application, activity);
+ return latest?.event_type === SCHEDULED ? latest : null;
+};
+
+/** What the booking said: type, date, time, notes. */
+export const scheduleDetails = (event) => {
+ const m = event?.metadata || {};
+ return {
+ type: m.interview_type || '',
+ date: m.scheduled_date || '',
+ time: m.scheduled_time || '',
+ notes: m.notes || '',
+ };
+};
+
+/**
+ * Where this application's human interview has got to.
+ *
+ * `completed` is answered by the interview row and nothing else, so it stays
+ * true even if the log is trimmed, lost, or never written. The other three are
+ * log-derived and advisory — none of them can put a candidate in front of a
+ * hiring decision, and none of them can keep one out of it.
+ */
+export const humanInterviewState = (application, activity = [], interviews = []) => {
+ if (hasInterview(application, interviews)) return 'completed';
+ const [latest] = interviewEventsFor(application, activity);
+ if (!latest) return 'none';
+ if (latest.event_type === SCHEDULED) return 'scheduled';
+ if (latest.event_type === CANCELLED) return 'cancelled';
+ if (latest.event_type === NO_SHOW) return 'no_show';
+ /* A completion event with no interview row behind it. The row is the fact;
+ the event is a note about it. Treated as not completed, deliberately. */
+ return 'none';
+};
diff --git a/src/lib/krowHooks.js b/src/lib/krowHooks.js
index 0a2a136..9f5b002 100644
--- a/src/lib/krowHooks.js
+++ b/src/lib/krowHooks.js
@@ -6,6 +6,7 @@ import { request } from '@/api/httpClient';
import { generateJobDescription, screenCandidate, matchTalentForJob } from './krowAi';
import { recalcProfilePatch } from './krowScore';
import { logActivity } from './userTracking';
+import { CANCELLED, COMPLETED, NO_SHOW, SCHEDULED } from './humanInterviews';
import { evaluateChallenge } from './provingGround';
/**
@@ -705,6 +706,11 @@ export function useAssignWorkers() {
entry.application_id = application.id;
} else {
entry.application = {
+ /* Same link the position page files: without it a worker assigned
+ straight from the pool arrives as an application belonging to
+ nobody, and the hire it may later become cannot be traced back
+ to the profile it came from. */
+ worker_profile_id: worker.profile?.id,
applicant_name: worker.name,
email: worker.email || '',
phone: worker.profile?.phone || '',
@@ -787,6 +793,179 @@ export function useMarkInterviewReady() {
});
}
+/**
+ * Schedule a human interview.
+ *
+ * Two writes, and the order matters. First the status transition, through
+ * `useMarkInterviewReady` — the existing mechanism, unchanged, which is what
+ * makes a candidate interview-ready everywhere in the product. Then the booking
+ * itself as a `user_activity` row carrying type, date, time and notes.
+ *
+ * WHAT THIS DELIBERATELY DOES NOT DO
+ *
+ * It does not create an `ai_interviews` row. That row is the durable evidence
+ * an interview was COMPLETED, and writing one here would mean every candidate
+ * merely booked in appeared in the final-selection queue — a hiring decision
+ * put in front of a recruiter for someone nobody has met yet. Scheduling is not
+ * completion, and the two writes are kept apart so nothing can confuse them.
+ *
+ * The booking goes through `base44.entities.UserActivity.create` rather than
+ * `logActivity`, which swallows its own failures on purpose so that tracking
+ * cannot break a user flow. That is the right call for tracking and the wrong
+ * one here: the operator typed a date and needs to be told if it did not save.
+ */
+export function useScheduleHumanInterview() {
+ const queryClient = useQueryClient();
+ const markInterviewReady = useMarkInterviewReady();
+
+ return useMutation({
+ mutationFn: /** @param {any} vars */ async ({ application, position, type, date, time, notes }) => {
+ if (!application?.id) throw new Error('No application to schedule against');
+ if (!date || !time) throw new Error('An interview needs a date and a time');
+
+ await markInterviewReady.mutateAsync({ application, position });
+
+ const when = `${date} ${time}`;
+ return base44.entities.UserActivity.create({
+ event_type: SCHEDULED,
+ details: `${application.applicant_name} booked for a ${type || 'video'} interview on ${when}`,
+ application_id: application.id,
+ position_id: position?.id || application.job_posting_id,
+ metadata: {
+ interview_type: type || 'video',
+ scheduled_date: date,
+ scheduled_time: time,
+ notes: notes || '',
+ },
+ });
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['userActivity'] });
+ queryClient.invalidateQueries({ queryKey: ['applications'] });
+ queryClient.invalidateQueries({ queryKey: owliverContextKey });
+ },
+ });
+}
+
+/**
+ * The line that says a human interview actually happened.
+ *
+ * The record goes in `ai_interviews` because that is where KROW keeps completed
+ * interviews, and it is currently reused as the durable completed-interview
+ * record for BOTH the AI flow and this one. The name is wrong for half of what
+ * it now holds; renaming it is a migration and deliberately out of scope, so
+ * this comment stands in for it. What matters is the invariant the table
+ * enforces either way: a row exists if and only if an interview was completed,
+ * which is what `isFinalSelection` reads and why it needed no change.
+ *
+ * NOTHING ABOUT AI IS INVENTED HERE. No transcript, no AI score, no AI verdict,
+ * no hire recommendation, no integrity score, no category scores, no flags —
+ * every one of those columns is left to its schema default rather than filled
+ * with a plausible-looking number that no machine produced. `messages` stays
+ * the empty array it defaults to, which is also the structural tell that no AI
+ * conducted this: an AI interview always carries its transcript.
+ *
+ * `overall_interview_score` is omitted rather than sent as 0, and that is not a
+ * cosmetic choice. `WorkflowService.CreateInterview` copies the score onto the
+ * application only when the request mentions it, so omitting it leaves a real
+ * screening score intact instead of overwriting it with a zero this interview
+ * never measured.
+ *
+ * The interviewer's own assessment is optional, human-entered, and recorded as
+ * what it is. It sorts and informs; it decides nothing. Hiring stays an
+ * explicit recruiter action on a separate control.
+ */
+const HUMAN_INTERVIEW_NOTE = 'Human interview — recorded by the hiring team, not conducted by AI.';
+
+export function useCompleteHumanInterview() {
+ const queryClient = useQueryClient();
+ const createInterview = useCreateInterview();
+
+ return useMutation({
+ mutationFn: /** @param {any} vars */ async ({ application, position, assessment, notes }) => {
+ if (!application?.id) throw new Error('No application to record an interview against');
+ if (!application.job_posting_id) throw new Error('That application is not attached to a position');
+
+ /** @type {any} */
+ const record = {
+ application_id: application.id,
+ job_posting_id: application.job_posting_id,
+ /* Denormalised the same way the AI path denormalises them, and factual
+ either way: this is who was interviewed, for what. */
+ job_title: application.job_title || position?.title || '',
+ candidate_name: application.applicant_name || '',
+ summary: notes?.trim() ? `${HUMAN_INTERVIEW_NOTE} ${notes.trim()}` : HUMAN_INTERVIEW_NOTE,
+ };
+ /* Only when a person actually chose one. Left unsent, the column takes
+ its schema default — which is the database's word, not ours. */
+ if (assessment) record.verdict = assessment;
+
+ /* The existing hook, so there is one interview client rather than two.
+ It transactionally sets the application to `interview` and links
+ `interview_id`, and it logs its own `start_interview` event — noise on
+ this path, and not worth changing a shared write to silence. */
+ const interview = await createInterview.mutateAsync(record);
+
+ await base44.entities.UserActivity.create({
+ event_type: COMPLETED,
+ details: `${application.applicant_name} completed a human interview`,
+ application_id: application.id,
+ position_id: position?.id || application.job_posting_id,
+ interview_id: interview?.id,
+ metadata: { conducted_by: 'human', assessment: assessment || '' },
+ });
+
+ return interview;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['interviews'] });
+ queryClient.invalidateQueries({ queryKey: ['applications'] });
+ queryClient.invalidateQueries({ queryKey: ['userActivity'] });
+ queryClient.invalidateQueries({ queryKey: owliverContextKey });
+ },
+ });
+}
+
+/**
+ * An interview that was booked and did not happen.
+ *
+ * Cancelled, or nobody came. Both write an event and NOTHING else: no interview
+ * record, because no interview took place; no new application status, because
+ * the enum has no value for this and inventing one would put a second funnel
+ * beside the real one. The application stays where it was, which is honest —
+ * it is still at interview stage, still waiting for an interview to happen.
+ *
+ * The consequence is the correct one and worth saying out loud: with no
+ * interview row, the candidate cannot reach final selection. A no-show is not a
+ * decision, and this does not make one.
+ */
+export function useRecordInterviewNotHeld() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: /** @param {any} vars */ async ({ application, position, outcome, notes }) => {
+ if (!application?.id) throw new Error('No application to record against');
+ if (outcome !== CANCELLED && outcome !== NO_SHOW) {
+ throw new Error(`Not an outcome this records: ${outcome}`);
+ }
+
+ return base44.entities.UserActivity.create({
+ event_type: outcome,
+ details: outcome === NO_SHOW
+ ? `${application.applicant_name} did not attend the interview`
+ : `Interview with ${application.applicant_name} was cancelled`,
+ application_id: application.id,
+ position_id: position?.id || application.job_posting_id,
+ metadata: { notes: notes || '' },
+ });
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['userActivity'] });
+ queryClient.invalidateQueries({ queryKey: owliverContextKey });
+ },
+ });
+}
+
export function useLearningPaths() {
return useQuery({ queryKey: ['learningPaths'], queryFn: () => base44.entities.LearningPath.list('-created_date', 100) });
}
diff --git a/src/pages/PositionDetail.jsx b/src/pages/PositionDetail.jsx
index 6a032c3..3bd7204 100644
--- a/src/pages/PositionDetail.jsx
+++ b/src/pages/PositionDetail.jsx
@@ -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 || '',
diff --git a/src/pages/admin/Analytics.jsx b/src/pages/admin/Analytics.jsx
index f8f7fe3..ec9a0e0 100644
--- a/src/pages/admin/Analytics.jsx
+++ b/src/pages/admin/Analytics.jsx
@@ -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]
);
diff --git a/src/pages/admin/Candidates.jsx b/src/pages/admin/Candidates.jsx
index 4753477..e3562f7 100644
--- a/src/pages/admin/Candidates.jsx
+++ b/src/pages/admin/Candidates.jsx
@@ -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 (
hiresWithFill({ staff, applications, postings }),
+ () => buildHires({ staff, applications, postings }),
[staff, applications, postings]
);
diff --git a/src/pages/admin/TalentProfile.jsx b/src/pages/admin/TalentProfile.jsx
new file mode 100644
index 0000000..19f2832
--- /dev/null
+++ b/src/pages/admin/TalentProfile.jsx
@@ -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 (
+
+ {facts.map(({ icon: Icon, label }) => (
+
+
+ {label}
+
+ ))}
+
+ );
+}
+
+function Tags({ title, values = [] }) {
+ if (!values.length) return null;
+ return (
+
+
{title}
+
+ {values.map((v) => {v} )}
+
+
+ );
+}
+
+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
;
+ }
+
+ if (!profile) {
+ return (
+
+ navigate('/admin/talent-pool')}>Back to Talent Pool}
+ />
+
+ );
+ }
+
+ return (
+
+
+ navigate('/admin/talent-pool')}>
+ Talent Pool
+
+ setAddOpen(true)}>
+ Consider for a position
+
+
+ )}
+ >
+
+
+
+
+
+
+ {profile.career_goals && (
+
{profile.career_goals}
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* The whole reason this page is separate from the candidate page:
+ one person, every position, each at its own stage. */}
+
+
+ Positions considered for
+
+
+ {mine.length === 0 ? (
+ setAddOpen(true)}> Consider for a position}
+ />
+ ) : (
+
+ {mine.map((a) => {
+ const posting = postingFor(a);
+ return (
+
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"
+ >
+
+
+ {posting?.title || a.job_title || 'Position'}
+
+
+ {posting?.company || '—'}
+ {a.ai_score ? ` · scored ${a.ai_score}` : ''}
+
+
+
+ {isFinalSelection(a, interviews) && (
+
+
+ Awaiting decision
+
+ )}
+
+ {stageLabel(a, interviews)}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/candidates/nodes.jsx b/src/pages/admin/candidates/nodes.jsx
index 0d9240a..59aea28 100644
--- a/src/pages/admin/candidates/nodes.jsx
+++ b/src/pages/admin/candidates/nodes.jsx
@@ -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 (
}
@@ -34,10 +36,17 @@ function CandidatesToolbar() {
<>
({ 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. */}
!hasInterview(a, interviews)).length
+ : 0;
+
if (isLoading) {
return (
@@ -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 (
-
No candidates match your filters
+
+ {emptyQueue ? 'Nobody is awaiting a decision' : 'No candidates match your filters'}
+
+ {emptyQueue && (
+
+ Candidates arrive here once their interview is completed, and leave once they are hired or declined.
+
+ )}
{isFiltered && (
Clear filters
@@ -84,6 +113,13 @@ function CandidatesList() {
return (
+ {missingEvidence > 0 && (
+
+ {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.`}
+
+ )}
{filtered.map((app, idx) => (
- ({
- 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 ? (
+ ({
+ 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,
+ }))}
+ />
+ ) : (
+
+ )}
);
diff --git a/src/pages/admin/talent-pool/nodes.jsx b/src/pages/admin/talent-pool/nodes.jsx
index 22e9e88..4a834e9 100644
--- a/src/pages/admin/talent-pool/nodes.jsx
+++ b/src/pages/admin/talent-pool/nodes.jsx
@@ -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)}
/>
+ setConsidering(p)}
+ />
toast.info(`Opening ${p.full_name}'s KROW Identity`)}
+ onClick={() => navigate(`/admin/talent/${p.id}`)}
/>
),
},
]}
/>
+
+ { if (!next) setConsidering(null); }}
+ profile={considering}
+ existing={applicationsForProfile(applications, considering)}
+ />
);
}