job_matching assignment by recruiter added
parent
17c99d24e4
commit
38249cc6f2
|
|
@ -1,6 +1,7 @@
|
||||||
from fastapi import APIRouter,Depends, Query
|
from fastapi import APIRouter,Depends, Query
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
from db_setup import get_session
|
from db_setup import get_session
|
||||||
from inbox.enums import Candidate_application_Status
|
from inbox.enums import Candidate_application_Status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
@ -11,6 +12,10 @@ load_dotenv()
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class AssignJobPostBody(BaseModel):
|
||||||
|
job_post_id: str | None = None
|
||||||
|
|
||||||
@router.get("/email/fetch")
|
@router.get("/email/fetch")
|
||||||
async def fetch_email(
|
async def fetch_email(
|
||||||
top:int=Query(100),
|
top:int=Query(100),
|
||||||
|
|
@ -88,6 +93,23 @@ async def rematch_inbox(
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/inbox/{record_id}/assign-job-post")
|
||||||
|
async def assign_job_post(
|
||||||
|
record_id: str,
|
||||||
|
payload: AssignJobPostBody,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=Email(session=session)
|
||||||
|
data=await service.assign_job_post(record_id,payload.job_post_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("/inbox/{record_id}/read")
|
@router.post("/inbox/{record_id}/read")
|
||||||
async def mark_inbox_read(
|
async def mark_inbox_read(
|
||||||
record_id: str,
|
record_id: str,
|
||||||
|
|
@ -125,6 +147,7 @@ async def get_all_applications(
|
||||||
record_id: str | None = Query(None),
|
record_id: str | None = Query(None),
|
||||||
application_status: Candidate_application_Status = Query(default=Candidate_application_Status.CLOSED),
|
application_status: Candidate_application_Status = Query(default=Candidate_application_Status.CLOSED),
|
||||||
isread: bool = Query(default=True),
|
isread: bool = Query(default=True),
|
||||||
|
assigned: bool | None = Query(default=None),
|
||||||
search: str | None = Query(None),
|
search: str | None = Query(None),
|
||||||
top: int | None = Query(None),
|
top: int | None = Query(None),
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
|
|
@ -135,21 +158,21 @@ async def get_all_applications(
|
||||||
service=Email(session=session)
|
service=Email(session=session)
|
||||||
|
|
||||||
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED):
|
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED):
|
||||||
items=await service.get_all_applications(top, skip, search, application_status=application_status)
|
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned)
|
||||||
total=await service.count_inbox_messages(search, application_status=application_status)
|
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned)
|
||||||
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
||||||
if isread==False:
|
if isread==False:
|
||||||
items=await service.get_all_applications(top, skip, search, isread=False)
|
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned)
|
||||||
total=await service.count_inbox_messages(search, isread=False)
|
total=await service.count_inbox_messages(search, isread=False, assigned=assigned)
|
||||||
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
||||||
if record_id:
|
if record_id:
|
||||||
item=await service.get_application_by_id(record_id)
|
item=await service.get_application_by_id(record_id)
|
||||||
return JSONResponse(content={"data":item,"total":1,"status_code":200})
|
return JSONResponse(content={"data":item,"total":1,"status_code":200})
|
||||||
|
|
||||||
items=await service.get_all_applications(top,skip,search)
|
items=await service.get_all_applications(top,skip,search,assigned=assigned)
|
||||||
total=await service.count_inbox_messages(search)
|
total=await service.count_inbox_messages(search,assigned=assigned)
|
||||||
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
|
||||||
|
|
@ -205,8 +205,8 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
resume_text: str | None = Field(default=None)
|
resume_text: str | None = Field(default=None)
|
||||||
experience: str | None = Field(default=None)
|
experience: str | None = Field(default=None)
|
||||||
suggested_job_post_ids: list[str] | None = Field(default=None, sa_column=Column(JSONB))
|
suggested_job_post_ids: list[str] | None = Field(default=None, sa_column=Column(JSONB))
|
||||||
assinged_job_post_id: uuid.UUID | None = Field(default=None,foreign_key="job_posts.id")
|
assigned_job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id", index=True)
|
||||||
|
|
||||||
match_summary: str | None = Field(default=None)
|
match_summary: str | None = Field(default=None)
|
||||||
match_reasoning: str | None = Field(default=None)
|
match_reasoning: str | None = Field(default=None)
|
||||||
match_status: str | None = Field(default=None)
|
match_status: str | None = Field(default=None)
|
||||||
|
|
@ -433,7 +433,7 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_inbox_messages(
|
async def get_inbox_messages(
|
||||||
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED
|
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None
|
||||||
):
|
):
|
||||||
statement = select(cls).order_by(cls.message_received_time.desc())
|
statement = select(cls).order_by(cls.message_received_time.desc())
|
||||||
if search:
|
if search:
|
||||||
|
|
@ -441,13 +441,18 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
|
|
||||||
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
|
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
|
||||||
statement = statement.where(cls.application_status==application_status)
|
statement = statement.where(cls.application_status==application_status)
|
||||||
|
|
||||||
|
if assigned is True:
|
||||||
|
statement = statement.where(cls.assigned_job_post_id.is_not(None))
|
||||||
|
elif assigned is False:
|
||||||
|
statement = statement.where(cls.assigned_job_post_id.is_(None))
|
||||||
|
|
||||||
if skip:
|
if skip:
|
||||||
statement = statement.offset(skip)
|
statement = statement.offset(skip)
|
||||||
|
|
||||||
if top is not None:
|
if top is not None:
|
||||||
statement = statement.limit(top)
|
statement = statement.limit(top)
|
||||||
|
|
||||||
if isread==False:
|
if isread==False:
|
||||||
statement = statement.where(cls.message_read==False)
|
statement = statement.where(cls.message_read==False)
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
|
|
@ -463,12 +468,34 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED):
|
async def set_assigned_job_post(cls, session: AsyncSession, record_id, job_post_id):
|
||||||
|
"""Set or clear assigned_job_post_id; returns the row or None if missing."""
|
||||||
|
row = await cls.get_inbox_message_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
if job_post_id is None:
|
||||||
|
row.assigned_job_post_id = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
row.assigned_job_post_id = uuid.UUID(str(job_post_id))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None):
|
||||||
statement = select(func.count()).select_from(cls)
|
statement = select(func.count()).select_from(cls)
|
||||||
if search:
|
if search:
|
||||||
statement = statement.where(cls._search_filter(search))
|
statement = statement.where(cls._search_filter(search))
|
||||||
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
|
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
|
||||||
statement = statement.where(cls.application_status==application_status)
|
statement = statement.where(cls.application_status==application_status)
|
||||||
|
if assigned is True:
|
||||||
|
statement = statement.where(cls.assigned_job_post_id.is_not(None))
|
||||||
|
elif assigned is False:
|
||||||
|
statement = statement.where(cls.assigned_job_post_id.is_(None))
|
||||||
if isread==False:
|
if isread==False:
|
||||||
statement = statement.where(cls.message_read==False)
|
statement = statement.where(cls.message_read==False)
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,13 @@ def serialize_message(message: Inbox_Messages) -> dict:
|
||||||
"message_reply": message.message_reply,
|
"message_reply": message.message_reply,
|
||||||
"file_path": message.file_path,
|
"file_path": message.file_path,
|
||||||
"suggested_job_post_ids": list(message.suggested_job_post_ids or []),
|
"suggested_job_post_ids": list(message.suggested_job_post_ids or []),
|
||||||
|
"assigned_job_post_id": str(message.assigned_job_post_id) if message.assigned_job_post_id else None,
|
||||||
"match_summary": message.match_summary,
|
"match_summary": message.match_summary,
|
||||||
"match_reasoning": message.match_reasoning,
|
"match_reasoning": message.match_reasoning,
|
||||||
"match_status": message.match_status,
|
"match_status": message.match_status,
|
||||||
"match_error": message.match_error,
|
"match_error": message.match_error,
|
||||||
"matched_at": message.matched_at.isoformat() if message.matched_at else None,
|
"matched_at": message.matched_at.isoformat() if message.matched_at else None,
|
||||||
|
"resume_text": message.resume_text,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -95,6 +97,13 @@ def serialize_application(message: Inbox_Messages) -> dict:
|
||||||
"attachment": _attachment_name(message),
|
"attachment": _attachment_name(message),
|
||||||
"has_attachment": message.attachment,
|
"has_attachment": message.attachment,
|
||||||
"resume_text": message.resume_text,
|
"resume_text": message.resume_text,
|
||||||
|
"suggested_job_post_ids": list(message.suggested_job_post_ids or []),
|
||||||
|
"assigned_job_post_id": str(message.assigned_job_post_id) if message.assigned_job_post_id else None,
|
||||||
|
"match_summary": message.match_summary,
|
||||||
|
"match_reasoning": message.match_reasoning,
|
||||||
|
"match_status": message.match_status,
|
||||||
|
"match_error": message.match_error,
|
||||||
|
"matched_at": message.matched_at.isoformat() if message.matched_at else None,
|
||||||
"ats_score": None,
|
"ats_score": None,
|
||||||
"phone": message.candidate_phone_number,
|
"phone": message.candidate_phone_number,
|
||||||
"experience": message.experience or "",
|
"experience": message.experience or "",
|
||||||
|
|
|
||||||
|
|
@ -92,15 +92,33 @@ class Email:
|
||||||
files=load_message_files(message)
|
files=load_message_files(message)
|
||||||
if files:
|
if files:
|
||||||
item["files"]=files
|
item["files"]=files
|
||||||
|
from job.candidate.views import CandidateView
|
||||||
|
cv=CandidateView(session=self.session)
|
||||||
|
suggested=[]
|
||||||
|
for job_id in item.get("suggested_job_post_ids") or []:
|
||||||
|
jp=await cv.get_job_post_by_id(record_id=job_id)
|
||||||
|
if jp:
|
||||||
|
if jp.get("is_deleted") or not jp.get("is_active"):
|
||||||
|
suggested.append({**jp,"unavailable":True})
|
||||||
|
else:
|
||||||
|
suggested.append(jp)
|
||||||
|
else:
|
||||||
|
suggested.append({"id":str(job_id),"unavailable":True})
|
||||||
|
item["suggested_job_posts"]=suggested
|
||||||
|
assigned_id=item.get("assigned_job_post_id")
|
||||||
|
if assigned_id:
|
||||||
|
item["assigned_job_post"]=await cv.get_job_post_by_id(record_id=assigned_id)
|
||||||
|
else:
|
||||||
|
item["assigned_job_post"]=None
|
||||||
return item
|
return item
|
||||||
|
|
||||||
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED):
|
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None):
|
||||||
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED):
|
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED):
|
||||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status)
|
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned)
|
||||||
elif isread==False:
|
elif isread==False:
|
||||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread)
|
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned)
|
||||||
else:
|
else:
|
||||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search)
|
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned)
|
||||||
return [serialize_application(m) for m in messages]
|
return [serialize_application(m) for m in messages]
|
||||||
|
|
||||||
async def get_application_by_id(self,record_id):
|
async def get_application_by_id(self,record_id):
|
||||||
|
|
@ -141,13 +159,27 @@ class Email:
|
||||||
results.append({"email":email,"sent":False})
|
results.append({"email":email,"sent":False})
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED):
|
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None):
|
||||||
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
|
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
|
||||||
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status)
|
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned)
|
||||||
elif isread==False:
|
elif isread==False:
|
||||||
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False)
|
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned)
|
||||||
else:
|
else:
|
||||||
return await Inbox_Messages.count_inbox_messages(self.session,search)
|
return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned)
|
||||||
|
|
||||||
|
async def assign_job_post(self,record_id,job_post_id):
|
||||||
|
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(status_code=404,detail="Message not found")
|
||||||
|
if job_post_id is not None:
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
post=await JobPosts.get_job_post_by_id(self.session,job_post_id)
|
||||||
|
if not post or post.is_deleted or not post.is_active:
|
||||||
|
raise HTTPException(status_code=422,detail="Job post is missing, deleted, or inactive")
|
||||||
|
updated=await Inbox_Messages.set_assigned_job_post(self.session,record_id,job_post_id)
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=404,detail="Message not found")
|
||||||
|
return await self.get_inbox_message_by_id(record_id)
|
||||||
|
|
||||||
async def mark_read(self,record_id):
|
async def mark_read(self,record_id):
|
||||||
message=await Inbox_Messages.mark_message_read(self.session,record_id)
|
message=await Inbox_Messages.mark_message_read(self.session,record_id)
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,33 @@ async def buffer_channels(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/job/fetch")
|
||||||
|
async def fetch_job_posts(
|
||||||
|
search: str | None = Query(None),
|
||||||
|
top: int | None = Query(None),
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
ids: str | None = Query(None),
|
||||||
|
active_only: bool = Query(True),
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.JOB_BOARD_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=JobPost(session=session)
|
||||||
|
id_list=[x.strip() for x in (ids or "").split(",") if x.strip()] or None
|
||||||
|
data,total=await service.fetch_job_posts(
|
||||||
|
search=search,
|
||||||
|
top=top,
|
||||||
|
skip=skip,
|
||||||
|
ids=id_list,
|
||||||
|
active_only=active_only,
|
||||||
|
)
|
||||||
|
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/fetch")
|
@router.get("/candidate/fetch")
|
||||||
async def fetch_candidate(
|
async def fetch_candidate(
|
||||||
user_id:str=Query(None),
|
user_id:str=Query(None),
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ def serialize_candidate_profile(
|
||||||
"current_employment": message.current_employment if message else None,
|
"current_employment": message.current_employment if message else None,
|
||||||
"resume_text": message.resume_text if message else None,
|
"resume_text": message.resume_text if message else None,
|
||||||
"suggested_job_post_ids": list(message.suggested_job_post_ids or []) if message else [],
|
"suggested_job_post_ids": list(message.suggested_job_post_ids or []) if message else [],
|
||||||
|
"assigned_job_post_id": str(message.assigned_job_post_id) if message and message.assigned_job_post_id else None,
|
||||||
"match_summary": message.match_summary if message else None,
|
"match_summary": message.match_summary if message else None,
|
||||||
"match_reasoning": message.match_reasoning if message else None,
|
"match_reasoning": message.match_reasoning if message else None,
|
||||||
"match_status": message.match_status if message else None,
|
"match_status": message.match_status if message else None,
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ class CandidateView:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
async def get_job_post_by_id(self,record_id,data=None):
|
async def get_job_post_by_id(self,record_id,data=None,*,as_assigned=False):
|
||||||
"""Load full job_posts row and optionally append it onto a candidate payload."""
|
"""Load full job_posts row and optionally append it onto a candidate payload."""
|
||||||
try:
|
try:
|
||||||
job_post_data=await JobPosts.get_job_post_by_id(session=self.session,record_id=record_id)
|
job_post_data=await JobPosts.get_job_post_by_id(session=self.session,record_id=record_id)
|
||||||
|
|
@ -128,12 +128,20 @@ class CandidateView:
|
||||||
return None
|
return None
|
||||||
payload=serialize_job_post(job_post_data)
|
payload=serialize_job_post(job_post_data)
|
||||||
if isinstance(data,dict):
|
if isinstance(data,dict):
|
||||||
data.setdefault("job_posts",[]).append(payload)
|
if as_assigned:
|
||||||
if data.get("recruiter") is None and payload.get("created_by_name"):
|
data["assigned_job_post"]=payload
|
||||||
data["recruiter"]=payload.get("created_by_name")
|
if payload.get("created_by_name"):
|
||||||
data["recruiter_id"]=payload.get("created_by")
|
data["recruiter"]=payload.get("created_by_name")
|
||||||
if data.get("job_title") is None and payload.get("title"):
|
data["recruiter_id"]=payload.get("created_by")
|
||||||
data["job_title"]=payload.get("title")
|
if payload.get("title"):
|
||||||
|
data["job_title"]=payload.get("title")
|
||||||
|
else:
|
||||||
|
data.setdefault("job_posts",[]).append(payload)
|
||||||
|
if data.get("recruiter") is None and payload.get("created_by_name"):
|
||||||
|
data["recruiter"]=payload.get("created_by_name")
|
||||||
|
data["recruiter_id"]=payload.get("created_by")
|
||||||
|
if data.get("job_title") is None and payload.get("title"):
|
||||||
|
data["job_title"]=payload.get("title")
|
||||||
return payload
|
return payload
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
@ -146,6 +154,10 @@ class CandidateView:
|
||||||
for record in records:
|
for record in records:
|
||||||
payload=serialize_candidate_profile(record)
|
payload=serialize_candidate_profile(record)
|
||||||
payload["job_posts"]=[]
|
payload["job_posts"]=[]
|
||||||
|
payload["assigned_job_post"]=None
|
||||||
|
assigned_id=payload.get("assigned_job_post_id")
|
||||||
|
if assigned_id:
|
||||||
|
await self.get_job_post_by_id(record_id=assigned_id,data=payload,as_assigned=True)
|
||||||
for job_id in payload.get("suggested_job_post_ids") or []:
|
for job_id in payload.get("suggested_job_post_ids") or []:
|
||||||
await self.get_job_post_by_id(record_id=job_id,data=payload)
|
await self.get_job_post_by_id(record_id=job_id,data=payload)
|
||||||
enriched.append(payload)
|
enriched.append(payload)
|
||||||
|
|
@ -163,6 +175,7 @@ class CandidateView:
|
||||||
feedback=[]
|
feedback=[]
|
||||||
documents=[]
|
documents=[]
|
||||||
job_posts=[]
|
job_posts=[]
|
||||||
|
assigned_job_post=None
|
||||||
base=None
|
base=None
|
||||||
user_id=None
|
user_id=None
|
||||||
favorite=None
|
favorite=None
|
||||||
|
|
@ -178,6 +191,18 @@ class CandidateView:
|
||||||
activity.extend(payload.get("activity") or [])
|
activity.extend(payload.get("activity") or [])
|
||||||
feedback.extend(payload.get("feedback") or [])
|
feedback.extend(payload.get("feedback") or [])
|
||||||
documents.extend(payload.get("documents") or [])
|
documents.extend(payload.get("documents") or [])
|
||||||
|
if payload.get("assigned_job_post_id") and assigned_job_post is None:
|
||||||
|
await self.get_job_post_by_id(
|
||||||
|
record_id=payload.get("assigned_job_post_id"),
|
||||||
|
data=payload,
|
||||||
|
as_assigned=True,
|
||||||
|
)
|
||||||
|
assigned_job_post=payload.get("assigned_job_post")
|
||||||
|
if base.get("recruiter") is None and payload.get("recruiter"):
|
||||||
|
base["recruiter"]=payload.get("recruiter")
|
||||||
|
base["recruiter_id"]=payload.get("recruiter_id")
|
||||||
|
if base.get("job_title") is None and payload.get("job_title"):
|
||||||
|
base["job_title"]=payload.get("job_title")
|
||||||
for job_id in payload.get("suggested_job_post_ids") or []:
|
for job_id in payload.get("suggested_job_post_ids") or []:
|
||||||
await self.get_job_post_by_id(record_id=job_id,data=payload)
|
await self.get_job_post_by_id(record_id=job_id,data=payload)
|
||||||
for jp in payload.get("job_posts") or []:
|
for jp in payload.get("job_posts") or []:
|
||||||
|
|
@ -209,4 +234,12 @@ class CandidateView:
|
||||||
base["documents"]=documents
|
base["documents"]=documents
|
||||||
base["notes"]=notes
|
base["notes"]=notes
|
||||||
base["job_posts"]=job_posts or base.get("job_posts") or []
|
base["job_posts"]=job_posts or base.get("job_posts") or []
|
||||||
|
base["assigned_job_post"]=assigned_job_post
|
||||||
|
if assigned_job_post:
|
||||||
|
base["assigned_job_post_id"]=assigned_job_post.get("id")
|
||||||
|
if assigned_job_post.get("created_by_name"):
|
||||||
|
base["recruiter"]=assigned_job_post.get("created_by_name")
|
||||||
|
base["recruiter_id"]=assigned_job_post.get("created_by")
|
||||||
|
if assigned_job_post.get("title"):
|
||||||
|
base["job_title"]=assigned_job_post.get("title")
|
||||||
return base
|
return base
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from sqlalchemy import DateTime, JSON
|
from sqlalchemy import DateTime, JSON, func, or_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import Field, Relationship, SQLModel, select
|
from sqlmodel import Field, Relationship, SQLModel, select
|
||||||
|
|
||||||
|
|
@ -69,6 +69,57 @@ class JobPosts(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_by_ids(cls, session: AsyncSession, ids: list[str], *, active_only: bool = True):
|
||||||
|
uids = []
|
||||||
|
for raw in ids or []:
|
||||||
|
uid = cls._as_uuid(raw)
|
||||||
|
if uid is not None:
|
||||||
|
uids.append(uid)
|
||||||
|
if not uids:
|
||||||
|
return []
|
||||||
|
statement = select(cls).where(cls.id.in_(uids))
|
||||||
|
if active_only:
|
||||||
|
statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712
|
||||||
|
result = await session.execute(statement)
|
||||||
|
rows = list(result.scalars().all())
|
||||||
|
by_id = {str(r.id): r for r in rows}
|
||||||
|
# Preserve request order so suggestion ranks stay stable.
|
||||||
|
return [by_id[str(u)] for u in uids if str(u) in by_id]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def fetch_job_posts(
|
||||||
|
cls,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
search: str | None = None,
|
||||||
|
top: int | None = None,
|
||||||
|
skip: int = 0,
|
||||||
|
ids: list[str] | None = None,
|
||||||
|
active_only: bool = True,
|
||||||
|
):
|
||||||
|
if ids:
|
||||||
|
rows = await cls.get_by_ids(session, ids, active_only=active_only)
|
||||||
|
return rows, len(rows)
|
||||||
|
|
||||||
|
statement = select(cls)
|
||||||
|
if active_only:
|
||||||
|
statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712
|
||||||
|
if search:
|
||||||
|
like = f"%{search.strip()}%"
|
||||||
|
statement = statement.where(
|
||||||
|
or_(cls.title.ilike(like), cls.location.ilike(like))
|
||||||
|
)
|
||||||
|
count_statement = select(func.count()).select_from(statement.subquery())
|
||||||
|
total = (await session.execute(count_statement)).scalar_one()
|
||||||
|
statement = statement.order_by(cls.created_at.desc())
|
||||||
|
if skip:
|
||||||
|
statement = statement.offset(skip)
|
||||||
|
if top is not None:
|
||||||
|
statement = statement.limit(top)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return list(result.scalars().all()), total
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def insert_job_post(cls, session: AsyncSession, fields: dict):
|
async def insert_job_post(cls, session: AsyncSession, fields: dict):
|
||||||
row = cls(**fields)
|
row = cls(**fields)
|
||||||
|
|
|
||||||
|
|
@ -132,3 +132,14 @@ class JobPost:
|
||||||
return await list_buffer_channels()
|
return await list_buffer_channels()
|
||||||
except (httpx.HTTPError,BufferError,RuntimeError) as e:
|
except (httpx.HTTPError,BufferError,RuntimeError) as e:
|
||||||
raise HTTPException(status_code=502,detail="Failed to list Buffer channels") from e
|
raise HTTPException(status_code=502,detail="Failed to list Buffer channels") from e
|
||||||
|
|
||||||
|
async def fetch_job_posts(self,search=None,top=None,skip=0,ids=None,active_only=True):
|
||||||
|
rows,total=await JobPosts.fetch_job_posts(
|
||||||
|
self.session,
|
||||||
|
search=search,
|
||||||
|
top=top,
|
||||||
|
skip=skip,
|
||||||
|
ids=ids,
|
||||||
|
active_only=active_only,
|
||||||
|
)
|
||||||
|
return [serialize_job_post(r) for r in rows],total
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import ConfirmEmail from './pages/ConfirmEmail'
|
||||||
const SCREENS = {
|
const SCREENS = {
|
||||||
dashboard: lazy(() => import('./screens/Dashboard')),
|
dashboard: lazy(() => import('./screens/Dashboard')),
|
||||||
inbox: lazy(() => import('./screens/Inbox')),
|
inbox: lazy(() => import('./screens/Inbox')),
|
||||||
|
matching: lazy(() => import('./screens/Matching')),
|
||||||
jobs: lazy(() => import('./screens/Jobs')),
|
jobs: lazy(() => import('./screens/Jobs')),
|
||||||
candidates: lazy(() => import('./screens/Candidates')),
|
candidates: lazy(() => import('./screens/Candidates')),
|
||||||
talentpool: lazy(() => import('./screens/TalentPool')),
|
talentpool: lazy(() => import('./screens/TalentPool')),
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import ConfirmEmail from '../pages/ConfirmEmail'
|
||||||
|
|
||||||
import Dashboard from '../screens/Dashboard'
|
import Dashboard from '../screens/Dashboard'
|
||||||
import Inbox from '../screens/Inbox'
|
import Inbox from '../screens/Inbox'
|
||||||
|
import Matching from '../screens/Matching'
|
||||||
import Jobs from '../screens/Jobs'
|
import Jobs from '../screens/Jobs'
|
||||||
import Candidates from '../screens/Candidates'
|
import Candidates from '../screens/Candidates'
|
||||||
import TalentPool from '../screens/TalentPool'
|
import TalentPool from '../screens/TalentPool'
|
||||||
|
|
@ -48,7 +49,7 @@ import Settings from '../screens/Settings'
|
||||||
import Help from '../screens/Help'
|
import Help from '../screens/Help'
|
||||||
|
|
||||||
const SCREENS = {
|
const SCREENS = {
|
||||||
dashboard: Dashboard, inbox: Inbox, jobs: Jobs, candidates: Candidates,
|
dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates,
|
||||||
talentpool: TalentPool, pipeline: Pipeline, import: CvImport, jobboard: JobBoard,
|
talentpool: TalentPool, pipeline: Pipeline, import: CvImport, jobboard: JobBoard,
|
||||||
recruiterhub: RecruiterHub, tasks: Tasks, aiassistant: AiAssistant,
|
recruiterhub: RecruiterHub, tasks: Tasks, aiassistant: AiAssistant,
|
||||||
interviews: Interviews, assessments: Assessments, offers: Offers,
|
interviews: Interviews, assessments: Assessments, offers: Offers,
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,26 @@ export function listMessages() {
|
||||||
*
|
*
|
||||||
* Unlike /inbox/fetch this one IS permissioned server-side
|
* Unlike /inbox/fetch this one IS permissioned server-side
|
||||||
* (require_permission(INBOX_VIEW)), so a caller without the tag gets a 403.
|
* (require_permission(INBOX_VIEW)), so a caller without the tag gets a 403.
|
||||||
|
*
|
||||||
|
* `assigned` is tri-valued: omit for no filter, true for rows with an
|
||||||
|
* assigned_job_post_id, false for the Job Matching queue.
|
||||||
*/
|
*/
|
||||||
export function listApplications({ search, top, skip, recordId, isread, applicationStatus } = {}) {
|
export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned } = {}) {
|
||||||
return request('/inbox/all-applications', {
|
return request('/inbox/all-applications', {
|
||||||
// `isread` is tri-valued on the wire: omit it for every tab (server defaults
|
// `isread` is tri-valued on the wire: omit it for every tab (server defaults
|
||||||
// to true = no filter), send false for the Unread tab only. buildUrl drops
|
// to true = no filter), send false for the Unread tab only. buildUrl drops
|
||||||
// undefined but keeps false, so `isread: undefined` sends no param at all.
|
// undefined but keeps false, so `isread: undefined` sends no param at all.
|
||||||
// Same for `application_status`: omit for every tab (server defaults to
|
// Same for `application_status`: omit for every tab (server defaults to
|
||||||
// CLOSED = no filter), send PROCESS / REJECTED for those tabs only.
|
// CLOSED = no filter), send PROCESS / REJECTED for those tabs only.
|
||||||
params: { search, top, skip, record_id: recordId, isread, application_status: applicationStatus },
|
params: {
|
||||||
|
search,
|
||||||
|
top,
|
||||||
|
skip,
|
||||||
|
record_id: recordId,
|
||||||
|
isread,
|
||||||
|
application_status: applicationStatus,
|
||||||
|
assigned,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,3 +60,16 @@ export function syncMailbox({ token, top, skip } = {}) {
|
||||||
export function markRead(recordId) {
|
export function markRead(recordId) {
|
||||||
return request(`/inbox/${recordId}/read`, { method: 'POST' })
|
return request(`/inbox/${recordId}/read`, { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Assign (or clear with null) the job post for one application. Requires inbox.edit. */
|
||||||
|
export function assignJobPost(recordId, jobPostId) {
|
||||||
|
return request(`/inbox/${recordId}/assign-job-post`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { job_post_id: jobPostId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-queue the matching agent for one application. Requires inbox.edit. */
|
||||||
|
export function rematch(recordId) {
|
||||||
|
return request(`/inbox/${recordId}/match`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { request } from '../lib/apiClient'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active job posts — Job Matching hydrates suggestions and the manual picker.
|
||||||
|
*
|
||||||
|
* Permissioned with job_board.view (not jobs.*). `ids` is a comma-joined list
|
||||||
|
* so one round trip can resolve a whole suggestion rail.
|
||||||
|
*/
|
||||||
|
export function list({ search, top, skip, ids, activeOnly = true } = {}) {
|
||||||
|
const idParam = Array.isArray(ids) ? ids.filter(Boolean).join(',') : ids
|
||||||
|
return request('/job/fetch', {
|
||||||
|
params: {
|
||||||
|
search,
|
||||||
|
top,
|
||||||
|
skip,
|
||||||
|
ids: idParam || undefined,
|
||||||
|
active_only: activeOnly,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -18,6 +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: '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' },
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { seedQuery } from '../data/seedQueries'
|
import { seedQuery } from '../data/seedQueries'
|
||||||
|
import { qk } from '../lib/queryKeys'
|
||||||
|
import * as inboxApi from '../api/inbox'
|
||||||
|
|
||||||
const SIDEBAR_KEY = 'tf-sidebar'
|
const SIDEBAR_KEY = 'tf-sidebar'
|
||||||
|
|
||||||
|
|
@ -76,20 +78,31 @@ export function useHotkeys({ onEscape }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The four sidebar badge counts. App.updateBadges() was an imperative DOM write
|
* The sidebar badge counts. App.updateBadges() was an imperative DOM write
|
||||||
* 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
|
||||||
|
* exists to drive to zero.
|
||||||
*/
|
*/
|
||||||
export function useBadges() {
|
export function useBadges() {
|
||||||
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
|
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
|
||||||
const { data: notifications = [] } = useQuery(seedQuery('notifications'))
|
const { data: notifications = [] } = useQuery(seedQuery('notifications'))
|
||||||
const { data: tasks = [] } = useQuery(seedQuery('tasks'))
|
const { data: tasks = [] } = useQuery(seedQuery('tasks'))
|
||||||
const { data: inbox = [] } = useQuery(seedQuery('inbox'))
|
const { data: inbox = [] } = useQuery(seedQuery('inbox'))
|
||||||
|
const { data: matchingTotal = 0 } = useQuery({
|
||||||
|
queryKey: qk.mailbox.assignments({ assigned: false }),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await inboxApi.listApplications({ assigned: false, top: 1 })
|
||||||
|
return res?.total ?? 0
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
jobs: jobs.filter((j) => j.status === 'Open').length,
|
jobs: jobs.filter((j) => j.status === 'Open').length,
|
||||||
notifications: notifications.filter((n) => n.unread).length,
|
notifications: notifications.filter((n) => n.unread).length,
|
||||||
tasks: tasks.filter((t) => !t.done).length,
|
tasks: tasks.filter((t) => !t.done).length,
|
||||||
inbox: inbox.filter((i) => i.unread).length,
|
inbox: inbox.filter((i) => i.unread).length,
|
||||||
|
matching: matchingTotal,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ export const qk = {
|
||||||
messages: () => ['mailbox', 'messages'],
|
messages: () => ['mailbox', 'messages'],
|
||||||
applications: (p = {}) => ['mailbox', 'applications', p],
|
applications: (p = {}) => ['mailbox', 'applications', p],
|
||||||
message: (id) => ['mailbox', 'message', id],
|
message: (id) => ['mailbox', 'message', id],
|
||||||
|
assignments: (p = {}) => ['mailbox', 'assignments', p],
|
||||||
|
},
|
||||||
|
jobPosts: {
|
||||||
|
all: () => ['jobPosts'],
|
||||||
|
list: (p = {}) => ['jobPosts', 'list', p],
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- seed-backed buckets ---
|
// --- seed-backed buckets ---
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,17 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{live.assigned_job_post && (
|
||||||
|
<>
|
||||||
|
<div style={LABEL}>Assigned Role</div>
|
||||||
|
<div className="k-tags" style={{ marginBottom: 14 }}>
|
||||||
|
<span className="tag" style={{ background: 'var(--primary-soft)', color: 'var(--primary-fg)' }}>
|
||||||
|
{live.assigned_job_post.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{live.job_posts?.length > 0 && (
|
{live.job_posts?.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div style={LABEL}>Suggested Roles</div>
|
<div style={LABEL}>Suggested Roles</div>
|
||||||
|
|
|
||||||
|
|
@ -524,7 +524,6 @@ export default function Inbox() {
|
||||||
onPreview={() => setPreviewing(selected)}
|
onPreview={() => setPreviewing(selected)}
|
||||||
onImport={() => importItem(selected)}
|
onImport={() => importItem(selected)}
|
||||||
onParse={() => parseResume(selected)}
|
onParse={() => parseResume(selected)}
|
||||||
onAssign={() => setAssigning(selected)}
|
|
||||||
onMove={() => moveToPipeline(selected)}
|
onMove={() => moveToPipeline(selected)}
|
||||||
onNote={() => setNoting(selected)}
|
onNote={() => setNoting(selected)}
|
||||||
onReject={() => reject(selected)}
|
onReject={() => reject(selected)}
|
||||||
|
|
@ -603,7 +602,8 @@ function orDash(value, suffix = '') {
|
||||||
return value == null || value === '' ? '—' : `${value}${suffix}`
|
return value == null || value === '' ? '—' : `${value}${suffix}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onAssign, onMove, onNote, onReject }) {
|
function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onMove, onNote, onReject }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'
|
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'
|
||||||
const ringColor = i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)'
|
const ringColor = i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)'
|
||||||
// Every action below writes to a table column or an endpoint that does not
|
// Every action below writes to a table column or an endpoint that does not
|
||||||
|
|
@ -700,8 +700,11 @@ function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onA
|
||||||
<button className="btn btn-secondary" onClick={onParse} disabled title={noBackend}>
|
<button className="btn btn-secondary" onClick={onParse} disabled title={noBackend}>
|
||||||
<Icon name="sparkles" /> Parse Resume
|
<Icon name="sparkles" /> Parse Resume
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary" onClick={onAssign} disabled title={noBackend}>
|
<button
|
||||||
<Icon name="users" /> Assign Recruiter
|
className="btn btn-secondary"
|
||||||
|
onClick={() => navigate(`/matching?record=${i.id}`)}
|
||||||
|
>
|
||||||
|
<Icon name="target" /> Assign Job
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary" onClick={onMove} disabled title={noBackend}>
|
<button className="btn btn-secondary" onClick={onMove} disabled title={noBackend}>
|
||||||
<Icon name="layers" /> Move to Pipeline
|
<Icon name="layers" /> Move to Pipeline
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,868 @@
|
||||||
|
/* ============================================================
|
||||||
|
Job Matching — assign each inbound application to exactly one job post.
|
||||||
|
|
||||||
|
Queue layout mirrors Inbox (Tabs over a .split). 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.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import Modal from '../ui/Modal'
|
||||||
|
import { Tabs } from '../ui/Tabs'
|
||||||
|
import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives'
|
||||||
|
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 jobPostsApi from '../api/jobPosts'
|
||||||
|
import {
|
||||||
|
avatarColor, fmtDate, initials as initialsOf, inboxSources, sourceMeta,
|
||||||
|
} 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: {},
|
||||||
|
all: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESUME_STATUS = {
|
||||||
|
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
|
||||||
|
failed: 'Failed', dlq: 'Failed', skipped: 'Pending',
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(value) {
|
||||||
|
if (!value) return null
|
||||||
|
const d = new Date(value)
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceFrom(messageTo) {
|
||||||
|
const raw = (messageTo || '').trim()
|
||||||
|
if (!raw) 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 SourceChip({ item }) {
|
||||||
|
return (
|
||||||
|
<span className="source-chip" style={{ '--chip': item.sourceMeta?.color }}>
|
||||||
|
<span className="source-dot" />
|
||||||
|
{item.source}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Requirement chip lights green when the resume text contains it (client-side). */
|
||||||
|
function reqInResume(req, resumeText) {
|
||||||
|
if (!req || !resumeText) return false
|
||||||
|
const needle = String(req).trim().toLowerCase()
|
||||||
|
if (!needle) return false
|
||||||
|
return resumeText.toLowerCase().includes(needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapApplication(row) {
|
||||||
|
const name = row.name || row.email || 'Unknown'
|
||||||
|
const suggested = Array.isArray(row.suggested_job_post_ids) ? row.suggested_job_post_ids : []
|
||||||
|
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 || '',
|
||||||
|
suggestedIds: suggested.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)',
|
||||||
|
...sourceFrom(row.message_to),
|
||||||
|
body: htmlToText(row.body),
|
||||||
|
resumeText: row.resume_text || '',
|
||||||
|
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'
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobCard({ post, rank, selected, onSelect, resumeText, manual }) {
|
||||||
|
const unavailable = Boolean(post?.unavailable) || !post?.title
|
||||||
|
const title = post?.title || 'Unavailable'
|
||||||
|
const meta = [
|
||||||
|
post?.employment_type,
|
||||||
|
post?.location,
|
||||||
|
post?.experience_min != null || post?.experience_max != null
|
||||||
|
? `${post?.experience_min ?? '?'}–${post?.experience_max ?? '?'} yrs`
|
||||||
|
: null,
|
||||||
|
].filter(Boolean).join(' · ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
tabIndex={0}
|
||||||
|
className="list-row"
|
||||||
|
onClick={() => !unavailable && onSelect(post.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (unavailable) return
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onSelect(post.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
cursor: unavailable ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: unavailable ? 0.55 : 1,
|
||||||
|
borderColor: selected ? 'var(--primary)' : undefined,
|
||||||
|
boxShadow: selected ? 'var(--ring)' : undefined,
|
||||||
|
marginBottom: 8,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="lr-main" style={{ minWidth: 0 }}>
|
||||||
|
<div className="flex items-center gap-8" style={{ marginBottom: 4, flexWrap: 'wrap' }}>
|
||||||
|
<span className="tag">{manual ? 'Manual' : `AI #${rank}`}</span>
|
||||||
|
<div className="lr-title">{title}</div>
|
||||||
|
{unavailable ? (
|
||||||
|
<Badge className="b-gray">Unavailable</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge>{post.status || 'draft'}</Badge>
|
||||||
|
)}
|
||||||
|
{selected && <Icon name="check-circle" />}
|
||||||
|
</div>
|
||||||
|
{meta && <div className="cell-sub">{meta}</div>}
|
||||||
|
{!unavailable && (post.requirements || []).length > 0 && (
|
||||||
|
<div className="k-tags" style={{ marginTop: 8 }}>
|
||||||
|
{(post.requirements || []).slice(0, 8).map((req) => {
|
||||||
|
const hit = reqInResume(req, resumeText)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={req}
|
||||||
|
className="tag"
|
||||||
|
style={hit ? {
|
||||||
|
background: 'var(--success-soft)',
|
||||||
|
color: 'var(--success-fg)',
|
||||||
|
} : undefined}
|
||||||
|
>
|
||||||
|
{req}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PickRoleModal({ onClose, onPick }) {
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const { data = [], isPending, isError, error } = useQuery({
|
||||||
|
queryKey: qk.jobPosts.list({ search: q || undefined, top: 30 }),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await jobPostsApi.list({ search: q || undefined, top: 30 })
|
||||||
|
return Array.isArray(res?.data) ? res.data : []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Choose a different role"
|
||||||
|
subtitle="Search open job posts"
|
||||||
|
size="modal-lg"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={<button className="btn btn-secondary" onClick={onClose}>Cancel</button>}
|
||||||
|
>
|
||||||
|
<div className="toolbar-search" style={{ maxWidth: 'none', marginBottom: 14 }}>
|
||||||
|
<Icon name="search" />
|
||||||
|
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search title or location…" autoFocus />
|
||||||
|
</div>
|
||||||
|
{isPending && <EmptyState icon="briefcase" title="Loading…">Fetching job posts.</EmptyState>}
|
||||||
|
{isError && (
|
||||||
|
<EmptyState icon="alert" title="Couldn’t load roles">
|
||||||
|
{friendlyAuthError(error, 'Request failed')}
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
{!isPending && !isError && data.length === 0 && (
|
||||||
|
<EmptyState icon="briefcase" title="No roles found">Try a different search.</EmptyState>
|
||||||
|
)}
|
||||||
|
<div className="list-tight">
|
||||||
|
{data.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="list-row"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => { onPick(p); onClose() }}
|
||||||
|
>
|
||||||
|
<div className="lr-main">
|
||||||
|
<div className="lr-title">{p.title}</div>
|
||||||
|
<div className="lr-sub">
|
||||||
|
{[p.employment_type, p.location].filter(Boolean).join(' · ') || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge>{p.status}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Matching() {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const { can } = useAuth()
|
||||||
|
const canEdit = can('inbox.edit')
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const deepLink = searchParams.get('record')
|
||||||
|
|
||||||
|
const [tab, setTab] = useState('needs')
|
||||||
|
const [selectedId, setSelectedId] = useState(deepLink || null)
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
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 listQuery = useQuery({
|
||||||
|
queryKey: qk.mailbox.assignments({ ...tabFilter, tab }),
|
||||||
|
queryFn: () => fetchApplications(tabFilter),
|
||||||
|
})
|
||||||
|
|
||||||
|
const needsCount = useQuery({
|
||||||
|
queryKey: qk.mailbox.assignments({ assigned: false }),
|
||||||
|
queryFn: async () => (await inboxApi.listApplications({ assigned: false, top: 1 }))?.total ?? 0,
|
||||||
|
})
|
||||||
|
const assignedCount = useQuery({
|
||||||
|
queryKey: qk.mailbox.assignments({ assigned: true }),
|
||||||
|
queryFn: async () => (await inboxApi.listApplications({ assigned: true, top: 1 }))?.total ?? 0,
|
||||||
|
})
|
||||||
|
const allCount = useQuery({
|
||||||
|
queryKey: qk.mailbox.assignments({}),
|
||||||
|
queryFn: async () => (await inboxApi.listApplications({ top: 1 }))?.total ?? 0,
|
||||||
|
})
|
||||||
|
const noneCountQuery = useQuery({
|
||||||
|
queryKey: qk.mailbox.assignments({ kind: 'none' }),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetchApplications({})
|
||||||
|
return res.rows.filter((r) => !r.assignedId && r.suggestedIds.length === 0).length
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = listQuery.data?.rows ?? []
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let list = rows
|
||||||
|
if (tab === 'none') {
|
||||||
|
list = list.filter((r) => !r.assignedId && r.suggestedIds.length === 0)
|
||||||
|
}
|
||||||
|
const needle = q.trim().toLowerCase()
|
||||||
|
if (!needle) return list
|
||||||
|
return list.filter((r) => (
|
||||||
|
r.name.toLowerCase().includes(needle)
|
||||||
|
|| r.position.toLowerCase().includes(needle)
|
||||||
|
|| r.email.toLowerCase().includes(needle)
|
||||||
|
))
|
||||||
|
}, [rows, tab, q])
|
||||||
|
|
||||||
|
const noneCount = noneCountQuery.data ?? 0
|
||||||
|
|
||||||
|
// Preselect deep link once, then clear the query so refresh doesn't re-pin.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!deepLink) return undefined
|
||||||
|
setSelectedId(deepLink)
|
||||||
|
setSearchParams({}, { replace: true })
|
||||||
|
return undefined
|
||||||
|
}, [deepLink, setSearchParams])
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
queryKey: qk.mailbox.message(selectedId),
|
||||||
|
queryFn: () => fetchDetail(selectedId),
|
||||||
|
enabled: Boolean(selectedId),
|
||||||
|
})
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
const titlesQuery = useQuery({
|
||||||
|
queryKey: qk.jobPosts.list({ ids: hydrateIds }),
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!hydrateIds.length) return []
|
||||||
|
const res = await jobPostsApi.list({ ids: hydrateIds, activeOnly: false })
|
||||||
|
return Array.isArray(res?.data) ? res.data : []
|
||||||
|
},
|
||||||
|
enabled: hydrateIds.length > 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const titleById = useMemo(() => {
|
||||||
|
const map = new Map()
|
||||||
|
for (const p of titlesQuery.data || []) map.set(String(p.id), p.title)
|
||||||
|
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])
|
||||||
|
|
||||||
|
const selectedPost = useMemo(() => {
|
||||||
|
if (!selection) return null
|
||||||
|
if (manualPost && String(manualPost.id) === String(selection)) return manualPost
|
||||||
|
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])
|
||||||
|
|
||||||
|
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 }
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast(friendlyAuthError(err, 'Could not assign job post.'), 'error')
|
||||||
|
},
|
||||||
|
onSuccess: (_res, vars) => {
|
||||||
|
const name = listRow?.name || detail?.name || 'Candidate'
|
||||||
|
const title = selectedPost?.title || titleById.get(vars.jobPostId) || 'role'
|
||||||
|
if (vars.jobPostId) toast(`${name} → ${title}`, 'success')
|
||||||
|
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() })
|
||||||
|
|
||||||
|
// 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
|
||||||
|
setSelectedId(next?.id || null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||||
|
if (e.key === 'j' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
const idx = filtered.findIndex((r) => r.id === selectedId)
|
||||||
|
const next = filtered[Math.min(filtered.length - 1, (idx < 0 ? 0 : idx + 1))]
|
||||||
|
if (next) setSelectedId(next.id)
|
||||||
|
} else if (e.key === 'k' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
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 })
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setSelection(detail?.assignedId || null)
|
||||||
|
setManualPost(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [filtered, selectedId, suggestionCards, 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 matchFailed = ['failed', 'no_text', 'dlq'].includes(detail?.matchStatus)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>Job Matching</h1>
|
||||||
|
<p className="page-sub">Route applications to the right open role</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!canEdit && (
|
||||||
|
<div className="alert alert-danger" style={{ marginBottom: 14 }}>
|
||||||
|
Your account does not hold <code>inbox.edit</code>, which the server requires to
|
||||||
|
assign, unassign, or retry a match. Controls below stay disabled.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div style={{ padding: '0 8px', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onChange={(t) => { setTab(t); setSelectedId(null) }}
|
||||||
|
tabs={TABS.map((t) => ({
|
||||||
|
key: t.key,
|
||||||
|
label: t.label,
|
||||||
|
count: counts[t.key],
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="split">
|
||||||
|
<div className="split-list">
|
||||||
|
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<div className="toolbar-search" style={{ maxWidth: 'none' }}>
|
||||||
|
<Icon name="search" />
|
||||||
|
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{listQuery.isPending && (
|
||||||
|
<EmptyState icon="target" title="Loading…">Fetching applications.</EmptyState>
|
||||||
|
)}
|
||||||
|
{listQuery.isError && (
|
||||||
|
<EmptyState icon="alert" title="Couldn’t load queue">
|
||||||
|
{friendlyAuthError(listQuery.error, 'Request failed')}
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
{listQuery.isSuccess && filtered.length === 0 && (
|
||||||
|
<EmptyState icon="check-circle" title="Queue clear">
|
||||||
|
{tab === 'needs'
|
||||||
|
? 'Every application in this view has a role.'
|
||||||
|
: 'Nothing matches this filter.'}
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
{filtered.map((i) => (
|
||||||
|
<div
|
||||||
|
key={i.id}
|
||||||
|
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
|
||||||
|
onClick={() => setSelectedId(i.id)}
|
||||||
|
>
|
||||||
|
<Avatar name={i.name} initials={i.initials} color={i.color} />
|
||||||
|
<div className="ii-main">
|
||||||
|
<div className="ii-name">{i.name}</div>
|
||||||
|
<div className="ii-pos">{i.position}</div>
|
||||||
|
<div className="ii-meta">
|
||||||
|
<SourceChip item={i} />
|
||||||
|
<AssignmentBadge item={i} titleById={titleById} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
) : detailQuery.isError ? (
|
||||||
|
<div style={{ padding: '100px 20px' }}>
|
||||||
|
<EmptyState icon="alert" title="Couldn’t load this application">
|
||||||
|
{friendlyAuthError(detailQuery.error, 'Request failed')}
|
||||||
|
</EmptyState>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MatchingWorkspace
|
||||||
|
listRow={listRow}
|
||||||
|
detail={detail}
|
||||||
|
loading={detailQuery.isPending}
|
||||||
|
canEdit={canEdit}
|
||||||
|
selection={selection}
|
||||||
|
setSelection={setSelection}
|
||||||
|
manualPost={manualPost}
|
||||||
|
suggestionCards={suggestionCards}
|
||||||
|
selectedPost={selectedPost}
|
||||||
|
resumeText={resumeText}
|
||||||
|
whyOpen={whyOpen}
|
||||||
|
setWhyOpen={setWhyOpen}
|
||||||
|
matchFailed={matchFailed}
|
||||||
|
onPickManual={() => setShowPicker(true)}
|
||||||
|
onSkip={() => {
|
||||||
|
const idx = filtered.findIndex((r) => r.id === selectedId)
|
||||||
|
const next = filtered[idx + 1]
|
||||||
|
if (next) setSelectedId(next.id)
|
||||||
|
}}
|
||||||
|
onAssign={() => {
|
||||||
|
if (!selection || !canEdit) return
|
||||||
|
assignMutation.mutate({ recordId: selectedId, jobPostId: selection })
|
||||||
|
}}
|
||||||
|
onUnassign={() => {
|
||||||
|
if (!canEdit) return
|
||||||
|
assignMutation.mutate({ recordId: selectedId, jobPostId: null })
|
||||||
|
}}
|
||||||
|
onChange={() => setShowPicker(true)}
|
||||||
|
onRematch={() => rematchMutation.mutate(selectedId)}
|
||||||
|
assigning={assignMutation.isPending}
|
||||||
|
rematching={rematchMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPicker && (
|
||||||
|
<PickRoleModal
|
||||||
|
onClose={() => setShowPicker(false)}
|
||||||
|
onPick={(post) => {
|
||||||
|
setManualPost(post)
|
||||||
|
setSelection(String(post.id))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchingWorkspace({
|
||||||
|
listRow,
|
||||||
|
detail,
|
||||||
|
loading,
|
||||||
|
canEdit,
|
||||||
|
selection,
|
||||||
|
setSelection,
|
||||||
|
manualPost,
|
||||||
|
suggestionCards,
|
||||||
|
selectedPost,
|
||||||
|
resumeText,
|
||||||
|
whyOpen,
|
||||||
|
setWhyOpen,
|
||||||
|
matchFailed,
|
||||||
|
onPickManual,
|
||||||
|
onSkip,
|
||||||
|
onAssign,
|
||||||
|
onUnassign,
|
||||||
|
onChange,
|
||||||
|
onRematch,
|
||||||
|
assigning,
|
||||||
|
rematching,
|
||||||
|
}) {
|
||||||
|
const i = {
|
||||||
|
name: detail?.name || listRow?.name || '…',
|
||||||
|
initials: detail?.initials || listRow?.initials,
|
||||||
|
color: detail?.color || listRow?.color,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
const assigned = detail?.assignedPost
|
||||||
|
const currentId = detail?.assignedId
|
||||||
|
const canAssign = canEdit && selection && selection !== currentId && !assigning
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
|
||||||
|
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
|
||||||
|
<div className="ph-role">{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>
|
||||||
|
{loading && <span className="cell-sub">Loading details…</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{assigned && (
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
boxShadow: 'none',
|
||||||
|
background: 'var(--primary-soft)',
|
||||||
|
border: '1px solid var(--primary-border)',
|
||||||
|
marginBottom: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="card-body flex items-center gap-12" style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<Icon name="check-circle" />
|
||||||
|
<div>
|
||||||
|
<div>Assigned to <b>{assigned.title}</b></div>
|
||||||
|
<div className="cell-sub">
|
||||||
|
{[assigned.employment_type, assigned.location].filter(Boolean).join(' · ') || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<button className="btn btn-secondary btn-sm" disabled={!canEdit} title={!canEdit ? 'Requires inbox.edit' : undefined} onClick={onChange}>
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" disabled={!canEdit || assigning} title={!canEdit ? 'Requires inbox.edit' : undefined} onClick={onUnassign}>
|
||||||
|
Unassign
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 18,
|
||||||
|
alignItems: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
|
||||||
|
{matchFailed ? (
|
||||||
|
<div className="alert alert-danger" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ marginBottom: 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 style={{ marginBottom: 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" style={{ marginTop: 8 }}>
|
||||||
|
{detail?.matchReasoning || listRow?.matchReasoning}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="fw-600" style={{ marginBottom: 6 }}>Resume text</div>
|
||||||
|
<pre className="resume-thumb" style={{ maxHeight: 220, marginBottom: 16 }}>
|
||||||
|
{resumeText || 'Resume text not extracted yet.'}
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
{(detail?.body) && (
|
||||||
|
<>
|
||||||
|
<div className="fw-600" style={{ marginBottom: 6 }}>Email body</div>
|
||||||
|
<pre className="resume-thumb" style={{ maxHeight: 160 }}>
|
||||||
|
{detail.body}
|
||||||
|
</pre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
|
||||||
|
<div className="fw-600" style={{ marginBottom: 8 }}>Suggested roles</div>
|
||||||
|
{suggestionCards.length === 0 && !manualPost ? (
|
||||||
|
<EmptyState icon="alert" title="No suggested roles">
|
||||||
|
<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}
|
||||||
|
onClick={onPickManual}
|
||||||
|
>
|
||||||
|
Choose a role
|
||||||
|
</button>
|
||||||
|
</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}
|
||||||
|
rank={0}
|
||||||
|
manual
|
||||||
|
selected={String(selection) === String(manualPost.id)}
|
||||||
|
onSelect={(id) => setSelection(id)}
|
||||||
|
resumeText={resumeText}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ width: '100%', marginTop: 8 }}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||||||
|
onClick={onPickManual}
|
||||||
|
>
|
||||||
|
Choose a different role…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex gap-8"
|
||||||
|
style={{
|
||||||
|
marginTop: 20,
|
||||||
|
paddingTop: 16,
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button className="btn btn-secondary" onClick={onSkip}>Skip</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!canAssign}
|
||||||
|
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||||||
|
onClick={onAssign}
|
||||||
|
>
|
||||||
|
{selectedPost?.title
|
||||||
|
? `Assign to ${selectedPost.title}`
|
||||||
|
: 'Assign'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue