/talentpool integatrted
parent
da3077c302
commit
54c39946d3
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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] : []
|
||||
}
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
|
|
@ -62,7 +175,16 @@ export default function TalentPool() {
|
|||
<div className="grid g-3">
|
||||
{list.length === 0 ? (
|
||||
<div style={{ gridColumn: '1/-1' }}>
|
||||
{/* Same slot, same component — a failed fetch must not read as "no results". */}
|
||||
{query.isError ? (
|
||||
<EmptyState title="Could not load talent pool">
|
||||
{friendlyAuthError(query.error, 'Please try again.')}
|
||||
</EmptyState>
|
||||
) : query.isPending ? (
|
||||
<EmptyState title="Loading talent pool…">Fetching candidates.</EmptyState>
|
||||
) : (
|
||||
<EmptyState title="No talent found">Try a different search or department.</EmptyState>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
list.map((c) => (
|
||||
|
|
@ -70,7 +192,7 @@ export default function TalentPool() {
|
|||
key={c.id}
|
||||
className="card"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/candidates', { state: { openCandidate: c.id } })}
|
||||
onClick={() => setProfileFor(c)}
|
||||
>
|
||||
<div className="card-body">
|
||||
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
|
||||
|
|
@ -95,6 +217,24 @@ export default function TalentPool() {
|
|||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{atsFor && (
|
||||
<AtsMatch
|
||||
candidate={atsFor}
|
||||
onClose={() => setAtsFor(null)}
|
||||
onProfile={(c) => { setAtsFor(null); setProfileFor(c) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{profileFor && (
|
||||
<CandidateProfile
|
||||
candidate={profileFor}
|
||||
onClose={() => setProfileFor(null)}
|
||||
onAdvance={advance}
|
||||
onToggleFav={toggleFav}
|
||||
onAtsMatch={(c) => { setProfileFor(null); setAtsFor(c) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue