Merge branch 'main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into MERGE_NEW_UI
commit
331a4cb0fc
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)))}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 candidate’s 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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue