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' &&
event.target.select()} />

Copy this link to share with a member of your hiring team.

} + {dialog.type === 'stage' &&
{ event.preventDefault(); if (!moveStage.isPending) moveStage.mutate() }}> +

{live.job_title || 'Current application'} · Currently {stageLabel}

+
+