job_matching assignment by recruiter added

pull/9/head
ahmed.mujtaba 2026-08-11 19:01:24 +05:00
parent 17c99d24e4
commit 38249cc6f2
19 changed files with 1198 additions and 37 deletions

View File

@ -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,19 +158,19 @@ 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

View File

@ -205,7 +205,7 @@ 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)
@ -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:
@ -442,6 +442,11 @@ 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)
@ -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)

View File

@ -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 "",

View File

@ -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)

View File

@ -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),

View File

@ -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,

View File

@ -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,6 +128,14 @@ 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):
if as_assigned:
data["assigned_job_post"]=payload
if payload.get("created_by_name"):
data["recruiter"]=payload.get("created_by_name")
data["recruiter_id"]=payload.get("created_by")
if payload.get("title"):
data["job_title"]=payload.get("title")
else:
data.setdefault("job_posts",[]).append(payload) data.setdefault("job_posts",[]).append(payload)
if data.get("recruiter") is None and payload.get("created_by_name"): if data.get("recruiter") is None and payload.get("created_by_name"):
data["recruiter"]=payload.get("created_by_name") data["recruiter"]=payload.get("created_by_name")
@ -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

View File

@ -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)

View File

@ -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

View File

@ -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')),

View File

@ -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,

View File

@ -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' })
}

View File

@ -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,
},
})
}

View File

@ -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' },

View File

@ -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,
} }
} }

View File

@ -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 ---

View File

@ -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>

View File

@ -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

View File

@ -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="Couldnt 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, 15 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="Couldnt 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="Couldnt 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>
)
}