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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,6 +128,14 @@ class CandidateView:
return None
payload=serialize_job_post(job_post_data)
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)
if data.get("recruiter") is None and payload.get("created_by_name"):
data["recruiter"]=payload.get("created_by_name")
@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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 && (
<>
<div style={LABEL}>Suggested Roles</div>

View File

@ -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
<button className="btn btn-secondary" onClick={onParse} disabled title={noBackend}>
<Icon name="sparkles" /> Parse Resume
</button>
<button className="btn btn-secondary" onClick={onAssign} disabled title={noBackend}>
<Icon name="users" /> Assign Recruiter
<button
className="btn btn-secondary"
onClick={() => navigate(`/matching?record=${i.id}`)}
>
<Icon name="target" /> Assign Job
</button>
<button className="btn btn-secondary" onClick={onMove} disabled title={noBackend}>
<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>
)
}