Compare commits

...

3 Commits

Author SHA1 Message Date
ahmed.mujtaba 756023e304 Merge pull request 'MERGE_NEW_UI' (#92) from MERGE_NEW_UI into main
Deploy to S3 / deploy (push) Successful in 15m16s Details
Reviewed-on: #92
2026-09-11 13:41:41 +00:00
ahmed.mujtaba 331a4cb0fc Merge branch 'main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into MERGE_NEW_UI 2026-09-11 18:38:56 +05:00
Talha Ahmed 1f4ec4d451 Stop candidate profiles crashing on production payload shapes.
Deploy to S3 / deploy (push) Successful in 11m32s Details
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 16:51:36 +05:00
5 changed files with 64 additions and 17 deletions

View File

@ -1,5 +1,5 @@
import { lazy } from 'react' 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 AuthProvider from './auth/AuthProvider'
import RequireAuth from './auth/RequireAuth' import RequireAuth from './auth/RequireAuth'
@ -47,6 +47,14 @@ const SCREENS = {
// Detail pages live outside the ROUTES table (no sidebar entry, parameterized path). // Detail pages live outside the ROUTES table (no sidebar entry, parameterized path).
const CandidatePage = lazy(() => import('./screens/CandidatePage')) 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 <Navigate to={tab ? `${to}?tab=${encodeURIComponent(tab)}` : to} replace />
}
export default function App() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
@ -95,6 +103,14 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
<Route
path="/candidates/:userId"
element={
<RequireAuth permission="candidates.view">
<LegacyCandidateRedirect />
</RequireAuth>
}
/>
</Route> </Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />

View File

@ -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. */ /** First comma-separated stored path — inbox_messages.file_path can list several. */
export function firstKey(filePath) { 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/... */ /** 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. */ /** First usable S3/http ref on a candidate or inbox payload. */
export function resumeKeyFrom(item) { export function resumeKeyFrom(item) {
if (!item) return null 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) 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) if (fromDocs) return firstKey(fromDocs)
return firstKey(item.file_path || item.filePath) return firstKey(item.file_path || item.filePath)
} }

View File

@ -25,6 +25,11 @@ export default class ErrorBoundary extends Component {
<EmptyState icon="alert" title="Something went wrong"> <EmptyState icon="alert" title="Something went wrong">
This screen hit an unexpected error. The rest of the app is fine This screen hit an unexpected error. The rest of the app is fine
try again, or head back to the dashboard. try again, or head back to the dashboard.
{this.state.error?.message ? (
<div className="text-muted" style={{ marginTop: 8, fontSize: 12 }}>
{this.state.error.message}
</div>
) : null}
</EmptyState> </EmptyState>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<button className="btn btn-secondary" onClick={() => this.setState({ error: null })}> <button className="btn btn-secondary" onClick={() => this.setState({ error: null })}>

View File

@ -145,7 +145,7 @@ export default function CandidateProfile({
// employer" changed every repaint. Fixed per candidate. // employer" changed every repaint. Fixed per candidate.
const priorCompany = useMemo(() => pick(companies), []) const priorCompany = useMemo(() => pick(companies), [])
const candidateInterviews = interviews.filter((i) => i.candidateId === c.id) const candidateInterviews = (Array.isArray(interviews) ? interviews : []).filter((i) => i.candidateId === c.id)
// The application row interviews/activity/feedback attach to. Detail mode // The application row interviews/activity/feedback attach to. Detail mode
// flattens every application the candidate owns; writes land on the first, // flattens every application the candidate owns; writes land on the first,
@ -256,7 +256,7 @@ export default function CandidateProfile({
const counts = live && { const counts = live && {
Interview: live.interviews?.length ?? 0, Interview: live.interviews?.length ?? 0,
Forms: (formsQuery.data?.data ?? []).filter((r) => r.form_type !== 'requisition').length, Forms: (Array.isArray(formsQuery.data?.data) ? formsQuery.data.data : []).filter((r) => r.form_type !== 'requisition').length,
Notes: live.notes?.length ?? 0, Notes: live.notes?.length ?? 0,
Activity: live.activity?.length ?? 0, Activity: live.activity?.length ?? 0,
Documents: live.documents?.length ?? 0, Documents: live.documents?.length ?? 0,
@ -445,11 +445,11 @@ export default function CandidateProfile({
</> </>
)} )}
{live.job_posts?.length > 0 && ( {Array.isArray(live.job_posts) && live.job_posts.length > 0 && (
<> <>
<div style={LABEL}>Suggested Roles</div> <div style={LABEL}>Suggested Roles</div>
<div className="k-tags"> <div className="k-tags">
{live.job_posts.map((j) => <span className="tag" key={j.id}>{j.title}</span>)} {live.job_posts.filter(Boolean).map((j) => <span className="tag" key={j.id || j.title}>{j.title}</span>)}
</div> </div>
</> </>
)} )}
@ -471,7 +471,7 @@ export default function CandidateProfile({
<Info label="Rating" val={`${c.rating} / 5.0`} /> <Info label="Rating" val={`${c.rating} / 5.0`} />
</div> </div>
<div style={LABEL}>Skills</div> <div style={LABEL}>Skills</div>
<div className="k-tags">{c.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div> <div className="k-tags">{(Array.isArray(c.skills) ? c.skills : []).map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</> </>
)))} )))}

View File

@ -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 experience = (value) => value == null || value === '' ? '—' : Number.isFinite(Number(value)) ? `${value} years` : value
const profileLocation = (candidate) => candidate.location || candidate.city || candidate.candidate_city const profileLocation = (candidate) => candidate.location || candidate.city || candidate.candidate_city
const appliedRole = (candidate) => candidate.job_title || candidate.assigned_job_post?.title || candidate.jobTitle 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) { function externalUrl(value) {
try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) ? url.href : null } catch { return null } 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 }) { function DocumentRow({ document, index, candidate, resume = false }) {
const download = useDownload(candidate) 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' const ext = name.includes('.') ? name.split('.').pop().toUpperCase().slice(0, 4) : 'FILE'
return <div className="cw-document"> return <div className="cw-document">
<span className="cw-document-icon"><Icon name="file" /><small>{ext}</small></span> <span className="cw-document-icon"><Icon name="file" /><small>{ext}</small></span>
<div className="cw-document-name"><strong title={name}>{name}</strong><small>{ext}{resume && candidate.applied ? ` · ${fmtDate(candidate.applied)}` : ' · Attached document'}</small></div> <div className="cw-document-name"><strong title={name}>{name}</strong><small>{ext}{resume && candidate.applied ? ` · ${fmtDate(candidate.applied)}` : ' · Attached document'}</small></div>
<div className="cw-document-actions"> <div className="cw-document-actions">
<OpenResumeButton filePath={document.path} label={resume ? 'Preview' : `Preview ${name}`} icon="eye" className={resume ? 'btn btn-secondary btn-sm' : 'btn btn-secondary btn-sm cw-icon-label'} /> <OpenResumeButton filePath={path} label={resume ? 'Preview' : `Preview ${name}`} icon="eye" className={resume ? 'btn btn-secondary btn-sm' : 'btn btn-secondary btn-sm cw-icon-label'} />
{(candidate.inbox_id || candidate.manual_upload_candidate_id) && <button className="btn btn-secondary btn-sm" disabled={download.isPending} aria-label={`Download ${name}`} title={`Download ${name}`} onClick={() => download.mutate({ index, filename: name })}><Icon name="download" />{resume && <span>{download.isPending ? 'Downloading…' : 'Download'}</span>}</button>} {(candidate.inbox_id || candidate.manual_upload_candidate_id) && <button className="btn btn-secondary btn-sm" disabled={download.isPending} aria-label={`Download ${name}`} title={`Download ${name}`} onClick={() => download.mutate({ index, filename: name })}><Icon name="download" />{resume && <span>{download.isPending ? 'Downloading…' : 'Download'}</span>}</button>}
</div> </div>
</div> </div>
@ -165,9 +175,9 @@ function DocumentRow({ document, index, candidate, resume = false }) {
function RecentActivity({ candidate, onTab }) { function RecentActivity({ candidate, onTab }) {
const events = [ 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 })), ...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 })),
...(candidate.notes || []).map((item) => ({ title: 'Internal note added', detail: item.note, date: item.created_at, 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 })),
...(candidate.interviews || []).map((item) => ({ title: item.interview_type || 'Interview', detail: item.interview_status, date: item.interview_date })), ...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.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 }] : []), ...(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) ].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 { can } = useAuth()
const { toast } = useToast() const { toast } = useToast()
const skills = profileSkills(candidate) 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 canMove = can('pipeline.edit') && Boolean(candidate.inbox_id || candidate.manual_upload_candidate_id)
const isClosed = ['Rejected', 'Hired'].includes(stage) const isClosed = ['Rejected', 'Hired'].includes(stage)
const status = isClosed ? 'Closed' : stage === 'On Hold' ? 'On hold' : 'In progress' const status = isClosed ? 'Closed' : stage === 'On Hold' ? 'On hold' : 'In progress'
@ -224,7 +234,7 @@ export default function CandidateWorkspaceOverview({ candidate, stage, nextStage
<div className="cw-summary">{candidate.match_summary && <p>{candidate.match_summary}</p>}{candidate.match_reasoning && <p>{candidate.match_reasoning}</p>}{score == null && !candidate.match_summary && !candidate.match_reasoning && <p>Compare this candidates resume with the assigned role.</p>}</div> <div className="cw-summary">{candidate.match_summary && <p>{candidate.match_summary}</p>}{candidate.match_reasoning && <p>{candidate.match_reasoning}</p>}{score == null && !candidate.match_summary && !candidate.match_reasoning && <p>Compare this candidates resume with the assigned role.</p>}</div>
</div> </div>
</WorkspaceCard>} </WorkspaceCard>}
{candidate.job_posts?.length > 0 && <WorkspaceCard title="Suggested Roles"><div className="cw-skills">{candidate.job_posts.map((job) => <span key={job.id}>{job.title}</span>)}</div></WorkspaceCard>} {asList(candidate.job_posts).length > 0 && <WorkspaceCard title="Suggested Roles"><div className="cw-skills">{asList(candidate.job_posts).map((job) => <span key={job.id || job.title}>{job.title || job.id}</span>)}</div></WorkspaceCard>}
</div> </div>
<aside className="cw-column cw-column-actions" aria-label="Candidate actions and activity"> <aside className="cw-column cw-column-actions" aria-label="Candidate actions and activity">
<WorkspaceCard title="Quick Actions"><div className="cw-action-grid"> <WorkspaceCard title="Quick Actions"><div className="cw-action-grid">