feat: add recruitment dashboard

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-09-20 08:36:54 +02:00
parent 06c708b160
commit 06b16064f8
7 changed files with 647 additions and 9 deletions

View file

@ -158,10 +158,33 @@ describe('Go Guess frontend', () => {
await user.type(screen.getByLabelText(/password/i), 'admin123') await user.type(screen.getByLabelText(/password/i), 'admin123')
await user.click(screen.getByRole('button', { name: /sign in/i })) await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(await screen.findByRole('heading', { name: 'Job postings' })).toBeInTheDocument() expect(
expect(await screen.findByRole('link', { name: 'Senior Go Engineer' })).toBeInTheDocument() await screen.findByRole('heading', { name: 'Your hiring pipeline, at a glance.' }),
).toBeInTheDocument()
expect(await screen.findByRole('link', { name: /Senior Go Engineer/ })).toBeInTheDocument()
expect(sessionStorage.getItem('go-guess-token')).toBe('jwt-token') expect(sessionStorage.getItem('go-guess-token')).toBe('jwt-token')
expect(window.location.pathname).toBe('/jobs') expect(window.location.pathname).toBe('/')
})
it('shows dashboard metrics, graphs, and links on the main page', async () => {
authenticate()
renderApp()
expect(
await screen.findByRole('heading', { name: 'Your hiring pipeline, at a glance.' }),
).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Total jobs1/ })).toHaveAttribute('href', '/jobs')
expect(screen.getByRole('link', { name: /Participants1/ })).toHaveAttribute(
'href',
'/participants',
)
expect(screen.getByRole('meter', { name: 'Draft jobs' })).toHaveAttribute('aria-valuenow', '1')
expect(screen.getByRole('meter', { name: 'Go: 1 participants' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Senior Go Engineer/ })).toHaveAttribute(
'href',
'/jobs/7',
)
expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('href', '/')
}) })
it('returns to login when the active session expires', async () => { it('returns to login when the active session expires', async () => {

View file

@ -1,10 +1,11 @@
import { useState } from 'react' import { useState } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom' import { Route, Routes } from 'react-router-dom'
import { authStorage } from '../api/client' import { authStorage } from '../api/client'
import { AppFooter } from '../components/layout/AppFooter' import { AppFooter } from '../components/layout/AppFooter'
import { GoGuessHeader } from '../components/layout/GoGuessHeader' import { GoGuessHeader } from '../components/layout/GoGuessHeader'
import { WorkspaceUtilities } from '../components/layout/WorkspaceUtilities' import { WorkspaceUtilities } from '../components/layout/WorkspaceUtilities'
import { NotFound } from '../components/ui/NotFound' import { NotFound } from '../components/ui/NotFound'
import { DashboardPage } from '../features/dashboard/DashboardPage'
import { InboxPage, ScheduledInterviewPage } from '../features/interviews' import { InboxPage, ScheduledInterviewPage } from '../features/interviews'
import { CreateJobPage, JobDetailPage, JobsPage } from '../features/jobs' import { CreateJobPage, JobDetailPage, JobsPage } from '../features/jobs'
import { import {
@ -25,7 +26,7 @@ export function AuthenticatedApp({ onLogout }: { onLogout: () => void }) {
<WorkspaceUtilities user={user} /> <WorkspaceUtilities user={user} />
<div className="workspace-content"> <div className="workspace-content">
<Routes> <Routes>
<Route index element={<Navigate to="/jobs" replace />} /> <Route index element={<DashboardPage />} />
<Route path="/jobs" element={<JobsPage />} /> <Route path="/jobs" element={<JobsPage />} />
<Route path="/jobs/new" element={<CreateJobPage />} /> <Route path="/jobs/new" element={<CreateJobPage />} />
<Route path="/jobs/:jobId" element={<JobDetailPage />} /> <Route path="/jobs/:jobId" element={<JobDetailPage />} />

View file

@ -34,7 +34,7 @@ export function useAuthSession() {
function completeLogin() { function completeLogin() {
setAuthenticated(true) setAuthenticated(true)
setSessionExpired(false) setSessionExpired(false)
navigate('/jobs', { replace: true }) navigate('/', { replace: true })
} }
function logout() { function logout() {

View file

@ -2,7 +2,7 @@ import { Link } from 'react-router-dom'
function Logo() { function Logo() {
return ( return (
<Link className="logo" to="/jobs" aria-label="Go Guess home"> <Link className="logo" to="/" aria-label="Go Guess home">
<span className="logo-mark" aria-hidden="true"> <span className="logo-mark" aria-hidden="true">
G G
</span> </span>

View file

@ -1,7 +1,7 @@
import { import {
BriefcaseBusiness, BriefcaseBusiness,
CalendarDays,
CircleHelp, CircleHelp,
LayoutDashboard,
LogOut, LogOut,
UserRound, UserRound,
UsersRound, UsersRound,
@ -30,6 +30,7 @@ export function SideBar({
</div> </div>
<nav aria-label="Primary navigation"> <nav aria-label="Primary navigation">
<p className="nav-label">Workspace</p> <p className="nav-label">Workspace</p>
<NavItem to="/" label="Dashboard" icon={<LayoutDashboard size={20} />} close={close} />
<NavItem to="/jobs" label="Jobs" icon={<BriefcaseBusiness size={20} />} close={close} /> <NavItem to="/jobs" label="Jobs" icon={<BriefcaseBusiness size={20} />} close={close} />
<NavItem <NavItem
to="/participants" to="/participants"

View file

@ -0,0 +1,232 @@
import {
ArrowRight,
BriefcaseBusiness,
CircleHelp,
Plus,
Sparkles,
UserPlus,
UsersRound,
} from 'lucide-react'
import { Link } from 'react-router-dom'
import { useData } from '../../app/useData'
import { ErrorAlert, Loading } from '../../components/actions'
import { StatusBadge } from '../../components/ui/Display'
import type { JobStatus } from '../../types'
const jobStatuses: { value: JobStatus; label: string }[] = [
{ value: 'published', label: 'Published' },
{ value: 'draft', label: 'Draft' },
{ value: 'deprecated', label: 'Deprecated' },
]
export function DashboardPage() {
const { jobs, participants, loading, error, reload } = useData()
const questionCount = jobs.reduce((total, job) => total + job.questions.length, 0)
const publishedCount = jobs.filter((job) => job.status === 'published').length
const statusCounts = jobStatuses.map((status) => ({
...status,
count: jobs.filter((job) => job.status === status.value).length,
}))
const maxStatusCount = Math.max(...statusCounts.map((status) => status.count), 1)
const skillCounts = participants
.flatMap((participant) => participant.traits)
.reduce<Map<string, number>>((counts, skill) => {
counts.set(skill, (counts.get(skill) ?? 0) + 1)
return counts
}, new Map())
const topSkills = [...skillCounts.entries()]
.sort(([leftSkill, leftCount], [rightSkill, rightCount]) =>
rightCount === leftCount ? leftSkill.localeCompare(rightSkill) : rightCount - leftCount,
)
.slice(0, 5)
const maxSkillCount = Math.max(...topSkills.map(([, count]) => count), 1)
const recentJobs = [...jobs]
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
.slice(0, 4)
if (loading) return <Loading />
return (
<div className="dashboard">
<section className="dashboard-hero">
<div>
<span className="eyebrow">Workspace overview</span>
<h1>Your hiring pipeline, at a glance.</h1>
<p>
Track open roles, assessment coverage, and the skills available in your talent pool.
</p>
<div className="dashboard-hero-actions">
<Link to="/jobs/new" className="button primary">
<Plus size={18} /> Create job
</Link>
<Link to="/participants/new" className="button dashboard-secondary">
<UserPlus size={18} /> Add participant
</Link>
</div>
</div>
<div className="dashboard-hero-art" aria-hidden="true">
<Sparkles size={24} />
<div className="hero-orbit hero-orbit-large" />
<div className="hero-orbit hero-orbit-small" />
<strong>{publishedCount}</strong>
<span>published roles</span>
</div>
</section>
{error && <ErrorAlert message={error} retry={reload} />}
<section className="dashboard-metrics" aria-label="Recruitment summary">
<Link to="/jobs" className="dashboard-metric">
<span className="dashboard-metric-icon cyan">
<BriefcaseBusiness size={20} />
</span>
<span>
<small>Total jobs</small>
<strong>{jobs.length}</strong>
</span>
<ArrowRight size={18} />
</Link>
<Link to="/participants" className="dashboard-metric">
<span className="dashboard-metric-icon lime">
<UsersRound size={20} />
</span>
<span>
<small>Participants</small>
<strong>{participants.length}</strong>
</span>
<ArrowRight size={18} />
</Link>
<Link to="/questions" className="dashboard-metric">
<span className="dashboard-metric-icon orange">
<CircleHelp size={20} />
</span>
<span>
<small>Questions in use</small>
<strong>{questionCount}</strong>
</span>
<ArrowRight size={18} />
</Link>
</section>
<div className="dashboard-grid">
<section className="card dashboard-panel">
<div className="dashboard-panel-heading">
<div>
<span className="eyebrow">Pipeline health</span>
<h2>Jobs by status</h2>
</div>
<Link to="/jobs">
View jobs <ArrowRight size={16} />
</Link>
</div>
<div className="dashboard-bars">
{statusCounts.map((status) => (
<div className="dashboard-bar-row" key={status.value}>
<div>
<span>{status.label}</span>
<strong>{status.count}</strong>
</div>
<div
className={`dashboard-bar status-bar-${status.value}`}
role="meter"
aria-label={`${status.label} jobs`}
aria-valuenow={status.count}
aria-valuemin={0}
aria-valuemax={maxStatusCount}
>
<span style={{ width: `${(status.count / maxStatusCount) * 100}%` }} />
</div>
</div>
))}
</div>
{!jobs.length && <p className="dashboard-empty">Create a job to start your pipeline.</p>}
</section>
<section className="card dashboard-panel">
<div className="dashboard-panel-heading">
<div>
<span className="eyebrow">Talent signals</span>
<h2>Top participant skills</h2>
</div>
<Link to="/participants">
View people <ArrowRight size={16} />
</Link>
</div>
{topSkills.length ? (
<div className="dashboard-skill-chart">
{topSkills.map(([skill, count]) => (
<div className="dashboard-skill-row" key={skill}>
<span>{skill}</span>
<div
className="dashboard-skill-track"
role="meter"
aria-label={`${skill}: ${count} participants`}
aria-valuenow={count}
aria-valuemin={0}
aria-valuemax={maxSkillCount}
>
<span style={{ width: `${(count / maxSkillCount) * 100}%` }} />
</div>
<strong>{count}</strong>
</div>
))}
</div>
) : (
<p className="dashboard-empty">Participant skills will appear here.</p>
)}
</section>
<section className="card dashboard-panel dashboard-recent">
<div className="dashboard-panel-heading">
<div>
<span className="eyebrow">Latest activity</span>
<h2>Recent jobs</h2>
</div>
<Link to="/jobs">
See all <ArrowRight size={16} />
</Link>
</div>
{recentJobs.length ? (
<div className="dashboard-job-list">
{recentJobs.map((job) => (
<Link to={`/jobs/${job.id}`} key={job.id}>
<span>
<strong>{job.title}</strong>
<small>
{job.position} · {job.questions.length} questions
</small>
</span>
<StatusBadge value={job.status} />
<ArrowRight size={17} />
</Link>
))}
</div>
) : (
<p className="dashboard-empty">No jobs have been created yet.</p>
)}
</section>
<section className="dashboard-quick-links">
<span className="eyebrow">Quick actions</span>
<h2>Keep things moving</h2>
<Link to="/questions">
<CircleHelp size={20} />
<span>
<strong>Build an assessment</strong>
<small>Browse the question library</small>
</span>
<ArrowRight size={18} />
</Link>
<Link to="/calendar">
<Sparkles size={20} />
<span>
<strong>Review the calendar</strong>
<small>See upcoming interviews</small>
</span>
<ArrowRight size={18} />
</Link>
</section>
</div>
</div>
)
}

View file

@ -680,6 +680,356 @@ button {
box-shadow: 0 6px 24px rgba(14, 42, 56, 0.04); box-shadow: 0 6px 24px rgba(14, 42, 56, 0.04);
} }
.dashboard {
display: grid;
gap: 1.2rem;
}
.dashboard-hero {
position: relative;
overflow: hidden;
display: grid;
grid-template-columns: minmax(0, 1fr) 260px;
align-items: center;
min-height: 300px;
padding: clamp(2rem, 5vw, 4rem);
color: white;
border-radius: 24px;
background:
radial-gradient(circle at 70% 0%, rgba(0, 169, 200, 0.3), transparent 38%),
linear-gradient(135deg, var(--navy), #123f55);
box-shadow: 0 24px 55px rgba(8, 28, 44, 0.18);
}
.dashboard-hero::after {
content: '';
position: absolute;
width: 260px;
height: 260px;
right: -90px;
bottom: -170px;
border: 45px solid rgba(217, 239, 77, 0.1);
border-radius: 50%;
}
.dashboard-hero > div:first-child {
position: relative;
z-index: 1;
max-width: 690px;
}
.dashboard-hero .eyebrow {
color: var(--lime);
}
.dashboard-hero h1 {
max-width: 620px;
color: white;
}
.dashboard-hero p {
max-width: 590px;
margin-bottom: 1.6rem;
color: #bdd0d8;
font-size: 1.02rem;
line-height: 1.7;
}
.dashboard-hero-actions {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
}
.button.dashboard-secondary {
color: white;
border-color: rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.08);
}
.button.dashboard-secondary:hover {
border-color: rgba(255, 255, 255, 0.4);
background: rgba(255, 255, 255, 0.13);
}
.dashboard-hero-art {
position: relative;
z-index: 1;
display: flex;
width: 210px;
height: 210px;
align-items: center;
justify-content: center;
flex-direction: column;
justify-self: end;
}
.dashboard-hero-art > svg {
position: absolute;
top: 16px;
right: 20px;
color: var(--lime);
}
.dashboard-hero-art strong {
color: var(--lime);
font-family: 'Manrope';
font-size: 3.4rem;
line-height: 1;
}
.dashboard-hero-art > span {
margin-top: 0.45rem;
color: #b9cbd3;
font-size: 0.8rem;
font-weight: 700;
}
.hero-orbit {
position: absolute;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 50%;
}
.hero-orbit-large {
inset: 0;
}
.hero-orbit-small {
inset: 24px;
border-color: rgba(217, 239, 77, 0.3);
}
.dashboard-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.dashboard-metric {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 1rem;
padding: 1.2rem;
border: 1px solid #e1e9eb;
border-radius: 14px;
background: white;
box-shadow: 0 6px 24px rgba(14, 42, 56, 0.04);
transition:
transform 0.18s,
box-shadow 0.18s;
}
.dashboard-metric:hover {
transform: translateY(-2px);
box-shadow: var(--shadow);
}
.dashboard-metric > span:nth-child(2) {
display: flex;
flex-direction: column;
}
.dashboard-metric small {
color: var(--muted);
font-size: 0.76rem;
font-weight: 700;
}
.dashboard-metric strong {
margin-top: 0.15rem;
color: var(--navy);
font-family: 'Manrope';
font-size: 1.65rem;
}
.dashboard-metric > svg {
color: #9aabb2;
}
.dashboard-metric-icon {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: 12px;
}
.dashboard-metric-icon.cyan {
color: #087187;
background: #def5f8;
}
.dashboard-metric-icon.lime {
color: #5b690b;
background: #f1f8c8;
}
.dashboard-metric-icon.orange {
color: #a44527;
background: #fff0e9;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.2rem;
}
.dashboard-panel {
min-width: 0;
padding: 1.5rem;
}
.dashboard-panel-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.6rem;
}
.dashboard-panel-heading .eyebrow,
.dashboard-quick-links .eyebrow {
margin-bottom: 0.35rem;
}
.dashboard-panel-heading h2,
.dashboard-quick-links h2 {
margin: 0;
}
.dashboard-panel-heading > a {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--cyan-dark);
font-size: 0.8rem;
font-weight: 700;
white-space: nowrap;
}
.dashboard-bars {
display: grid;
gap: 1.25rem;
}
.dashboard-bar-row > div:first-child {
display: flex;
justify-content: space-between;
margin-bottom: 0.45rem;
color: #526872;
font-size: 0.82rem;
}
.dashboard-bar-row strong {
color: var(--navy);
}
.dashboard-bar,
.dashboard-skill-track {
overflow: hidden;
height: 10px;
border-radius: 999px;
background: #edf2f3;
}
.dashboard-bar > span,
.dashboard-skill-track > span {
display: block;
height: 100%;
min-width: 0;
border-radius: inherit;
background: var(--cyan);
}
.dashboard-bar.status-bar-published > span {
background: linear-gradient(90deg, #16a274, #43c99c);
}
.dashboard-bar.status-bar-draft > span {
background: linear-gradient(90deg, #eda83c, #f6cb68);
}
.dashboard-bar.status-bar-deprecated > span {
background: linear-gradient(90deg, #cf6767, #e6988c);
}
.dashboard-skill-chart {
display: grid;
gap: 1rem;
}
.dashboard-skill-row {
display: grid;
grid-template-columns: minmax(80px, 0.7fr) minmax(120px, 2fr) 24px;
align-items: center;
gap: 0.8rem;
font-size: 0.82rem;
}
.dashboard-skill-row > span {
overflow: hidden;
color: #526872;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-skill-track {
height: 8px;
}
.dashboard-skill-track > span {
background: linear-gradient(90deg, var(--cyan-dark), var(--cyan));
}
.dashboard-skill-row strong {
color: var(--navy);
text-align: right;
}
.dashboard-empty {
margin: 2rem 0;
color: var(--muted);
text-align: center;
}
.dashboard-recent {
padding-bottom: 0.4rem;
}
.dashboard-job-list {
margin-inline: -1.5rem;
}
.dashboard-job-list > a {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.8rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--line);
}
.dashboard-job-list > a:hover {
background: #f8fbfb;
}
.dashboard-job-list > a > span:first-child {
display: flex;
min-width: 0;
flex-direction: column;
}
.dashboard-job-list strong {
overflow: hidden;
color: var(--navy);
font-size: 0.9rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-job-list small {
overflow: hidden;
margin-top: 0.2rem;
color: var(--muted);
font-size: 0.74rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-job-list svg {
color: var(--cyan-dark);
}
.dashboard-quick-links {
padding: 1.5rem;
color: white;
border-radius: var(--radius);
background: linear-gradient(145deg, rgba(0, 169, 200, 0.12), transparent 60%), var(--navy);
}
.dashboard-quick-links .eyebrow {
color: var(--lime);
}
.dashboard-quick-links h2 {
color: white;
margin-bottom: 1.25rem;
}
.dashboard-quick-links > a {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.8rem;
margin-top: 0.7rem;
padding: 0.9rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 11px;
background: rgba(255, 255, 255, 0.06);
}
.dashboard-quick-links > a:hover {
border-color: rgba(217, 239, 77, 0.4);
background: rgba(255, 255, 255, 0.1);
}
.dashboard-quick-links > a > svg:first-child {
color: var(--lime);
}
.dashboard-quick-links > a > svg:last-child {
color: #8fa7b2;
}
.dashboard-quick-links a span {
display: flex;
flex-direction: column;
}
.dashboard-quick-links small {
margin-top: 0.15rem;
color: #93aab4;
font-size: 0.74rem;
}
.metrics { .metrics {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@ -2166,6 +2516,13 @@ fieldset {
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.dashboard-hero {
grid-template-columns: minmax(0, 1fr) 190px;
}
.dashboard-hero-art {
width: 170px;
height: 170px;
}
.split-panels, .split-panels,
.library-grid, .library-grid,
.session-grid { .session-grid {
@ -2199,6 +2556,16 @@ fieldset {
} }
@media (max-width: 800px) { @media (max-width: 800px) {
.dashboard-hero {
grid-template-columns: 1fr;
}
.dashboard-hero-art {
display: none;
}
.dashboard-metrics,
.dashboard-grid {
grid-template-columns: 1fr;
}
.login-shell { .login-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -2294,6 +2661,20 @@ fieldset {
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.dashboard-hero {
padding: 1.6rem;
border-radius: 18px;
}
.dashboard-hero-actions {
flex-direction: column;
}
.dashboard-hero-actions .button {
width: 100%;
}
.dashboard-panel-heading {
align-items: flex-start;
flex-direction: column;
}
.page-header { .page-header {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;