From 45d82feeea69a2cceb1a99974d4eedb366321f2d Mon Sep 17 00:00:00 2001 From: sheheryarsoomro12 Date: Thu, 10 Sep 2026 14:16:57 +0500 Subject: [PATCH 01/14] Update .gitignore to include local macOS launcher files --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) 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 From 74bae5330e9d818aa33a1decb16b65f3907c86b2 Mon Sep 17 00:00:00 2001 From: sheheryarsoomro12 Date: Thu, 10 Sep 2026 15:09:19 +0500 Subject: [PATCH 02/14] Add candidate workspace UI for the profile tab page. Give the candidate route a dedicated dark workspace layout with overview actions, while keeping the drawer profile flow unchanged. Co-authored-by: Cursor --- frontend/candidate-profile.test.mjs | 177 ++++++++++++++ frontend/package.json | 1 + frontend/src/app/AppLayout.jsx | 13 +- frontend/src/app/Topbar.jsx | 11 +- frontend/src/screens/CandidatePage.jsx | 1 + frontend/src/screens/CandidateProfile.jsx | 85 +++++-- frontend/src/screens/CandidateWorkspace.jsx | 247 ++++++++++++++++++++ frontend/src/styles/candidate-workspace.css | 221 ++++++++++++++++++ frontend/src/ui/primitives.jsx | 2 + 9 files changed, 730 insertions(+), 28 deletions(-) create mode 100644 frontend/candidate-profile.test.mjs create mode 100644 frontend/src/screens/CandidateWorkspace.jsx create mode 100644 frontend/src/styles/candidate-workspace.css 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 918db3f..23632bc 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 @@ -208,6 +213,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 @@ -232,6 +254,7 @@ export default function CandidateProfile({ ) : detail.isError ? ( {friendlyAuthError(detail.error, 'Please try again.')} + ) : !live ? ( This candidate is no longer in the pipeline. @@ -284,7 +307,7 @@ export default function CandidateProfile({ const body = ( <> -
+ {variant === 'page' ? :
@@ -329,19 +352,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 ? ( <>
@@ -619,11 +648,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}

+
+
+ +
+
+ +
+ {{n.initials}} +
{{n.author}}
{{n.text}}
{{n.when}}
+
+
+
+
+ + + +
+
Profile viewed by Meera Khan
1h ago
+
Email sent: Interview invitation
1 day ago
+
Assessment score updated to 82%
2 days ago
+
+
+ + +
+
    +
  1. Application received

    Applied via Careers page

  2. +
  3. AI screening completed

    Match score: 82%

  4. +
  5. Interview scheduled

    Technical — System Design

  6. +
+
+
+ + +
+ +
Stage changed
Screening → Interview
Meera Khan · 10:14 AM
+
Feedback submitted
by Farhan Ali
+
+
+ +
+ +
+ + + + From 1f4ec4d4512afa009d2a35c9011e849371f0f81f Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Fri, 11 Sep 2026 16:51:36 +0500 Subject: [PATCH 05/14] Stop candidate profiles crashing on production payload shapes. Co-authored-by: Cursor --- frontend/src/App.jsx | 18 +++++++++++++- frontend/src/api/s3.js | 22 ++++++++++++++--- frontend/src/components/ErrorBoundary.jsx | 5 ++++ frontend/src/screens/CandidateProfile.jsx | 10 ++++---- frontend/src/screens/CandidateWorkspace.jsx | 26 ++++++++++++++------- 5 files changed, 64 insertions(+), 17 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9c52297..3956eba 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,5 @@ import { lazy } from 'react' -import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +import { BrowserRouter, Navigate, Route, Routes, useParams, useSearchParams } from 'react-router-dom' import AuthProvider from './auth/AuthProvider' import RequireAuth from './auth/RequireAuth' @@ -47,6 +47,14 @@ const SCREENS = { // Detail pages live outside the ROUTES table (no sidebar entry, parameterized path). const CandidatePage = lazy(() => import('./screens/CandidatePage')) +function LegacyCandidateRedirect() { + const { userId } = useParams() + const [params] = useSearchParams() + const tab = params.get('tab') + const to = `/candidate/${encodeURIComponent(userId)}` + return +} + export default function App() { return ( @@ -95,6 +103,14 @@ export default function App() { } /> + + + + } + /> } /> diff --git a/frontend/src/api/s3.js b/frontend/src/api/s3.js index d77043d..b0f0579 100644 --- a/frontend/src/api/s3.js +++ b/frontend/src/api/s3.js @@ -12,9 +12,17 @@ export function openUrl(key, { expiresIn } = {}) { }) } +function asList(value) { + return Array.isArray(value) ? value : [] +} + /** First comma-separated stored path — inbox_messages.file_path can list several. */ export function firstKey(filePath) { - return (filePath || '').split(',')[0].trim() || null + if (Array.isArray(filePath)) return firstKey(filePath[0]) + if (filePath && typeof filePath === 'object') { + return firstKey(filePath.url || filePath.path || filePath.key) + } + return String(filePath || '').split(',')[0].trim() || null } /** S3 object address (virtual-hosted URL) or record-scoped key Email|Manual|Form|Temp/... */ @@ -37,9 +45,17 @@ export function canOpen(filePath) { /** First usable S3/http ref on a candidate or inbox payload. */ export function resumeKeyFrom(item) { if (!item) return null - const fromFiles = (item.files || []).map((f) => f.url).find(Boolean) + if (typeof item.files === 'string') { + const key = firstKey(item.files) + if (key) return key + } + const fromFiles = asList(item.files).map((f) => (typeof f === 'string' ? f : f?.url || f?.path)).find(Boolean) if (fromFiles) return firstKey(fromFiles) - const fromDocs = (item.documents || []).map((d) => d.path).find(Boolean) + if (typeof item.documents === 'string') { + const key = firstKey(item.documents) + if (key) return key + } + const fromDocs = asList(item.documents).map((d) => (typeof d === 'string' ? d : d?.path || d?.url)).find(Boolean) if (fromDocs) return firstKey(fromDocs) return firstKey(item.file_path || item.filePath) } diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx index 226b5b5..d33eebe 100644 --- a/frontend/src/components/ErrorBoundary.jsx +++ b/frontend/src/components/ErrorBoundary.jsx @@ -25,6 +25,11 @@ export default class ErrorBoundary extends Component { This screen hit an unexpected error. The rest of the app is fine — try again, or head back to the dashboard. + {this.state.error?.message ? ( +
+ {this.state.error.message} +
+ ) : null}
Skills
-
{c.skills.map((s) => {s})}
+
{(Array.isArray(c.skills) ? c.skills : []).map((s) => {s})}
)))} diff --git a/frontend/src/screens/CandidateWorkspace.jsx b/frontend/src/screens/CandidateWorkspace.jsx index ee9e964..e164ce2 100644 --- a/frontend/src/screens/CandidateWorkspace.jsx +++ b/frontend/src/screens/CandidateWorkspace.jsx @@ -17,7 +17,16 @@ const display = (value) => value === 0 ? '0' : value || '—' const experience = (value) => value == null || value === '' ? '—' : Number.isFinite(Number(value)) ? `${value} years` : value const profileLocation = (candidate) => candidate.location || candidate.city || candidate.candidate_city const appliedRole = (candidate) => candidate.job_title || candidate.assigned_job_post?.title || candidate.jobTitle -const profileSkills = (candidate) => [...new Set((candidate.skills?.length ? candidate.skills : candidate.matched_keywords || []).filter((skill) => typeof skill === 'string' && skill.trim()))] +function asList(value) { + if (Array.isArray(value)) return value.filter((item) => item != null) + if (typeof value === 'string' && value.trim()) return value.split(/[,;]/).map((item) => item.trim()).filter(Boolean) + return [] +} +const profileSkills = (candidate) => { + const skills = asList(candidate?.skills) + const keywords = asList(candidate?.matched_keywords) + return [...new Set((skills.length ? skills : keywords).filter((skill) => typeof skill === 'string' && skill.trim()))] +} function externalUrl(value) { try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) ? url.href : null } catch { return null } @@ -151,13 +160,14 @@ function Applications({ candidate, stage }) { function DocumentRow({ document, index, candidate, resume = false }) { const download = useDownload(candidate) - const name = document.name || 'Candidate document' + const path = typeof document === 'string' ? document : document?.path + const name = (typeof document === 'string' ? document : document?.name) || 'Candidate document' const ext = name.includes('.') ? name.split('.').pop().toUpperCase().slice(0, 4) : 'FILE' return
{ext}
{name}{ext}{resume && candidate.applied ? ` · ${fmtDate(candidate.applied)}` : ' · Attached document'}
- + {(candidate.inbox_id || candidate.manual_upload_candidate_id) && }
@@ -165,9 +175,9 @@ function DocumentRow({ document, index, candidate, resume = false }) { function RecentActivity({ candidate, onTab }) { const events = [ - ...(candidate.activity || []).map((item) => ({ title: item.activity_type || 'Activity recorded', detail: item.description || item.activity_status, date: item.activity_date, author: item.created_by_name })), - ...(candidate.notes || []).map((item) => ({ title: 'Internal note added', detail: item.note, date: item.created_at, author: item.created_by_name })), - ...(candidate.interviews || []).map((item) => ({ title: item.interview_type || 'Interview', detail: item.interview_status, date: item.interview_date })), + ...asList(candidate.activity).map((item) => ({ title: item.activity_type || 'Activity recorded', detail: item.description || item.activity_status, date: item.activity_date, author: item.created_by_name })), + ...asList(candidate.notes).map((item) => ({ title: 'Internal note added', detail: item.note, date: item.created_at, author: item.created_by_name })), + ...asList(candidate.interviews).map((item) => ({ title: item.interview_type || 'Interview', detail: item.interview_status, date: item.interview_date })), ...(candidate.matched_at ? [{ title: 'Screening completed', detail: candidate.match_summary || candidate.match_status, date: candidate.matched_at }] : []), ...(candidate.applied ? [{ title: 'Application received', detail: candidate.source ? `Applied via ${candidate.source}` : appliedRole(candidate), date: candidate.applied }] : []), ].sort((a, b) => (toDate(b.date)?.getTime() || 0) - (toDate(a.date)?.getTime() || 0)).slice(0, 4) @@ -183,7 +193,7 @@ export default function CandidateWorkspaceOverview({ candidate, stage, nextStage const { can } = useAuth() const { toast } = useToast() const skills = profileSkills(candidate) - const documents = candidate.documents || [] + const documents = asList(candidate.documents) const canMove = can('pipeline.edit') && Boolean(candidate.inbox_id || candidate.manual_upload_candidate_id) const isClosed = ['Rejected', 'Hired'].includes(stage) const status = isClosed ? 'Closed' : stage === 'On Hold' ? 'On hold' : 'In progress' @@ -224,7 +234,7 @@ export default function CandidateWorkspaceOverview({ candidate, stage, nextStage
{candidate.match_summary &&

{candidate.match_summary}

}{candidate.match_reasoning &&

{candidate.match_reasoning}

}{score == null && !candidate.match_summary && !candidate.match_reasoning &&

Compare this candidate’s resume with the assigned role.

}
} - {candidate.job_posts?.length > 0 &&
{candidate.job_posts.map((job) => {job.title})}
} + {asList(candidate.job_posts).length > 0 &&
{asList(candidate.job_posts).map((job) => {job.title || job.id})}
}