666 lines
24 KiB
JavaScript
666 lines
24 KiB
JavaScript
/* ============================================================
|
||
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="Couldn’t 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="Couldn’t 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>
|
||
)
|
||
}
|