ui-polish 3/6: screen sweep A (9 representative screens)

Dashboard, Candidates, Jobs, Settings, Inbox, Interviews, Matching,
Managers, JobCandidates — bounded checklist per screen:

- Hand-rolled .page-head blocks replaced with <PageHeader/>. Fixes
  Matching, whose bare <h1> rendered in the wrong face and weight.
- Candidates drops its verbatim copy of the DataTable thead for
  DataTableHead; clickable rows use .row-click.
- Interviews deletes its local STATUS_CLASS shadow (Badge auto-maps
  identically); Dashboard imports the shared PRIORITY_CLASS.
- Icon-only .act-btn buttons gain aria-labels mirroring their tooltips.
- Emails no longer overflow: Settings/Candidates clip with full text in
  title=""; Managers shows the full address again instead of chopping
  the domain off the data.
- Mechanical inline-style swaps to the new utilities (mb-*, mt-*,
  flex-1, justify-between, flex-wrap) on exact matches only.
- Settings uses its imported EmptyState instead of a hand-rolled copy;
  JobCandidates embedded title uses .page-title-sm instead of an inline
  font size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/29/head
Talha Ahmed 2026-08-25 21:46:11 +05:00
parent bd16f8a775
commit 4757895029
9 changed files with 115 additions and 165 deletions

View File

@ -14,7 +14,8 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import { Pagination, useDataTable } from '../ui/DataTable' import { DataTableHead, Pagination, useDataTable } from '../ui/DataTable'
import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives' import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import CandidateProfile from './CandidateProfile' import CandidateProfile from './CandidateProfile'
@ -263,14 +264,10 @@ export default function Candidates() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Candidates"
<h1 className="page-title">Candidates</h1> sub={<>{rows.length} candidate account{rows.length === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}</>}
<p className="page-sub"> actions={<>
{rows.length} candidate account{rows.length === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}
</p>
</div>
<div className="page-head-actions">
<button className="btn btn-secondary" onClick={() => toast('Candidates exported', 'success')}> <button className="btn btn-secondary" onClick={() => toast('Candidates exported', 'success')}>
<Icon name="download" /> Export <Icon name="download" /> Export
</button> </button>
@ -280,11 +277,11 @@ export default function Candidates() {
<button className="btn btn-primary" onClick={() => setAdding(true)}> <button className="btn btn-primary" onClick={() => setAdding(true)}>
<Icon name="plus" /> Add Candidate <Icon name="plus" /> Add Candidate
</button> </button>
</div> </>}
</div> />
{recentChips.length > 0 && ( {recentChips.length > 0 && (
<div className="flex items-center gap-8" style={{ marginBottom: 14, flexWrap: 'wrap' }}> <div className="flex items-center gap-8 flex-wrap mb-12">
<span className="text-muted text-sm fw-600">Recently viewed:</span> <span className="text-muted text-sm fw-600">Recently viewed:</span>
{recentChips.map((c) => ( {recentChips.map((c) => (
<button key={c.id} className="prompt-chip" style={{ padding: '5px 10px' }} onClick={() => openProfile(c)}> <button key={c.id} className="prompt-chip" style={{ padding: '5px 10px' }} onClick={() => openProfile(c)}>
@ -342,30 +339,7 @@ export default function Candidates() {
<div className="dt"> <div className="dt">
<div className="table-wrap"> <div className="table-wrap">
<table className="data"> <table className="data">
<thead> <DataTableHead columns={columns} sort={t.sort} toggleSort={t.toggleSort} />
<tr>
{columns.map((c) => {
const isSorted = t.sort.key === c.key
const cls = [
c.sortable ? 'sortable' : '',
isSorted ? (t.sort.dir === 1 ? 'sorted-asc' : 'sorted-desc') : '',
].filter(Boolean).join(' ')
return (
<th
key={c.key}
className={cls}
style={{ textAlign: c.align || 'left' }}
onClick={c.sortable ? () => t.toggleSort(c.key) : undefined}
>
{c.label}
{c.sortable && (
<span className="sort-ind">{isSorted ? (t.sort.dir === 1 ? '▲' : '▼') : '⇅'}</span>
)}
</th>
)
})}
</tr>
</thead>
<tbody> <tbody>
{t.pageRows.length === 0 ? ( {t.pageRows.length === 0 ? (
<tr> <tr>
@ -379,7 +353,7 @@ export default function Candidates() {
t.pageRows.map((c) => ( t.pageRows.map((c) => (
<tr <tr
key={c.id} key={c.id}
style={{ cursor: 'pointer' }} className="row-click"
onClick={() => openProfile(c)} onClick={() => openProfile(c)}
> >
<td> <td>
@ -392,7 +366,7 @@ export default function Candidates() {
</div> </div>
</td> </td>
<td> <td>
<span className="text-sm">{c.email ?? '—'}</span> <span className="text-sm cell-clip" title={c.email || undefined}>{c.email ?? '—'}</span>
</td> </td>
<td> <td>
{c.isActive {c.isActive
@ -948,7 +922,7 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
<div className="dz-icn" style={{ width: 44, height: 44, borderRadius: 13, marginBottom: 10 }}> <div className="dz-icn" style={{ width: 44, height: 44, borderRadius: 13, marginBottom: 10 }}>
<Icon name="upload" /> <Icon name="upload" />
</div> </div>
<h3 style={{ fontSize: 15 }}>Drop the CV here or click to browse</h3> <h3>Drop the CV here or click to browse</h3>
<p className="text-muted text-sm"> <p className="text-muted text-sm">
PDF only · text-based resumes · up to {MAX_CV_MB} MB PDF only · text-based resumes · up to {MAX_CV_MB} MB
</p> </p>
@ -956,7 +930,7 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
{cv && ( {cv && (
<div className="upload-row"> <div className="upload-row">
<span className="attach-icn" style={{ width: 34, height: 34 }}><Icon name="file" /></span> <span className="attach-icn" style={{ width: 34, height: 34 }}><Icon name="file" /></span>
<div style={{ flex: 1, minWidth: 0 }}> <div className="flex-1">
<div className="fw-600 text-sm">{cv.name}</div> <div className="fw-600 text-sm">{cv.name}</div>
<div className="cell-sub">{Math.max(1, Math.round(cv.size / 1024))} KB</div> <div className="cell-sub">{Math.max(1, Math.round(cv.size / 1024))} KB</div>
</div> </div>

View File

@ -5,7 +5,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Chart, { ChartLegend } from '../ui/Chart' import Chart, { ChartLegend } from '../ui/Chart'
import Charts from '../lib/charts' import Charts from '../lib/charts'
import Dropdown from '../ui/Dropdown' import Dropdown from '../ui/Dropdown'
import { Avatar, Badge, EmptyState, Icon, KpiTile, ProgressBar } from '../ui/primitives' import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, Icon, KpiTile, PRIORITY_CLASS, ProgressBar } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
@ -17,7 +18,6 @@ import * as jobsApi from '../api/jobs'
import * as tasksApi from '../api/tasks' import * as tasksApi from '../api/tasks'
const POLL_MS = 60_000 const POLL_MS = 60_000
const PRIORITY_CLASS = { High: 'b-red', Medium: 'b-amber', Low: 'b-gray' }
function asObject(data) { function asObject(data) {
return data && typeof data === 'object' && !Array.isArray(data) ? data : null return data && typeof data === 'object' && !Array.isArray(data) ? data : null
@ -373,14 +373,10 @@ export default function Dashboard() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title={<>{greetingFor()}, {firstName} 👋</>}
<h1 className="page-title">{greetingFor()}, {firstName} 👋</h1> sub={<>Heres whats happening with your hiring today {todayLabel}</>}
<p className="page-sub"> actions={<>
Heres whats happening with your hiring today {todayLabel}
</p>
</div>
<div className="page-head-actions">
<Link className="btn btn-secondary" to="/reports"> <Link className="btn btn-secondary" to="/reports">
<Icon name="download" /> Export <Icon name="download" /> Export
</Link> </Link>
@ -404,8 +400,8 @@ export default function Dashboard() {
<Link className="btn btn-primary" to="/jobs" state={{ openCreate: true }}> <Link className="btn btn-primary" to="/jobs" state={{ openCreate: true }}>
<Icon name="plus" /> Create Job <Icon name="plus" /> Create Job
</Link> </Link>
</div> </>}
</div> />
<div className="grid g-kpi-7"> <div className="grid g-kpi-7">
{tiles.map((c) => <KpiTile key={c.label} {...c} />)} {tiles.map((c) => <KpiTile key={c.label} {...c} />)}

View File

@ -9,6 +9,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import EmailBody, { looksLikeHtml } from '../ui/EmailBody' import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
import PageHeader from '../ui/PageHeader'
import { Tabs } from '../ui/Tabs' import { Tabs } from '../ui/Tabs'
import { Pagination, pageWindow } from '../ui/DataTable' import { Pagination, pageWindow } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives' import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
@ -719,12 +720,10 @@ export default function Inbox() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Recruitment Inbox"
<h1 className="page-title">Recruitment Inbox</h1> sub="Every candidate, every source — one unified queue"
<p className="page-sub">Every candidate, every source one unified queue</p> actions={<>
</div>
<div className="page-head-actions">
<span className="integration-status"><span className="pulse" />Microsoft Graph API · Connected</span> <span className="integration-status"><span className="pulse" />Microsoft Graph API · Connected</span>
<button <button
className="btn btn-secondary" className="btn btn-secondary"
@ -739,8 +738,8 @@ export default function Inbox() {
<button className="btn btn-primary" onClick={() => navigate('/import')}> <button className="btn btn-primary" onClick={() => navigate('/import')}>
<Icon name="upload" /> Upload CVs <Icon name="upload" /> Upload CVs
</button> </button>
</div> </>}
</div> />
<div className="card"> <div className="card">
<div style={{ margin: '0 16px', paddingTop: 8 }}> <div style={{ margin: '0 16px', paddingTop: 8 }}>

View File

@ -25,6 +25,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import DataTable from '../ui/DataTable' import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader'
import { Tabs } from '../ui/Tabs' import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, Stars } from '../ui/primitives' import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, Stars } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
@ -39,13 +40,6 @@ import { avatarColor, fmtShort, initials as initialsOf } from '../data/seed'
const FETCH_TOP = 200 const FETCH_TOP = 200
const STATUS_CLASS = {
Scheduled: 'b-blue',
Completed: 'b-green',
Cancelled: 'b-red',
'No Show': 'b-amber',
}
/** <input type="date"> + <input type="time"> -> one ISO instant, or null. */ /** <input type="date"> + <input type="time"> -> one ISO instant, or null. */
function toInstant(date, time) { function toInstant(date, time) {
if (!date) return null if (!date) return null
@ -229,14 +223,14 @@ export default function Interviews() {
}, },
{ {
key: 'status', label: 'Status', sortable: true, key: 'status', label: 'Status', sortable: true,
render: (iv) => <Badge className={STATUS_CLASS[iv.status] ?? 'b-gray'}>{iv.status}</Badge>, render: (iv) => <Badge>{iv.status}</Badge>,
}, },
{ {
key: '_a', label: 'Actions', align: 'right', key: '_a', label: 'Actions', align: 'right',
render: (iv) => ( render: (iv) => (
<div className="row-actions"> <div className="row-actions">
<button <button
className="act-btn" data-tip="View candidate" className="act-btn" data-tip="View candidate" aria-label="View candidate"
disabled={!iv.userId} disabled={!iv.userId}
onClick={() => navigate('/candidates', { state: { openCandidate: iv.userId } })} onClick={() => navigate('/candidates', { state: { openCandidate: iv.userId } })}
> >
@ -245,14 +239,14 @@ export default function Interviews() {
{iv.status === 'Scheduled' && ( {iv.status === 'Scheduled' && (
<> <>
<button <button
className="act-btn" data-tip="Mark completed" className="act-btn" data-tip="Mark completed" aria-label="Mark completed"
disabled={setStatusMutation.isPending} disabled={setStatusMutation.isPending}
onClick={() => setStatusMutation.mutate({ id: iv.id, next: 'Completed' })} onClick={() => setStatusMutation.mutate({ id: iv.id, next: 'Completed' })}
> >
<Icon name="check" /> <Icon name="check" />
</button> </button>
<button <button
className="act-btn" data-tip="Cancel interview" className="act-btn" data-tip="Cancel interview" aria-label="Cancel interview"
disabled={setStatusMutation.isPending} disabled={setStatusMutation.isPending}
onClick={() => setStatusMutation.mutate({ id: iv.id, next: 'Cancelled' })} onClick={() => setStatusMutation.mutate({ id: iv.id, next: 'Cancelled' })}
> >
@ -260,7 +254,7 @@ export default function Interviews() {
</button> </button>
</> </>
)} )}
<button className="act-btn" data-tip="Scorecard" onClick={() => setFeedbackFor(iv)}> <button className="act-btn" data-tip="Scorecard" aria-label="Scorecard" onClick={() => setFeedbackFor(iv)}>
<Icon name="star" /> <Icon name="star" />
</button> </button>
</div> </div>
@ -272,18 +266,16 @@ export default function Interviews() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Interviews"
<h1 className="page-title">Interviews</h1> sub="Manage and track all interview activity"
<p className="page-sub">Manage and track all interview activity</p> actions={<>
</div>
<div className="page-head-actions">
<Link className="btn btn-secondary" to="/calendar"><Icon name="calendar" /> Calendar View</Link> <Link className="btn btn-secondary" to="/calendar"><Icon name="calendar" /> Calendar View</Link>
<button className="btn btn-primary" onClick={() => setScheduling(true)}> <button className="btn btn-primary" onClick={() => setScheduling(true)}>
<Icon name="plus" /> Schedule Interview <Icon name="plus" /> Schedule Interview
</button> </button>
</div> </>}
</div> />
<div className="grid g-kpi mb-18"> <div className="grid g-kpi mb-18">
<KpiCard label="Scheduled" value={allQuery.isPending ? '—' : stats.scheduled} icon="calendar" tone="i-blue" /> <KpiCard label="Scheduled" value={allQuery.isPending ? '—' : stats.scheduled} icon="calendar" tone="i-blue" />
@ -484,14 +476,14 @@ function Scorecard({ interview: iv, onClose, onSaved, toast }) {
color={avatarColor(iv.candidate)} color={avatarColor(iv.candidate)}
className="avatar-lg" className="avatar-lg"
/> />
<div style={{ flex: 1 }}> <div className="flex-1">
<div className="ph-name" style={{ fontSize: 17 }}>{iv.candidate}</div> <div className="ph-name" style={{ fontSize: 17 }}>{iv.candidate}</div>
<div className="ph-role">{iv.type}{iv.jobTitle ? ` · ${iv.jobTitle}` : ''}</div> <div className="ph-role">{iv.type}{iv.jobTitle ? ` · ${iv.jobTitle}` : ''}</div>
</div> </div>
<Badge className={STATUS_CLASS[iv.status] ?? 'b-gray'}>{iv.status}</Badge> <Badge>{iv.status}</Badge>
</div> </div>
<div style={{ marginBottom: 18 }}> <div className="mb-18">
<Tabs <Tabs
value={tab} value={tab}
onChange={setTab} onChange={setTab}
@ -519,14 +511,14 @@ function Scorecard({ interview: iv, onClose, onSaved, toast }) {
)} )}
{template && ( {template && (
<> <>
<div className="form-field" style={{ marginBottom: 8 }}> <div className="form-field mb-8">
<label>Evaluation Template</label> <label>Evaluation Template</label>
<select value={templateName} onChange={(e) => setTemplateName(e.target.value)}> <select value={templateName} onChange={(e) => setTemplateName(e.target.value)}>
{templates.map((t) => <option key={t.id}>{t.name}</option>)} {templates.map((t) => <option key={t.id}>{t.name}</option>)}
</select> </select>
</div> </div>
<CriteriaList criteria={template.criteria} ratings={ratings} setRating={setRating} /> <CriteriaList criteria={template.criteria} ratings={ratings} setRating={setRating} />
<div className="form-field" style={{ marginTop: 8 }}> <div className="form-field mt-8">
<label>Comments</label> <label>Comments</label>
<textarea <textarea
placeholder="Strengths, concerns, and areas explored…" placeholder="Strengths, concerns, and areas explored…"
@ -534,7 +526,7 @@ function Scorecard({ interview: iv, onClose, onSaved, toast }) {
onChange={(e) => setComments(e.target.value)} onChange={(e) => setComments(e.target.value)}
/> />
</div> </div>
<div className="form-field" style={{ marginTop: 14 }}> <div className="form-field mt-12">
<label>Overall Recommendation</label> <label>Overall Recommendation</label>
<div className="seg" style={{ marginTop: 4 }}> <div className="seg" style={{ marginTop: 4 }}>
{['Strong Hire', 'Hire', 'Lean Hire', 'No Hire'].map((r) => ( {['Strong Hire', 'Hire', 'Lean Hire', 'No Hire'].map((r) => (
@ -662,7 +654,7 @@ function ScheduleForm({ applications, loading, busy, onClose, onSubmit, toast })
</div> </div>
</div> </div>
<p className="text-muted" style={{ marginTop: 16, fontSize: 13 }}> <p className="text-muted text-sm mt-16">
Interviews attach to an application, so only candidates with an assigned job post appear here. 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. Duration, meeting mode and interviewers are not stored by the interview record.
</p> </p>

View File

@ -92,7 +92,7 @@ function CandidateCard({ c, onView }) {
<span className="cand-meta"><Icon name="briefcase" /> {c.experience != null ? `${c.experience} yrs` : '—'}</span> <span className="cand-meta"><Icon name="briefcase" /> {c.experience != null ? `${c.experience} yrs` : '—'}</span>
<span className="cand-company">{c.currentCompany ?? ''}</span> <span className="cand-company">{c.currentCompany ?? ''}</span>
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge> <Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge>
<button className="act-btn" data-tip="View" onClick={(e) => { e.stopPropagation(); onView(c) }}> <button className="act-btn" data-tip="View" aria-label="View candidate" onClick={(e) => { e.stopPropagation(); onView(c) }}>
<Icon name="eye" /> <Icon name="eye" />
</button> </button>
</div> </div>
@ -119,7 +119,7 @@ function FailedCard({ c, onView }) {
<Badge className="b-red">{c.errorCode ?? 'FAILED'}</Badge> <Badge className="b-red">{c.errorCode ?? 'FAILED'}</Badge>
<span className="cand-company" /> <span className="cand-company" />
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge> <Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge>
<button className="act-btn" data-tip="View" onClick={(e) => { e.stopPropagation(); onView(c) }}> <button className="act-btn" data-tip="View" aria-label="View details" onClick={(e) => { e.stopPropagation(); onView(c) }}>
<Icon name="eye" /> <Icon name="eye" />
</button> </button>
</div> </div>
@ -143,7 +143,7 @@ export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
> >
<div className="profile-hero"> <div className="profile-hero">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" /> <Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
<div style={{ flex: 1 }}> <div className="flex-1">
<div className="ph-name">{displayName(c.name)}</div> <div className="ph-name">{displayName(c.name)}</div>
<div className="ph-role">{roleLine}</div> <div className="ph-role">{roleLine}</div>
<div className="ph-tags"> <div className="ph-tags">
@ -195,14 +195,14 @@ export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
{tab === 'Job Match' && completed && ( {tab === 'Job Match' && completed && (
<> <>
<div className="form-section-title" style={{ marginTop: 0 }}>Job-Match Score</div> <div className="form-section-title" style={{ marginTop: 0 }}>Job-Match Score</div>
<p className="text-muted text-sm" style={{ marginBottom: 14 }}> <p className="text-muted text-sm mb-12">
Match this candidate against the job the CV was scored for. Match this candidate against the job the CV was scored for.
</p> </p>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}> <div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
<div className="card-body flex items-center gap-12"> <div className="card-body flex items-center gap-12">
<MiniRing score={c.aiScore} size={56} /> <MiniRing score={c.aiScore} size={56} />
<div style={{ flex: 1, minWidth: 0 }}> <div className="flex-1">
<div className="fw-600" style={{ marginBottom: 8 }}>{jobTitle ?? 'Selected job'}</div> <div className="fw-600 mb-8">{jobTitle ?? 'Selected job'}</div>
<ProgressBar pct={c.aiScore} /> <ProgressBar pct={c.aiScore} />
</div> </div>
{band && <Badge className={bandCls}>{band}</Badge>} {band && <Badge className={bandCls}>{band}</Badge>}
@ -212,7 +212,7 @@ export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
<div className="form-section-title" style={{ marginTop: 0 }}> <div className="form-section-title" style={{ marginTop: 0 }}>
Matched must-have skills ({c.matchedSkills.length}) Matched must-have skills ({c.matchedSkills.length})
</div> </div>
<div className="k-tags" style={{ marginBottom: 16 }}> <div className="k-tags mb-16">
{c.matchedSkills.length {c.matchedSkills.length
? c.matchedSkills.map((s) => ( ? c.matchedSkills.map((s) => (
<span className="skill-pill skill-matched" key={s}><Icon name="check" /> {s}</span> <span className="skill-pill skill-matched" key={s}><Icon name="check" /> {s}</span>
@ -223,7 +223,7 @@ export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
<div className="form-section-title" style={{ marginTop: 0 }}> <div className="form-section-title" style={{ marginTop: 0 }}>
Missing must-have skills ({c.missingSkills.length}) Missing must-have skills ({c.missingSkills.length})
</div> </div>
<div className="k-tags" style={{ marginBottom: 16 }}> <div className="k-tags mb-16">
{c.missingSkills.length {c.missingSkills.length
? c.missingSkills.map((s) => ( ? c.missingSkills.map((s) => (
<span className="skill-pill skill-missing" key={s}><Icon name="x" /> {s}</span> <span className="skill-pill skill-missing" key={s}><Icon name="x" /> {s}</span>
@ -282,9 +282,9 @@ export default function JobCandidates({ jobId, jobTitle }) {
if (!jobId) return null if (!jobId) return null
return ( return (
<div style={{ marginTop: 24 }}> <div className="mt-24">
<div style={{ marginBottom: 14 }}> <div className="mb-12">
<h2 className="page-title" style={{ fontSize: 20 }}>Candidates</h2> <h2 className="page-title page-title-sm">Candidates</h2>
<p className="page-sub"> <p className="page-sub">
{rows.length} candidate{rows.length === 1 ? '' : 's'} · {scored} scored · {failed} failed {rows.length} candidate{rows.length === 1 ? '' : 's'} · {scored} scored · {failed} failed
{jobTitle ? ` · vs ${jobTitle}` : ''} {jobTitle ? ` · vs ${jobTitle}` : ''}

View File

@ -14,6 +14,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import AiFieldAssist from '../ui/AiFieldAssist' import AiFieldAssist from '../ui/AiFieldAssist'
import DataTable from '../ui/DataTable' import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives' import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
@ -221,11 +222,11 @@ export default function Jobs() {
key: '_a', label: 'Actions', align: 'right', key: '_a', label: 'Actions', align: 'right',
render: (j) => ( render: (j) => (
<div className="row-actions"> <div className="row-actions">
<button className="act-btn" data-tip="View" onClick={() => setViewing(j)}><Icon name="eye" /></button> <button className="act-btn" data-tip="View" aria-label="View job" onClick={() => setViewing(j)}><Icon name="eye" /></button>
{canEdit && ( {canEdit && (
<button className="act-btn" data-tip="Edit" onClick={() => setEditing(j)}><Icon name="edit" /></button> <button className="act-btn" data-tip="Edit" aria-label="Edit job" onClick={() => setEditing(j)}><Icon name="edit" /></button>
)} )}
<button className="act-btn" data-tip="Publish" onClick={() => navigate('/jobboard', { state: { publishJob: j.id } })}><Icon name="send" /></button> <button className="act-btn" data-tip="Publish" aria-label="Publish job" onClick={() => navigate('/jobboard', { state: { publishJob: j.id } })}><Icon name="send" /></button>
</div> </div>
), ),
}, },
@ -233,12 +234,10 @@ export default function Jobs() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Jobs"
<h1 className="page-title">Jobs</h1> sub={`${jobs.length} requisitions · ${openCount} currently open`}
<p className="page-sub">{jobs.length} requisitions · {openCount} currently open</p> actions={<>
</div>
<div className="page-head-actions">
<button className="btn btn-secondary" onClick={exportJobs} disabled={exporting}> <button className="btn btn-secondary" onClick={exportJobs} disabled={exporting}>
<Icon name="download" /> {exporting ? 'Exporting…' : 'Export'} <Icon name="download" /> {exporting ? 'Exporting…' : 'Export'}
</button> </button>
@ -247,8 +246,8 @@ export default function Jobs() {
<Icon name="plus" /> Create Job <Icon name="sparkles" /> <Icon name="plus" /> Create Job <Icon name="sparkles" />
</button> </button>
)} )}
</div> </>}
</div> />
<div className="card"> <div className="card">
{jobsQuery.isPending && ( {jobsQuery.isPending && (
@ -620,7 +619,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
) : ( ) : (
<span className="attach-icn" style={{ width: 34, height: 34 }}><Icon name="file" /></span> <span className="attach-icn" style={{ width: 34, height: 34 }}><Icon name="file" /></span>
)} )}
<div style={{ flex: 1, minWidth: 0 }}> <div className="flex-1">
<div className="fw-600 text-sm">{imageFile.name}</div> <div className="fw-600 text-sm">{imageFile.name}</div>
<div className="cell-sub">{Math.max(1, Math.round(imageFile.size / 1024))} KB</div> <div className="cell-sub">{Math.max(1, Math.round(imageFile.size / 1024))} KB</div>
</div> </div>
@ -640,7 +639,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
</div> </div>
<p className="text-muted" style={{ marginTop: 16, fontSize: 13 }}> <p className="text-muted text-sm mt-16">
Saves the requisition to the board publish to a channel later from the Job Board. Saves the requisition to the board publish to a channel later from the Job Board.
</p> </p>
</form> </form>
@ -849,7 +848,7 @@ function RecruiterAssignment({ jobPostId, fallbackName, canEdit }) {
return ( return (
<> <>
<div className="divider" /> <div className="divider" />
<div style={{ marginBottom: 16 }}> <div className="mb-16">
<div style={SECTION_LABEL}>Recruiter ownership</div> <div style={SECTION_LABEL}>Recruiter ownership</div>
{currentQuery.isError ? ( {currentQuery.isError ? (
<p className="text-muted text-sm"> <p className="text-muted text-sm">
@ -967,7 +966,7 @@ function JobDetail({
{j.description && ( {j.description && (
<> <>
<div className="divider" /> <div className="divider" />
<div style={{ marginBottom: 16 }}> <div className="mb-16">
<div style={SECTION_LABEL}>Description</div> <div style={SECTION_LABEL}>Description</div>
<p style={{ color: 'var(--text-2)' }}>{j.description}</p> <p style={{ color: 'var(--text-2)' }}>{j.description}</p>
</div> </div>

View File

@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives' import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
@ -46,12 +47,10 @@ export default function Managers() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Hiring Managers"
<h1 className="page-title">Hiring Managers</h1> sub={`${managers.length} managers · ${totalReqs} active requisitions`}
<p className="page-sub">{managers.length} managers · {totalReqs} active requisitions</p> />
</div>
</div>
{managersQuery.isPending && ( {managersQuery.isPending && (
<EmptyState icon="managers" title="Loading…">Fetching hiring managers.</EmptyState> <EmptyState icon="managers" title="Loading…">Fetching hiring managers.</EmptyState>
@ -71,9 +70,9 @@ export default function Managers() {
{managers.map((m) => ( {managers.map((m) => (
<div className="card" key={m.id}> <div className="card" key={m.id}>
<div className="card-body"> <div className="card-body">
<div className="flex items-center gap-12" style={{ marginBottom: 14 }}> <div className="flex items-center gap-12 mb-12">
<Avatar name={m.name} className="avatar-lg" /> <Avatar name={m.name} className="avatar-lg" />
<div style={{ flex: 1 }}> <div className="flex-1">
<div className="lr-title">{m.name}</div> <div className="lr-title">{m.name}</div>
<div className="lr-sub">{m.title || m.roleName || 'Hiring manager'}</div> <div className="lr-sub">{m.title || m.roleName || 'Hiring manager'}</div>
</div> </div>
@ -83,8 +82,10 @@ export default function Managers() {
<div className="stat-mini"><span className="stat-mini-val">{m.teamSize ?? '—'}</span><span className="stat-mini-lbl">Team Size</span></div> <div className="stat-mini"><span className="stat-mini-val">{m.teamSize ?? '—'}</span><span className="stat-mini-lbl">Team Size</span></div>
</div> </div>
<div className="divider" style={{ margin: '12px 0' }} /> <div className="divider" style={{ margin: '12px 0' }} />
<div className="flex items-center" style={{ justifyContent: 'space-between' }}> <div className="flex items-center justify-between gap-8">
<span className="cell-sub"><Icon name="mail" /> {m.email ? m.email.split('@')[0] : '—'}</span> <span className="cell-sub truncate min-w-0" title={m.email || undefined}>
<Icon name="mail" /> {m.email || '—'}
</span>
<button className="btn btn-ghost btn-sm" onClick={() => setDetail(m)}>View</button> <button className="btn btn-ghost btn-sm" onClick={() => setDetail(m)}>View</button>
</div> </div>
</div> </div>

View File

@ -12,6 +12,7 @@ import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import EmailBody, { looksLikeHtml } from '../ui/EmailBody' import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
import PageHeader from '../ui/PageHeader'
import { Tabs } from '../ui/Tabs' import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives' import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives'
import { JobCard, PickRoleModal } from '../ui/SuggestedRoles' import { JobCard, PickRoleModal } from '../ui/SuggestedRoles'
@ -375,15 +376,10 @@ export default function Matching() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader title="Job Matching" sub="Route applications to the right open role" />
<div>
<h1>Job Matching</h1>
<p className="page-sub">Route applications to the right open role</p>
</div>
</div>
{!canEdit && ( {!canEdit && (
<div className="alert alert-danger" style={{ marginBottom: 14 }}> <div className="alert alert-danger mb-12">
Your account does not hold <code>inbox.edit</code>, which the server requires to Your account does not hold <code>inbox.edit</code>, which the server requires to
assign, unassign, or retry a match. Controls below stay disabled. assign, unassign, or retry a match. Controls below stay disabled.
</div> </div>
@ -554,7 +550,7 @@ function MatchingWorkspace({
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}> <div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" /> <Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
<div style={{ flex: 1 }}> <div className="flex-1">
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div> <div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
<div className="ph-role">{i.position}</div> <div className="ph-role">{i.position}</div>
<div className="ph-tags" style={{ marginTop: 8 }}> <div className="ph-tags" style={{ marginTop: 8 }}>
@ -577,7 +573,7 @@ function MatchingWorkspace({
marginBottom: 18, marginBottom: 18,
}} }}
> >
<div className="card-body flex items-center gap-12" style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}> <div className="card-body flex items-center gap-12 justify-between flex-wrap">
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<Icon name="check-circle" /> <Icon name="check-circle" />
<div> <div>
@ -609,8 +605,8 @@ function MatchingWorkspace({
> >
<div style={{ flex: '1 1 320px', minWidth: 0 }}> <div style={{ flex: '1 1 320px', minWidth: 0 }}>
{matchFailed ? ( {matchFailed ? (
<div className="alert alert-danger" style={{ marginBottom: 16 }}> <div className="alert alert-danger mb-16">
<div style={{ marginBottom: 8 }}>{detail?.matchError || 'Matching failed for this application.'}</div> <div className="mb-8">{detail?.matchError || 'Matching failed for this application.'}</div>
<button <button
className="btn btn-secondary btn-sm" className="btn btn-secondary btn-sm"
disabled={!canEdit || rematching} disabled={!canEdit || rematching}
@ -621,7 +617,7 @@ function MatchingWorkspace({
</button> </button>
</div> </div>
) : ( ) : (
<div style={{ marginBottom: 16 }}> <div className="mb-16">
<div className="fw-600" style={{ marginBottom: 6 }}>AI verdict</div> <div className="fw-600" style={{ marginBottom: 6 }}>AI verdict</div>
<p style={{ marginBottom: 4 }}> <p style={{ marginBottom: 4 }}>
{detail?.matchSummary || listRow?.matchSummary || 'No match summary yet.'} {detail?.matchSummary || listRow?.matchSummary || 'No match summary yet.'}
@ -639,7 +635,7 @@ function MatchingWorkspace({
</button> </button>
)} )}
{whyOpen && ( {whyOpen && (
<p className="text-muted text-sm" style={{ marginTop: 8 }}> <p className="text-muted text-sm mt-8">
{detail?.matchReasoning || listRow?.matchReasoning} {detail?.matchReasoning || listRow?.matchReasoning}
</p> </p>
)} )}
@ -649,7 +645,7 @@ function MatchingWorkspace({
{/* Email first: it is the application itself, and the resume is its {/* Email first: it is the application itself, and the resume is its
attachment. Reading order follows that. */} attachment. Reading order follows that. */}
{(detail?.subject || detail?.body) && ( {(detail?.subject || detail?.body) && (
<div style={{ marginBottom: 16 }}> <div className="mb-16">
<div className="fw-600" style={{ marginBottom: 6 }}>Email</div> <div className="fw-600" style={{ marginBottom: 6 }}>Email</div>
<div className="email-head">Subject: {detail.subject || '(no subject)'}</div> <div className="email-head">Subject: {detail.subject || '(no subject)'}</div>
{looksLikeHtml(detail.bodyHtml) ? ( {looksLikeHtml(detail.bodyHtml) ? (
@ -669,7 +665,7 @@ function MatchingWorkspace({
</div> </div>
<div role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}> <div role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
<div className="fw-600" style={{ marginBottom: 8 }}>Suggested roles</div> <div className="fw-600 mb-8">Suggested roles</div>
{suggestionCards.length === 0 && !manualPost ? ( {suggestionCards.length === 0 && !manualPost ? (
<EmptyState icon="alert" title="No suggested roles"> <EmptyState icon="alert" title="No suggested roles">
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap', marginTop: 10 }}> <div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap', marginTop: 10 }}>

View File

@ -9,6 +9,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader'
import { Tabs } from '../ui/Tabs' import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives' import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
@ -115,23 +116,19 @@ export default function Settings() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Settings"
<h1 className="page-title">Settings</h1> sub="Configure your workspace and team preferences"
<p className="page-sub">Configure your workspace and team preferences</p> actions={showOrgSave && (
</div> <button
<div className="page-head-actions"> className="btn btn-primary"
{showOrgSave && ( disabled={!canConfigure || save.isPending}
<button onClick={() => save.mutate()}
className="btn btn-primary" >
disabled={!canConfigure || save.isPending} <Icon name="check" /> {save.isPending ? 'Saving…' : 'Save Changes'}
onClick={() => save.mutate()} </button>
> )}
<Icon name="check" /> {save.isPending ? 'Saving…' : 'Save Changes'} />
</button>
)}
</div>
</div>
<Tabs value={tab} onChange={setTab} tabs={TABS.map((t) => ({ key: t, label: t }))} /> <Tabs value={tab} onChange={setTab} tabs={TABS.map((t) => ({ key: t, label: t }))} />
@ -264,14 +261,10 @@ function Users() {
{usersQuery.isError ? ( {usersQuery.isError ? (
<div className="card-body"> <div className="card-body">
<div className="empty-state"> <EmptyState icon="alert" title="Couldnt load users">
<Icon name="alert" /> {friendlyAuthError(usersQuery.error, 'The server did not return the user list.')}
<h3>Couldnt load users</h3> {' '}This tab needs the <code>rbac_users.view</code> permission.
<p> </EmptyState>
{friendlyAuthError(usersQuery.error, 'The server did not return the user list.')}
{' '}This tab needs the <code>rbac_users.view</code> permission.
</p>
</div>
</div> </div>
) : ( ) : (
<div className="table-wrap"> <div className="table-wrap">
@ -288,9 +281,9 @@ function Users() {
<td> <td>
<div className="user-cell"> <div className="user-cell">
<Avatar name={u.name} /> <Avatar name={u.name} />
<div> <div className="min-w-0">
<div className="cell-primary">{u.name}</div> <div className="cell-primary">{u.name}</div>
<div className="cell-sub">{u.email}</div> <div className="cell-sub" title={u.email || undefined}>{u.email}</div>
</div> </div>
</div> </div>
</td> </td>