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

254 lines
10 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.

/* ============================================================
Calendar — live on GET /interview/fetch, scoped to the visible month.
The month grid is the prototype's, unchanged. What moved is the data source
and the window: `from_date`/`to_date` are sent for the month on screen, so
paging back a year is one small request rather than a filter over everything
ever scheduled. Each month is its own query key, so revisiting a month you
already looked at repaints from cache.
"Today" is real time now. The prototype pinned TODAY to 2026-07-09 so its
generated dates stayed stable; with live rows that pin would highlight the
wrong cell and show an empty agenda every day of the year.
============================================================ */
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import PageHeader from '../ui/PageHeader'
import { Avatar, EmptyState, Icon } from '../ui/primitives'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as interviewsApi from '../api/interviews'
import { byInboxId, useApplications } from '../lib/useApplications'
import { avatarColor, initials as initialsOf } from '../data/seed'
/* Round -> event colour. Unknown rounds fall through to blue rather than
vanishing; `interview_type` is free text, so an unrecognised value is normal. */
const EVENT_COLORS = {
'Phone Screen': 'b-blue', Technical: 'b-indigo', 'System Design': 'b-purple',
'Onsite Loop': 'b-teal', 'Hiring Manager': 'b-amber', 'Culture Fit': 'b-green',
'Final Round': 'b-red',
}
const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
/** The prototype's hand-built month grid, unchanged in behaviour. */
function buildCells(year, month) {
const startDow = new Date(year, month, 1).getDay()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const prevDays = new Date(year, month, 0).getDate()
const cells = []
for (let i = startDow - 1; i >= 0; i--) cells.push({ day: prevDays - i, other: true })
for (let d = 1; d <= daysInMonth; d++) cells.push({ day: d, other: false, date: new Date(year, month, d) })
while (cells.length % 7 !== 0 || cells.length < 42) {
cells.push({ day: cells.length - daysInMonth - startDow + 1, other: true })
}
return cells.slice(0, 42)
}
export default function Calendar() {
const navigate = useNavigate()
const today = useMemo(() => new Date(), [])
const [{ year, month }, setView] = useState({ year: today.getFullYear(), month: today.getMonth() })
/* Half-open [from, to): the route filters `interview_date >= from` and
`< to`, so passing the 1st of the next month includes the whole month
without an off-by-one on the last day. */
const from = useMemo(() => new Date(year, month, 1), [year, month])
const to = useMemo(() => new Date(year, month + 1, 1), [year, month])
const monthQuery = useQuery({
queryKey: qk.interviews.range({ month: `${year}-${String(month + 1).padStart(2, '0')}` }),
queryFn: async () => {
const res = await interviewsApi.listRange({
fromDate: from.toISOString(),
toDate: to.toISOString(),
top: 500,
})
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(interviewsApi.toInterviewView)
},
})
/* Job title and the candidate's user id are not on the interview row; the
application supplies both. One extra request for the whole screen. */
const appsQuery = useApplications()
const appByInbox = useMemo(() => byInboxId(appsQuery.data), [appsQuery.data])
const events = useMemo(
() => (monthQuery.data ?? [])
.filter((iv) => iv.when)
.map((iv) => {
const app = appByInbox.get(iv.inboxId)
return {
...iv,
jobTitle: iv.jobTitle || app?.jobTitle || null,
userId: app?.userId ?? null,
}
}),
[monthQuery.data, appByInbox],
)
/* One pass into a day bucket, so the 42 cells below are lookups rather than
42 filters over the month. */
const byDay = useMemo(() => {
const map = new Map()
for (const e of events) {
const key = e.when.toDateString()
if (!map.has(key)) map.set(key, [])
map.get(key).push(e)
}
for (const list of map.values()) list.sort((a, b) => a.when - b.when)
return map
}, [events])
const cells = useMemo(() => buildCells(year, month), [year, month])
const monthName = new Date(year, month).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
const todayKey = today.toDateString()
const todayIvs = byDay.get(todayKey) ?? []
const step = (delta) =>
setView(({ year: y, month: m }) => {
const next = m + delta
if (next < 0) return { year: y - 1, month: 11 }
if (next > 11) return { year: y + 1, month: 0 }
return { year: y, month: next }
})
const openCandidate = (userId) => {
if (!userId) return
navigate('/candidates', { state: { openCandidate: userId } })
}
return (
<div className="page">
<PageHeader
title="Calendar"
sub={`Interview schedule at a glance${monthQuery.isSuccess ? ` · ${events.length} this month` : ''}`}
actions={<>
<div className="flex items-center gap-8">
<button className="btn btn-icon btn-secondary" onClick={() => step(-1)} aria-label="Previous month">
<Icon name="chevron-left" />
</button>
<span className="fw-600" style={{ minWidth: 140, textAlign: 'center' }}>{monthName}</span>
<button className="btn btn-icon btn-secondary" onClick={() => step(1)} aria-label="Next month">
<Icon name="chevron-right" />
</button>
</div>
<button
className="btn btn-secondary"
onClick={() => setView({ year: today.getFullYear(), month: today.getMonth() })}
>
Today
</button>
<button
className="btn btn-primary"
onClick={() => navigate('/interviews', { state: { openSchedule: true } })}
>
<Icon name="plus" /> Schedule
</button>
</>}
/>
{monthQuery.isError ? (
<div className="card">
<div className="card-body">
<EmptyState icon="alert" title="Couldnt load the calendar">
{friendlyAuthError(monthQuery.error, 'The server did not return interviews.')}
{' '}This screen needs the <code>candidates.view</code> permission.
</EmptyState>
</div>
</div>
) : (
<div className="grid g-2-1">
<div className="card">
<div className="card-body">
<div className="cal-grid">
{DOW.map((d) => <div className="cal-dow" key={d}>{d}</div>)}
{cells.map((c, i) => {
const dayEvents = !c.other && c.date ? (byDay.get(c.date.toDateString()) ?? []) : []
const isToday = !c.other && c.date && c.date.toDateString() === todayKey
return (
<div className={`cal-cell ${c.other ? 'other' : ''} ${isToday ? 'today' : ''}`} key={i}>
<div className="cal-date">{c.day}</div>
{dayEvents.slice(0, 3).map((iv) => (
<div
key={iv.id}
className={`cal-event ${EVENT_COLORS[iv.type] || 'b-blue'}`}
title={`${iv.candidate} · ${iv.type}${iv.jobTitle ? ` · ${iv.jobTitle}` : ''}`}
onClick={() => openCandidate(iv.userId)}
>
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric' })} {iv.candidate.split(' ')[0]}
</div>
))}
{dayEvents.length > 3 && (
<div className="cal-event b-gray">+{dayEvents.length - 3} more</div>
)}
</div>
)
})}
</div>
</div>
</div>
<div className="card" style={{ alignSelf: 'start' }}>
<div className="card-head">
<div>
<h3>Today</h3>
<span className="ch-sub">
{today.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
</span>
</div>
</div>
<div className="card-body">
<div className="list-tight">
{monthQuery.isPending ? (
<p className="text-muted">Loading</p>
) : todayIvs.length === 0 ? (
<p className="text-muted">No interviews today</p>
) : (
todayIvs.map((iv) => (
<div
key={iv.id}
className="list-row"
style={{ cursor: iv.userId ? 'pointer' : 'default' }}
onClick={() => openCandidate(iv.userId)}
>
<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}</div>
</div>
<div className="lr-right">
<div className="fw-600 text-sm">
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
</div>
{iv.webLink && (
<a
className="lr-sub"
href={iv.webLink}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
>
Open in Outlook
</a>
)}
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
)}
</div>
)
}