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:
2026-09-17 22:52:51 +05:30
parent 64140c7add
commit dca184289e
17 changed files with 1624 additions and 94 deletions

View File

@@ -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. 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:
<td 40 -> 0 five table rows of people who were never hired
<span 51 -> 13
<div 78 -> 39
<p 26 -> 7
<svg 20 -> 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 `<div>`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 ## 2026-08-27 — the seed gained the three statuses nothing exercised
`application_status` has seven values. The fixture produced four: `applied`, `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. 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 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. 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.

View File

@@ -5959,6 +5959,354 @@ record('an assigned candidate counts as hired',
records.HIRED_STATUSES.includes('assigned') && records.HIRED_STATUSES.includes('assigned') &&
records.rankOf('assigned') >= records.STAGE_ORDER.indexOf('hired')); 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 /* "Screened and waiting on a decision" includes the shortlisted. This counted
`ai_screened` alone, which read as correct only while nothing was ever `ai_screened` alone, which read as correct only while nothing was ever
shortlisted — the first shortlisted candidate dropped out of the count in shortlisted — the first shortlisted candidate dropped out of the count in

View File

@@ -38,6 +38,7 @@ import AdminCandidateProfile from '@/pages/admin/CandidateProfile';
import AdminCandidatesAnalysis from '@/pages/admin/CandidatesAnalysis'; import AdminCandidatesAnalysis from '@/pages/admin/CandidatesAnalysis';
import AdminHiredHistory from '@/pages/admin/HiredHistory'; import AdminHiredHistory from '@/pages/admin/HiredHistory';
import AdminTalentPool from '@/pages/admin/TalentPool'; import AdminTalentPool from '@/pages/admin/TalentPool';
import AdminTalentProfile from '@/pages/admin/TalentProfile';
import AdminAnalytics from '@/pages/admin/Analytics'; import AdminAnalytics from '@/pages/admin/Analytics';
import AdminActivity from '@/pages/admin/Activity'; import AdminActivity from '@/pages/admin/Activity';
import AdminProfile from '@/pages/admin/Profile'; import AdminProfile from '@/pages/admin/Profile';
@@ -107,6 +108,12 @@ const AuthenticatedApp = () => {
<Route path="candidates-analysis" element={<AdminCandidatesAnalysis />} /> <Route path="candidates-analysis" element={<AdminCandidatesAnalysis />} />
<Route path="hired" element={<AdminHiredHistory />} /> <Route path="hired" element={<AdminHiredHistory />} />
<Route path="talent-pool" element={<AdminTalentPool />} /> <Route path="talent-pool" element={<AdminTalentPool />} />
{/* 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. */}
<Route path="talent/:id" element={<AdminTalentProfile />} />
<Route path="university" element={<University />} /> <Route path="university" element={<University />} />
<Route path="university/:id" element={<CourseDetail />} /> <Route path="university/:id" element={<CourseDetail />} />
<Route path="analytics" element={<AdminAnalytics />} /> <Route path="analytics" element={<AdminAnalytics />} />

View File

@@ -1080,9 +1080,6 @@ export default function KrowAssistant({
> >
<SuggestedQuestions prompts={prompts} open={showSuggestions} onSelect={runPrompt} /> <SuggestedQuestions prompts={prompts} open={showSuggestions} onSelect={runPrompt} />
{composer} {composer}
<p className="px-1 text-[10px] leading-tight text-ink-4">
Owliver reads this page&apos;s data. Check anything you act on.
</p>
</div> </div>
</Surface> </Surface>
); );

View File

@@ -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 (
<Modal
open={open}
onOpenChange={onOpenChange}
title={profile ? `Consider ${profile.full_name} for a position` : 'Consider for a position'}
description="This files an application against the vacancy and starts its hiring pipeline. The talent-pool record is not copied."
size="lg"
busy={Boolean(busyId)}
>
{isLoading && <div className="h-24" />}
{!isLoading && open_.length === 0 && (
<EmptyState
title="No open positions"
description="A position has to be open before anyone can be considered for it."
action={<Button variant="outline" onClick={() => { onOpenChange?.(false); navigate('/admin/positions'); }}>Go to Positions</Button>}
/>
)}
{!isLoading && open_.length > 0 && (
<ul className="space-y-2">
{open_.map((p) => {
const already = appliedTo.get(p.id);
const remaining = staff.length ? remainingFor(p, staff) : null;
return (
<li
key={p.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/70 bg-surface px-4 py-3"
>
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{p.title}</p>
<p className="truncate text-body-sm text-ink-3">
{[p.company, p.location].filter(Boolean).join(' · ') || '—'}
{remaining !== null && ` · ${remaining} of ${p.headcount ?? 1} open`}
</p>
</div>
{already ? (
<div className="flex shrink-0 items-center gap-2">
<Badge variant="neutral" size="sm">Already considered</Badge>
<StatusBadge status={already.status} size="sm" />
</div>
) : (
<Button
size="sm"
onClick={() => add(p)}
loading={busyId === p.id}
disabled={Boolean(busyId)}
>
Consider
</Button>
)}
</li>
);
})}
</ul>
)}
</Modal>
);
}

View File

@@ -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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input'; 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 [type, setType] = useState('video');
const [date, setDate] = useState(''); const [date, setDate] = useState('');
const [time, setTime] = useState(''); const [time, setTime] = useState('');
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState('');
const [scheduled, setScheduled] = useState(false);
const handleSchedule = () => { const [recording, setRecording] = useState(null); // 'completed' | NO_SHOW | CANCELLED
setScheduled(true); const [assessment, setAssessment] = useState('');
setTimeout(() => { const [outcomeNotes, setOutcomeNotes] = useState('');
setScheduled(false);
onClose(); const [done, setDone] = useState('');
}, 1500); 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 ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Schedule Interview</DialogTitle> <DialogTitle>{title}</DialogTitle>
</DialogHeader> </DialogHeader>
{scheduled ? ( {done ? (
<div className="py-8 text-center"> <div className="py-8 text-center">
<div className="w-12 h-12 rounded-full bg-[#D1FAE5] flex items-center justify-center mx-auto mb-3"> <div className="w-12 h-12 rounded-full bg-[#D1FAE5] flex items-center justify-center mx-auto mb-3">
<Calendar className="w-6 h-6 text-[#059669]" /> <Check className="w-6 h-6 text-[#059669]" />
</div> </div>
<p className="text-[14px] font-medium text-[#111827]">Interview scheduled!</p> <p className="text-[14px] font-medium text-[#111827]">{done}</p>
<p className="text-[12px] text-[#6B7280]">{application?.applicant_name} will be notified.</p> <p className="text-[12px] text-[#6B7280]">{application?.applicant_name}</p>
</div>
) : 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. */
<div className="py-8 text-center">
<div className="w-12 h-12 rounded-full bg-[#EEF3FE] flex items-center justify-center mx-auto mb-3">
<Check className="w-6 h-6 text-[#0838E0]" />
</div>
<p className="text-[14px] font-medium text-[#111827]">Interview already recorded</p>
<p className="text-[12px] text-[#6B7280] mt-1">
{application?.applicant_name} is awaiting a hiring decision.
</p>
<DialogFooter className="mt-5">
<Button variant="outline" onClick={onClose}>Close</Button>
</DialogFooter>
</div>
) : state === 'scheduled' && !recording ? (
<div className="space-y-4">
<div className="rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] p-3">
<p className="text-[13px] font-medium text-[#111827]">
{TYPE_LABEL[booked?.type] || 'Video'} interview booked
</p>
<p className="text-[12px] text-[#6B7280] mt-0.5">
{booked?.date} at {booked?.time}
</p>
{booked?.notes && (
<p className="text-[12px] text-[#6B7280] mt-1.5">{booked.notes}</p>
)}
</div>
<div>
<p className="text-[13px] font-medium text-[#374151]">What happened?</p>
<p className="text-[12px] text-[#6B7280] mt-0.5">
Only a completed interview puts this candidate up for a hiring decision.
</p>
<div className="grid gap-2 mt-2.5">
<button
onClick={() => 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"
>
<Check className="w-4 h-4 text-[#059669] shrink-0" />
<span className="text-[13px] font-medium text-[#111827]">Interview completed</span>
</button>
<button
onClick={() => 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"
>
<UserX className="w-4 h-4 text-[#B45309] shrink-0" />
<span className="text-[13px] font-medium text-[#111827]">Candidate did not attend</span>
</button>
<button
onClick={() => 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"
>
<CalendarX className="w-4 h-4 text-[#6B7280] shrink-0" />
<span className="text-[13px] font-medium text-[#111827]">Interview cancelled</span>
</button>
</div>
</div>
{error && <p className="text-[12px] text-[#B91C1C]">{error}</p>}
<DialogFooter>
<Button variant="outline" onClick={onClose}>Close</Button>
</DialogFooter>
</div>
) : recording ? (
<div className="space-y-4">
{recording === 'completed' ? (
<div>
<Label className="text-[13px] text-[#374151]">Interviewer&apos;s assessment (optional)</Label>
<p className="text-[12px] text-[#6B7280] mt-0.5">
Your own reading, entered by you. It is recorded alongside the interview
and never decides anything — hiring stays a separate, deliberate action.
</p>
<div className="grid grid-cols-3 gap-2 mt-1.5">
{ASSESSMENTS.map((a) => (
<button
key={a.value}
onClick={() => 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}
</button>
))}
</div>
</div>
) : (
<p className="text-[13px] text-[#374151]">
{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.'}
</p>
)}
<div>
<Label className="text-[13px] text-[#374151]">Notes (optional)</Label>
<textarea
value={outcomeNotes}
onChange={(e) => setOutcomeNotes(e.target.value)}
placeholder={recording === 'completed' ? 'How did it go?' : 'Anything worth recording...'}
className={field}
/>
</div>
{error && <p className="text-[12px] text-[#B91C1C]">{error}</p>}
<DialogFooter>
<Button variant="outline" onClick={() => { setRecording(null); setError(''); }} disabled={busy}>
Back
</Button>
<Button onClick={submitOutcome} disabled={busy} className="bg-[#0838E0] hover:bg-[#062BAF]">
{recording === 'completed' ? 'Record interview' : 'Save'}
</Button>
</DialogFooter>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{(state === 'cancelled' || state === 'no_show') && (
<p className="text-[12px] text-[#6B7280]">
{state === 'no_show'
? 'The last interview was not attended. Booking another replaces it.'
: 'The last interview was cancelled. Booking another replaces it.'}
</p>
)}
<div> <div>
<Label className="text-[13px] text-[#374151]">Interview Type</Label> <Label className="text-[13px] text-[#374151]">Interview Type</Label>
<div className="grid grid-cols-3 gap-2 mt-1.5"> <div className="grid grid-cols-3 gap-2 mt-1.5">
{[ {TYPES.map((opt) => (
{ value: 'video', label: 'Video', icon: Video },
{ value: 'phone', label: 'Phone', icon: Phone },
{ value: 'inperson', label: 'In Person', icon: User },
].map((opt) => (
<button <button
key={opt.value} key={opt.value}
onClick={() => setType(opt.value)} onClick={() => setType(opt.value)}
@@ -76,13 +310,22 @@ export default function ScheduleInterviewModal({ open, onClose, application }) {
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
placeholder="Add any prep notes..." 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}
/> />
</div> </div>
{/* Said plainly, because the old version said the opposite of the
truth: booking records the booking, and nothing more. */}
<p className="text-[12px] text-[#6B7280]">
<Calendar className="w-3 h-3 inline-block mr-1 -mt-0.5" />
Scheduling records the booking. Come back here afterwards to record what happened.
</p>
{error && <p className="text-[12px] text-[#B91C1C]">{error}</p>}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button> <Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
<Button onClick={handleSchedule} disabled={!date || !time} className="bg-[#0838E0] hover:bg-[#062BAF]"> <Button onClick={submitSchedule} disabled={!date || !time || busy} className="bg-[#0838E0] hover:bg-[#062BAF]">
<Clock className="w-4 h-4 mr-2" /> Schedule <Clock className="w-4 h-4 mr-2" /> Schedule
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -91,4 +334,4 @@ export default function ScheduleInterviewModal({ open, onClose, application }) {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }

View File

@@ -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 * This module used to pad the hires list with five invented people so Hired
* eight so the page reads as a hiring history rather than as three rows. That * History read as a history rather than as three rows. The padding was applied
* padding is pre-existing product behaviour, not something derived — it is kept * to Analytics too, so "total hires" counted eight where the database held
* here, named for what it is, so both pages show what the page has always shown * three, and no page said which figure was which. A dashboard that quietly
* and there is one list to delete when the deployment has real volume. * 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, * Hires now come only from `staff` — the rows the hire workflow actually
* so a real hire is never shadowed by a demo one. * 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. */ /** Headline figures: volume, speed, quality, and how many are still on. */
export function summarise(hires) { 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. */ /** Counted as hired. `assigned` is hired and then rostered, not a separate fate. */
export const HIRED_STATUSES = ['hired', 'assigned']; 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. * How far a candidate got.
* *

102
src/lib/humanInterviews.js Normal file
View File

@@ -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';
};

View File

@@ -6,6 +6,7 @@ import { request } from '@/api/httpClient';
import { generateJobDescription, screenCandidate, matchTalentForJob } from './krowAi'; import { generateJobDescription, screenCandidate, matchTalentForJob } from './krowAi';
import { recalcProfilePatch } from './krowScore'; import { recalcProfilePatch } from './krowScore';
import { logActivity } from './userTracking'; import { logActivity } from './userTracking';
import { CANCELLED, COMPLETED, NO_SHOW, SCHEDULED } from './humanInterviews';
import { evaluateChallenge } from './provingGround'; import { evaluateChallenge } from './provingGround';
/** /**
@@ -705,6 +706,11 @@ export function useAssignWorkers() {
entry.application_id = application.id; entry.application_id = application.id;
} else { } else {
entry.application = { 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, applicant_name: worker.name,
email: worker.email || '', email: worker.email || '',
phone: worker.profile?.phone || '', 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() { export function useLearningPaths() {
return useQuery({ queryKey: ['learningPaths'], queryFn: () => base44.entities.LearningPath.list('-created_date', 100) }); return useQuery({ queryKey: ['learningPaths'], queryFn: () => base44.entities.LearningPath.list('-created_date', 100) });
} }

View File

@@ -147,11 +147,21 @@ export default function PositionDetail() {
const [messageApp, setMessageApp] = useState(null); const [messageApp, setMessageApp] = useState(null);
const [inviteOpen, setInviteOpen] = useState(false); 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 admitTalent = async (profile, status = 'applied') => {
const existing = jobApps.find((a) => a.email && profile.email && a.email.toLowerCase() === profile.email.toLowerCase()); const existing = jobApps.find((a) => a.email && profile.email && a.email.toLowerCase() === profile.email.toLowerCase());
if (existing) return existing; if (existing) return existing;
return createApp.mutateAsync({ return createApp.mutateAsync({
worker_profile_id: profile.id,
applicant_name: profile.full_name, applicant_name: profile.full_name,
email: profile.email || '', email: profile.email || '',
phone: profile.phone || '', phone: profile.phone || '',

View File

@@ -10,7 +10,7 @@ import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/analytics/nodes'; import '@/pages/admin/analytics/nodes';
import { import {
buildEfficiency, buildFunnel, buildInsights, buildTrend, byDepartment, byPosition, buildEfficiency, buildFunnel, buildInsights, buildTrend, byDepartment, byPosition,
hiresWithFill, summarise, buildHires, summarise,
} from '@/lib/hiringRecords'; } from '@/lib/hiringRecords';
/** /**
@@ -41,7 +41,7 @@ export default function AdminAnalytics() {
const { data: postings = [] } = useJobPostings(); const { data: postings = [] } = useJobPostings();
const hires = useMemo( const hires = useMemo(
() => hiresWithFill({ staff, applications, postings }), () => buildHires({ staff, applications, postings }),
[staff, applications, postings] [staff, applications, postings]
); );

View File

@@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useApplications, useInterviews, useJobPostings, useUpdateApplication, useHireCandidate } from '@/lib/krowHooks'; import { useApplications, useInterviews, useJobPostings, useUpdateApplication, useHireCandidate } from '@/lib/krowHooks';
import { isFinalSelection } from '@/lib/hiringRecords';
import { base44 } from '@/api/base44Client'; import { base44 } from '@/api/base44Client';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { AdminPage } from '@/components/admin/PageShell'; import { AdminPage } from '@/components/admin/PageShell';
@@ -23,17 +24,60 @@ const SCORE_BANDS = {
unscored: (s) => !s, 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() { export default function AdminCandidates() {
const { data: applications = [], isLoading } = useApplications(); const { data: applications = [], isLoading } = useApplications();
const { data: postings = [] } = useJobPostings(); 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 updateApp = useUpdateApplication();
const hireCandidate = useHireCandidate(); const hireCandidate = useHireCandidate();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [position, setPosition] = useState('all'); const [position, setPosition] = useState('all');
const [stage, setStage] = useState('all'); const [stage, setStage] = useState(DEFAULT_STAGE);
const [band, setBand] = useState('all'); const [band, setBand] = useState('all');
const [sort, setSort] = useState('score'); const [sort, setSort] = useState('score');
@@ -88,15 +132,18 @@ export default function AdminCandidates() {
.filter((a) => { .filter((a) => {
if (q && !`${a.applicant_name} ${a.email} ${a.job_title} ${(a.skills || []).join(' ')}`.toLowerCase().includes(q)) return false; 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 (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; if (band !== 'all' && !SCORE_BANDS[band](a.ai_score)) return false;
return true; return true;
}) })
.sort(SORTS[sort].compare); .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'); /* The queue is the resting state, so sitting in it is not "filtered" —
const clearFilters = () => { setSearch(''); setPosition('all'); setStage('all'); setBand('all'); }; 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 postingById = useMemo(() => Object.fromEntries(postings.map(p => [p.id, p])), [postings]);
const resolveTitle = (a) => a.job_title || postingById[a.job_posting_id]?.title || ''; 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, applications, filtered, isLoading, isFiltered, clearFilters, positions, resolveTitle,
search, setSearch, position, setPosition, stage, setStage, band, setBand, sort, setSort, search, setSearch, position, setPosition, stage, setStage, band, setBand, sort, setSort,
handleAction, setMessageApp, setScheduleApp, handleDecline, handleDelete, handleHire, 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 ( return (
<AdminPage <AdminPage

View File

@@ -4,7 +4,7 @@ import { Avatar, Badge, Drawer, StatusBadge, Surface } from '@/components/ds';
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks'; import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
import { AdminPage, SectionTitle } from '@/components/admin/PageShell'; import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
import { SkillSurface } from '@/components/skills/SkillSurface'; 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 { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider'; import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { UiEditor } from '@/components/ui-editor/UiEditor'; import { UiEditor } from '@/components/ui-editor/UiEditor';
@@ -126,7 +126,7 @@ export default function AdminHiredHistory() {
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
const hires = useMemo( const hires = useMemo(
() => hiresWithFill({ staff, applications, postings }), () => buildHires({ staff, applications, postings }),
[staff, applications, postings] [staff, applications, postings]
); );

View 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>
);
}

View File

@@ -6,6 +6,7 @@ import CandidateCard from '@/components/krow/CandidateCard';
import { registerNodeType } from '@/lib/ui/registry'; import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition'; import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer'; import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
import { hasInterview } from '@/lib/hiringRecords';
/** /**
* Candidates, as addressable nodes. * Candidates, as addressable nodes.
@@ -27,6 +28,7 @@ function CandidatesToolbar() {
positions, filtered, applications, isFiltered, positions, filtered, applications, isFiltered,
} = useUiContext(); } = useUiContext();
return ( return (
<Toolbar <Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />} search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}
@@ -34,10 +36,17 @@ function CandidatesToolbar() {
<> <>
<FilterSelect value={position} onChange={setPosition} label="Position" <FilterSelect value={position} onChange={setPosition} label="Position"
options={[{ value: 'all', label: 'All positions' }, ...positions.map((p) => ({ value: p, label: p }))]} /> 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={[ <FilterSelect value={stage} onChange={setStage} label="Stage" options={[
{ value: 'all', label: 'All stages' }, { value: 'applied', label: 'Applied' }, { value: 'final', label: 'Final selection' }, { value: 'all', label: 'All stages' },
{ value: 'ai_screened', label: 'AI Screened' }, { value: 'interview', label: 'Interviewing' }, { value: 'applied', label: 'Applied' }, { value: 'ai_screened', label: 'AI Screened' },
{ value: 'hired', label: 'Hired' }, { value: 'rejected', label: 'Declined' }, { 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={[ <FilterSelect value={band} onChange={setBand} label="Score" options={[
{ value: 'all', label: 'All scores' }, { value: 'top', label: '80+ Top' }, { value: 'all', label: 'All scores' }, { value: 'top', label: '80+ Top' },
@@ -55,10 +64,18 @@ function CandidatesToolbar() {
function CandidatesList() { function CandidatesList() {
const { const {
isLoading, filtered, isFiltered, clearFilters, resolveTitle, isLoading, filtered, isFiltered, clearFilters, resolveTitle, stage, interviews = [],
handleAction, setMessageApp, setScheduleApp, handleDecline, handleDelete, handleHire, handleAction, setMessageApp, setScheduleApp, handleDecline, handleDelete, handleHire,
} = useUiContext(); } = 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) { if (isLoading) {
return ( return (
<div className="space-y-3"> <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) { if (filtered.length === 0) {
const emptyQueue = stage === 'final' && !isFiltered;
return ( return (
<div className="text-center py-16"> <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 && ( {isFiltered && (
<Button size="xs" variant="outline" className="mt-3" onClick={clearFilters}> <Button size="xs" variant="outline" className="mt-3" onClick={clearFilters}>
Clear filters Clear filters
@@ -84,6 +113,13 @@ function CandidatesList() {
return ( return (
<div className="space-y-4"> <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) => ( {filtered.map((app, idx) => (
<CandidateCard <CandidateCard
key={app.id} key={app.id}

View File

@@ -90,30 +90,46 @@ function HiredSummary() {
} }
/* 3. The chronology. */ /* 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 = {} }) { function HiredChronology({ attrs = {} }) {
const { recent, filtered } = useUiContext(); const { recent, filtered } = useUiContext();
if (!recent.length) return null;
return ( return (
<section aria-labelledby="chronology" className="space-y-3" {...attrs}> <section aria-labelledby="chronology" className="space-y-3" {...attrs}>
<SectionTitle <SectionTitle
id="chronology" id="chronology"
title="Recent hiring timeline" 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"> <Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<Timeline {recent.length ? (
items={recent.map((h, i) => ({ <Timeline
id: h.id, items={recent.map((h, i) => ({
title: h.name, id: h.id,
description: [h.role, h.company !== '—' ? h.company : null] title: h.name,
.filter(Boolean).join(' · '), description: [h.role, h.company !== '—' ? h.company : null]
timestamp: formatDate(h.hire_date), .filter(Boolean).join(' · '),
tone: i === 0 ? 'brand' : 'neutral', timestamp: formatDate(h.hire_date),
current: i === 0, tone: i === 0 ? 'brand' : 'neutral',
icon: UserCheck, current: i === 0,
}))} icon: UserCheck,
/> }))}
/>
) : (
<EmptyState
title="No hires yet"
description="The timeline fills in as candidates are hired for a position."
/>
)}
</Surface> </Surface>
</section> </section>
); );

View File

@@ -1,9 +1,13 @@
import React from 'react'; import React, { useState } from 'react';
import { Bookmark, Star, UserRound, Trophy, CheckCircle2, TrendingUp, Sparkles } from 'lucide-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 { cn } from '@/lib/utils';
import { import {
Avatar, Badge, DataTable, IconButton, ProgressRing, SearchInput, toast, Avatar, Badge, DataTable, IconButton, ProgressRing, SearchInput,
} from '@/components/ds'; } 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 { getScoreBand, toFICO } from '@/lib/talentHome';
import { SectionTitle, Toolbar } from '@/components/admin/PageShell'; import { SectionTitle, Toolbar } from '@/components/admin/PageShell';
import { FilterSelect } from '@/pages/admin/Positions'; import { FilterSelect } from '@/pages/admin/Positions';
@@ -167,6 +171,12 @@ function TalentSegments({ attrs = {} }) {
/** The directory: its heading, its filters and the ranked table. */ /** The directory: its heading, its filters and the ranked table. */
function TalentDirectory({ attrs = {} }) { 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 { const {
profiles, filtered, isLoading, isFiltered, clearFilters, saved, toggleSave, profiles, filtered, isLoading, isFiltered, clearFilters, saved, toggleSave,
search, setSearch, skill, setSkill, experience, setExperience, location, setLocation, search, setSearch, skill, setSkill, experience, setExperience, location, setLocation,
@@ -213,6 +223,10 @@ function TalentDirectory({ attrs = {} }) {
pageSize={15} pageSize={15}
isFiltered={isFiltered} isFiltered={isFiltered}
onClearFilters={clearFilters} 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" caption="Talent pool ranked by career score"
columns={[ columns={[
{ {
@@ -284,18 +298,32 @@ function TalentDirectory({ attrs = {} }) {
size="sm" size="sm"
onClick={() => toggleSave(p.id, p.full_name)} 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 <IconButton
icon={UserRound} icon={UserRound}
label={`View ${p.full_name}'s profile`} label={`View ${p.full_name}'s profile`}
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => toast.info(`Opening ${p.full_name}'s KROW Identity`)} onClick={() => navigate(`/admin/talent/${p.id}`)}
/> />
</div> </div>
), ),
}, },
]} ]}
/> />
<AddToPositionModal
open={Boolean(considering)}
onOpenChange={(next) => { if (!next) setConsidering(null); }}
profile={considering}
existing={applicationsForProfile(applications, considering)}
/>
</div> </div>
); );
} }