/* ============================================================ 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) => (
interviews.view permission.
{allQuery.isPending ? 'Loading…' : 'Nothing scheduled ahead.'}
) : ( upcoming.map((iv) => (