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:
@@ -1080,9 +1080,6 @@ export default function KrowAssistant({
|
||||
>
|
||||
<SuggestedQuestions prompts={prompts} open={showSuggestions} onSelect={runPrompt} />
|
||||
{composer}
|
||||
<p className="px-1 text-[10px] leading-tight text-ink-4">
|
||||
Owliver reads this page's data. Check anything you act on.
|
||||
</p>
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
|
||||
144
src/components/krow/AddToPositionModal.jsx
Normal file
144
src/components/krow/AddToPositionModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule Interview</DialogTitle>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{scheduled ? (
|
||||
{done ? (
|
||||
<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">
|
||||
<Calendar className="w-6 h-6 text-[#059669]" />
|
||||
<Check className="w-6 h-6 text-[#059669]" />
|
||||
</div>
|
||||
<p className="text-[14px] font-medium text-[#111827]">Interview scheduled!</p>
|
||||
<p className="text-[12px] text-[#6B7280]">{application?.applicant_name} will be notified.</p>
|
||||
<p className="text-[14px] font-medium text-[#111827]">{done}</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'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 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>
|
||||
<Label className="text-[13px] text-[#374151]">Interview Type</Label>
|
||||
<div className="grid grid-cols-3 gap-2 mt-1.5">
|
||||
{[
|
||||
{ value: 'video', label: 'Video', icon: Video },
|
||||
{ value: 'phone', label: 'Phone', icon: Phone },
|
||||
{ value: 'inperson', label: 'In Person', icon: User },
|
||||
].map((opt) => (
|
||||
{TYPES.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setType(opt.value)}
|
||||
@@ -76,13 +310,22 @@ export default function ScheduleInterviewModal({ open, onClose, application }) {
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add any prep notes..."
|
||||
className="w-full mt-1.5 min-h-[60px] p-2.5 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none"
|
||||
className={field}
|
||||
/>
|
||||
</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>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSchedule} disabled={!date || !time} className="bg-[#0838E0] hover:bg-[#062BAF]">
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||
<Button onClick={submitSchedule} disabled={!date || !time || busy} className="bg-[#0838E0] hover:bg-[#062BAF]">
|
||||
<Clock className="w-4 h-4 mr-2" /> Schedule
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -91,4 +334,4 @@ export default function ScheduleInterviewModal({ open, onClose, application }) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user