diff --git a/backend/job/app.py b/backend/job/app.py index 6036a95..d271172 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -337,6 +337,7 @@ async def cv_bank_upload( except HTTPException: raise except Exception as e: + logger.exception("cv-bank upload failed") raise HTTPException(status_code=500,detail=str(e)) @@ -660,6 +661,31 @@ async def fetch_job_posts( raise HTTPException(status_code=500,detail=str(e)) +@router.get("/job/departments/fetch") +async def fetch_job_departments( + active_only: bool = Query(False), + current_user: dict = Depends( + require_permission( + PermissionTag.JOB_BOARD_VIEW, + PermissionTag.CANDIDATES_VIEW, + PermissionTag.TALENT_VIEW, + PermissionTag.JOBS_VIEW, + require_all=False, + ) + ), + session: AsyncSession = Depends(get_session), +): + """Distinct job_posts.department values for filter dropdowns.""" + try: + service=JobPost(session=session) + data=await service.fetch_departments(active_only=active_only) + return JSONResponse(content={"data":data,"total":len(data),"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.get("/jobs/fetch") async def fetch_jobs( search: str | None = Query(None), diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index c7a8a13..b599971 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlmodel import Field, Relationship, SQLModel, select -from linkedin_utils import NO_SLUG, slug_from_url +from linkedin_utils import NO_SLUG, primary_slug_from_text, slug_from_url if TYPE_CHECKING: from inbox.models import Inbox diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index 78e35aa..6765169 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -167,6 +167,24 @@ class JobPosts(SQLModel, table=True): result = await session.execute(statement) return list(result.scalars().all()), total + @classmethod + async def list_departments(cls, session: AsyncSession, *, active_only: bool = False): + """Distinct non-empty departments on non-deleted job posts. + + The column default is "" — those rows are omitted so a dropdown never + offers a blank option. Closed requisitions still contribute unless + `active_only` is set: a past hiring department is a legitimate filter. + """ + statement = select(cls.department).where( + cls.is_deleted == False, # noqa: E712 + cls.department != "", + ) + if active_only: + statement = statement.where(cls.is_active == True) # noqa: E712 + statement = statement.distinct().order_by(cls.department) + result = await session.execute(statement) + return list(result.scalars().all()) + @classmethod async def insert_job_post(cls, session: AsyncSession, fields: dict): row = cls(**fields) diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index c42af25..21f6414 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -183,6 +183,9 @@ class JobPost: ) return [serialize_job_post(r) for r in rows],total + async def fetch_departments(self,active_only=False): + return await JobPosts.list_departments(self.session,active_only=active_only) + async def fetch_jobs(self,search=None,department=None,requisition_status=None, employment_type=None,top=None,skip=0,active_only=True): rows,total=await JobPosts.fetch_job_posts( diff --git a/backend/users/models.py b/backend/users/models.py index 6a075cd..53b8500 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -211,7 +211,12 @@ class Users(SQLModel, table=True): user = cls(**fields) session.add(user) await session.commit() - return await cls.get_user_by_id(session, user.id) + # get_user_by_id is staff-only (role_id != 8). Candidate inserts must + # still return the row — CV bank / Add Candidate call user.id next. + loaded = await cls.get_user_by_id(session, user.id) + if loaded is not None: + return loaded + return await cls.get_user_id(session, user.id) @classmethod async def update_user(cls, session: AsyncSession, record_id: str, fields: dict): diff --git a/frontend/src/api/jobPosts.js b/frontend/src/api/jobPosts.js index 0cdb18a..9a16844 100644 --- a/frontend/src/api/jobPosts.js +++ b/frontend/src/api/jobPosts.js @@ -1,5 +1,18 @@ import { request } from '../lib/apiClient' +/** + * Distinct departments on job_posts — GET /job/departments/fetch. + * + * Vocabulary for candidate / talent department dropdowns. Empty strings (the + * column default) are omitted server-side. `activeOnly` defaults false so a + * closed requisition's department still appears. + */ +export function listDepartments({ activeOnly = false } = {}) { + return request('/job/departments/fetch', { + params: { active_only: activeOnly }, + }) +} + /** * Active job posts — Job Matching hydrates suggestions and the manual picker. * diff --git a/frontend/src/data/seed.js b/frontend/src/data/seed.js index a5a86dc..316e9c7 100644 --- a/frontend/src/data/seed.js +++ b/frontend/src/data/seed.js @@ -63,7 +63,12 @@ export const TODAY = new Date('2026-07-09T09:00:00'); function email(name) { return name.toLowerCase().replace(/[^a-z ]/g, '').replace(/ /g, '.') + '@' + pick(['gmail.com', 'outlook.com', 'proton.me', 'icloud.com']); } function phone() { return '+1 (' + int(200, 989) + ') ' + int(200, 999) + '-' + int(1000, 9999); } function daysAgo(n) { const d = new Date(TODAY); d.setDate(d.getDate() - n); return d; } - function fmtDate(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } + function fmtDate(d) { + if (d == null || d === '') return '' + const date = d instanceof Date ? d : new Date(d) + if (Number.isNaN(date.getTime())) return '' + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + } function fmtShort(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } const avatarColors = ['var(--av-1)', 'var(--av-2)', 'var(--av-3)', 'var(--av-4)', 'var(--av-5)', 'var(--av-6)', 'var(--av-7)', 'var(--av-8)']; diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 4ac7797..641f3eb 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -73,6 +73,7 @@ export const qk = { jobPosts: { all: () => ['jobPosts'], list: (p = {}) => ['jobPosts', 'list', p], + departments: (p = {}) => ['jobPosts', 'departments', p], }, jobs: { all: () => ['jobs'], diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 69eb05f..bbe2068 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -29,7 +29,7 @@ import { useFormState } from '../components/AuthLayout' import { persist, useSeedMutation } from '../data/seedQueries' import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '../data/seed' -const EMPTY_FILTERS = { account: '' } +const EMPTY_FILTERS = { account: '', department: '' } /** Same ladder Talent Pool uses for Advance Stage on the shared profile modal. */ const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'] @@ -137,6 +137,13 @@ export default function Candidates() { queryFn: () => fetchCandidates({ top: pageSize, skip }), }) const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs }) + const deptsQuery = useQuery({ + queryKey: qk.jobPosts.departments(), + queryFn: async () => { + const res = await jobPostsApi.listDepartments() + return Array.isArray(res?.data) ? res.data : [] + }, + }) const candidates = useMemo(() => candidatesQuery.data ?? [], [candidatesQuery.data]) const jobsById = useMemo( () => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])), @@ -352,6 +359,7 @@ export default function Candidates() { with the scoring columns: on a users row every one of them would match nothing and silently empty the table. */} setFilter('account', v)} any="Any Account" options={['Active', 'Unconfirmed']} /> + setFilter('department', v)} any="All Departments" options={deptsQuery.data ?? []} /> )} diff --git a/frontend/src/screens/CvImport.jsx b/frontend/src/screens/CvImport.jsx index acb08e7..07c9699 100644 --- a/frontend/src/screens/CvImport.jsx +++ b/frontend/src/screens/CvImport.jsx @@ -117,8 +117,8 @@ export default function CvImport() { try { const res = await candidatesApi.uploadToCvBank(files[k]) results.push({ rowId: rowIds[k], ok: true, email: res?.data?.candidate_email ?? null }) - } catch { - results.push({ rowId: rowIds[k], ok: false, error: 'FAILED' }) + } catch (err) { + results.push({ rowId: rowIds[k], ok: false, error: friendlyAuthError(err, 'FAILED') }) } } return results @@ -294,7 +294,7 @@ export default function CvImport() { )} {i.status === 'Failed' && ( -
Could not be processed
+
{i.error || 'Could not be processed'}
)}
diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index efed63d..bee6987 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -123,8 +123,8 @@ function outlookListTime(value) { * Nothing matches -> show the first recipient verbatim rather than guess. */ function sourceFrom(messageTo) { - const raw = (messageTo || '').trim() - if (!raw) return { source: 'Unknown', sourceMeta: null } + const raw = typeof messageTo === 'string' ? messageTo.trim() : String(messageTo ?? '').trim() + if (!raw || raw === '[object Object]') return { source: 'Unknown', sourceMeta: null } const flat = raw.toLowerCase().replace(/[^a-z]/g, '') const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, ''))) if (hit) return { source: hit, sourceMeta: sourceMeta[hit] } @@ -304,7 +304,9 @@ async function fetchMessageDetail(recordId) { matchError: row.match_error || '', matchedAt: parseDate(row.matched_at), resumeText: row.resume_text || '', - suggestedIds: (row.suggested_job_post_ids || []).map(String), + suggestedIds: Array.isArray(row.suggested_job_post_ids) + ? row.suggested_job_post_ids.map(String) + : [], suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [], assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, assignedPost: row.assigned_job_post || null, @@ -350,7 +352,9 @@ async function fetchApplications(params) { experience: row.experience, recruiter: row.recruiter, duplicate: Boolean(row.duplicate), - suggestedIds: (row.suggested_job_post_ids || []).map(String), + suggestedIds: Array.isArray(row.suggested_job_post_ids) + ? row.suggested_job_post_ids.map(String) + : [], assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, } }), @@ -497,7 +501,10 @@ function useSetReadAll(toast) { */ function useRowSelection(rows) { const [selectedIds, setSelectedIds] = useState(() => new Set()) - const visibleIds = useMemo(() => rows.map((r) => r.id), [rows]) + const visibleIds = useMemo( + () => (Array.isArray(rows) ? rows.map((r) => r.id) : []), + [rows], + ) useEffect(() => { setSelectedIds((prev) => { @@ -783,17 +790,20 @@ export default function Inbox() { }, [isForms, formSheetsQuery.data, formSheet]) const activeQuery = isForms ? formQuery : applicationsQuery - const inbox = activeQuery.data?.rows ?? [] + const inbox = Array.isArray(activeQuery.data?.rows) ? activeQuery.data.rows : [] const serverCounts = isForms ? (formCountsQuery.data ?? {}) : (countsQuery.data ?? {}) const counts = useMemo( - () => ({ - 'All Applications': serverCounts.all ?? 0, - Unread: serverCounts.unread ?? 0, - Processed: serverCounts.processed ?? 0, - Rejected: serverCounts.rejected ?? 0, - Duplicates: serverCounts.duplicates ?? 0, - }), + () => { + const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0) + return { + 'All Applications': n(serverCounts.all), + Unread: n(serverCounts.unread), + Processed: n(serverCounts.processed), + Rejected: n(serverCounts.rejected), + Duplicates: n(serverCounts.duplicates), + } + }, [serverCounts], ) diff --git a/frontend/src/screens/Matching.jsx b/frontend/src/screens/Matching.jsx index a5d37b6..54a877d 100644 --- a/frontend/src/screens/Matching.jsx +++ b/frontend/src/screens/Matching.jsx @@ -59,8 +59,8 @@ function parseDate(value) { } function sourceFrom(messageTo) { - const raw = (messageTo || '').trim() - if (!raw) return { source: 'Unknown', sourceMeta: null } + const raw = typeof messageTo === 'string' ? messageTo.trim() : String(messageTo ?? '').trim() + if (!raw || raw === '[object Object]') return { source: 'Unknown', sourceMeta: null } const flat = raw.toLowerCase().replace(/[^a-z]/g, '') const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, ''))) if (hit) return { source: hit, sourceMeta: sourceMeta[hit] } @@ -90,7 +90,6 @@ function htmlToText(value) { function mapApplication(row) { const name = row.name || row.email || 'Unknown' - const suggested = Array.isArray(row.suggested_job_post_ids) ? row.suggested_job_post_ids : [] return { id: String(row.id), name, @@ -105,7 +104,9 @@ function mapApplication(row) { resumeStatus: row.resume_status || RESUME_STATUS[row.match_status] || 'Pending', resumeText: row.resume_text || '', filePath: row.file_path || '', - suggestedIds: suggested.map(String), + suggestedIds: Array.isArray(row.suggested_job_post_ids) + ? row.suggested_job_post_ids.map(String) + : [], assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, matchStatus: row.match_status || null, matchSummary: row.match_summary || '', @@ -203,19 +204,19 @@ export default function Matching() { }) const needsCount = useQuery({ - queryKey: qk.mailbox.assignments({ assigned: false }), + queryKey: qk.mailbox.assignments({ assigned: false, count: true }), queryFn: async () => (await inboxApi.listApplications({ assigned: false, top: 1 }))?.total ?? 0, }) const assignedCount = useQuery({ - queryKey: qk.mailbox.assignments({ assigned: true }), + queryKey: qk.mailbox.assignments({ assigned: true, count: true }), queryFn: async () => (await inboxApi.listApplications({ assigned: true, top: 1 }))?.total ?? 0, }) const allCount = useQuery({ - queryKey: qk.mailbox.assignments({}), + queryKey: qk.mailbox.assignments({ count: true }), queryFn: async () => (await inboxApi.listApplications({ top: 1 }))?.total ?? 0, }) const noneCountQuery = useQuery({ - queryKey: qk.mailbox.assignments({ kind: 'none' }), + queryKey: qk.mailbox.assignments({ kind: 'none', count: true }), queryFn: async () => (await inboxApi.listApplications({ assigned: false, noSuggestions: true, top: 1 }))?.total ?? 0, }) diff --git a/frontend/src/screens/TalentPool.jsx b/frontend/src/screens/TalentPool.jsx index dad8657..0e6520f 100644 --- a/frontend/src/screens/TalentPool.jsx +++ b/frontend/src/screens/TalentPool.jsx @@ -44,8 +44,9 @@ import { seedQuery, useSeedMutation } from '../data/seedQueries' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as candidatesApi from '../api/candidates' +import * as jobPostsApi from '../api/jobPosts' import * as pipelineApi from '../api/pipeline' -import { avatarColor, departments, initials as initialsOf } from '../data/seed' +import { avatarColor, initials as initialsOf } from '../data/seed' /** Backend GET /candidate/fetch caps `limit` at 100. */ const PAGE_SIZE_MAX = 100 @@ -145,6 +146,14 @@ export default function TalentPool() { queryKey: qk.candidates.list({ limit: pageSize }), queryFn: () => candidatesApi.list({ limit: pageSize }), }) + const deptsQuery = useQuery({ + queryKey: qk.jobPosts.departments(), + queryFn: async () => { + const res = await jobPostsApi.listDepartments() + return Array.isArray(res?.data) ? res.data : [] + }, + }) + const departments = deptsQuery.data ?? [] const pool = useMemo( () => buildPool(candidatesApi.toRows(query.data), templates), diff --git a/frontend/src/ui/DataTable.jsx b/frontend/src/ui/DataTable.jsx index d29d8b7..750bdbc 100644 --- a/frontend/src/ui/DataTable.jsx +++ b/frontend/src/ui/DataTable.jsx @@ -166,7 +166,7 @@ export function Pagination({
- {pageButtons.map((p, i) => + {(pageButtons ?? []).map((p, i) => p === '…' ? ( ) : ( diff --git a/frontend/src/ui/SuggestedRoles.jsx b/frontend/src/ui/SuggestedRoles.jsx index 4285bea..567c267 100644 --- a/frontend/src/ui/SuggestedRoles.jsx +++ b/frontend/src/ui/SuggestedRoles.jsx @@ -18,11 +18,18 @@ import * as jobPostsApi from '../api/jobPosts' /** Requirement chip lights green when the resume text contains it (client-side). */ export function reqInResume(req, resumeText) { if (!req || !resumeText) return false - const needle = String(req).trim().toLowerCase() + const needle = (typeof req === 'string' ? req : (req?.name || req?.label || '')).trim().toLowerCase() if (!needle) return false return resumeText.toLowerCase().includes(needle) } +function reqLabel(req) { + if (req == null) return '' + if (typeof req === 'string' || typeof req === 'number') return String(req) + if (typeof req === 'object') return String(req.name || req.label || req.skill || '') + return String(req) +} + export function JobCard({ post, rank, selected, onSelect, resumeText, manual, badge }) { const unavailable = Boolean(post?.unavailable) || !post?.title const title = post?.title || 'Unavailable' @@ -70,20 +77,22 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual, ba {selected && }
{meta &&
{meta}
} - {!unavailable && (post.requirements || []).length > 0 && ( + {!unavailable && Array.isArray(post.requirements) && post.requirements.length > 0 && (
- {(post.requirements || []).slice(0, 8).map((req) => { + {post.requirements.slice(0, 8).map((req, i) => { + const label = reqLabel(req) + if (!label) return null const hit = reqInResume(req, resumeText) return ( - {req} + {label} ) })} diff --git a/frontend/src/ui/Tabs.jsx b/frontend/src/ui/Tabs.jsx index 2eb0764..cadaad2 100644 --- a/frontend/src/ui/Tabs.jsx +++ b/frontend/src/ui/Tabs.jsx @@ -51,7 +51,9 @@ export function Tabs({ tabs, value, onChange, className = 'tabs', idBase }) { className={`tab${active ? ' active' : ''}`} onClick={() => onChange(key)} > - {label}{t.count != null && {t.count}} + {label}{t.count != null && typeof t.count !== 'object' && ( + {t.count} + )} ) })}