talentpool filter workds
parent
893e32f666
commit
7f3735362e
|
|
@ -60,6 +60,7 @@ def clamp_in_resume(key,sentinel):
|
||||||
|
|
||||||
|
|
||||||
def _clean_linkedin(value,resume_text):
|
def _clean_linkedin(value,resume_text):
|
||||||
|
"""Keep a LinkedIn URL only when the CV evidences it. Sentinel / invented → None."""
|
||||||
url=(value or "").strip()
|
url=(value or "").strip()
|
||||||
if not url or url.lower() in (NO_LINKEDIN.lower(),"none","null","n/a","-"):
|
if not url or url.lower() in (NO_LINKEDIN.lower(),"none","null","n/a","-"):
|
||||||
return None
|
return None
|
||||||
|
|
@ -70,7 +71,19 @@ def _clean_linkedin(value,resume_text):
|
||||||
return None
|
return None
|
||||||
if not lowered.startswith("http://") and not lowered.startswith("https://"):
|
if not lowered.startswith("http://") and not lowered.startswith("https://"):
|
||||||
url="https://"+url.lstrip("/")
|
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):
|
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.
|
- 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 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}
|
- 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):
|
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.
|
- 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.responses import FileResponse,JSONResponse
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from db_setup import get_session
|
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.interviews.views import Interview
|
||||||
from job.notes.views import Note
|
from job.notes.views import Note
|
||||||
from job.activity.views import ActivityLog
|
from job.activity.views import ActivityLog
|
||||||
|
|
@ -33,6 +33,11 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class MatchingAssign(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
job_post_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class CandidateUpdate(BaseModel):
|
class CandidateUpdate(BaseModel):
|
||||||
favorite: bool | None = None
|
favorite: bool | None = None
|
||||||
rating: float | 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
|
parsed=await reader.injest_manual_upload() # 400 on unreadable/empty PDF
|
||||||
text=parsed.get("text") or ""
|
text=parsed.get("text") or ""
|
||||||
detected,_=extract_candidate_email(text)
|
detected,_=extract_candidate_email(text)
|
||||||
|
parsed_linkedin=await parse_linkedin_url_from_cv(text)
|
||||||
# Basename against both separator styles — a Windows client sends
|
# Basename against both separator styles — a Windows client sends
|
||||||
# C:\Users\x\cv.pdf whose PosixPath name is the whole string.
|
# 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"
|
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,
|
file_name=original,
|
||||||
created_by=current_user.get("id"),
|
created_by=current_user.get("id"),
|
||||||
pdf_bytes=content,
|
pdf_bytes=content,
|
||||||
|
linkedin_url=parsed_linkedin,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
uploaded=S3().upload_for_record(
|
uploaded=S3().upload_for_record(
|
||||||
|
|
@ -332,6 +339,7 @@ async def cv_bank_upload(
|
||||||
"file_name":row.file_name,
|
"file_name":row.file_name,
|
||||||
"file_path":row.file_path or None,
|
"file_path":row.file_path or None,
|
||||||
"candidate_email":row.candidate_email 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,
|
"created_at":row.created_at.isoformat() if row.created_at else None,
|
||||||
},"status_code":200})
|
},"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -359,6 +367,7 @@ async def cv_bank_fetch(
|
||||||
"file_path":(r.file_path or "").strip() or None,
|
"file_path":(r.file_path or "").strip() or None,
|
||||||
"candidate_email":r.candidate_email or None,
|
"candidate_email":r.candidate_email or None,
|
||||||
"candidate_name":r.candidate_name 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,
|
"created_at":r.created_at.isoformat() if r.created_at else None,
|
||||||
} for r in rows]
|
} for r in rows]
|
||||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
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))
|
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")
|
@router.post("/candidate/inbox-match")
|
||||||
async def candidate_inbox_match(
|
async def candidate_inbox_match(
|
||||||
inbox_message_id: str = Query(...),
|
inbox_message_id: str = Query(...),
|
||||||
|
|
|
||||||
|
|
@ -380,25 +380,27 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# ---- CV bank -----------------------------------------------------------
|
# ---- CV bank -----------------------------------------------------------
|
||||||
# apply_via="cv_bank" rows are a private store of CVs with NO job, NO user
|
# apply_via="cv_bank" marks origin: the CV Import "No job" tab. Unassigned
|
||||||
# account and NO inbox entry — deliberately invisible to Candidates,
|
# rows (job_post_id IS NULL) are the bank; Job Matching assigns a job_post_id
|
||||||
# Pipeline (whose list inner-joins Users/JobPosts) and the Inbox. They wait
|
# (and a user account) so get_all's Users/JobPosts inner joins pick them up
|
||||||
# until a recruiter picks them up; email is captured only when the CV
|
# as normal applications. apply_via stays "cv_bank" so Matching can still
|
||||||
# contains one.
|
# list them. No inbox entry.
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def insert_bank_cv(cls, session: AsyncSession, *, candidate_email,
|
async def insert_bank_cv(cls, session: AsyncSession, *, candidate_email,
|
||||||
candidate_name, full_text, file_name,
|
candidate_name, full_text, file_name,
|
||||||
created_by, pdf_bytes,
|
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.
|
"""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).
|
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
|
linkedin_url is the employment-agent extraction (None when the CV has
|
||||||
(same pattern as create_manual_upload_candidate) so the person shows
|
none — never a constructed slug). When the CV carries an email, the
|
||||||
up on the Candidates screen; unlike an application there is still no
|
candidate ACCOUNT is created/reused so the person shows up on the
|
||||||
inbox entry, no scoring, and no setup email. A CV with no detectable
|
Candidates screen; unlike an application there is still no inbox entry,
|
||||||
email banks fine and simply stays account-less."""
|
no scoring, and no setup email. A CV with no detectable email banks
|
||||||
|
fine and simply stays account-less."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from role.models import EnumRoles, Roles
|
from role.models import EnumRoles, Roles
|
||||||
|
|
@ -427,12 +429,21 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
user.is_active = True
|
user.is_active = True
|
||||||
session.add(user)
|
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(
|
row = cls(
|
||||||
candidate_email=email,
|
candidate_email=email,
|
||||||
candidate_name=(candidate_name or "").strip() or (email or ""),
|
candidate_name=(candidate_name or "").strip() or (email or ""),
|
||||||
job_post_id=None,
|
job_post_id=None,
|
||||||
full_text=full_text or "",
|
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",
|
apply_via="cv_bank",
|
||||||
user_id=user.id if user else None,
|
user_id=user.id if user else None,
|
||||||
created_by=cls._as_uuid(created_by),
|
created_by=cls._as_uuid(created_by),
|
||||||
|
|
@ -455,14 +466,16 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def list_bank(cls, session: AsyncSession, limit=100, offset=0):
|
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 = (
|
total = (
|
||||||
await session.execute(
|
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
|
).scalar() or 0
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(cls)
|
select(cls)
|
||||||
.where(cls.apply_via == "cv_bank")
|
.where(*bank)
|
||||||
.order_by(cls.created_at.desc(), cls.id.desc())
|
.order_by(cls.created_at.desc(), cls.id.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
|
|
@ -470,12 +483,104 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
return list(result.scalars().all()), total
|
return list(result.scalars().all()), total
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def delete_bank_cv(cls, session: AsyncSession, record_id):
|
async def list_matching(cls, session: AsyncSession, *, assigned=None,
|
||||||
"""Hard delete, bank rows only — never reachable for application rows.
|
search=None, limit=100, offset=0):
|
||||||
The cv_bank_files row goes with it via ON DELETE CASCADE."""
|
"""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)
|
row = await cls.get_by_id(session, record_id)
|
||||||
if not row or row.apply_via != "cv_bank":
|
if not row or row.apply_via != "cv_bank":
|
||||||
return None
|
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)
|
file_row = await CvBankFiles.get(session, row.id)
|
||||||
if file_row:
|
if file_row:
|
||||||
await session.delete(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]:
|
def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
|
||||||
return {
|
return {
|
||||||
"id":str(row.id) if row.id else None,
|
"id":str(row.id) if row.id else None,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ from job.candidate.plugins import (
|
||||||
get_scoring_settings,
|
get_scoring_settings,
|
||||||
normalize_spaced_text,
|
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.models import JobPosts
|
||||||
from job.job_post.serializers import serialize_job_post
|
from job.job_post.serializers import serialize_job_post
|
||||||
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
|
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:
|
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()
|
text=(resume_text or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
|
|
@ -1088,3 +1088,52 @@ class CandidateView:
|
||||||
raise HTTPException(status_code=404,detail="Not found")
|
raise HTTPException(status_code=404,detail="Not found")
|
||||||
name=(entry.get("name") or path.name).strip() or path.name
|
name=(entry.get("name") or path.name).strip() or path.name
|
||||||
return 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 {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
"title": row.title,
|
"title": row.title,
|
||||||
|
# Talent Pool / candidate filters key off attached job_posts.department.
|
||||||
|
"department": row.department or None,
|
||||||
"employment_type": row.employment_type,
|
"employment_type": row.employment_type,
|
||||||
"location": row.location,
|
"location": row.location,
|
||||||
"experience_min": row.experience_min,
|
"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.
|
"""Requisition view of a job post, for the Jobs screen.
|
||||||
|
|
||||||
Deliberately separate from serialize_job_post: that payload is shared by the
|
Deliberately separate from serialize_job_post: that payload is shared by the
|
||||||
inbox, candidate and matching paths, and widening it would change five
|
inbox, candidate and matching paths. department is the one shared field —
|
||||||
response shapes at once.
|
talent-pool filters key off it on attached job_posts.
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"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.
|
* Score the decoded attachments of inbox messages against a job post.
|
||||||
* Needs candidates.create. messageIds are inbox_messages PK uuids (the `id`
|
* Needs candidates.create. messageIds are inbox_messages PK uuids (the `id`
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export const ROUTES = [
|
||||||
// --- Workspace ---
|
// --- Workspace ---
|
||||||
{ path: 'dashboard', title: 'Dashboard', icon: 'dashboard', group: 'Workspace', permission: 'dashboard.view' },
|
{ 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: '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: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' },
|
||||||
{ path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' },
|
{ path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' },
|
||||||
{ path: 'talentpool', title: 'Talent Pool', icon: 'talent', 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 { useQuery } from '@tanstack/react-query'
|
||||||
import { qk } from '../lib/queryKeys'
|
import { qk } from '../lib/queryKeys'
|
||||||
import * as inboxApi from '../api/inbox'
|
import * as inboxApi from '../api/inbox'
|
||||||
|
import * as candidatesApi from '../api/candidates'
|
||||||
import * as tasksApi from '../api/tasks'
|
import * as tasksApi from '../api/tasks'
|
||||||
import * as jobsApi from '../api/jobs'
|
import * as jobsApi from '../api/jobs'
|
||||||
import * as notificationsApi from '../api/notifications'
|
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
|
* 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.
|
* completing a task updates the badge with no call site involved at all.
|
||||||
*
|
*
|
||||||
* `matching` is the unassigned applications queue — the one number Job Matching
|
* `matching` is the unassigned CV-bank queue — CVs imported with No job that
|
||||||
* exists to drive to zero.
|
* still have no job_post_id. Job Matching exists to drive that number to zero.
|
||||||
*/
|
*/
|
||||||
export function useBadges() {
|
export function useBadges() {
|
||||||
const { data: matchingTotal = 0 } = useQuery({
|
const { data: matchingTotal = 0 } = useQuery({
|
||||||
queryKey: qk.mailbox.assignments({ assigned: false }),
|
queryKey: qk.candidates.matching({ assigned: false, count: true }),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await inboxApi.listApplications({ assigned: false, top: 1 })
|
try {
|
||||||
return res?.total ?? 0
|
const res = await candidatesApi.listMatching({ assigned: false, top: 1 })
|
||||||
|
return res?.total ?? 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const { data: tasksTotal = 0 } = useQuery({
|
const { data: tasksTotal = 0 } = useQuery({
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,8 @@ export const qk = {
|
||||||
count: (p = {}) => ['candidates', 'count', p],
|
count: (p = {}) => ['candidates', 'count', p],
|
||||||
detail: (id) => ['candidates', 'detail', id],
|
detail: (id) => ['candidates', 'detail', id],
|
||||||
history: (id, p = {}) => ['candidates', 'history', id, p],
|
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
|
// 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 —
|
// 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.
|
/* The stored-CV bank — CVs imported with No job. Unassigned rows live here;
|
||||||
This list is the bank's home: browse, download, or remove; picking a CV up
|
assigning a job in Job Matching sets job_post_id and they leave this list. */
|
||||||
for a job later is a future action. */
|
|
||||||
function CvBank() {
|
function CvBank() {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
@ -432,6 +431,11 @@ function CvBank() {
|
||||||
{r.candidate_email || 'No email detected'}
|
{r.candidate_email || 'No email detected'}
|
||||||
{r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''}
|
{r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''}
|
||||||
</div>
|
</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 ? (
|
{r.file_path ? (
|
||||||
<div className="cell-sub truncate" title={r.file_path} style={{ marginTop: 2 }}>
|
<div className="cell-sub truncate" title={r.file_path} style={{ marginTop: 2 }}>
|
||||||
{r.file_path}
|
{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;
|
Needs assignment: job_post_id is null. Assign writes job_post_id (and a
|
||||||
skip = (page-1)*limit, same as Inbox. The AI's suggested_job_post_ids land
|
candidate user) so Pipeline / Candidates fetch the row like any other
|
||||||
here as a radiogroup; Assign writes assigned_job_post_id. Nothing is
|
manual upload. Assigned tab is the same origin with a job already linked.
|
||||||
marked read — that stays Inbox's job so this page cannot silently move the
|
|
||||||
inbox nav badge.
|
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
|
|
||||||
import OpenResumeButton from '../ui/OpenResumeButton'
|
import OpenResumeButton from '../ui/OpenResumeButton'
|
||||||
import PageHeader from '../ui/PageHeader'
|
import PageHeader from '../ui/PageHeader'
|
||||||
import { Tabs } from '../ui/Tabs'
|
import { Tabs } from '../ui/Tabs'
|
||||||
|
|
@ -23,34 +21,26 @@ import { useToast } from '../ui/Toast'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { qk } from '../lib/queryKeys'
|
import { qk } from '../lib/queryKeys'
|
||||||
import { friendlyAuthError } from '../lib/errors'
|
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 jobPostsApi from '../api/jobPosts'
|
||||||
import * as s3Api from '../api/s3'
|
import * as s3Api from '../api/s3'
|
||||||
import {
|
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
|
||||||
avatarColor, fmtDate, initials as initialsOf, inboxSources, sourceMeta,
|
|
||||||
} from '../data/seed'
|
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: 'needs', label: 'Needs assignment' },
|
{ key: 'needs', label: 'Needs assignment' },
|
||||||
{ key: 'assigned', label: 'Assigned' },
|
{ key: 'assigned', label: 'Assigned' },
|
||||||
{ key: 'none', label: 'No suggestions' },
|
|
||||||
{ key: 'all', label: 'All' },
|
{ key: 'all', label: 'All' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const TAB_FILTERS = {
|
const TAB_FILTERS = {
|
||||||
needs: { assigned: false },
|
needs: { assigned: false },
|
||||||
assigned: { assigned: true },
|
assigned: { assigned: true },
|
||||||
none: { assigned: false, noSuggestions: true },
|
|
||||||
all: {},
|
all: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same cap as GET /inbox/all-applications `top`. */
|
|
||||||
const PAGE_SIZE_MAX = 500
|
const PAGE_SIZE_MAX = 500
|
||||||
|
|
||||||
const RESUME_STATUS = {
|
const CV_BANK_META = { icon: 'file', color: 'var(--c2)', channel: 'Upload' }
|
||||||
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
|
|
||||||
failed: 'Failed', dlq: 'Failed', skipped: 'Pending',
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDate(value) {
|
function parseDate(value) {
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
|
|
@ -58,13 +48,39 @@ function parseDate(value) {
|
||||||
return Number.isNaN(d.getTime()) ? null : d
|
return Number.isNaN(d.getTime()) ? null : d
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceFrom(messageTo) {
|
function mapRow(row) {
|
||||||
const raw = typeof messageTo === 'string' ? messageTo.trim() : String(messageTo ?? '').trim()
|
const name = row.name || row.email || row.file_name || 'Unknown'
|
||||||
if (!raw || raw === '[object Object]') return { source: 'Unknown', sourceMeta: null }
|
return {
|
||||||
const flat = raw.toLowerCase().replace(/[^a-z]/g, '')
|
id: String(row.id),
|
||||||
const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, '')))
|
name,
|
||||||
if (hit) return { source: hit, sourceMeta: sourceMeta[hit] }
|
initials: initialsOf(name),
|
||||||
return { source: raw.split(',')[0].trim(), sourceMeta: null }
|
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 }) {
|
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 }) {
|
function AssignmentBadge({ item, titleById }) {
|
||||||
if (item.matchStatus === 'processing') {
|
|
||||||
return <Badge className="b-gray">Matching…</Badge>
|
|
||||||
}
|
|
||||||
if (item.assignedId) {
|
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>
|
return <Badge className="b-green">{title}</Badge>
|
||||||
}
|
}
|
||||||
const n = item.suggestedIds.length
|
return <Badge className="b-amber">No job</Badge>
|
||||||
if (n > 0) return <Badge className="b-blue">{n} suggested</Badge>
|
|
||||||
return <Badge className="b-amber">No match</Badge>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Matching() {
|
export default function Matching() {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const { can } = useAuth()
|
const { can } = useAuth()
|
||||||
const canEdit = can('inbox.edit')
|
const canEdit = can('candidates.edit')
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const deepLink = searchParams.get('record')
|
const deepLink = searchParams.get('record')
|
||||||
|
|
@ -188,7 +116,6 @@ export default function Matching() {
|
||||||
const [selection, setSelection] = useState(null)
|
const [selection, setSelection] = useState(null)
|
||||||
const [manualPost, setManualPost] = useState(null)
|
const [manualPost, setManualPost] = useState(null)
|
||||||
const [showPicker, setShowPicker] = useState(false)
|
const [showPicker, setShowPicker] = useState(false)
|
||||||
const [whyOpen, setWhyOpen] = useState(false)
|
|
||||||
|
|
||||||
const tabFilter = TAB_FILTERS[tab] ?? {}
|
const tabFilter = TAB_FILTERS[tab] ?? {}
|
||||||
const listParams = useMemo(() => ({
|
const listParams = useMemo(() => ({
|
||||||
|
|
@ -199,25 +126,21 @@ export default function Matching() {
|
||||||
}), [tabFilter, page, pageSize, q])
|
}), [tabFilter, page, pageSize, q])
|
||||||
|
|
||||||
const listQuery = useQuery({
|
const listQuery = useQuery({
|
||||||
queryKey: qk.mailbox.assignments({ ...listParams, tab }),
|
queryKey: qk.candidates.matching({ ...listParams, tab }),
|
||||||
queryFn: () => fetchApplications(listParams),
|
queryFn: () => fetchQueue(listParams),
|
||||||
})
|
})
|
||||||
|
|
||||||
const needsCount = useQuery({
|
const needsCount = useQuery({
|
||||||
queryKey: qk.mailbox.assignments({ assigned: false, count: true }),
|
queryKey: qk.candidates.matching({ assigned: false, count: true }),
|
||||||
queryFn: async () => (await inboxApi.listApplications({ assigned: false, top: 1 }))?.total ?? 0,
|
queryFn: async () => (await candidatesApi.listMatching({ assigned: false, top: 1 }))?.total ?? 0,
|
||||||
})
|
})
|
||||||
const assignedCount = useQuery({
|
const assignedCount = useQuery({
|
||||||
queryKey: qk.mailbox.assignments({ assigned: true, count: true }),
|
queryKey: qk.candidates.matching({ assigned: true, count: true }),
|
||||||
queryFn: async () => (await inboxApi.listApplications({ assigned: true, top: 1 }))?.total ?? 0,
|
queryFn: async () => (await candidatesApi.listMatching({ assigned: true, top: 1 }))?.total ?? 0,
|
||||||
})
|
})
|
||||||
const allCount = useQuery({
|
const allCount = useQuery({
|
||||||
queryKey: qk.mailbox.assignments({ count: true }),
|
queryKey: qk.candidates.matching({ count: true }),
|
||||||
queryFn: async () => (await inboxApi.listApplications({ top: 1 }))?.total ?? 0,
|
queryFn: async () => (await candidatesApi.listMatching({ 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,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const rows = listQuery.data?.rows ?? []
|
const rows = listQuery.data?.rows ?? []
|
||||||
|
|
@ -226,13 +149,10 @@ export default function Matching() {
|
||||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||||
const currentPage = Math.min(page, pages)
|
const currentPage = Math.min(page, pages)
|
||||||
|
|
||||||
const noneCount = noneCountQuery.data ?? 0
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (listQuery.isSuccess && page > pages) setPage(pages)
|
if (listQuery.isSuccess && page > pages) setPage(pages)
|
||||||
}, [listQuery.isSuccess, page, pages])
|
}, [listQuery.isSuccess, page, pages])
|
||||||
|
|
||||||
// Preselect deep link once, then clear the query so refresh doesn't re-pin.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!deepLink) return undefined
|
if (!deepLink) return undefined
|
||||||
setSelectedId(deepLink)
|
setSelectedId(deepLink)
|
||||||
|
|
@ -241,7 +161,7 @@ export default function Matching() {
|
||||||
}, [deepLink, setSearchParams])
|
}, [deepLink, setSearchParams])
|
||||||
|
|
||||||
const detailQuery = useQuery({
|
const detailQuery = useQuery({
|
||||||
queryKey: qk.mailbox.message(selectedId),
|
queryKey: qk.candidates.matchingDetail(selectedId),
|
||||||
queryFn: () => fetchDetail(selectedId),
|
queryFn: () => fetchDetail(selectedId),
|
||||||
enabled: Boolean(selectedId),
|
enabled: Boolean(selectedId),
|
||||||
})
|
})
|
||||||
|
|
@ -249,12 +169,10 @@ export default function Matching() {
|
||||||
const detail = detailQuery.data
|
const detail = detailQuery.data
|
||||||
const listRow = filtered.find((r) => r.id === selectedId) || rows.find((r) => r.id === selectedId)
|
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 hydrateIds = useMemo(() => {
|
||||||
const ids = new Set()
|
const ids = new Set()
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (r.assignedId) ids.add(r.assignedId)
|
if (r.assignedId) ids.add(r.assignedId)
|
||||||
for (const id of r.suggestedIds) ids.add(id)
|
|
||||||
}
|
}
|
||||||
return [...ids]
|
return [...ids]
|
||||||
}, [rows])
|
}, [rows])
|
||||||
|
|
@ -275,24 +193,11 @@ export default function Matching() {
|
||||||
return map
|
return map
|
||||||
}, [titlesQuery.data])
|
}, [titlesQuery.data])
|
||||||
|
|
||||||
// Reset local selection when the selected application changes.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setManualPost(null)
|
setManualPost(null)
|
||||||
setWhyOpen(false)
|
|
||||||
if (detail?.assignedId) setSelection(detail.assignedId)
|
if (detail?.assignedId) setSelection(detail.assignedId)
|
||||||
else if (detail?.suggestedIds?.[0]) setSelection(detail.suggestedIds[0])
|
|
||||||
else setSelection(null)
|
else setSelection(null)
|
||||||
}, [detail?.id, detail?.assignedId, detail?.suggestedIds])
|
}, [detail?.id, detail?.assignedId])
|
||||||
|
|
||||||
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])
|
|
||||||
|
|
||||||
const selectedPost = useMemo(() => {
|
const selectedPost = useMemo(() => {
|
||||||
if (!selection) return null
|
if (!selection) return null
|
||||||
|
|
@ -300,17 +205,11 @@ export default function Matching() {
|
||||||
if (detail?.assignedPost && String(detail.assignedPost.id) === String(selection)) {
|
if (detail?.assignedPost && String(detail.assignedPost.id) === String(selection)) {
|
||||||
return detail.assignedPost
|
return detail.assignedPost
|
||||||
}
|
}
|
||||||
const hit = suggestionCards.find((c) => String(c.post.id) === String(selection))
|
return null
|
||||||
return hit?.post || null
|
}, [selection, manualPost, detail])
|
||||||
}, [selection, manualPost, detail, suggestionCards])
|
|
||||||
|
|
||||||
const assignMutation = useMutation({
|
const assignMutation = useMutation({
|
||||||
mutationFn: ({ recordId, jobPostId }) => inboxApi.assignJobPost(recordId, jobPostId),
|
mutationFn: ({ recordId, jobPostId }) => candidatesApi.assignMatchingJob(recordId, jobPostId),
|
||||||
onMutate: async ({ recordId, jobPostId }) => {
|
|
||||||
await qc.cancelQueries({ queryKey: qk.mailbox.all() })
|
|
||||||
await qc.cancelQueries({ queryKey: ['mailbox', 'assignments'] })
|
|
||||||
return { recordId, jobPostId }
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast(friendlyAuthError(err, 'Could not assign job post.'), 'error')
|
toast(friendlyAuthError(err, 'Could not assign job post.'), 'error')
|
||||||
},
|
},
|
||||||
|
|
@ -321,12 +220,11 @@ export default function Matching() {
|
||||||
else toast(`${name} unassigned`, 'success')
|
else toast(`${name} unassigned`, 'success')
|
||||||
},
|
},
|
||||||
onSettled: async (_res, _err, vars) => {
|
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.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) {
|
if (tab === 'needs' && vars.jobPostId) {
|
||||||
const idx = filtered.findIndex((r) => r.id === vars.recordId)
|
const idx = filtered.findIndex((r) => r.id === vars.recordId)
|
||||||
const next = filtered[idx + 1] || filtered[idx - 1] || null
|
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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e) => {
|
const onKey = (e) => {
|
||||||
const tag = e.target?.tagName
|
const tag = e.target?.tagName
|
||||||
|
|
@ -360,9 +247,6 @@ export default function Matching() {
|
||||||
const idx = filtered.findIndex((r) => r.id === selectedId)
|
const idx = filtered.findIndex((r) => r.id === selectedId)
|
||||||
const next = filtered[Math.max(0, (idx < 0 ? 0 : idx - 1))]
|
const next = filtered[Math.max(0, (idx < 0 ? 0 : idx - 1))]
|
||||||
if (next) setSelectedId(next.id)
|
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) {
|
} else if (e.key === 'Enter' && canEdit && selection && selection !== detail?.assignedId) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
assignMutation.mutate({ recordId: selectedId, jobPostId: selection })
|
assignMutation.mutate({ recordId: selectedId, jobPostId: selection })
|
||||||
|
|
@ -373,27 +257,25 @@ export default function Matching() {
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKey)
|
document.addEventListener('keydown', onKey)
|
||||||
return () => document.removeEventListener('keydown', onKey)
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
}, [filtered, selectedId, suggestionCards, canEdit, selection, detail, assignMutation])
|
}, [filtered, selectedId, canEdit, selection, detail, assignMutation])
|
||||||
|
|
||||||
const counts = {
|
const counts = {
|
||||||
needs: needsCount.data ?? 0,
|
needs: needsCount.data ?? 0,
|
||||||
assigned: assignedCount.data ?? 0,
|
assigned: assignedCount.data ?? 0,
|
||||||
none: noneCount,
|
|
||||||
all: allCount.data ?? 0,
|
all: allCount.data ?? 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
const resumeText = detail?.resumeText || listRow?.resumeText || ''
|
|
||||||
const resumeKey = s3Api.resumeKeyFrom(detail) || s3Api.resumeKeyFrom(listRow)
|
const resumeKey = s3Api.resumeKeyFrom(detail) || s3Api.resumeKeyFrom(listRow)
|
||||||
const matchFailed = ['failed', 'no_text', 'dlq'].includes(detail?.matchStatus)
|
const resumeText = detail?.resumeText || listRow?.resumeText || ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<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 && (
|
{!canEdit && (
|
||||||
<div className="alert alert-danger mb-12">
|
<div className="alert alert-danger mb-12">
|
||||||
Your account does not hold <code>inbox.edit</code>, which the server requires to
|
Your account does not hold <code>candidates.edit</code>, which the server requires to
|
||||||
assign, unassign, or retry a match. Controls below stay disabled.
|
assign or unassign a role. Controls below stay disabled.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -425,7 +307,7 @@ export default function Matching() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{listQuery.isPending && (
|
{listQuery.isPending && (
|
||||||
<EmptyState icon="target" title="Loading…">Fetching applications.</EmptyState>
|
<EmptyState icon="target" title="Loading…">Fetching CVs from the bank.</EmptyState>
|
||||||
)}
|
)}
|
||||||
{listQuery.isError && (
|
{listQuery.isError && (
|
||||||
<EmptyState icon="alert" title="Couldn’t load queue">
|
<EmptyState icon="alert" title="Couldn’t load queue">
|
||||||
|
|
@ -435,14 +317,14 @@ export default function Matching() {
|
||||||
{listQuery.isSuccess && filtered.length === 0 && (
|
{listQuery.isSuccess && filtered.length === 0 && (
|
||||||
<EmptyState icon="check-circle" title="Queue clear">
|
<EmptyState icon="check-circle" title="Queue clear">
|
||||||
{tab === 'needs'
|
{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.'}
|
: 'Nothing matches this filter.'}
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
)}
|
)}
|
||||||
{filtered.map((i) => (
|
{filtered.map((i) => (
|
||||||
<div
|
<div
|
||||||
key={i.id}
|
key={i.id}
|
||||||
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
|
className={`inbox-item${selectedId === i.id ? ' active' : ''}`}
|
||||||
onClick={() => setSelectedId(i.id)}
|
onClick={() => setSelectedId(i.id)}
|
||||||
>
|
>
|
||||||
<Avatar name={i.name} initials={i.initials} color={i.color} />
|
<Avatar name={i.name} initials={i.initials} color={i.color} />
|
||||||
|
|
@ -480,13 +362,13 @@ export default function Matching() {
|
||||||
<div className="split-detail">
|
<div className="split-detail">
|
||||||
{!selectedId ? (
|
{!selectedId ? (
|
||||||
<div style={{ padding: '100px 20px' }}>
|
<div style={{ padding: '100px 20px' }}>
|
||||||
<EmptyState icon="target" title="Select an application">
|
<EmptyState icon="target" title="Select a CV">
|
||||||
Choose an item from the list to review suggestions and assign a role.
|
Choose an item from the list to assign a job post.
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
</div>
|
</div>
|
||||||
) : detailQuery.isError ? (
|
) : detailQuery.isError ? (
|
||||||
<div style={{ padding: '100px 20px' }}>
|
<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')}
|
{friendlyAuthError(detailQuery.error, 'Request failed')}
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -497,15 +379,9 @@ export default function Matching() {
|
||||||
loading={detailQuery.isPending}
|
loading={detailQuery.isPending}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
selection={selection}
|
selection={selection}
|
||||||
setSelection={setSelection}
|
|
||||||
manualPost={manualPost}
|
|
||||||
suggestionCards={suggestionCards}
|
|
||||||
selectedPost={selectedPost}
|
selectedPost={selectedPost}
|
||||||
resumeText={resumeText}
|
resumeText={resumeText}
|
||||||
resumeKey={resumeKey}
|
resumeKey={resumeKey}
|
||||||
whyOpen={whyOpen}
|
|
||||||
setWhyOpen={setWhyOpen}
|
|
||||||
matchFailed={matchFailed}
|
|
||||||
onPickManual={() => setShowPicker(true)}
|
onPickManual={() => setShowPicker(true)}
|
||||||
onSkip={() => {
|
onSkip={() => {
|
||||||
const idx = filtered.findIndex((r) => r.id === selectedId)
|
const idx = filtered.findIndex((r) => r.id === selectedId)
|
||||||
|
|
@ -521,9 +397,7 @@ export default function Matching() {
|
||||||
assignMutation.mutate({ recordId: selectedId, jobPostId: null })
|
assignMutation.mutate({ recordId: selectedId, jobPostId: null })
|
||||||
}}
|
}}
|
||||||
onChange={() => setShowPicker(true)}
|
onChange={() => setShowPicker(true)}
|
||||||
onRematch={() => rematchMutation.mutate(selectedId)}
|
|
||||||
assigning={assignMutation.isPending}
|
assigning={assignMutation.isPending}
|
||||||
rematching={rematchMutation.isPending}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -549,23 +423,15 @@ function MatchingWorkspace({
|
||||||
loading,
|
loading,
|
||||||
canEdit,
|
canEdit,
|
||||||
selection,
|
selection,
|
||||||
setSelection,
|
|
||||||
manualPost,
|
|
||||||
suggestionCards,
|
|
||||||
selectedPost,
|
selectedPost,
|
||||||
resumeText,
|
resumeText,
|
||||||
resumeKey,
|
resumeKey,
|
||||||
whyOpen,
|
|
||||||
setWhyOpen,
|
|
||||||
matchFailed,
|
|
||||||
onPickManual,
|
onPickManual,
|
||||||
onSkip,
|
onSkip,
|
||||||
onAssign,
|
onAssign,
|
||||||
onUnassign,
|
onUnassign,
|
||||||
onChange,
|
onChange,
|
||||||
onRematch,
|
|
||||||
assigning,
|
assigning,
|
||||||
rematching,
|
|
||||||
}) {
|
}) {
|
||||||
const i = {
|
const i = {
|
||||||
name: detail?.name || listRow?.name || '…',
|
name: detail?.name || listRow?.name || '…',
|
||||||
|
|
@ -574,8 +440,9 @@ function MatchingWorkspace({
|
||||||
position: detail?.position || listRow?.position,
|
position: detail?.position || listRow?.position,
|
||||||
source: detail?.source || listRow?.source,
|
source: detail?.source || listRow?.source,
|
||||||
sourceMeta: detail?.sourceMeta || listRow?.sourceMeta,
|
sourceMeta: detail?.sourceMeta || listRow?.sourceMeta,
|
||||||
processing: detail?.processing || listRow?.processing,
|
email: detail?.email || listRow?.email || '',
|
||||||
resumeStatus: detail?.resumeStatus || listRow?.resumeStatus,
|
received: detail?.received || listRow?.received,
|
||||||
|
linkedinUrl: detail?.linkedinUrl || listRow?.linkedinUrl || null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const assigned = detail?.assignedPost
|
const assigned = detail?.assignedPost
|
||||||
|
|
@ -588,20 +455,28 @@ function MatchingWorkspace({
|
||||||
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
|
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
|
<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 }}>
|
<div className="ph-tags" style={{ marginTop: 8 }}>
|
||||||
<SourceChip item={i} />{' '}
|
<SourceChip item={i} />{' '}
|
||||||
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
|
{i.received && <span className="cell-sub">Added {fmtDate(i.received)}</span>}
|
||||||
{i.resumeStatus}
|
|
||||||
</Badge>
|
|
||||||
{loading && <span className="cell-sub">Loading details…</span>}
|
{loading && <span className="cell-sub">Loading details…</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{s3Api.canOpen(resumeKey) && (
|
{(s3Api.canOpen(resumeKey) || i.linkedinUrl) && (
|
||||||
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -626,10 +501,10 @@ function MatchingWorkspace({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-8">
|
<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
|
Change
|
||||||
</button>
|
</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
|
Unassign
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -646,86 +521,29 @@ function MatchingWorkspace({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
|
<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>
|
<div className="fw-600" style={{ marginBottom: 6 }}>CV</div>
|
||||||
{s3Api.canOpen(resumeKey) ? (
|
{s3Api.canOpen(resumeKey) ? (
|
||||||
<OpenResumeButton filePath={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>
|
||||||
|
|
||||||
<div role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
|
<div role="radiogroup" aria-label="Job post" style={{ flex: '0 1 360px', minWidth: 260 }}>
|
||||||
<div className="fw-600 mb-8">Suggested roles</div>
|
<div className="fw-600 mb-8">Job post</div>
|
||||||
{suggestionCards.length === 0 && !manualPost ? (
|
{!selectedPost ? (
|
||||||
<EmptyState icon="alert" title="No suggested roles">
|
<EmptyState icon="briefcase" title="No job selected">
|
||||||
<p>No job post was suggested. Choose a role manually.</p>
|
<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 }}>
|
<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
|
<button
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
title={!canEdit ? 'Requires candidates.edit' : undefined}
|
||||||
onClick={onPickManual}
|
onClick={onPickManual}
|
||||||
>
|
>
|
||||||
Choose a role
|
Choose a role
|
||||||
|
|
@ -733,24 +551,12 @@ function MatchingWorkspace({
|
||||||
</div>
|
</div>
|
||||||
</EmptyState>
|
</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
|
<JobCard
|
||||||
post={manualPost}
|
post={selectedPost}
|
||||||
rank={0}
|
rank={0}
|
||||||
manual
|
manual
|
||||||
selected={String(selection) === String(manualPost.id)}
|
selected={String(selection) === String(selectedPost.id)}
|
||||||
onSelect={(id) => setSelection(id)}
|
onSelect={() => {}}
|
||||||
resumeText={resumeText}
|
resumeText={resumeText}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -758,10 +564,10 @@ function MatchingWorkspace({
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
style={{ width: '100%', marginTop: 8 }}
|
style={{ width: '100%', marginTop: 8 }}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
title={!canEdit ? 'Requires candidates.edit' : undefined}
|
||||||
onClick={onPickManual}
|
onClick={onPickManual}
|
||||||
>
|
>
|
||||||
Choose a different role…
|
{selectedPost ? 'Choose a different role…' : 'Choose a role…'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -780,11 +586,10 @@ function MatchingWorkspace({
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={!canAssign}
|
disabled={!canAssign}
|
||||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
title={!canEdit ? 'Requires candidates.edit' : undefined}
|
||||||
onClick={onAssign}
|
onClick={onAssign}
|
||||||
>
|
>
|
||||||
{selectedPost?.title
|
Assign
|
||||||
? 'Assign' :'Assign'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,11 @@
|
||||||
The layout, the toolbar, the card and the 8-tab profile modal are the
|
The layout, the toolbar, the card and the 8-tab profile modal are the
|
||||||
originals, unchanged. Only the data source moved.
|
originals, unchanged. Only the data source moved.
|
||||||
|
|
||||||
The endpoint returns name, email, experience, application_status and the
|
The endpoint returns name, email, experience, application_status, suggested
|
||||||
suggested job title. It has no aiScore, skills, currentCompany, source or
|
job titles and the attached job_posts (with department). It has no skills or
|
||||||
department — the agent writes a verdict and prose, not a score, and no
|
currentCompany on list rows — those still come from the seed overlay. Each
|
||||||
résumé-derived skills are persisted. Each record is therefore OVERLAID on a
|
record is therefore OVERLAID on a seed candidate: real values win, seed fills
|
||||||
seed candidate: real values win, seed fills the rest, so the card renders
|
the rest, so the card renders exactly as it always did.
|
||||||
exactly as it always did.
|
|
||||||
|
|
||||||
Clicking a card opens CandidateProfile in place. It used to deep-link into
|
Clicking a card opens CandidateProfile in place. It used to deep-link into
|
||||||
/candidates, which stopped resolving once the ids became real user_ids.
|
/candidates, which stopped resolving once the ids became real user_ids.
|
||||||
|
|
@ -69,6 +68,25 @@ function years(value) {
|
||||||
return Number.isFinite(n) ? n : null
|
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.
|
* One API record overlaid on one seed candidate.
|
||||||
*
|
*
|
||||||
|
|
@ -84,6 +102,7 @@ function merge(row, template) {
|
||||||
|| row.current_title
|
|| row.current_title
|
||||||
const stage = STAGE_FROM_STATUS[row.application_status] || template.stage
|
const stage = STAGE_FROM_STATUS[row.application_status] || template.stage
|
||||||
const experience = years(row.experience)
|
const experience = years(row.experience)
|
||||||
|
const departments = departmentsOf(row)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...template,
|
...template,
|
||||||
|
|
@ -97,6 +116,10 @@ function merge(row, template) {
|
||||||
status: stage,
|
status: stage,
|
||||||
currentTitle: title || template.currentTitle,
|
currentTitle: title || template.currentTitle,
|
||||||
jobTitle: title || template.jobTitle,
|
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.
|
// Prefer real Form / platform tags from manual_upload; seed only as fallback.
|
||||||
source: row.source || template.source,
|
source: row.source || template.source,
|
||||||
// NO seed fallback. `ai_score` is the candidate's current ats_results row,
|
// NO seed fallback. `ai_score` is the candidate's current ats_results row,
|
||||||
|
|
@ -185,7 +208,7 @@ export default function TalentPool() {
|
||||||
const list = useMemo(
|
const list = useMemo(
|
||||||
() =>
|
() =>
|
||||||
pool.filter((c) => {
|
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
|
if (q && !(c.name + c.currentCompany + c.skills.join(' ')).toLowerCase().includes(q.toLowerCase())) return false
|
||||||
return true
|
return true
|
||||||
}),
|
}),
|
||||||
|
|
@ -235,7 +258,7 @@ export default function TalentPool() {
|
||||||
</div>
|
</div>
|
||||||
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
|
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
|
||||||
<option value="">All Departments</option>
|
<option value="">All Departments</option>
|
||||||
{departments.map((d) => <option key={d}>{d}</option>)}
|
{departments.map((d) => <option key={d} value={d}>{d}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<PageSizeField
|
<PageSizeField
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue