Keep the app sidebar on candidate profiles and add compact Previous/Next browsing.
The profile was forcing its own chrome and a candidate list rail; recruiters now stay in the normal shell and step through the list they opened, with a denser layout that still fits a phone. Co-authored-by: Cursor <cursoragent@cursor.com>Edition_Fomrs
parent
74bae5330e
commit
80eb5b3bbc
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Candidate browse queue — unique ids, neighbors, profile paths.
|
||||
*
|
||||
* node candidate-browse.test.mjs
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import { uniqueBrowseEntries, neighborsOf, candidatePath } from './src/lib/candidateBrowse.js'
|
||||
|
||||
const rows = [
|
||||
{ userId: 'a', name: 'Ada', stage: 'Interview', jobTitle: 'Backend' },
|
||||
{ user_id: 'a', name: 'Ada duplicate application' },
|
||||
{ userId: '', name: 'Skipped' },
|
||||
{ userId: 'b', email: 'b@example.com' },
|
||||
{ userId: 'c', name: 'Chris', job_title: 'Design' },
|
||||
]
|
||||
|
||||
const entries = uniqueBrowseEntries(rows)
|
||||
assert.deepEqual(entries.map((row) => row.userId), ['a', 'b', 'c'])
|
||||
assert.equal(entries[0].name, 'Ada')
|
||||
assert.equal(entries[0].stage, 'Interview')
|
||||
assert.equal(entries[1].name, 'b@example.com')
|
||||
assert.equal(entries[2].jobTitle, 'Design')
|
||||
|
||||
const mid = neighborsOf(entries, 'b')
|
||||
assert.equal(mid.index, 1)
|
||||
assert.equal(mid.total, 3)
|
||||
assert.equal(mid.prev.userId, 'a')
|
||||
assert.equal(mid.next.userId, 'c')
|
||||
|
||||
const first = neighborsOf(entries, 'a')
|
||||
assert.equal(first.prev, null)
|
||||
assert.equal(first.next.userId, 'b')
|
||||
|
||||
const missing = neighborsOf(entries, 'z')
|
||||
assert.equal(missing.index, -1)
|
||||
assert.equal(missing.prev, null)
|
||||
assert.equal(missing.next, null)
|
||||
|
||||
assert.equal(candidatePath('user/1'), '/candidate/user%2F1')
|
||||
assert.equal(candidatePath('abc', 'Resume'), '/candidate/abc?tab=Resume')
|
||||
|
||||
console.log('All candidate browse checks passed')
|
||||
|
|
@ -57,7 +57,13 @@ page.on('request', async (request) => {
|
|||
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 === '/candidate/fetch') {
|
||||
const userId = new URL(request.url()).searchParams.get('user_id')
|
||||
if (!userId) data = []
|
||||
else if (userId === 'test-candidate-b') data = { ...candidate, user_id: 'test-candidate-b', name: 'Omar Ali' }
|
||||
else if (userId === 'test-candidate-c') data = { ...candidate, user_id: 'test-candidate-c', name: 'Hina Raza' }
|
||||
else 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' })
|
||||
|
|
@ -72,6 +78,13 @@ page.on('request', async (request) => {
|
|||
})
|
||||
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 }))
|
||||
sessionStorage.setItem('tf-candidate-browse', JSON.stringify({
|
||||
entries: [
|
||||
{ userId: 'test-candidate', name: 'Sarah Khan', stage: 'Shortlist', jobTitle: 'Marketing Manager' },
|
||||
{ userId: 'test-candidate-b', name: 'Omar Ali', stage: 'Interview', jobTitle: 'Marketing Manager' },
|
||||
{ userId: 'test-candidate-c', name: 'Hina Raza', stage: 'Screening', jobTitle: 'Content Lead' },
|
||||
],
|
||||
}))
|
||||
}, user)
|
||||
async function clickText(selector, text) {
|
||||
const clicked = await page.evaluate((selector, text) => {
|
||||
|
|
@ -89,7 +102,8 @@ 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.ok(await page.$('.sidebar'), 'Candidate page keeps the app sidebar')
|
||||
assert.equal(await page.$eval('.cw-browse-counter', (element) => element.textContent.trim()), '1 of 3')
|
||||
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 })
|
||||
|
|
@ -102,6 +116,13 @@ try {
|
|||
}
|
||||
console.log('ok Profile data, 3-column desktop and mobile layouts (320–1536px)')
|
||||
await page.setViewport({ width: 1536, height: 1100 })
|
||||
await page.click('[aria-label="Next candidate"]')
|
||||
await page.waitForFunction(() => document.querySelector('.cw-hero h1')?.textContent === 'Omar Ali')
|
||||
assert.equal(await page.$eval('.cw-browse-counter', (element) => element.textContent.trim()), '2 of 3')
|
||||
assert.match(page.url(), /\/candidate\/test-candidate-b/)
|
||||
await page.click('[aria-label="Previous candidate"]')
|
||||
await page.waitForFunction(() => document.querySelector('.cw-hero h1')?.textContent === 'Sarah Khan')
|
||||
console.log('ok Previous / next walks the candidate list')
|
||||
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)')
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@
|
|||
"test:candidates": "node candidates-table.test.mjs",
|
||||
"test:profile": "node candidate-profile.test.mjs",
|
||||
"test:cvbank": "node cvbank.test.mjs",
|
||||
"test:browse": "node candidate-browse.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"
|
||||
"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 && node candidate-browse.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
|
|
|
|||
|
|
@ -39,17 +39,17 @@ export default function AppLayout() {
|
|||
}, [location.pathname, setNavOpen])
|
||||
|
||||
return (
|
||||
<div id="app" className={candidateView ? 'candidate-workspace' : undefined} data-theme={candidateView ? 'dark' : undefined}>
|
||||
<div id="app" className={candidateView ? 'candidate-workspace' : undefined}>
|
||||
<a className="skip-link" href="#main-content">Skip to content</a>
|
||||
{(!candidateView || navOpen) && <Sidebar
|
||||
<Sidebar
|
||||
collapsed={collapsed}
|
||||
mobileOpen={navOpen}
|
||||
onToggleCollapse={toggleCollapsed}
|
||||
badges={badges}
|
||||
/>}
|
||||
/>
|
||||
|
||||
<div className="main-wrap">
|
||||
<Topbar onOpenNav={() => setNavOpen((o) => !o)} searchRef={searchRef} candidateView={candidateView} />
|
||||
<Topbar onOpenNav={() => setNavOpen((o) => !o)} searchRef={searchRef} />
|
||||
<main className="content" id="main-content" ref={contentRef}>
|
||||
{/* Keyed by pathname: navigating away from a crashed screen resets it. */}
|
||||
<ErrorBoundary key={location.pathname}>
|
||||
|
|
@ -60,14 +60,14 @@ export default function AppLayout() {
|
|||
</main>
|
||||
</div>
|
||||
|
||||
{!candidateView && <button
|
||||
<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)} />
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||
|
||||
import { Avatar, Icon } from '../ui/primitives'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { openCandidateProfile } from '../lib/candidateBrowse'
|
||||
import * as searchApi from '../api/search'
|
||||
|
||||
export default function GlobalSearch({ inputRef }) {
|
||||
|
|
@ -79,7 +80,7 @@ export default function GlobalSearch({ inputRef }) {
|
|||
|
||||
{candidates.length > 0 && <div className="search-group-label">Candidates</div>}
|
||||
{candidates.map((c) => (
|
||||
<div key={c.id} className="search-item" onClick={() => go('/candidates', { openCandidate: c.id })}>
|
||||
<div key={c.id} className="search-item" onClick={() => openCandidateProfile(navigate, c.id, candidates.map((row) => ({ userId: row.id, name: row.name })))}>
|
||||
<Avatar name={c.name} />
|
||||
<div>
|
||||
<div className="si-title">{c.name}</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ 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'
|
||||
|
|
@ -29,7 +28,7 @@ async function fetchNotifications() {
|
|||
}
|
||||
}
|
||||
|
||||
export default function Topbar({ onOpenNav, searchRef, candidateView = false }) {
|
||||
export default function Topbar({ onOpenNav, searchRef }) {
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const { user, signOut } = useAuth()
|
||||
const { toast } = useToast()
|
||||
|
|
@ -69,10 +68,6 @@ export default function Topbar({ onOpenNav, searchRef, candidateView = false })
|
|||
|
||||
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>
|
||||
|
|
@ -80,7 +75,7 @@ export default function Topbar({ onOpenNav, searchRef, candidateView = false })
|
|||
<GlobalSearch inputRef={searchRef} />
|
||||
|
||||
<div className="topbar-actions">
|
||||
{!candidateView && <button
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={toggleTheme}
|
||||
aria-pressed={theme === 'dark'}
|
||||
|
|
@ -89,7 +84,7 @@ export default function Topbar({ onOpenNav, searchRef, candidateView = false })
|
|||
>
|
||||
<span className="icon-sun"><Icon name="sun" /></span>
|
||||
<span className="icon-moon"><Icon name="moon" /></span>
|
||||
</button>}
|
||||
</button>
|
||||
|
||||
<DropdownGroup>
|
||||
<Dropdown
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
/* Session-scoped candidate queue so Previous / Next on the profile walks
|
||||
the same list the recruiter was looking at (Candidates table, Pipeline
|
||||
board, CV Bank, search), not an arbitrary server page. */
|
||||
|
||||
export const BROWSE_KEY = 'tf-candidate-browse'
|
||||
|
||||
export function uniqueBrowseEntries(rows = []) {
|
||||
const entries = []
|
||||
const seen = new Set()
|
||||
for (const row of rows) {
|
||||
if (!row) continue
|
||||
const userId = String(row.userId ?? row.user_id ?? '').trim()
|
||||
if (!userId || seen.has(userId)) continue
|
||||
seen.add(userId)
|
||||
entries.push({
|
||||
userId,
|
||||
name: String(row.name || row.email || 'Candidate'),
|
||||
stage: row.stage || null,
|
||||
jobTitle: row.jobTitle || row.job_title || null,
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
export function neighborsOf(entries, userId) {
|
||||
const id = String(userId ?? '')
|
||||
const index = entries.findIndex((entry) => entry.userId === id)
|
||||
return {
|
||||
index,
|
||||
total: entries.length,
|
||||
prev: index > 0 ? entries[index - 1] : null,
|
||||
next: index >= 0 && index < entries.length - 1 ? entries[index + 1] : null,
|
||||
current: index >= 0 ? entries[index] : null,
|
||||
entries,
|
||||
}
|
||||
}
|
||||
|
||||
export function candidatePath(userId, tab) {
|
||||
const path = `/candidate/${encodeURIComponent(userId)}`
|
||||
return tab ? `${path}?tab=${encodeURIComponent(tab)}` : path
|
||||
}
|
||||
|
||||
export function rememberCandidateBrowse(rows) {
|
||||
const entries = uniqueBrowseEntries(rows)
|
||||
try {
|
||||
sessionStorage.setItem(BROWSE_KEY, JSON.stringify({ entries }))
|
||||
} catch { /* private mode / quota — browsing still works for this click */ }
|
||||
return entries
|
||||
}
|
||||
|
||||
export function readCandidateBrowse() {
|
||||
try {
|
||||
const parsed = JSON.parse(sessionStorage.getItem(BROWSE_KEY) || 'null')
|
||||
return uniqueBrowseEntries(parsed?.entries || [])
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function openCandidateProfile(navigate, userId, rows, { replace = false, tab } = {}) {
|
||||
if (rows) rememberCandidateBrowse(rows)
|
||||
if (!userId) return
|
||||
navigate(candidatePath(userId, tab), { replace })
|
||||
}
|
||||
|
|
@ -107,6 +107,7 @@ export const qk = {
|
|||
applications: (email) => ['candidates', 'applications', email],
|
||||
matching: (p = {}) => ['candidates', 'matching', p],
|
||||
matchingDetail: (id) => ['candidates', 'matching', 'detail', id],
|
||||
browse: (p = {}) => ['candidates', 'browse', p],
|
||||
},
|
||||
// Board rows come from the same endpoint as qk.candidates.list but are cached
|
||||
// MAPPED (kanban cards, not the raw envelope), so they need their own key —
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { Icon } from '../ui/primitives'
|
||||
|
||||
export function CandidateBrowseNav({ browse, onBrowse }) {
|
||||
if (!browse || browse.index < 0 || browse.total < 1) return null
|
||||
const { index, total, prev, next } = browse
|
||||
return (
|
||||
<div className="cw-browse-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm cw-browse-arrow"
|
||||
disabled={!prev}
|
||||
aria-label="Previous candidate"
|
||||
title={prev ? `Previous: ${prev.name}` : 'No previous candidate'}
|
||||
onClick={() => prev && onBrowse(prev.userId)}
|
||||
>
|
||||
<Icon name="chevron-left" />
|
||||
</button>
|
||||
<span className="cw-browse-counter">{index + 1} of {total}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm cw-browse-arrow"
|
||||
disabled={!next}
|
||||
aria-label="Next candidate"
|
||||
title={next ? `Next: ${next.name}` : 'No next candidate'}
|
||||
onClick={() => next && onBrowse(next.userId)}
|
||||
>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,21 +4,87 @@
|
|||
need a real page with a real URL (shareable, refresh-safe). This is a thin
|
||||
shell over CandidateProfile in `variant="page"` mode: the identity shell
|
||||
carries only the userId and the live detail query fills everything else.
|
||||
Opened from Candidates, Talent Pool and the Pipeline board. */
|
||||
Opened from Candidates, Talent Pool and the Pipeline board.
|
||||
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
Previous / Next walk the session queue written when the recruiter opened
|
||||
this profile from a list. A direct URL falls back to the first page of
|
||||
candidates they can already see, so the arrows still do something. */
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { isHiringManager } from '../auth/permissions'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { candidatePath, neighborsOf, readCandidateBrowse, uniqueBrowseEntries } from '../lib/candidateBrowse'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import * as pipelineApi from '../api/pipeline'
|
||||
import CandidateProfile from './CandidateProfile'
|
||||
|
||||
function useCandidateBrowse(userId) {
|
||||
const { user } = useAuth()
|
||||
const manager = isHiringManager(user)
|
||||
const stored = readCandidateBrowse()
|
||||
const fallback = useQuery({
|
||||
queryKey: qk.candidates.browse({ manager }),
|
||||
queryFn: async () => {
|
||||
if (manager) {
|
||||
const res = await candidatesApi.listForManager({ limit: 200, offset: 0 })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return uniqueBrowseEntries(rows.map((row) => ({
|
||||
...row,
|
||||
stage: pipelineApi.STAGE_FROM_STATUS[String(row.application_status || '').toUpperCase()] || null,
|
||||
})))
|
||||
}
|
||||
const res = await candidatesApi.list({ limit: 100, offset: 0 })
|
||||
const rows = Array.isArray(res?.data) ? res.data.map(candidatesApi.toApplicationListView) : []
|
||||
return uniqueBrowseEntries(rows)
|
||||
},
|
||||
enabled: stored.length === 0 && Boolean(userId),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const entries = stored.length ? stored : (fallback.data ?? [])
|
||||
return neighborsOf(entries, userId)
|
||||
}
|
||||
|
||||
export default function CandidatePage() {
|
||||
const { userId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const browse = useCandidateBrowse(userId)
|
||||
const tab = searchParams.get('tab') || undefined
|
||||
|
||||
function goTo(id) {
|
||||
if (!id || String(id) === String(userId)) return
|
||||
navigate(candidatePath(id, tab))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(event) {
|
||||
if (!event.altKey || event.metaKey || event.ctrlKey) return
|
||||
const tag = event.target?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable) return
|
||||
if (event.key === 'ArrowLeft' && browse.prev) {
|
||||
event.preventDefault()
|
||||
goTo(browse.prev.userId)
|
||||
}
|
||||
if (event.key === 'ArrowRight' && browse.next) {
|
||||
event.preventDefault()
|
||||
goTo(browse.next.userId)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [browse.prev, browse.next, userId, tab])
|
||||
|
||||
return (
|
||||
<CandidateProfile
|
||||
key={userId}
|
||||
variant="page"
|
||||
candidate={{ id: userId, userId, name: '' }}
|
||||
browse={browse}
|
||||
onBrowse={goTo}
|
||||
onClose={() => (window.history.length > 1 ? navigate(-1) : navigate('/candidates'))}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import * as pipelineApi from '../api/pipeline'
|
|||
import * as s3Api from '../api/s3'
|
||||
import CandidateFormsTab from './CandidateForms'
|
||||
import CandidateWorkspaceOverview, { CandidateWorkspaceHero } from './CandidateWorkspace'
|
||||
import { CandidateBrowseNav } from './CandidateBrowse'
|
||||
import { PreviousApplications, ReappliedBadge, candidateApplicationsOf } from '../components/ReapplicantHistory'
|
||||
import { fmtDate, fmtTime, toDate } from '../lib/format'
|
||||
import { companies, moneyK, pick } from '../data/seed'
|
||||
|
|
@ -109,18 +110,27 @@ function useProfileWrite({ userId, mutationFn, success, onDone }) {
|
|||
*/
|
||||
export default function CandidateProfile({
|
||||
candidate: c, atsScore = null, recommendation = null, onClose, onAdvance, onToggleFav, onAtsMatch,
|
||||
variant = 'modal',
|
||||
variant = 'modal', browse = null, onBrowse,
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const { can, user } = useAuth()
|
||||
const isManager = isHiringManager(user)
|
||||
const visibleTabs = isManager ? ['Forms', 'Notes'] : TABS
|
||||
const [searchParams] = useSearchParams()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [tab, setTab] = useState(() => tabFromSearch(
|
||||
variant === 'page' ? searchParams.get('tab') : null,
|
||||
visibleTabs,
|
||||
isManager ? 'Forms' : 'Overview',
|
||||
))
|
||||
function changeTab(next) {
|
||||
setTab(next)
|
||||
if (variant !== 'page') return
|
||||
setSearchParams((prev) => {
|
||||
const params = new URLSearchParams(prev)
|
||||
params.set('tab', next)
|
||||
return params
|
||||
}, { replace: true })
|
||||
}
|
||||
const tabId = useId()
|
||||
const [dialog, setDialog] = useState(null)
|
||||
const [stageReason, setStageReason] = useState('')
|
||||
|
|
@ -358,7 +368,7 @@ export default function CandidateProfile({
|
|||
<Tabs
|
||||
idBase={tabId}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
onChange={changeTab}
|
||||
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 }))}
|
||||
/>
|
||||
|
|
@ -366,7 +376,7 @@ export default function CandidateProfile({
|
|||
|
||||
<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}
|
||||
candidate={live || c} stage={stageLabel} nextStage={nextStage} onTab={changeTab} 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}
|
||||
|
|
@ -642,15 +652,16 @@ export default function CandidateProfile({
|
|||
return (
|
||||
<div className="cand-page">
|
||||
<div className="cand-page-bar">
|
||||
<div className="cand-page-crumb">
|
||||
<button className="btn btn-secondary btn-sm" onClick={onClose}>
|
||||
<Icon name="chevron-left" /> Back
|
||||
</button>
|
||||
<div className="cand-page-crumb">
|
||||
Candidates <span>/</span> <strong>{live?.name || c.name || '…'}</strong>
|
||||
<span className="cw-crumb-path">Candidates <span>/</span></span> <strong>{live?.name || c.name || '…'}</strong>
|
||||
</div>
|
||||
<div className="cand-page-actions">
|
||||
{!isManager && <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>}
|
||||
{onBrowse && <CandidateBrowseNav browse={browse} onBrowse={onBrowse} />}
|
||||
</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
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantH
|
|||
import { useFormState } from '../components/AuthLayout'
|
||||
import { persist, useSeedMutation } from '../data/seedQueries'
|
||||
import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '../data/seed'
|
||||
import { openCandidateProfile } from '../lib/candidateBrowse'
|
||||
|
||||
const EMPTY_FILTERS = { account: '', stage: '', band: '' }
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
|
@ -159,11 +160,6 @@ function HiringManagerCandidates() {
|
|||
const [q, setQ] = useState('')
|
||||
const [jobId, setJobId] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const id = location.state?.openCandidate
|
||||
if (id) navigate(`/candidate/${id}`, { replace: true })
|
||||
}, [location.state, navigate])
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: qk.candidates.managerList(),
|
||||
queryFn: async () => {
|
||||
|
|
@ -194,6 +190,11 @@ function HiringManagerCandidates() {
|
|||
})
|
||||
}, [rowsAll, q, jobId])
|
||||
|
||||
useEffect(() => {
|
||||
const id = location.state?.openCandidate
|
||||
if (id) openCandidateProfile(navigate, id, rows.length ? rows : undefined, { replace: true })
|
||||
}, [location.state, navigate, rows])
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
|
|
@ -286,7 +287,7 @@ function HiringManagerCandidates() {
|
|||
? 'No candidates match these filters.'
|
||||
: 'No candidates are allocated to jobs opened from your requisitions yet.'
|
||||
}
|
||||
onRowClick={(r) => r.user_id && navigate(`/candidate/${r.user_id}`)}
|
||||
onRowClick={(r) => r.user_id && openCandidateProfile(navigate, r.user_id, rows)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -382,10 +383,10 @@ function RecruiterCandidates() {
|
|||
// Real candidates get the full profile PAGE; the modal stays only as the
|
||||
// fallback for rows without a user account.
|
||||
const uid = c.userId
|
||||
if (uid) navigate(`/candidate/${uid}`)
|
||||
if (uid) openCandidateProfile(navigate, uid, candidates)
|
||||
else setProfileFor(c)
|
||||
},
|
||||
[qc, navigate],
|
||||
[qc, navigate, candidates],
|
||||
)
|
||||
|
||||
// Deep links from Talent Pool, global search, dashboard…
|
||||
|
|
@ -393,8 +394,8 @@ function RecruiterCandidates() {
|
|||
const st = location.state
|
||||
if (!st) return
|
||||
if (st.openAdd) setAdding(true)
|
||||
if (st.openCandidate) navigate(`/candidate/${st.openCandidate}`, { replace: true })
|
||||
}, [location.state, navigate])
|
||||
if (st.openCandidate) openCandidateProfile(navigate, st.openCandidate, candidates.length ? candidates : undefined, { replace: true })
|
||||
}, [location.state, navigate, candidates])
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const f = filters
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { exportStyledXlsx } from '../lib/exportXlsx'
|
|||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import * as s3Api from '../api/s3'
|
||||
import { openCandidateProfile } from '../lib/candidateBrowse'
|
||||
import { avatarColor, initials as initialsOf } from '../data/seed'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
|
@ -225,7 +226,7 @@ export default function CvBank() {
|
|||
|
||||
async function view(row) {
|
||||
if (!row.isStoredCv) {
|
||||
if (row.userId) navigate(`/candidate/${row.userId}`)
|
||||
if (row.userId) openCandidateProfile(navigate, row.userId, rows)
|
||||
else toast('This applicant has no profile to open', 'info')
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { friendlyAuthError } from '../lib/errors'
|
|||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import * as pipelineApi from '../api/pipeline'
|
||||
import { ReappliedBadge } from '../components/ReapplicantHistory'
|
||||
import { openCandidateProfile } from '../lib/candidateBrowse'
|
||||
|
||||
/* Stage colours reference CSS tokens so the board re-tints with the theme. */
|
||||
export const KANBAN_STAGES = [
|
||||
|
|
@ -276,7 +277,7 @@ export default function Pipeline() {
|
|||
// The profile page keys off users.id, so an application
|
||||
// with no linked account cannot deep-link.
|
||||
if (!c.userId) return
|
||||
navigate(`/candidate/${c.userId}`)
|
||||
openCandidateProfile(navigate, c.userId, candidates)
|
||||
}}
|
||||
>
|
||||
<div className="k-card-top">
|
||||
|
|
|
|||
|
|
@ -1,219 +1,183 @@
|
|||
/* 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 detail is a compact workspace that follows the app theme.
|
||||
Previous / Next live in the page bar. There is no extra candidate list. */
|
||||
.candidate-workspace .content { padding: 0 24px 28px; background: var(--bg); }
|
||||
html[data-theme="dark"] .candidate-workspace .content { background: radial-gradient(ellipse at 50% 0, #0b29304d, transparent 58%), var(--bg); }
|
||||
.candidate-workspace .cand-page { max-width: 1680px; margin-inline: auto; font-size: 13px; min-width: 0; }
|
||||
.candidate-workspace .cand-page-bar { gap: 10px; min-height: 44px; margin: 0; padding-block: 8px; }
|
||||
.candidate-workspace .cand-page-crumb { font-size: 12px; display: flex; align-items: center; min-width: 0; white-space: normal; overflow: visible; }
|
||||
.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 .cand-page-crumb strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.candidate-workspace .cand-page-actions { width: auto; margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
.candidate-workspace .btn, .candidate-dialog .btn { min-height: 32px; padding: 5px 10px; border-radius: 7px; gap: 6px; font-size: 12px; font-weight: 500; box-shadow: none; white-space: nowrap; }
|
||||
.candidate-workspace .btn-primary, .candidate-dialog .btn-primary { font-weight: 650; }
|
||||
.candidate-workspace .btn-sm { min-height: 28px; padding: 4px 8px; font-size: 12px; }
|
||||
.candidate-workspace .btn svg, .candidate-dialog .btn svg { width: 15px; height: 15px; 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; }
|
||||
.candidate-workspace .star-btn.on { color: var(--primary); border-color: var(--primary-border); }
|
||||
.cw-browse-nav { display: flex; align-items: center; gap: 4px; }
|
||||
.cw-browse-arrow { width: 32px; padding: 5px; flex-shrink: 0; }
|
||||
.cw-browse-counter { min-width: 52px; text-align: center; font-size: 12px; color: var(--text-2); white-space: nowrap; }
|
||||
|
||||
.cw-hero { display: flex; gap: 16px; padding: 16px 18px; border: 1px solid var(--border); border-radius: 12px; background: linear-gradient(110deg, var(--bg-elev), var(--bg-sunken) 70%, var(--bg-elev)); }
|
||||
.cw-hero .cw-avatar { width: 56px; height: 56px; font-size: 20px; 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-hero-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.cw-name { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
|
||||
.cw-name h1 { margin: 0; font-size: 22px; line-height: 1.2; letter-spacing: -.4px; font-weight: 650; overflow-wrap: anywhere; }
|
||||
.cw-contact { display: flex; flex-wrap: wrap; gap: 6px 16px; color: var(--text-3); font-size: 12px; }
|
||||
.cw-contact > * { display: inline-flex; align-items: center; gap: 6px; 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-contact .cw-external, .cw-info a { color: var(--info); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.cw-contact svg { width: 14px; height: 14px; flex-shrink: 0; }
|
||||
.cw-hero-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.cw-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 132px), 1fr)); gap: 12px 0; margin-top: 14px; }
|
||||
.cw-hero, .cw-overview, .cw-card, .cw-column { min-width: 0; max-width: 100%; }
|
||||
.cw-fact { display: flex; align-items: center; gap: 10px; min-width: 0; padding: 0 12px; 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-fact > svg { width: 16px; height: 16px; color: var(--text-3); flex-shrink: 0; }
|
||||
.cw-fact span { display: block; color: var(--text-3); font-size: 11px; line-height: 1.3; margin-bottom: 2px; }
|
||||
.cw-fact strong { font-size: 12px; font-weight: 500; line-height: 1.35; overflow-wrap: anywhere; }
|
||||
|
||||
.cw-tabs { min-width: 0; max-width: 100%; }
|
||||
.cw-tabs .tabs { gap: 4px; margin-bottom: 12px; min-width: 0; max-width: 100%; }
|
||||
.cw-tabs .tab { min-height: 42px; padding: 8px 14px; margin: 0; font-size: 12.5px; font-weight: 400; border-bottom-width: 2px; }
|
||||
.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-tabs .tab-count { background: var(--bg-sunken); color: var(--text-2); font-size: 11px; min-width: 16px; }
|
||||
|
||||
.cw-overview { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.65fr) minmax(0, .95fr); gap: 12px; align-items: start; }
|
||||
.cw-column { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
||||
.cw-card { min-width: 0; padding: 14px; border: 1px solid var(--border); border-radius: 10px; background: linear-gradient(120deg, var(--bg-elev), var(--bg-sunken) 90%); }
|
||||
.cw-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
|
||||
.cw-card-head h2 { font-size: 13px; font-weight: 650; letter-spacing: -.1px; line-height: 1.3; margin: 0; }
|
||||
.cw-link { display: inline-flex; align-items: center; gap: 6px; color: var(--primary); text-decoration: underline; text-underline-offset: 3px; font-size: 12px; white-space: nowrap; }
|
||||
.cw-link svg { width: 14px; height: 14px; }
|
||||
.cw-info { display: grid; gap: 10px; margin: 0; }
|
||||
.cw-info > div { display: grid; grid-template-columns: minmax(108px, .9fr) minmax(0, 1.4fr); gap: 8px; line-height: 1.4; font-size: 12px; }
|
||||
.cw-info dt { display: flex; align-items: flex-start; gap: 8px; color: var(--text-3); }
|
||||
.cw-info dt svg { width: 14px; height: 14px; 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-skills { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.cw-skills > span { padding: 4px 8px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-sunken); color: var(--text-2); font-size: 11px; max-width: 100%; overflow-wrap: anywhere; }
|
||||
.cw-table-wrap { overflow: auto; min-width: 0; max-width: 100%; }
|
||||
.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 th { color: var(--text-3); text-transform: uppercase; letter-spacing: .4px; font-size: 10px; font-weight: 500; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); padding: 7px 6px; white-space: nowrap; }
|
||||
.cw-applications td { padding: 10px 6px; border-bottom: 1px solid var(--border); }
|
||||
.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 strong { display: block; font-size: 12px; font-weight: 550; }
|
||||
.cw-applications small { display: block; color: var(--text-3); font-size: 11px; margin-top: 3px; }
|
||||
.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-applications .is-current { background: linear-gradient(90deg, var(--primary-soft), transparent); }
|
||||
.cw-summary { margin: 0; color: var(--text-2); font-size: 12.5px; line-height: 1.65; white-space: pre-line; overflow-wrap: anywhere; }
|
||||
.cw-summary p + p { margin-top: 8px; }
|
||||
.cw-empty { color: var(--text-3); font-size: 12.5px; line-height: 1.6; margin: 0; }
|
||||
.cw-document { display: flex; align-items: center; gap: 10px; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: linear-gradient(100deg, var(--bg-elev), var(--bg-sunken)); min-width: 0; }
|
||||
.cw-document-icon { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 26px; height: 32px; background: linear-gradient(135deg, #ff7575, #df424d); border-radius: 4px; color: #fff; flex-shrink: 0; }
|
||||
.cw-document-icon svg { width: 14px; height: 14px; }
|
||||
.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-name small { display: block; color: var(--text-3); font-size: 11px; margin-top: 2px; }
|
||||
.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-document-list { display: grid; gap: 8px; }
|
||||
.candidate-workspace .cw-icon-label { width: 28px; font-size: 0; gap: 0; }
|
||||
.cw-bottom-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 12px; }
|
||||
.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 { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.cw-rating > span { font-size: 12px; color: var(--primary); }
|
||||
.cw-rating .rating-stars .rs svg { width: 18px; height: 18px; }
|
||||
.cw-rating .rating-stars .rs svg { width: 16px; height: 16px; }
|
||||
.cw-recruiter { display: flex; align-items: center; gap: 10px; }
|
||||
.cw-recruiter .avatar { width: 32px; height: 32px; font-size: 12px; }
|
||||
.cw-recruiter .avatar { width: 28px; height: 28px; font-size: 11px; }
|
||||
.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-recruiter small { display: block; color: var(--text-3); font-size: 11px; margin-top: 2px; }
|
||||
.cw-action-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; }
|
||||
.cw-action-grid .btn { font-size: 12px; justify-content: flex-start; padding: 7px 8px; white-space: normal; text-align: left; }
|
||||
.candidate-workspace .cw-danger, .candidate-dialog .cw-danger { border: 1px solid var(--danger); color: var(--danger); background: var(--danger-soft); }
|
||||
.candidate-workspace .cw-danger:hover:not(:disabled), .candidate-dialog .cw-danger:hover:not(:disabled) { background: var(--danger); color: var(--danger-fg); }
|
||||
.cw-active-application { font-size: 12px; color: var(--text-3); margin: -2px 0 10px; }
|
||||
.cw-status-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); gap: 8px; }
|
||||
.cw-field-label { display: block; color: var(--text-3); font-size: 11px; margin-bottom: 4px; }
|
||||
.cw-status-grid select, .cw-status-value { width: 100%; min-height: 34px; padding: 6px 8px; background: var(--bg-sunken); 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 { position: relative; padding: 0 0 16px 22px; }
|
||||
.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 li::before { content: ''; position: absolute; left: 0; top: 4px; width: 9px; height: 9px; background: var(--info); border: 2px solid var(--bg-elev); border-radius: 50%; z-index: 1; }
|
||||
.cw-activity li:not(:last-child)::after { content: ''; position: absolute; width: 1px; left: 4px; top: 14px; bottom: 2px; background: var(--border); }
|
||||
.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 p { color: var(--text-3); font-size: 12px; line-height: 1.55; margin: 4px 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-screening { display: flex; align-items: center; gap: 14px; }
|
||||
.cw-match { display: grid; justify-items: center; gap: 4px; 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; }
|
||||
.cw-tab-content { padding: 16px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: 10px; }
|
||||
.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); }
|
||||
/* Sidebar is 262px, so these fire at the same content width as a full-bleed 1279/900. */
|
||||
@media (max-width: 1560px) {
|
||||
.candidate-workspace .content { padding-inline: 18px; }
|
||||
.candidate-workspace .topbar { padding-inline: 18px; }
|
||||
.cw-overview { grid-template-columns: minmax(0, 1fr) minmax(0, 1.55fr); }
|
||||
.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-hero-actions { margin-left: auto; }
|
||||
.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: 1100px) {
|
||||
.cw-overview { display: flex; flex-direction: column; }
|
||||
.cw-overview > * { width: 100%; min-width: 0; }
|
||||
.cw-column-actions { order: 0; }
|
||||
.cw-hero { flex-wrap: wrap; }
|
||||
.cw-hero-actions { width: 100%; flex-wrap: wrap; }
|
||||
.cand-page-actions .star-btn { width: 32px; padding: 5px; font-size: 0; gap: 0; }
|
||||
.cand-page-actions .star-btn svg { width: 15px; height: 15px; }
|
||||
}
|
||||
@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 .content { padding: 0 12px 20px; }
|
||||
.candidate-workspace .topbar { padding: 10px 12px; gap: 8px; flex-wrap: wrap; }
|
||||
.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; }
|
||||
.candidate-workspace .cand-page-bar { gap: 8px; padding-block: 6px; flex-wrap: wrap; }
|
||||
.candidate-workspace .cand-page-crumb { flex: 1; min-width: 0; }
|
||||
.candidate-workspace .cand-page-crumb .cw-crumb-path { display: none; }
|
||||
.candidate-workspace .cand-page-actions { width: 100%; margin-left: 0; justify-content: flex-end; flex-wrap: nowrap; }
|
||||
.candidate-workspace .cand-page-actions .btn { flex: 0 0 auto; }
|
||||
.candidate-workspace .cand-page-crumb .btn { flex-shrink: 0; }
|
||||
.cw-browse-nav { gap: 2px; }
|
||||
.cw-browse-arrow { width: 30px; min-height: 30px; padding: 4px; }
|
||||
.cw-browse-counter { min-width: 44px; font-size: 11px; }
|
||||
.cw-hero { padding: 12px; gap: 12px; }
|
||||
.cw-hero .cw-avatar { width: 44px; height: 44px; font-size: 16px; }
|
||||
.cw-name h1 { font-size: 18px; }
|
||||
.cw-hero-actions .btn { flex: 1 1 auto; justify-content: center; }
|
||||
.cw-facts { margin-top: 8px; }
|
||||
.cw-fact { border-left: 0; padding: 8px 0 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-tabs .tab { padding-inline: 12px; min-height: 40px; }
|
||||
.cw-action-grid .btn { min-height: 40px; }
|
||||
.cw-info > div { grid-template-columns: 120px minmax(0, 1fr); }
|
||||
.cw-applications { min-width: 360px; }
|
||||
.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-tab-content { padding: 12px; }
|
||||
.cw-screening { align-items: flex-start; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue