talentpool filter workds
parent
893e32f666
commit
7f3735362e
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(...),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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, 1–5 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="Couldn’t 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="Couldn’t load this application">
|
||||
<EmptyState icon="alert" title="Couldn’t 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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue