Merge pull request 'LINK_X_USER_INBOX' (#9) from LINK_X_USER_INBOX into main
Reviewed-on: #9pull/10/head
commit
d0a8d09506
|
|
@ -0,0 +1,75 @@
|
|||
"""Employment response decorators for `parse_employment_response`.
|
||||
|
||||
Pure module: no FastAPI imports, no HTTPException, and no module-level state.
|
||||
Mirrors job/candidate/decorators.py — stacked wrappers that clean LLM output
|
||||
before the task persists it:
|
||||
|
||||
raw JSON -> require_json_object -> clamp_company_to_resume
|
||||
-> clamp_education_to_resume -> parse_employment_response
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from employment_agent.prompt import EDUCATION,NO_COMPANY
|
||||
|
||||
|
||||
def require_json_object(func):
|
||||
"""Reject non-dict LLM payloads before field parsing runs."""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(data,resume_text="",*args,**kwargs):
|
||||
if not isinstance(data,dict):
|
||||
raise RuntimeError(f"model did not return a JSON object: {data!r}")
|
||||
return func(data,resume_text,*args,**kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def clamp_company_to_resume(func):
|
||||
"""Keep company only when it appears in resume_text; else NO_COMPANY."""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(data,resume_text="",*args,**kwargs):
|
||||
company,education=func(data,resume_text,*args,**kwargs)
|
||||
company=(company or "").strip()
|
||||
if not company or company.lower()==NO_COMPANY.lower():
|
||||
return NO_COMPANY,education
|
||||
haystack=(resume_text or "").lower()
|
||||
if company.lower() not in haystack:
|
||||
return NO_COMPANY,education
|
||||
return company,education
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def clamp_education_to_resume(func):
|
||||
"""Keep education only when it appears in resume_text; else EDUCATION."""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(data,resume_text="",*args,**kwargs):
|
||||
company,education=func(data,resume_text,*args,**kwargs)
|
||||
education=(education or "").strip()
|
||||
if not education or education.lower()==EDUCATION.lower():
|
||||
return company,EDUCATION
|
||||
haystack=(resume_text or "").lower()
|
||||
if education.lower() not in haystack:
|
||||
return company,EDUCATION
|
||||
return company,education
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@require_json_object
|
||||
@clamp_company_to_resume
|
||||
@clamp_education_to_resume
|
||||
def parse_employment_response(data,resume_text:str="") -> tuple[str,str]:
|
||||
"""Pull company + education from LLM JSON; decorators clamp to the resume."""
|
||||
current=data.get("current_employment")
|
||||
education=data.get("education")
|
||||
if not isinstance(current,str):
|
||||
current=""
|
||||
if not isinstance(education,str):
|
||||
education=""
|
||||
return current.strip(),education.strip()
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
"""Employment extraction entrypoint — llm_setup.llm_call only.
|
||||
|
||||
Pure module: no FastAPI imports and no HTTPException.
|
||||
Called from inbox.tasks.match_inbox_message; no HTTP surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from employment_agent.decorators import parse_employment_response
|
||||
from employment_agent.prompt import EDUCATION,NO_COMPANY,prompt,user_prompt
|
||||
from llm_setup import llm_call
|
||||
|
||||
logger=logging.getLogger("employment_agent")
|
||||
|
||||
|
||||
async def run_employment_agent(*,resume_text="") -> tuple[str,str]:
|
||||
text=(resume_text or "").strip()
|
||||
if not text:
|
||||
return NO_COMPANY,EDUCATION
|
||||
try:
|
||||
data=await llm_call(prompt(),user_prompt(text),json_mode=True)
|
||||
return parse_employment_response(data,text)
|
||||
except Exception as e:
|
||||
logger.exception("employment llm_call failed")
|
||||
raise RuntimeError(str(e)) from e
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
"""Employment LLM prompt builders.
|
||||
|
||||
Pure module: no FastAPI imports and no HTTPException.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
NO_COMPANY="no company was mentioned"
|
||||
EDUCATION="No Education Mentioned"
|
||||
|
||||
|
||||
def prompt():
|
||||
return f"""You are an HR-ATS recruiting assistant.
|
||||
|
||||
You are given CV/resume text. Identify the candidate's CURRENT employer company
|
||||
name and their education (degree / school) when present.
|
||||
|
||||
Rules:
|
||||
- Return only the company name that appears in the resume text for the ongoing / most recent role.
|
||||
- Return only education that appears in the resume text.
|
||||
- The company string you return MUST appear verbatim (or as a clear substring) in the resume text.
|
||||
- The education string you return MUST appear verbatim (or as a clear substring) in the resume text.
|
||||
- Do not invent a company. If none is mentioned, return exactly: {NO_COMPANY}
|
||||
- Do not invent education. If none is mentioned, return exactly: {EDUCATION}
|
||||
|
||||
Respond with JSON only:
|
||||
{{
|
||||
"current_employment": "Company Name",
|
||||
"education": "Degree / School"
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def user_prompt(resume_text:str) -> str:
|
||||
return json.dumps({"resume_text":resume_text or ""},ensure_ascii=False)
|
||||
|
|
@ -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),
|
||||
|
|
@ -134,22 +157,22 @@ async def get_all_applications(
|
|||
try:
|
||||
service=Email(session=session)
|
||||
|
||||
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
|
||||
items=await service.get_all_applications(top, skip, search, application_status=application_status)
|
||||
total=await service.count_inbox_messages(search, application_status=application_status)
|
||||
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, 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))
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
|
|
|||
|
|
@ -8,4 +8,9 @@ class Candidate_application_Status(str, Enum):
|
|||
APPROVED="APPROVED"
|
||||
REJECTED="REJECTED"
|
||||
ONHOLD="ONHOLD"
|
||||
CLOSED="CLOSED"
|
||||
CLOSED="CLOSED"
|
||||
SCREENING="SCREENING"
|
||||
ASSESSMENT="ASSESSMENT"
|
||||
INTERVIEW="INTERVIEW"
|
||||
OFFER="OFFER"
|
||||
HIRED="HIRED"
|
||||
|
|
@ -2,7 +2,7 @@ import logging
|
|||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import Field, Relationship, SQLModel, select, true
|
||||
|
||||
from job.candidate.models import Activity, Feedback, Interviews
|
||||
from users.models import Users
|
||||
from users.plugins import hash_password
|
||||
|
||||
|
|
@ -44,7 +45,23 @@ class Inbox(SQLModel, table=True):
|
|||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
updated_at: datetime = Field(default_factory=datetime.now)
|
||||
|
||||
user: Optional[Users] = Relationship(
|
||||
favorite: Optional[bool] = Field(default=False)
|
||||
rating: Optional[float] = Field(default=0.0)
|
||||
|
||||
# selectin on one-to-many: joined would repeat the inbox row per child
|
||||
interviews: List["Interviews"] = Relationship(
|
||||
back_populates="inbox",
|
||||
sa_relationship_kwargs={"lazy": "selectin"},
|
||||
)
|
||||
activity: List["Activity"] = Relationship(
|
||||
back_populates="inbox",
|
||||
sa_relationship_kwargs={"lazy": "selectin"},
|
||||
)
|
||||
feedback: List["Feedback"] = Relationship(
|
||||
back_populates="inbox",
|
||||
sa_relationship_kwargs={"lazy": "selectin"},
|
||||
)
|
||||
user: Optional["Users"] = Relationship(
|
||||
back_populates="inbox",
|
||||
sa_relationship_kwargs={"lazy": "joined"},
|
||||
)
|
||||
|
|
@ -57,9 +74,16 @@ class Inbox(SQLModel, table=True):
|
|||
@classmethod
|
||||
async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None,limit:int=10,offset:int=0,search:str|None=None):
|
||||
try:
|
||||
options=[selectinload(cls.messages)]
|
||||
if user_id:
|
||||
options.extend([
|
||||
selectinload(cls.interviews),
|
||||
selectinload(cls.activity),
|
||||
selectinload(cls.feedback),
|
||||
])
|
||||
qry = (
|
||||
select(cls)
|
||||
.options(selectinload(cls.messages))
|
||||
.options(*options)
|
||||
.join(Users, cls.user_id == Users.id)
|
||||
.join(Roles, Users.role_id == Roles.id)
|
||||
.where(Roles.role_name == EnumRoles.CANDIDATE.value)
|
||||
|
|
@ -97,6 +121,52 @@ class Inbox(SQLModel, table=True):
|
|||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
@classmethod
|
||||
async def get_inbox_by_id(cls,session:AsyncSession,record_id:int|str|None):
|
||||
if record_id is None:
|
||||
return None
|
||||
try:
|
||||
iid=int(record_id)
|
||||
except (TypeError,ValueError):
|
||||
return None
|
||||
result=await session.execute(select(cls).where(cls.id==iid))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def get_inbox_by_message_id(cls,session:AsyncSession,message_id):
|
||||
try:
|
||||
mid=uuid.UUID(str(message_id))
|
||||
except ValueError:
|
||||
return None
|
||||
result=await session.execute(
|
||||
select(cls).where(cls.message_id==mid).order_by(cls.created_at.desc())
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def get_inbox_by_user_id(cls,session:AsyncSession,user_id):
|
||||
try:
|
||||
uid=uuid.UUID(str(user_id))
|
||||
except ValueError:
|
||||
return None
|
||||
result=await session.execute(
|
||||
select(cls).where(cls.user_id==uid).order_by(cls.created_at.desc())
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def update_inbox(cls,session:AsyncSession,record_id,fields:dict):
|
||||
row=await cls.get_inbox_by_id(session,record_id)
|
||||
if not row:
|
||||
return None
|
||||
for key,value in fields.items():
|
||||
setattr(row,key,value)
|
||||
row.updated_at=datetime.now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
class Inbox_Alerts(SQLModel, table=True):
|
||||
__tablename__ = "inbox_alerts"
|
||||
|
|
@ -135,12 +205,16 @@ 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))
|
||||
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)
|
||||
match_error: str | None = Field(default=None)
|
||||
matched_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||
|
||||
candidate_phone_number: str | None = Field(default="xxx-xxx-xxxx")
|
||||
candidate_education: str | None = Field(default=None)
|
||||
current_employment: str | None = Field(default=None)
|
||||
inbox: list[Inbox] = Relationship(back_populates="messages")
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -172,6 +246,9 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
*,
|
||||
resume_text=None,
|
||||
experience=None,
|
||||
candidate_education=None,
|
||||
candidate_phone_number=None,
|
||||
current_employment=None,
|
||||
suggested_job_post_ids=None,
|
||||
summary="",
|
||||
reasoning="",
|
||||
|
|
@ -184,6 +261,12 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
return None
|
||||
if resume_text is not None:
|
||||
row.resume_text = resume_text
|
||||
if candidate_phone_number is not None:
|
||||
row.candidate_phone_number = candidate_phone_number
|
||||
if candidate_education is not None:
|
||||
row.candidate_education = candidate_education
|
||||
if current_employment is not None:
|
||||
row.current_employment = current_employment
|
||||
row.suggested_job_post_ids = suggested_job_post_ids
|
||||
row.match_summary = summary or None
|
||||
row.match_reasoning = reasoning or None
|
||||
|
|
@ -350,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:
|
||||
|
|
@ -358,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)
|
||||
|
|
@ -380,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)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
|
|
@ -20,6 +21,11 @@ EMAIL_API_TOKEN=os.getenv("EMAIL_API_TOKEN")
|
|||
BACKEND_URL=os.getenv("BACKEND_URL","http://localhost:8000")
|
||||
|
||||
_ATTACHMENTS_DIR=Path(__file__).resolve().parent/"decoded_attachments"
|
||||
# Prefer +92 / 03xx style numbers; fall back to a looser intl-ish pattern.
|
||||
_PHONE=re.compile(
|
||||
r"(?:\+?92[\s\-]?)?0?3\d{2}[\s\-]?\d{7}"
|
||||
r"|(?:\+?\d{1,3}[\s\-]?)?(?:\(?\d{2,4}\)?[\s\-]?)?\d{3,4}[\s\-]?\d{3,4}"
|
||||
)
|
||||
|
||||
|
||||
async def request_email_confirmation(email):
|
||||
|
|
@ -127,6 +133,13 @@ def load_message_files(message:Inbox_Messages) -> list[dict]:
|
|||
return files
|
||||
|
||||
|
||||
def extract_phone(text:str) -> str|None:
|
||||
m=_PHONE.search(text or "")
|
||||
if not m:
|
||||
return None
|
||||
return re.sub(r"[\s\-()]+"," ",m.group(0)).strip()
|
||||
|
||||
|
||||
async def extract_resume_text(file_paths:list[str]) -> tuple[str,str]:
|
||||
candidates=[resolve_attachment_path(p) for p in (file_paths or []) if p and p.strip()]
|
||||
existing=[p for p in candidates if p.is_file() and p.suffix.lower()==".pdf"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -76,10 +78,10 @@ def serialize_application(message: Inbox_Messages) -> dict:
|
|||
the board tag (Rozee, Mustakbil, Employee Referral, ...) lands.
|
||||
|
||||
The tab also wants ats_score, phone, experience, recruiter, duplicate and a
|
||||
processing state beyond read/unread. inbox_messages has no columns for any of
|
||||
those, so they come back null instead of invented — see the note in
|
||||
inbox/file_decoder.py. `processing` is derived from message_read alone, so it
|
||||
is only ever "Unread" or "Read"; Imported/Processed/Rejected need a column.
|
||||
processing state beyond read/unread. phone comes from candidate_phone_number
|
||||
(filled by the match task); ats_score/recruiter/duplicate stay null until
|
||||
columns exist. `processing` is derived from message_read alone, so it is only
|
||||
ever "Unread" or "Read"; Imported/Processed/Rejected need a column.
|
||||
"""
|
||||
return {
|
||||
"id": str(message.id),
|
||||
|
|
@ -95,9 +97,17 @@ 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": None,
|
||||
"phone": message.candidate_phone_number,
|
||||
"experience": message.experience or "",
|
||||
"current_employment": message.current_employment or "",
|
||||
"recruiter": None,
|
||||
"duplicate": None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ from datetime import datetime,timezone
|
|||
|
||||
from agent.execute_agent import run_agent
|
||||
from db_setup import session_scope
|
||||
from employment_agent.execute_agent import run_employment_agent
|
||||
from inbox.models import Inbox_Messages
|
||||
from inbox.plugins import extract_resume_text
|
||||
from inbox.plugins import extract_phone,extract_resume_text
|
||||
from job.job_post.models import JobPosts
|
||||
from job.job_post.serializers import serialize_job_post
|
||||
from taskiq_management.broker_setup import MAX_RETRIES,RETRY_DELAY,broker
|
||||
|
|
@ -50,6 +51,7 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
|
|||
job_posts=[serialize_job_post(p) for p in posts]
|
||||
|
||||
text,extract_err=await extract_resume_text(paths)
|
||||
phone=extract_phone(text) if text else None
|
||||
if not text:
|
||||
async with session_scope() as session:
|
||||
await Inbox_Messages.set_match_result(
|
||||
|
|
@ -62,16 +64,26 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
|
|||
if status=="failed":
|
||||
raise RuntimeError(result.get("error") or "agent returned failed status")
|
||||
|
||||
current_employment,education=await run_employment_agent(resume_text=text)
|
||||
|
||||
async with session_scope() as session:
|
||||
await Inbox_Messages.set_match_result(
|
||||
session,
|
||||
record_id,
|
||||
resume_text=text,
|
||||
experience=result.get("experience") or "",
|
||||
candidate_phone_number=phone,
|
||||
current_employment=current_employment,
|
||||
candidate_education=education,
|
||||
suggested_job_post_ids=result.get("suggested_job_post_ids") or [],
|
||||
summary=result.get("summary") or "",
|
||||
reasoning=result.get("reasoning") or "",
|
||||
status=status,
|
||||
error=result.get("error") or "",
|
||||
)
|
||||
return {"status":status,"suggested_job_post_ids":result.get("suggested_job_post_ids") or []}
|
||||
return {
|
||||
"status":status,
|
||||
"suggested_job_post_ids":result.get("suggested_job_post_ids") or [],
|
||||
"current_employment":current_employment,
|
||||
"education":education,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
|
||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status)
|
||||
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,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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
def serialize_activity(row) -> dict:
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"inbox_id": row.inbox_id,
|
||||
"activity_type": row.activity_type,
|
||||
"activity_date": row.activity_date.isoformat() if row.activity_date else None,
|
||||
"activity_time": row.activity_time.isoformat() if row.activity_time else None,
|
||||
"activity_status": row.activity_status,
|
||||
"description": row.description,
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from inbox.models import Inbox
|
||||
from job.candidate.models import Activity
|
||||
from job.activity.serializers import serialize_activity
|
||||
|
||||
|
||||
class ActivityLog:
|
||||
def __init__(self,session:AsyncSession):
|
||||
self.session=session
|
||||
|
||||
async def _resolve_inbox(self,payload):
|
||||
if payload.get("inbox_id") is not None:
|
||||
row=await Inbox.get_inbox_by_id(self.session,payload["inbox_id"])
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Inbox not found")
|
||||
return row
|
||||
if payload.get("message_id"):
|
||||
row=await Inbox.get_inbox_by_message_id(self.session,payload["message_id"])
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Inbox not found for message_id")
|
||||
return row
|
||||
if payload.get("user_id"):
|
||||
row=await Inbox.get_inbox_by_user_id(self.session,payload["user_id"])
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Inbox not found for user_id")
|
||||
return row
|
||||
return None
|
||||
|
||||
async def get_activity(self,activity_id=None,inbox_id=None):
|
||||
if activity_id:
|
||||
row=await Activity.get_activity_by_id(self.session,activity_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Activity not found")
|
||||
return serialize_activity(row)
|
||||
if inbox_id is None:
|
||||
raise HTTPException(status_code=400,detail="activity_id or inbox_id is required")
|
||||
rows=await Activity.get_activity_by_inbox(self.session,int(inbox_id))
|
||||
return [serialize_activity(r) for r in rows]
|
||||
|
||||
async def create_activity(self,payload):
|
||||
link=await self._resolve_inbox(payload)
|
||||
fields={
|
||||
"activity_type":payload.get("activity_type") or "",
|
||||
"activity_status":payload.get("activity_status") or "",
|
||||
"description":payload.get("description"),
|
||||
"inbox_id":link.id if link else None,
|
||||
}
|
||||
if payload.get("activity_date") is not None:
|
||||
fields["activity_date"]=payload["activity_date"]
|
||||
if payload.get("activity_time") is not None:
|
||||
fields["activity_time"]=payload["activity_time"]
|
||||
row=await Activity.insert_activity(self.session,fields)
|
||||
return serialize_activity(row)
|
||||
|
|
@ -3,6 +3,10 @@ from fastapi.responses import JSONResponse
|
|||
from fastapi import HTTPException
|
||||
from db_setup import get_session
|
||||
from job.candidate.views import FileRead,CandidateView
|
||||
from job.interviews.views import Interview
|
||||
from job.notes.views import Note
|
||||
from job.activity.views import ActivityLog
|
||||
from job.feedback.views import FeedbackView
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from users.permissions import PermissionTag, require_permission
|
||||
from job.job_post.views import JobPost,JobPostCreate
|
||||
|
|
@ -11,6 +15,8 @@ from job.job_post.plugins import PlatformAlias
|
|||
from fastapi import UploadFile, File
|
||||
from dotenv import load_dotenv
|
||||
from datetime import datetime, time, timezone
|
||||
from pydantic import BaseModel
|
||||
from uuid import UUID
|
||||
|
||||
load_dotenv()
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
|
@ -19,6 +25,65 @@ logger = logging.getLogger(__name__)
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
class CandidateUpdate(BaseModel):
|
||||
favorite: bool | None = None
|
||||
rating: float | None = None
|
||||
|
||||
|
||||
class InterviewCreate(BaseModel):
|
||||
inbox_id: int
|
||||
interview_date: datetime | None = None
|
||||
interview_time: datetime | None = None
|
||||
interview_type: str | None = None
|
||||
interview_status: str | None = None
|
||||
|
||||
|
||||
class InterviewUpdate(BaseModel):
|
||||
interview_date: datetime | None = None
|
||||
interview_time: datetime | None = None
|
||||
interview_type: str | None = None
|
||||
interview_status: str | None = None
|
||||
inbox_id: int | None = None
|
||||
|
||||
|
||||
class NoteCreate(BaseModel):
|
||||
user_id: UUID
|
||||
note: str
|
||||
|
||||
|
||||
class NoteUpdate(BaseModel):
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class ActivityCreate(BaseModel):
|
||||
message_id: UUID | None = None
|
||||
user_id: UUID | None = None
|
||||
inbox_id: int | None = None
|
||||
activity_type: str | None = None
|
||||
activity_status: str | None = None
|
||||
description: str | None = None
|
||||
activity_date: datetime | None = None
|
||||
activity_time: datetime | None = None
|
||||
|
||||
|
||||
class FeedbackCreate(BaseModel):
|
||||
inbox_id: int | None = None
|
||||
review: str | None = None
|
||||
financial_status: str | None = None
|
||||
score: float | None = None
|
||||
note: str | None = None
|
||||
reviewed_by: UUID | None = None
|
||||
|
||||
|
||||
class FeedbackUpdate(BaseModel):
|
||||
review: str | None = None
|
||||
financial_status: str | None = None
|
||||
score: float | None = None
|
||||
note: str | None = None
|
||||
inbox_id: int | None = None
|
||||
reviewed_by: UUID | None = None
|
||||
|
||||
|
||||
|
||||
@router.get("/jobs/alias")
|
||||
async def get_job_alias():
|
||||
|
|
@ -103,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),
|
||||
|
|
@ -115,11 +207,215 @@ async def fetch_candidate(
|
|||
try:
|
||||
service=CandidateView(session=session)
|
||||
data=await service.get_candidate(user_id=user_id,limit=limit,offset=offset,search=search)
|
||||
# total is the RESULT-SET size, not len(data) — a pager cannot be driven
|
||||
# off the page length. By id stays 1, per the house envelope.
|
||||
|
||||
|
||||
total=await service.count_candidates(user_id=user_id,search=search) if isinstance(data,list) else 1
|
||||
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.patch("/candidate/update")
|
||||
async def update_candidate(
|
||||
user_id:str=Query(...),
|
||||
payload:CandidateUpdate=...,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=CandidateView(session=session)
|
||||
data=await service.update_candidate(user_id,payload.model_dump(exclude_unset=True))
|
||||
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.get("/interview/fetch")
|
||||
async def fetch_interview(
|
||||
interview_id:str=Query(None),
|
||||
inbox_id:int=Query(None),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Interview(session=session)
|
||||
data=await service.get_interview(interview_id=interview_id,inbox_id=inbox_id)
|
||||
total=1 if isinstance(data,dict) else len(data)
|
||||
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.post("/interview/create")
|
||||
async def create_interview(
|
||||
payload:InterviewCreate,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Interview(session=session)
|
||||
data=await service.create_interview(payload.model_dump(exclude_unset=True))
|
||||
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.patch("/interview/update")
|
||||
async def update_interview(
|
||||
interview_id:str=Query(...),
|
||||
payload:InterviewUpdate=...,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Interview(session=session)
|
||||
data=await service.update_interview(interview_id,payload.model_dump(exclude_unset=True))
|
||||
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.get("/notes/fetch")
|
||||
async def fetch_notes(
|
||||
note_id:str=Query(None),
|
||||
user_id:str=Query(None),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Note(session=session)
|
||||
data=await service.get_note(note_id=note_id,user_id=user_id)
|
||||
total=1 if isinstance(data,dict) else len(data)
|
||||
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.post("/notes/create")
|
||||
async def create_note(
|
||||
payload:NoteCreate,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Note(session=session)
|
||||
data=await service.create_note(payload.model_dump(exclude_unset=True),current_user)
|
||||
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.patch("/notes/update")
|
||||
async def update_note(
|
||||
note_id:str=Query(...),
|
||||
payload:NoteUpdate=...,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Note(session=session)
|
||||
data=await service.update_note(note_id,payload.model_dump(exclude_unset=True))
|
||||
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.get("/activity/fetch")
|
||||
async def fetch_activity(
|
||||
activity_id:str=Query(None),
|
||||
inbox_id:int=Query(None),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=ActivityLog(session=session)
|
||||
data=await service.get_activity(activity_id=activity_id,inbox_id=inbox_id)
|
||||
total=1 if isinstance(data,dict) else len(data)
|
||||
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.post("/activity/create")
|
||||
async def create_activity(
|
||||
payload:ActivityCreate,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=ActivityLog(session=session)
|
||||
data=await service.create_activity(payload.model_dump(exclude_unset=True))
|
||||
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.get("/feedback/fetch")
|
||||
async def fetch_feedback(
|
||||
feedback_id:str=Query(None),
|
||||
inbox_id:int=Query(None),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=FeedbackView(session=session)
|
||||
data=await service.get_feedback(feedback_id=feedback_id,inbox_id=inbox_id)
|
||||
total=1 if isinstance(data,dict) else len(data)
|
||||
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.post("/feedback/create")
|
||||
async def create_feedback(
|
||||
payload:FeedbackCreate,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=FeedbackView(session=session)
|
||||
data=await service.create_feedback(payload.model_dump(exclude_unset=True),current_user)
|
||||
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.patch("/feedback/update")
|
||||
async def update_feedback(
|
||||
feedback_id:str=Query(...),
|
||||
payload:FeedbackUpdate=...,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=FeedbackView(session=session)
|
||||
data=await service.update_feedback(feedback_id,payload.model_dump(exclude_unset=True))
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,266 @@
|
|||
# from sqlmodel import SQLModel, Field
|
||||
# from uuid import UUID, uuid4
|
||||
# from datetime import datetime
|
||||
# from enum import Enum
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
# class CV_extraction(SQLModel,table=True):
|
||||
from sqlalchemy import DateTime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, Relationship, SQLModel, select
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inbox.models import Inbox
|
||||
from users.models import Users
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# Every datetime below is aware (see _now, and the API parses ISO input carrying
|
||||
# an offset), so each column is declared timestamptz. SQLModel maps a bare
|
||||
# `datetime` to TIMESTAMP WITHOUT TIME ZONE, and asyncpg refuses to bind an aware
|
||||
# value to one — "can't subtract offset-naive and offset-aware datetimes" — which
|
||||
# turns every insert here into a 500. Same pairing as job/job_post/models.py.
|
||||
|
||||
|
||||
class Interviews(SQLModel, table=True):
|
||||
__tablename__ = "interviews"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
interview_date: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
interview_time: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
interview_type: str = Field(default="")
|
||||
interview_status: str = Field(default="")
|
||||
inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
|
||||
inbox: Optional["Inbox"] = Relationship(
|
||||
back_populates="interviews",
|
||||
sa_relationship_kwargs={"lazy": "selectin"},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||
try:
|
||||
return uuid.UUID(str(record_id))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_interview_by_id(cls, session: AsyncSession, record_id):
|
||||
uid = cls._as_uuid(record_id)
|
||||
if uid is None:
|
||||
return None
|
||||
result = await session.execute(select(cls).where(cls.id == uid))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def get_interviews_by_inbox(cls, session: AsyncSession, inbox_id: int):
|
||||
result = await session.execute(
|
||||
select(cls).where(cls.inbox_id == inbox_id).order_by(cls.interview_date.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
async def insert_interview(cls, session: AsyncSession, fields: dict):
|
||||
row = cls(**fields)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return await cls.get_interview_by_id(session, row.id)
|
||||
|
||||
@classmethod
|
||||
async def update_interview(cls, session: AsyncSession, record_id, fields: dict):
|
||||
row = await cls.get_interview_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
setattr(row, key, value)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
class Notes(SQLModel, table=True):
|
||||
__tablename__ = "notes"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
note: str = Field(default="")
|
||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
user: Optional["Users"] = Relationship(
|
||||
back_populates="notes",
|
||||
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[Notes.user_id]"},
|
||||
)
|
||||
author: Optional["Users"] = Relationship(
|
||||
back_populates="authored_notes",
|
||||
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[Notes.created_by]"},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||
try:
|
||||
return uuid.UUID(str(record_id))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_note_by_id(cls, session: AsyncSession, record_id):
|
||||
uid = cls._as_uuid(record_id)
|
||||
if uid is None:
|
||||
return None
|
||||
result = await session.execute(select(cls).where(cls.id == uid))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def get_notes_by_user(cls, session: AsyncSession, user_id):
|
||||
uid = cls._as_uuid(user_id)
|
||||
if uid is None:
|
||||
return []
|
||||
result = await session.execute(
|
||||
select(cls).where(cls.user_id == uid).order_by(cls.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
async def insert_note(cls, session: AsyncSession, fields: dict):
|
||||
row = cls(**fields)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return await cls.get_note_by_id(session, row.id)
|
||||
|
||||
@classmethod
|
||||
async def update_note(cls, session: AsyncSession, record_id, fields: dict):
|
||||
row = await cls.get_note_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
setattr(row, key, value)
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
class Activity(SQLModel, table=True):
|
||||
__tablename__ = "activity"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
activity_type: str = Field(default="")
|
||||
activity_date: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
activity_time: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
activity_status: str = Field(default="")
|
||||
description: str | None = Field(default=None)
|
||||
inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
|
||||
inbox: Optional["Inbox"] = Relationship(
|
||||
back_populates="activity",
|
||||
sa_relationship_kwargs={"lazy": "selectin"},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||
try:
|
||||
return uuid.UUID(str(record_id))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_activity_by_id(cls, session: AsyncSession, record_id):
|
||||
uid = cls._as_uuid(record_id)
|
||||
if uid is None:
|
||||
return None
|
||||
result = await session.execute(select(cls).where(cls.id == uid))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def get_activity_by_inbox(cls, session: AsyncSession, inbox_id: int):
|
||||
result = await session.execute(
|
||||
select(cls).where(cls.inbox_id == inbox_id).order_by(cls.activity_date.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
async def insert_activity(cls, session: AsyncSession, fields: dict):
|
||||
row = cls(**fields)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return await cls.get_activity_by_id(session, row.id)
|
||||
|
||||
@classmethod
|
||||
async def update_activity(cls, session: AsyncSession, record_id, fields: dict):
|
||||
row = await cls.get_activity_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
setattr(row, key, value)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
class Feedback(SQLModel, table=True):
|
||||
__tablename__ = "feedback"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
review: str = Field(default="")
|
||||
financial_status: str = Field(default="")
|
||||
score: float = Field(default=0.0)
|
||||
note: str | None = Field(default=None)
|
||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
reviewed_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
|
||||
user: Optional["Users"] = Relationship(
|
||||
back_populates="feedback",
|
||||
sa_relationship_kwargs={"lazy": "selectin"},
|
||||
)
|
||||
inbox: Optional["Inbox"] = Relationship(
|
||||
back_populates="feedback",
|
||||
sa_relationship_kwargs={"lazy": "selectin"},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||
try:
|
||||
return uuid.UUID(str(record_id))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_feedback_by_id(cls, session: AsyncSession, record_id):
|
||||
uid = cls._as_uuid(record_id)
|
||||
if uid is None:
|
||||
return None
|
||||
result = await session.execute(select(cls).where(cls.id == uid))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def get_feedback_by_inbox(cls, session: AsyncSession, inbox_id: int):
|
||||
result = await session.execute(
|
||||
select(cls).where(cls.inbox_id == inbox_id).order_by(cls.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
async def insert_feedback(cls, session: AsyncSession, fields: dict):
|
||||
row = cls(**fields)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return await cls.get_feedback_by_id(session, row.id)
|
||||
|
||||
@classmethod
|
||||
async def update_feedback(cls, session: AsyncSession, record_id, fields: dict):
|
||||
row = await cls.get_feedback_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
setattr(row, key, value)
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
import users.models as _users_models # noqa: E402, F401
|
||||
|
|
|
|||
|
|
@ -28,3 +28,46 @@ def normalize_spaced_text(text) -> str:
|
|||
lines = [re.sub(r" {2,}", " ", line).strip() for line in text.splitlines()]
|
||||
return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()
|
||||
|
||||
|
||||
# Mirrors frontend Inbox.jsx sourceFrom — board name is tagged in the To address.
|
||||
INBOX_SOURCES = (
|
||||
"Microsoft Outlook",
|
||||
"Career Portal",
|
||||
"Manual CV Upload",
|
||||
"LinkedIn",
|
||||
"Indeed",
|
||||
"Rozee",
|
||||
"Mustakbil",
|
||||
"Employee Referral",
|
||||
"Recruitment Agency",
|
||||
"Campus Hiring",
|
||||
"Walk-in",
|
||||
)
|
||||
|
||||
|
||||
def _letters_only(value: str) -> str:
|
||||
return re.sub(r"[^a-z]", "", (value or "").lower())
|
||||
|
||||
|
||||
def source_from_message_to(message_to: str | None) -> str:
|
||||
raw = (message_to or "").strip()
|
||||
if not raw:
|
||||
return "Unknown"
|
||||
flat = _letters_only(raw)
|
||||
for name in INBOX_SOURCES:
|
||||
if _letters_only(name) and _letters_only(name) in flat:
|
||||
return name
|
||||
return raw.split(",")[0].strip()
|
||||
|
||||
|
||||
def documents_from_message(file_name: str | None, file_path: str | None) -> list[dict]:
|
||||
names = [n.strip() for n in (file_name or "").split(",") if n.strip()]
|
||||
paths = [p.strip() for p in (file_path or "").split(",") if p.strip()]
|
||||
out = []
|
||||
for i, name in enumerate(names):
|
||||
out.append({"name": name, "path": paths[i] if i < len(paths) else None})
|
||||
if not out and paths:
|
||||
for path in paths:
|
||||
out.append({"name": path.rsplit("/", 1)[-1].rsplit("\\", 1)[-1], "path": path})
|
||||
return out
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,25 @@
|
|||
from inbox.models import Inbox
|
||||
from typing import Any,List,Dict
|
||||
|
||||
from job.candidate.plugins import documents_from_message, source_from_message_to
|
||||
from job.interviews.serializers import serialize_interview
|
||||
from job.activity.serializers import serialize_activity
|
||||
from job.feedback.serializers import serialize_feedback
|
||||
|
||||
def serialize_candidate_profile(link:Inbox|List[Inbox]|Dict[str,Any]|List[Dict[str,Any]]) -> Dict[str,Any]|List[Dict[str,Any]]:
|
||||
|
||||
def serialize_candidate_profile(
|
||||
link:Inbox|List[Inbox]|Dict[str,Any]|List[Dict[str,Any]],
|
||||
*,
|
||||
detail:bool=False,
|
||||
) -> Dict[str,Any]|List[Dict[str,Any]]:
|
||||
if isinstance(link,list):
|
||||
return [serialize_candidate_profile(item) for item in link]
|
||||
return [serialize_candidate_profile(item,detail=detail) for item in link]
|
||||
if isinstance(link,dict):
|
||||
return link
|
||||
|
||||
user = link.user
|
||||
message = link.messages
|
||||
return {
|
||||
payload = {
|
||||
"inbox_id": link.id,
|
||||
"user_id": str(link.user_id) if link.user_id else None,
|
||||
"name": user.name if user else None,
|
||||
|
|
@ -20,8 +29,10 @@ def serialize_candidate_profile(link:Inbox|List[Inbox]|Dict[str,Any]|List[Dict[s
|
|||
"created_at": link.created_at.isoformat() if link.created_at else None,
|
||||
"application_status": message.application_status if message else None,
|
||||
"experience": message.experience if message else None,
|
||||
"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,
|
||||
|
|
@ -29,3 +40,31 @@ def serialize_candidate_profile(link:Inbox|List[Inbox]|Dict[str,Any]|List[Dict[s
|
|||
"matched_at": message.matched_at.isoformat() if message and message.matched_at else None,
|
||||
"job_posts": [],
|
||||
}
|
||||
if not detail:
|
||||
return payload
|
||||
|
||||
payload.update({
|
||||
"favorite": link.favorite,
|
||||
"rating": link.rating,
|
||||
"phone": message.candidate_phone_number if message else None,
|
||||
"education": message.candidate_education if message else None,
|
||||
"currentCompany": message.current_employment if message else None,
|
||||
"stage": message.application_status if message else None,
|
||||
"source": source_from_message_to(message.message_to if message else None),
|
||||
"applied": message.message_received_time if message else None,
|
||||
"documents": documents_from_message(
|
||||
message.file_name if message else None,
|
||||
message.file_path if message else None,
|
||||
),
|
||||
"recruiter": None,
|
||||
"recruiter_id": None,
|
||||
"job_title": None,
|
||||
"ai_score": None,
|
||||
"recommendation": None,
|
||||
"sub_scores": None,
|
||||
"interviews": [serialize_interview(r) for r in (link.interviews or [])],
|
||||
"activity": [serialize_activity(r) for r in (link.activity or [])],
|
||||
"feedback": [serialize_feedback(r) for r in (link.feedback or [])],
|
||||
"notes": [],
|
||||
})
|
||||
return payload
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@ from datetime import datetime,timezone
|
|||
from fastapi import HTTPException
|
||||
from pypdf import PdfReader
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import true
|
||||
from job.job_post.models import JobPosts
|
||||
from job.job_post.serializers import serialize_job_post
|
||||
from job.candidate.serializers import serialize_candidate_profile
|
||||
from job.candidate.models import Notes
|
||||
from job.notes.serializers import serialize_note
|
||||
from inbox.models import Inbox_Messages,Inbox
|
||||
from job.candidate.plugins import normalize_spaced_text
|
||||
|
||||
|
|
@ -77,7 +80,12 @@ class CandidateView:
|
|||
|
||||
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None):
|
||||
try:
|
||||
rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=limit,offset=offset,search=search)
|
||||
detail=bool(user_id)
|
||||
# Detail mode must see every application for the candidate, not one page.
|
||||
fetch_limit=1000 if detail else limit
|
||||
rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=fetch_limit,offset=offset,search=search)
|
||||
if detail:
|
||||
return await self.attach_profile_detail(rows)
|
||||
return await self.attach_job_posts(rows)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
@ -92,7 +100,27 @@ 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 update_candidate(self,user_id,payload):
|
||||
try:
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=400,detail="user_id is required")
|
||||
fields={k:v for k,v in (payload or {}).items() if k in ("favorite","rating") and v is not None}
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400,detail="favorite or rating is required")
|
||||
links=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=100,offset=0)
|
||||
records=links if isinstance(links,list) else ([links] if links else [])
|
||||
if not records:
|
||||
raise HTTPException(status_code=404,detail="Candidate not found")
|
||||
for link in records:
|
||||
await Inbox.update_inbox(self.session,link.id,fields)
|
||||
refreshed=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=100,offset=0)
|
||||
return await self.attach_profile_detail(refreshed)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
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)
|
||||
|
|
@ -100,7 +128,20 @@ class CandidateView:
|
|||
return None
|
||||
payload=serialize_job_post(job_post_data)
|
||||
if isinstance(data,dict):
|
||||
data.setdefault("job_posts",[]).append(payload)
|
||||
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))
|
||||
|
|
@ -113,7 +154,92 @@ 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)
|
||||
return enriched[0] if single else enriched
|
||||
|
||||
async def attach_profile_detail(self,data):
|
||||
"""Detail mode: flatten child collections across every Inbox row for the candidate."""
|
||||
single=not isinstance(data,list)
|
||||
records=[data] if single else list(data or [])
|
||||
if not records:
|
||||
return {} if single else []
|
||||
|
||||
interviews=[]
|
||||
activity=[]
|
||||
feedback=[]
|
||||
documents=[]
|
||||
job_posts=[]
|
||||
assigned_job_post=None
|
||||
base=None
|
||||
user_id=None
|
||||
favorite=None
|
||||
rating=None
|
||||
for record in records:
|
||||
payload=serialize_candidate_profile(record,detail=True)
|
||||
if base is None:
|
||||
base=payload
|
||||
user_id=payload.get("user_id")
|
||||
favorite=payload.get("favorite")
|
||||
rating=payload.get("rating")
|
||||
interviews.extend(payload.get("interviews") or [])
|
||||
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 []:
|
||||
if not any(x.get("id")==jp.get("id") for x in job_posts):
|
||||
job_posts.append(jp)
|
||||
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")
|
||||
|
||||
notes=[]
|
||||
uid=Notes._as_uuid(user_id) if user_id else None
|
||||
if uid is not None:
|
||||
result=await self.session.execute(
|
||||
select(Notes)
|
||||
.options(selectinload(Notes.author))
|
||||
.where(Notes.user_id==uid)
|
||||
.order_by(Notes.created_at.desc())
|
||||
)
|
||||
notes=[serialize_note(r) for r in result.scalars().all()]
|
||||
|
||||
activity.sort(key=lambda r:(r.get("activity_date") or ""),reverse=True)
|
||||
base["favorite"]=favorite
|
||||
base["rating"]=rating
|
||||
base["interviews"]=interviews
|
||||
base["activity"]=activity
|
||||
base["feedback"]=feedback
|
||||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
def serialize_feedback(row) -> dict:
|
||||
reviewer=getattr(row,"user",None)
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"inbox_id": row.inbox_id,
|
||||
"review": row.review,
|
||||
"financial_status": row.financial_status,
|
||||
"score": row.score,
|
||||
"note": row.note,
|
||||
"reviewed_by": str(row.reviewed_by) if row.reviewed_by else None,
|
||||
"reviewed_by_name": reviewer.name if reviewer else None,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import select
|
||||
|
||||
from job.candidate.models import Feedback
|
||||
from job.feedback.serializers import serialize_feedback
|
||||
|
||||
|
||||
class FeedbackView:
|
||||
def __init__(self,session:AsyncSession):
|
||||
self.session=session
|
||||
|
||||
async def _load(self,record_id):
|
||||
uid=Feedback._as_uuid(record_id)
|
||||
if uid is None:
|
||||
return None
|
||||
result=await self.session.execute(
|
||||
select(Feedback).options(selectinload(Feedback.user)).where(Feedback.id==uid)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_feedback(self,feedback_id=None,inbox_id=None):
|
||||
if feedback_id:
|
||||
row=await self._load(feedback_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Feedback not found")
|
||||
return serialize_feedback(row)
|
||||
if inbox_id is None:
|
||||
raise HTTPException(status_code=400,detail="feedback_id or inbox_id is required")
|
||||
result=await self.session.execute(
|
||||
select(Feedback)
|
||||
.options(selectinload(Feedback.user))
|
||||
.where(Feedback.inbox_id==int(inbox_id))
|
||||
.order_by(Feedback.created_at.desc())
|
||||
)
|
||||
return [serialize_feedback(r) for r in result.scalars().all()]
|
||||
|
||||
async def create_feedback(self,payload,current_user):
|
||||
fields={
|
||||
"review":payload.get("review") or "",
|
||||
"financial_status":payload.get("financial_status") or "",
|
||||
"score":payload.get("score") if payload.get("score") is not None else 0.0,
|
||||
"note":payload.get("note"),
|
||||
"inbox_id":payload.get("inbox_id"),
|
||||
"reviewed_by":payload.get("reviewed_by") or (
|
||||
current_user.get("id") if isinstance(current_user,dict) else None
|
||||
),
|
||||
}
|
||||
row=await Feedback.insert_feedback(self.session,fields)
|
||||
row=await self._load(row.id)
|
||||
return serialize_feedback(row)
|
||||
|
||||
async def update_feedback(self,feedback_id,payload):
|
||||
allowed=("review","financial_status","score","note","inbox_id","reviewed_by")
|
||||
fields={k:v for k,v in payload.items() if v is not None and k in allowed}
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400,detail="No fields to update")
|
||||
row=await Feedback.update_feedback(self.session,feedback_id,fields)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Feedback not found")
|
||||
row=await self._load(row.id)
|
||||
return serialize_feedback(row)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
def serialize_interview(row) -> dict:
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"inbox_id": row.inbox_id,
|
||||
"interview_date": row.interview_date.isoformat() if row.interview_date else None,
|
||||
"interview_time": row.interview_time.isoformat() if row.interview_time else None,
|
||||
"interview_type": row.interview_type,
|
||||
"interview_status": row.interview_status,
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from job.candidate.models import Interviews
|
||||
from job.interviews.serializers import serialize_interview
|
||||
|
||||
|
||||
class Interview:
|
||||
def __init__(self,session:AsyncSession):
|
||||
self.session=session
|
||||
|
||||
async def get_interview(self,interview_id=None,inbox_id=None):
|
||||
if interview_id:
|
||||
row=await Interviews.get_interview_by_id(self.session,interview_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Interview not found")
|
||||
return serialize_interview(row)
|
||||
if inbox_id is None:
|
||||
raise HTTPException(status_code=400,detail="interview_id or inbox_id is required")
|
||||
rows=await Interviews.get_interviews_by_inbox(self.session,int(inbox_id))
|
||||
return [serialize_interview(r) for r in rows]
|
||||
|
||||
async def create_interview(self,payload):
|
||||
fields={
|
||||
"interview_date":payload.get("interview_date"),
|
||||
"interview_time":payload.get("interview_time"),
|
||||
"interview_type":payload.get("interview_type") or "",
|
||||
"interview_status":payload.get("interview_status") or "",
|
||||
"inbox_id":payload.get("inbox_id"),
|
||||
}
|
||||
fields={k:v for k,v in fields.items() if v is not None}
|
||||
row=await Interviews.insert_interview(self.session,fields)
|
||||
return serialize_interview(row)
|
||||
|
||||
async def update_interview(self,interview_id,payload):
|
||||
fields={k:v for k,v in payload.items() if v is not None}
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400,detail="No fields to update")
|
||||
row=await Interviews.update_interview(self.session,interview_id,fields)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Interview not found")
|
||||
return serialize_interview(row)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ def serialize_job_post(row) -> dict:
|
|||
"status": row.status,
|
||||
"buffer_error": row.buffer_error,
|
||||
"created_by": str(row.created_by) if row.created_by else None,
|
||||
"created_by_name": row.user.name if getattr(row, "user", None) else None,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
def serialize_note(row) -> dict:
|
||||
author=getattr(row,"author",None)
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"note": row.note,
|
||||
"user_id": str(row.user_id) if row.user_id else None,
|
||||
"created_by": str(row.created_by) if row.created_by else None,
|
||||
"created_by_name": author.name if author else None,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import select
|
||||
|
||||
from job.candidate.models import Notes
|
||||
from job.notes.serializers import serialize_note
|
||||
|
||||
|
||||
class Note:
|
||||
def __init__(self,session:AsyncSession):
|
||||
self.session=session
|
||||
|
||||
async def _load(self,record_id):
|
||||
uid=Notes._as_uuid(record_id)
|
||||
if uid is None:
|
||||
return None
|
||||
result=await self.session.execute(
|
||||
select(Notes).options(selectinload(Notes.author)).where(Notes.id==uid)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_note(self,note_id=None,user_id=None):
|
||||
if note_id:
|
||||
row=await self._load(note_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Note not found")
|
||||
return serialize_note(row)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=400,detail="note_id or user_id is required")
|
||||
uid=Notes._as_uuid(user_id)
|
||||
if uid is None:
|
||||
raise HTTPException(status_code=400,detail="Invalid user_id")
|
||||
result=await self.session.execute(
|
||||
select(Notes)
|
||||
.options(selectinload(Notes.author))
|
||||
.where(Notes.user_id==uid)
|
||||
.order_by(Notes.created_at.desc())
|
||||
)
|
||||
return [serialize_note(r) for r in result.scalars().all()]
|
||||
|
||||
async def create_note(self,payload,current_user):
|
||||
fields={
|
||||
"note":payload.get("note") or "",
|
||||
"user_id":payload.get("user_id"),
|
||||
"created_by":current_user.get("id") if isinstance(current_user,dict) else None,
|
||||
}
|
||||
if not fields["user_id"]:
|
||||
raise HTTPException(status_code=400,detail="user_id is required")
|
||||
row=await Notes.insert_note(self.session,fields)
|
||||
row=await self._load(row.id)
|
||||
return serialize_note(row)
|
||||
|
||||
async def update_note(self,note_id,payload):
|
||||
fields={k:v for k,v in payload.items() if v is not None and k in ("note",)}
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400,detail="No fields to update")
|
||||
row=await Notes.update_note(self.session,note_id,fields)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Note not found")
|
||||
row=await self._load(row.id)
|
||||
return serialize_note(row)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING,List,Optional
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -12,6 +12,7 @@ from job.job_post.models import JobPosts
|
|||
|
||||
if TYPE_CHECKING: # runtime import would cycle: inbox.models imports this module
|
||||
from inbox.models import Inbox
|
||||
from job.candidate.models import Feedback, Notes
|
||||
|
||||
class Users(SQLModel, table=True):
|
||||
__tablename__ = "users"
|
||||
|
|
@ -26,15 +27,29 @@ class Users(SQLModel, table=True):
|
|||
# selectin, not joined: this is a one-to-many, so a joined load would repeat the
|
||||
# user row once per post. Without an explicit strategy the default is a lazy load,
|
||||
# which raises MissingGreenlet the moment anything touches it under asyncio.
|
||||
job_posts: list[JobPosts] = Relationship(
|
||||
job_posts: List[JobPosts] = Relationship(
|
||||
back_populates="user",
|
||||
sa_relationship_kwargs={"lazy": "selectin"},
|
||||
)
|
||||
inbox: list["Inbox"] = Relationship(
|
||||
inbox: List["Inbox"] = Relationship(
|
||||
back_populates="user",
|
||||
sa_relationship_kwargs={"lazy": "selectin"},
|
||||
)
|
||||
feedback: List["Feedback"] = Relationship(
|
||||
back_populates="user",
|
||||
sa_relationship_kwargs={"lazy": "selectin"},
|
||||
)
|
||||
notes: List["Notes"] = Relationship(
|
||||
back_populates="user",
|
||||
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[Notes.user_id]"},
|
||||
)
|
||||
authored_notes: List["Notes"] = Relationship(
|
||||
back_populates="author",
|
||||
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[Notes.created_by]"},
|
||||
)
|
||||
|
||||
password: str
|
||||
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
updated_at: datetime = Field(default_factory=datetime.now)
|
||||
is_active: bool = Field(default=False)
|
||||
|
|
@ -134,3 +149,6 @@ class Users(SQLModel, table=True):
|
|||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
import job.candidate.models as _candidate_models # noqa: E402, F401
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ export function list({ search, limit, offset } = {}) {
|
|||
/**
|
||||
* One candidate by users.id.
|
||||
*
|
||||
* Passing user_id switches the endpoint into DETAIL mode
|
||||
* (backend/job/candidate/views.py:get_candidate), which is a different and much
|
||||
* larger payload than the list rows: résumé text, the AI match verdict, phone,
|
||||
* education, source, documents, favorite/rating, and the four child collections
|
||||
* — interviews, activity, feedback, notes — flattened across every inbox row the
|
||||
* candidate owns.
|
||||
*
|
||||
* NOTE the asymmetric response: get_candidate_profile returns a BARE OBJECT
|
||||
* rather than a one-element list when user_id matches exactly one row
|
||||
* (backend/inbox/models.py:68-70). Callers must normalise — see toRows().
|
||||
|
|
@ -30,3 +37,58 @@ export function toRows(res) {
|
|||
if (Array.isArray(res?.data)) return res.data
|
||||
return res?.data ? [res.data] : []
|
||||
}
|
||||
|
||||
/**
|
||||
* favorite/rating live on the `inbox` row, not on the user, so the server applies
|
||||
* the change to EVERY application belonging to the candidate and hands back the
|
||||
* refreshed detail payload. Pipeline stage is not writable here — no endpoint
|
||||
* updates inbox_messages.application_status yet.
|
||||
*/
|
||||
export function update(userId, payload) {
|
||||
return request('/candidate/update', { method: 'PATCH', params: { user_id: userId }, body: payload })
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Child records of a profile.
|
||||
|
||||
Reads are deliberately absent: the detail payload above already bundles all
|
||||
four collections, so a separate GET per tab would be a second round trip for
|
||||
data the modal is holding. Writers invalidate qk.candidates.detail(userId) and
|
||||
the whole modal repaints from one refetch.
|
||||
|
||||
Scoping differs by table and is not interchangeable — notes hang off the
|
||||
candidate (users.id), while interviews, activity and feedback hang off one
|
||||
application (inbox.id).
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
export function createNote({ userId, note }) {
|
||||
return request('/notes/create', { method: 'POST', body: { user_id: userId, note } })
|
||||
}
|
||||
|
||||
export function createInterview({ inboxId, date, time, type, status }) {
|
||||
return request('/interview/create', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
inbox_id: inboxId,
|
||||
interview_date: date,
|
||||
interview_time: time,
|
||||
interview_type: type,
|
||||
interview_status: status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** `reviewed_by` is omitted on purpose: the server stamps the caller. */
|
||||
export function createFeedback({ inboxId, review, score, note }) {
|
||||
return request('/feedback/create', {
|
||||
method: 'POST',
|
||||
body: { inbox_id: inboxId, review, score, note },
|
||||
})
|
||||
}
|
||||
|
||||
export function createActivity({ inboxId, type, status, description }) {
|
||||
return request('/activity/create', {
|
||||
method: 'POST',
|
||||
body: { inbox_id: inboxId, activity_type: type, activity_status: status, description },
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { NavLink } from 'react-router-dom'
|
|||
import { NAV_GROUPS, ROUTES } from './routes'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import Icon from '../ui/icons'
|
||||
import BrandMark from '../components/BrandMark'
|
||||
import { BrandGlyph } from '../components/BrandMark'
|
||||
|
||||
export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badges }) {
|
||||
const { can } = useAuth()
|
||||
|
|
@ -18,7 +18,7 @@ export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badge
|
|||
>
|
||||
<div className="sidebar-brand">
|
||||
<div className="brand-logo">
|
||||
<BrandMark />
|
||||
<BrandGlyph />
|
||||
</div>
|
||||
<div className="brand-text">
|
||||
<span className="brand-name">TalentFlow</span>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,25 @@
|
|||
/* The wordmark, in two pieces.
|
||||
|
||||
BrandGlyph is the emblem on its own. The sidebar paints its own `.brand-logo`
|
||||
tile and its own `.brand-name`, so handing it the full BrandMark nested one
|
||||
green tile inside another and printed "TalentFlow" twice — once inside the
|
||||
tile, once beside it. Callers that already supply their own chrome take the
|
||||
glyph; callers that want the whole lockup (the auth screens) take the default. */
|
||||
|
||||
export function BrandGlyph() {
|
||||
return (
|
||||
<svg className="brand-mark" viewBox="0 0 100 64.46" aria-hidden="true">
|
||||
<path d="M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BrandMark({ size = 'md', showName = true }) {
|
||||
const logoClass = size === 'lg' ? 'brand-logo brand-logo-lg' : 'brand-logo'
|
||||
return (
|
||||
<div className="auth-brand">
|
||||
<div className={logoClass}>
|
||||
<svg className="brand-mark" viewBox="0 0 100 64.46" aria-hidden="true">
|
||||
<path d="M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z" />
|
||||
</svg>
|
||||
<BrandGlyph />
|
||||
</div>
|
||||
{showName ? <span className="brand-name">TalentFlow</span> : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -1,18 +1,106 @@
|
|||
/* The 8-tab candidate profile modal, split out of Candidates.jsx — it was the
|
||||
single largest block in js/candidates.js and deserves its own file. */
|
||||
single largest block in js/candidates.js and deserves its own file.
|
||||
|
||||
TWO DATA MODES, selected by whether the caller passes a `userId`:
|
||||
|
||||
SEED (Candidates.jsx) — every tab renders from the seed record, exactly as
|
||||
the prototype did.
|
||||
LIVE (TalentPool.jsx) — GET /candidate/fetch?user_id= switches the endpoint
|
||||
into detail mode and returns the real record: résumé text, the agent's
|
||||
match verdict, documents, and the four child collections (interviews,
|
||||
notes, activity, feedback). The write tabs POST to their own endpoints
|
||||
and invalidate this one query, so the whole modal repaints from a single
|
||||
refetch.
|
||||
|
||||
Live collections are NEVER padded with the seed's demo rows. An empty tab gets
|
||||
an empty state, because inventing three scorecards for a real applicant is
|
||||
worse than showing none.
|
||||
|
||||
Scoping differs between the child tables and is not interchangeable: notes
|
||||
hang off the candidate (users.id), while interviews, activity and feedback
|
||||
hang off one application (inbox.id). */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { seedQuery } from '../data/seedQueries'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import { companies, fmtDate, moneyK, pick } from '../data/seed'
|
||||
|
||||
const TABS = ['Overview', 'Resume', 'Timeline', 'Interview', 'Notes', 'Activity', 'Documents', 'Feedback']
|
||||
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
|
||||
const REVIEWS = ['Strong Hire', 'Hire', 'Lean Hire', 'No Hire']
|
||||
const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round']
|
||||
const INTERVIEW_STATES = ['Scheduled', 'Completed', 'Cancelled', 'No Show']
|
||||
const ACTIVITY_TYPES = ['Call', 'Email', 'Meeting', 'Screening', 'Assessment', 'Note']
|
||||
|
||||
/** Seed timestamps are Date objects; the API sends ISO strings. */
|
||||
function fmtWhen(value, fallback = '—') {
|
||||
if (!value) return fallback
|
||||
const d = value instanceof Date ? value : new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? String(value) : fmtDate(d)
|
||||
}
|
||||
|
||||
function fmtClock(value) {
|
||||
if (!value) return null
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? null : d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function stamp(value) {
|
||||
const d = value instanceof Date ? value : new Date(value ?? NaN)
|
||||
return Number.isNaN(d.getTime()) ? 0 : d.getTime()
|
||||
}
|
||||
|
||||
/** <input type="date"> + <input type="time"> -> one ISO instant, or null. */
|
||||
function toInstant(date, time) {
|
||||
if (!date) return null
|
||||
const d = new Date(`${date}T${time || '00:00'}`)
|
||||
return Number.isNaN(d.getTime()) ? null : d.toISOString()
|
||||
}
|
||||
|
||||
function Info({ label, val }) {
|
||||
return (
|
||||
<div className="info-item">
|
||||
<div className="il">{label}</div>
|
||||
<div className="iv">{val === 0 || val ? val : '—'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useCandidateDetail(userId) {
|
||||
return useQuery({
|
||||
queryKey: qk.candidates.detail(userId),
|
||||
queryFn: async () => candidatesApi.toRows(await candidatesApi.getByUserId(userId))[0] ?? null,
|
||||
enabled: Boolean(userId),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A write against one of the child endpoints. Every one of them invalidates the
|
||||
* single detail query the modal renders from, so a saved note and a submitted
|
||||
* scorecard both land through the same refetch rather than through hand-patched
|
||||
* cache entries that could drift from the server's view.
|
||||
*/
|
||||
function useProfileWrite({ userId, mutationFn, success, onDone }) {
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
onSuccess: async (_data, vars) => {
|
||||
await qc.invalidateQueries({ queryKey: qk.candidates.detail(userId) })
|
||||
toast(typeof success === 'function' ? success(vars) : success, 'success')
|
||||
onDone?.()
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not save. Please try again.'), 'error'),
|
||||
})
|
||||
}
|
||||
|
||||
export default function CandidateProfile({ candidate: c, onClose, onAdvance, onToggleFav, onAtsMatch }) {
|
||||
const { toast } = useToast()
|
||||
|
|
@ -20,12 +108,53 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
|
||||
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
||||
|
||||
const isLive = Boolean(c.userId)
|
||||
const detail = useCandidateDetail(c.userId)
|
||||
const live = detail.data ?? null
|
||||
|
||||
// The prototype called DB.pick() inline while rendering, so the "previous
|
||||
// employer" changed every repaint. Fixed per candidate.
|
||||
const priorCompany = useMemo(() => pick(companies), [])
|
||||
|
||||
const candidateInterviews = interviews.filter((i) => i.candidateId === c.id)
|
||||
|
||||
// The application row interviews/activity/feedback attach to. Detail mode
|
||||
// flattens every application the candidate owns; writes land on the first,
|
||||
// which is the one the header is describing.
|
||||
const inboxId = live?.inbox_id ?? null
|
||||
|
||||
const favorite = live ? Boolean(live.favorite) : c.favorite
|
||||
const setFavorite = useProfileWrite({
|
||||
userId: c.userId,
|
||||
mutationFn: (next) => candidatesApi.update(c.userId, { favorite: next }),
|
||||
success: (next) => (next ? `${c.name} added to favorites` : 'Removed from favorites'),
|
||||
})
|
||||
|
||||
const title = live?.job_title || c.currentTitle
|
||||
const company = live?.currentCompany || c.currentCompany
|
||||
|
||||
const counts = live && {
|
||||
Interview: live.interviews?.length ?? 0,
|
||||
Notes: live.notes?.length ?? 0,
|
||||
Activity: live.activity?.length ?? 0,
|
||||
Documents: live.documents?.length ?? 0,
|
||||
Feedback: live.feedback?.length ?? 0,
|
||||
}
|
||||
|
||||
// In live mode nothing below the hero can be trusted until the detail payload
|
||||
// lands, so one guard replaces every tab body rather than each tab inventing
|
||||
// its own half-loaded state.
|
||||
const guard = !isLive ? null
|
||||
: detail.isPending ? (
|
||||
<EmptyState icon="refresh" title="Loading candidate…">Fetching the full record.</EmptyState>
|
||||
) : detail.isError ? (
|
||||
<EmptyState icon="alert" title="Could not load this candidate">
|
||||
{friendlyAuthError(detail.error, 'Please try again.')}
|
||||
</EmptyState>
|
||||
) : !live ? (
|
||||
<EmptyState icon="user" title="No record found">This candidate is no longer in the pipeline.</EmptyState>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Candidate Profile"
|
||||
|
|
@ -35,11 +164,12 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
footer={
|
||||
<>
|
||||
<button
|
||||
className={`btn btn-ghost star-btn${c.favorite ? ' on' : ''}`}
|
||||
className={`btn btn-ghost star-btn${favorite ? ' on' : ''}`}
|
||||
style={{ marginRight: 'auto' }}
|
||||
onClick={() => onToggleFav(c)}
|
||||
disabled={isLive && (setFavorite.isPending || !live)}
|
||||
onClick={() => (isLive ? setFavorite.mutate(!favorite) : onToggleFav(c))}
|
||||
>
|
||||
<Icon name="star" /> {c.favorite ? 'Favorited' : 'Favorite'}
|
||||
<Icon name="star" /> {favorite ? 'Favorited' : 'Favorite'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
|
||||
<Icon name="target" /> ATS Match
|
||||
|
|
@ -56,10 +186,10 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
<div className="profile-hero">
|
||||
<Avatar name={c.name} initials={c.initials} color={c.color} className="avatar-lg" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="ph-name">{c.name}</div>
|
||||
<div className="ph-role">{c.currentTitle} at {c.currentCompany}</div>
|
||||
<div className="ph-name">{live?.name || c.name}</div>
|
||||
<div className="ph-role">{company ? `${title} at ${company}` : title}</div>
|
||||
<div className="ph-tags">
|
||||
<Badge>{c.stage}</Badge> <Badge className="b-gray">{c.source}</Badge>
|
||||
<Badge>{c.stage}</Badge> <Badge className="b-gray">{live?.source || c.source}</Badge>
|
||||
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -70,32 +200,87 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
</div>
|
||||
|
||||
<div style={{ marginTop: 22 }}>
|
||||
<Tabs value={tab} onChange={setTab} tabs={TABS.map((t) => ({ key: t, label: t }))} />
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
tabs={TABS.map((t) => ({ key: t, label: t, count: counts ? counts[t] : undefined }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="tab-pane active">
|
||||
{tab === 'Overview' && (
|
||||
{tab === 'Overview' && (guard || (live ? (
|
||||
<>
|
||||
<div className="info-grid" style={{ marginBottom: 20 }}>
|
||||
<div className="info-item"><div className="il">Email</div><div className="iv">{c.email}</div></div>
|
||||
<div className="info-item"><div className="il">Phone</div><div className="iv">{c.phone}</div></div>
|
||||
<div className="info-item"><div className="il">Location</div><div className="iv">{c.location}</div></div>
|
||||
<div className="info-item"><div className="il">Applied For</div><div className="iv">{c.jobTitle}</div></div>
|
||||
<div className="info-item"><div className="il">Current Company</div><div className="iv">{c.currentCompany}</div></div>
|
||||
<div className="info-item"><div className="il">Experience</div><div className="iv">{c.experience} years</div></div>
|
||||
<div className="info-item"><div className="il">Education</div><div className="iv">{c.education}</div></div>
|
||||
<div className="info-item"><div className="il">Source</div><div className="iv">{c.source}</div></div>
|
||||
<div className="info-item"><div className="il">Recruiter</div><div className="iv">{c.recruiter}</div></div>
|
||||
<div className="info-item"><div className="il">Applied On</div><div className="iv">{fmtDate(c.applied)}</div></div>
|
||||
<div className="info-item"><div className="il">Expected Salary</div><div className="iv">{moneyK(c.salary)}</div></div>
|
||||
<div className="info-item"><div className="il">Rating</div><div className="iv">⭐ {c.rating} / 5.0</div></div>
|
||||
<Info label="Email" val={live.email} />
|
||||
<Info label="Phone" val={live.phone} />
|
||||
<Info label="Applied For" val={live.job_title} />
|
||||
<Info label="Current Company" val={live.currentCompany} />
|
||||
<Info label="Experience" val={live.experience} />
|
||||
<Info label="Education" val={live.education} />
|
||||
<Info label="Source" val={live.source} />
|
||||
<Info label="Recruiter" val={live.recruiter} />
|
||||
<Info label="Applied On" val={fmtWhen(live.applied)} />
|
||||
<Info label="Screened On" val={fmtWhen(live.matched_at)} />
|
||||
<Info label="Rating" val={`⭐ ${(live.rating ?? 0).toFixed(1)} / 5.0`} />
|
||||
<Info label="Applications" val={live.job_posts?.length || 0} />
|
||||
</div>
|
||||
|
||||
{(live.match_summary || live.match_reasoning) && (
|
||||
<>
|
||||
<div style={LABEL}>AI Screening</div>
|
||||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 18 }}>
|
||||
<div className="card-body">
|
||||
{live.match_summary && <p style={{ marginBottom: live.match_reasoning ? 10 : 0 }}>{live.match_summary}</p>}
|
||||
{live.match_reasoning && <p className="text-muted text-sm">{live.match_reasoning}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<div className="k-tags">
|
||||
{live.job_posts.map((j) => <span className="tag" key={j.id}>{j.title}</span>)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="info-grid" style={{ marginBottom: 20 }}>
|
||||
<Info label="Email" val={c.email} />
|
||||
<Info label="Phone" val={c.phone} />
|
||||
<Info label="Location" val={c.location} />
|
||||
<Info label="Applied For" val={c.jobTitle} />
|
||||
<Info label="Current Company" val={c.currentCompany} />
|
||||
<Info label="Experience" val={`${c.experience} years`} />
|
||||
<Info label="Education" val={c.education} />
|
||||
<Info label="Source" val={c.source} />
|
||||
<Info label="Recruiter" val={c.recruiter} />
|
||||
<Info label="Applied On" val={fmtWhen(c.applied)} />
|
||||
<Info label="Expected Salary" val={moneyK(c.salary)} />
|
||||
<Info label="Rating" val={`⭐ ${c.rating} / 5.0`} />
|
||||
</div>
|
||||
<div style={LABEL}>Skills</div>
|
||||
<div className="k-tags">{c.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
|
||||
</>
|
||||
)}
|
||||
)))}
|
||||
|
||||
{tab === 'Resume' && (
|
||||
{tab === 'Resume' && (guard || (live ? (
|
||||
<ResumeTab live={live} />
|
||||
) : (
|
||||
<>
|
||||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
|
||||
<div className="card-body">
|
||||
|
|
@ -125,9 +310,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
<Icon name="download" /> Download PDF
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
)))}
|
||||
|
||||
{tab === 'Timeline' && (
|
||||
{tab === 'Timeline' && (guard || (live ? (
|
||||
<TimelineTab live={live} />
|
||||
) : (
|
||||
<div className="timeline">
|
||||
{[
|
||||
{ icon: 'user-plus', title: 'Application received', meta: fmtDate(c.applied), desc: `Applied via ${c.source}` },
|
||||
|
|
@ -144,9 +331,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)))}
|
||||
|
||||
{tab === 'Interview' && (
|
||||
{tab === 'Interview' && (guard || (live ? (
|
||||
<InterviewTab userId={c.userId} inboxId={inboxId} rows={live.interviews ?? []} />
|
||||
) : (
|
||||
candidateInterviews.length ? (
|
||||
<div className="list-tight">
|
||||
{candidateInterviews.map((iv) => (
|
||||
|
|
@ -167,9 +356,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
Schedule an interview to get started.
|
||||
</EmptyState>
|
||||
)
|
||||
)}
|
||||
)))}
|
||||
|
||||
{tab === 'Notes' && (
|
||||
{tab === 'Notes' && (guard || (live ? (
|
||||
<NotesTab userId={c.userId} rows={live.notes ?? []} />
|
||||
) : (
|
||||
<>
|
||||
<div className="form-field">
|
||||
<label>Add a note</label>
|
||||
|
|
@ -201,9 +392,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)))}
|
||||
|
||||
{tab === 'Activity' && (
|
||||
{tab === 'Activity' && (guard || (live ? (
|
||||
<ActivityTab userId={c.userId} inboxId={inboxId} rows={live.activity ?? []} />
|
||||
) : (
|
||||
<div className="list-tight">
|
||||
{[
|
||||
{ icon: 'eye', tone: 'i-green', text: `Profile viewed by ${c.recruiter}`, when: '1h ago' },
|
||||
|
|
@ -222,9 +415,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)))}
|
||||
|
||||
{tab === 'Documents' && (
|
||||
{tab === 'Documents' && (guard || (live ? (
|
||||
<DocumentsTab rows={live.documents ?? []} />
|
||||
) : (
|
||||
<div className="list-tight">
|
||||
{[
|
||||
{ n: 'Resume.pdf', s: '284 KB' }, { n: 'Cover_Letter.pdf', s: '112 KB' },
|
||||
|
|
@ -241,9 +436,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)))}
|
||||
|
||||
{tab === 'Feedback' && (
|
||||
{tab === 'Feedback' && (guard || (live ? (
|
||||
<FeedbackTab userId={c.userId} inboxId={inboxId} rows={live.feedback ?? []} />
|
||||
) : (
|
||||
<>
|
||||
<div className="list-tight">
|
||||
{['Strong Hire', 'Hire', 'Lean Hire'].map((score, i) => {
|
||||
|
|
@ -270,8 +467,433 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
<Icon name="plus" /> Submit Scorecard
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
)))}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Live tabs. Each owns its own form state and its own write, so a half-typed
|
||||
note is not held by the modal shell and does not survive a tab switch.
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
function ResumeTab({ live }) {
|
||||
const source = live.documents?.[0]?.name
|
||||
if (!live.resume_text) {
|
||||
return (
|
||||
<EmptyState icon="file" title="No résumé text">
|
||||
{source
|
||||
? `${source} is attached but has not been parsed yet — run the match to extract it.`
|
||||
: 'This candidate applied without an attachment we could read.'}
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
|
||||
<div className="card-body">
|
||||
<h3 style={{ marginBottom: 4 }}>{live.name}</h3>
|
||||
<p className="text-muted">
|
||||
{source ? `Extracted from ${source}` : 'Extracted from the application email'}
|
||||
</p>
|
||||
<div className="divider" />
|
||||
<div className="text-sm" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>{live.resume_text}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineTab({ live }) {
|
||||
const events = useMemo(() => {
|
||||
const out = []
|
||||
if (live.applied) {
|
||||
out.push({
|
||||
icon: 'user-plus',
|
||||
title: 'Application received',
|
||||
at: live.applied,
|
||||
desc: live.source ? `Applied via ${live.source}` : null,
|
||||
})
|
||||
}
|
||||
if (live.matched_at) {
|
||||
out.push({
|
||||
icon: 'sparkles',
|
||||
title: 'AI screening completed',
|
||||
at: live.matched_at,
|
||||
desc: live.match_error || live.match_summary || live.match_status,
|
||||
})
|
||||
}
|
||||
for (const iv of live.interviews ?? []) {
|
||||
out.push({
|
||||
icon: 'calendar',
|
||||
title: iv.interview_type || 'Interview',
|
||||
at: iv.interview_date,
|
||||
desc: iv.interview_status,
|
||||
})
|
||||
}
|
||||
for (const a of live.activity ?? []) {
|
||||
out.push({
|
||||
icon: 'zap',
|
||||
title: a.activity_type || 'Activity',
|
||||
at: a.activity_date,
|
||||
desc: a.description || a.activity_status,
|
||||
})
|
||||
}
|
||||
for (const f of live.feedback ?? []) {
|
||||
out.push({
|
||||
icon: 'star',
|
||||
title: f.review ? `Feedback: ${f.review}` : 'Feedback submitted',
|
||||
at: f.created_at,
|
||||
desc: f.reviewed_by_name ? `by ${f.reviewed_by_name}` : f.note,
|
||||
})
|
||||
}
|
||||
return out.sort((a, b) => stamp(a.at) - stamp(b.at))
|
||||
}, [live])
|
||||
|
||||
if (!events.length) {
|
||||
return <EmptyState icon="clock" title="Nothing recorded yet">Activity appears here as the candidate moves.</EmptyState>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="timeline">
|
||||
{events.map((e, i) => (
|
||||
<div className="tl-item" key={`${e.title}-${i}`}>
|
||||
<div className="tl-dot"><Icon name={e.icon} /></div>
|
||||
<div className="tl-title">{e.title}</div>
|
||||
<div className="tl-meta">{fmtWhen(e.at)}</div>
|
||||
{e.desc && <div className="tl-desc">{e.desc}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InterviewTab({ userId, inboxId, rows }) {
|
||||
const { toast } = useToast()
|
||||
const [form, setForm] = useState({ type: INTERVIEW_TYPES[0], date: '', time: '', status: INTERVIEW_STATES[0] })
|
||||
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const create = useProfileWrite({
|
||||
userId,
|
||||
// interviews.interview_date and .interview_time are BOTH datetime columns,
|
||||
// so the same instant goes to each rather than inventing a second one.
|
||||
mutationFn: (instant) => candidatesApi.createInterview({
|
||||
inboxId, date: instant, time: instant, type: form.type, status: form.status,
|
||||
}),
|
||||
success: 'Interview scheduled',
|
||||
onDone: () => setForm({ type: INTERVIEW_TYPES[0], date: '', time: '', status: INTERVIEW_STATES[0] }),
|
||||
})
|
||||
|
||||
function submit() {
|
||||
const instant = toInstant(form.date, form.time)
|
||||
if (!instant) {
|
||||
toast('Pick a date for the interview', 'warning')
|
||||
return
|
||||
}
|
||||
create.mutate(instant)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{rows.length ? (
|
||||
<div className="list-tight">
|
||||
{rows.map((iv) => {
|
||||
const clock = fmtClock(iv.interview_time)
|
||||
return (
|
||||
<div className="list-row" key={iv.id}>
|
||||
<span className="kpi-icn i-blue" style={{ width: 38, height: 38, borderRadius: 10 }}>
|
||||
<Icon name="calendar" />
|
||||
</span>
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{iv.interview_type || 'Interview'}</div>
|
||||
<div className="lr-sub">{fmtWhen(iv.interview_date)}{clock ? ` · ${clock}` : ''}</div>
|
||||
</div>
|
||||
<div className="lr-right">
|
||||
{iv.interview_status ? <Badge>{iv.interview_status}</Badge> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="calendar" title="No interviews scheduled">
|
||||
Schedule the first round below.
|
||||
</EmptyState>
|
||||
)}
|
||||
|
||||
<div className="divider" />
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>Schedule an interview</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>Type</label>
|
||||
<select value={form.type} onChange={(e) => set('type', e.target.value)}>
|
||||
{INTERVIEW_TYPES.map((t) => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Status</label>
|
||||
<select value={form.status} onChange={(e) => set('status', e.target.value)}>
|
||||
{INTERVIEW_STATES.map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Date</label>
|
||||
<input type="date" value={form.date} onChange={(e) => set('date', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Time</label>
|
||||
<input type="time" value={form.time} onChange={(e) => set('time', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ marginTop: 10 }}
|
||||
disabled={!inboxId || create.isPending}
|
||||
onClick={submit}
|
||||
>
|
||||
<Icon name="plus" /> {create.isPending ? 'Scheduling…' : 'Schedule Interview'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NotesTab({ userId, rows }) {
|
||||
const [text, setText] = useState('')
|
||||
const create = useProfileWrite({
|
||||
userId,
|
||||
mutationFn: () => candidatesApi.createNote({ userId, note: text.trim() }),
|
||||
success: 'Note saved',
|
||||
onDone: () => setText(''),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-field">
|
||||
<label>Add a note</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Write a private note about this candidate…"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ margin: '10px 0 18px' }}
|
||||
disabled={!text.trim() || create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
<Icon name="plus" /> {create.isPending ? 'Saving…' : 'Add Note'}
|
||||
</button>
|
||||
|
||||
{rows.length ? (
|
||||
<div className="list-tight">
|
||||
{rows.map((n) => (
|
||||
<div className="list-row" key={n.id}>
|
||||
<Avatar name={n.created_by_name || 'Unknown'} />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{n.created_by_name || 'Unknown author'}</div>
|
||||
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>{n.note}</div>
|
||||
<div className="lr-sub">{fmtWhen(n.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="edit" title="No notes yet">The first note on this candidate goes above.</EmptyState>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityTab({ userId, inboxId, rows }) {
|
||||
const { toast } = useToast()
|
||||
const [form, setForm] = useState({ type: ACTIVITY_TYPES[0], description: '' })
|
||||
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const create = useProfileWrite({
|
||||
userId,
|
||||
mutationFn: () => candidatesApi.createActivity({
|
||||
inboxId, type: form.type, status: 'Logged', description: form.description.trim(),
|
||||
}),
|
||||
success: 'Activity logged',
|
||||
onDone: () => setForm({ type: ACTIVITY_TYPES[0], description: '' }),
|
||||
})
|
||||
|
||||
function submit() {
|
||||
if (!form.description.trim()) {
|
||||
toast('Describe what happened', 'warning')
|
||||
return
|
||||
}
|
||||
create.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{rows.length ? (
|
||||
<div className="list-tight">
|
||||
{rows.map((a) => {
|
||||
const clock = fmtClock(a.activity_time)
|
||||
return (
|
||||
<div className="list-row" key={a.id}>
|
||||
<span className="kpi-icn i-purple" style={{ width: 34, height: 34, borderRadius: 9 }}>
|
||||
<Icon name="zap" />
|
||||
</span>
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{a.activity_type || 'Activity'}</div>
|
||||
{a.description && (
|
||||
<div className="lr-sub" style={{ color: 'var(--text-2)', fontSize: 13 }}>{a.description}</div>
|
||||
)}
|
||||
<div className="lr-sub">{fmtWhen(a.activity_date)}{clock ? ` · ${clock}` : ''}</div>
|
||||
</div>
|
||||
<div className="lr-right">{a.activity_status ? <Badge>{a.activity_status}</Badge> : null}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="zap" title="No activity recorded">Log the first touchpoint below.</EmptyState>
|
||||
)}
|
||||
|
||||
<div className="divider" />
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>Log activity</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>Type</label>
|
||||
<select value={form.type} onChange={(e) => set('type', e.target.value)}>
|
||||
{ACTIVITY_TYPES.map((t) => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>What happened</label>
|
||||
<input
|
||||
value={form.description}
|
||||
onChange={(e) => set('description', e.target.value)}
|
||||
placeholder="Called to confirm availability"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
style={{ marginTop: 10 }}
|
||||
disabled={!inboxId || create.isPending}
|
||||
onClick={submit}
|
||||
>
|
||||
<Icon name="plus" /> {create.isPending ? 'Logging…' : 'Log Activity'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DocumentsTab({ rows }) {
|
||||
if (!rows.length) {
|
||||
return <EmptyState icon="file" title="No documents">This application arrived without attachments.</EmptyState>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="list-tight">
|
||||
{rows.map((d, i) => (
|
||||
<div className="list-row" key={`${d.name}-${i}`}>
|
||||
<span className="kpi-icn i-red" style={{ width: 38, height: 38, borderRadius: 10 }}>
|
||||
<Icon name="file" />
|
||||
</span>
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{d.name}</div>
|
||||
<div className="lr-sub">{d.path || 'Stored with the application'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* No download button: attachments live on the worker's filesystem and no
|
||||
route serves them yet, so a button here could only lie. */}
|
||||
<p className="text-muted text-sm" style={{ marginTop: 12 }}>
|
||||
<Icon name="info" /> Attachments are stored server-side; download is not exposed yet.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedbackTab({ userId, inboxId, rows }) {
|
||||
const { toast } = useToast()
|
||||
const [form, setForm] = useState({ review: REVIEWS[0], score: '', note: '' })
|
||||
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const create = useProfileWrite({
|
||||
userId,
|
||||
mutationFn: () => candidatesApi.createFeedback({
|
||||
inboxId,
|
||||
review: form.review,
|
||||
score: form.score === '' ? 0 : Number(form.score),
|
||||
note: form.note.trim(),
|
||||
}),
|
||||
success: 'Scorecard submitted',
|
||||
onDone: () => setForm({ review: REVIEWS[0], score: '', note: '' }),
|
||||
})
|
||||
|
||||
function submit() {
|
||||
const score = form.score === '' ? 0 : Number(form.score)
|
||||
if (!Number.isFinite(score) || score < 0 || score > 100) {
|
||||
toast('Score must be between 0 and 100', 'warning')
|
||||
return
|
||||
}
|
||||
create.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{rows.length ? (
|
||||
<div className="list-tight">
|
||||
{rows.map((f) => (
|
||||
<div className="list-row" key={f.id}>
|
||||
<Avatar name={f.reviewed_by_name || 'Unknown'} />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{f.reviewed_by_name || 'Unknown reviewer'}</div>
|
||||
{f.note && <div className="lr-sub" style={{ color: 'var(--text-2)' }}>{f.note}</div>}
|
||||
<div className="lr-sub">{fmtWhen(f.created_at)}{f.score ? ` · ${f.score}/100` : ''}</div>
|
||||
</div>
|
||||
<div className="lr-right">{f.review ? <Badge>{f.review}</Badge> : null}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="award" title="No scorecards yet">Be the first to review this candidate.</EmptyState>
|
||||
)}
|
||||
|
||||
<div className="divider" />
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>Submit a scorecard</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>Recommendation</label>
|
||||
<select value={form.review} onChange={(e) => set('review', e.target.value)}>
|
||||
{REVIEWS.map((r) => <option key={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Score (0–100)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={form.score}
|
||||
onChange={(e) => set('score', e.target.value)}
|
||||
placeholder="80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Notes</label>
|
||||
<textarea
|
||||
value={form.note}
|
||||
onChange={(e) => set('note', e.target.value)}
|
||||
placeholder="What stood out, and what would you probe next round?"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ marginTop: 10 }}
|
||||
disabled={!inboxId || create.isPending}
|
||||
onClick={submit}
|
||||
>
|
||||
<Icon name="plus" /> {create.isPending ? 'Submitting…' : 'Submit Scorecard'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,868 @@
|
|||
/* ============================================================
|
||||
Job Matching — assign each inbound application to exactly one job post.
|
||||
|
||||
Queue layout mirrors Inbox (Tabs over a .split). The AI's suggested_job_post_ids
|
||||
land here as a radiogroup; Assign writes assigned_job_post_id. Nothing is
|
||||
marked read — that stays Inbox's job so this page cannot silently move the
|
||||
inbox nav badge.
|
||||
============================================================ */
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as inboxApi from '../api/inbox'
|
||||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import {
|
||||
avatarColor, fmtDate, initials as initialsOf, inboxSources, sourceMeta,
|
||||
} from '../data/seed'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'needs', label: 'Needs assignment' },
|
||||
{ key: 'assigned', label: 'Assigned' },
|
||||
{ key: 'none', label: 'No suggestions' },
|
||||
{ key: 'all', label: 'All' },
|
||||
]
|
||||
|
||||
const TAB_FILTERS = {
|
||||
needs: { assigned: false },
|
||||
assigned: { assigned: true },
|
||||
none: {},
|
||||
all: {},
|
||||
}
|
||||
|
||||
const RESUME_STATUS = {
|
||||
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
|
||||
failed: 'Failed', dlq: 'Failed', skipped: 'Pending',
|
||||
}
|
||||
|
||||
function parseDate(value) {
|
||||
if (!value) return null
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
function sourceFrom(messageTo) {
|
||||
const raw = (messageTo || '').trim()
|
||||
if (!raw) return { source: 'Unknown', sourceMeta: null }
|
||||
const flat = raw.toLowerCase().replace(/[^a-z]/g, '')
|
||||
const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, '')))
|
||||
if (hit) return { source: hit, sourceMeta: sourceMeta[hit] }
|
||||
return { source: raw.split(',')[0].trim(), sourceMeta: null }
|
||||
}
|
||||
|
||||
function SourceChip({ item }) {
|
||||
return (
|
||||
<span className="source-chip" style={{ '--chip': item.sourceMeta?.color }}>
|
||||
<span className="source-dot" />
|
||||
{item.source}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function htmlToText(value) {
|
||||
const raw = (value || '').trim()
|
||||
if (!raw) return ''
|
||||
if (!/<[a-z!/]/i.test(raw)) return raw
|
||||
const withBreaks = raw
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|li|tr|h[1-6]|blockquote|table)\s*>/gi, '\n')
|
||||
const doc = new DOMParser().parseFromString(withBreaks, 'text/html')
|
||||
doc.querySelectorAll('script, style, head').forEach((n) => n.remove())
|
||||
return (doc.body?.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
/** Requirement chip lights green when the resume text contains it (client-side). */
|
||||
function reqInResume(req, resumeText) {
|
||||
if (!req || !resumeText) return false
|
||||
const needle = String(req).trim().toLowerCase()
|
||||
if (!needle) return false
|
||||
return resumeText.toLowerCase().includes(needle)
|
||||
}
|
||||
|
||||
function mapApplication(row) {
|
||||
const name = row.name || row.email || 'Unknown'
|
||||
const suggested = Array.isArray(row.suggested_job_post_ids) ? row.suggested_job_post_ids : []
|
||||
return {
|
||||
id: String(row.id),
|
||||
name,
|
||||
initials: initialsOf(name),
|
||||
color: avatarColor(name),
|
||||
email: row.email || '',
|
||||
position: row.position || '(no subject)',
|
||||
...sourceFrom(row.source),
|
||||
received: parseDate(row.received),
|
||||
unread: Boolean(row.unread),
|
||||
processing: row.processing || 'Unread',
|
||||
resumeStatus: row.resume_status || RESUME_STATUS[row.match_status] || 'Pending',
|
||||
resumeText: row.resume_text || '',
|
||||
suggestedIds: suggested.map(String),
|
||||
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
|
||||
matchStatus: row.match_status || null,
|
||||
matchSummary: row.match_summary || '',
|
||||
matchReasoning: row.match_reasoning || '',
|
||||
matchError: row.match_error || '',
|
||||
matchedAt: parseDate(row.matched_at),
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApplications(params) {
|
||||
const res = await inboxApi.listApplications(params)
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return { rows: rows.map(mapApplication), total: res?.total ?? rows.length }
|
||||
}
|
||||
|
||||
async function fetchDetail(recordId) {
|
||||
const res = await inboxApi.getMessage(recordId)
|
||||
const row = res?.data
|
||||
if (!row) return null
|
||||
const name = row.sender_name || row.fromEmail || 'Unknown'
|
||||
return {
|
||||
id: String(row.id),
|
||||
name,
|
||||
initials: initialsOf(name),
|
||||
color: avatarColor(name),
|
||||
email: row.fromEmail || '',
|
||||
position: row.subject || '(no subject)',
|
||||
...sourceFrom(row.message_to),
|
||||
body: htmlToText(row.body),
|
||||
resumeText: row.resume_text || '',
|
||||
resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending',
|
||||
processing: row.unread ? 'Unread' : 'Read',
|
||||
suggestedIds: (row.suggested_job_post_ids || []).map(String),
|
||||
suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [],
|
||||
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
|
||||
assignedPost: row.assigned_job_post || null,
|
||||
matchStatus: row.match_status || null,
|
||||
matchSummary: row.match_summary || '',
|
||||
matchReasoning: row.match_reasoning || '',
|
||||
matchError: row.match_error || '',
|
||||
matchedAt: parseDate(row.matched_at),
|
||||
}
|
||||
}
|
||||
|
||||
function AssignmentBadge({ item, titleById }) {
|
||||
if (item.matchStatus === 'processing') {
|
||||
return <Badge className="b-gray">Matching…</Badge>
|
||||
}
|
||||
if (item.assignedId) {
|
||||
const title = titleById.get(item.assignedId) || 'Assigned'
|
||||
return <Badge className="b-green">{title}</Badge>
|
||||
}
|
||||
const n = item.suggestedIds.length
|
||||
if (n > 0) return <Badge className="b-blue">{n} suggested</Badge>
|
||||
return <Badge className="b-amber">No match</Badge>
|
||||
}
|
||||
|
||||
function JobCard({ post, rank, selected, onSelect, resumeText, manual }) {
|
||||
const unavailable = Boolean(post?.unavailable) || !post?.title
|
||||
const title = post?.title || 'Unavailable'
|
||||
const meta = [
|
||||
post?.employment_type,
|
||||
post?.location,
|
||||
post?.experience_min != null || post?.experience_max != null
|
||||
? `${post?.experience_min ?? '?'}–${post?.experience_max ?? '?'} yrs`
|
||||
: null,
|
||||
].filter(Boolean).join(' · ')
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
tabIndex={0}
|
||||
className="list-row"
|
||||
onClick={() => !unavailable && onSelect(post.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (unavailable) return
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onSelect(post.id)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: unavailable ? 'not-allowed' : 'pointer',
|
||||
opacity: unavailable ? 0.55 : 1,
|
||||
borderColor: selected ? 'var(--primary)' : undefined,
|
||||
boxShadow: selected ? 'var(--ring)' : undefined,
|
||||
marginBottom: 8,
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className="lr-main" style={{ minWidth: 0 }}>
|
||||
<div className="flex items-center gap-8" style={{ marginBottom: 4, flexWrap: 'wrap' }}>
|
||||
<span className="tag">{manual ? 'Manual' : `AI #${rank}`}</span>
|
||||
<div className="lr-title">{title}</div>
|
||||
{unavailable ? (
|
||||
<Badge className="b-gray">Unavailable</Badge>
|
||||
) : (
|
||||
<Badge>{post.status || 'draft'}</Badge>
|
||||
)}
|
||||
{selected && <Icon name="check-circle" />}
|
||||
</div>
|
||||
{meta && <div className="cell-sub">{meta}</div>}
|
||||
{!unavailable && (post.requirements || []).length > 0 && (
|
||||
<div className="k-tags" style={{ marginTop: 8 }}>
|
||||
{(post.requirements || []).slice(0, 8).map((req) => {
|
||||
const hit = reqInResume(req, resumeText)
|
||||
return (
|
||||
<span
|
||||
key={req}
|
||||
className="tag"
|
||||
style={hit ? {
|
||||
background: 'var(--success-soft)',
|
||||
color: 'var(--success-fg)',
|
||||
} : undefined}
|
||||
>
|
||||
{req}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PickRoleModal({ onClose, onPick }) {
|
||||
const [q, setQ] = useState('')
|
||||
const { data = [], isPending, isError, error } = useQuery({
|
||||
queryKey: qk.jobPosts.list({ search: q || undefined, top: 30 }),
|
||||
queryFn: async () => {
|
||||
const res = await jobPostsApi.list({ search: q || undefined, top: 30 })
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Choose a different role"
|
||||
subtitle="Search open job posts"
|
||||
size="modal-lg"
|
||||
onClose={onClose}
|
||||
footer={<button className="btn btn-secondary" onClick={onClose}>Cancel</button>}
|
||||
>
|
||||
<div className="toolbar-search" style={{ maxWidth: 'none', marginBottom: 14 }}>
|
||||
<Icon name="search" />
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search title or location…" autoFocus />
|
||||
</div>
|
||||
{isPending && <EmptyState icon="briefcase" title="Loading…">Fetching job posts.</EmptyState>}
|
||||
{isError && (
|
||||
<EmptyState icon="alert" title="Couldn’t load roles">
|
||||
{friendlyAuthError(error, 'Request failed')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{!isPending && !isError && data.length === 0 && (
|
||||
<EmptyState icon="briefcase" title="No roles found">Try a different search.</EmptyState>
|
||||
)}
|
||||
<div className="list-tight">
|
||||
{data.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="list-row"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => { onPick(p); onClose() }}
|
||||
>
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{p.title}</div>
|
||||
<div className="lr-sub">
|
||||
{[p.employment_type, p.location].filter(Boolean).join(' · ') || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<Badge>{p.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Matching() {
|
||||
const { toast } = useToast()
|
||||
const { can } = useAuth()
|
||||
const canEdit = can('inbox.edit')
|
||||
const qc = useQueryClient()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const deepLink = searchParams.get('record')
|
||||
|
||||
const [tab, setTab] = useState('needs')
|
||||
const [selectedId, setSelectedId] = useState(deepLink || null)
|
||||
const [q, setQ] = useState('')
|
||||
const [selection, setSelection] = useState(null)
|
||||
const [manualPost, setManualPost] = useState(null)
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [whyOpen, setWhyOpen] = useState(false)
|
||||
|
||||
const tabFilter = TAB_FILTERS[tab] ?? {}
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: qk.mailbox.assignments({ ...tabFilter, tab }),
|
||||
queryFn: () => fetchApplications(tabFilter),
|
||||
})
|
||||
|
||||
const needsCount = useQuery({
|
||||
queryKey: qk.mailbox.assignments({ assigned: false }),
|
||||
queryFn: async () => (await inboxApi.listApplications({ assigned: false, top: 1 }))?.total ?? 0,
|
||||
})
|
||||
const assignedCount = useQuery({
|
||||
queryKey: qk.mailbox.assignments({ assigned: true }),
|
||||
queryFn: async () => (await inboxApi.listApplications({ assigned: true, top: 1 }))?.total ?? 0,
|
||||
})
|
||||
const allCount = useQuery({
|
||||
queryKey: qk.mailbox.assignments({}),
|
||||
queryFn: async () => (await inboxApi.listApplications({ top: 1 }))?.total ?? 0,
|
||||
})
|
||||
const noneCountQuery = useQuery({
|
||||
queryKey: qk.mailbox.assignments({ kind: 'none' }),
|
||||
queryFn: async () => {
|
||||
const res = await fetchApplications({})
|
||||
return res.rows.filter((r) => !r.assignedId && r.suggestedIds.length === 0).length
|
||||
},
|
||||
})
|
||||
|
||||
const rows = listQuery.data?.rows ?? []
|
||||
const filtered = useMemo(() => {
|
||||
let list = rows
|
||||
if (tab === 'none') {
|
||||
list = list.filter((r) => !r.assignedId && r.suggestedIds.length === 0)
|
||||
}
|
||||
const needle = q.trim().toLowerCase()
|
||||
if (!needle) return list
|
||||
return list.filter((r) => (
|
||||
r.name.toLowerCase().includes(needle)
|
||||
|| r.position.toLowerCase().includes(needle)
|
||||
|| r.email.toLowerCase().includes(needle)
|
||||
))
|
||||
}, [rows, tab, q])
|
||||
|
||||
const noneCount = noneCountQuery.data ?? 0
|
||||
|
||||
// Preselect deep link once, then clear the query so refresh doesn't re-pin.
|
||||
useEffect(() => {
|
||||
if (!deepLink) return undefined
|
||||
setSelectedId(deepLink)
|
||||
setSearchParams({}, { replace: true })
|
||||
return undefined
|
||||
}, [deepLink, setSearchParams])
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: qk.mailbox.message(selectedId),
|
||||
queryFn: () => fetchDetail(selectedId),
|
||||
enabled: Boolean(selectedId),
|
||||
})
|
||||
|
||||
const detail = detailQuery.data
|
||||
const listRow = filtered.find((r) => r.id === selectedId) || rows.find((r) => r.id === selectedId)
|
||||
|
||||
// Hydrate titles for list badges (assigned + suggestions) in one call.
|
||||
const hydrateIds = useMemo(() => {
|
||||
const ids = new Set()
|
||||
for (const r of rows) {
|
||||
if (r.assignedId) ids.add(r.assignedId)
|
||||
for (const id of r.suggestedIds) ids.add(id)
|
||||
}
|
||||
return [...ids]
|
||||
}, [rows])
|
||||
|
||||
const titlesQuery = useQuery({
|
||||
queryKey: qk.jobPosts.list({ ids: hydrateIds }),
|
||||
queryFn: async () => {
|
||||
if (!hydrateIds.length) return []
|
||||
const res = await jobPostsApi.list({ ids: hydrateIds, activeOnly: false })
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
enabled: hydrateIds.length > 0,
|
||||
})
|
||||
|
||||
const titleById = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const p of titlesQuery.data || []) map.set(String(p.id), p.title)
|
||||
return map
|
||||
}, [titlesQuery.data])
|
||||
|
||||
// Reset local selection when the selected application changes.
|
||||
useEffect(() => {
|
||||
setManualPost(null)
|
||||
setWhyOpen(false)
|
||||
if (detail?.assignedId) setSelection(detail.assignedId)
|
||||
else if (detail?.suggestedIds?.[0]) setSelection(detail.suggestedIds[0])
|
||||
else setSelection(null)
|
||||
}, [detail?.id, detail?.assignedId, detail?.suggestedIds])
|
||||
|
||||
const suggestionCards = useMemo(() => {
|
||||
const fromDetail = detail?.suggestedPosts || []
|
||||
const byId = new Map(fromDetail.map((p) => [String(p.id), p]))
|
||||
const ids = detail?.suggestedIds || listRow?.suggestedIds || []
|
||||
return ids.map((id, i) => ({
|
||||
rank: i + 1,
|
||||
post: byId.get(id) || { id, unavailable: true },
|
||||
}))
|
||||
}, [detail, listRow])
|
||||
|
||||
const selectedPost = useMemo(() => {
|
||||
if (!selection) return null
|
||||
if (manualPost && String(manualPost.id) === String(selection)) return manualPost
|
||||
if (detail?.assignedPost && String(detail.assignedPost.id) === String(selection)) {
|
||||
return detail.assignedPost
|
||||
}
|
||||
const hit = suggestionCards.find((c) => String(c.post.id) === String(selection))
|
||||
return hit?.post || null
|
||||
}, [selection, manualPost, detail, suggestionCards])
|
||||
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: ({ recordId, jobPostId }) => inboxApi.assignJobPost(recordId, jobPostId),
|
||||
onMutate: async ({ recordId, jobPostId }) => {
|
||||
await qc.cancelQueries({ queryKey: qk.mailbox.all() })
|
||||
await qc.cancelQueries({ queryKey: ['mailbox', 'assignments'] })
|
||||
return { recordId, jobPostId }
|
||||
},
|
||||
onError: (err) => {
|
||||
toast(friendlyAuthError(err, 'Could not assign job post.'), 'error')
|
||||
},
|
||||
onSuccess: (_res, vars) => {
|
||||
const name = listRow?.name || detail?.name || 'Candidate'
|
||||
const title = selectedPost?.title || titleById.get(vars.jobPostId) || 'role'
|
||||
if (vars.jobPostId) toast(`${name} → ${title}`, 'success')
|
||||
else toast(`${name} unassigned`, 'success')
|
||||
},
|
||||
onSettled: async (_res, _err, vars) => {
|
||||
await qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
||||
await qc.invalidateQueries({ queryKey: ['mailbox', 'assignments'] })
|
||||
await qc.invalidateQueries({ queryKey: qk.mailbox.message(vars.recordId) })
|
||||
await qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||||
|
||||
// Auto-advance only on the Needs assignment tab after a real assign.
|
||||
if (tab === 'needs' && vars.jobPostId) {
|
||||
const idx = filtered.findIndex((r) => r.id === vars.recordId)
|
||||
const next = filtered[idx + 1] || filtered[idx - 1] || null
|
||||
setSelectedId(next?.id || null)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const rematchMutation = useMutation({
|
||||
mutationFn: (recordId) => inboxApi.rematch(recordId),
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not retry match.'), 'error'),
|
||||
onSuccess: () => toast('Match re-queued', 'success'),
|
||||
onSettled: (_r, _e, recordId) => {
|
||||
qc.invalidateQueries({ queryKey: qk.mailbox.message(recordId) })
|
||||
qc.invalidateQueries({ queryKey: ['mailbox', 'assignments'] })
|
||||
},
|
||||
})
|
||||
|
||||
// Keyboard: j/k move queue, 1–5 pick suggestion, Enter assigns, Esc clears.
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
const tag = e.target?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||
if (e.key === 'j' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
const idx = filtered.findIndex((r) => r.id === selectedId)
|
||||
const next = filtered[Math.min(filtered.length - 1, (idx < 0 ? 0 : idx + 1))]
|
||||
if (next) setSelectedId(next.id)
|
||||
} else if (e.key === 'k' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const idx = filtered.findIndex((r) => r.id === selectedId)
|
||||
const next = filtered[Math.max(0, (idx < 0 ? 0 : idx - 1))]
|
||||
if (next) setSelectedId(next.id)
|
||||
} else if (e.key >= '1' && e.key <= '5') {
|
||||
const card = suggestionCards[Number(e.key) - 1]
|
||||
if (card && !card.post.unavailable) setSelection(String(card.post.id))
|
||||
} else if (e.key === 'Enter' && canEdit && selection && selection !== detail?.assignedId) {
|
||||
e.preventDefault()
|
||||
assignMutation.mutate({ recordId: selectedId, jobPostId: selection })
|
||||
} else if (e.key === 'Escape') {
|
||||
setSelection(detail?.assignedId || null)
|
||||
setManualPost(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [filtered, selectedId, suggestionCards, canEdit, selection, detail, assignMutation])
|
||||
|
||||
const counts = {
|
||||
needs: needsCount.data ?? 0,
|
||||
assigned: assignedCount.data ?? 0,
|
||||
none: noneCount,
|
||||
all: allCount.data ?? 0,
|
||||
}
|
||||
|
||||
const resumeText = detail?.resumeText || listRow?.resumeText || ''
|
||||
const matchFailed = ['failed', 'no_text', 'dlq'].includes(detail?.matchStatus)
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Job Matching</h1>
|
||||
<p className="page-sub">Route applications to the right open role</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canEdit && (
|
||||
<div className="alert alert-danger" style={{ marginBottom: 14 }}>
|
||||
Your account does not hold <code>inbox.edit</code>, which the server requires to
|
||||
assign, unassign, or retry a match. Controls below stay disabled.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div style={{ padding: '0 8px', borderBottom: '1px solid var(--border)' }}>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(t) => { setTab(t); setSelectedId(null) }}
|
||||
tabs={TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
count: counts[t.key],
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="split">
|
||||
<div className="split-list">
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div className="toolbar-search" style={{ maxWidth: 'none' }}>
|
||||
<Icon name="search" />
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search…" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{listQuery.isPending && (
|
||||
<EmptyState icon="target" title="Loading…">Fetching applications.</EmptyState>
|
||||
)}
|
||||
{listQuery.isError && (
|
||||
<EmptyState icon="alert" title="Couldn’t load queue">
|
||||
{friendlyAuthError(listQuery.error, 'Request failed')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{listQuery.isSuccess && filtered.length === 0 && (
|
||||
<EmptyState icon="check-circle" title="Queue clear">
|
||||
{tab === 'needs'
|
||||
? 'Every application in this view has a role.'
|
||||
: 'Nothing matches this filter.'}
|
||||
</EmptyState>
|
||||
)}
|
||||
{filtered.map((i) => (
|
||||
<div
|
||||
key={i.id}
|
||||
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
|
||||
onClick={() => setSelectedId(i.id)}
|
||||
>
|
||||
<Avatar name={i.name} initials={i.initials} color={i.color} />
|
||||
<div className="ii-main">
|
||||
<div className="ii-name">{i.name}</div>
|
||||
<div className="ii-pos">{i.position}</div>
|
||||
<div className="ii-meta">
|
||||
<SourceChip item={i} />
|
||||
<AssignmentBadge item={i} titleById={titleById} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="split-detail">
|
||||
{!selectedId ? (
|
||||
<div style={{ padding: '100px 20px' }}>
|
||||
<EmptyState icon="target" title="Select an application">
|
||||
Choose an item from the list to review suggestions and assign a role.
|
||||
</EmptyState>
|
||||
</div>
|
||||
) : detailQuery.isError ? (
|
||||
<div style={{ padding: '100px 20px' }}>
|
||||
<EmptyState icon="alert" title="Couldn’t load this application">
|
||||
{friendlyAuthError(detailQuery.error, 'Request failed')}
|
||||
</EmptyState>
|
||||
</div>
|
||||
) : (
|
||||
<MatchingWorkspace
|
||||
listRow={listRow}
|
||||
detail={detail}
|
||||
loading={detailQuery.isPending}
|
||||
canEdit={canEdit}
|
||||
selection={selection}
|
||||
setSelection={setSelection}
|
||||
manualPost={manualPost}
|
||||
suggestionCards={suggestionCards}
|
||||
selectedPost={selectedPost}
|
||||
resumeText={resumeText}
|
||||
whyOpen={whyOpen}
|
||||
setWhyOpen={setWhyOpen}
|
||||
matchFailed={matchFailed}
|
||||
onPickManual={() => setShowPicker(true)}
|
||||
onSkip={() => {
|
||||
const idx = filtered.findIndex((r) => r.id === selectedId)
|
||||
const next = filtered[idx + 1]
|
||||
if (next) setSelectedId(next.id)
|
||||
}}
|
||||
onAssign={() => {
|
||||
if (!selection || !canEdit) return
|
||||
assignMutation.mutate({ recordId: selectedId, jobPostId: selection })
|
||||
}}
|
||||
onUnassign={() => {
|
||||
if (!canEdit) return
|
||||
assignMutation.mutate({ recordId: selectedId, jobPostId: null })
|
||||
}}
|
||||
onChange={() => setShowPicker(true)}
|
||||
onRematch={() => rematchMutation.mutate(selectedId)}
|
||||
assigning={assignMutation.isPending}
|
||||
rematching={rematchMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPicker && (
|
||||
<PickRoleModal
|
||||
onClose={() => setShowPicker(false)}
|
||||
onPick={(post) => {
|
||||
setManualPost(post)
|
||||
setSelection(String(post.id))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MatchingWorkspace({
|
||||
listRow,
|
||||
detail,
|
||||
loading,
|
||||
canEdit,
|
||||
selection,
|
||||
setSelection,
|
||||
manualPost,
|
||||
suggestionCards,
|
||||
selectedPost,
|
||||
resumeText,
|
||||
whyOpen,
|
||||
setWhyOpen,
|
||||
matchFailed,
|
||||
onPickManual,
|
||||
onSkip,
|
||||
onAssign,
|
||||
onUnassign,
|
||||
onChange,
|
||||
onRematch,
|
||||
assigning,
|
||||
rematching,
|
||||
}) {
|
||||
const i = {
|
||||
name: detail?.name || listRow?.name || '…',
|
||||
initials: detail?.initials || listRow?.initials,
|
||||
color: detail?.color || listRow?.color,
|
||||
position: detail?.position || listRow?.position,
|
||||
source: detail?.source || listRow?.source,
|
||||
sourceMeta: detail?.sourceMeta || listRow?.sourceMeta,
|
||||
processing: detail?.processing || listRow?.processing,
|
||||
resumeStatus: detail?.resumeStatus || listRow?.resumeStatus,
|
||||
}
|
||||
|
||||
const assigned = detail?.assignedPost
|
||||
const currentId = detail?.assignedId
|
||||
const canAssign = canEdit && selection && selection !== currentId && !assigning
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
|
||||
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
|
||||
<div className="ph-role">{i.position}</div>
|
||||
<div className="ph-tags" style={{ marginTop: 8 }}>
|
||||
<SourceChip item={i} />{' '}
|
||||
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
|
||||
{i.resumeStatus}
|
||||
</Badge>
|
||||
{loading && <span className="cell-sub">Loading details…</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{assigned && (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
boxShadow: 'none',
|
||||
background: 'var(--primary-soft)',
|
||||
border: '1px solid var(--primary-border)',
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
<div className="card-body flex items-center gap-12" style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<div className="flex items-center gap-8">
|
||||
<Icon name="check-circle" />
|
||||
<div>
|
||||
<div>Assigned to <b>{assigned.title}</b></div>
|
||||
<div className="cell-sub">
|
||||
{[assigned.employment_type, assigned.location].filter(Boolean).join(' · ') || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-8">
|
||||
<button className="btn btn-secondary btn-sm" disabled={!canEdit} title={!canEdit ? 'Requires inbox.edit' : undefined} onClick={onChange}>
|
||||
Change
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" disabled={!canEdit || assigning} title={!canEdit ? 'Requires inbox.edit' : undefined} onClick={onUnassign}>
|
||||
Unassign
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 18,
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
|
||||
{matchFailed ? (
|
||||
<div className="alert alert-danger" style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8 }}>{detail?.matchError || 'Matching failed for this application.'}</div>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={!canEdit || rematching}
|
||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||||
onClick={onRematch}
|
||||
>
|
||||
<Icon name="sparkles" /> Retry match
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="fw-600" style={{ marginBottom: 6 }}>AI verdict</div>
|
||||
<p style={{ marginBottom: 4 }}>
|
||||
{detail?.matchSummary || listRow?.matchSummary || 'No match summary yet.'}
|
||||
</p>
|
||||
{detail?.matchedAt && (
|
||||
<div className="cell-sub">Matched {fmtDate(detail.matchedAt)}</div>
|
||||
)}
|
||||
{(detail?.matchReasoning || listRow?.matchReasoning) && (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ marginTop: 8, paddingLeft: 0 }}
|
||||
onClick={() => setWhyOpen((v) => !v)}
|
||||
>
|
||||
{whyOpen ? '▾' : '▸'} Why these roles?
|
||||
</button>
|
||||
)}
|
||||
{whyOpen && (
|
||||
<p className="text-muted text-sm" style={{ marginTop: 8 }}>
|
||||
{detail?.matchReasoning || listRow?.matchReasoning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fw-600" style={{ marginBottom: 6 }}>Resume text</div>
|
||||
<pre className="resume-thumb" style={{ maxHeight: 220, marginBottom: 16 }}>
|
||||
{resumeText || 'Resume text not extracted yet.'}
|
||||
</pre>
|
||||
|
||||
{(detail?.body) && (
|
||||
<>
|
||||
<div className="fw-600" style={{ marginBottom: 6 }}>Email body</div>
|
||||
<pre className="resume-thumb" style={{ maxHeight: 160 }}>
|
||||
{detail.body}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
|
||||
<div className="fw-600" style={{ marginBottom: 8 }}>Suggested roles</div>
|
||||
{suggestionCards.length === 0 && !manualPost ? (
|
||||
<EmptyState icon="alert" title="No suggested roles">
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap', marginTop: 10 }}>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={!canEdit || rematching}
|
||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||||
onClick={onRematch}
|
||||
>
|
||||
Retry match
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!canEdit}
|
||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||||
onClick={onPickManual}
|
||||
>
|
||||
Choose a role
|
||||
</button>
|
||||
</div>
|
||||
</EmptyState>
|
||||
) : (
|
||||
suggestionCards.map(({ rank, post }) => (
|
||||
<JobCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
rank={rank}
|
||||
selected={String(selection) === String(post.id)}
|
||||
onSelect={(id) => setSelection(id)}
|
||||
resumeText={resumeText}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{manualPost && (
|
||||
<JobCard
|
||||
post={manualPost}
|
||||
rank={0}
|
||||
manual
|
||||
selected={String(selection) === String(manualPost.id)}
|
||||
onSelect={(id) => setSelection(id)}
|
||||
resumeText={resumeText}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ width: '100%', marginTop: 8 }}
|
||||
disabled={!canEdit}
|
||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||||
onClick={onPickManual}
|
||||
>
|
||||
Choose a different role…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex gap-8"
|
||||
style={{
|
||||
marginTop: 20,
|
||||
paddingTop: 16,
|
||||
borderTop: '1px solid var(--border)',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<button className="btn btn-secondary" onClick={onSkip}>Skip</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={!canAssign}
|
||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||||
onClick={onAssign}
|
||||
>
|
||||
{selectedPost?.title
|
||||
? `Assign to ${selectedPost.title}`
|
||||
: 'Assign'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -13,6 +13,13 @@
|
|||
|
||||
Clicking a card opens CandidateProfile in place. It used to deep-link into
|
||||
/candidates, which stopped resolving once the ids became real user_ids.
|
||||
|
||||
The card is seed-overlaid, but the MODAL is not: it re-reads the candidate by
|
||||
`userId` through GET /candidate/fetch?user_id=, which is a far richer payload
|
||||
than the list rows — résumé text, the agent's verdict, documents, and the
|
||||
interviews / notes / activity / feedback collections, all writable from their
|
||||
own tabs. That switch happens inside CandidateProfile; passing `userId` is the
|
||||
whole trigger.
|
||||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
|
|
@ -52,10 +59,10 @@ function years(value) {
|
|||
/**
|
||||
* One API record overlaid on one seed candidate.
|
||||
*
|
||||
* `id` deliberately stays the SEED id: CandidateProfile joins seed interviews on
|
||||
* c.id and the favourite/advance mutations key off it, so a UUID here would
|
||||
* empty the Interview tab and silently drop those writes. The real identifier
|
||||
* rides along on `userId`.
|
||||
* `id` deliberately stays the SEED id: the favourite/advance seed mutations key
|
||||
* off it, so a UUID here would silently drop those writes. The real identifier
|
||||
* rides along on `userId`, and that is what the profile modal reads its live
|
||||
* record with.
|
||||
*/
|
||||
function merge(row, template) {
|
||||
const name = row.name || template.name
|
||||
|
|
@ -125,6 +132,8 @@ export default function TalentPool() {
|
|||
|
||||
// Both mirror Candidates.jsx so a change made here shows up there too. The
|
||||
// card renders neither favourite nor stage, so only the open modal restates.
|
||||
// Favourite is a real PATCH once the modal holds a userId; this seed path is
|
||||
// the fallback for inbox rows that were never linked to a user.
|
||||
function toggleFav(c) {
|
||||
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, favorite: !x.favorite } : x)))
|
||||
setProfileFor((p) => (p && p.id === c.id ? { ...p, favorite: !p.favorite } : p))
|
||||
|
|
|
|||
Loading…
Reference in New Issue