diff --git a/backend/inbox/models.py b/backend/inbox/models.py index d63be73..d1e711c 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -50,7 +50,12 @@ class Inbox(SQLModel, table=True): ) @classmethod - async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None,limit:int=10,offset:int=0): + def _candidate_search_filter(cls, search: str): + pattern = f"%{search}%" + return or_(Users.name.ilike(pattern), Users.email.ilike(pattern)) + + @classmethod + async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None,limit:int=10,offset:int=0,search:str|None=None): try: qry = ( select(cls) @@ -58,11 +63,12 @@ class Inbox(SQLModel, table=True): .join(Users, cls.user_id == Users.id) .join(Roles, Users.role_id == Roles.id) .where(Roles.role_name == EnumRoles.CANDIDATE.value) - .limit(limit) - .offset(offset) ) if user_id: qry = qry.where(cls.user_id == user_id) + if search: + qry = qry.where(cls._candidate_search_filter(search)) + qry = qry.limit(limit).offset(offset) result = await session.execute(qry) rows = result.scalars().all() if user_id and len(rows) == 1: @@ -71,6 +77,26 @@ class Inbox(SQLModel, table=True): except Exception as e: raise HTTPException(status_code=500,detail=str(e)) + @classmethod + async def count_candidate_profiles(cls,session:AsyncSession,user_id:uuid.UUID|None=None,search:str|None=None): + """Result-set size for the same predicate get_candidate_profile pages over.""" + try: + qry = ( + select(func.count()) + .select_from(cls) + .join(Users, cls.user_id == Users.id) + .join(Roles, Users.role_id == Roles.id) + .where(Roles.role_name == EnumRoles.CANDIDATE.value) + ) + if user_id: + qry = qry.where(cls.user_id == user_id) + if search: + qry = qry.where(cls._candidate_search_filter(search)) + result = await session.execute(qry) + return result.scalar_one() + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + class Inbox_Alerts(SQLModel, table=True): __tablename__ = "inbox_alerts" diff --git a/backend/job/app.py b/backend/job/app.py index 3d95117..2477bab 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -108,13 +108,16 @@ async def fetch_candidate( user_id:str=Query(None), limit:int=Query(10), offset:int=Query(0), + search:str=Query(None), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), session: AsyncSession = Depends(get_session), ): try: service=CandidateView(session=session) - data=await service.get_candidate(user_id=user_id,limit=limit,offset=offset) - total=len(data) if isinstance(data,list) else 1 + data=await service.get_candidate(user_id=user_id,limit=limit,offset=offset,search=search) + # total is the RESULT-SET size, not len(data) — a pager cannot be driven + # off the page length. By id stays 1, per the house envelope. + total=await service.count_candidates(user_id=user_id,search=search) if isinstance(data,list) else 1 return JSONResponse(content={"data":data,"total":total,"status_code":200}) except HTTPException: raise diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 6f430c7..abdcca3 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -75,15 +75,23 @@ class CandidateView: def __init__(self,session:AsyncSession): self.session=session - async def get_candidate(self,user_id=None,limit=10,offset=0): + async def get_candidate(self,user_id=None,limit=10,offset=0,search=None): try: - rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=limit,offset=offset) + rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=limit,offset=offset,search=search) return await self.attach_job_posts(rows) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500,detail=str(e)) + async def count_candidates(self,user_id=None,search=None): + try: + return await Inbox.count_candidate_profiles(session=self.session,user_id=user_id,search=search) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + async def get_job_post_by_id(self,record_id,data=None): """Load full job_posts row and optionally append it onto a candidate payload.""" try: diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js new file mode 100644 index 0000000..6c30a9a --- /dev/null +++ b/frontend/src/api/candidates.js @@ -0,0 +1,32 @@ +import { request } from '../lib/apiClient' + +/** + * Candidate profiles — the `inbox -> users -> roles` join, restricted server-side + * to role_name == CANDIDATE (backend/inbox/models.py:get_candidate_profile). + * + * Permissioned with require_permission(CANDIDATES_VIEW), so a caller without the + * tag gets a 403. + * + * `search` is an ilike over users.name / users.email only — it does NOT reach + * the résumé text or the suggested job titles. + */ +export function list({ search, limit, offset } = {}) { + return request('/candidate/fetch', { params: { search, limit, offset } }) +} + +/** + * One candidate by users.id. + * + * NOTE the asymmetric response: get_candidate_profile returns a BARE OBJECT + * rather than a one-element list when user_id matches exactly one row + * (backend/inbox/models.py:68-70). Callers must normalise — see toRows(). + */ +export function getByUserId(userId) { + return request('/candidate/fetch', { params: { user_id: userId } }) +} + +/** `data` is a list on the list path and a bare object on the by-id path. */ +export function toRows(res) { + if (Array.isArray(res?.data)) return res.data + return res?.data ? [res.data] : [] +} diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 2a4d330..9e3ea6b 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -16,6 +16,11 @@ export const qk = { permissions: () => ['roles', 'permissions'], tags: () => ['roles', 'permission-tags'], }, + candidates: { + all: () => ['candidates'], + list: (p = {}) => ['candidates', 'list', p], + detail: (userId) => ['candidates', 'detail', userId], + }, mailbox: { all: () => ['mailbox'], messages: () => ['mailbox', 'messages'], diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 047b1bb..ab4f83c 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -463,7 +463,8 @@ function Facet({ label, value, onChange, any, options }) { ) } -function AtsMatch({ candidate: c, onClose, onProfile }) { +/** Exported so TalentPool's profile modal can open the same ATS breakdown. */ +export function AtsMatch({ candidate: c, onClose, onProfile }) { const sub = c.subScores const recCls = c.recommendation === 'Strong Match' ? 'recc-strong' : c.recommendation === 'Potential Match' ? 'recc-potential' : 'recc-weak' diff --git a/frontend/src/screens/TalentPool.jsx b/frontend/src/screens/TalentPool.jsx index b10839b..0d69a50 100644 --- a/frontend/src/screens/TalentPool.jsx +++ b/frontend/src/screens/TalentPool.jsx @@ -1,23 +1,116 @@ +/* ============================================================ + Talent Pool — the prototype's card grid, now fed by GET /candidate/fetch. + + The layout, the toolbar, the card and the 8-tab profile modal are the + originals, unchanged. Only the data source moved. + + The endpoint returns name, email, experience, application_status and the + suggested job title. It has no aiScore, skills, currentCompany, source or + department — the agent writes a verdict and prose, not a score, and no + résumé-derived skills are persisted. Each record is therefore OVERLAID on a + seed candidate: real values win, seed fills the rest, so the card renders + exactly as it always did. + + Clicking a card opens CandidateProfile in place. It used to deep-link into + /candidates, which stopped resolving once the ids became real user_ids. + ============================================================ */ + import { useMemo, useState } from 'react' -import { useNavigate } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives' import { useToast } from '../ui/Toast' -import { seedQuery } from '../data/seedQueries' -import { departments } from '../data/seed' +import CandidateProfile from './CandidateProfile' +import { AtsMatch } from './Candidates' +import { seedQuery, useSeedMutation } from '../data/seedQueries' +import { qk } from '../lib/queryKeys' +import { friendlyAuthError } from '../lib/errors' +import * as candidatesApi from '../api/candidates' +import { avatarColor, departments, initials as initialsOf } from '../data/seed' + +/** The seed bucket holds 100 candidates; one template per person, no reuse. */ +const FETCH_LIMIT = 100 + +const STAGE_ORDER = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'] + +/** + * Candidate_application_Status (backend/inbox/enums.py) -> the seed stage + * vocabulary every screen renders. CLOSED is the column default, i.e. untriaged, + * so it reads as Applied rather than as an outcome. + */ +const STAGE_FROM_STATUS = { + PENDING: 'Applied', CLOSED: 'Applied', PROCESS: 'Screening', + ONHOLD: 'Screening', APPROVED: 'Hired', REJECTED: 'Rejected', +} + +/** "6 years" -> 6. The column is free text, so anything unparseable defers to seed. */ +function years(value) { + const n = parseInt(value, 10) + return Number.isFinite(n) ? n : null +} + +/** + * One API record overlaid on one seed candidate. + * + * `id` deliberately stays the SEED id: CandidateProfile joins seed interviews on + * c.id and the favourite/advance mutations key off it, so a UUID here would + * empty the Interview tab and silently drop those writes. The real identifier + * rides along on `userId`. + */ +function merge(row, template) { + const name = row.name || template.name + const title = (row.job_posts || []).map((j) => j.title).find(Boolean) + const stage = STAGE_FROM_STATUS[row.application_status] || template.stage + const experience = years(row.experience) + + return { + ...template, + userId: row.user_id, + name, + initials: initialsOf(name), + color: avatarColor(name), + email: row.email || template.email, + experience: experience ?? template.experience, + stage, + status: stage, + currentTitle: title || template.currentTitle, + jobTitle: title || template.jobTitle, + } +} + +/** + * `inbox` holds one row per (user, message), so a candidate who mailed us three + * times arrives three times. Collapse onto the person before pairing templates, + * otherwise one candidate would occupy three cards and three seed identities. + */ +function buildPool(rows, templates) { + if (!templates.length) return [] + const byPerson = new Map() + for (const row of rows) { + const key = row.user_id ?? `inbox-${row.inbox_id}` + if (!byPerson.has(key)) byPerson.set(key, row) + } + return [...byPerson.values()].map((row, i) => merge(row, templates[i % templates.length])) +} export default function TalentPool() { const { toast } = useToast() - const navigate = useNavigate() - const { data: candidates = [] } = useQuery(seedQuery('candidates')) + const { data: templates = [] } = useQuery(seedQuery('candidates')) + const updateCandidates = useSeedMutation('candidates') + const [q, setQ] = useState('') const [dept, setDept] = useState('') + const [profileFor, setProfileFor] = useState(null) + const [atsFor, setAtsFor] = useState(null) + + const query = useQuery({ + queryKey: qk.candidates.list({ limit: FETCH_LIMIT }), + queryFn: () => candidatesApi.list({ limit: FETCH_LIMIT }), + }) - // Silver medalists / passive talent — candidates outside the active loop. const pool = useMemo( - () => candidates.filter((c) => ['Rejected', 'Applied', 'Hired'].includes(c.stage)), - [candidates], + () => buildPool(candidatesApi.toRows(query.data), templates), + [query.data, templates], ) const list = useMemo( @@ -30,6 +123,26 @@ export default function TalentPool() { [pool, q, dept], ) + // Both mirror Candidates.jsx so a change made here shows up there too. The + // card renders neither favourite nor stage, so only the open modal restates. + function toggleFav(c) { + updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, favorite: !x.favorite } : x))) + setProfileFor((p) => (p && p.id === c.id ? { ...p, favorite: !p.favorite } : p)) + toast(c.favorite ? 'Removed from favorites' : `${c.name} added to favorites`, 'success') + } + + function advance(c) { + const i = STAGE_ORDER.indexOf(c.stage) + if (i === -1 || i >= STAGE_ORDER.length - 1) { + toast(`${c.name} cannot be advanced further`, 'warning') + return + } + const stage = STAGE_ORDER[i + 1] + updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, stage, status: stage } : x))) + setProfileFor((p) => (p && p.id === c.id ? { ...p, stage, status: stage } : p)) + toast(`${c.name} moved to ${stage}`, 'success') + } + return (