feat: add collaborative interview scheduling

Add candidate decisions, co-interviewer accounts, scheduled interview inbox and calendar views, candidate-safe meeting links, shared documents, and private live notes backed by RabbitMQ.

Extend the disposable integration environment to validate PostgreSQL migrations, seeded users, interview lifecycle behavior, and RabbitMQ fanout.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-09-19 21:58:24 +02:00
parent fd06fbbb5d
commit 75e3c73a61
35 changed files with 3621 additions and 58 deletions

View file

@ -2,6 +2,7 @@ import {
ArrowLeft,
ArrowRight,
BriefcaseBusiness,
CalendarDays,
Check,
ChevronRight,
CircleHelp,
@ -15,6 +16,7 @@ import {
Plus,
Search,
Send,
Inbox,
Sparkles,
Trash2,
Upload,
@ -42,6 +44,14 @@ import {
useParams,
} from 'react-router-dom'
import { api, ApiError, authStorage } from './api'
import {
CalendarPage,
InboxPage,
ParticipantMeetingPage,
ScheduleInterviewForm,
ScheduledInterviewPage,
UsersPage,
} from './interview-workflow'
import type {
CandidateMatch,
CreateJobInput,
@ -54,6 +64,7 @@ import type {
Participant,
Question,
QuestionType,
ScheduledInterview,
} from './types'
interface AppData {
@ -276,6 +287,24 @@ function AppLayout({ onLogout }: { onLogout: () => void }) {
icon={<CircleHelp size={20} />}
close={() => setOpen(false)}
/>
<NavItem
to="/inbox"
label="Inbox"
icon={<Inbox size={20} />}
close={() => setOpen(false)}
/>
<NavItem
to="/calendar"
label="Calendar"
icon={<CalendarDays size={20} />}
close={() => setOpen(false)}
/>
<NavItem
to="/users"
label="Users"
icon={<UserRound size={20} />}
close={() => setOpen(false)}
/>
</nav>
<div className="sidebar-bottom">
<div className="user-chip">
@ -301,6 +330,10 @@ function AppLayout({ onLogout }: { onLogout: () => void }) {
<Route path="/participants" element={<ParticipantsPage />} />
<Route path="/participants/new" element={<CreateParticipantPage />} />
<Route path="/participants/:participantId" element={<ParticipantDetailPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/inbox" element={<InboxPage />} />
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/scheduled-interviews/:id" element={<ScheduledInterviewPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
@ -458,6 +491,7 @@ function JobDetailPage() {
const [job, setJob] = useState<Job>()
const [matches, setMatches] = useState<CandidateMatch[]>([])
const [invitations, setInvitations] = useState<Invitation[]>([])
const [scheduledInterviews, setScheduledInterviews] = useState<ScheduledInterview[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [tab, setTab] = useState<'overview' | 'questions' | 'candidates' | 'invitations'>(
@ -468,14 +502,16 @@ function JobDetailPage() {
setLoading(true)
setError('')
try {
const [jobValue, candidates, invitationItems] = await Promise.all([
const [jobValue, candidates, invitationItems, scheduledItems] = await Promise.all([
api.jobs.get(id),
api.jobs.candidates(id),
api.jobs.invitations(id),
api.jobs.interviews(id),
])
setJob(jobValue)
setMatches(candidates)
setInvitations(invitationItems)
setScheduledInterviews(scheduledItems)
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
@ -484,12 +520,18 @@ function JobDetailPage() {
}
useEffect(() => {
let active = true
void Promise.all([api.jobs.get(id), api.jobs.candidates(id), api.jobs.invitations(id)])
.then(([jobValue, candidates, invitationItems]) => {
void Promise.all([
api.jobs.get(id),
api.jobs.candidates(id),
api.jobs.invitations(id),
api.jobs.interviews(id),
])
.then(([jobValue, candidates, invitationItems, scheduledItems]) => {
if (!active) return
setJob(jobValue)
setMatches(candidates)
setInvitations(invitationItems)
setScheduledInterviews(scheduledItems)
})
.catch((requestError: unknown) => {
if (active) setError(errorMessage(requestError))
@ -563,7 +605,14 @@ function JobDetailPage() {
job={job}
matches={matches}
invitations={invitations}
scheduledInterviews={scheduledInterviews}
onInvited={(invitation) => setInvitations((current) => [invitation, ...current])}
onInvitationUpdated={(invitation) =>
setInvitations((current) =>
current.map((item) => (item.id === invitation.id ? invitation : item)),
)
}
onScheduled={(interview) => setScheduledInterviews((current) => [interview, ...current])}
/>
)}
</>
@ -752,17 +801,24 @@ function InvitationsView({
job,
matches,
invitations,
scheduledInterviews,
onInvited,
onInvitationUpdated,
onScheduled,
}: {
job: Job
matches: CandidateMatch[]
invitations: Invitation[]
scheduledInterviews: ScheduledInterview[]
onInvited: (invitation: Invitation) => void
onInvitationUpdated: (invitation: Invitation) => void
onScheduled: (interview: ScheduledInterview) => void
}) {
const [sendingId, setSendingId] = useState<number>()
const [review, setReview] = useState<Interview>()
const [reviewingId, setReviewingId] = useState<number>()
const [error, setError] = useState('')
const [scheduling, setScheduling] = useState<Invitation>()
const invitedIds = new Set(invitations.map((invitation) => invitation.participantId))
const eligible = matches.filter((match) => !invitedIds.has(match.participant.id))
@ -863,7 +919,62 @@ function InvitationsView({
<EmptyState title="No invitations" description="Invite a candidate to this interview." />
)}
</div>
{review && <InvitationAnswerReview interview={review} onClose={() => setReview(undefined)} />}
{review && (
<InvitationAnswerReview
interview={review}
onClose={() => setReview(undefined)}
onOutcome={(invitation) => {
onInvitationUpdated(invitation)
setReview((current) => current && { ...current, invitation })
}}
/>
)}
{scheduling && (
<ScheduleInterviewForm
jobId={job.id}
invitation={scheduling}
onCancel={() => setScheduling(undefined)}
onCreated={(interview) => {
onScheduled(interview)
setScheduling(undefined)
}}
/>
)}
<div className="scheduled-list">
{invitations
.filter((invitation) => invitation.outcome === 'passed')
.filter(
(invitation) =>
!scheduledInterviews.some(
(interview) => interview.participantId === invitation.participantId,
),
)
.map((invitation) => (
<button
className="button primary"
type="button"
key={invitation.id}
onClick={() => setScheduling(invitation)}
>
<CalendarDays size={17} /> Schedule {invitation.participantName}
</button>
))}
{scheduledInterviews.map((interview) => (
<Link
className="scheduled-summary"
to={`/scheduled-interviews/${interview.id}`}
key={interview.id}
>
<span>
<strong>{interview.participantName}</strong>
<small>
{new Date(interview.startsAt).toLocaleString()} · {interview.location}
</small>
</span>
<StatusBadge value={interview.status} />
</Link>
))}
</div>
</section>
)
}
@ -871,10 +982,32 @@ function InvitationsView({
function InvitationAnswerReview({
interview,
onClose,
onOutcome,
}: {
interview: Interview
onClose: () => void
onOutcome: (invitation: Invitation) => void
}) {
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
async function decide(outcome: 'passed' | 'failed') {
setSaving(true)
setError('')
try {
onOutcome(
await api.jobs.setInvitationOutcome(
interview.invitation.jobId,
interview.invitation.id,
outcome,
),
)
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
setSaving(false)
}
}
return (
<section className="answer-review" aria-labelledby="answer-review-title">
<div className="section-heading">
@ -883,6 +1016,32 @@ function InvitationAnswerReview({
<h2 id="answer-review-title">{interview.invitation.participantName}</h2>
<p>{interview.invitation.participantEmail}</p>
</div>
{interview.invitation.status === 'completed' && (
<div className="outcome-actions" aria-label="Candidate outcome">
<div>
<strong>Assessment decision</strong>
<p>Record whether this candidate should proceed to a scheduled interview.</p>
</div>
<button
className="button pass"
type="button"
disabled={saving}
onClick={() => void decide('passed')}
>
<Check size={18} /> Pass candidate
</button>
<button
className="button fail"
type="button"
disabled={saving}
onClick={() => void decide('failed')}
>
<X size={18} /> Fail candidate
</button>
{interview.invitation.outcome && <StatusBadge value={interview.invitation.outcome} />}
</div>
)}
{error && <ErrorAlert message={error} />}
<div className="review-heading-actions">
<StatusBadge value={interview.invitation.status} />
<button className="button ghost" type="button" onClick={onClose}>
@ -2247,6 +2406,7 @@ export default function App() {
if (location.pathname.startsWith('/participant/')) {
return (
<Routes>
<Route path="/participant/meeting/:token" element={<ParticipantMeetingPage />} />
<Route path="/participant/:invitationId" element={<ParticipantInterviewPage />} />
<Route path="*" element={<NotFound />} />
</Routes>