diff --git a/scripts/__baseline__/README.md b/scripts/__baseline__/README.md index 0f261e0..28ddb66 100644 --- a/scripts/__baseline__/README.md +++ b/scripts/__baseline__/README.md @@ -5,6 +5,60 @@ agent layer existed. `skill-check.mjs` asserts against it on every run. Regenerating it is a deliberate act, and the reason belongs here. +## OUTSTANDING — the HTML baselines contain data that no longer exists + +**Six checks fail on purpose. Do not regenerate these baselines to clear them.** + + Hired History paints the same styled elements, in the same order + Hired History shows the same words + Hired History added only identity wrappers + analytics: paints the same styled elements, in the same order + analytics: shows the same words + candidates: shows the same words + +The first five are the `DEMO_FILL` removal, described immediately below. The +sixth is the final-selection queue and has its own dated entry further down. + +`hiringRecords.js` used to pad the hires list with five invented people +(`DEMO_FILL`) so Hired History read as a history rather than as three rows. The +padding applied to Analytics too, so "total hires" counted eight where the +database held three. It has been removed: hires now come only from `staff`. + +`hired-history.pre-migration.html` and `analytics.pre-migration.html` were +captured **while the padding was in effect** — and because these baselines render +with queries disabled, the padding is *all* they contain. The Hired History +baseline is 31,453 characters holding all five invented names; the genuine empty +state is 11,947 and holds none. + +**Verified before leaving them failing**, so the drift is known rather than +assumed. Tag counts, baseline → now: + + 0 five table rows of people who were never hired + 13 +
39 +

7 + 8 + +Every difference is content that was fabricated. No styling, ordering or +structural rule changed. + +**Why they are not regenerated yet.** Two of them — `added only identity +wrappers` and `carries node identity in the DOM` — exist to prove the UI node +tree migration added exactly two `

`s and nothing else. That proof needs the +baseline to be *pre*-migration markup. Recapturing now would write *post*- +migration markup into a file named `pre-migration`, and the check would compare +the current render against itself and pass forever without proving anything. + +So the debt is held until the migration work lands, at which point both files +are recaptured together and this section is replaced by a dated entry saying so. + +**One related fix was made rather than deferred.** `HiredChronology` used to +`return null` with no hires, which took the `chronology` node identity out of the +DOM with it — so with an honest empty dataset the section could not be addressed +by Owliver or the layout editor at all, and the page said nothing where it most +needed to. It now renders an empty state inside the section, which the section +keeps. `carries node identity in the DOM` passes again because of it. + ## 2026-08-27 — the seed gained the three statuses nothing exercised `application_status` has seven values. The fixture produced four: `applied`, @@ -63,3 +117,47 @@ with it. It now names the page's topics in a sentence. Every intent `kind` was unchanged. No routing moved, no skill matching changed. That is why this regeneration was safe: the diff was read first, and it was one cosmetic change on a path that only runs when no agent is configured at all. + +## 2026-09-11 — Candidates became the final-selection queue + +`candidates: shows the same words` now fails, and it is the only check this +change breaks. + +The page used to list every application the org had ever taken — all seven +statuses at once — which made it a second Talent Pool rather than the queue of +hiring decisions waiting on a human. It now opens on final selection: interview +completed, not hired, not rejected. That is a change to what the page *says*, so +a check asserting the page says exactly what it said before was always going to +fail. There is no version of this work that leaves those words alone. + +**What drifted, verified before leaving it failing.** The rendered delta is the +empty state and nothing else: + + before "…0 of 0 candidates No candidates match your filters" + now "…0 of 0 candidates Nobody is awaiting a decision + Candidates arrive here once their interview is completed, + and leave once they are hired or declined." + +Title, subtitle and toolbar meta are byte-identical. The old copy was not merely +different, it was untrue: with no filters applied there is nothing to clear, and +"no candidates match your filters" describes a filter that was never set. + +Tag and class counts, baseline → now: + + class="…" 26 -> 27 one inserted: the description paragraph + +One insertion, nothing changed and nothing dropped — which is why +`candidates: paints the same styled elements, in the same order` and +`candidates: carries node identity in the DOM` both still pass. Those two are +the migration proof; only the words moved. + +The new stage filter options cost nothing here. `FilterSelect` is a Radix +`Select`, so its options live in a portal that is closed in static markup, and +`SelectValue` renders empty on the server — the baseline contains neither the +old option labels nor the new ones. + +**Why it is not regenerated.** The same reason as the five above: +`candidates.pre-migration.html` is load-bearing for the UI node tree proof, and +recapturing it now would write post-migration markup into a file named +`pre-migration`. It is held until the migration work lands and all of these are +recaptured together. diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs index 53eef2a..dae668a 100644 --- a/scripts/skill-check.mjs +++ b/scripts/skill-check.mjs @@ -5959,6 +5959,354 @@ record('an assigned candidate counts as hired', records.HIRED_STATUSES.includes('assigned') && records.rankOf('assigned') >= records.STAGE_ORDER.indexOf('hired')); +/* ── Final selection, and seats ────────────────────────────────────────── + * + * Final selection is DERIVED — there is no `final_selection` value in the + * `application_status` enum and none is added. The fact it reads is the + * existence of an interview row, which is a NOT NULL foreign key, rather than + * `job_applications.interview_id`, which the schema keeps as an unconstrained + * soft reference precisely so it may dangle. + * + * That distinction is the whole point: `status = 'interview'` is set both when + * an interview is arranged and when one is completed, so status alone cannot + * tell a queue of people who have been interviewed from a queue of people who + * have merely been booked in. + */ +const fsApps = [ + { id: 'a-applied', status: 'applied' }, + { id: 'a-booked', status: 'interview' }, // arranged, never sat + { id: 'a-done', status: 'interview' }, // sat it + { id: 'a-hired', status: 'hired' }, + { id: 'a-assigned', status: 'assigned' }, + { id: 'a-rejected', status: 'rejected' }, +]; +const fsInterviews = [ + { id: 'i1', application_id: 'a-done' }, + { id: 'i2', application_id: 'a-hired' }, + { id: 'i3', application_id: 'a-rejected' }, +]; +const queue = records.finalSelection(fsApps, fsInterviews).map((a) => a.id); + +record('final selection holds only the interviewed and undecided', + queue.length === 1 && queue[0] === 'a-done', queue.join(', ') || 'empty'); +record('...so an interview merely arranged does not qualify', + !records.isFinalSelection(fsApps[1], fsInterviews), + 'status interview with no interview row'); +record('...and a decided application never reappears in the queue', + ['a-hired', 'a-assigned', 'a-rejected'] + .every((id) => !queue.includes(id)), + 'hired, assigned and rejected all excluded'); +record('...while the interview record, not interview_id, is what is read', + !records.isFinalSelection({ id: 'x', status: 'interview', interview_id: 'dangling' }, []), + 'a soft reference alone proves nothing'); + +/* Seats are counted from the employment records. A `filled` column would be a + second source of truth, and nothing could say which one was right. */ +const seatPosting = { id: 'p1', headcount: 2 }; +const seatStaff = [ + { id: 's1', job_posting_id: 'p1', status: 'active' }, + { id: 's2', job_posting_id: 'p1', status: 'onboarding' }, + { id: 's3', job_posting_id: 'p1', status: 'inactive' }, // left; seat reopens + { id: 's4', job_posting_id: 'p2', status: 'active' }, // another position +]; +record('seats filled are counted from the staff records', + records.filledFor('p1', seatStaff) === 2, `${records.filledFor('p1', seatStaff)} of 2`); +record('...someone who has left frees their seat', + records.remainingFor({ id: 'p1', headcount: 3 }, seatStaff) === 1, + 'inactive staff do not hold a seat'); +record('...a full position reports no remaining seats, never a negative number', + records.isFullyStaffed(seatPosting, seatStaff) + && records.remainingFor({ id: 'p1', headcount: 1 }, seatStaff) === 0, + 'over-hiring floors at zero'); + +/* ── The Candidates page IS the final-selection queue ───────────────────── + * + * The derivation above is only worth having if the page that decides hires + * actually opens on it. Candidates used to list every application the org had + * ever taken — all seven statuses at once — which made it a second Talent Pool + * rather than a queue of decisions waiting. + * + * Two rules these assertions hold in place, both of them safety rules rather + * than presentation ones: + * + * The evidence is the interview ROW. `interview_id` is a soft reference the + * schema deliberately leaves unconstrained, and it dangles in real data. + * + * The score never gates. `ai_score`, `verdict` and `hire_recommendation` + * assist a recruiter; a human makes the selection. A candidate who scored + * zero and sat the interview is still a decision somebody owes them. + */ +record('a candidate who scored zero still reaches the queue', + records.isFinalSelection({ id: 'a-zero', status: 'interview', ai_score: 0 }, + [{ id: 'i9', application_id: 'a-zero' }]), + 'screening is not a gate on the decision'); + +{ + /* Comments are stripped first: these assert what the page DOES, and a + comment explaining the bug that was fixed must not read as the bug. */ + const decomment = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + const candidatesSrc = decomment(readFileSync(join(ROOT, 'src/pages/admin/Candidates.jsx'), 'utf8')); + const nodesSrc = decomment(readFileSync(join(ROOT, 'src/pages/admin/candidates/nodes.jsx'), 'utf8')); + /* Read from source rather than imported: the page pulls in the interview + modal, which touches `window` at module scope, and standing up a DOM to + read one string constant would be a worse test than reading the string. */ + const defaultStage = candidatesSrc.match(/export const DEFAULT_STAGE = '([a-z_]+)'/)?.[1]; + + record('the candidate page opens on the final-selection queue', + defaultStage === 'final' && /useState\(DEFAULT_STAGE\)/.test(candidatesSrc), + String(defaultStage)); + + /* The interview rows were fetched and then thrown away — `useInterviews();` + on its own line, its result unbound — which is why the queue could not be + derived on the one page that needed it. */ + record('the candidate page keeps the interview rows it fetches', + /(const|let)\s*\{[^}]*\}\s*=\s*useInterviews\(\)/.test(candidatesSrc), + 'useInterviews() is bound, not discarded'); + + /* The filter compared `a.stage`. Applications carry `status`; no application + has ever had a `stage` property, so every stage selection emptied the + page. */ + record('the stage filter reads a field applications actually carry', + !/\ba\.stage\b/.test(candidatesSrc) && /matchesStage\(/.test(candidatesSrc), + 'status, not an undefined stage'); + + /* Narrowing the default view is only safe if nothing becomes unreachable. + `shortlisted` and `assigned` were absent from these options, so the rows + holding them could not be found by any selection. */ + const offered = [...nodesSrc.matchAll(/\{ value: '([a-z_]+)', label: '[^']+' \}/g)] + .map((m) => m[1]); + const unreachable = records.APPLICATION_STATUSES.filter((st) => !offered.includes(st)); + record('every application status is still reachable from the stage filter', + unreachable.length === 0, unreachable.join(', ') || 'all seven offered'); + + record('...and the queue itself is offered alongside them', + offered.includes('final') && offered.includes('all'), + 'final selection and all stages'); +} + +/* ── Human interviews leave a trace, and only completion counts ─────────── + * + * A human interview used to persist nothing at all: the modal collected a + * type, a date, a time and notes, announced "Interview scheduled!", and dropped + * every one of them. So the final-selection queue could only ever hold people + * an AI had interviewed, and anyone seen by a person was invisible to it. + * + * What changed is where each fact lives, and the split is the safety property: + * + * booking → a `user_activity` row. Advances nobody. + * completion→ an `ai_interviews` row. The evidence, unchanged since Step 6. + * + * `isFinalSelection` is not touched by any of this and is not expected to be. + * These assertions exist to prove that a booking cannot masquerade as an + * interview, which is the one way this work could put a candidate in front of a + * hiring decision that nobody has actually met. + */ +const human = await server.ssrLoadModule('/src/lib/humanInterviews.js'); + +{ + const app = { id: 'a-human', status: 'interview', job_posting_id: 'p1', applicant_name: 'A' }; + const at = (n) => new Date(Date.UTC(2026, 0, n)).toISOString(); + + const bookedEvent = { + event_type: human.SCHEDULED, application_id: 'a-human', created_date: at(1), + metadata: { interview_type: 'video', scheduled_date: '2026-01-09', scheduled_time: '10:00', notes: 'prep' }, + }; + const completedRow = { id: 'i-human', application_id: 'a-human' }; + + /* Booked, and nothing else. */ + record('a booked human interview is recorded', + human.humanInterviewState(app, [bookedEvent], []) === 'scheduled'); + record('...and the booking keeps what was entered', + (() => { + const d = human.scheduleDetails(human.scheduledInterview(app, [bookedEvent])); + return d.type === 'video' && d.date === '2026-01-09' && d.time === '10:00' && d.notes === 'prep'; + })(), 'type, date, time and notes survive'); + record('...but booking alone is NOT final selection', + !records.isFinalSelection(app, []), + 'no interview row, no decision to make'); + + /* Held. */ + record('a completed human interview is recorded', + human.humanInterviewState(app, [bookedEvent], [completedRow]) === 'completed'); + record('...and a completed human interview DOES reach final selection', + records.isFinalSelection(app, [completedRow])); + record('...even with no score, because a human interview measures none', + records.isFinalSelection({ ...app, ai_score: 0 }, [completedRow]), + 'absence of an AI score is not absence of an interview'); + + /* Not held. Both outcomes are events, and neither is an interview. */ + for (const [label, type] of [['cancelled', human.CANCELLED], ['not attended', human.NO_SHOW]]) { + const events = [bookedEvent, { event_type: type, application_id: 'a-human', created_date: at(2) }]; + record(`an interview ${label} leaves no completion record`, + human.humanInterviewState(app, events, []) === (type === human.CANCELLED ? 'cancelled' : 'no_show')); + record(`...so ${label} never reaches final selection`, + !records.isFinalSelection(app, [])); + } + + /* A decided application is out regardless of how it was interviewed. */ + for (const status of ['hired', 'assigned', 'rejected']) { + record(`a ${status} application leaves the queue even with a human interview`, + !records.isFinalSelection({ ...app, status }, [completedRow])); + } + + /* The Step 6 guard, restated against a human booking: a booking may well set + interview_id one day, and it must still prove nothing on its own. */ + record('a dangling interview_id still does not qualify a human interview', + !records.isFinalSelection({ ...app, interview_id: 'nope' }, []), + 'the row is the evidence, not the reference'); + + /* An event claiming completion with no row behind it. The log is not the + record, and treating it as one would reintroduce exactly the soft-reference + problem interview_id already demonstrates. */ + record('a completion EVENT without an interview row is not completion', + human.humanInterviewState(app, + [{ event_type: human.COMPLETED, application_id: 'a-human', created_date: at(3) }], []) === 'none', + 'user_activity is a log, not the evidence'); +} + +/* ── Nothing about AI is invented for a human interview ─────────────────── + * + * The completed-interview record lives in `ai_interviews`, which is currently + * reused as the durable completion record for both flows. Reusing the table is + * a deliberate decision; filling in its AI columns with plausible numbers no + * machine produced would not be. A fabricated score or verdict would be read by + * analytics, by the candidate profile and by a recruiter as a measurement. + * + * `overall_interview_score` matters twice over: the server copies it onto the + * application only when the request mentions it, so sending a 0 would overwrite + * a real screening score with one this interview never took. + */ +{ + const hooksSrc = readFileSync(join(ROOT, 'src/lib/krowHooks.js'), 'utf8'); + const complete = hooksSrc.slice( + hooksSrc.indexOf('export function useCompleteHumanInterview'), + hooksSrc.indexOf('export function useRecordInterviewNotHeld') + ); + const schedule = hooksSrc.slice( + hooksSrc.indexOf('export function useScheduleHumanInterview'), + hooksSrc.indexOf('export function useCompleteHumanInterview') + ); + + record('the human completion path exists at all', complete.length > 0 && schedule.length > 0); + + const fabricated = ['overall_interview_score', 'integrity_score', 'ai_flags', 'category_scores', + 'hire_recommendation', 'messages', 'ai_score'] + .filter((f) => new RegExp(`^\\s*${f}\\s*:`, 'm').test(complete)); + record('a human interview fabricates no AI data', + fabricated.length === 0, fabricated.join(', ') || 'no AI column is written'); + + record('...and the interviewer\'s own assessment is only sent when given', + /if \(assessment\) record\.verdict = assessment;/.test(complete), + 'verdict is human-entered or left to the schema'); + + record('...while the facts it does write are the ones it knows', + /application_id: application\.id/.test(complete) + && /job_posting_id: application\.job_posting_id/.test(complete)); + + /* The rule the whole step turns on. */ + record('scheduling never writes an interview record', + !/AIInterview|useCreateInterview|createInterview/.test(schedule), + 'a booking touches user_activity only'); + + record('...and completion goes through the existing interview client', + /createInterview\.mutateAsync/.test(complete), + 'no second interview API'); + + /* Not held writes an event and nothing else — no row, and no invented + application status to go with it. */ + const notHeld = hooksSrc.slice(hooksSrc.indexOf('export function useRecordInterviewNotHeld')); + record('a cancelled or unattended interview writes no record and no new status', + !/AIInterview|createInterview/.test(notHeld) && !/JobApplication\.update/.test(notHeld), + 'an event, and nothing else'); +} + +/* The AI flow is untouched: it still writes its own record at completion, and + still does it through the same client this now shares. */ +{ + const aiSrc = readFileSync(join(ROOT, 'src/components/krow/AIInterviewModal.jsx'), 'utf8'); + record('the AI interview still records itself on completion', + /const finishInterview = useCallback/.test(aiSrc) + && /createInterview\.mutateAsync\(\{/.test(aiSrc) + && /messages: finalMessages/.test(aiSrc), + 'unchanged by the human path'); +} + +/* The modal is no longer theatre. */ +{ + const modalSrc = readFileSync(join(ROOT, 'src/components/krow/ScheduleInterviewModal.jsx'), 'utf8'); + record('scheduling an interview persists it', + /useScheduleHumanInterview/.test(modalSrc) && /schedule\.mutateAsync/.test(modalSrc)); + record('...the props its six call sites pass are unchanged', + /\{ open, onClose, application, position \}/.test(modalSrc), + 'open, onClose, application — position optional'); + record('...and a failed save is reported rather than celebrated', + /setError\(/.test(modalSrc) && !/setScheduled\(true\)/.test(modalSrc), + 'the old version always claimed success'); +} + +/* Every path that files an application on somebody's behalf must carry the + link back to the talent-pool record. The column is nullable, so omitting it + saves cleanly and fails silently: the application belongs to an email address + instead of to a person, and the hire it becomes cannot be traced back to the + profile it came from. Both call sites are asserted because they were written + at different times and only one of them is on the position page. */ +const positionDetailSource = readFileSync(join(ROOT, 'src/pages/PositionDetail.jsx'), 'utf8'); +const hooksSource = readFileSync(join(ROOT, 'src/lib/krowHooks.js'), 'utf8'); +record('admitting talent to a position links the application to the profile', + /worker_profile_id: profile\.id/.test(positionDetailSource), + 'PositionDetail.admitTalent'); +record('...and so does assigning a worker straight from the pool', + /worker_profile_id: worker\.profile\?\.id/.test(hooksSource), + 'useAssignWorkers filing an application'); + +/* ── One person, many positions ────────────────────────────────────────── + * + * The two profile routes answer different questions and must stay separate. + * `/admin/talent/:id` is the PERSON — a `worker_profiles` row, and every + * position they are up for. `/admin/candidates/:id` is that person FOR ONE + * POSITION — a `job_applications` row, with its score, interview and decision. + * + * One screen doing both is how a product ends up with a candidate record per + * vacancy, which is exactly what `worker_profiles` exists to prevent. + */ +record('the person and the application have separate routes', + routed.has('/admin/talent/:id') && routed.has('/admin/candidates/:id'), + [...routed].filter((r) => r.includes('talent') || r.includes('candidates')).join(', ')); + +/* Which applications are this person's. The foreign key is the answer; email is + the fallback for rows written before the key was being set, and it has to be + there or every application already in the database detaches from its person. */ +const whoProfile = { id: 'wp-1', email: 'Maria@Example.com' }; +const whoApps = [ + { id: 'by-key', worker_profile_id: 'wp-1', email: 'nothing@else.com' }, + { id: 'by-email', worker_profile_id: null, email: 'maria@example.com' }, + { id: 'by-email-case', email: 'MARIA@EXAMPLE.COM' }, + { id: 'somebody-else', worker_profile_id: 'wp-2', email: 'other@example.com' }, +]; +const whoIds = records.applicationsForProfile(whoApps, whoProfile).map((a) => a.id); +record('an application finds its person by foreign key', + whoIds.includes('by-key'), 'worker_profile_id'); +record('...and falls back to email, case-insensitively, for older rows', + whoIds.includes('by-email') && whoIds.includes('by-email-case'), + 'citext identity, as worker_profiles already asserts'); +record('...without collecting somebody else', + !whoIds.includes('somebody-else') && whoIds.length === 3, whoIds.join(', ')); +record('...and asks for nobody when there is no profile', + records.applicationsForProfile(whoApps, null).length === 0, 'empty, not everything'); + +/* The reported symptom: a talent pool you could read and not use. */ +const talentPoolSource = readFileSync(join(ROOT, 'src/pages/admin/talent-pool/nodes.jsx'), 'utf8'); +record('a talent pool row opens that person', + /onRowClick=\{\(p\) => navigate\(`\/admin\/talent\/\$\{p\.id\}`\)\}/.test(talentPoolSource), + 'DataTable wires the handler only when it is given one'); +record('...and no action pretends to open something it does not', + !/toast\.info\(`Opening/.test(talentPoolSource), + 'the "view profile" toast is gone'); +record('...and a person can be put in front of a vacancy from the pool', + /setConsidering\(p\)/.test(talentPoolSource) + && /AddToPositionModal/.test(talentPoolSource), + 'the step that was missing between pool and position'); + /* "Screened and waiting on a decision" includes the shortlisted. This counted `ai_screened` alone, which read as correct only while nothing was ever shortlisted — the first shortlisted candidate dropped out of the count in diff --git a/src/App.jsx b/src/App.jsx index 98cba13..fef4ad2 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -38,6 +38,7 @@ import AdminCandidateProfile from '@/pages/admin/CandidateProfile'; import AdminCandidatesAnalysis from '@/pages/admin/CandidatesAnalysis'; import AdminHiredHistory from '@/pages/admin/HiredHistory'; import AdminTalentPool from '@/pages/admin/TalentPool'; +import AdminTalentProfile from '@/pages/admin/TalentProfile'; import AdminAnalytics from '@/pages/admin/Analytics'; import AdminActivity from '@/pages/admin/Activity'; import AdminProfile from '@/pages/admin/Profile'; @@ -107,6 +108,12 @@ const AuthenticatedApp = () => { } /> } /> } /> + {/* The person, keyed by worker profile — distinct from + `candidates/:id`, which is one person FOR ONE POSITION. Two + routes because they answer different questions; one screen + would push the product back towards a candidate record per + vacancy, which is the thing `worker_profiles` exists to stop. */} + } /> } /> } /> } /> diff --git a/src/components/ai-assistant/KrowAssistant.jsx b/src/components/ai-assistant/KrowAssistant.jsx index eba1960..e54f137 100644 --- a/src/components/ai-assistant/KrowAssistant.jsx +++ b/src/components/ai-assistant/KrowAssistant.jsx @@ -1080,9 +1080,6 @@ export default function KrowAssistant({ > {composer} -

- Owliver reads this page's data. Check anything you act on. -

); diff --git a/src/components/krow/AddToPositionModal.jsx b/src/components/krow/AddToPositionModal.jsx new file mode 100644 index 0000000..0af75c0 --- /dev/null +++ b/src/components/krow/AddToPositionModal.jsx @@ -0,0 +1,144 @@ +import React, { useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Badge, Button, EmptyState, Modal, StatusBadge, toast } from '@/components/ds'; +import { useCreateApplication, useJobPostings } from '@/lib/krowHooks'; +import { remainingFor } from '@/lib/hiringRecords'; + +/** + * Put a person from the talent pool in front of a vacancy. + * + * This is the step the product was missing. The talent pool held people, the + * positions held vacancies, and nothing in the interface joined the two — so a + * candidate could only enter a pipeline from the position's own suggestion + * panel, and the Candidates page filled up with applications nobody could trace + * back to a person. + * + * What it writes is one `job_applications` row, which is the join: + * `worker_profile_id` names the person, `job_posting_id` names the vacancy. The + * master record is never copied — the same profile can hold an application + * against every position it is considered for. + * + * Filing the same person against the same position twice is refused by the + * database (`UNIQUE (job_posting_id, email)`), so positions they are already up + * for are shown as such rather than offered and then rejected. + */ +export default function AddToPositionModal({ open, onOpenChange, profile, existing = [], staff = [] }) { + const navigate = useNavigate(); + const { data: postings = [], isLoading } = useJobPostings(); + const createApplication = useCreateApplication(); + const [busyId, setBusyId] = useState(null); + + /* A vacancy you can still be considered for: open, and not one this person is + already in the running for. Drafts and closed roles are not offers. */ + const open_ = useMemo( + () => postings.filter((p) => p.status === 'active' || p.status === 'paused'), + [postings] + ); + + const appliedTo = useMemo(() => { + const byPosting = new Map(); + for (const a of existing) byPosting.set(a.job_posting_id, a); + return byPosting; + }, [existing]); + + const add = async (posting) => { + if (!profile) return; + setBusyId(posting.id); + try { + const application = await createApplication.mutateAsync({ + /* The link back to the person. Without it the row saves anyway — the + column is nullable — and the hire it may become cannot be traced to + the profile it came from. */ + worker_profile_id: profile.id, + applicant_name: profile.full_name, + email: profile.email || '', + phone: profile.phone || '', + years_experience: profile.experience_years || 0, + skills: profile.skills || [], + availability: profile.availability || [], + certifications: profile.certifications || [], + companies_worked: (profile.experience || []).map((e) => e.company).filter(Boolean), + selfie_url: profile.selfie_url || '', + professional_summary: profile.career_goals || '', + cover_letter: '', + job_posting_id: posting.id, + job_title: posting.title, + status: 'applied', + ai_score: profile.krow_score || 0, + }); + toast.success(`${profile.full_name} is now up for ${posting.title}`); + onOpenChange?.(false); + if (application?.id) navigate(`/admin/candidates/${application.id}`); + } catch (error) { + /* The duplicate guard lives in the database, so this is also the path a + race takes — two operators adding the same person at once. Saying what + happened beats a generic failure. */ + const message = String(error?.message || ''); + toast.error(/conflict|exists|duplicate/i.test(message) + ? `${profile.full_name} is already up for ${posting.title}` + : message || 'That position could not be opened for this candidate'); + } finally { + setBusyId(null); + } + }; + + return ( + + {isLoading &&
} + + {!isLoading && open_.length === 0 && ( + { onOpenChange?.(false); navigate('/admin/positions'); }}>Go to Positions} + /> + )} + + {!isLoading && open_.length > 0 && ( +
    + {open_.map((p) => { + const already = appliedTo.get(p.id); + const remaining = staff.length ? remainingFor(p, staff) : null; + return ( +
  • +
    +

    {p.title}

    +

    + {[p.company, p.location].filter(Boolean).join(' · ') || '—'} + {remaining !== null && ` · ${remaining} of ${p.headcount ?? 1} open`} +

    +
    + + {already ? ( +
    + Already considered + +
    + ) : ( + + )} +
  • + ); + })} +
+ )} + + ); +} diff --git a/src/components/krow/ScheduleInterviewModal.jsx b/src/components/krow/ScheduleInterviewModal.jsx index 1340670..9a64120 100644 --- a/src/components/krow/ScheduleInterviewModal.jsx +++ b/src/components/krow/ScheduleInterviewModal.jsx @@ -1,50 +1,284 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; -import { Calendar, Clock, Video, Phone, User } from 'lucide-react'; +import { Calendar, CalendarX, Check, Clock, Phone, User, UserX, Video } from 'lucide-react'; +import { + useCompleteHumanInterview, useInterviews, useRecordInterviewNotHeld, + useScheduleHumanInterview, useUserActivity, +} from '@/lib/krowHooks'; +import { + CANCELLED, NO_SHOW, humanInterviewState, scheduleDetails, scheduledInterview, +} from '@/lib/humanInterviews'; + +/** + * Booking a human interview, and recording what came of it. + * + * This modal used to be a decoration. It collected a type, a date, a time and + * some notes, said "Interview scheduled!", and threw all four away — no + * request, no record, and nobody notified despite the message saying so. A + * candidate interviewed by a person therefore left no trace anywhere, which is + * why the final-selection queue could only ever contain AI interviews. + * + * It now covers the whole of a human interview's life, in the order it happens: + * + * nothing booked → the booking form, as before + * booked → what happened: held, not attended, or called off + * held → nothing left to do; the record exists + * + * THE ONE RULE THIS FILE EXISTS TO KEEP + * + * Booking an interview and completing one are different writes with different + * consequences. The booking is a `user_activity` row and advances nobody. Only + * completion writes the `ai_interviews` record that `isFinalSelection` reads, + * and completion is an explicit act by whoever ran the interview. So a + * candidate cannot reach a hiring decision by being put in a calendar. + * + * The props are unchanged — `open`, `onClose`, `application` — because six + * places mount this: admin Candidates (directly and through its node tree), + * Candidate Profile, Position Detail, the older Candidates page, and + * CandidateCard's own fallback. `position` is optional and only sharpens the + * activity record when a caller has it. + */ + +const TYPES = [ + { value: 'video', label: 'Video', icon: Video }, + { value: 'phone', label: 'Phone', icon: Phone }, + { value: 'inperson', label: 'In Person', icon: User }, +]; + +const TYPE_LABEL = Object.fromEntries(TYPES.map((t) => [t.value, t.label])); + +/* The interviewer's own reading, in the three words the column already holds. + Recorded because a person chose it, and never acted on automatically — the + hire control is elsewhere and stays a deliberate click. */ +const ASSESSMENTS = [ + { value: 'hire', label: 'Would hire' }, + { value: 'maybe', label: 'Unsure' }, + { value: 'no', label: 'Would not hire' }, +]; + +const field = 'w-full mt-1.5 min-h-[60px] p-2.5 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none'; + +export default function ScheduleInterviewModal({ open, onClose, application, position }) { + const { data: activity = [] } = useUserActivity(); + const { data: interviews = [] } = useInterviews(); + + const schedule = useScheduleHumanInterview(); + const complete = useCompleteHumanInterview(); + const notHeld = useRecordInterviewNotHeld(); -export default function ScheduleInterviewModal({ open, onClose, application }) { const [type, setType] = useState('video'); const [date, setDate] = useState(''); const [time, setTime] = useState(''); const [notes, setNotes] = useState(''); - const [scheduled, setScheduled] = useState(false); - const handleSchedule = () => { - setScheduled(true); - setTimeout(() => { - setScheduled(false); - onClose(); - }, 1500); + const [recording, setRecording] = useState(null); // 'completed' | NO_SHOW | CANCELLED + const [assessment, setAssessment] = useState(''); + const [outcomeNotes, setOutcomeNotes] = useState(''); + + const [done, setDone] = useState(''); + const [error, setError] = useState(''); + + const state = useMemo( + () => humanInterviewState(application, activity, interviews), + [application, activity, interviews] + ); + const booking = useMemo( + () => scheduledInterview(application, activity), + [application, activity] + ); + const booked = booking ? scheduleDetails(booking) : null; + + const busy = schedule.isPending || complete.isPending || notHeld.isPending; + + const finish = (message) => { + setDone(message); + setTimeout(() => { setDone(''); onClose(); }, 1500); }; + const run = async (work, message) => { + setError(''); + try { + await work(); + finish(message); + } catch (e) { + /* The booking is a real write now, so a failure is a real failure and + says so, rather than the old unconditional "Interview scheduled!". */ + setError(e?.message || 'That could not be saved. Please try again.'); + } + }; + + const submitSchedule = () => run( + () => schedule.mutateAsync({ application, position, type, date, time, notes }), + 'Interview scheduled' + ); + + const submitOutcome = () => { + if (recording === 'completed') { + return run( + () => complete.mutateAsync({ application, position, assessment, notes: outcomeNotes }), + 'Interview recorded' + ); + } + return run( + () => notHeld.mutateAsync({ application, position, outcome: recording, notes: outcomeNotes }), + recording === NO_SHOW ? 'Marked as not attended' : 'Interview cancelled' + ); + }; + + const title = state === 'scheduled' ? 'Interview' : 'Schedule Interview'; + return ( !v && onClose()}> - Schedule Interview + {title} - {scheduled ? ( + {done ? (
- +
-

Interview scheduled!

-

{application?.applicant_name} will be notified.

+

{done}

+

{application?.applicant_name}

+
+ ) : state === 'completed' ? ( + /* The interview record exists, so there is nothing to book and + nothing to record. This is also the state that puts the candidate + in the final-selection queue. */ +
+
+ +
+

Interview already recorded

+

+ {application?.applicant_name} is awaiting a hiring decision. +

+ + + +
+ ) : state === 'scheduled' && !recording ? ( +
+
+

+ {TYPE_LABEL[booked?.type] || 'Video'} interview booked +

+

+ {booked?.date} at {booked?.time} +

+ {booked?.notes && ( +

{booked.notes}

+ )} +
+ +
+

What happened?

+

+ Only a completed interview puts this candidate up for a hiring decision. +

+
+ + + +
+
+ + {error &&

{error}

} + + + + +
+ ) : recording ? ( +
+ {recording === 'completed' ? ( +
+ +

+ Your own reading, entered by you. It is recorded alongside the interview + and never decides anything — hiring stays a separate, deliberate action. +

+
+ {ASSESSMENTS.map((a) => ( + + ))} +
+
+ ) : ( +

+ {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.'} +

+ )} + +
+ +