@@ -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})}
}