From 38249cc6f242dbf90e0947ed3b7d4263350402b0 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Tue, 11 Aug 2026 19:01:24 +0500 Subject: [PATCH] job_matching assignment by recruiter added --- backend/inbox/app.py | 37 +- backend/inbox/models.py | 39 +- backend/inbox/serializers.py | 9 + backend/inbox/views.py | 48 +- backend/job/app.py | 27 + backend/job/candidate/serializers.py | 1 + backend/job/candidate/views.py | 47 +- backend/job/job_post/models.py | 53 +- backend/job/job_post/views.py | 11 + frontend/src/App.jsx | 1 + frontend/src/__smoke__/entry.jsx | 3 +- frontend/src/api/inbox.js | 28 +- frontend/src/api/jobPosts.js | 20 + frontend/src/app/routes.js | 1 + frontend/src/app/useShell.js | 15 +- frontend/src/lib/queryKeys.js | 5 + frontend/src/screens/CandidateProfile.jsx | 11 + frontend/src/screens/Inbox.jsx | 11 +- frontend/src/screens/Matching.jsx | 868 ++++++++++++++++++++++ 19 files changed, 1198 insertions(+), 37 deletions(-) create mode 100644 frontend/src/api/jobPosts.js create mode 100644 frontend/src/screens/Matching.jsx diff --git a/backend/inbox/app.py b/backend/inbox/app.py index 9d3c7fd..34ff814 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -1,6 +1,7 @@ from fastapi import APIRouter,Depends, Query from fastapi.responses import JSONResponse from fastapi import HTTPException +from pydantic import BaseModel from db_setup import get_session from inbox.enums import Candidate_application_Status from sqlalchemy.ext.asyncio import AsyncSession @@ -11,6 +12,10 @@ load_dotenv() router = APIRouter() + +class AssignJobPostBody(BaseModel): + job_post_id: str | None = None + @router.get("/email/fetch") async def fetch_email( top:int=Query(100), @@ -88,6 +93,23 @@ async def rematch_inbox( 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") async def mark_inbox_read( record_id: str, @@ -125,6 +147,7 @@ async def get_all_applications( record_id: str | None = Query(None), application_status: Candidate_application_Status = Query(default=Candidate_application_Status.CLOSED), isread: bool = Query(default=True), + assigned: bool | None = Query(default=None), search: str | None = Query(None), top: int | None = Query(None), skip: int = Query(0, ge=0), @@ -135,21 +158,21 @@ async def get_all_applications( 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): - items=await service.get_all_applications(top, skip, search, application_status=application_status) - total=await service.count_inbox_messages(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, assigned=assigned) return JSONResponse(content={"data":items,"total":total,"status_code":200}) if isread==False: - items=await service.get_all_applications(top, skip, search, isread=False) - total=await service.count_inbox_messages(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, assigned=assigned) return JSONResponse(content={"data":items,"total":total,"status_code":200}) if record_id: item=await service.get_application_by_id(record_id) return JSONResponse(content={"data":item,"total":1,"status_code":200}) - items=await service.get_all_applications(top,skip,search) - total=await service.count_inbox_messages(search) + items=await service.get_all_applications(top,skip,search,assigned=assigned) + total=await service.count_inbox_messages(search,assigned=assigned) return JSONResponse(content={"data":items,"total":total,"status_code":200}) except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500,detail=str(e)) \ No newline at end of file + raise HTTPException(status_code=500,detail=str(e)) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 6f58778..8e65cff 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -205,8 +205,8 @@ class Inbox_Messages(SQLModel, table=True): resume_text: 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)) - 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_reasoning: str | None = Field(default=None) match_status: str | None = Field(default=None) @@ -433,7 +433,7 @@ class Inbox_Messages(SQLModel, table=True): @classmethod 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()) 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: 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: statement = statement.offset(skip) if top is not None: statement = statement.limit(top) - + if isread==False: statement = statement.where(cls.message_read==False) result = await session.execute(statement) @@ -463,12 +468,34 @@ class Inbox_Messages(SQLModel, table=True): return result.scalars().first() @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) if search: statement = statement.where(cls._search_filter(search)) if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED: 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: statement = statement.where(cls.message_read==False) result = await session.execute(statement) diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index 4b725fc..b8cd123 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -61,11 +61,13 @@ def serialize_message(message: Inbox_Messages) -> dict: "message_reply": message.message_reply, "file_path": message.file_path, "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, + "resume_text": message.resume_text, } @@ -95,6 +97,13 @@ def serialize_application(message: Inbox_Messages) -> dict: "attachment": _attachment_name(message), "has_attachment": message.attachment, "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, "phone": message.candidate_phone_number, "experience": message.experience or "", diff --git a/backend/inbox/views.py b/backend/inbox/views.py index cfff765..0b87e4d 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -92,15 +92,33 @@ class Email: files=load_message_files(message) if 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 - 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): - 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: - 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: - 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] async def get_application_by_id(self,record_id): @@ -141,13 +159,27 @@ class Email: results.append({"email":email,"sent":False}) 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: - 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: - 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: - 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): message=await Inbox_Messages.mark_message_read(self.session,record_id) diff --git a/backend/job/app.py b/backend/job/app.py index c4ef5e6..07f83e2 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -168,6 +168,33 @@ async def buffer_channels( except Exception as 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") async def fetch_candidate( user_id:str=Query(None), diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index d097423..fbebde3 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -32,6 +32,7 @@ def serialize_candidate_profile( "current_employment": message.current_employment 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 [], + "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_reasoning": message.match_reasoning if message else None, "match_status": message.match_status if message else None, diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 07997d1..e709a7c 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -120,7 +120,7 @@ class CandidateView: except Exception as 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.""" try: 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 payload=serialize_job_post(job_post_data) if isinstance(data,dict): - 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") + 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) + 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 except Exception as e: raise HTTPException(status_code=500,detail=str(e)) @@ -146,6 +154,10 @@ class CandidateView: for record in records: payload=serialize_candidate_profile(record) 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 []: await self.get_job_post_by_id(record_id=job_id,data=payload) enriched.append(payload) @@ -163,6 +175,7 @@ class CandidateView: feedback=[] documents=[] job_posts=[] + assigned_job_post=None base=None user_id=None favorite=None @@ -178,6 +191,18 @@ class CandidateView: activity.extend(payload.get("activity") or []) feedback.extend(payload.get("feedback") 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 []: await self.get_job_post_by_id(record_id=job_id,data=payload) for jp in payload.get("job_posts") or []: @@ -209,4 +234,12 @@ class CandidateView: base["documents"]=documents base["notes"]=notes 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 diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index d87c564..2a32b89 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone 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 sqlmodel import Field, Relationship, SQLModel, select @@ -69,6 +69,57 @@ class JobPosts(SQLModel, table=True): ) 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 async def insert_job_post(cls, session: AsyncSession, fields: dict): row = cls(**fields) diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index 3d64f93..a01bcb0 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -132,3 +132,14 @@ class JobPost: return await list_buffer_channels() except (httpx.HTTPError,BufferError,RuntimeError) as 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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index bd7eb2e..b7313a4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -17,6 +17,7 @@ import ConfirmEmail from './pages/ConfirmEmail' const SCREENS = { dashboard: lazy(() => import('./screens/Dashboard')), inbox: lazy(() => import('./screens/Inbox')), + matching: lazy(() => import('./screens/Matching')), jobs: lazy(() => import('./screens/Jobs')), candidates: lazy(() => import('./screens/Candidates')), talentpool: lazy(() => import('./screens/TalentPool')), diff --git a/frontend/src/__smoke__/entry.jsx b/frontend/src/__smoke__/entry.jsx index d5f4914..9832a6a 100644 --- a/frontend/src/__smoke__/entry.jsx +++ b/frontend/src/__smoke__/entry.jsx @@ -25,6 +25,7 @@ import ConfirmEmail from '../pages/ConfirmEmail' import Dashboard from '../screens/Dashboard' import Inbox from '../screens/Inbox' +import Matching from '../screens/Matching' import Jobs from '../screens/Jobs' import Candidates from '../screens/Candidates' import TalentPool from '../screens/TalentPool' @@ -48,7 +49,7 @@ import Settings from '../screens/Settings' import Help from '../screens/Help' 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, recruiterhub: RecruiterHub, tasks: Tasks, aiassistant: AiAssistant, interviews: Interviews, assessments: Assessments, offers: Offers, diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js index beee5c6..4c3bd23 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -16,15 +16,26 @@ export function listMessages() { * * Unlike /inbox/fetch this one IS permissioned server-side * (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', { // `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 // undefined but keeps false, so `isread: undefined` sends no param at all. // Same for `application_status`: omit for every tab (server defaults to // 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) { 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' }) +} diff --git a/frontend/src/api/jobPosts.js b/frontend/src/api/jobPosts.js new file mode 100644 index 0000000..6d49764 --- /dev/null +++ b/frontend/src/api/jobPosts.js @@ -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, + }, + }) +} diff --git a/frontend/src/app/routes.js b/frontend/src/app/routes.js index 521439f..aace388 100644 --- a/frontend/src/app/routes.js +++ b/frontend/src/app/routes.js @@ -18,6 +18,7 @@ export const ROUTES = [ // --- Workspace --- { path: 'dashboard', title: 'Dashboard', icon: 'dashboard', group: 'Workspace', permission: 'dashboard.view' }, { path: 'inbox', title: 'Recruitment Inbox', icon: 'inbox', group: 'Workspace', permission: 'inbox.view', badge: 'inbox' }, + { path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'inbox.view', badge: 'matching' }, { path: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' }, { path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' }, { path: 'talentpool', title: 'Talent Pool', icon: 'talent', group: 'Workspace', permission: 'candidates.view' }, diff --git a/frontend/src/app/useShell.js b/frontend/src/app/useShell.js index 8b98fcd..4e55aa4 100644 --- a/frontend/src/app/useShell.js +++ b/frontend/src/app/useShell.js @@ -3,6 +3,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { seedQuery } from '../data/seedQueries' +import { qk } from '../lib/queryKeys' +import * as inboxApi from '../api/inbox' 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 * 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() { const { data: jobs = [] } = useQuery(seedQuery('jobs')) const { data: notifications = [] } = useQuery(seedQuery('notifications')) const { data: tasks = [] } = useQuery(seedQuery('tasks')) 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 { jobs: jobs.filter((j) => j.status === 'Open').length, notifications: notifications.filter((n) => n.unread).length, tasks: tasks.filter((t) => !t.done).length, inbox: inbox.filter((i) => i.unread).length, + matching: matchingTotal, } } diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 9e3ea6b..007f409 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -26,6 +26,11 @@ export const qk = { messages: () => ['mailbox', 'messages'], applications: (p = {}) => ['mailbox', 'applications', p], message: (id) => ['mailbox', 'message', id], + assignments: (p = {}) => ['mailbox', 'assignments', p], + }, + jobPosts: { + all: () => ['jobPosts'], + list: (p = {}) => ['jobPosts', 'list', p], }, // --- seed-backed buckets --- diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index 9c7fc04..a741dc6 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -237,6 +237,17 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT )} + {live.assigned_job_post && ( + <> +
Assigned Role
+
+ + {live.assigned_job_post.title} + +
+ + )} + {live.job_posts?.length > 0 && ( <>
Suggested Roles
diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 89772ea..ee1ddfd 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -524,7 +524,6 @@ export default function Inbox() { onPreview={() => setPreviewing(selected)} onImport={() => importItem(selected)} onParse={() => parseResume(selected)} - onAssign={() => setAssigning(selected)} onMove={() => moveToPipeline(selected)} onNote={() => setNoting(selected)} onReject={() => reject(selected)} @@ -603,7 +602,8 @@ function orDash(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 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 @@ -700,8 +700,11 @@ function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onA - } + > +
+ + setQ(e.target.value)} placeholder="Search title or location…" autoFocus /> +
+ {isPending && Fetching job posts.} + {isError && ( + + {friendlyAuthError(error, 'Request failed')} + + )} + {!isPending && !isError && data.length === 0 && ( + Try a different search. + )} +
+ {data.map((p) => ( +
{ onPick(p); onClose() }} + > +
+
{p.title}
+
+ {[p.employment_type, p.location].filter(Boolean).join(' · ') || '—'} +
+
+ {p.status} +
+ ))} +
+ + ) +} + +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 ( +
+
+
+

Job Matching

+

Route applications to the right open role

+
+
+ + {!canEdit && ( +
+ Your account does not hold inbox.edit, which the server requires to + assign, unassign, or retry a match. Controls below stay disabled. +
+ )} + +
+
+ { setTab(t); setSelectedId(null) }} + tabs={TABS.map((t) => ({ + key: t.key, + label: t.label, + count: counts[t.key], + }))} + /> +
+ +
+
+
+
+ + setQ(e.target.value)} placeholder="Search…" /> +
+
+
+ {listQuery.isPending && ( + Fetching applications. + )} + {listQuery.isError && ( + + {friendlyAuthError(listQuery.error, 'Request failed')} + + )} + {listQuery.isSuccess && filtered.length === 0 && ( + + {tab === 'needs' + ? 'Every application in this view has a role.' + : 'Nothing matches this filter.'} + + )} + {filtered.map((i) => ( +
setSelectedId(i.id)} + > + +
+
{i.name}
+
{i.position}
+
+ + +
+
+
+ ))} +
+
+ +
+ {!selectedId ? ( +
+ + Choose an item from the list to review suggestions and assign a role. + +
+ ) : detailQuery.isError ? ( +
+ + {friendlyAuthError(detailQuery.error, 'Request failed')} + +
+ ) : ( + 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} + /> + )} +
+
+
+ + {showPicker && ( + setShowPicker(false)} + onPick={(post) => { + setManualPost(post) + setSelection(String(post.id)) + }} + /> + )} +
+ ) +} + +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 ( +
+
+ +
+
{i.name}
+
{i.position}
+
+ {' '} + + {i.resumeStatus} + + {loading && Loading details…} +
+
+
+ + {assigned && ( +
+
+
+ +
+
Assigned to {assigned.title}
+
+ {[assigned.employment_type, assigned.location].filter(Boolean).join(' · ') || '—'} +
+
+
+
+ + +
+
+
+ )} + +
+
+ {matchFailed ? ( +
+
{detail?.matchError || 'Matching failed for this application.'}
+ +
+ ) : ( +
+
AI verdict
+

+ {detail?.matchSummary || listRow?.matchSummary || 'No match summary yet.'} +

+ {detail?.matchedAt && ( +
Matched {fmtDate(detail.matchedAt)}
+ )} + {(detail?.matchReasoning || listRow?.matchReasoning) && ( + + )} + {whyOpen && ( +

+ {detail?.matchReasoning || listRow?.matchReasoning} +

+ )} +
+ )} + +
Resume text
+
+            {resumeText || 'Resume text not extracted yet.'}
+          
+ + {(detail?.body) && ( + <> +
Email body
+
+                {detail.body}
+              
+ + )} +
+ +
+
Suggested roles
+ {suggestionCards.length === 0 && !manualPost ? ( + +
+ + +
+
+ ) : ( + suggestionCards.map(({ rank, post }) => ( + setSelection(id)} + resumeText={resumeText} + /> + )) + )} + {manualPost && ( + setSelection(id)} + resumeText={resumeText} + /> + )} + +
+
+ +
+ + +
+
+ ) +}