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 { 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 <Navigate to={tab ? `${to}?tab=${encodeURIComponent(tab)}` : to} replace />
}
export default function App() {
return (
<BrowserRouter>
@ -95,6 +103,14 @@ export default function App() {
</RequireAuth>
}
/>
<Route
path="/candidates/:userId"
element={
<RequireAuth permission="candidates.view">
<LegacyCandidateRedirect />
</RequireAuth>
}
/>
</Route>
<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. */
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)
}

View File

@ -25,6 +25,11 @@ export default class ErrorBoundary extends Component {
<EmptyState icon="alert" title="Something went wrong">
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 ? (
<div className="text-muted" style={{ marginTop: 8, fontSize: 12 }}>
{this.state.error.message}
</div>
) : null}
</EmptyState>
<div style={{ textAlign: 'center' }}>
<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.
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
// flattens every application the candidate owns; writes land on the first,
@ -256,7 +256,7 @@ export default function CandidateProfile({
const counts = live && {
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,
Activity: live.activity?.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 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>
</>
)}
@ -471,7 +471,7 @@ export default function CandidateProfile({
<Info label="Rating" val={`${c.rating} / 5.0`} />
</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 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 <div className="cw-document">
<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-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>}
</div>
</div>
@ -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
<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>
</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>
<aside className="cw-column cw-column-actions" aria-label="Candidate actions and activity">
<WorkspaceCard title="Quick Actions"><div className="cw-action-grid">