diff --git a/.gitignore b/.gitignore
index bffe937..58c95ff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,6 +20,10 @@ dist/**/*
.claude/
.audit.js
+# Local macOS launcher (not shared — machine-specific)
+Start.command
+start.command
+
# Backups
.backup-prebrand/
*.bak
diff --git a/frontend/candidate-profile.test.mjs b/frontend/candidate-profile.test.mjs
new file mode 100644
index 0000000..099384c
--- /dev/null
+++ b/frontend/candidate-profile.test.mjs
@@ -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 (320–1536px)')
+ 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() }
diff --git a/frontend/package.json b/frontend/package.json
index f06b864..158c403 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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"
diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx
index 13ba098..b4a364b 100644
--- a/frontend/src/app/AppLayout.jsx
+++ b/frontend/src/app/AppLayout.jsx
@@ -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 (
-
+
Skip to content
-
+ />}
- setNavOpen((o) => !o)} searchRef={searchRef} />
+ setNavOpen((o) => !o)} searchRef={searchRef} candidateView={candidateView} />
{/* Keyed by pathname: navigating away from a crashed screen resets it. */}
@@ -59,14 +60,14 @@ export default function AppLayout() {
-
+ }
setDockOpen(false)} />
diff --git a/frontend/src/app/Topbar.jsx b/frontend/src/app/Topbar.jsx
index 27bc55b..947666f 100644
--- a/frontend/src/app/Topbar.jsx
+++ b/frontend/src/app/Topbar.jsx
@@ -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 (
+ {candidateView &&
+
+ Utopia BrandsHR Portal
+ }
@@ -75,7 +80,7 @@ export default function Topbar({ onOpenNav, searchRef }) {
-
+ }
(window.history.length > 1 ? navigate(-1) : navigate('/candidates'))}
diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx
index cd4ff2e..a3b13bc 100644
--- a/frontend/src/screens/CandidateProfile.jsx
+++ b/frontend/src/screens/CandidateProfile.jsx
@@ -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 ? (
{friendlyAuthError(detail.error, 'Please try again.')}
+
) : !live ? (
This candidate is no longer in the pipeline.
@@ -294,7 +317,7 @@ export default function CandidateProfile({
const body = (
<>
-
+ {variant === 'page' ?
:
@@ -339,19 +362,25 @@ export default function CandidateProfile({
{live?.professional_summary ?
{live.professional_summary}
: null}
) : null}
-
+
}
-
+
({ 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 }))}
/>
-
- {tab === 'Overview' && (guard || (live ? (
+
+ {tab === 'Overview' && (guard || (variant === 'page' ?
setRating.mutate(n)}
+ atsScore={atsScore} recommendation={recommendation}
+ atsAction={canScoreAts && can('candidates.create') ? : null}
+ /> : live ? (
<>
@@ -629,11 +658,27 @@ export default function CandidateProfile({
Candidates / {live?.name || c.name || '…'}
- {actions &&
{actions}
}
-
-
-
{body}
+ {!isManager &&
+
+
}
+ {body}
+ {dialog && live && { if (!moveStage.isPending) setDialog(null) }}
+ >
+ {dialog.type === 'interview' && setDialog(null)} />}
+ {dialog.type === 'note' && setDialog(null)} />}
+ {dialog.type === 'share' && }
+ {dialog.type === 'stage' && }
+ }
)
}
@@ -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 }) {