HR-ATS-Portal/frontend/src/screens/Interviews.jsx

666 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* ============================================================
Interviews — live on GET /interview/fetch (range mode).
Scheduling writes POST /interview/create, the row actions write
PATCH /interview/update, and the scorecard writes POST /feedback/create
against the same application. Templates come from /feedback/templates/fetch.
FOUR COLUMNS THE PROTOTYPE HAD ARE GONE. `serialize_interview` returns seven
fields and the `interviews` table has no more columns than that, so meeting
mode, duration, the interviewer list and the feedback verdict have no source.
They are dropped rather than rendered as permanent em-dashes — the rule Jobs,
Candidates and Inbox already follow. Job title is not on the interview row
either; it is hydrated from the pipeline application the interview hangs off.
An interview is scoped to an APPLICATION (inbox.id), which is why the
candidate picker reads the pipeline board rather than the candidate list:
that payload is the only one carrying inbox_id, the person and the role
together. Manual-upload candidates have no inbox row and therefore cannot be
scheduled here at all — the picker says so instead of silently omitting them.
============================================================ */
import { useEffect, useMemo, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader'
import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, SkeletonRows, Stars } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as interviewsApi from '../api/interviews'
import * as candidatesApi from '../api/candidates'
import * as feedbackApi from '../api/feedback'
import { INTERVIEW_STATUSES, INTERVIEW_TYPES } from '../api/interviews'
import { byInboxId, useApplications } from '../lib/useApplications'
import { fmtShort, fmtTime } from '../lib/format'
import { avatarColor, initials as initialsOf } from '../data/seed'
const FETCH_TOP = 200
/** <input type="date"> + <input type="time"> -> one ISO instant, or null. */
function toInstant(date, time) {
if (!date) return null
const d = new Date(`${date}T${time || '09:00'}`)
return Number.isNaN(d.getTime()) ? null : d.toISOString()
}
function clock(d) {
return fmtTime(d) || '—'
}
function sameDay(a, b) {
return Boolean(a && b) && a.toDateString() === b.toDateString()
}
export default function Interviews() {
const { toast } = useToast()
const navigate = useNavigate()
const location = useLocation()
const qc = useQueryClient()
const [q, setQ] = useState('')
const [status, setStatus] = useState('')
const [type, setType] = useState('')
const [feedbackFor, setFeedbackFor] = useState(null)
const [scheduling, setScheduling] = useState(false)
useEffect(() => {
if (location.state?.openSchedule) setScheduling(true)
}, [location.state])
/* Status is filtered SERVER-side (the range branch takes it), round is not —
there is no type param on the route, so that select stays client-side. */
const listQuery = useQuery({
queryKey: qk.interviews.range({ top: FETCH_TOP, status: status || null }),
queryFn: async () => {
const res = await interviewsApi.listRange({ top: FETCH_TOP, status: status || undefined })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(interviewsApi.toInterviewView)
},
})
/* The KPI strip must count the WHOLE table, not the filtered page, so it
reads the unfiltered set. With no status filter this resolves to the same
query key as the list above and React Query serves both from one request. */
const allQuery = useQuery({
queryKey: qk.interviews.range({ top: FETCH_TOP, status: null }),
queryFn: async () => {
const res = await interviewsApi.listRange({ top: FETCH_TOP })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(interviewsApi.toInterviewView)
},
})
const appsQuery = useApplications()
const appByInbox = useMemo(() => byInboxId(appsQuery.data), [appsQuery.data])
const hydrate = useMemo(
() => (iv) => {
const app = appByInbox.get(iv.inboxId)
return {
...iv,
jobTitle: iv.jobTitle || app?.jobTitle || null,
userId: iv.userId ?? app?.userId ?? null,
}
},
[appByInbox],
)
const interviews = useMemo(
() => (listQuery.data ?? []).map(hydrate),
[listQuery.data, hydrate],
)
const all = useMemo(() => allQuery.data ?? [], [allQuery.data])
const stats = useMemo(() => {
const today = new Date()
return {
scheduled: all.filter((i) => i.status === 'Scheduled').length,
completed: all.filter((i) => i.status === 'Completed').length,
today: all.filter((i) => sameDay(i.when, today)).length,
cancelled: all.filter((i) => ['Cancelled', 'No Show'].includes(i.status)).length,
}
}, [all])
const rows = useMemo(
() =>
interviews.filter((iv) => {
if (type && iv.type !== type) return false
if (q) {
const hay = `${iv.candidate} ${iv.jobTitle ?? ''} ${iv.type}`.toLowerCase()
if (!hay.includes(q.toLowerCase())) return false
}
return true
}),
[interviews, q, type],
)
const upcoming = useMemo(() => {
const now = Date.now()
return all
.map(hydrate)
.filter((iv) => iv.status === 'Scheduled' && iv.when && iv.when.getTime() >= now)
.sort((a, b) => a.when - b.when)
.slice(0, 4)
}, [all, hydrate])
const invalidate = () => {
qc.invalidateQueries({ queryKey: qk.interviews.all() })
}
const setStatusMutation = useMutation({
mutationFn: async ({ id, next }) => {
const res = await interviewsApi.update(id, { status: next })
if (next === 'Cancelled') {
try {
await interviewsApi.cancelCalendarEvent(id)
} catch (err) {
toast(
friendlyAuthError(err, 'Interview cancelled, but the Outlook invite could not be cancelled.'),
'warning',
)
}
}
return res
},
onSuccess: (_res, { next }) => {
invalidate()
toast(`Interview marked ${next.toLowerCase()}`, 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not update the interview.'), 'error'),
})
const create = useMutation({
mutationFn: async (body) => {
const res = await interviewsApi.create(body)
const interviewId = res?.data?.id
if (interviewId) {
try {
await interviewsApi.createCalendarEvent(interviewId)
} catch (err) {
toast(
friendlyAuthError(err, 'Interview saved, but the Outlook invite could not be created.'),
'warning',
)
}
}
return res
},
onSuccess: () => {
invalidate()
setScheduling(false)
toast('Interview scheduled', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not schedule the interview.'), 'error'),
})
const columns = [
{
key: 'candidate', label: 'Candidate', sortable: true,
render: (iv) => (
<div className="user-cell">
<Avatar name={iv.candidate} initials={initialsOf(iv.candidate)} color={avatarColor(iv.candidate)} />
<div>
<div className="cell-primary">{iv.candidate}</div>
<div className="cell-sub">{iv.jobTitle || '—'}</div>
</div>
</div>
),
},
{ key: 'type', label: 'Round', sortable: true, render: (iv) => <Badge className="b-indigo">{iv.type}</Badge> },
{
key: 'when', label: 'Date & Time', sortable: true,
sortValue: (iv) => (iv.when ? iv.when.getTime() : 0),
render: (iv) => (
<>
<div className="text-sm fw-600">{iv.when ? fmtShort(iv.when) : '—'}</div>
<div className="cell-sub">{clock(iv.when)}</div>
</>
),
},
{
key: 'status', label: 'Status', sortable: true,
render: (iv) => <Badge>{iv.status}</Badge>,
},
{
key: '_a', label: 'Actions', align: 'right',
render: (iv) => (
<div className="row-actions">
<button
className="act-btn" data-tip="View candidate" aria-label="View candidate"
disabled={!iv.userId}
onClick={() => navigate('/candidates', { state: { openCandidate: iv.userId } })}
>
<Icon name="eye" />
</button>
{iv.status === 'Scheduled' && (
<>
<button
className="act-btn" data-tip="Mark completed" aria-label="Mark completed"
disabled={setStatusMutation.isPending}
onClick={() => setStatusMutation.mutate({ id: iv.id, next: 'Completed' })}
>
<Icon name="check" />
</button>
<button
className="act-btn" data-tip="Cancel interview" aria-label="Cancel interview"
disabled={setStatusMutation.isPending}
onClick={() => setStatusMutation.mutate({ id: iv.id, next: 'Cancelled' })}
>
<Icon name="x-circle" />
</button>
</>
)}
<button className="act-btn" data-tip="Scorecard" aria-label="Scorecard" onClick={() => setFeedbackFor(iv)}>
<Icon name="star" />
</button>
</div>
),
},
]
const listError = listQuery.isError
return (
<div className="page">
<PageHeader
title="Interviews"
sub="Manage and track all interview activity"
actions={<>
<Link className="btn btn-secondary" to="/calendar"><Icon name="calendar" /> Calendar View</Link>
<button className="btn btn-primary" onClick={() => setScheduling(true)}>
<Icon name="plus" /> Schedule Interview
</button>
</>}
/>
<div className="grid g-kpi mb-18">
<KpiCard label="Scheduled" value={allQuery.isPending ? '—' : stats.scheduled} icon="calendar" tone="i-blue" />
<KpiCard label="Completed" value={allQuery.isPending ? '—' : stats.completed} icon="check-circle" tone="i-green" />
<KpiCard label="Today" value={allQuery.isPending ? '—' : stats.today} icon="clock" tone="i-purple" />
<KpiCard label="Cancelled / No-show" value={allQuery.isPending ? '—' : stats.cancelled} icon="x-circle" tone="i-red" />
</div>
<div className="grid g-2-1">
<div className="card">
<div className="card-head"><div><h3>All Interviews</h3></div></div>
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search candidate or role…" />
</div>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All Status</option>
{INTERVIEW_STATUSES.map((s) => <option key={s}>{s}</option>)}
</select>
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
<option value="">All Rounds</option>
{INTERVIEW_TYPES.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
</div>
{listQuery.isPending && (
<div className="card-body">
<SkeletonRows rows={6} />
</div>
)}
{listError && (
<div className="card-body">
<EmptyState icon="alert" title="Couldnt load interviews">
{friendlyAuthError(listQuery.error, 'The server did not return interviews.')}
{' '}This screen needs the <code>interviews.view</code> permission.
</EmptyState>
</div>
)}
{!listQuery.isPending && !listError && (
<DataTable
columns={columns}
rows={rows}
pageSize={50}
empty="No interviews match these filters."
/>
)}
</div>
<div className="card" style={{ alignSelf: 'start' }}>
<div className="card-head"><div><h3>Up Next</h3><span className="ch-sub">Scheduled sessions</span></div></div>
<div className="card-body">
<div className="list-tight">
{upcoming.length === 0 ? (
<p className="text-muted">
{allQuery.isPending ? 'Loading…' : 'Nothing scheduled ahead.'}
</p>
) : (
upcoming.map((iv) => (
<div className="list-row" key={iv.id}>
<Avatar name={iv.candidate} initials={initialsOf(iv.candidate)} color={avatarColor(iv.candidate)} />
<div className="lr-main">
<div className="lr-title">{iv.candidate}</div>
<div className="lr-sub">{iv.type}{iv.jobTitle ? ` · ${iv.jobTitle}` : ''}</div>
</div>
<div className="lr-right">
<div className="fw-600 text-sm">{iv.when ? fmtShort(iv.when) : '—'}</div>
<div className="lr-sub">{clock(iv.when)}</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
{feedbackFor && (
<Scorecard
interview={feedbackFor}
onClose={() => setFeedbackFor(null)}
onSaved={() => { setFeedbackFor(null); invalidate() }}
toast={toast}
/>
)}
{scheduling && (
<ScheduleForm
applications={appsQuery.data ?? []}
loading={appsQuery.isPending}
busy={create.isPending}
onClose={() => setScheduling(false)}
onSubmit={(body) => create.mutate(body)}
toast={toast}
/>
)}
</div>
)
}
function CriteriaList({ criteria, ratings, setRating }) {
return criteria.map((c) => (
<div className="setting-row" style={{ padding: '12px 0' }} key={c}>
<div className="setting-info"><h4>{c}</h4></div>
<Stars value={ratings[c] ?? 0} onChange={(v) => setRating(c, v)} />
</div>
))
}
/**
* The scorecard now PERSISTS. It writes one `feedback` row against the
* interview's application: `review` carries the overall recommendation,
* `score` the mean of the criteria stars, and `note` the comments plus the
* per-criterion breakdown — the feedback table has no structured criteria
* column, so folding them into the note is the only way they survive the write
* at all. `reviewed_by` is omitted on purpose: the server stamps the caller.
*
* The Upload Sheet tab is now an explicit "not stored" state. There is no
* attachment endpoint for feedback, and a dropzone that accepts a file and
* discards it is worse than saying so.
*/
function Scorecard({ interview: iv, onClose, onSaved, toast }) {
const templatesQuery = useQuery({
queryKey: qk.feedbackTemplates.list(),
queryFn: async () => {
const res = await feedbackApi.listTemplates()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(feedbackApi.toTemplateView)
},
})
const templates = templatesQuery.data ?? []
const [tab, setTab] = useState('form')
const [templateName, setTemplateName] = useState('')
const [ratings, setRatings] = useState({})
const [comments, setComments] = useState('')
const [recommendation, setRecommendation] = useState('Hire')
const initial = templates[0] ?? null
useEffect(() => {
if (initial?.name && !templateName) setTemplateName(initial.name)
}, [initial, templateName])
const template = templates.find((t) => t.name === templateName) ?? initial
const setRating = (crit, val) => setRatings((r) => ({ ...r, [crit]: val }))
const save = useMutation({
mutationFn: (body) => candidatesApi.createFeedback(body),
onSuccess: () => {
toast('Scorecard submitted', 'success')
onSaved()
},
onError: (err) => toast(friendlyAuthError(err, 'Could not submit the scorecard.'), 'error'),
})
function submit() {
if (iv.inboxId == null) {
toast('This interview is not linked to an application, so feedback cannot be stored', 'warning')
return
}
const scored = Object.entries(ratings).filter(([, v]) => v > 0)
if (!scored.length) {
toast('Rate at least one criterion', 'warning')
return
}
const mean = scored.reduce((s, [, v]) => s + v, 0) / scored.length
const breakdown = scored.map(([k, v]) => `${k}: ${v}/5`).join(' · ')
const note = [comments.trim(), breakdown].filter(Boolean).join('\n\n')
save.mutate({
inboxId: iv.inboxId,
review: recommendation,
score: Number(mean.toFixed(2)),
note,
})
}
return (
<Modal
title="Interview Evaluation"
subtitle={`${iv.candidate} · ${iv.type}`}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={submit} disabled={!template || save.isPending}>
<Icon name="check" /> {save.isPending ? 'Submitting…' : 'Submit Scorecard'}
</button>
</>
}
>
<div className="flex items-center gap-12 mb-18">
<Avatar
name={iv.candidate}
initials={initialsOf(iv.candidate)}
color={avatarColor(iv.candidate)}
className="avatar-lg"
/>
<div className="flex-1">
<div className="ph-name" style={{ fontSize: 17 }}>{iv.candidate}</div>
<div className="ph-role">{iv.type}{iv.jobTitle ? ` · ${iv.jobTitle}` : ''}</div>
</div>
<Badge>{iv.status}</Badge>
</div>
<div className="mb-18">
<Tabs
value={tab}
onChange={setTab}
tabs={[
{ key: 'form', label: 'Dynamic Form' },
{ key: 'upload', label: 'Upload Sheet' },
]}
/>
</div>
{tab === 'form' && (
<div className="tab-pane active">
{templatesQuery.isPending && (
<EmptyState icon="file" title="Loading templates…">Fetching scorecard templates.</EmptyState>
)}
{templatesQuery.isError && (
<EmptyState icon="alert" title="Couldnt load templates">
{friendlyAuthError(templatesQuery.error, 'Request failed')}
</EmptyState>
)}
{templatesQuery.isSuccess && templates.length === 0 && (
<EmptyState icon="file" title="No evaluation templates">
Create one from Settings before scoring an interview.
</EmptyState>
)}
{template && (
<>
<div className="form-field mb-8">
<label>Evaluation Template</label>
<select value={templateName} onChange={(e) => setTemplateName(e.target.value)}>
{templates.map((t) => <option key={t.id}>{t.name}</option>)}
</select>
</div>
<CriteriaList criteria={template.criteria} ratings={ratings} setRating={setRating} />
<div className="form-field mt-8">
<label>Comments</label>
<textarea
placeholder="Strengths, concerns, and areas explored…"
value={comments}
onChange={(e) => setComments(e.target.value)}
/>
</div>
<div className="form-field mt-12">
<label>Overall Recommendation</label>
<div className="seg" style={{ marginTop: 4 }}>
{['Strong Hire', 'Hire', 'Lean Hire', 'No Hire'].map((r) => (
<button
type="button" key={r}
className={r === recommendation ? 'active' : ''}
onClick={() => setRecommendation(r)}
>
{r}
</button>
))}
</div>
</div>
</>
)}
</div>
)}
{tab === 'upload' && (
<div className="tab-pane active">
<EmptyState icon="upload" title="Attachments are not stored yet">
The feedback record has no document column and there is no upload route, so a signed
sheet would be accepted and then lost. Capture the ratings on the form tab instead.
</EmptyState>
</div>
)}
</Modal>
)
}
function ScheduleForm({ applications, loading, busy, onClose, onSubmit, toast }) {
const [form, setForm] = useState({
inboxId: '',
type: INTERVIEW_TYPES[0],
date: '',
time: '14:00',
status: 'Scheduled',
})
const [errors, setErrors] = useState({})
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
const inboxId = form.inboxId || (applications[0] ? String(applications[0].inboxId) : '')
function submit() {
if (busy) return
const next = {}
if (!inboxId) next.inboxId = 'Pick a candidate'
if (!form.date) next.date = 'Date is required'
setErrors(next)
if (Object.keys(next).length) return
const instant = toInstant(form.date, form.time)
if (!instant) {
toast('That date and time could not be read', 'warning')
return
}
onSubmit({
inboxId: Number(inboxId),
instant,
type: form.type,
status: form.status,
})
}
return (
<Modal
title="Schedule Interview"
subtitle="Set up a new interview session"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
<button className="btn btn-primary" onClick={submit} disabled={busy || !applications.length}>
<Icon name="calendar" /> {busy ? 'Scheduling…' : 'Schedule'}
</button>
</>
}
>
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="form-grid">
<div className="form-field col-span-2">
<label>Candidate <span className="req">*</span></label>
<select
value={inboxId}
className={errors.inboxId ? 'err' : ''}
onChange={(e) => set('inboxId', e.target.value)}
disabled={loading || !applications.length}
>
{loading && <option value="">Loading applications</option>}
{!loading && !applications.length && <option value="">No assigned applications</option>}
{applications.map((a) => (
<option key={a.inboxId} value={a.inboxId}>
{a.name}{a.jobTitle ? `${a.jobTitle}` : ''}
</option>
))}
</select>
<FieldError>{errors.inboxId}</FieldError>
</div>
<div className="form-field">
<label>Interview Round</label>
<select value={form.type} onChange={(e) => set('type', e.target.value)}>
{INTERVIEW_TYPES.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
<div className="form-field">
<label>Status</label>
<select value={form.status} onChange={(e) => set('status', e.target.value)}>
{INTERVIEW_STATUSES.map((s) => <option key={s}>{s}</option>)}
</select>
</div>
<div className="form-field">
<label>Date <span className="req">*</span></label>
<input
type="date"
className={errors.date ? 'err' : ''}
value={form.date}
onChange={(e) => set('date', e.target.value)}
/>
<FieldError>{errors.date}</FieldError>
</div>
<div className="form-field">
<label>Time</label>
<input type="time" value={form.time} onChange={(e) => set('time', e.target.value)} />
</div>
</div>
<p className="text-muted text-sm mt-16">
Interviews attach to an application, so only candidates with an assigned job post appear here.
Duration, meeting mode and interviewers are not stored by the interview record.
</p>
</form>
</Modal>
)
}