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 { 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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 })}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
)))}
|
)))}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 candidate’s 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 candidate’s 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">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue