/* ============================================================ 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 /** + -> 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) => (
{iv.candidate}
{iv.jobTitle || '—'}
), }, { key: 'type', label: 'Round', sortable: true, render: (iv) => {iv.type} }, { key: 'when', label: 'Date & Time', sortable: true, sortValue: (iv) => (iv.when ? iv.when.getTime() : 0), render: (iv) => ( <>
{iv.when ? fmtShort(iv.when) : '—'}
{clock(iv.when)}
), }, { key: 'status', label: 'Status', sortable: true, render: (iv) => {iv.status}, }, { key: '_a', label: 'Actions', align: 'right', render: (iv) => (
{iv.status === 'Scheduled' && ( <> )}
), }, ] const listError = listQuery.isError return (
Calendar View } />

All Interviews

setQ(e.target.value)} placeholder="Search candidate or role…" />
{listQuery.isPending && (
)} {listError && (
{friendlyAuthError(listQuery.error, 'The server did not return interviews.')} {' '}This screen needs the interviews.view permission.
)} {!listQuery.isPending && !listError && ( )}

Up Next

Scheduled sessions
{upcoming.length === 0 ? (

{allQuery.isPending ? 'Loading…' : 'Nothing scheduled ahead.'}

) : ( upcoming.map((iv) => (
{iv.candidate}
{iv.type}{iv.jobTitle ? ` · ${iv.jobTitle}` : ''}
{iv.when ? fmtShort(iv.when) : '—'}
{clock(iv.when)}
)) )}
{feedbackFor && ( setFeedbackFor(null)} onSaved={() => { setFeedbackFor(null); invalidate() }} toast={toast} /> )} {scheduling && ( setScheduling(false)} onSubmit={(body) => create.mutate(body)} toast={toast} /> )}
) } function CriteriaList({ criteria, ratings, setRating }) { return criteria.map((c) => (

{c}

setRating(c, v)} />
)) } /** * 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 ( } >
{iv.candidate}
{iv.type}{iv.jobTitle ? ` · ${iv.jobTitle}` : ''}
{iv.status}
{tab === 'form' && (
{templatesQuery.isPending && ( Fetching scorecard templates. )} {templatesQuery.isError && ( {friendlyAuthError(templatesQuery.error, 'Request failed')} )} {templatesQuery.isSuccess && templates.length === 0 && ( Create one from Settings before scoring an interview. )} {template && ( <>