commit
f35ccc1081
|
|
@ -24,7 +24,7 @@ dist/**/*
|
||||||
.backup-prebrand/
|
.backup-prebrand/
|
||||||
*.bak
|
*.bak
|
||||||
*.backup
|
*.backup
|
||||||
|
*/node_modules/*
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,89 @@ async def get_ats_score_for_user(session:AsyncSession,user_id,job_post_id=None):
|
||||||
return _ats_score_payload((await session.execute(qry)).scalars().first())
|
return _ats_score_payload((await session.execute(qry)).scalars().first())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_ats_scores_for_users(session:AsyncSession,user_ids):
|
||||||
|
"""{str(user_id): score payload} for a whole page of candidates.
|
||||||
|
|
||||||
|
Same source and same three resolution paths as get_ats_score_for_user /
|
||||||
|
get_ats_score_for_manual_user, but three queries for the page instead of two
|
||||||
|
per row — a 100-card talent pool called the single-row helpers 200 times.
|
||||||
|
|
||||||
|
Paths are applied in precedence order and a user found by an earlier one is
|
||||||
|
never overwritten: direct ats_results.user_id, then the inbox join for scores
|
||||||
|
whose email matched no user, then the manual_upload email join. Within a path
|
||||||
|
the newest current score wins, which is what the unpinned single-row helpers
|
||||||
|
return when a candidate has several applications.
|
||||||
|
"""
|
||||||
|
uids=[]
|
||||||
|
seen=set()
|
||||||
|
for raw in user_ids or []:
|
||||||
|
try:
|
||||||
|
uid=uuid.UUID(str(raw))
|
||||||
|
except (TypeError,ValueError):
|
||||||
|
continue
|
||||||
|
if uid not in seen:
|
||||||
|
seen.add(uid)
|
||||||
|
uids.append(uid)
|
||||||
|
if not uids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
scores={}
|
||||||
|
|
||||||
|
def collect(pairs):
|
||||||
|
# computed_at DESC on every query, so the first row seen for a user is
|
||||||
|
# the newest, and a later path can never displace an earlier one.
|
||||||
|
for ats,owner in pairs:
|
||||||
|
key=str(owner) if owner else None
|
||||||
|
if key and key not in scores:
|
||||||
|
scores[key]=_ats_score_payload(ats)
|
||||||
|
|
||||||
|
direct=(
|
||||||
|
select(AtsResults)
|
||||||
|
.where(AtsResults.user_id.in_(uids),AtsResults.is_current==True) # noqa: E712
|
||||||
|
.order_by(AtsResults.computed_at.desc())
|
||||||
|
)
|
||||||
|
collect((r,r.user_id) for r in (await session.execute(direct)).scalars().all())
|
||||||
|
|
||||||
|
remaining=[u for u in uids if str(u) not in scores]
|
||||||
|
if remaining:
|
||||||
|
via_inbox=(
|
||||||
|
select(AtsResults,Inbox.user_id)
|
||||||
|
.join(Inbox,AtsResults.inbox_id==Inbox.id)
|
||||||
|
.join(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
|
||||||
|
.where(
|
||||||
|
Inbox.user_id.in_(remaining),
|
||||||
|
Inbox_Messages.assigned_job_post_id.is_not(None),
|
||||||
|
AtsResults.job_post_id==Inbox_Messages.assigned_job_post_id,
|
||||||
|
AtsResults.is_current==True, # noqa: E712
|
||||||
|
)
|
||||||
|
.order_by(AtsResults.computed_at.desc())
|
||||||
|
)
|
||||||
|
collect((await session.execute(via_inbox)).all())
|
||||||
|
|
||||||
|
remaining=[u for u in uids if str(u) not in scores]
|
||||||
|
if remaining:
|
||||||
|
via_manual=(
|
||||||
|
select(AtsResults,Manual_UPLOAD_CANDIDATE.user_id)
|
||||||
|
.join(Candidates,AtsResults.candidate_id==Candidates.id)
|
||||||
|
.join(
|
||||||
|
Manual_UPLOAD_CANDIDATE,
|
||||||
|
(Candidates.job_id==Manual_UPLOAD_CANDIDATE.job_post_id)
|
||||||
|
&(Candidates.candidate_email==Manual_UPLOAD_CANDIDATE.candidate_email),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Manual_UPLOAD_CANDIDATE.user_id.in_(remaining),
|
||||||
|
Manual_UPLOAD_CANDIDATE.apply_via=="manual_upload",
|
||||||
|
Candidates.status=="completed",
|
||||||
|
AtsResults.job_post_id==Manual_UPLOAD_CANDIDATE.job_post_id,
|
||||||
|
AtsResults.is_current==True, # noqa: E712
|
||||||
|
)
|
||||||
|
.order_by(AtsResults.computed_at.desc())
|
||||||
|
)
|
||||||
|
collect((await session.execute(via_manual)).all())
|
||||||
|
|
||||||
|
return scores
|
||||||
|
|
||||||
|
|
||||||
async def get_ats_score_for_manual_user(session:AsyncSession,user_id,job_post_id=None):
|
async def get_ats_score_for_manual_user(session:AsyncSession,user_id,job_post_id=None):
|
||||||
"""Current ats_results overall score for an Add Candidate user -> dict, or None.
|
"""Current ats_results overall score for an Add Candidate user -> dict, or None.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -698,7 +698,7 @@ async def fetch_pipeline_candidates(
|
||||||
@router.get("/pipeline/candidate/score/fetch")
|
@router.get("/pipeline/candidate/score/fetch")
|
||||||
async def fetch_pipeline_candidate_score(
|
async def fetch_pipeline_candidate_score(
|
||||||
user_id:uuid.UUID=Query(...),
|
user_id:uuid.UUID=Query(...),
|
||||||
job_post_id:uuid.UUID=Query(...),
|
job_post_id:uuid.UUID=Query(None),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -644,14 +644,17 @@ class CandidateView:
|
||||||
async def attach_job_posts(self,data):
|
async def attach_job_posts(self,data):
|
||||||
"""Normalize list/single, serialize each record, attach full job_posts rows.
|
"""Normalize list/single, serialize each record, attach full job_posts rows.
|
||||||
|
|
||||||
Two batched queries per page, not two per row — a 200-row pipeline board
|
Three batched queries per page, not two per row — a 200-row pipeline board
|
||||||
issued 600+ sequential job_posts round trips before.
|
issued 600+ sequential job_posts round trips before.
|
||||||
"""
|
"""
|
||||||
|
from inbox.plugins import get_ats_scores_for_users
|
||||||
|
|
||||||
single=not isinstance(data,list)
|
single=not isinstance(data,list)
|
||||||
records=[data] if single else list(data or [])
|
records=[data] if single else list(data or [])
|
||||||
|
|
||||||
payloads=[]
|
payloads=[]
|
||||||
wanted=[]
|
wanted=[]
|
||||||
|
owners=[]
|
||||||
for record in records:
|
for record in records:
|
||||||
payload=serialize_candidate_profile(record)
|
payload=serialize_candidate_profile(record)
|
||||||
payload["job_posts"]=[]
|
payload["job_posts"]=[]
|
||||||
|
|
@ -661,10 +664,22 @@ class CandidateView:
|
||||||
payload["ai_score"]=score
|
payload["ai_score"]=score
|
||||||
payload["recommendation"]=band
|
payload["recommendation"]=band
|
||||||
payloads.append(payload)
|
payloads.append(payload)
|
||||||
|
if payload.get("user_id"):
|
||||||
|
owners.append(payload["user_id"])
|
||||||
if payload.get("assigned_job_post_id"):
|
if payload.get("assigned_job_post_id"):
|
||||||
wanted.append(payload["assigned_job_post_id"])
|
wanted.append(payload["assigned_job_post_id"])
|
||||||
wanted.extend(payload.get("suggested_job_post_ids") or [])
|
wanted.extend(payload.get("suggested_job_post_ids") or [])
|
||||||
|
|
||||||
|
# ats_results is the source the inbox denorm above is copied FROM, so a
|
||||||
|
# live score wins over it. A candidate with neither keeps ai_score None —
|
||||||
|
# the list rows must not invent a number the scoring engine never produced.
|
||||||
|
ats=await get_ats_scores_for_users(self.session,owners)
|
||||||
|
for payload in payloads:
|
||||||
|
row=ats.get(str(payload.get("user_id") or ""))
|
||||||
|
if row and row.get("overall_score") is not None:
|
||||||
|
payload["ai_score"]=row["overall_score"]
|
||||||
|
payload["recommendation"]=row.get("band") or self._recommendation(row["overall_score"])
|
||||||
|
|
||||||
posts=await self._job_posts_by_id(wanted)
|
posts=await self._job_posts_by_id(wanted)
|
||||||
# Copy per attach: two candidates on the same post held independent dicts
|
# Copy per attach: two candidates on the same post held independent dicts
|
||||||
# back when every row re-serialized its own.
|
# back when every row re-serialized its own.
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
|
||||||
<script type="module" crossorigin src="/assets/index-D0GY3L2I.js"></script>
|
<script type="module" crossorigin src="/assets/index-IKokchhk.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -7,49 +7,49 @@
|
||||||
"react": {
|
"react": {
|
||||||
"src": "../../react/index.js",
|
"src": "../../react/index.js",
|
||||||
"file": "react.js",
|
"file": "react.js",
|
||||||
"fileHash": "0d8caf33",
|
"fileHash": "30cd4518",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react-dom": {
|
"react-dom": {
|
||||||
"src": "../../react-dom/index.js",
|
"src": "../../react-dom/index.js",
|
||||||
"file": "react-dom.js",
|
"file": "react-dom.js",
|
||||||
"fileHash": "e31b8bc6",
|
"fileHash": "3e8e68cc",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react/jsx-dev-runtime": {
|
"react/jsx-dev-runtime": {
|
||||||
"src": "../../react/jsx-dev-runtime.js",
|
"src": "../../react/jsx-dev-runtime.js",
|
||||||
"file": "react_jsx-dev-runtime.js",
|
"file": "react_jsx-dev-runtime.js",
|
||||||
"fileHash": "8d3fcfde",
|
"fileHash": "9f7d4b35",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react/jsx-runtime": {
|
"react/jsx-runtime": {
|
||||||
"src": "../../react/jsx-runtime.js",
|
"src": "../../react/jsx-runtime.js",
|
||||||
"file": "react_jsx-runtime.js",
|
"file": "react_jsx-runtime.js",
|
||||||
"fileHash": "e0c49b5a",
|
"fileHash": "5e6dc2ef",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"@tanstack/react-query": {
|
"@tanstack/react-query": {
|
||||||
"src": "../../@tanstack/react-query/build/modern/index.js",
|
"src": "../../@tanstack/react-query/build/modern/index.js",
|
||||||
"file": "@tanstack_react-query.js",
|
"file": "@tanstack_react-query.js",
|
||||||
"fileHash": "436099d2",
|
"fileHash": "f8ca42ca",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"@tanstack/react-query-devtools": {
|
"@tanstack/react-query-devtools": {
|
||||||
"src": "../../@tanstack/react-query-devtools/build/modern/index.js",
|
"src": "../../@tanstack/react-query-devtools/build/modern/index.js",
|
||||||
"file": "@tanstack_react-query-devtools.js",
|
"file": "@tanstack_react-query-devtools.js",
|
||||||
"fileHash": "956f8b03",
|
"fileHash": "fd826471",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"react-dom/client": {
|
"react-dom/client": {
|
||||||
"src": "../../react-dom/client.js",
|
"src": "../../react-dom/client.js",
|
||||||
"file": "react-dom_client.js",
|
"file": "react-dom_client.js",
|
||||||
"fileHash": "b0d019a2",
|
"fileHash": "55eb3962",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react-router-dom": {
|
"react-router-dom": {
|
||||||
"src": "../../react-router-dom/dist/index.mjs",
|
"src": "../../react-router-dom/dist/index.mjs",
|
||||||
"file": "react-router-dom.js",
|
"file": "react-router-dom.js",
|
||||||
"fileHash": "26f4c951",
|
"fileHash": "fbd054cd",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,38 @@ export function listTransitions({ inboxId, manualUploadId, transitionId } = {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One candidate's CURRENT ATS score — GET /pipeline/candidate/score/fetch
|
||||||
|
* (pipeline.view). Envelope is `{ data: { manual, inbox }, total, status_code }`,
|
||||||
|
* where each side is `{overall_score, band, job_post_id, computed_at,
|
||||||
|
* candidate_id, user_id}` or null.
|
||||||
|
*
|
||||||
|
* Score only. It carries no matched/missing keywords and no critique — those
|
||||||
|
* live on the scored-candidate row (/candidate/scored/fetch).
|
||||||
|
*
|
||||||
|
* `jobPostId` pins the score to one application; omitted (as the talent pool
|
||||||
|
* does, where a candidate need not have an assigned post) the newest current
|
||||||
|
* score across the candidate's applications wins.
|
||||||
|
*/
|
||||||
|
export function fetchCandidateScore({ userId, jobPostId } = {}) {
|
||||||
|
return request('/pipeline/candidate/score/fetch', {
|
||||||
|
params: { user_id: userId, job_post_id: jobPostId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two-sided envelope -> one score row, or null.
|
||||||
|
*
|
||||||
|
* A candidate is reached either through the inbox (they mailed us) or through
|
||||||
|
* Add Candidate (manual_upload); both sides resolve the same ats_results table,
|
||||||
|
* so whichever side answered is the score. Inbox wins a tie because an emailed
|
||||||
|
* application is the one the pipeline board is showing.
|
||||||
|
*/
|
||||||
|
export function toAtsScore(res) {
|
||||||
|
const data = res?.data ?? {}
|
||||||
|
return data.inbox ?? data.manual ?? null
|
||||||
|
}
|
||||||
|
|
||||||
function sourceFields(row, kind) {
|
function sourceFields(row, kind) {
|
||||||
if (kind === 'manual') {
|
if (kind === 'manual') {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ export const qk = {
|
||||||
all: () => ['pipeline'],
|
all: () => ['pipeline'],
|
||||||
board: (p = {}) => ['pipeline', 'board', p],
|
board: (p = {}) => ['pipeline', 'board', p],
|
||||||
transitions: (inboxId) => ['pipeline', 'transitions', inboxId],
|
transitions: (inboxId) => ['pipeline', 'transitions', inboxId],
|
||||||
|
// One candidate's current ats_results score. Fetched on demand (a card
|
||||||
|
// click), never as part of a list, so it is keyed per candidate + job.
|
||||||
|
candidateScore: (p = {}) => ['pipeline', 'candidate-score', p],
|
||||||
},
|
},
|
||||||
analytics: {
|
analytics: {
|
||||||
all: () => ['analytics'],
|
all: () => ['analytics'],
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,16 @@ function useProfileWrite({ userId, mutationFn, success, onDone }) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CandidateProfile({ candidate: c, onClose, onAdvance, onToggleFav, onAtsMatch }) {
|
/**
|
||||||
|
* `atsScore` / `recommendation` are the CURRENT ats_results row, fetched by the
|
||||||
|
* caller (Talent Pool reads GET /pipeline/candidate/score/fetch on card click).
|
||||||
|
* They are optional: a caller that does not fetch it passes nothing and the hero
|
||||||
|
* falls back to the detail payload's denormalised ai_score, then to the row's.
|
||||||
|
* When present they WIN, because ats_results is the source the denorm copies.
|
||||||
|
*/
|
||||||
|
export default function CandidateProfile({
|
||||||
|
candidate: c, atsScore = null, recommendation = null, onClose, onAdvance, onToggleFav, onAtsMatch,
|
||||||
|
}) {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [tab, setTab] = useState('Overview')
|
const [tab, setTab] = useState('Overview')
|
||||||
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
|
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
|
||||||
|
|
@ -212,10 +221,17 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
||||||
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
|
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'center' }}>
|
{/* No score anywhere -> the whole block goes, rather than a ring drawn
|
||||||
<ScoreChip score={live?.ai_score ?? c.aiScore} />
|
around a blank. Seed-backed callers still pass a number and are
|
||||||
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
|
unaffected; only live candidates the engine never scored drop out. */}
|
||||||
</div>
|
{(atsScore ?? live?.ai_score ?? c.aiScore) != null && (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<ScoreChip score={atsScore ?? live?.ai_score ?? c.aiScore} />
|
||||||
|
{/* The band caption replaces the static label only when the caller
|
||||||
|
actually fetched one — every other screen keeps "AI Match". */}
|
||||||
|
<div className="cell-sub" style={{ marginTop: 4 }}>{recommendation || 'AI Match'}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 22 }}>
|
<div style={{ marginTop: 22 }}>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,14 @@
|
||||||
interviews / notes / activity / feedback collections, all writable from their
|
interviews / notes / activity / feedback collections, all writable from their
|
||||||
own tabs. That switch happens inside CandidateProfile; passing `userId` is the
|
own tabs. That switch happens inside CandidateProfile; passing `userId` is the
|
||||||
whole trigger.
|
whole trigger.
|
||||||
|
|
||||||
|
A SECOND read fires on that same click: GET /pipeline/candidate/score/fetch,
|
||||||
|
the pipeline board's score endpoint, which returns the candidate's current
|
||||||
|
ats_results row. The two run concurrently — this screen owns the score call,
|
||||||
|
CandidateProfile owns the detail call — and the score wins over both the
|
||||||
|
list row's denormalised ai_score and the seed placeholder. It is deliberately
|
||||||
|
NOT fetched for the grid: 100 cards would be 100 requests, and the card only
|
||||||
|
ever needed a number good enough to sort by eye.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
@ -33,6 +41,7 @@ import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
||||||
import { qk } from '../lib/queryKeys'
|
import { qk } from '../lib/queryKeys'
|
||||||
import { friendlyAuthError } from '../lib/errors'
|
import { friendlyAuthError } from '../lib/errors'
|
||||||
import * as candidatesApi from '../api/candidates'
|
import * as candidatesApi from '../api/candidates'
|
||||||
|
import * as pipelineApi from '../api/pipeline'
|
||||||
import { avatarColor, departments, initials as initialsOf } from '../data/seed'
|
import { avatarColor, departments, initials as initialsOf } from '../data/seed'
|
||||||
|
|
||||||
/** The seed bucket holds 100 candidates; one template per person, no reuse. */
|
/** The seed bucket holds 100 candidates; one template per person, no reuse. */
|
||||||
|
|
@ -82,10 +91,12 @@ function merge(row, template) {
|
||||||
status: stage,
|
status: stage,
|
||||||
currentTitle: title || template.currentTitle,
|
currentTitle: title || template.currentTitle,
|
||||||
jobTitle: title || template.jobTitle,
|
jobTitle: title || template.jobTitle,
|
||||||
// Real ATS score (scoring engine, joined server-side by inbox message)
|
// NO seed fallback. `ai_score` is the candidate's current ats_results row,
|
||||||
// wins over the seed placeholder; recommendation follows it.
|
// resolved server-side; null means the scoring engine never scored this
|
||||||
aiScore: row.ai_score ?? template.aiScore,
|
// person, and the card renders nothing rather than a plausible fake number
|
||||||
recommendation: row.recommendation ?? template.recommendation,
|
// a recruiter would read as a real match.
|
||||||
|
aiScore: row.ai_score ?? null,
|
||||||
|
recommendation: row.recommendation ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,6 +135,28 @@ export default function TalentPool() {
|
||||||
[query.data, templates],
|
[query.data, templates],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The clicked candidate's current ATS score, from the pipeline board's own
|
||||||
|
* endpoint. `enabled` is the "only on click" rule: with no open profile there
|
||||||
|
* is no userId, and the query never runs. React Query caches it per user, so
|
||||||
|
* re-opening the same card repaints from cache.
|
||||||
|
*
|
||||||
|
* Sent WITHOUT job_post_id on purpose. The pool is a cross-job view — it has
|
||||||
|
* no job filter and most rows carry no assigned post — so pinning would only
|
||||||
|
* ever hide a score that exists under some other post. Unpinned, the endpoint
|
||||||
|
* answers with the newest current score the candidate has anywhere.
|
||||||
|
*/
|
||||||
|
const scoreQuery = useQuery({
|
||||||
|
queryKey: qk.pipeline.candidateScore({ userId: profileFor?.userId ?? null }),
|
||||||
|
queryFn: () => pipelineApi.fetchCandidateScore({ userId: profileFor.userId }),
|
||||||
|
select: pipelineApi.toAtsScore,
|
||||||
|
enabled: Boolean(profileFor?.userId),
|
||||||
|
})
|
||||||
|
|
||||||
|
// A candidate with no ats_results row answers `null`, which must read as "no
|
||||||
|
// live score" and leave the existing value alone — not as a score of zero.
|
||||||
|
const atsScore = scoreQuery.data?.overall_score ?? null
|
||||||
|
|
||||||
const list = useMemo(
|
const list = useMemo(
|
||||||
() =>
|
() =>
|
||||||
pool.filter((c) => {
|
pool.filter((c) => {
|
||||||
|
|
@ -214,7 +247,7 @@ export default function TalentPool() {
|
||||||
<div className="lr-title">{c.name}</div>
|
<div className="lr-title">{c.name}</div>
|
||||||
<div className="lr-sub">{c.currentTitle}</div>
|
<div className="lr-sub">{c.currentTitle}</div>
|
||||||
</div>
|
</div>
|
||||||
<ScoreChip score={c.aiScore} />
|
{c.aiScore != null && <ScoreChip score={c.aiScore} />}
|
||||||
</div>
|
</div>
|
||||||
<div className="k-tags" style={{ marginBottom: 12 }}>
|
<div className="k-tags" style={{ marginBottom: 12 }}>
|
||||||
{c.skills.slice(0, 4).map((s) => <span className="tag" key={s}>{s}</span>)}
|
{c.skills.slice(0, 4).map((s) => <span className="tag" key={s}>{s}</span>)}
|
||||||
|
|
@ -242,6 +275,8 @@ export default function TalentPool() {
|
||||||
{profileFor && (
|
{profileFor && (
|
||||||
<CandidateProfile
|
<CandidateProfile
|
||||||
candidate={profileFor}
|
candidate={profileFor}
|
||||||
|
atsScore={atsScore}
|
||||||
|
recommendation={scoreQuery.data?.band ?? null}
|
||||||
onClose={() => setProfileFor(null)}
|
onClose={() => setProfileFor(null)}
|
||||||
onAdvance={advance}
|
onAdvance={advance}
|
||||||
onToggleFav={toggleFav}
|
onToggleFav={toggleFav}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue