talentpool filter workds

pull/34/head
ahmed.mujtaba 2026-08-30 20:50:06 +05:00
parent 893e32f666
commit 7f3735362e
14 changed files with 467 additions and 344 deletions

View File

@ -60,6 +60,7 @@ def clamp_in_resume(key,sentinel):
def _clean_linkedin(value,resume_text):
"""Keep a LinkedIn URL only when the CV evidences it. Sentinel / invented → None."""
url=(value or "").strip()
if not url or url.lower() in (NO_LINKEDIN.lower(),"none","null","n/a","-"):
return None
@ -70,7 +71,19 @@ def _clean_linkedin(value,resume_text):
return None
if not lowered.startswith("http://") and not lowered.startswith("https://"):
url="https://"+url.lstrip("/")
return url
text=(resume_text or "").strip()
if not text:
return url
from linkedin_utils import slug_from_url,slugs_from_text
agent_slug=slug_from_url(url)
if agent_slug:
return url if agent_slug in slugs_from_text(text) else None
if "lnkd.in" in lowered:
from linkedin_utils import profile_url_from_text
evidenced=profile_url_from_text(text)
if evidenced and "lnkd.in" in evidenced.lower():
return evidenced
return None
def _clean_phone(value,resume_text):

View File

@ -39,6 +39,7 @@ linkedin_url (its own key — extract this separately from the other fields):
- Copy the full slug. Never drop a trailing path segment.
- Do not return a company page (linkedin.com/company/...), a search URL, or a URL that is not LinkedIn.
- Do not invent a profile. If none is mentioned, return exactly: {NO_LINKEDIN}
- Never guess a slug or construct linkedin.com/in/<name> from the candidate's name. The stored value will be null when this sentinel is returned.
phone (its own key extract this separately; copy EVERY digit):
- Return the candidate's own mobile / phone exactly as written, including country code when present.

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter,Depends,Query,Response
from fastapi.responses import FileResponse,JSONResponse
from fastapi import HTTPException
from db_setup import get_session
from job.candidate.views import CandidateScoring,FileRead,CandidateView
from job.candidate.views import CandidateScoring,FileRead,CandidateView,parse_linkedin_url_from_cv
from job.interviews.views import Interview
from job.notes.views import Note
from job.activity.views import ActivityLog
@ -33,6 +33,11 @@ logger = logging.getLogger(__name__)
router = APIRouter()
class MatchingAssign(BaseModel):
id: UUID
job_post_id: UUID | None = None
class CandidateUpdate(BaseModel):
favorite: bool | None = None
rating: float | None = None
@ -297,6 +302,7 @@ async def cv_bank_upload(
parsed=await reader.injest_manual_upload() # 400 on unreadable/empty PDF
text=parsed.get("text") or ""
detected,_=extract_candidate_email(text)
parsed_linkedin=await parse_linkedin_url_from_cv(text)
# Basename against both separator styles — a Windows client sends
# C:\Users\x\cv.pdf whose PosixPath name is the whole string.
original=PurePosixPath(PureWindowsPath(file.filename or "resume.pdf").name).name or "resume.pdf"
@ -308,6 +314,7 @@ async def cv_bank_upload(
file_name=original,
created_by=current_user.get("id"),
pdf_bytes=content,
linkedin_url=parsed_linkedin,
)
try:
uploaded=S3().upload_for_record(
@ -332,6 +339,7 @@ async def cv_bank_upload(
"file_name":row.file_name,
"file_path":row.file_path or None,
"candidate_email":row.candidate_email or None,
"linkedin_url":row.linkedin_url or None,
"created_at":row.created_at.isoformat() if row.created_at else None,
},"status_code":200})
except HTTPException:
@ -359,6 +367,7 @@ async def cv_bank_fetch(
"file_path":(r.file_path or "").strip() or None,
"candidate_email":r.candidate_email or None,
"candidate_name":r.candidate_name or None,
"linkedin_url":r.linkedin_url or None,
"created_at":r.created_at.isoformat() if r.created_at else None,
} for r in rows]
return JSONResponse(content={"data":data,"total":total,"status_code":200})
@ -423,6 +432,61 @@ async def cv_bank_delete(
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/matching/fetch")
async def matching_fetch(
top: int = Query(10, ge=1, le=500),
skip: int = Query(0, ge=0),
assigned: bool | None = Query(default=None),
search: str | None = Query(default=None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Job Matching queue: CV Import 'No job' rows (apply_via=cv_bank)."""
try:
service=CandidateView(session=session)
data,total=await service.list_matching(
assigned=assigned,search=search,limit=top,offset=skip,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/matching/fetch_by_id")
async def matching_fetch_by_id(
id: str = Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateView(session=session)
data=await service.get_matching(id)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/matching/assign")
async def matching_assign(
payload: MatchingAssign,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
"""Set job_post_id on a CV-bank row — it then joins like any manual upload."""
try:
service=CandidateView(session=session)
data=await service.assign_matching(payload.id,payload.job_post_id,current_user.get("id"))
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/inbox-match")
async def candidate_inbox_match(
inbox_message_id: str = Query(...),

View File

@ -380,25 +380,27 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
return out
# ---- CV bank -----------------------------------------------------------
# apply_via="cv_bank" rows are a private store of CVs with NO job, NO user
# account and NO inbox entry — deliberately invisible to Candidates,
# Pipeline (whose list inner-joins Users/JobPosts) and the Inbox. They wait
# until a recruiter picks them up; email is captured only when the CV
# contains one.
# apply_via="cv_bank" marks origin: the CV Import "No job" tab. Unassigned
# rows (job_post_id IS NULL) are the bank; Job Matching assigns a job_post_id
# (and a user account) so get_all's Users/JobPosts inner joins pick them up
# as normal applications. apply_via stays "cv_bank" so Matching can still
# list them. No inbox entry.
@classmethod
async def insert_bank_cv(cls, session: AsyncSession, *, candidate_email,
candidate_name, full_text, file_name,
created_by, pdf_bytes,
content_type="application/pdf"):
content_type="application/pdf",
linkedin_url=None):
"""Bank a CV: metadata row + its bytes (cv_bank_files) in one commit.
file_path is filled after S3 upload under Temp/{id}/ (see cv_bank_upload).
When the CV carries an email, the candidate ACCOUNT is created/reused
(same pattern as create_manual_upload_candidate) so the person shows
up on the Candidates screen; unlike an application there is still no
inbox entry, no scoring, and no setup email. A CV with no detectable
email banks fine and simply stays account-less."""
linkedin_url is the employment-agent extraction (None when the CV has
none never a constructed slug). When the CV carries an email, the
candidate ACCOUNT is created/reused so the person shows up on the
Candidates screen; unlike an application there is still no inbox entry,
no scoring, and no setup email. A CV with no detectable email banks
fine and simply stays account-less."""
import os
from role.models import EnumRoles, Roles
@ -427,12 +429,21 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
user.is_active = True
session.add(user)
url = (linkedin_url or "").strip() or None
if url:
linkedin_slug = slug_from_url(url) or NO_SLUG
else:
linkedin_slug = primary_slug_from_text(full_text or "")
if user and url:
await Users.set_linkedin_url_if_empty(session, user_id=user.id, url=url)
row = cls(
candidate_email=email,
candidate_name=(candidate_name or "").strip() or (email or ""),
job_post_id=None,
full_text=full_text or "",
linkedin_slug=primary_slug_from_text(full_text or ""),
linkedin_slug=linkedin_slug,
linkedin_url=url,
apply_via="cv_bank",
user_id=user.id if user else None,
created_by=cls._as_uuid(created_by),
@ -455,14 +466,16 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
@classmethod
async def list_bank(cls, session: AsyncSession, limit=100, offset=0):
"""Unassigned No-job CVs only — assigned rows leave the bank for Matching."""
bank = (cls.apply_via == "cv_bank", cls.job_post_id.is_(None))
total = (
await session.execute(
select(func.count()).select_from(cls).where(cls.apply_via == "cv_bank")
select(func.count()).select_from(cls).where(*bank)
)
).scalar() or 0
result = await session.execute(
select(cls)
.where(cls.apply_via == "cv_bank")
.where(*bank)
.order_by(cls.created_at.desc(), cls.id.desc())
.limit(limit)
.offset(offset)
@ -470,12 +483,104 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
return list(result.scalars().all()), total
@classmethod
async def delete_bank_cv(cls, session: AsyncSession, record_id):
"""Hard delete, bank rows only — never reachable for application rows.
The cv_bank_files row goes with it via ON DELETE CASCADE."""
async def list_matching(cls, session: AsyncSession, *, assigned=None,
search=None, limit=100, offset=0):
"""No-job-tab origin (`apply_via=cv_bank`). `assigned` is tri-valued:
None = all, False = still in the bank, True = job_post_id set."""
filters = [cls.apply_via == "cv_bank"]
if assigned is True:
filters.append(cls.job_post_id.is_not(None))
elif assigned is False:
filters.append(cls.job_post_id.is_(None))
if search and str(search).strip():
like = f"%{str(search).strip()}%"
filters.append(or_(
cls.candidate_name.ilike(like),
cls.candidate_email.ilike(like),
cls.file_name.ilike(like),
))
total = (
await session.execute(
select(func.count()).select_from(cls).where(*filters)
)
).scalar() or 0
result = await session.execute(
select(cls)
.where(*filters)
.order_by(cls.created_at.desc(), cls.id.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all()), total
@classmethod
async def _ensure_bank_user(cls, session: AsyncSession, row):
"""Candidate USER so get_all / Candidates can see the row after assign.
insert_user commits; caller must reload `row` afterwards."""
if row.user_id:
return None
import os
from role.models import EnumRoles, Roles
from users.models import Users
from users.plugins import hash_password
email = (row.candidate_email or "").strip().lower()
if not email:
email = f"cvbank-{row.id.hex}@no-email.local"
user = await Users.get_user_by_email(session, email)
if not user:
role = await Roles.get_role_by_name(session, EnumRoles.CANDIDATE.value)
name = (row.candidate_name or "").strip() or (row.file_name or "").strip() or email
user = await Users.insert_user(session, {
"name": name,
"email": email,
"role_id": role.id if role else 8,
"password": hash_password(os.getenv("DEFAULT_CANDIDATE_PASSWORD", "Utopia!@#")),
"is_active": True,
"is_approved": True,
"is_deleted": False,
})
elif user.is_deleted or not user.is_active:
user.is_deleted = False
user.is_active = True
session.add(user)
return user
@classmethod
async def assign_job_post(cls, session: AsyncSession, record_id, job_post_id):
"""Set job_post_id on a CV-bank row. None unassigns (back to the bank).
Origin apply_via stays cv_bank. Creates/reuses a candidate user so the
row joins like any other manual_upload_candidate application."""
row = await cls.get_by_id(session, record_id)
if not row or row.apply_via != "cv_bank":
return None
jid = cls._as_uuid(job_post_id) if job_post_id not in (None, "") else None
user = await cls._ensure_bank_user(session, row)
row = await cls.get_by_id(session, record_id)
if not row:
return None
if user is not None:
row.user_id = user.id
if not (row.candidate_email or "").strip() and user.email:
row.candidate_email = user.email
if not (row.candidate_name or "").strip() and user.name:
row.candidate_name = user.name
row.job_post_id = jid
row.status = "PENDING" if jid else "BANKED"
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def delete_bank_cv(cls, session: AsyncSession, record_id):
"""Hard delete unassigned bank rows only — assigned rows are applications.
The cv_bank_files row goes with it via ON DELETE CASCADE."""
row = await cls.get_by_id(session, record_id)
if not row or row.apply_via != "cv_bank" or row.job_post_id is not None:
return None
file_row = await CvBankFiles.get(session, row.id)
if file_row:
await session.delete(file_row)

View File

@ -41,6 +41,28 @@ def serialize_candidate(row) -> dict:
}
def serialize_matching_candidate(row, job_post=None) -> Dict[str,Any]:
"""CV-bank origin row for Job Matching. assigned_job_post_id is job_posts.id."""
name=(row.candidate_name or "").strip() or (row.candidate_email or "").strip() or (row.file_name or "").strip() or "Unknown"
job_payload=serialize_job_post(job_post) if job_post else None
return {
"id":str(row.id),
"name":name,
"email":(row.candidate_email or "").strip() or None,
"file_name":(row.file_name or "").strip() or None,
"file_path":(row.file_path or "").strip() or None,
"resume_text":row.full_text or None,
"linkedin_url":row.linkedin_url or None,
"apply_via":row.apply_via,
"status":row.status or None,
"user_id":str(row.user_id) if row.user_id else None,
"assigned_job_post_id":str(row.job_post_id) if row.job_post_id else None,
"assigned_job_post":job_payload,
"created_at":row.created_at.isoformat() if row.created_at else None,
"updated_at":row.updated_at.isoformat() if row.updated_at else None,
}
def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
return {
"id":str(row.id) if row.id else None,

View File

@ -26,7 +26,7 @@ from job.candidate.plugins import (
get_scoring_settings,
normalize_spaced_text,
)
from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate
from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
@ -46,7 +46,7 @@ MANUAL_UPLOAD_TO_ADDRESS=os.getenv(
async def parse_linkedin_url_from_cv(resume_text) -> str | None:
"""Employment-agent `linkedin_url` key from parsed CV text. None if absent or the call fails."""
"""Employment-agent `linkedin_url` from CV text. None if absent, sentinel, invented, or the call fails."""
text=(resume_text or "").strip()
if not text:
return None
@ -1088,3 +1088,52 @@ class CandidateView:
raise HTTPException(status_code=404,detail="Not found")
name=(entry.get("name") or path.name).strip() or path.name
return path,name
async def list_matching(self,assigned=None,search=None,limit=10,offset=0):
rows,total=await Manual_UPLOAD_CANDIDATE.list_matching(
self.session,assigned=assigned,search=search,limit=limit,offset=offset,
)
job_ids=[str(r.job_post_id) for r in rows if r.job_post_id]
posts=await JobPosts.get_by_ids(self.session,job_ids,active_only=False) if job_ids else []
by_id={str(p.id):p for p in posts}
data=[
serialize_matching_candidate(r,by_id.get(str(r.job_post_id)) if r.job_post_id else None)
for r in rows
]
return data,total
async def get_matching(self,record_id):
row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,record_id)
if not row or row.apply_via!="cv_bank":
raise HTTPException(status_code=404,detail="CV not found")
job_post=None
if row.job_post_id:
job_post=await JobPosts.get_job_post_by_id(self.session,str(row.job_post_id))
return serialize_matching_candidate(row,job_post)
async def assign_matching(self,record_id,job_post_id,current_user=None):
row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,record_id)
if not row or row.apply_via!="cv_bank":
raise HTTPException(status_code=404,detail="CV not found")
job_post=None
if job_post_id not in (None,""):
job_post=await JobPosts.get_job_post_by_id(self.session,str(job_post_id))
if not job_post or job_post.is_deleted:
raise HTTPException(status_code=404,detail="Job post not found")
was_unassigned=row.job_post_id is None
row=await Manual_UPLOAD_CANDIDATE.assign_job_post(self.session,record_id,job_post_id)
if not row:
raise HTTPException(status_code=404,detail="CV not found")
if job_post is None and row.job_post_id:
job_post=await JobPosts.get_job_post_by_id(self.session,str(row.job_post_id))
if was_unassigned and row.job_post_id and row.user_id:
title=(job_post.title if job_post else "") or str(row.job_post_id)
await HistoryRecorder(self.session).record(
HistoryEvent.CANDIDATE_CREATED.value,
actor_id=current_user,user_id=row.user_id,
manual_upload_candidate_id=row.id,
entity_type="manual_upload_candidate",entity_id=row.id,
to_value=title,
description="cv_bank",commit=True,
)
return serialize_matching_candidate(row,job_post)

View File

@ -2,6 +2,8 @@ def serialize_job_post(row) -> dict:
return {
"id": str(row.id),
"title": row.title,
# Talent Pool / candidate filters key off attached job_posts.department.
"department": row.department or None,
"employment_type": row.employment_type,
"location": row.location,
"experience_min": row.experience_min,
@ -31,8 +33,8 @@ def serialize_job_row(row, *, recruiter_name=None, applicant_count=0) -> dict:
"""Requisition view of a job post, for the Jobs screen.
Deliberately separate from serialize_job_post: that payload is shared by the
inbox, candidate and matching paths, and widening it would change five
response shapes at once.
inbox, candidate and matching paths. department is the one shared field
talent-pool filters key off it on attached job_posts.
"""
return {
"id": str(row.id),

View File

@ -92,6 +92,34 @@ export function viewCvBankCv(id) {
})
}
/**
* Job Matching queue CV Import "No job" rows (`apply_via=cv_bank`).
* Needs candidates.view. `assigned` is tri-valued: omit for all, false for
* still in the bank, true for rows that already have a job_post_id.
*/
export function listMatching({ search, top = 10, skip = 0, assigned } = {}) {
return request('/candidate/matching/fetch', {
params: { search, top, skip, assigned },
})
}
/** One matching row by manual_upload_candidate id. Needs candidates.view. */
export function getMatching(id) {
return request('/candidate/matching/fetch_by_id', { params: { id } })
}
/**
* Link (or unlink) a job post on a CV-bank row. Needs candidates.edit.
* After assign the row has job_post_id + user_id and Pipeline/Candidates
* fetch it like any other manual_upload_candidate.
*/
export function assignMatchingJob(id, jobPostId) {
return request('/candidate/matching/assign', {
method: 'POST',
body: { id, job_post_id: jobPostId },
})
}
/**
* Score the decoded attachments of inbox messages against a job post.
* Needs candidates.create. messageIds are inbox_messages PK uuids (the `id`

View File

@ -18,7 +18,7 @@ export const ROUTES = [
// --- Workspace ---
{ path: 'dashboard', title: 'Dashboard', icon: 'dashboard', group: 'Workspace', permission: 'dashboard.view' },
{ path: 'inbox', title: 'Recruitment Inbox', icon: 'inbox', group: 'Workspace', permission: 'inbox.view', badge: 'inbox' },
{ path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'inbox.view', badge: 'matching' },
{ path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'candidates.view', badge: 'matching' },
{ path: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' },
{ path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' },
{ path: 'talentpool', title: 'Talent Pool', icon: 'talent', group: 'Workspace', permission: 'candidates.view' },

View File

@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { qk } from '../lib/queryKeys'
import * as inboxApi from '../api/inbox'
import * as candidatesApi from '../api/candidates'
import * as tasksApi from '../api/tasks'
import * as jobsApi from '../api/jobs'
import * as notificationsApi from '../api/notifications'
@ -94,15 +95,19 @@ export function useHotkeys({ onEscape }) {
* that every mutating call site had to remember to call; these are derived, so
* completing a task updates the badge with no call site involved at all.
*
* `matching` is the unassigned applications queue the one number Job Matching
* exists to drive to zero.
* `matching` is the unassigned CV-bank queue CVs imported with No job that
* still have no job_post_id. Job Matching exists to drive that number to zero.
*/
export function useBadges() {
const { data: matchingTotal = 0 } = useQuery({
queryKey: qk.mailbox.assignments({ assigned: false }),
queryKey: qk.candidates.matching({ assigned: false, count: true }),
queryFn: async () => {
const res = await inboxApi.listApplications({ assigned: false, top: 1 })
return res?.total ?? 0
try {
const res = await candidatesApi.listMatching({ assigned: false, top: 1 })
return res?.total ?? 0
} catch {
return 0
}
},
})
const { data: tasksTotal = 0 } = useQuery({

View File

@ -92,6 +92,8 @@ export const qk = {
count: (p = {}) => ['candidates', 'count', p],
detail: (id) => ['candidates', 'detail', id],
history: (id, p = {}) => ['candidates', 'history', id, p],
matching: (p = {}) => ['candidates', 'matching', p],
matchingDetail: (id) => ['candidates', 'matching', 'detail', id],
},
// Board rows come from the same endpoint as qk.candidates.list but are cached
// MAPPED (kanban cards, not the raw envelope), so they need their own key —

View File

@ -349,9 +349,8 @@ export default function CvImport() {
)
}
/* The stored-CV bank a private store with no job, account or inbox entry.
This list is the bank's home: browse, download, or remove; picking a CV up
for a job later is a future action. */
/* The stored-CV bank CVs imported with No job. Unassigned rows live here;
assigning a job in Job Matching sets job_post_id and they leave this list. */
function CvBank() {
const { toast } = useToast()
const qc = useQueryClient()
@ -432,6 +431,11 @@ function CvBank() {
{r.candidate_email || 'No email detected'}
{r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''}
</div>
{r.linkedin_url ? (
<div className="cell-sub" style={{ marginTop: 2 }}>
<a href={r.linkedin_url} target="_blank" rel="noopener noreferrer">{r.linkedin_url}</a>
</div>
) : null}
{r.file_path ? (
<div className="cell-sub truncate" title={r.file_path} style={{ marginTop: 2 }}>
{r.file_path}

View File

@ -1,18 +1,16 @@
/* ============================================================
Job Matching assign each inbound application to exactly one job post.
Job Matching assign a job post to CVs stored with "No job"
(CV Import CV bank, apply_via=cv_bank on manual_upload_candidate).
Queue layout mirrors Inbox (Tabs over a .split). Page size defaults to 10;
skip = (page-1)*limit, same as Inbox. The AI's suggested_job_post_ids land
here as a radiogroup; Assign writes assigned_job_post_id. Nothing is
marked read that stays Inbox's job so this page cannot silently move the
inbox nav badge.
Needs assignment: job_post_id is null. Assign writes job_post_id (and a
candidate user) so Pipeline / Candidates fetch the row like any other
manual upload. Assigned tab is the same origin with a job already linked.
============================================================ */
import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
import OpenResumeButton from '../ui/OpenResumeButton'
import PageHeader from '../ui/PageHeader'
import { Tabs } from '../ui/Tabs'
@ -23,34 +21,26 @@ import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as inboxApi from '../api/inbox'
import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts'
import * as s3Api from '../api/s3'
import {
avatarColor, fmtDate, initials as initialsOf, inboxSources, sourceMeta,
} from '../data/seed'
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
const TABS = [
{ key: 'needs', label: 'Needs assignment' },
{ key: 'assigned', label: 'Assigned' },
{ key: 'none', label: 'No suggestions' },
{ key: 'all', label: 'All' },
]
const TAB_FILTERS = {
needs: { assigned: false },
assigned: { assigned: true },
none: { assigned: false, noSuggestions: true },
all: {},
}
/** Same cap as GET /inbox/all-applications `top`. */
const PAGE_SIZE_MAX = 500
const RESUME_STATUS = {
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
failed: 'Failed', dlq: 'Failed', skipped: 'Pending',
}
const CV_BANK_META = { icon: 'file', color: 'var(--c2)', channel: 'Upload' }
function parseDate(value) {
if (!value) return null
@ -58,13 +48,39 @@ function parseDate(value) {
return Number.isNaN(d.getTime()) ? null : d
}
function sourceFrom(messageTo) {
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] }
return { source: raw.split(',')[0].trim(), sourceMeta: null }
function mapRow(row) {
const name = row.name || row.email || row.file_name || 'Unknown'
return {
id: String(row.id),
name,
initials: initialsOf(name),
color: avatarColor(name),
email: row.email || '',
position: row.file_name || 'CV bank',
source: 'CV bank',
sourceMeta: CV_BANK_META,
received: parseDate(row.created_at),
resumeText: row.resume_text || '',
filePath: row.file_path || '',
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
assignedPost: row.assigned_job_post || null,
status: row.status || null,
userId: row.user_id || null,
linkedinUrl: row.linkedin_url || null,
}
}
async function fetchQueue(params) {
const res = await candidatesApi.listMatching(params)
const rows = Array.isArray(res?.data) ? res.data : []
return { rows: rows.map(mapRow), total: res?.total ?? rows.length }
}
async function fetchDetail(recordId) {
const res = await candidatesApi.getMatching(recordId)
const row = res?.data
if (!row) return null
return mapRow(row)
}
function SourceChip({ item }) {
@ -76,106 +92,18 @@ function SourceChip({ item }) {
)
}
function htmlToText(value) {
const raw = (value || '').trim()
if (!raw) return ''
if (!/<[a-z!/]/i.test(raw)) return raw
const withBreaks = raw
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|tr|h[1-6]|blockquote|table)\s*>/gi, '\n')
const doc = new DOMParser().parseFromString(withBreaks, 'text/html')
doc.querySelectorAll('script, style, head').forEach((n) => n.remove())
return (doc.body?.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
}
function mapApplication(row) {
const name = row.name || row.email || 'Unknown'
return {
id: String(row.id),
name,
initials: initialsOf(name),
color: avatarColor(name),
email: row.email || '',
position: row.position || '(no subject)',
...sourceFrom(row.source),
received: parseDate(row.received),
unread: Boolean(row.unread),
processing: row.processing || 'Unread',
resumeStatus: row.resume_status || RESUME_STATUS[row.match_status] || 'Pending',
resumeText: row.resume_text || '',
filePath: row.file_path || '',
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 || '',
matchReasoning: row.match_reasoning || '',
matchError: row.match_error || '',
matchedAt: parseDate(row.matched_at),
}
}
async function fetchApplications(params) {
const res = await inboxApi.listApplications(params)
const rows = Array.isArray(res?.data) ? res.data : []
return { rows: rows.map(mapApplication), total: res?.total ?? rows.length }
}
async function fetchDetail(recordId) {
const res = await inboxApi.getMessage(recordId)
const row = res?.data
if (!row) return null
const name = row.sender_name || row.fromEmail || 'Unknown'
return {
id: String(row.id),
name,
initials: initialsOf(name),
color: avatarColor(name),
email: row.fromEmail || '',
position: row.subject || '(no subject)',
// Same value as `position`, kept under its own name: the email panel renders
// it as a mail header, not as the candidate's role.
subject: row.subject || '',
...sourceFrom(row.message_to),
body: htmlToText(row.body),
// Kept raw for the HTML viewer; `body` stays as the plain-text fallback for
// mail that never had markup. EmailBody sanitises before rendering.
bodyHtml: row.body || '',
resumeText: row.resume_text || '',
files: Array.isArray(row.files) ? row.files : [],
filePath: row.file_path || '',
resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending',
processing: row.unread ? 'Unread' : 'Read',
suggestedIds: (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,
matchStatus: row.match_status || null,
matchSummary: row.match_summary || '',
matchReasoning: row.match_reasoning || '',
matchError: row.match_error || '',
matchedAt: parseDate(row.matched_at),
}
}
function AssignmentBadge({ item, titleById }) {
if (item.matchStatus === 'processing') {
return <Badge className="b-gray">Matching</Badge>
}
if (item.assignedId) {
const title = titleById.get(item.assignedId) || 'Assigned'
const title = titleById.get(item.assignedId) || item.assignedPost?.title || 'Assigned'
return <Badge className="b-green">{title}</Badge>
}
const n = item.suggestedIds.length
if (n > 0) return <Badge className="b-blue">{n} suggested</Badge>
return <Badge className="b-amber">No match</Badge>
return <Badge className="b-amber">No job</Badge>
}
export default function Matching() {
const { toast } = useToast()
const { can } = useAuth()
const canEdit = can('inbox.edit')
const canEdit = can('candidates.edit')
const qc = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams()
const deepLink = searchParams.get('record')
@ -188,7 +116,6 @@ export default function Matching() {
const [selection, setSelection] = useState(null)
const [manualPost, setManualPost] = useState(null)
const [showPicker, setShowPicker] = useState(false)
const [whyOpen, setWhyOpen] = useState(false)
const tabFilter = TAB_FILTERS[tab] ?? {}
const listParams = useMemo(() => ({
@ -199,25 +126,21 @@ export default function Matching() {
}), [tabFilter, page, pageSize, q])
const listQuery = useQuery({
queryKey: qk.mailbox.assignments({ ...listParams, tab }),
queryFn: () => fetchApplications(listParams),
queryKey: qk.candidates.matching({ ...listParams, tab }),
queryFn: () => fetchQueue(listParams),
})
const needsCount = useQuery({
queryKey: qk.mailbox.assignments({ assigned: false, count: true }),
queryFn: async () => (await inboxApi.listApplications({ assigned: false, top: 1 }))?.total ?? 0,
queryKey: qk.candidates.matching({ assigned: false, count: true }),
queryFn: async () => (await candidatesApi.listMatching({ assigned: false, top: 1 }))?.total ?? 0,
})
const assignedCount = useQuery({
queryKey: qk.mailbox.assignments({ assigned: true, count: true }),
queryFn: async () => (await inboxApi.listApplications({ assigned: true, top: 1 }))?.total ?? 0,
queryKey: qk.candidates.matching({ assigned: true, count: true }),
queryFn: async () => (await candidatesApi.listMatching({ assigned: true, top: 1 }))?.total ?? 0,
})
const allCount = useQuery({
queryKey: qk.mailbox.assignments({ count: true }),
queryFn: async () => (await inboxApi.listApplications({ top: 1 }))?.total ?? 0,
})
const noneCountQuery = useQuery({
queryKey: qk.mailbox.assignments({ kind: 'none', count: true }),
queryFn: async () => (await inboxApi.listApplications({ assigned: false, noSuggestions: true, top: 1 }))?.total ?? 0,
queryKey: qk.candidates.matching({ count: true }),
queryFn: async () => (await candidatesApi.listMatching({ top: 1 }))?.total ?? 0,
})
const rows = listQuery.data?.rows ?? []
@ -226,13 +149,10 @@ export default function Matching() {
const pages = Math.max(1, Math.ceil(total / pageSize))
const currentPage = Math.min(page, pages)
const noneCount = noneCountQuery.data ?? 0
useEffect(() => {
if (listQuery.isSuccess && page > pages) setPage(pages)
}, [listQuery.isSuccess, page, pages])
// Preselect deep link once, then clear the query so refresh doesn't re-pin.
useEffect(() => {
if (!deepLink) return undefined
setSelectedId(deepLink)
@ -241,7 +161,7 @@ export default function Matching() {
}, [deepLink, setSearchParams])
const detailQuery = useQuery({
queryKey: qk.mailbox.message(selectedId),
queryKey: qk.candidates.matchingDetail(selectedId),
queryFn: () => fetchDetail(selectedId),
enabled: Boolean(selectedId),
})
@ -249,12 +169,10 @@ export default function Matching() {
const detail = detailQuery.data
const listRow = filtered.find((r) => r.id === selectedId) || rows.find((r) => r.id === selectedId)
// Hydrate titles for list badges (assigned + suggestions) in one call.
const hydrateIds = useMemo(() => {
const ids = new Set()
for (const r of rows) {
if (r.assignedId) ids.add(r.assignedId)
for (const id of r.suggestedIds) ids.add(id)
}
return [...ids]
}, [rows])
@ -275,24 +193,11 @@ export default function Matching() {
return map
}, [titlesQuery.data])
// Reset local selection when the selected application changes.
useEffect(() => {
setManualPost(null)
setWhyOpen(false)
if (detail?.assignedId) setSelection(detail.assignedId)
else if (detail?.suggestedIds?.[0]) setSelection(detail.suggestedIds[0])
else setSelection(null)
}, [detail?.id, detail?.assignedId, detail?.suggestedIds])
const suggestionCards = useMemo(() => {
const fromDetail = detail?.suggestedPosts || []
const byId = new Map(fromDetail.map((p) => [String(p.id), p]))
const ids = detail?.suggestedIds || listRow?.suggestedIds || []
return ids.map((id, i) => ({
rank: i + 1,
post: byId.get(id) || { id, unavailable: true },
}))
}, [detail, listRow])
}, [detail?.id, detail?.assignedId])
const selectedPost = useMemo(() => {
if (!selection) return null
@ -300,17 +205,11 @@ export default function Matching() {
if (detail?.assignedPost && String(detail.assignedPost.id) === String(selection)) {
return detail.assignedPost
}
const hit = suggestionCards.find((c) => String(c.post.id) === String(selection))
return hit?.post || null
}, [selection, manualPost, detail, suggestionCards])
return null
}, [selection, manualPost, detail])
const assignMutation = useMutation({
mutationFn: ({ recordId, jobPostId }) => inboxApi.assignJobPost(recordId, jobPostId),
onMutate: async ({ recordId, jobPostId }) => {
await qc.cancelQueries({ queryKey: qk.mailbox.all() })
await qc.cancelQueries({ queryKey: ['mailbox', 'assignments'] })
return { recordId, jobPostId }
},
mutationFn: ({ recordId, jobPostId }) => candidatesApi.assignMatchingJob(recordId, jobPostId),
onError: (err) => {
toast(friendlyAuthError(err, 'Could not assign job post.'), 'error')
},
@ -321,12 +220,11 @@ export default function Matching() {
else toast(`${name} unassigned`, 'success')
},
onSettled: async (_res, _err, vars) => {
await qc.invalidateQueries({ queryKey: qk.mailbox.all() })
await qc.invalidateQueries({ queryKey: ['mailbox', 'assignments'] })
await qc.invalidateQueries({ queryKey: qk.mailbox.message(vars.recordId) })
await qc.invalidateQueries({ queryKey: qk.candidates.all() })
await qc.invalidateQueries({ queryKey: qk.cvBank.all() })
await qc.invalidateQueries({ queryKey: qk.pipeline.all() })
await qc.invalidateQueries({ queryKey: qk.candidates.matchingDetail(vars.recordId) })
// Auto-advance only on the Needs assignment tab after a real assign.
if (tab === 'needs' && vars.jobPostId) {
const idx = filtered.findIndex((r) => r.id === vars.recordId)
const next = filtered[idx + 1] || filtered[idx - 1] || null
@ -335,17 +233,6 @@ export default function Matching() {
},
})
const rematchMutation = useMutation({
mutationFn: (recordId) => inboxApi.rematch(recordId),
onError: (err) => toast(friendlyAuthError(err, 'Could not retry match.'), 'error'),
onSuccess: () => toast('Match re-queued', 'success'),
onSettled: (_r, _e, recordId) => {
qc.invalidateQueries({ queryKey: qk.mailbox.message(recordId) })
qc.invalidateQueries({ queryKey: ['mailbox', 'assignments'] })
},
})
// Keyboard: j/k move queue, 15 pick suggestion, Enter assigns, Esc clears.
useEffect(() => {
const onKey = (e) => {
const tag = e.target?.tagName
@ -360,9 +247,6 @@ export default function Matching() {
const idx = filtered.findIndex((r) => r.id === selectedId)
const next = filtered[Math.max(0, (idx < 0 ? 0 : idx - 1))]
if (next) setSelectedId(next.id)
} else if (e.key >= '1' && e.key <= '5') {
const card = suggestionCards[Number(e.key) - 1]
if (card && !card.post.unavailable) setSelection(String(card.post.id))
} else if (e.key === 'Enter' && canEdit && selection && selection !== detail?.assignedId) {
e.preventDefault()
assignMutation.mutate({ recordId: selectedId, jobPostId: selection })
@ -373,27 +257,25 @@ export default function Matching() {
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [filtered, selectedId, suggestionCards, canEdit, selection, detail, assignMutation])
}, [filtered, selectedId, canEdit, selection, detail, assignMutation])
const counts = {
needs: needsCount.data ?? 0,
assigned: assignedCount.data ?? 0,
none: noneCount,
all: allCount.data ?? 0,
}
const resumeText = detail?.resumeText || listRow?.resumeText || ''
const resumeKey = s3Api.resumeKeyFrom(detail) || s3Api.resumeKeyFrom(listRow)
const matchFailed = ['failed', 'no_text', 'dlq'].includes(detail?.matchStatus)
const resumeText = detail?.resumeText || listRow?.resumeText || ''
return (
<div className="page">
<PageHeader title="Job Matching" sub="Route applications to the right open role" />
<PageHeader title="Job Matching" sub="Assign a job to CVs stored with no job" />
{!canEdit && (
<div className="alert alert-danger mb-12">
Your account does not hold <code>inbox.edit</code>, which the server requires to
assign, unassign, or retry a match. Controls below stay disabled.
Your account does not hold <code>candidates.edit</code>, which the server requires to
assign or unassign a role. Controls below stay disabled.
</div>
)}
@ -425,7 +307,7 @@ export default function Matching() {
</div>
<div>
{listQuery.isPending && (
<EmptyState icon="target" title="Loading…">Fetching applications.</EmptyState>
<EmptyState icon="target" title="Loading…">Fetching CVs from the bank.</EmptyState>
)}
{listQuery.isError && (
<EmptyState icon="alert" title="Couldnt load queue">
@ -435,14 +317,14 @@ export default function Matching() {
{listQuery.isSuccess && filtered.length === 0 && (
<EmptyState icon="check-circle" title="Queue clear">
{tab === 'needs'
? 'Every application in this view has a role.'
? 'Every CV from the No job tab has a role, or the bank is empty.'
: 'Nothing matches this filter.'}
</EmptyState>
)}
{filtered.map((i) => (
<div
key={i.id}
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
className={`inbox-item${selectedId === i.id ? ' active' : ''}`}
onClick={() => setSelectedId(i.id)}
>
<Avatar name={i.name} initials={i.initials} color={i.color} />
@ -480,13 +362,13 @@ export default function Matching() {
<div className="split-detail">
{!selectedId ? (
<div style={{ padding: '100px 20px' }}>
<EmptyState icon="target" title="Select an application">
Choose an item from the list to review suggestions and assign a role.
<EmptyState icon="target" title="Select a CV">
Choose an item from the list to assign a job post.
</EmptyState>
</div>
) : detailQuery.isError ? (
<div style={{ padding: '100px 20px' }}>
<EmptyState icon="alert" title="Couldnt load this application">
<EmptyState icon="alert" title="Couldnt load this CV">
{friendlyAuthError(detailQuery.error, 'Request failed')}
</EmptyState>
</div>
@ -497,15 +379,9 @@ export default function Matching() {
loading={detailQuery.isPending}
canEdit={canEdit}
selection={selection}
setSelection={setSelection}
manualPost={manualPost}
suggestionCards={suggestionCards}
selectedPost={selectedPost}
resumeText={resumeText}
resumeKey={resumeKey}
whyOpen={whyOpen}
setWhyOpen={setWhyOpen}
matchFailed={matchFailed}
onPickManual={() => setShowPicker(true)}
onSkip={() => {
const idx = filtered.findIndex((r) => r.id === selectedId)
@ -521,9 +397,7 @@ export default function Matching() {
assignMutation.mutate({ recordId: selectedId, jobPostId: null })
}}
onChange={() => setShowPicker(true)}
onRematch={() => rematchMutation.mutate(selectedId)}
assigning={assignMutation.isPending}
rematching={rematchMutation.isPending}
/>
)}
</div>
@ -549,23 +423,15 @@ function MatchingWorkspace({
loading,
canEdit,
selection,
setSelection,
manualPost,
suggestionCards,
selectedPost,
resumeText,
resumeKey,
whyOpen,
setWhyOpen,
matchFailed,
onPickManual,
onSkip,
onAssign,
onUnassign,
onChange,
onRematch,
assigning,
rematching,
}) {
const i = {
name: detail?.name || listRow?.name || '…',
@ -574,8 +440,9 @@ function MatchingWorkspace({
position: detail?.position || listRow?.position,
source: detail?.source || listRow?.source,
sourceMeta: detail?.sourceMeta || listRow?.sourceMeta,
processing: detail?.processing || listRow?.processing,
resumeStatus: detail?.resumeStatus || listRow?.resumeStatus,
email: detail?.email || listRow?.email || '',
received: detail?.received || listRow?.received,
linkedinUrl: detail?.linkedinUrl || listRow?.linkedinUrl || null,
}
const assigned = detail?.assignedPost
@ -588,20 +455,28 @@ function MatchingWorkspace({
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
<div className="flex-1">
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
<div className="ph-role">{i.position}</div>
<div className="ph-role">{i.email || i.position}</div>
<div className="ph-tags" style={{ marginTop: 8 }}>
<SourceChip item={i} />{' '}
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
{i.resumeStatus}
</Badge>
{i.received && <span className="cell-sub">Added {fmtDate(i.received)}</span>}
{loading && <span className="cell-sub">Loading details</span>}
</div>
</div>
</div>
{s3Api.canOpen(resumeKey) && (
{(s3Api.canOpen(resumeKey) || i.linkedinUrl) && (
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
<OpenResumeButton filePath={resumeKey} />
{s3Api.canOpen(resumeKey) && <OpenResumeButton filePath={resumeKey} />}
{i.linkedinUrl && (
<a
className="btn btn-secondary btn-sm"
href={i.linkedinUrl}
target="_blank"
rel="noopener noreferrer"
>
<Icon name="linkedin" /> LinkedIn
</a>
)}
</div>
)}
@ -626,10 +501,10 @@ function MatchingWorkspace({
</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary btn-sm" disabled={!canEdit} title={!canEdit ? 'Requires inbox.edit' : undefined} onClick={onChange}>
<button className="btn btn-secondary btn-sm" disabled={!canEdit} title={!canEdit ? 'Requires candidates.edit' : undefined} onClick={onChange}>
Change
</button>
<button className="btn btn-ghost btn-sm" disabled={!canEdit || assigning} title={!canEdit ? 'Requires inbox.edit' : undefined} onClick={onUnassign}>
<button className="btn btn-ghost btn-sm" disabled={!canEdit || assigning} title={!canEdit ? 'Requires candidates.edit' : undefined} onClick={onUnassign}>
Unassign
</button>
</div>
@ -646,86 +521,29 @@ function MatchingWorkspace({
}}
>
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
{matchFailed ? (
<div className="alert alert-danger mb-16">
<div className="mb-8">{detail?.matchError || 'Matching failed for this application.'}</div>
<button
className="btn btn-secondary btn-sm"
disabled={!canEdit || rematching}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={onRematch}
>
<Icon name="sparkles" /> Retry match
</button>
</div>
) : (
<div className="mb-16">
<div className="fw-600" style={{ marginBottom: 6 }}>AI verdict</div>
<p style={{ marginBottom: 4 }}>
{detail?.matchSummary || listRow?.matchSummary || 'No match summary yet.'}
</p>
{detail?.matchedAt && (
<div className="cell-sub">Matched {fmtDate(detail.matchedAt)}</div>
)}
{(detail?.matchReasoning || listRow?.matchReasoning) && (
<button
className="btn btn-ghost btn-sm"
style={{ marginTop: 8, paddingLeft: 0 }}
onClick={() => setWhyOpen((v) => !v)}
>
{whyOpen ? '▾' : '▸'} Why these roles?
</button>
)}
{whyOpen && (
<p className="text-muted text-sm mt-8">
{detail?.matchReasoning || listRow?.matchReasoning}
</p>
)}
</div>
)}
{/* Email first: it is the application itself, and the resume is its
attachment. Reading order follows that. */}
{(detail?.subject || detail?.body) && (
<div className="mb-16">
<div className="fw-600" style={{ marginBottom: 6 }}>Email</div>
<div className="email-head">Subject: {detail.subject || '(no subject)'}</div>
{looksLikeHtml(detail.bodyHtml) ? (
<EmailBody html={detail.bodyHtml} />
) : (
<pre className="resume-thumb is-full email-plain">
{detail.body || 'No email body.'}
</pre>
)}
</div>
)}
<div className="fw-600" style={{ marginBottom: 6 }}>CV</div>
{s3Api.canOpen(resumeKey) ? (
<OpenResumeButton filePath={resumeKey} />
) : (
<p className="text-muted text-sm">No CV file stored in S3 for this application.</p>
<p className="text-muted text-sm">No CV file stored in S3 for this record.</p>
)}
{resumeText ? (
<pre className="resume-thumb is-full" style={{ marginTop: 12, maxHeight: 280, overflow: 'auto' }}>
{resumeText}
</pre>
) : null}
</div>
<div role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
<div className="fw-600 mb-8">Suggested roles</div>
{suggestionCards.length === 0 && !manualPost ? (
<EmptyState icon="alert" title="No suggested roles">
<p>No job post was suggested. Choose a role manually.</p>
<div role="radiogroup" aria-label="Job post" style={{ flex: '0 1 360px', minWidth: 260 }}>
<div className="fw-600 mb-8">Job post</div>
{!selectedPost ? (
<EmptyState icon="briefcase" title="No job selected">
<p>Pick a role for this CV. After assign it is a normal candidate on that job.</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap', marginTop: 10 }}>
<button
className="btn btn-secondary btn-sm"
disabled={!canEdit || rematching}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={onRematch}
>
Retry match
</button>
<button
className="btn btn-primary btn-sm"
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
title={!canEdit ? 'Requires candidates.edit' : undefined}
onClick={onPickManual}
>
Choose a role
@ -733,24 +551,12 @@ function MatchingWorkspace({
</div>
</EmptyState>
) : (
suggestionCards.map(({ rank, post }) => (
<JobCard
key={post.id}
post={post}
rank={rank}
selected={String(selection) === String(post.id)}
onSelect={(id) => setSelection(id)}
resumeText={resumeText}
/>
))
)}
{manualPost && (
<JobCard
post={manualPost}
post={selectedPost}
rank={0}
manual
selected={String(selection) === String(manualPost.id)}
onSelect={(id) => setSelection(id)}
selected={String(selection) === String(selectedPost.id)}
onSelect={() => {}}
resumeText={resumeText}
/>
)}
@ -758,10 +564,10 @@ function MatchingWorkspace({
className="btn btn-secondary"
style={{ width: '100%', marginTop: 8 }}
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
title={!canEdit ? 'Requires candidates.edit' : undefined}
onClick={onPickManual}
>
Choose a different role
{selectedPost ? 'Choose a different role…' : 'Choose a role…'}
</button>
</div>
</div>
@ -780,11 +586,10 @@ function MatchingWorkspace({
<button
className="btn btn-primary"
disabled={!canAssign}
title={!canEdit ? 'Requires inbox.edit' : undefined}
title={!canEdit ? 'Requires candidates.edit' : undefined}
onClick={onAssign}
>
{selectedPost?.title
? 'Assign' :'Assign'}
Assign
</button>
</div>
</div>

View File

@ -4,12 +4,11 @@
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.
The endpoint returns name, email, experience, application_status, suggested
job titles and the attached job_posts (with department). It has no skills or
currentCompany on list rows those still come from the seed overlay. 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.
@ -69,6 +68,25 @@ function years(value) {
return Number.isFinite(n) ? n : null
}
/**
* Distinct departments from the candidate's assigned + suggested job posts.
* Seed templates also carry a department, but that is a prototype leftover and
* must not drive the toolbar filter it would never match /job/departments/fetch.
*/
function departmentsOf(row) {
const seen = new Set()
const out = []
const add = (value) => {
const d = typeof value === 'string' ? value : ''
if (!d || seen.has(d)) return
seen.add(d)
out.push(d)
}
add(row.assigned_job_post?.department)
for (const jp of row.job_posts || []) add(jp.department)
return out
}
/**
* One API record overlaid on one seed candidate.
*
@ -84,6 +102,7 @@ function merge(row, template) {
|| row.current_title
const stage = STAGE_FROM_STATUS[row.application_status] || template.stage
const experience = years(row.experience)
const departments = departmentsOf(row)
return {
...template,
@ -97,6 +116,10 @@ function merge(row, template) {
status: stage,
currentTitle: title || template.currentTitle,
jobTitle: title || template.jobTitle,
// Live job-post departments only. Seed department is left on `department`
// for the seed-only profile modal, but the filter reads `departments`.
departments,
department: departments[0] || template.department,
// Prefer real Form / platform tags from manual_upload; seed only as fallback.
source: row.source || template.source,
// NO seed fallback. `ai_score` is the candidate's current ats_results row,
@ -185,7 +208,7 @@ export default function TalentPool() {
const list = useMemo(
() =>
pool.filter((c) => {
if (dept && c.department !== dept) return false
if (dept && !(c.departments || []).includes(dept)) return false
if (q && !(c.name + c.currentCompany + c.skills.join(' ')).toLowerCase().includes(q.toLowerCase())) return false
return true
}),
@ -235,7 +258,7 @@ export default function TalentPool() {
</div>
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
<option value="">All Departments</option>
{departments.map((d) => <option key={d}>{d}</option>)}
{departments.map((d) => <option key={d} value={d}>{d}</option>)}
</select>
<PageSizeField
value={pageSize}