Compare commits

...

3 Commits

Author SHA1 Message Date
ahmed.mujtaba b11c679d0f Merge branch 'sheheryar-UI' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into SQS_BROKER 2026-09-10 19:23:08 +05:00
sheheryarsoomro12 74bae5330e Add candidate workspace UI for the profile tab page.
Give the candidate route a dedicated dark workspace layout with overview actions, while keeping the drawer profile flow unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 15:09:19 +05:00
sheheryarsoomro12 45d82feeea Update .gitignore to include local macOS launcher files 2026-09-10 14:16:57 +05:00
10 changed files with 734 additions and 28 deletions

4
.gitignore vendored
View File

@ -20,6 +20,10 @@ dist/**/*
.claude/
.audit.js
# Local macOS launcher (not shared — machine-specific)
Start.command
start.command
# Backups
.backup-prebrand/
*.bak

View File

@ -0,0 +1,177 @@
/* Candidate workspace integration + responsive checks. Start Vite first, then:
node candidate-profile.test.mjs
ATS_BASE_URL / CHROME_PATH override the local server/browser. All API calls
are intercepted with fixtures; this test never writes to the real backend.
ATS_SCREENSHOT_DIR optionally saves desktop and mobile preview images. */
import assert from 'node:assert/strict'
import { existsSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'
import puppeteer from 'puppeteer-core'
const base = process.env.ATS_BASE_URL || 'http://127.0.0.1:5173'
const chrome = process.env.CHROME_PATH || [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/usr/bin/google-chrome', '/usr/bin/chromium',
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
].find(existsSync)
assert(chrome, 'Set CHROME_PATH to an installed Chrome browser')
const permissions = ['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'settings', 'requisitions'].flatMap((module) => ['view', 'create', 'edit', 'manage'].map((action) => `${module}.${action}`))
const user = { id: 'test-recruiter', name: 'Adeel Haider', email: 'adeel@example.com', role_name: 'hr_administrator', permissions }
const fixture = {
user_id: 'test-candidate', inbox_id: 101, message_id: 'test-message', name: 'Sarah Khan',
email: 'sarah.khan@example.com', phone: '+92 300 0000000', city: 'Lahore, Pakistan',
currentCompany: 'Techvista Solutions', current_title: 'Digital Marketing Specialist',
experience: '5 years', education: 'BBA - Marketing, LUMS', linkedin_url: 'https://example.com/sarah',
job_title: 'Marketing Manager', assigned_job_post_id: 'job-marketing', source: 'LinkedIn',
application_status: 'PENDING', applied: '2026-09-09T09:00:00Z', rating: 4, favorite: false,
professional_summary: 'Results-driven digital marketing professional with 5 years of experience in developing and executing data-driven marketing strategies. Experienced in increasing brand visibility, improving customer engagement, and delivering measurable growth through SEO, PPC, and social media campaigns.',
matched_keywords: ['Digital Marketing', 'SEO', 'Google Ads', 'Social Media', 'Content Marketing', 'Analytics', 'Brand Strategy'],
recruiter: 'Adeel Haider', documents: [
{ name: 'Sarah_Khan_Resume.pdf', path: 'Email/test/resume.pdf' },
{ name: 'Portfolio.pdf', path: 'Email/test/portfolio.pdf' },
{ name: 'Cover_Letter.pdf', path: 'Email/test/cover.pdf' },
],
previous_applications: [
{ source: 'inbox', inbox_id: 101, message_id: 'test-message', job_post_id: 'job-marketing', job_title: 'Marketing Manager', status: 'PENDING', applied_at: '2026-09-09' },
{ source: 'inbox', inbox_id: 99, message_id: 'old-message', job_post_id: 'job-social', job_title: 'Social Media Specialist', status: 'CLOSED', applied_at: '2026-08-03' },
{ source: 'manual', manual_upload_candidate_id: 'manual-old', job_title: 'Digital Marketing Executive', status: 'REJECTED', applied_at: '2026-07-20' },
],
notes: [{ id: 'note-1', note: 'Relevant paid-media experience. Explore budget ownership during screening.', created_by_name: 'Adeel Haider', created_at: '2026-09-09T11:30:00Z' }],
interviews: [{ id: 'interview-1', interview_type: 'Phone Screen', interview_date: '2026-09-11T09:00:00Z', interview_status: 'Scheduled' }],
activity: [{ id: 'activity-1', activity_type: 'Recruiter assigned', description: 'Adeel Haider is responsible for the current application.', activity_date: '2026-09-10T09:00:00Z' }],
}
const browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-sandbox'], defaultViewport: { width: 1536, height: 1100 } })
const page = await browser.newPage()
const errors = []
const writes = []
const reads = []
let candidate = structuredClone(fixture)
let activeUser = user
let failDetail = false
page.on('pageerror', (error) => errors.push(error.message))
await page.setRequestInterception(true)
page.on('request', async (request) => {
if (!['fetch', 'xhr', 'preflight'].includes(request.resourceType()) && new URL(request.url()).port !== '8000') return request.continue()
const path = new URL(request.url()).pathname
reads.push({ path, url: request.url() })
let data = []
if (request.method() === 'OPTIONS') return request.respond({ status: 204, headers: { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*', 'access-control-allow-methods': '*' } })
if (path === '/users/me') data = activeUser
if (path === '/candidate/fetch') data = candidate
if (path === '/forms/definitions') data = {}
if (path === '/s3/open') data = { url: `${base}/fixture-resume.pdf` }
if (path === '/fixture-resume.pdf') return request.respond({ status: 200, contentType: 'application/pdf', body: '%PDF-1.4\n%%EOF' })
if (request.method() !== 'GET') {
const body = JSON.parse(request.postData() || '{}')
writes.push({ path, body })
if (path === '/candidate/update') Object.assign(candidate, body)
if (path === '/candidate/stage') candidate.application_status = body.to_stage
if (path === '/notes/create') candidate.notes.push({ id: 'new-note', note: body.note, created_at: new Date().toISOString() })
}
return request.respond({ status: path === '/candidate/fetch' && failDetail ? 500 : 200, contentType: 'application/json', headers: { 'access-control-allow-origin': '*' }, body: JSON.stringify({ data, status_code: 200 }) })
})
await page.evaluateOnNewDocument((account) => {
localStorage.setItem('tf-auth', JSON.stringify({ access_token: 'fixture-token', refresh_token: 'fixture-refresh', expires_at: Date.now() + 3600000, data: account }))
}, user)
async function clickText(selector, text) {
const clicked = await page.evaluate((selector, text) => {
const button = [...document.querySelectorAll(selector)].find((item) => item.textContent.trim() === text)
if (!button) return false
button.click(); return true
}, selector, text)
assert(clicked, `Missing ${text}`)
}
async function load() {
await page.goto(`${base}/candidate/test-candidate`, { waitUntil: 'networkidle0' })
await page.waitForSelector('.cw-hero h1')
}
try {
await load()
assert.equal(await page.$eval('.cw-hero h1', (element) => element.textContent), 'Sarah Khan')
assert.equal(await page.$$eval('.cw-applications tbody tr', (rows) => rows.length), 3)
assert.equal(await page.$('.sidebar'), null, 'Detail page must use full width')
assert.equal(writes.length, 0, 'Opening a candidate is read-only')
for (const width of [1536, 1280, 1024, 768, 390, 320]) {
await page.setViewport({ width, height: 1100 })
const overflow = await page.evaluate(() => [document.documentElement, document.querySelector('.content'), document.querySelector('.cand-page')].map((node) => node.scrollWidth - node.clientWidth))
assert(overflow.every((amount) => amount <= 1), `Overflow at ${width}px: ${overflow}`)
if (process.env.ATS_SCREENSHOT_DIR && [1536, 390].includes(width)) {
mkdirSync(process.env.ATS_SCREENSHOT_DIR, { recursive: true })
await page.screenshot({ path: join(process.env.ATS_SCREENSHOT_DIR, `candidate-${width}.png`), fullPage: true })
}
}
console.log('ok Profile data, 3-column desktop and mobile layouts (3201536px)')
await page.setViewport({ width: 1536, height: 1100 })
await clickText('.cand-page-actions button', 'Favorite')
await page.waitForFunction(() => document.querySelector('.cand-page-actions button').getAttribute('aria-pressed') === 'true')
await page.click('.cw-rating [role=radio]:nth-child(5)')
await page.waitForFunction(() => document.querySelector('.cw-rating').textContent.includes('5.0 / 5'))
assert(writes.some((write) => write.path === '/candidate/update' && write.body.rating === 5))
await clickText('.cw-action-grid button', 'Add Note')
await page.type('.candidate-dialog textarea', 'Follow up on campaign results.')
await clickText('.candidate-dialog button', 'Add Note')
await page.waitForSelector('.candidate-dialog', { hidden: true })
assert(writes.some((write) => write.path === '/notes/create' && write.body.note === 'Follow up on campaign results.'))
console.log('ok Favorite, rating and notes retain existing API requests')
await clickText('.cw-action-grid button', 'Schedule Interview')
await page.$eval('.candidate-dialog input[type=date]', (input) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set
setter.call(input, '2027-01-20'); input.dispatchEvent(new Event('input', { bubbles: true }))
})
await clickText('.candidate-dialog button', 'Schedule Interview')
await page.waitForSelector('.candidate-dialog', { hidden: true })
assert(writes.some((write) => write.path === '/interview/create' && write.body.inbox_id === 101))
const downloadSession = await page.createCDPSession()
await downloadSession.send('Page.setDownloadBehavior', { behavior: 'deny' })
await clickText('.cw-hero-actions button', 'Download CV')
await page.waitForFunction(() => !document.querySelector('.cw-hero-actions button').disabled)
assert(reads.some((read) => read.path === '/s3/open' && new URL(read.url).searchParams.get('key') === 'Email/test/resume.pdf'))
assert(reads.some((read) => read.path === '/fixture-resume.pdf'))
assert(!reads.some((read) => read.path === '/documents/download'), 'S3 documents must not use the local-file download endpoint')
console.log('ok Interview scheduling and signed resume download')
await page.select('.cw-status-grid select', 'Interview')
const beforeCancel = writes.length
await clickText('.candidate-dialog button', 'Cancel')
assert.equal(writes.length, beforeCancel, 'Cancelling must not update stage')
await page.select('.cw-status-grid select', 'Interview')
await clickText('.candidate-dialog button', 'Save Stage')
await page.waitForSelector('.candidate-dialog', { hidden: true })
assert(writes.some((write) => write.path === '/candidate/stage' && write.body.to_stage === 'INTERVIEW'))
await clickText('.cw-action-grid button', 'Reject')
assert(await page.$eval('.candidate-dialog button[type=submit]', (button) => button.disabled), 'Rejection needs a reason')
await clickText('.candidate-dialog button', 'Cancel')
console.log('ok Stage confirmation, cancellation, rejection reason')
for (const tab of ['Resume', 'Interviews', 'Forms', 'Notes', 'Activity', 'Timeline', 'History', 'Overview']) {
await page.evaluate((label) => [...document.querySelectorAll('[role=tab]')].find((item) => item.textContent.startsWith(label)).click(), tab)
await page.waitForFunction((label) => document.querySelector('[role=tab][aria-selected=true]')?.textContent.startsWith(label), {}, tab)
}
await page.click('[role=tab][aria-selected=true]')
await page.keyboard.press('ArrowRight')
assert((await page.$eval('[role=tab][aria-selected=true]', (tab) => tab.textContent)).startsWith('Resume'))
assert(await page.$eval('[role=tabpanel]', (panel) => Boolean(document.getElementById(panel.getAttribute('aria-labelledby')))))
console.log('ok All tabs and keyboard navigation')
activeUser = { ...user, role_name: 'hiring_manager' }
await load()
assert.deepEqual(await page.$$eval('[role=tab]', (tabs) => tabs.map((tab) => tab.textContent.replace(/\d/g, '').trim())), ['Forms', 'Notes'])
assert.equal(await page.$('.cw-overview'), null)
console.log('ok Hiring manager restricted view')
activeUser = { ...user, permissions: ['candidates.view', 'interviews.view'] }
candidate = { user_id: 'test-candidate', name: 'Candidate with no attachments', notes: [], interviews: [], documents: [] }
await load()
assert.equal(await page.$('.cw-document'), null)
assert(await page.$eval('.cand-page-actions button', (button) => button.disabled))
assert(await page.$eval('.cw-status-grid select', (select) => select.disabled))
assert((await page.$eval('.cw-overview', (element) => element.textContent)).includes('No resume attached'))
console.log('ok Missing fields, missing attachments and read-only permissions')
activeUser = user
failDetail = true
await load()
await page.waitForFunction(() => document.querySelector('.cand-page').textContent.includes('Could not load this candidate'), { timeout: 15000 })
assert.equal(await page.$('.cw-overview'), null, 'Do not show stale candidate details after a failed load')
failDetail = false
await clickText('.cand-page button', 'Try again')
await page.waitForSelector('.cw-overview')
console.log('ok Load failure and retry')
assert.deepEqual(errors, [], 'No runtime errors')
console.log('All candidate workspace checks passed')
} finally { await browser.close() }

View File

@ -14,6 +14,7 @@
"test:format": "node format.test.mjs",
"test:inbox": "node inbox-loading.test.mjs",
"test:candidates": "node candidates-table.test.mjs",
"test:profile": "node candidate-profile.test.mjs",
"test:cvbank": "node cvbank.test.mjs",
"test:mobile": "node mobile.test.mjs",
"verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node format.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs"

View File

@ -20,6 +20,7 @@ export default function AppLayout() {
const badges = useBadges()
const routeKey = location.pathname.split('/')[1] || 'dashboard'
const candidateView = routeKey === 'candidate'
const route = ROUTE_BY_PATH[routeKey]
useRouteMeta(route)
@ -38,17 +39,17 @@ export default function AppLayout() {
}, [location.pathname, setNavOpen])
return (
<div id="app">
<div id="app" className={candidateView ? 'candidate-workspace' : undefined} data-theme={candidateView ? 'dark' : undefined}>
<a className="skip-link" href="#main-content">Skip to content</a>
<Sidebar
{(!candidateView || navOpen) && <Sidebar
collapsed={collapsed}
mobileOpen={navOpen}
onToggleCollapse={toggleCollapsed}
badges={badges}
/>
/>}
<div className="main-wrap">
<Topbar onOpenNav={() => setNavOpen((o) => !o)} searchRef={searchRef} />
<Topbar onOpenNav={() => setNavOpen((o) => !o)} searchRef={searchRef} candidateView={candidateView} />
<main className="content" id="main-content" ref={contentRef}>
{/* Keyed by pathname: navigating away from a crashed screen resets it. */}
<ErrorBoundary key={location.pathname}>
@ -59,14 +60,14 @@ export default function AppLayout() {
</main>
</div>
<button
{!candidateView && <button
className="ai-fab"
onClick={() => setDockOpen(true)}
title="AI Recruiter Assistant"
aria-label="Open AI Assistant"
>
<Icon name="sparkles" />
</button>
</button>}
<AiDock open={dockOpen} onClose={() => setDockOpen(false)} />

View File

@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Dropdown, { DropdownGroup } from '../ui/Dropdown'
import GlobalSearch from './GlobalSearch'
import { BrandGlyph } from '../components/BrandMark'
import { Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useTheme } from '../theme/ThemeProvider'
@ -28,7 +29,7 @@ async function fetchNotifications() {
}
}
export default function Topbar({ onOpenNav, searchRef }) {
export default function Topbar({ onOpenNav, searchRef, candidateView = false }) {
const { theme, toggleTheme } = useTheme()
const { user, signOut } = useAuth()
const { toast } = useToast()
@ -68,6 +69,10 @@ export default function Topbar({ onOpenNav, searchRef }) {
return (
<header className="topbar">
{candidateView && <Link to="/candidates" className="candidate-brand" aria-label="Utopia Brands — Candidates">
<BrandGlyph />
<span><strong>Utopia Brands</strong><small>HR Portal</small></span>
</Link>}
<button className="icon-btn menu-toggle" onClick={onOpenNav} aria-label="Toggle menu">
<Icon name="menu" />
</button>
@ -75,7 +80,7 @@ export default function Topbar({ onOpenNav, searchRef }) {
<GlobalSearch inputRef={searchRef} />
<div className="topbar-actions">
<button
{!candidateView && <button
className="icon-btn"
onClick={toggleTheme}
aria-pressed={theme === 'dark'}
@ -84,7 +89,7 @@ export default function Topbar({ onOpenNav, searchRef }) {
>
<span className="icon-sun"><Icon name="sun" /></span>
<span className="icon-moon"><Icon name="moon" /></span>
</button>
</button>}
<DropdownGroup>
<Dropdown

View File

@ -16,6 +16,7 @@ export default function CandidatePage() {
return (
<CandidateProfile
key={userId}
variant="page"
candidate={{ id: userId, userId, name: '' }}
onClose={() => (window.history.length > 1 ? navigate(-1) : navigate('/candidates'))}

View File

@ -1,5 +1,5 @@

import { useMemo, useState } from 'react'
import { useId, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@ -19,6 +19,7 @@ import * as formsApi from '../api/forms'
import * as pipelineApi from '../api/pipeline'
import * as s3Api from '../api/s3'
import CandidateFormsTab from './CandidateForms'
import CandidateWorkspaceOverview, { CandidateWorkspaceHero } from './CandidateWorkspace'
import { PreviousApplications, ReappliedBadge, candidateApplicationsOf } from '../components/ReapplicantHistory'
import { fmtDate, fmtTime, toDate } from '../lib/format'
import { companies, moneyK, pick } from '../data/seed'
@ -120,6 +121,10 @@ export default function CandidateProfile({
visibleTabs,
isManager ? 'Forms' : 'Overview',
))
const tabId = useId()
const [dialog, setDialog] = useState(null)
const [stageReason, setStageReason] = useState('')
const openAction = (type, stage) => { setStageReason(''); setDialog({ type, stage }) }
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
const isLive = Boolean(c.userId)
@ -153,7 +158,7 @@ export default function CandidateProfile({
const setFavorite = useProfileWrite({
userId: c.userId,
mutationFn: (next) => candidatesApi.update(c.userId, { favorite: next }),
success: (next) => (next ? `${c.name} added to favorites` : 'Removed from favorites'),
success: (next) => (next ? `${live?.name || c.name || 'Candidate'} added to favorites` : 'Removed from favorites'),
})
// Same PATCH as favorite: the server writes rating onto every inbox row the
@ -215,6 +220,23 @@ export default function CandidateProfile({
},
})
const moveStage = useProfileWrite({
userId: c.userId,
mutationFn: () => pipelineApi.changeStage({
inboxId: live?.inbox_id ?? undefined,
manualUploadId: live?.inbox_id ? undefined : live?.manual_upload_candidate_id,
toStage: pipelineApi.STATUS_FROM_STAGE[dialog.stage],
changeReason: stageReason.trim() || 'Updated from candidate profile',
}),
success: () => `Moved to ${dialog.stage}`,
onDone: () => {
setDialog(null)
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
qc.invalidateQueries({ queryKey: qk.forms.all() })
qc.invalidateQueries({ queryKey: qk.analytics.all() })
},
})
// The hero experience chip: live experience is free text ("6 years"), seed is
// a number. Render nothing rather than a bare "yrs exp".
const expRaw = live?.experience ?? c.experience
@ -239,6 +261,7 @@ export default function CandidateProfile({
) : detail.isError ? (
<EmptyState icon="alert" title="Could not load this candidate">
{friendlyAuthError(detail.error, 'Please try again.')}
<button className="btn btn-secondary" onClick={() => detail.refetch()}>Try again</button>
</EmptyState>
) : !live ? (
<EmptyState icon="user" title="No record found">This candidate is no longer in the pipeline.</EmptyState>
@ -294,7 +317,7 @@ export default function CandidateProfile({
const body = (
<>
<div className="profile-hero">
{variant === 'page' ? <CandidateWorkspaceHero candidate={live || c} stage={live ? stageLabel : null} restricted={isManager} /> : <div className="profile-hero">
<Avatar name={live?.name || c.name} initials={c.initials} color={c.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name">
@ -339,19 +362,25 @@ export default function CandidateProfile({
{live?.professional_summary ? <div className="ats-summary">{live.professional_summary}</div> : null}
</div>
) : null}
</div>
</div>}
<div style={{ marginTop: 22 }}>
<div className={variant === 'page' ? 'cw-tabs' : undefined} style={variant === 'page' ? undefined : { marginTop: 22 }}>
<Tabs
idBase={tabId}
value={tab}
onChange={setTab}
className="tabs tabs-wrap"
tabs={visibleTabs.map((t) => ({ key: t, label: t, count: counts ? counts[t] : undefined }))}
className={variant === 'page' ? 'tabs' : 'tabs tabs-wrap'}
tabs={visibleTabs.map((t) => ({ key: t, label: t === 'Interview' ? 'Interviews' : t, count: counts && ['Interview', 'Forms', 'Notes'].includes(t) ? counts[t] : undefined }))}
/>
</div>
<div className="tab-pane active">
{tab === 'Overview' && (guard || (live ? (
<div className={`tab-pane active${variant === 'page' && tab !== 'Overview' ? ' cw-tab-content' : ''}`} role="tabpanel" id={`${tabId}-panel-${visibleTabs.indexOf(tab)}`} aria-labelledby={`${tabId}-tab-${visibleTabs.indexOf(tab)}`}>
{tab === 'Overview' && (guard || (variant === 'page' ? <CandidateWorkspaceOverview
candidate={live || c} stage={stageLabel} nextStage={nextStage} onTab={setTab} onAction={openAction}
rating={rating} ratingPending={setRating.isPending} onRating={(n) => setRating.mutate(n)}
atsScore={atsScore} recommendation={recommendation}
atsAction={canScoreAts && can('candidates.create') ? <button className="btn btn-secondary btn-sm" disabled={scoreAts.isPending} onClick={() => scoreAts.mutate()}><Icon name="sparkles" />{scoreAts.isPending ? 'Scoring…' : 'Score with ATS'}</button> : null}
/> : live ? (
<>
<PreviousApplications row={live} />
<div className="info-grid" style={{ marginBottom: 20 }}>
@ -629,11 +658,27 @@ export default function CandidateProfile({
<div className="cand-page-crumb">
Candidates <span>/</span> <strong>{live?.name || c.name || '…'}</strong>
</div>
{actions && <div className="cand-page-actions">{actions}</div>}
</div>
<div className="card">
<div className="card-body">{body}</div>
{!isManager && <div className="cand-page-actions">
<button className={`btn btn-secondary star-btn${favorite ? ' on' : ''}`} aria-pressed={Boolean(favorite)} disabled={!live || setFavorite.isPending || !can('candidates.edit')} onClick={() => setFavorite.mutate(!favorite)}><Icon name="star" />{favorite ? 'Favorited' : 'Favorite'}</button>
</div>}
</div>
{body}
{dialog && live && <Modal
title={{ interview: 'Schedule Interview', note: 'Add Note', stage: dialog.stage === 'Rejected' ? 'Reject Application' : 'Move to Stage', share: 'Share Profile' }[dialog.type]}
subtitle={live.name}
size="candidate-dialog"
onClose={() => { if (!moveStage.isPending) setDialog(null) }}
>
{dialog.type === 'interview' && <InterviewTab userId={c.userId} inboxId={inboxId} rows={[]} onSaved={() => setDialog(null)} />}
{dialog.type === 'note' && <NotesTab userId={c.userId} rows={[]} onSaved={() => setDialog(null)} />}
{dialog.type === 'share' && <div className="form-field"><label htmlFor="candidate-share-link">Profile link</label><input id="candidate-share-link" readOnly value={window.location.href} onFocus={(event) => event.target.select()} /><p className="text-muted">Copy this link to share with a member of your hiring team.</p></div>}
{dialog.type === 'stage' && <form onSubmit={(event) => { event.preventDefault(); if (!moveStage.isPending) moveStage.mutate() }}>
<p className="cw-summary">{live.job_title || 'Current application'} · Currently {stageLabel}</p>
<div className="form-field"><label htmlFor="candidate-stage">Move to</label><select id="candidate-stage" value={dialog.stage} disabled={moveStage.isPending} onChange={(event) => setDialog({ ...dialog, stage: event.target.value })}>{Object.keys(pipelineApi.STATUS_FROM_STAGE).map((stage) => <option key={stage}>{stage}</option>)}</select></div>
<div className="form-field"><label htmlFor="candidate-stage-reason">Reason{['Rejected', 'On Hold'].includes(dialog.stage) ? ' (required)' : ' (optional)'}</label><textarea id="candidate-stage-reason" value={stageReason} disabled={moveStage.isPending} onChange={(event) => setStageReason(event.target.value)} required={['Rejected', 'On Hold'].includes(dialog.stage)} placeholder="Add context for the hiring team…" /></div>
<div className="cw-dialog-actions"><button type="button" className="btn btn-secondary" disabled={moveStage.isPending} onClick={() => setDialog(null)}>Cancel</button><button type="submit" className={`btn ${dialog.stage === 'Rejected' ? 'cw-danger' : 'btn-primary'}`} disabled={!can('pipeline.edit') || moveStage.isPending || dialog.stage === stageLabel || (['Rejected', 'On Hold'].includes(dialog.stage) && !stageReason.trim())}>{moveStage.isPending ? 'Saving…' : dialog.stage === 'Rejected' ? 'Reject Application' : 'Save Stage'}</button></div>
</form>}
</Modal>}
</div>
)
}
@ -906,8 +951,9 @@ function HistoryRow({ row: r }) {
)
}
function InterviewTab({ userId, inboxId, rows }) {
function InterviewTab({ userId, inboxId, rows, onSaved }) {
const { toast } = useToast()
const { can } = useAuth()
const [form, setForm] = useState({ type: INTERVIEW_TYPES[0], date: '', time: '', status: INTERVIEW_STATES[0] })
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
@ -919,7 +965,7 @@ function InterviewTab({ userId, inboxId, rows }) {
inboxId, date: instant, time: instant, type: form.type, status: form.status,
}),
success: 'Interview scheduled',
onDone: () => setForm({ type: INTERVIEW_TYPES[0], date: '', time: '', status: INTERVIEW_STATES[0] }),
onDone: () => { setForm({ type: INTERVIEW_TYPES[0], date: '', time: '', status: INTERVIEW_STATES[0] }); onSaved?.() },
})
function submit() {
@ -986,7 +1032,7 @@ function InterviewTab({ userId, inboxId, rows }) {
<button
className="btn btn-primary btn-sm"
style={{ marginTop: 10 }}
disabled={!inboxId || create.isPending}
disabled={!inboxId || create.isPending || !(can('interviews.create') || can('candidates.create'))}
onClick={submit}
>
<Icon name="plus" /> {create.isPending ? 'Scheduling…' : 'Schedule Interview'}
@ -1001,13 +1047,14 @@ function InterviewTab({ userId, inboxId, rows }) {
)
}
function NotesTab({ userId, rows }) {
function NotesTab({ userId, rows, onSaved }) {
const { can } = useAuth()
const [text, setText] = useState('')
const create = useProfileWrite({
userId,
mutationFn: () => candidatesApi.createNote({ userId, note: text.trim() }),
success: 'Note saved',
onDone: () => setText(''),
onDone: () => { setText(''); onSaved?.() },
})
return (
@ -1023,7 +1070,7 @@ function NotesTab({ userId, rows }) {
<button
className="btn btn-primary btn-sm"
style={{ margin: '10px 0 18px' }}
disabled={!text.trim() || create.isPending}
disabled={!text.trim() || create.isPending || !can('candidates.create')}
onClick={() => create.mutate()}
>
<Icon name="plus" /> {create.isPending ? 'Saving…' : 'Add Note'}

View File

@ -0,0 +1,247 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation } from '@tanstack/react-query'
import { Avatar, Badge, Icon, ScoreChip, Stars } from '../ui/primitives'
import OpenResumeButton from '../ui/OpenResumeButton'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { applicationStatusLabel, candidateApplicationsOf, hrefForPreviousApplication, ReappliedBadge } from '../components/ReapplicantHistory'
import { fmtDate, toDate } from '../lib/format'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as s3Api from '../api/s3'
import { STATUS_FROM_STAGE } from '../api/pipeline'
import '../styles/candidate-workspace.css'
const display = (value) => value === 0 ? '0' : value || '—'
const experience = (value) => value == null || value === '' ? '—' : Number.isFinite(Number(value)) ? `${value} years` : value
const profileLocation = (candidate) => candidate.location || candidate.city || candidate.candidate_city
const appliedRole = (candidate) => candidate.job_title || candidate.assigned_job_post?.title || candidate.jobTitle
const profileSkills = (candidate) => [...new Set((candidate.skills?.length ? candidate.skills : candidate.matched_keywords || []).filter((skill) => typeof skill === 'string' && skill.trim()))]
function externalUrl(value) {
try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) ? url.href : null } catch { return null }
}
export function WorkspaceCard({ title, action, children, className = '' }) {
return <section className={`cw-card ${className}`}>
<div className="cw-card-head"><h2>{title}</h2>{action}</div>
{children}
</section>
}
function ViewAll({ onClick, expanded }) {
return <button className="cw-link" onClick={onClick}><Icon name="arrow-right" />{expanded ? 'Show less' : 'View all'}</button>
}
function useDownload(candidate) {
const { toast } = useToast()
return useMutation({
mutationFn: async ({ index = 0, filename } = {}) => {
const path = candidate.documents?.[index]?.path
// The legacy document route serves local files only. S3 attachments use
// the same presign endpoint as Open Resume, without forwarding auth to S3.
if (s3Api.canOpen(path)) {
const key = s3Api.firstKey(path)
const url = s3Api.isS3Ref(key) ? (await s3Api.openUrl(key))?.data?.url : key
if (!url) throw new Error('Could not open this document.')
let response
try { response = await fetch(url) }
catch { throw new Error('Open the document and use the viewers download button.') }
if (!response.ok) throw new Error('Could not download this document. Please try again.')
const blobUrl = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = blobUrl
link.download = filename || 'Candidate document'
document.body.appendChild(link)
link.click()
link.remove()
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000)
return
}
return candidatesApi.downloadDocument({
inboxId: candidate.inbox_id,
manualUploadCandidateId: candidate.inbox_id ? undefined : candidate.manual_upload_candidate_id,
index, filename,
})
},
onError: (error) => toast(friendlyAuthError(error, 'Could not download the document.'), 'error'),
})
}
export function CandidateWorkspaceHero({ candidate, stage, restricted = false }) {
const resumeKey = s3Api.resumeKeyFrom(candidate)
const download = useDownload(candidate)
const resume = candidate.documents?.[0]
const linkedin = externalUrl(candidate.linkedin_url)
const facts = [
['briefcase', 'Applied for', appliedRole(candidate)],
['calendar', 'Applied on', fmtDate(candidate.applied)],
['send', 'Source', candidate.source],
['briefcase', 'Current company', candidate.currentCompany],
['clock', 'Experience', experience(candidate.experience)],
['award', 'Education', candidate.education],
['file', 'Total applications', candidateApplicationsOf(candidate).length],
]
return <header className="cw-hero">
<Avatar name={candidate.name} className="cw-avatar" color="linear-gradient(145deg, #b2acff, #9395f0)" />
<div className="cw-hero-body">
<div className="cw-hero-top">
<div className="cw-identity">
<div className="cw-name"><h1>{candidate.name || 'Candidate profile'}</h1>{stage && <Badge>{stage}</Badge>}<ReappliedBadge row={candidate} /></div>
<div className="cw-contact">
{candidate.email && <a href={`mailto:${candidate.email}`}><Icon name="mail" />{candidate.email}</a>}
{candidate.phone && <a href={`tel:${candidate.phone}`}><Icon name="phone" />{candidate.phone}</a>}
{profileLocation(candidate) && <span><Icon name="map" />{profileLocation(candidate)}</span>}
{linkedin && <a className="cw-external" href={linkedin} target="_blank" rel="noopener noreferrer"><Icon name="linkedin" />LinkedIn profile</a>}
</div>
</div>
{!restricted && <div className="cw-hero-actions">
{resume && (candidate.inbox_id || candidate.manual_upload_candidate_id) && <button className="btn btn-secondary" disabled={download.isPending} onClick={() => download.mutate({ filename: resume.name })}><Icon name="download" />{download.isPending ? 'Downloading…' : 'Download CV'}</button>}
<OpenResumeButton filePath={resumeKey} label="Open Resume" icon="eye" className="btn btn-primary" />
</div>}
</div>
{!restricted && <div className="cw-facts">{facts.map(([icon, label, value]) => <div className="cw-fact" key={label}>
<Icon name={icon} /><div><span>{label}</span><strong>{display(value)}</strong></div>
</div>)}</div>}
</div>
</header>
}
function CandidateInformation({ candidate }) {
const linkedin = externalUrl(candidate.linkedin_url)
const portfolio = externalUrl(candidate.portfolio_url || candidate.portfolio)
const rows = [
['user', 'Full name', candidate.name], ['mail', 'Email', candidate.email], ['phone', 'Phone', candidate.phone],
['map', 'Location', profileLocation(candidate)], ['briefcase', 'Current company', candidate.currentCompany],
['briefcase', 'Current title', candidate.current_title || candidate.currentTitle],
['clock', 'Experience', experience(candidate.experience)], ['award', 'Education', candidate.education],
['linkedin', 'LinkedIn', linkedin && <a href={linkedin} target="_blank" rel="noopener noreferrer">View LinkedIn profile</a>],
...(portfolio ? [['send', 'Portfolio', <a href={portfolio} target="_blank" rel="noopener noreferrer">View portfolio</a>]] : []),
...(candidate.notice_period ? [['calendar', 'Notice period', candidate.notice_period]] : []),
...(candidate.expected_salary ? [['dollar', 'Expected salary', candidate.expected_salary]] : []),
...(candidate.availability ? [['clock', 'Availability', candidate.availability]] : []),
]
return <WorkspaceCard title="Candidate Information"><dl className="cw-info">{rows.map(([icon, label, value]) => <div key={label}><dt><Icon name={icon} />{label}</dt><dd>{display(value)}</dd></div>)}</dl></WorkspaceCard>
}
function Applications({ candidate, stage }) {
const [expanded, setExpanded] = useState(false)
const rows = candidateApplicationsOf(candidate)
const visible = expanded ? rows : rows.slice(0, 3)
return <WorkspaceCard title={`Applications (${rows.length})`} action={rows.length > 3 && <ViewAll onClick={() => setExpanded(!expanded)} expanded={expanded} />}>
{rows.length ? <div className="cw-table-wrap"><table className="cw-applications">
<thead><tr><th>Job title</th><th>Applied on</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>{visible.map((application, index) => {
const current = Boolean(
(candidate.inbox_id && String(candidate.inbox_id) === String(application.inbox_id)) ||
(candidate.manual_upload_candidate_id && String(candidate.manual_upload_candidate_id) === String(application.manual_upload_candidate_id)),
)
const status = current ? stage : applicationStatusLabel(application.status, application)
const href = hrefForPreviousApplication(application)
return <tr key={`${application.inbox_id || application.manual_upload_candidate_id || application.form_data_id || 'application'}-${index}`} className={current ? 'is-current' : ''}>
<td><strong>{application.job_title || application.jobTitle || 'No job assigned'}</strong><small>{current ? 'Current application' : ({ inbox: 'Email application', manual: 'Manual application', form: 'Application form' }[application.source] || application.source || 'Application')}</small></td>
<td>{fmtDate(application.applied_at) || '—'}</td><td><Badge>{status}</Badge></td>
<td>{href ? <Link className="btn btn-secondary btn-sm" to={href} aria-label={`View ${application.job_title || 'application'}`}>View</Link> : <span className="cw-muted"></span>}</td>
</tr>
})}</tbody>
</table></div> : <p className="cw-empty">No applications recorded.</p>}
</WorkspaceCard>
}
function DocumentRow({ document, index, candidate, resume = false }) {
const download = useDownload(candidate)
const name = document.name || 'Candidate document'
const ext = name.includes('.') ? name.split('.').pop().toUpperCase().slice(0, 4) : 'FILE'
return <div className="cw-document">
<span className="cw-document-icon"><Icon name="file" /><small>{ext}</small></span>
<div className="cw-document-name"><strong title={name}>{name}</strong><small>{ext}{resume && candidate.applied ? ` · ${fmtDate(candidate.applied)}` : ' · Attached document'}</small></div>
<div className="cw-document-actions">
<OpenResumeButton filePath={document.path} label={resume ? 'Preview' : `Preview ${name}`} icon="eye" className={resume ? 'btn btn-secondary btn-sm' : 'btn btn-secondary btn-sm cw-icon-label'} />
{(candidate.inbox_id || candidate.manual_upload_candidate_id) && <button className="btn btn-secondary btn-sm" disabled={download.isPending} aria-label={`Download ${name}`} title={`Download ${name}`} onClick={() => download.mutate({ index, filename: name })}><Icon name="download" />{resume && <span>{download.isPending ? 'Downloading…' : 'Download'}</span>}</button>}
</div>
</div>
}
function RecentActivity({ candidate, onTab }) {
const events = [
...(candidate.activity || []).map((item) => ({ title: item.activity_type || 'Activity recorded', detail: item.description || item.activity_status, date: item.activity_date, author: item.created_by_name })),
...(candidate.notes || []).map((item) => ({ title: 'Internal note added', detail: item.note, date: item.created_at, author: item.created_by_name })),
...(candidate.interviews || []).map((item) => ({ title: item.interview_type || 'Interview', detail: item.interview_status, date: item.interview_date })),
...(candidate.matched_at ? [{ title: 'Screening completed', detail: candidate.match_summary || candidate.match_status, date: candidate.matched_at }] : []),
...(candidate.applied ? [{ title: 'Application received', detail: candidate.source ? `Applied via ${candidate.source}` : appliedRole(candidate), date: candidate.applied }] : []),
].sort((a, b) => (toDate(b.date)?.getTime() || 0) - (toDate(a.date)?.getTime() || 0)).slice(0, 4)
return <WorkspaceCard title="Recent Activity" action={<ViewAll onClick={() => onTab('Timeline')} />}>
{events.length ? <ol className="cw-activity">{events.map((event, index) => <li key={index}>
<div className="cw-activity-top"><strong>{event.title}</strong><time>{fmtDate(event.date) || '—'}</time></div>
{event.detail && <p>{event.detail}</p>}{event.author && <small>By {event.author}</small>}
</li>)}</ol> : <p className="cw-empty">No activity recorded yet.</p>}
</WorkspaceCard>
}
export default function CandidateWorkspaceOverview({ candidate, stage, nextStage, onTab, onAction, rating, ratingPending, onRating, atsScore, recommendation, atsAction }) {
const { can } = useAuth()
const { toast } = useToast()
const skills = profileSkills(candidate)
const documents = candidate.documents || []
const canMove = can('pipeline.edit') && Boolean(candidate.inbox_id || candidate.manual_upload_candidate_id)
const isClosed = ['Rejected', 'Hired'].includes(stage)
const status = isClosed ? 'Closed' : stage === 'On Hold' ? 'On hold' : 'In progress'
const score = atsScore ?? candidate.ai_score
async function share() {
try { await navigator.clipboard.writeText(window.location.href); toast('Profile link copied', 'success') }
catch { onAction('share') }
}
return <div className="cw-overview">
<div className="cw-column cw-column-info">
<CandidateInformation candidate={candidate} />
<WorkspaceCard title="Skills & Tags">
{skills.length ? <div className="cw-skills">{skills.map((skill) => <span key={skill}>{skill}</span>)}</div> : <p className="cw-empty">No skills added to this profile.</p>}
</WorkspaceCard>
</div>
<div className="cw-column cw-column-main">
<Applications candidate={candidate} stage={stage} />
<WorkspaceCard title="Professional Summary"><p className="cw-summary">{candidate.professional_summary || 'No professional summary available yet.'}</p></WorkspaceCard>
<WorkspaceCard title="Resume">
{documents[0] ? <DocumentRow document={documents[0]} index={0} candidate={candidate} resume /> : s3Api.canOpen(s3Api.resumeKeyFrom(candidate)) ? <OpenResumeButton filePath={s3Api.resumeKeyFrom(candidate)} /> : <p className="cw-empty">No resume attached to this application.</p>}
</WorkspaceCard>
<div className="cw-bottom-grid">
<WorkspaceCard title={`Additional Documents (${Math.max(0, documents.length - 1)})`}>
{documents.length > 1 ? <div className="cw-document-list">{documents.slice(1).map((document, index) => <DocumentRow key={`${document.name}-${index}`} document={document} index={index + 1} candidate={candidate} />)}</div> : <p className="cw-empty">No additional documents attached.</p>}
</WorkspaceCard>
<div className="cw-column">
<WorkspaceCard title="Ratings">
<div className="cw-rating" role="radiogroup" aria-label="Candidate rating"><Stars value={Math.round(rating)} onChange={onRating} disabled={ratingPending || !can('candidates.edit')} /><span>{rating ? `${rating.toFixed(1)} / 5` : 'Not rated'}</span></div>
{ratingPending && <small role="status" className="cw-muted">Saving rating</small>}
</WorkspaceCard>
<WorkspaceCard title="Recruiter">
{candidate.recruiter ? <div className="cw-recruiter"><Avatar name={candidate.recruiter} color="linear-gradient(145deg, #b2acff, #9395f0)" /><div><strong>{candidate.recruiter}</strong><small>Hiring team</small></div></div> : <p className="cw-empty">No recruiter assigned.</p>}
</WorkspaceCard>
</div>
</div>
{(score != null || candidate.match_summary || candidate.match_reasoning || atsAction) && <WorkspaceCard title="AI Screening" action={atsAction}>
<div className="cw-screening">{score != null && <div className="cw-match"><ScoreChip score={score} /><small>{recommendation || candidate.recommendation || 'AI Match'}</small></div>}
<div className="cw-summary">{candidate.match_summary && <p>{candidate.match_summary}</p>}{candidate.match_reasoning && <p>{candidate.match_reasoning}</p>}{score == null && !candidate.match_summary && !candidate.match_reasoning && <p>Compare this candidates resume with the assigned role.</p>}</div>
</div>
</WorkspaceCard>}
{candidate.job_posts?.length > 0 && <WorkspaceCard title="Suggested Roles"><div className="cw-skills">{candidate.job_posts.map((job) => <span key={job.id}>{job.title}</span>)}</div></WorkspaceCard>}
</div>
<aside className="cw-column cw-column-actions" aria-label="Candidate actions and activity">
<WorkspaceCard title="Quick Actions"><div className="cw-action-grid">
<button className="btn btn-primary" disabled={!(can('interviews.create') || can('candidates.create')) || !candidate.inbox_id} onClick={() => onAction('interview')}><Icon name="calendar" />Schedule Interview</button>
<button className="btn btn-secondary" disabled={!canMove} onClick={() => onAction('stage', nextStage || stage)}><Icon name="arrow-right" />Move to Stage</button>
<button className="btn btn-secondary" disabled={!can('candidates.create')} onClick={() => onAction('note')}><Icon name="message" />Add Note</button>
{candidate.email ? <a className="btn btn-secondary" href={`mailto:${candidate.email}`}><Icon name="mail" />Send Email</a> : <button className="btn btn-secondary" disabled><Icon name="mail" />Send Email</button>}
<button className="btn btn-secondary" onClick={share}><Icon name="copy" />Share Profile</button>
<button className="btn btn-secondary" onClick={() => onTab('Forms')}><Icon name="file" />View Forms</button>
<button className="btn cw-danger" disabled={!canMove || stage === 'Rejected'} onClick={() => onAction('stage', 'Rejected')}><Icon name="x" />Reject</button>
</div></WorkspaceCard>
<WorkspaceCard title="Status & Stage"><p className="cw-active-application">Active application: {appliedRole(candidate) || 'No job assigned'}</p>
<div className="cw-status-grid"><div><span className="cw-field-label">Status</span><div className="cw-status-value"><span className={`cw-status-dot${isClosed ? ' is-closed' : ''}`} />{status}</div></div>
<label><span className="cw-field-label">Stage</span><select aria-label="Application stage" value={stage || ''} disabled={!canMove} onChange={(event) => onAction('stage', event.target.value)}>{!stage && <option value="">Not set</option>}{Object.keys(STATUS_FROM_STAGE).map((name) => <option key={name}>{name}</option>)}</select></label>
</div>
</WorkspaceCard>
<RecentActivity candidate={candidate} onTab={onTab} />
</aside>
</div>
}

View File

@ -0,0 +1,221 @@
/* Candidate detail is a focused, full-width workspace. Tokens stay scoped so
returning to the rest of the ATS preserves the user's selected theme. */
.candidate-workspace[data-theme="dark"], .candidate-dialog {
color-scheme: dark;
--bg: #03171d;
--bg-elev: #071e26;
--bg-sunken: #0c2933;
--border: #1b404b;
--border-strong: #315764;
--text: #edf7fa;
--text-2: #b6ced7;
--text-3: #9ebbc6;
--primary: #ccfa70;
--primary-600: #dcff98;
--primary-fg: #14210b;
--primary-soft: #ccfa7012;
--primary-border: #ccfa7040;
--accent-ink: #ccfa70;
--success: #25e9a5;
--success-soft: #07543866;
--warning: #ffd16e;
--warning-soft: #614d164d;
--danger: #ff7c86;
--danger-soft: #66222c66;
--info: #82bcff;
--info-soft: #123c6866;
--purple: #b6a6ff;
--purple-soft: #3e326666;
--teal: #25e9a5;
--teal-soft: #07543866;
--avatar-fg: #071720;
--shadow: none;
color: var(--text);
background: var(--bg);
}
.candidate-workspace .main-wrap { width: 100%; margin-left: 0; }
.candidate-workspace .content { padding: 0 42px 40px; background: radial-gradient(ellipse at 50% 0, #0b29304d, transparent 58%), var(--bg); }
.candidate-workspace .topbar { min-height: 67px; height: auto; padding: 12px 42px; gap: 22px; background: #03181e; border-bottom: 1px solid var(--border); }
.candidate-brand { display: flex; align-items: center; gap: 13px; min-width: 230px; color: var(--text); text-decoration: none; }
.candidate-brand .brand-mark { width: 36px; height: 36px; fill: #25e9a5; }
.candidate-brand strong { display: block; font-size: 18px; line-height: 1.25; letter-spacing: -.4px; }
.candidate-brand small { display: block; color: var(--text-3); font-size: 12px; margin-top: 3px; }
.candidate-workspace .menu-toggle { display: inline-flex; order: -1; width: 32px; }
.candidate-workspace .topbar-search { max-width: 520px; }
.candidate-workspace .topbar-search input { height: 38px; border-radius: 8px; background: #0c2832; font-size: 13px; }
.candidate-workspace .profile-btn .avatar { background: #a19df5; color: #071720; }
.candidate-workspace .sidebar { position: fixed; top: 0; bottom: 0; left: 0; z-index: 60; transform: none; }
.candidate-workspace .cand-page { max-width: 1740px; margin-inline: auto; font-size: 13px; }
.candidate-workspace .cand-page-bar { gap: 16px; min-height: 58px; margin: 0; }
.candidate-workspace .cand-page-crumb { font-size: 12px; }
.candidate-workspace .cand-page-crumb span { margin: 0 8px; }
.candidate-workspace .cand-page-actions { width: auto; }
.candidate-workspace .btn, .candidate-dialog .btn { min-height: 36px; padding: 7px 12px; border-radius: 7px; gap: 8px; font-size: 12px; font-weight: 500; box-shadow: none; white-space: nowrap; }
.candidate-workspace .btn-secondary, .candidate-dialog .btn-secondary { border: 1px solid var(--border); color: var(--text); background: linear-gradient(120deg, #0c2730, #071e26); }
.candidate-workspace .btn-secondary:hover:not(:disabled), .candidate-dialog .btn-secondary:hover:not(:disabled) { background: #13333d; border-color: #39606b; }
.candidate-workspace .btn-primary, .candidate-dialog .btn-primary { color: var(--primary-fg); border: 1px solid #c5ed6e; background: linear-gradient(105deg, #d3fd80, #c9f86b); font-weight: 650; }
.candidate-workspace .btn-primary:hover:not(:disabled), .candidate-dialog .btn-primary:hover:not(:disabled) { background: #dcff9b; }
.candidate-workspace .btn-sm { min-height: 30px; padding: 5px 8px; font-size: 12px; }
.candidate-workspace .btn svg, .candidate-dialog .btn svg { width: 16px; height: 16px; flex-shrink: 0; }
.candidate-workspace :where(button, a, input, select, textarea):focus-visible, .candidate-dialog :where(button, a, input, select, textarea):focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
.candidate-workspace button:disabled, .candidate-dialog button:disabled { cursor: not-allowed; opacity: .45; }
.candidate-workspace .star-btn.on { color: var(--primary); border-color: #788e49; }
.cw-hero { display: flex; gap: 24px; padding: 22px 24px 20px; border: 1px solid var(--border); border-radius: 13px; background: linear-gradient(110deg, #09252e, #061d25 70%, #09252c); }
.cw-hero .cw-avatar { width: 78px; height: 78px; font-size: 28px; flex-shrink: 0; }
.cw-hero-body, .cw-identity { flex: 1; min-width: 0; }
.cw-hero-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.cw-name { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 8px; }
.cw-name h1 { margin: 0; font-size: 28px; line-height: 1.2; letter-spacing: -.7px; font-weight: 650; overflow-wrap: anywhere; }
.cw-contact { display: flex; flex-wrap: wrap; gap: 9px 22px; color: var(--text-3); font-size: 12px; }
.cw-contact > * { display: inline-flex; align-items: center; gap: 8px; min-width: 0; overflow-wrap: anywhere; }
.cw-contact a { color: var(--text-3); text-decoration: none; }
.cw-contact a:hover { color: var(--text); }
.cw-contact .cw-external, .cw-info a { color: #9dd3f1; text-decoration: underline; text-underline-offset: 3px; }
.cw-contact svg { width: 16px; height: 16px; flex-shrink: 0; }
.cw-hero-actions { display: flex; gap: 10px; flex-shrink: 0; }
.cw-facts { display: grid; grid-template-columns: 1.1fr 1fr .9fr 1.2fr .8fr 1.1fr .8fr; margin-top: 22px; }
.cw-fact { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 0 16px; border-left: 1px solid var(--border); }
.cw-fact:first-child { border-left: 0; padding-left: 0; }
.cw-fact:last-child { padding-right: 0; }
.cw-fact > svg { width: 20px; height: 20px; color: #c3dce4; flex-shrink: 0; }
.cw-fact span { display: block; color: var(--text-3); font-size: 12px; line-height: 1.4; margin-bottom: 4px; }
.cw-fact strong { font-size: 13px; font-weight: 500; line-height: 1.4; overflow-wrap: anywhere; }
.cw-tabs .tabs { gap: 10px; margin-bottom: 16px; }
.cw-tabs .tab { min-height: 55px; padding: 12px 20px; margin: 0; font-size: 13px; font-weight: 400; border-bottom-width: 3px; }
.cw-tabs .tab.active { color: var(--primary); border-bottom-color: var(--primary); font-weight: 600; }
.cw-tabs .tab-count { background: #153941; color: #cbdee4; font-size: 11px; min-width: 18px; }
.cw-overview { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.65fr) minmax(0, .99fr); gap: 16px; align-items: start; }
.cw-column { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.cw-card { min-width: 0; padding: 18px 17px; border: 1px solid var(--border); border-radius: 12px; background: linear-gradient(120deg, #09232c, #061e26 90%); }
.cw-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 16px; }
.cw-card-head h2 { font-size: 15px; font-weight: 650; letter-spacing: -.2px; line-height: 1.35; margin: 0; }
.cw-link { display: inline-flex; align-items: center; gap: 6px; color: #b4ed91; text-decoration: underline; text-underline-offset: 3px; font-size: 12px; white-space: nowrap; }
.cw-link svg { width: 15px; height: 15px; }
.cw-info { display: grid; gap: 15px; margin: 0; }
.cw-info > div { display: grid; grid-template-columns: minmax(115px, .9fr) minmax(0, 1.4fr); gap: 12px; line-height: 1.4; font-size: 12px; }
.cw-info dt { display: flex; align-items: flex-start; gap: 10px; color: var(--text-3); }
.cw-info dt svg { width: 15px; height: 15px; margin-top: 1px; flex-shrink: 0; }
.cw-info dd { margin: 0; overflow-wrap: anywhere; }
.cw-skills { display: flex; gap: 8px; flex-wrap: wrap; }
.cw-skills > span { padding: 6px 10px; border: 1px solid #284b57; border-radius: 12px; background: #102d38; color: #e0edf3; font-size: 12px; max-width: 100%; overflow-wrap: anywhere; }
.cw-table-wrap { overflow: auto; }
.cw-applications { width: 100%; border-collapse: collapse; font-size: 12px; text-align: left; }
.cw-applications th { color: #bad1dc; text-transform: uppercase; letter-spacing: .4px; font-size: 11px; font-weight: 500; border-top: 1px solid #15343d; border-bottom: 1px solid #15343d; padding: 9px 6px; white-space: nowrap; }
.cw-applications td { padding: 13px 6px; border-bottom: 1px solid #15343d; }
.cw-applications td:first-child, .cw-applications th:first-child { padding-left: 0; }
.cw-applications td:last-child, .cw-applications th:last-child { padding-right: 0; }
.cw-applications tr:last-child td { border-bottom: 0; }
.cw-applications td:nth-child(2) { color: var(--text-2); white-space: nowrap; }
.cw-applications strong { display: block; font-size: 13px; font-weight: 550; }
.cw-applications small { display: block; color: var(--text-3); font-size: 11px; margin-top: 4px; }
.cw-applications .badge { font-size: 11px; padding: 3px 7px; }
.cw-applications .is-current { background: linear-gradient(90deg, #12353438, transparent); }
.cw-summary { margin: 0; color: var(--text-2); font-size: 13px; line-height: 1.8; white-space: pre-line; overflow-wrap: anywhere; }
.cw-summary p + p { margin-top: 10px; }
.cw-empty { color: var(--text-3); font-size: 13px; line-height: 1.7; margin: 0; }
.cw-document { display: flex; align-items: center; gap: 11px; padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: linear-gradient(100deg, #0d2c36, #0a232b); min-width: 0; }
.cw-document-icon { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 29px; height: 36px; background: linear-gradient(135deg, #ff7575, #df424d); border-radius: 4px; color: #fff; flex-shrink: 0; }
.cw-document-icon svg { width: 16px; height: 16px; }
.cw-document-icon small { font-size: 7px; line-height: 1.2; margin-top: 2px; }
.cw-document-name { flex: 1; min-width: 0; }
.cw-document-name strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 500; }
.cw-document-name small { display: block; color: var(--text-3); font-size: 11px; margin-top: 4px; }
.cw-document-actions { display: flex; gap: 6px; flex-shrink: 0; }
.cw-document-list { display: grid; gap: 9px; }
.candidate-workspace .cw-icon-label { width: 30px; font-size: 0; gap: 0; }
.cw-bottom-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 14px; }
.cw-bottom-grid > .cw-card { align-self: start; }
.cw-bottom-grid .cw-card { padding: 17px; }
.cw-rating { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.cw-rating > span { font-size: 12px; color: var(--primary); }
.cw-rating .rating-stars .rs svg { width: 18px; height: 18px; }
.cw-recruiter { display: flex; align-items: center; gap: 10px; }
.cw-recruiter .avatar { width: 32px; height: 32px; font-size: 12px; }
.cw-recruiter strong { display: block; font-size: 12px; font-weight: 500; }
.cw-recruiter small { display: block; color: var(--text-3); font-size: 12px; margin-top: 3px; }
.cw-action-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 9px; }
.cw-action-grid .btn { font-size: 12px; justify-content: flex-start; padding: 8px; white-space: normal; text-align: left; }
.candidate-workspace .cw-danger, .candidate-dialog .cw-danger { border: 1px solid #ae4a55; color: #ff7c86; background: #2a172055; }
.candidate-workspace .cw-danger:hover:not(:disabled), .candidate-dialog .cw-danger:hover:not(:disabled) { background: #50232b; }
.cw-active-application { font-size: 12px; color: var(--text-3); margin: -4px 0 12px; }
.cw-status-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); gap: 10px; }
.cw-field-label { display: block; color: var(--text-3); font-size: 12px; margin-bottom: 5px; }
.cw-status-grid select, .cw-status-value { width: 100%; min-height: 37px; padding: 8px 10px; background: #0c2933; color: var(--text); border: 1px solid var(--border); border-radius: 7px; font-size: 12px; }
.cw-status-value { display: flex; align-items: center; gap: 8px; }
.cw-status-dot { width: 7px; height: 7px; background: var(--success); border-radius: 50%; flex-shrink: 0; }
.cw-status-dot.is-closed { background: var(--text-3); }
.cw-activity { list-style: none; margin: 0; padding: 0; }
.cw-activity li { position: relative; padding: 0 0 23px 24px; }
.cw-activity li:last-child { padding-bottom: 0; }
.cw-activity li::before { content: ''; position: absolute; left: 0; top: 4px; width: 10px; height: 10px; background: #59a8ff; border: 2px solid #245788; border-radius: 50%; z-index: 1; }
.cw-activity li:not(:last-child)::after { content: ''; position: absolute; width: 1px; left: 4px; top: 15px; bottom: 3px; background: #315662; }
.cw-activity-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.cw-activity strong { font-size: 12px; font-weight: 550; }
.cw-activity time { color: var(--text-3); font-size: 11px; white-space: nowrap; }
.cw-activity p { color: var(--text-3); font-size: 12px; line-height: 1.65; margin: 5px 0 0; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.cw-activity small { color: var(--text-3); font-size: 11px; }
.cw-screening { display: flex; align-items: center; gap: 18px; }
.cw-match { display: grid; justify-items: center; gap: 6px; flex-shrink: 0; }
.cw-match small { color: var(--text-3); font-size: 12px; }
.cw-tab-content { padding: 22px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: 12px; }
.candidate-dialog { border: 1px solid var(--border); width: min(620px, calc(100vw - 24px)); }
.candidate-dialog .form-field { margin-top: 16px; }
.candidate-dialog .empty-state { padding: 20px; }
.cw-dialog-actions { margin-top: 20px; display: flex; gap: 10px; justify-content: flex-end; }
.cw-muted { color: var(--text-3); }
@media (max-width: 1279px) {
.candidate-workspace .content { padding-inline: 24px; }
.candidate-workspace .topbar { padding-inline: 24px; }
.candidate-brand { min-width: 200px; }
.cw-overview { grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr); }
.cw-column-info { grid-column: 1; grid-row: 1; }
.cw-column-main { grid-column: 2; grid-row: 1 / span 2; }
.cw-column-actions { grid-column: 1; grid-row: 2; }
.cw-hero-top { flex-wrap: wrap; }
.cw-facts { grid-template-columns: repeat(4, minmax(0, 1fr)); row-gap: 18px; }
.cw-fact:nth-child(5) { border-left: 0; padding-left: 0; }
.cw-bottom-grid { grid-template-columns: minmax(0, 1fr); }
.cw-bottom-grid > .cw-column { display: grid; grid-template-columns: 1fr 1fr; }
.cw-info > div { grid-template-columns: minmax(105px, .9fr) minmax(0, 1.4fr); }
}
@media (max-width: 767px) {
.candidate-workspace .content { padding: 0 16px 28px; }
.candidate-workspace .topbar { padding: 12px 16px; gap: 10px; flex-wrap: wrap; }
.candidate-brand { min-width: 0; flex: 1; gap: 9px; }
.candidate-brand strong { font-size: 16px; }
.candidate-brand .brand-mark { width: 30px; }
.candidate-workspace .topbar-search { order: 4; max-width: none; flex-basis: 100%; }
.candidate-workspace .topbar-actions { margin-left: 0; }
.candidate-workspace .topbar-divider, .candidate-workspace .profile-meta, .candidate-workspace .profile-btn .chev { display: none; }
.candidate-workspace .cand-page-bar { gap: 10px; padding-block: 12px; }
.candidate-workspace .cand-page-crumb { flex: 1; }
.cw-hero { padding: 18px 16px; gap: 14px; flex-wrap: wrap; }
.cw-hero .cw-avatar { width: 54px; height: 54px; font-size: 22px; }
.cw-hero-body { display: contents; }
.cw-hero-top { flex: 1; min-width: 0; }
.cw-identity { flex-basis: 100%; }
.cw-name { gap: 8px; }
.cw-name h1 { font-size: 24px; }
.cw-hero-actions { flex-wrap: wrap; flex-shrink: 1; max-width: 100%; }
.cw-facts { width: 100%; margin-top: 6px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cw-fact { padding: 0 12px; gap: 9px; }
.cw-fact:nth-child(odd) { border-left: 0; padding-left: 0; }
.cw-tabs .tabs { gap: 0; }
.cw-tabs .tab { padding-inline: 16px; }
.cw-overview { display: flex; flex-direction: column; }
.cw-overview > * { width: 100%; }
.cw-column-actions { order: -1; }
.cw-action-grid .btn { min-height: 44px; }
.cw-info > div { grid-template-columns: 135px minmax(0, 1fr); font-size: 13px; }
.cw-applications { min-width: 470px; }
.cw-document { flex-wrap: wrap; }
.cw-document-name { min-width: 100px; }
.cw-document-actions { margin-left: auto; }
.cw-bottom-grid > .cw-column { display: flex; }
.cw-tab-content { padding: 16px; }
.cw-screening { align-items: flex-start; }
}
@media (prefers-reduced-motion: reduce) {
.candidate-workspace .tab-pane, .candidate-workspace .btn { animation: none; transition: none; }
}

View File

@ -193,7 +193,9 @@ export function Stars({ value, onChange, disabled }) {
className={`rs${n <= value ? ' on' : ''}`}
onClick={() => set(n)}
role="radio"
aria-label={`${n} out of 5 stars`}
aria-checked={n === value}
aria-disabled={Boolean(disabled)}
tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); set(n) } }}
>